feat(#156): wizard admin création dossier AM (create/review).
Généralise ValidationAmWizard en AmDossierWizard ; le + Asmat ouvre le mode création vers POST /assistantes-maternelles/dossier. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -61,6 +61,9 @@ class ApiConfig {
|
|||||||
static const String gestionnaires = '/gestionnaires';
|
static const String gestionnaires = '/gestionnaires';
|
||||||
static const String parents = '/parents';
|
static const String parents = '/parents';
|
||||||
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.
|
||||||
|
static const String assistantesMaternellesDossier =
|
||||||
|
'/assistantes-maternelles/dossier';
|
||||||
static const String enfants = '/enfants';
|
static const String enfants = '/enfants';
|
||||||
static const String relais = '/relais';
|
static const String relais = '/relais';
|
||||||
static const String dossiers = '/dossiers';
|
static const String dossiers = '/dossiers';
|
||||||
|
|||||||
@@ -645,6 +645,56 @@ class UserService {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Création dossier AM actif côté staff (#156).
|
||||||
|
/// `POST /assistantes-maternelles/dossier` — body aligné sur register AM.
|
||||||
|
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur).
|
||||||
|
/// Ne pas utiliser `POST /auth/register/am` (public / en_attente).
|
||||||
|
static Future<Map<String, dynamic>> createAmDossier(
|
||||||
|
Map<String, dynamic> body,
|
||||||
|
) async {
|
||||||
|
final http.Response response;
|
||||||
|
try {
|
||||||
|
response = await http.post(
|
||||||
|
Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesDossier}',
|
||||||
|
),
|
||||||
|
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 AM',
|
||||||
|
);
|
||||||
|
if (response.statusCode == 409) {
|
||||||
|
throw Exception(message.isNotEmpty
|
||||||
|
? message
|
||||||
|
: 'Conflit : e-mail, NIR ou agrément déjà utilisé.');
|
||||||
|
}
|
||||||
|
if (response.statusCode == 400) {
|
||||||
|
throw Exception(
|
||||||
|
message.isNotEmpty ? message : 'Données invalides (400).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw Exception(message);
|
||||||
|
}
|
||||||
|
|
||||||
// Récupérer la liste des assistantes maternelles
|
// Récupérer la liste des assistantes maternelles
|
||||||
static Future<List<AssistanteMaternelleModel>>
|
static Future<List<AssistanteMaternelleModel>>
|
||||||
getAssistantesMaternelles() async {
|
getAssistantesMaternelles() async {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
|
||||||
|
|
||||||
|
/// Modale de création dossier AM (#156) — même shell que [ValidationDossierModal].
|
||||||
|
class AmDossierCreateModal extends StatefulWidget {
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback? onSuccess;
|
||||||
|
|
||||||
|
const AmDossierCreateModal({
|
||||||
|
super.key,
|
||||||
|
required this.onClose,
|
||||||
|
this.onSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AmDossierCreateModal> createState() => _AmDossierCreateModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AmDossierCreateModalState extends State<AmDossierCreateModal> {
|
||||||
|
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;
|
||||||
|
static const double _bodyHeight = 435;
|
||||||
|
|
||||||
|
@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(
|
||||||
|
'Nouvelle assistante maternelle',
|
||||||
|
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: AmDossierWizard.create(
|
||||||
|
onClose: widget.onClose,
|
||||||
|
onSuccess: _onSuccess,
|
||||||
|
onStepChanged: _onStepChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,981 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:p_tits_pas/models/am_registration_data.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/email_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/widgets/admin/common/admin_am_photo_frame.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||||
|
|
||||||
|
enum AmDossierWizardMode { review, create }
|
||||||
|
|
||||||
|
/// Wizard dossier AM — modes [review] (validation pending) et [create] (staff #156).
|
||||||
|
class AmDossierWizard extends StatefulWidget {
|
||||||
|
final AmDossierWizardMode mode;
|
||||||
|
final DossierAM? dossier;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback onSuccess;
|
||||||
|
final void Function(int step, int total)? onStepChanged;
|
||||||
|
|
||||||
|
const AmDossierWizard._({
|
||||||
|
super.key,
|
||||||
|
required this.mode,
|
||||||
|
this.dossier,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onSuccess,
|
||||||
|
this.onStepChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AmDossierWizard.review({
|
||||||
|
Key? key,
|
||||||
|
required DossierAM dossier,
|
||||||
|
required VoidCallback onClose,
|
||||||
|
required VoidCallback onSuccess,
|
||||||
|
void Function(int step, int total)? onStepChanged,
|
||||||
|
}) {
|
||||||
|
return AmDossierWizard._(
|
||||||
|
key: key,
|
||||||
|
mode: AmDossierWizardMode.review,
|
||||||
|
dossier: dossier,
|
||||||
|
onClose: onClose,
|
||||||
|
onSuccess: onSuccess,
|
||||||
|
onStepChanged: onStepChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AmDossierWizard.create({
|
||||||
|
Key? key,
|
||||||
|
required VoidCallback onClose,
|
||||||
|
required VoidCallback onSuccess,
|
||||||
|
void Function(int step, int total)? onStepChanged,
|
||||||
|
}) {
|
||||||
|
return AmDossierWizard._(
|
||||||
|
key: key,
|
||||||
|
mode: AmDossierWizardMode.create,
|
||||||
|
onClose: onClose,
|
||||||
|
onSuccess: onSuccess,
|
||||||
|
onStepChanged: onStepChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isCreate => mode == AmDossierWizardMode.create;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AmDossierWizard> createState() => _AmDossierWizardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AmDossierWizardState extends State<AmDossierWizard> {
|
||||||
|
int _step = 0;
|
||||||
|
bool _showRefusForm = false;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
static const int _stepCount = 3;
|
||||||
|
static const List<int> _photoProRowLayout = [2, 2, 2, 2];
|
||||||
|
static const double _idPhotoAspectRatio = 35 / 45;
|
||||||
|
static const double _photoProGap = 24;
|
||||||
|
static const double _proColumnMinWidth = 260;
|
||||||
|
static const double _photoColumnMinWidth = 160;
|
||||||
|
|
||||||
|
// --- Create mode controllers / state ---
|
||||||
|
late final TextEditingController _nomCtrl;
|
||||||
|
late final TextEditingController _prenomCtrl;
|
||||||
|
late final TextEditingController _telCtrl;
|
||||||
|
late final TextEditingController _emailCtrl;
|
||||||
|
late final TextEditingController _adresseCtrl;
|
||||||
|
late final TextEditingController _cpCtrl;
|
||||||
|
late final TextEditingController _villeCtrl;
|
||||||
|
late final TextEditingController _nirCtrl;
|
||||||
|
late final TextEditingController _dateNaissanceCtrl;
|
||||||
|
late final TextEditingController _lieuNaissanceVilleCtrl;
|
||||||
|
late final TextEditingController _lieuNaissancePaysCtrl;
|
||||||
|
late final TextEditingController _agrementCtrl;
|
||||||
|
late final TextEditingController _dateAgrementCtrl;
|
||||||
|
late final TextEditingController _capaciteCtrl;
|
||||||
|
late final TextEditingController _placesCtrl;
|
||||||
|
late final TextEditingController _presentationCtrl;
|
||||||
|
|
||||||
|
Uint8List? _photoBytes;
|
||||||
|
String? _photoFilename;
|
||||||
|
|
||||||
|
bool get _isCreate => widget.isCreate;
|
||||||
|
DossierAM get _dossier => widget.dossier!;
|
||||||
|
bool get _isEnAttente =>
|
||||||
|
!_isCreate && _dossier.user.statut == 'en_attente';
|
||||||
|
|
||||||
|
static String _v(String? s) =>
|
||||||
|
(s != null && s.trim().isNotEmpty) ? s.trim() : '–';
|
||||||
|
|
||||||
|
static String _formatNirForDisplay(String? nir) {
|
||||||
|
final v = _v(nir);
|
||||||
|
if (v == '–') return v;
|
||||||
|
final raw = nirToRaw(v).toUpperCase();
|
||||||
|
return raw.length == 15 ? formatNir(raw) : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_nomCtrl = TextEditingController();
|
||||||
|
_prenomCtrl = TextEditingController();
|
||||||
|
_telCtrl = TextEditingController();
|
||||||
|
_emailCtrl = TextEditingController();
|
||||||
|
_adresseCtrl = TextEditingController();
|
||||||
|
_cpCtrl = TextEditingController();
|
||||||
|
_villeCtrl = TextEditingController();
|
||||||
|
_nirCtrl = TextEditingController();
|
||||||
|
_dateNaissanceCtrl = TextEditingController();
|
||||||
|
_lieuNaissanceVilleCtrl = TextEditingController();
|
||||||
|
_lieuNaissancePaysCtrl = TextEditingController(text: 'France');
|
||||||
|
_agrementCtrl = TextEditingController();
|
||||||
|
_dateAgrementCtrl = TextEditingController();
|
||||||
|
_capaciteCtrl = TextEditingController(text: '1');
|
||||||
|
_placesCtrl = TextEditingController(text: '1');
|
||||||
|
_presentationCtrl = TextEditingController();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nomCtrl.dispose();
|
||||||
|
_prenomCtrl.dispose();
|
||||||
|
_telCtrl.dispose();
|
||||||
|
_emailCtrl.dispose();
|
||||||
|
_adresseCtrl.dispose();
|
||||||
|
_cpCtrl.dispose();
|
||||||
|
_villeCtrl.dispose();
|
||||||
|
_nirCtrl.dispose();
|
||||||
|
_dateNaissanceCtrl.dispose();
|
||||||
|
_lieuNaissanceVilleCtrl.dispose();
|
||||||
|
_lieuNaissancePaysCtrl.dispose();
|
||||||
|
_agrementCtrl.dispose();
|
||||||
|
_dateAgrementCtrl.dispose();
|
||||||
|
_capaciteCtrl.dispose();
|
||||||
|
_placesCtrl.dispose();
|
||||||
|
_presentationCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
||||||
|
|
||||||
|
List<AdminDetailField> _photoProFields(DossierAM d) {
|
||||||
|
final u = d.user;
|
||||||
|
return [
|
||||||
|
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Date de naissance',
|
||||||
|
value: formatIsoDateFr(u.dateNaissance),
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Ville de naissance',
|
||||||
|
value: _v(u.lieuNaissanceVille),
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Pays de naissance',
|
||||||
|
value: _v(u.lieuNaissancePays),
|
||||||
|
),
|
||||||
|
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Date d’agrément',
|
||||||
|
value: formatIsoDateFr(d.dateAgrement),
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Capacité max (enfants)',
|
||||||
|
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–',
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Places disponibles',
|
||||||
|
value: d.placesDisponibles != null
|
||||||
|
? d.placesDisponibles.toString()
|
||||||
|
: '–',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
|
||||||
|
|
||||||
|
Widget _buildPhotoSectionReview(AppUser u) {
|
||||||
|
final photoUrl = _fullPhotoUrl(u.photoUrl);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: 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 Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: 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: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: SizedBox(
|
||||||
|
width: pw,
|
||||||
|
height: ph,
|
||||||
|
child: photoUrl.isEmpty
|
||||||
|
? ColoredBox(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.person_off_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Aucune photo fournie',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: AuthNetworkImage(
|
||||||
|
url: photoUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: pw,
|
||||||
|
height: ph,
|
||||||
|
loadingBuilder: (_, child, progress) {
|
||||||
|
if (progress == null) return child;
|
||||||
|
return ColoredBox(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: progress.expectedTotalBytes !=
|
||||||
|
null
|
||||||
|
? progress.cumulativeBytesLoaded /
|
||||||
|
(progress.expectedTotalBytes!)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
errorBuilder: (_, __, ___) => ColoredBox(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.broken_image_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Impossible de charger la photo',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickPhoto() async {
|
||||||
|
try {
|
||||||
|
final picked = await ImagePicker().pickImage(
|
||||||
|
source: ImageSource.gallery,
|
||||||
|
maxWidth: 1200,
|
||||||
|
imageQuality: 85,
|
||||||
|
);
|
||||||
|
if (picked == null) return;
|
||||||
|
final bytes = await picked.readAsBytes();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_photoBytes = bytes;
|
||||||
|
_photoFilename = picked.name;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Impossible de charger la photo'),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearPhoto() => setState(() {
|
||||||
|
_photoBytes = null;
|
||||||
|
_photoFilename = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
String? _validateStep0() {
|
||||||
|
final nom = _nomCtrl.text.trim();
|
||||||
|
final prenom = _prenomCtrl.text.trim();
|
||||||
|
if (nom.length < 2) return 'Le nom doit contenir au moins 2 caractères.';
|
||||||
|
if (prenom.length < 2) {
|
||||||
|
return 'Le prénom doit contenir au moins 2 caractères.';
|
||||||
|
}
|
||||||
|
final phoneErr = validateFrenchNationalPhone(_telCtrl.text);
|
||||||
|
if (phoneErr != null) return phoneErr;
|
||||||
|
final emailErr = validateEmail(_emailCtrl.text);
|
||||||
|
if (emailErr != null) return emailErr;
|
||||||
|
if (_adresseCtrl.text.trim().isEmpty) {
|
||||||
|
return 'L’adresse est requise.';
|
||||||
|
}
|
||||||
|
if (_cpCtrl.text.trim().isEmpty) return 'Le code postal est requis.';
|
||||||
|
if (_villeCtrl.text.trim().isEmpty) return 'La ville est requise.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateStep1() {
|
||||||
|
if (_photoBytes == null || _photoBytes!.isEmpty) {
|
||||||
|
return 'Une photo de profil est requise.';
|
||||||
|
}
|
||||||
|
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
|
||||||
|
if (birthIso == null) {
|
||||||
|
return 'La date de naissance est invalide (jj/mm/aaaa).';
|
||||||
|
}
|
||||||
|
final ville = _lieuNaissanceVilleCtrl.text.trim();
|
||||||
|
final pays = _lieuNaissancePaysCtrl.text.trim();
|
||||||
|
if (ville.length < 2 || pays.length < 2) {
|
||||||
|
return 'La ville et le pays de naissance sont requis (au moins 2 caractères).';
|
||||||
|
}
|
||||||
|
final nirErr = validateNir(_nirCtrl.text);
|
||||||
|
if (nirErr != null) return nirErr;
|
||||||
|
if (_agrementCtrl.text.trim().isEmpty) {
|
||||||
|
return 'Le numéro d’agrément est requis.';
|
||||||
|
}
|
||||||
|
final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text);
|
||||||
|
if (agrementIso == null) {
|
||||||
|
return 'La date d’agrément est invalide (jj/mm/aaaa).';
|
||||||
|
}
|
||||||
|
final capa = int.tryParse(_capaciteCtrl.text.trim());
|
||||||
|
if (capa == null || capa < 1 || capa > kAmCapaciteAccueilMax) {
|
||||||
|
return 'La capacité doit être entre 1 et $kAmCapaciteAccueilMax.';
|
||||||
|
}
|
||||||
|
final places = int.tryParse(_placesCtrl.text.trim());
|
||||||
|
if (places == null || places < 0 || places > capa) {
|
||||||
|
return 'Les places disponibles doivent être entre 0 et la capacité.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateCurrentStep() {
|
||||||
|
if (!_isCreate) return null;
|
||||||
|
switch (_step) {
|
||||||
|
case 0:
|
||||||
|
return _validateStep0();
|
||||||
|
case 1:
|
||||||
|
return _validateStep1();
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goNext() {
|
||||||
|
final err = _validateCurrentStep();
|
||||||
|
if (err != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(err), backgroundColor: Colors.red.shade700),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _step++);
|
||||||
|
_emitStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _imageMimeForBytes(Uint8List bytes) {
|
||||||
|
if (bytes.length >= 8 &&
|
||||||
|
bytes[0] == 0x89 &&
|
||||||
|
bytes[1] == 0x50 &&
|
||||||
|
bytes[2] == 0x4E &&
|
||||||
|
bytes[3] == 0x47) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
if (bytes.length >= 3 &&
|
||||||
|
bytes[0] == 0xFF &&
|
||||||
|
bytes[1] == 0xD8 &&
|
||||||
|
bytes[2] == 0xFF) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
if (bytes.length >= 6 &&
|
||||||
|
bytes[0] == 0x47 &&
|
||||||
|
bytes[1] == 0x49 &&
|
||||||
|
bytes[2] == 0x46) {
|
||||||
|
return 'image/gif';
|
||||||
|
}
|
||||||
|
if (bytes.length >= 12 &&
|
||||||
|
bytes[0] == 0x52 &&
|
||||||
|
bytes[1] == 0x49 &&
|
||||||
|
bytes[2] == 0x46 &&
|
||||||
|
bytes[3] == 0x46) {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _buildCreateBody() {
|
||||||
|
final bytes = _photoBytes!;
|
||||||
|
final mime = _imageMimeForBytes(bytes);
|
||||||
|
final photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}';
|
||||||
|
final fn = (_photoFilename ?? '').trim();
|
||||||
|
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text)!;
|
||||||
|
final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text)!;
|
||||||
|
final capa = int.parse(_capaciteCtrl.text.trim());
|
||||||
|
final places = int.parse(_placesCtrl.text.trim());
|
||||||
|
final presentation = _presentationCtrl.text.trim();
|
||||||
|
|
||||||
|
return <String, dynamic>{
|
||||||
|
'email': normalizeEmailText(_emailCtrl.text),
|
||||||
|
'prenom': _prenomCtrl.text.trim(),
|
||||||
|
'nom': _nomCtrl.text.trim(),
|
||||||
|
'telephone': normalizePhone(_telCtrl.text),
|
||||||
|
'adresse':
|
||||||
|
_adresseCtrl.text.trim().isNotEmpty ? _adresseCtrl.text.trim() : null,
|
||||||
|
'code_postal':
|
||||||
|
_cpCtrl.text.trim().isNotEmpty ? _cpCtrl.text.trim() : null,
|
||||||
|
'ville':
|
||||||
|
_villeCtrl.text.trim().isNotEmpty ? _villeCtrl.text.trim() : null,
|
||||||
|
'photo_base64': photoBase64,
|
||||||
|
'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg',
|
||||||
|
'consentement_photo': true,
|
||||||
|
'date_naissance': birthIso,
|
||||||
|
'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(),
|
||||||
|
'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(),
|
||||||
|
'nir': normalizeNir(_nirCtrl.text),
|
||||||
|
'numero_agrement': _agrementCtrl.text.trim(),
|
||||||
|
'date_agrement': agrementIso,
|
||||||
|
'capacite_accueil': capa,
|
||||||
|
'places_disponibles': places,
|
||||||
|
'biographie': presentation.isNotEmpty ? presentation : null,
|
||||||
|
'acceptation_cgu': true,
|
||||||
|
'acceptation_privacy': true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createAndValidate() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
final err0 = _validateStep0();
|
||||||
|
if (err0 != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(err0), backgroundColor: Colors.red.shade700),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final err1 = _validateStep1();
|
||||||
|
if (err1 != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ok = await showValidationValiderConfirmDialog(
|
||||||
|
context,
|
||||||
|
body:
|
||||||
|
'Créer et activer le dossier de cette assistante maternelle ? Un e-mail de création de mot de passe sera envoyé.',
|
||||||
|
);
|
||||||
|
if (!mounted || !ok) return;
|
||||||
|
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
await UserService.createAmDossier(_buildCreateBody());
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Dossier créé et validé. L’assistante maternelle est active.',
|
||||||
|
),
|
||||||
|
duration: 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_showRefusForm) {
|
||||||
|
return _buildRefusPage();
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Expanded(child: _buildStepContent()),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildNavigation(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStepContent() {
|
||||||
|
switch (_step) {
|
||||||
|
case 0:
|
||||||
|
return _buildStep0();
|
||||||
|
case 1:
|
||||||
|
return _buildStep1();
|
||||||
|
case 2:
|
||||||
|
return _buildStep2();
|
||||||
|
default:
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStep0() {
|
||||||
|
if (_isCreate) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||||
|
child: IdentityBlock.editable(
|
||||||
|
title: 'Identité et coordonnées',
|
||||||
|
nomController: _nomCtrl,
|
||||||
|
prenomController: _prenomCtrl,
|
||||||
|
telephoneController: _telCtrl,
|
||||||
|
emailController: _emailCtrl,
|
||||||
|
adresseController: _adresseCtrl,
|
||||||
|
codePostalController: _cpCtrl,
|
||||||
|
villeController: _villeCtrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final u = _dossier.user;
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||||
|
child: IdentityBlock.readOnlyFromUser(
|
||||||
|
u,
|
||||||
|
title: 'Identité et coordonnées',
|
||||||
|
emptyLabel: '–',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStep1() {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, c) {
|
||||||
|
final maxRowW = c.maxWidth;
|
||||||
|
final maxRowH = c.maxHeight;
|
||||||
|
const photoHeaderH = 0.0;
|
||||||
|
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
|
||||||
|
final idealPhotoW = bodyH * _idPhotoAspectRatio + 16;
|
||||||
|
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
||||||
|
.clamp(0.0, double.infinity);
|
||||||
|
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0);
|
||||||
|
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
||||||
|
photoW = photoW.clamp(0.0, maxRowW - _photoProGap);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: photoW,
|
||||||
|
child: _isCreate
|
||||||
|
? AdminAmPhotoFrame(
|
||||||
|
imageBytes: _photoBytes,
|
||||||
|
onTap: _pickPhoto,
|
||||||
|
onClear: _photoBytes != null ? _clearPhoto : null,
|
||||||
|
emptyLabel: 'Ajouter une photo',
|
||||||
|
)
|
||||||
|
: _buildPhotoSectionReview(_dossier.user),
|
||||||
|
),
|
||||||
|
const SizedBox(width: _photoProGap),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints:
|
||||||
|
BoxConstraints(minWidth: constraints.maxWidth),
|
||||||
|
child: _isCreate
|
||||||
|
? _buildCreateProFields()
|
||||||
|
: ValidationDetailSection(
|
||||||
|
title: 'Dossier professionnel',
|
||||||
|
fields: _photoProFields(_dossier),
|
||||||
|
rowLayout: _photoProRowLayout,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCreateProFields() {
|
||||||
|
return ValidationFormGrid(
|
||||||
|
title: 'Dossier professionnel',
|
||||||
|
rowLayout: _photoProRowLayout,
|
||||||
|
fields: [
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'NIR',
|
||||||
|
field: ValidationEditableField(
|
||||||
|
controller: _nirCtrl,
|
||||||
|
hintText: '15 caractères',
|
||||||
|
inputFormatters: [NirInputFormatter()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Date de naissance',
|
||||||
|
field: ValidationEditableField(
|
||||||
|
controller: _dateNaissanceCtrl,
|
||||||
|
hintText: 'jj / mm / aaaa',
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: const [FrenchDateInputFormatter()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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',
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: const [FrenchDateInputFormatter()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Capacité max (enfants)',
|
||||||
|
field: ValidationEditableField(
|
||||||
|
controller: _capaciteCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Places disponibles',
|
||||||
|
field: ValidationEditableField(
|
||||||
|
controller: _placesCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStep2() {
|
||||||
|
if (_isCreate) {
|
||||||
|
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: TextField(
|
||||||
|
controller: _presentationCtrl,
|
||||||
|
maxLines: null,
|
||||||
|
expands: true,
|
||||||
|
maxLength: 2000,
|
||||||
|
textAlignVertical: TextAlignVertical.top,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Présentation (optionnel, max 2000 caractères)',
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade50,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final presentation =
|
||||||
|
(_dossier.presentation != null && _dossier.presentation!.trim().isNotEmpty)
|
||||||
|
? _dossier.presentation!
|
||||||
|
: '–';
|
||||||
|
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(
|
||||||
|
presentation,
|
||||||
|
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNavigation() {
|
||||||
|
if (_step == 2) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||||
|
const Spacer(),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _step = 1);
|
||||||
|
_emitStep();
|
||||||
|
},
|
||||||
|
child: const Text('Précédent'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (_isCreate) ...[
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: _submitting ? null : _createAndValidate,
|
||||||
|
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
|
||||||
|
),
|
||||||
|
] else if (_isEnAttente) ...[
|
||||||
|
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
|
||||||
|
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: _goNext,
|
||||||
|
child: const Text('Suivant'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onValiderPressed() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
final ok = await showValidationValiderConfirmDialog(
|
||||||
|
context,
|
||||||
|
body:
|
||||||
|
'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.',
|
||||||
|
);
|
||||||
|
if (!mounted || !ok) return;
|
||||||
|
await _valider();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _valider() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
await UserService.validateUser(_dossier.user.id);
|
||||||
|
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());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refuserEnvoyer(String comment) async {
|
||||||
|
if (_submitting) return;
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
await UserService.refuseUser(_dossier.user.id, comment: comment);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.',
|
||||||
|
),
|
||||||
|
duration: 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.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/admin_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/am_dossier_create_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.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/dashboard_admin.dart';
|
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
||||||
@@ -28,6 +29,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
int _gestionnaireRefreshTick = 0;
|
int _gestionnaireRefreshTick = 0;
|
||||||
int _adminRefreshTick = 0;
|
int _adminRefreshTick = 0;
|
||||||
int _enfantRefreshTick = 0;
|
int _enfantRefreshTick = 0;
|
||||||
|
int _amRefreshTick = 0;
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final TextEditingController _amCapacityController = TextEditingController();
|
final TextEditingController _amCapacityController = TextEditingController();
|
||||||
String? _parentStatus;
|
String? _parentStatus;
|
||||||
@@ -272,6 +274,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
return AssistanteMaternelleManagementWidget(
|
return AssistanteMaternelleManagementWidget(
|
||||||
|
key: ValueKey('ams-$_amRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
capacityMin: int.tryParse(_amCapacityController.text),
|
capacityMin: int.tryParse(_amCapacityController.text),
|
||||||
);
|
);
|
||||||
@@ -331,6 +334,24 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (contentIndex == 2) {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AmDossierCreateModal(
|
||||||
|
onClose: () => Navigator.of(dialogContext).pop(),
|
||||||
|
onSuccess: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _amRefreshTick++);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (contentIndex == 3) {
|
if (contentIndex == 3) {
|
||||||
final created = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -374,7 +395,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'La création parent / enfant / AM sera disponible avec le ticket #129.',
|
'La création parent sera disponible avec le ticket #129.',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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/am_dossier_wizard.dart';
|
||||||
import 'package:p_tits_pas/utils/nir_utils.dart';
|
|
||||||
import 'package:p_tits_pas/models/user.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/admin_detail_modal.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 AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
|
/// Wrapper historique (#107) — délègue à [AmDossierWizard.review].
|
||||||
class ValidationAmWizard extends StatefulWidget {
|
class ValidationAmWizard extends StatelessWidget {
|
||||||
final DossierAM dossier;
|
final DossierAM dossier;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
final VoidCallback onSuccess;
|
final VoidCallback onSuccess;
|
||||||
@@ -28,480 +17,13 @@ class ValidationAmWizard extends StatefulWidget {
|
|||||||
this.onStepChanged,
|
this.onStepChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
|
||||||
State<ValidationAmWizard> createState() => _ValidationAmWizardState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
|
||||||
int _step = 0;
|
|
||||||
bool _showRefusForm = false;
|
|
||||||
bool _submitting = false;
|
|
||||||
|
|
||||||
static const int _stepCount = 3;
|
|
||||||
|
|
||||||
bool get _isEnAttente => widget.dossier.user.statut == 'en_attente';
|
|
||||||
|
|
||||||
static String _v(String? s) =>
|
|
||||||
(s != null && s.trim().isNotEmpty) ? s.trim() : '–';
|
|
||||||
|
|
||||||
/// Présentation lisible : `1 12 34 56 789 012 - 34` (15 caractères utiles requis).
|
|
||||||
static String _formatNirForDisplay(String? nir) {
|
|
||||||
final v = _v(nir);
|
|
||||||
if (v == '–') return v;
|
|
||||||
final raw = nirToRaw(v).toUpperCase();
|
|
||||||
return raw.length == 15 ? formatNir(raw) : v;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
|
||||||
}
|
|
||||||
|
|
||||||
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
|
||||||
|
|
||||||
/// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places.
|
|
||||||
List<AdminDetailField> _photoProFields(DossierAM d) {
|
|
||||||
final u = d.user;
|
|
||||||
return [
|
|
||||||
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
|
||||||
AdminDetailField(
|
|
||||||
label: 'Date de naissance',
|
|
||||||
value: formatIsoDateFr(u.dateNaissance),
|
|
||||||
),
|
|
||||||
AdminDetailField(
|
|
||||||
label: 'Ville de naissance',
|
|
||||||
value: _v(u.lieuNaissanceVille),
|
|
||||||
),
|
|
||||||
AdminDetailField(
|
|
||||||
label: 'Pays de naissance',
|
|
||||||
value: _v(u.lieuNaissancePays),
|
|
||||||
),
|
|
||||||
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
|
||||||
AdminDetailField(
|
|
||||||
label: 'Date d’agrément',
|
|
||||||
value: formatIsoDateFr(d.dateAgrement),
|
|
||||||
),
|
|
||||||
AdminDetailField(
|
|
||||||
label: 'Capacité max (enfants)',
|
|
||||||
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–',
|
|
||||||
),
|
|
||||||
AdminDetailField(
|
|
||||||
label: 'Places disponibles',
|
|
||||||
value: d.placesDisponibles != null
|
|
||||||
? d.placesDisponibles.toString()
|
|
||||||
: '–',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
static const List<int> _photoProRowLayout = [2, 2, 2, 2];
|
|
||||||
|
|
||||||
/// Proportion photo d’identité (35×45 mm).
|
|
||||||
static const double _idPhotoAspectRatio = 35 / 45;
|
|
||||||
|
|
||||||
static const double _photoProGap = 24;
|
|
||||||
/// Largeur mini réservée aux champs (évite une colonne photo trop gourmande).
|
|
||||||
static const double _proColumnMinWidth = 260;
|
|
||||||
static const double _photoColumnMinWidth = 160;
|
|
||||||
|
|
||||||
/// URL complète pour la photo : si relatif, préfixe [ApiConfig.baseUrl] (ex. `/api/v1/uploads/...`).
|
|
||||||
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
|
|
||||||
|
|
||||||
Widget _buildPhotoSection(AppUser u) {
|
|
||||||
final photoUrl = _fullPhotoUrl(u.photoUrl);
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, c) {
|
|
||||||
// Cadre clair : une seule épaisseur partout (photo + padding identique haut/bas/gauche/droite).
|
|
||||||
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 Align(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: 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: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
child: SizedBox(
|
|
||||||
width: pw,
|
|
||||||
height: ph,
|
|
||||||
child: photoUrl.isEmpty
|
|
||||||
? ColoredBox(
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.person_off_outlined,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey.shade400),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Aucune photo fournie',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: AuthNetworkImage(
|
|
||||||
url: photoUrl,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
width: pw,
|
|
||||||
height: ph,
|
|
||||||
loadingBuilder: (_, child, progress) {
|
|
||||||
if (progress == null) return child;
|
|
||||||
return ColoredBox(
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
child: Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
value: progress.expectedTotalBytes !=
|
|
||||||
null
|
|
||||||
? progress.cumulativeBytesLoaded /
|
|
||||||
(progress.expectedTotalBytes!)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
errorBuilder: (_, __, ___) => ColoredBox(
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment:
|
|
||||||
MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.broken_image_outlined,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey.shade400),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Impossible de charger la photo',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_showRefusForm) {
|
return AmDossierWizard.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;
|
|
||||||
final u = d.user;
|
|
||||||
switch (_step) {
|
|
||||||
case 0:
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
|
||||||
child: IdentityBlock.readOnlyFromUser(
|
|
||||||
u,
|
|
||||||
title: 'Identité et coordonnées',
|
|
||||||
emptyLabel: '–',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
case 1:
|
|
||||||
// Pas de SingleChildScrollView sur la Row (hauteur non bornée). Défilement à droite.
|
|
||||||
// Largeur photo ≈ ratio × hauteur utile, plafonnée pour laisser au moins [_proColumnMinWidth] aux champs.
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, c) {
|
|
||||||
final maxRowW = c.maxWidth;
|
|
||||||
final maxRowH = c.maxHeight;
|
|
||||||
const photoHeaderH = 0.0;
|
|
||||||
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
|
|
||||||
final idealPhotoW =
|
|
||||||
bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair
|
|
||||||
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
|
||||||
.clamp(0.0, double.infinity);
|
|
||||||
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0);
|
|
||||||
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
|
||||||
photoW = photoW.clamp(0.0, maxRowW - _photoProGap);
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: photoW,
|
|
||||||
child: _buildPhotoSection(u),
|
|
||||||
),
|
|
||||||
const SizedBox(width: _photoProGap),
|
|
||||||
Expanded(
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
minWidth: constraints.maxWidth),
|
|
||||||
child: ValidationDetailSection(
|
|
||||||
title: 'Dossier professionnel',
|
|
||||||
fields: _photoProFields(d),
|
|
||||||
rowLayout: _photoProRowLayout,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
case 2:
|
|
||||||
final presentation =
|
|
||||||
(d.presentation != null && d.presentation!.trim().isNotEmpty)
|
|
||||||
? d.presentation!
|
|
||||||
: '–';
|
|
||||||
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(
|
|
||||||
presentation,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.black87, fontSize: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return const SizedBox();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildNavigation() {
|
|
||||||
if (_step == 2) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
|
||||||
const Spacer(),
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() => _step = 1);
|
|
||||||
_emitStep();
|
|
||||||
},
|
|
||||||
child: const Text('Précédent'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
if (_isEnAttente) ...[
|
|
||||||
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
|
|
||||||
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) return;
|
|
||||||
final ok = await showValidationValiderConfirmDialog(
|
|
||||||
context,
|
|
||||||
body:
|
|
||||||
'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.',
|
|
||||||
);
|
|
||||||
if (!mounted || !ok) return;
|
|
||||||
await _valider();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _valider() async {
|
|
||||||
if (_submitting) return;
|
|
||||||
setState(() => _submitting = true);
|
|
||||||
try {
|
|
||||||
await UserService.validateUser(widget.dossier.user.id);
|
|
||||||
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());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _refuserEnvoyer(String comment) async {
|
|
||||||
if (_submitting) return;
|
|
||||||
setState(() => _submitting = true);
|
|
||||||
try {
|
|
||||||
await UserService.refuseUser(widget.dossier.user.id, comment: comment);
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: const Text(
|
|
||||||
'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.',
|
|
||||||
),
|
|
||||||
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