Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16001f695b | ||
|
|
e65ae4cebe | ||
|
|
1ebf683825 |
@@ -552,8 +552,7 @@ export class AuthService {
|
|||||||
: undefined;
|
: undefined;
|
||||||
enfant.photo_url = urlPhoto || undefined;
|
enfant.photo_url = urlPhoto || undefined;
|
||||||
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
|
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
|
||||||
enfant.consent_photo = !!enfantDto.consent_photo;
|
enfant.consent_photo = false;
|
||||||
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
|
|
||||||
enfant.is_multiple = enfantDto.grossesse_multiple || false;
|
enfant.is_multiple = enfantDto.grossesse_multiple || false;
|
||||||
|
|
||||||
const enfantEnregistre = await manager.save(Children, enfant);
|
const enfantEnregistre = await manager.save(Children, enfant);
|
||||||
@@ -1075,12 +1074,6 @@ export class AuthService {
|
|||||||
if (enfantDto.grossesse_multiple !== undefined) {
|
if (enfantDto.grossesse_multiple !== undefined) {
|
||||||
enfant.is_multiple = enfantDto.grossesse_multiple;
|
enfant.is_multiple = enfantDto.grossesse_multiple;
|
||||||
}
|
}
|
||||||
if (enfantDto.consent_photo !== undefined) {
|
|
||||||
enfant.consent_photo = !!enfantDto.consent_photo;
|
|
||||||
enfant.consent_photo_at = enfant.consent_photo
|
|
||||||
? new Date()
|
|
||||||
: null!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enfantDto.photo_base64 && enfantDto.photo_filename) {
|
if (enfantDto.photo_base64 && enfantDto.photo_filename) {
|
||||||
enfant.photo_url = await this.sauvegarderPhotoDepuisBase64(
|
enfant.photo_url = await this.sauvegarderPhotoDepuisBase64(
|
||||||
|
|||||||
@@ -59,14 +59,5 @@ export class EnfantInscriptionDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
grossesse_multiple?: boolean;
|
grossesse_multiple?: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: true,
|
|
||||||
required: false,
|
|
||||||
description: 'Consentement affichage / stockage photo (colonne enfants.consentement_photo)',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
consent_photo?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,13 +120,7 @@ export class EnfantsService {
|
|||||||
const child = await this.childrenRepository.findOne({ where: { id } });
|
const child = await this.childrenRepository.findOne({ where: { id } });
|
||||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||||
|
|
||||||
const patch: Partial<Children> = { ...dto } as Partial<Children>;
|
await this.childrenRepository.update(id, dto);
|
||||||
if (dto.consent_photo !== undefined) {
|
|
||||||
patch.consent_photo = dto.consent_photo;
|
|
||||||
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.childrenRepository.update(id, patch);
|
|
||||||
return this.findOne(id, currentUser);
|
return this.findOne(id, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,19 +24,15 @@ export class RelaisController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Lister tous les relais' })
|
||||||
summary: 'Lister tous les relais',
|
|
||||||
description:
|
|
||||||
'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.',
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.relaisService.findAll();
|
return this.relaisService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
||||||
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -37,34 +37,6 @@ class EnfantAdminModel {
|
|||||||
return '$fn $ln';
|
return '$fn $ln';
|
||||||
}
|
}
|
||||||
|
|
||||||
EnfantAdminModel copyWith({
|
|
||||||
String? id,
|
|
||||||
String? firstName,
|
|
||||||
String? lastName,
|
|
||||||
String? gender,
|
|
||||||
String? birthDate,
|
|
||||||
String? dueDate,
|
|
||||||
String? status,
|
|
||||||
String? photoUrl,
|
|
||||||
bool? consentPhoto,
|
|
||||||
bool? isMultiple,
|
|
||||||
List<EnfantParentLink>? parentLinks,
|
|
||||||
}) {
|
|
||||||
return EnfantAdminModel(
|
|
||||||
id: id ?? this.id,
|
|
||||||
firstName: firstName ?? this.firstName,
|
|
||||||
lastName: lastName ?? this.lastName,
|
|
||||||
gender: gender ?? this.gender,
|
|
||||||
birthDate: birthDate ?? this.birthDate,
|
|
||||||
dueDate: dueDate ?? this.dueDate,
|
|
||||||
status: status ?? this.status,
|
|
||||||
photoUrl: photoUrl ?? this.photoUrl,
|
|
||||||
consentPhoto: consentPhoto ?? this.consentPhoto,
|
|
||||||
isMultiple: isMultiple ?? this.isMultiple,
|
|
||||||
parentLinks: parentLinks ?? this.parentLinks,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
|
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
|
||||||
final linksRaw = json['parentLinks'] as List?;
|
final linksRaw = json['parentLinks'] as List?;
|
||||||
final links = <EnfantParentLink>[];
|
final links = <EnfantParentLink>[];
|
||||||
@@ -77,7 +49,10 @@ class EnfantAdminModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final photoUrl = json['photo_url'] as String? ?? json['photoUrl'] as String?;
|
final photoUrl = json['photo_url'] as String? ?? json['photoUrl'] as String?;
|
||||||
final consentPhoto = _parseBool(json['consent_photo']) ||
|
final hasPhoto = (photoUrl ?? '').trim().isNotEmpty;
|
||||||
|
// L'inscription parent exigeait le consentement pour envoyer la photo, mais
|
||||||
|
// le back a longtemps forcé consent_photo=false (voir reprise_mapper).
|
||||||
|
final consentFromApi = _parseBool(json['consent_photo']) ||
|
||||||
_parseBool(json['consentement_photo']) ||
|
_parseBool(json['consentement_photo']) ||
|
||||||
_parseBool(json['consentPhoto']);
|
_parseBool(json['consentPhoto']);
|
||||||
|
|
||||||
@@ -92,7 +67,7 @@ class EnfantAdminModel {
|
|||||||
(json['status'] ?? json['statut'])?.toString(),
|
(json['status'] ?? json['statut'])?.toString(),
|
||||||
),
|
),
|
||||||
photoUrl: photoUrl,
|
photoUrl: photoUrl,
|
||||||
consentPhoto: consentPhoto,
|
consentPhoto: consentFromApi || hasPhoto,
|
||||||
isMultiple: _parseBool(json['is_multiple']) ||
|
isMultiple: _parseBool(json['is_multiple']) ||
|
||||||
_parseBool(json['est_multiple']),
|
_parseBool(json['est_multiple']),
|
||||||
parentLinks: links,
|
parentLinks: links,
|
||||||
@@ -134,13 +109,6 @@ class EnfantParentLink {
|
|||||||
|
|
||||||
EnfantParentLink({required this.parentId, this.parentName});
|
EnfantParentLink({required this.parentId, this.parentName});
|
||||||
|
|
||||||
EnfantParentLink copyWith({String? parentId, String? parentName}) {
|
|
||||||
return EnfantParentLink(
|
|
||||||
parentId: parentId ?? this.parentId,
|
|
||||||
parentName: parentName ?? this.parentName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
|
factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
|
||||||
final parentId =
|
final parentId =
|
||||||
(json['parentId'] ?? json['id_parent'] ?? '').toString();
|
(json['parentId'] ?? json['id_parent'] ?? '').toString();
|
||||||
@@ -151,12 +119,6 @@ class EnfantParentLink {
|
|||||||
if (user is Map<String, dynamic>) {
|
if (user is Map<String, dynamic>) {
|
||||||
final u = AppUser.fromJson(user);
|
final u = AppUser.fromJson(user);
|
||||||
name = u.fullName.isNotEmpty ? u.fullName : u.email;
|
name = u.fullName.isNotEmpty ? u.fullName : u.email;
|
||||||
} else {
|
|
||||||
// Parfois le parent est aplati (prenom/nom) sans nested user.
|
|
||||||
final prenom = (parent['prenom'] ?? parent['first_name'] ?? '').toString().trim();
|
|
||||||
final nom = (parent['nom'] ?? parent['last_name'] ?? '').toString().trim();
|
|
||||||
final flat = '$prenom $nom'.trim();
|
|
||||||
if (flat.isNotEmpty) name = flat;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return EnfantParentLink(
|
return EnfantParentLink(
|
||||||
|
|||||||
@@ -146,21 +146,6 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
|
|
||||||
List<RelaisModel> _fallbackRelaisFromUser() {
|
|
||||||
final id = _selectedRelaisId?.trim();
|
|
||||||
if (id == null || id.isEmpty) return const [];
|
|
||||||
final nom = (widget.initialUser?.relaisNom ?? '').trim();
|
|
||||||
return [
|
|
||||||
RelaisModel(
|
|
||||||
id: id,
|
|
||||||
nom: nom.isNotEmpty ? nom : 'Relais actuel',
|
|
||||||
adresse: '',
|
|
||||||
actif: true,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadRelais() async {
|
Future<void> _loadRelais() async {
|
||||||
try {
|
try {
|
||||||
final list = await RelaisService.getRelais();
|
final list = await RelaisService.getRelais();
|
||||||
@@ -177,8 +162,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
if (selected != null) {
|
if (selected != null) {
|
||||||
filtered.add(selected);
|
filtered.add(selected);
|
||||||
} else {
|
} else {
|
||||||
// Garder l'id sélectionné et afficher un item de secours (nom carte).
|
_selectedRelaisId = null;
|
||||||
filtered.addAll(_fallbackRelaisFromUser());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,9 +172,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_relais = _fallbackRelaisFromUser();
|
_selectedRelaisId = null;
|
||||||
|
_relais = [];
|
||||||
_isLoadingRelais = false;
|
_isLoadingRelais = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import 'package:p_tits_pas/models/pending_family.dart';
|
|||||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
import 'package:p_tits_pas/services/api/tokenService.dart';
|
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||||
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
|
||||||
|
|
||||||
class DocumentActifInfo {
|
class DocumentActifInfo {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -461,40 +460,7 @@ class UserService {
|
|||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
|
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
|
||||||
}
|
}
|
||||||
final enfant = EnfantAdminModel.fromJson(
|
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
||||||
jsonDecode(response.body) as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
// GET /enfants/:id ne joint pas toujours parent.user → noms manquants.
|
|
||||||
return enrichEnfantParentNames(enfant);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Complète [parentName] via GET parent quand l'API n'a pas fourni le nested user.
|
|
||||||
static Future<EnfantAdminModel> enrichEnfantParentNames(
|
|
||||||
EnfantAdminModel enfant,
|
|
||||||
) async {
|
|
||||||
final links = enfant.parentLinks;
|
|
||||||
if (links.isEmpty) return enfant;
|
|
||||||
if (links.every((l) => (l.parentName ?? '').trim().isNotEmpty)) {
|
|
||||||
return enfant;
|
|
||||||
}
|
|
||||||
|
|
||||||
final enriched = await Future.wait(
|
|
||||||
links.map((link) async {
|
|
||||||
if ((link.parentName ?? '').trim().isNotEmpty) return link;
|
|
||||||
final id = link.parentId.trim();
|
|
||||||
if (id.isEmpty) return link;
|
|
||||||
try {
|
|
||||||
final parent = await getParent(id);
|
|
||||||
final name = parent.user.fullName.trim();
|
|
||||||
if (name.isEmpty) return link;
|
|
||||||
return link.copyWith(parentName: name);
|
|
||||||
} catch (_) {
|
|
||||||
return link;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return enfant.copyWith(parentLinks: enriched);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<EnfantAdminModel> updateEnfant({
|
static Future<EnfantAdminModel> updateEnfant({
|
||||||
@@ -509,10 +475,7 @@ class UserService {
|
|||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
|
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
|
||||||
}
|
}
|
||||||
final enfant = EnfantAdminModel.fromJson(
|
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
||||||
jsonDecode(response.body) as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
return enrichEnfantParentNames(enfant);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> deleteEnfant(String enfantId) async {
|
static Future<void> deleteEnfant(String enfantId) async {
|
||||||
@@ -689,24 +652,7 @@ class UserService {
|
|||||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
|
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
|
||||||
}
|
}
|
||||||
final am = _amModelFromBody(response.body);
|
return _amModelFromBody(response.body);
|
||||||
// Recalcule toujours places_available (capacité − enfants) après attachement.
|
|
||||||
return syncAmPlacesAvailable(am);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Aligne `places_available` sur capacité − enfants rattachés.
|
|
||||||
static Future<AssistanteMaternelleModel> syncAmPlacesAvailable(
|
|
||||||
AssistanteMaternelleModel am,
|
|
||||||
) async {
|
|
||||||
final expected = amExpectedPlacesAvailable(
|
|
||||||
maxChildren: am.maxChildren,
|
|
||||||
childrenCount: am.children.length,
|
|
||||||
);
|
|
||||||
if (expected == null || am.placesAvailable == expected) return am;
|
|
||||||
return updateAmFiche(
|
|
||||||
amUserId: am.user.id,
|
|
||||||
body: {'places_available': expected},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<AssistanteMaternelleModel> detachEnfantFromAm({
|
static Future<AssistanteMaternelleModel> detachEnfantFromAm({
|
||||||
|
|||||||
@@ -9,18 +9,6 @@ int? amExpectedPlacesAvailable({
|
|||||||
return (maxChildren - childrenCount).clamp(0, maxChildren);
|
return (maxChildren - childrenCount).clamp(0, maxChildren);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True s'il reste au moins une place d'accueil (capacité − enfants).
|
|
||||||
bool amHasFreePlace(AssistanteMaternelleModel am) {
|
|
||||||
final expected = amExpectedPlacesAvailable(
|
|
||||||
maxChildren: am.maxChildren,
|
|
||||||
childrenCount: am.children.length,
|
|
||||||
);
|
|
||||||
if (expected != null) return expected > 0;
|
|
||||||
final places = am.placesAvailable;
|
|
||||||
if (places != null) return places > 0;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
|
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
|
||||||
bool amHasPlacesInconsistency({
|
bool amHasPlacesInconsistency({
|
||||||
required int? maxChildren,
|
required int? maxChildren,
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ String normalizeEnfantStatus(String? raw) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
String _accordeAuGenre(String masculin, String feminin, String? gender) {
|
String _scolariseAccordeAuGenre(String? gender) {
|
||||||
final g = (gender ?? '').trim().toUpperCase();
|
final g = (gender ?? '').trim().toUpperCase();
|
||||||
if (g == 'F') return feminin;
|
if (g == 'F') return 'Scolarisée';
|
||||||
return masculin;
|
return 'Scolarisé';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Libellé affiché pour un statut enfant.
|
/// Libellé affiché pour un statut enfant.
|
||||||
@@ -28,9 +28,9 @@ String enfantStatusLabel(String? status, {String? gender}) {
|
|||||||
case 'sans_garde':
|
case 'sans_garde':
|
||||||
return 'Sans garde';
|
return 'Sans garde';
|
||||||
case 'garde':
|
case 'garde':
|
||||||
return _accordeAuGenre('Gardé', 'Gardée', gender);
|
return 'En garde';
|
||||||
case 'scolarise':
|
case 'scolarise':
|
||||||
return _accordeAuGenre('Scolarisé', 'Scolarisée', gender);
|
return _scolariseAccordeAuGenre(gender);
|
||||||
default:
|
default:
|
||||||
return status?.trim().isNotEmpty == true ? status!.trim() : '–';
|
return status?.trim().isNotEmpty == true ? status!.trim() : '–';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,6 @@ class ParentRegistrationPayload {
|
|||||||
final map = <String, dynamic>{
|
final map = <String, dynamic>{
|
||||||
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
||||||
'grossesse_multiple': c.multipleBirth,
|
'grossesse_multiple': c.multipleBirth,
|
||||||
'consent_photo': c.photoConsent,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
final prenom = c.firstName.trim();
|
final prenom = c.firstName.trim();
|
||||||
@@ -199,6 +198,9 @@ class ParentRegistrationPayload {
|
|||||||
map['photo_base64'] = photo.$1;
|
map['photo_base64'] = photo.$1;
|
||||||
map['photo_filename'] = photo.$2;
|
map['photo_filename'] = photo.$2;
|
||||||
}
|
}
|
||||||
|
// Consentement photo enfant (le back l'ignore encore à l'inscription —
|
||||||
|
// à brancher côté API ; en attendant le front admin infère via photo).
|
||||||
|
map['consent_photo'] = c.photoConsent;
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,12 +38,15 @@ class RepriseMapper {
|
|||||||
? isoToDdMmYyyy(e.dueDate)
|
? isoToDdMmYyyy(e.dueDate)
|
||||||
: isoToDdMmYyyy(e.birthDate);
|
: isoToDdMmYyyy(e.birthDate);
|
||||||
final photo = e.photoUrl?.trim();
|
final photo = e.photoUrl?.trim();
|
||||||
|
final hasPhoto = photo != null && photo.isNotEmpty;
|
||||||
return ChildData(
|
return ChildData(
|
||||||
firstName: e.firstName ?? '',
|
firstName: e.firstName ?? '',
|
||||||
lastName: e.lastName ?? '',
|
lastName: e.lastName ?? '',
|
||||||
dob: dob,
|
dob: dob,
|
||||||
genre: e.gender ?? '',
|
genre: e.gender ?? '',
|
||||||
photoConsent: e.consentPhoto,
|
// Inscription initiale exigeait la coche pour envoyer la photo ; le back
|
||||||
|
// ne persistait pas toujours consent_photo — on pré-coche si photo en base.
|
||||||
|
photoConsent: e.consentPhoto || hasPhoto,
|
||||||
multipleBirth: e.estMultiple,
|
multipleBirth: e.estMultiple,
|
||||||
isUnbornChild: isUnborn,
|
isUnbornChild: isUnborn,
|
||||||
cardColor: _childCardColors[index % _childCardColors.length],
|
cardColor: _childCardColors[index % _childCardColors.length],
|
||||||
@@ -197,7 +200,7 @@ class RepriseMapper {
|
|||||||
postalCode: dossier.codePostal ?? '',
|
postalCode: dossier.codePostal ?? '',
|
||||||
city: dossier.ville ?? '',
|
city: dossier.ville ?? '',
|
||||||
existingPhotoUrl: displayPhoto,
|
existingPhotoUrl: displayPhoto,
|
||||||
consentementPhoto: dossier.consentementPhoto,
|
consentementPhoto: dossier.consentementPhoto || hasPhoto,
|
||||||
dateOfBirth: parseIsoDate(dossier.dateNaissance),
|
dateOfBirth: parseIsoDate(dossier.dateNaissance),
|
||||||
birthCity: dossier.lieuNaissanceVille ?? '',
|
birthCity: dossier.lieuNaissanceVille ?? '',
|
||||||
birthCountry: dossier.lieuNaissancePays ?? '',
|
birthCountry: dossier.lieuNaissancePays ?? '',
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
|
|||||||
final int capacity;
|
final int capacity;
|
||||||
final void Function(ParentChildSummary child) onOpen;
|
final void Function(ParentChildSummary child) onOpen;
|
||||||
final void Function(ParentChildSummary child) onDetach;
|
final void Function(ParentChildSummary child) onDetach;
|
||||||
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
|
|
||||||
final VoidCallback? onAttachEmpty;
|
|
||||||
|
|
||||||
const AdminAmChildrenCapacityGrid({
|
const AdminAmChildrenCapacityGrid({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -29,7 +27,6 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
|
|||||||
required this.capacity,
|
required this.capacity,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onDetach,
|
required this.onDetach,
|
||||||
this.onAttachEmpty,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -81,7 +78,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (index < maxSlots) {
|
if (index < maxSlots) {
|
||||||
return _EmptySlot(onTap: onAttachEmpty);
|
return const _EmptySlot();
|
||||||
}
|
}
|
||||||
return const _UnavailableSlot();
|
return const _UnavailableSlot();
|
||||||
}
|
}
|
||||||
@@ -132,52 +129,18 @@ class _UnavailableSlot extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmptySlot extends StatefulWidget {
|
class _EmptySlot extends StatelessWidget {
|
||||||
final VoidCallback? onTap;
|
const _EmptySlot();
|
||||||
|
|
||||||
const _EmptySlot({this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_EmptySlot> createState() => _EmptySlotState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EmptySlotState extends State<_EmptySlot> {
|
|
||||||
bool _hovered = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final clickable = widget.onTap != null;
|
return _SlotShell(
|
||||||
return MouseRegion(
|
backgroundColor: Colors.grey.shade50,
|
||||||
onEnter: clickable ? (_) => setState(() => _hovered = true) : null,
|
borderColor: Colors.grey.shade300,
|
||||||
onExit: clickable ? (_) => setState(() => _hovered = false) : null,
|
|
||||||
cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer,
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: widget.onTap,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
hoverColor: const Color(0x149CC5C0),
|
|
||||||
child: _SlotShell(
|
|
||||||
backgroundColor: _hovered
|
|
||||||
? const Color(0xFFF3F0FA)
|
|
||||||
: Colors.grey.shade50,
|
|
||||||
borderColor: _hovered
|
|
||||||
? const Color(0xFFB8A4D4)
|
|
||||||
: Colors.grey.shade300,
|
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Place libre',
|
'Place libre',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||||
fontSize: 12,
|
|
||||||
color: _hovered
|
|
||||||
? const Color(0xFF6B3FA0)
|
|
||||||
: Colors.grey.shade500,
|
|
||||||
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
||||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||||
@@ -10,7 +11,6 @@ import 'package:p_tits_pas/services/user_service.dart';
|
|||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.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/identity_block.dart';
|
||||||
@@ -55,10 +55,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
late List<ParentChildSummary> _children;
|
late List<ParentChildSummary> _children;
|
||||||
late Set<String> _baselineChildIds;
|
late Set<String> _baselineChildIds;
|
||||||
|
|
||||||
/// Enfants ajoutés localement qui étaient déjà chez une autre AM
|
|
||||||
/// (enfantId → amUserId d'origine). Au save : détacher puis rattacher.
|
|
||||||
final Map<String, String> _transferFromAmIds = {};
|
|
||||||
|
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _dirty = false;
|
bool _dirty = false;
|
||||||
|
|
||||||
@@ -238,13 +234,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
|
|
||||||
int? _capaciteMax() => _parseIntField(_capaciteCtrl);
|
int? _capaciteMax() => _parseIntField(_capaciteCtrl);
|
||||||
|
|
||||||
/// True si plus aucune place d'accueil (enfants ≥ capacité max).
|
|
||||||
bool get _capacityFull {
|
|
||||||
final max = _capaciteMax();
|
|
||||||
if (max == null) return false;
|
|
||||||
return _children.length >= max;
|
|
||||||
}
|
|
||||||
|
|
||||||
int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
|
int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
|
||||||
maxChildren: _capaciteMax(),
|
maxChildren: _capaciteMax(),
|
||||||
childrenCount: _children.length,
|
childrenCount: _children.length,
|
||||||
@@ -322,17 +311,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (final id in currentIds.difference(_baselineChildIds)) {
|
for (final id in currentIds.difference(_baselineChildIds)) {
|
||||||
// Transfert : détacher l'ancienne AM sans recalculer ses places
|
|
||||||
// (le ! de vigilance apparaît côté liste AM).
|
|
||||||
final previousAmId = _transferFromAmIds[id] ??
|
|
||||||
(await UserService.findAmForEnfant(id))?.user.id;
|
|
||||||
if (previousAmId != null &&
|
|
||||||
previousAmId != widget.assistante.user.id) {
|
|
||||||
await UserService.detachEnfantFromAm(
|
|
||||||
amUserId: previousAmId,
|
|
||||||
enfantId: id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await UserService.attachEnfantToAm(
|
await UserService.attachEnfantToAm(
|
||||||
amUserId: widget.assistante.user.id,
|
amUserId: widget.assistante.user.id,
|
||||||
enfantId: id,
|
enfantId: id,
|
||||||
@@ -368,7 +346,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
_dirty = false;
|
_dirty = false;
|
||||||
_saving = false;
|
_saving = false;
|
||||||
_baselineChildIds = currentIds;
|
_baselineChildIds = currentIds;
|
||||||
_transferFromAmIds.clear();
|
|
||||||
});
|
});
|
||||||
widget.onSaved?.call();
|
widget.onSaved?.call();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -400,7 +377,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_children = kids;
|
_children = kids;
|
||||||
_baselineChildIds = kids.map((c) => c.id).toSet();
|
_baselineChildIds = kids.map((c) => c.id).toSet();
|
||||||
_transferFromAmIds.clear();
|
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -466,75 +442,55 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_children = _children.where((c) => c.id != child.id).toList();
|
_children = _children.where((c) => c.id != child.id).toList();
|
||||||
_transferFromAmIds.remove(child.id);
|
|
||||||
_syncPlacesAfterChildrenChange();
|
_syncPlacesAfterChildrenChange();
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _attachChild() async {
|
Future<void> _attachChild() async {
|
||||||
if (_capacityFull || !mounted) return;
|
List<EnfantAdminModel> all;
|
||||||
final selected = await AdminSelectEnfantModal.show(
|
try {
|
||||||
context,
|
all = await UserService.getEnfants();
|
||||||
excludeIds: _children.map((c) => c.id).toSet(),
|
} catch (e) {
|
||||||
title: 'Rattacher un enfant',
|
if (!mounted) return;
|
||||||
showSansGardeFilter: true,
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final linkedIds = _children.map((c) => c.id).toSet();
|
||||||
|
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
|
||||||
|
if (candidates.isEmpty) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final selected = await showDialog<EnfantAdminModel>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SimpleDialog(
|
||||||
|
title: const Text('Rattacher un enfant'),
|
||||||
|
children: candidates
|
||||||
|
.map(
|
||||||
|
(e) => SimpleDialogOption(
|
||||||
|
onPressed: () => Navigator.pop(ctx, e),
|
||||||
|
child: Text(e.fullName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (selected == null || !mounted) return;
|
if (selected == null || !mounted) return;
|
||||||
|
|
||||||
AssistanteMaternelleModel? previousAm;
|
|
||||||
try {
|
|
||||||
previousAm = await UserService.findAmForEnfant(selected.id);
|
|
||||||
} catch (_) {
|
|
||||||
previousAm = null;
|
|
||||||
}
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
final previousAmId = previousAm?.user.id;
|
|
||||||
final isTransfer = previousAmId != null &&
|
|
||||||
previousAmId != widget.assistante.user.id;
|
|
||||||
|
|
||||||
if (isTransfer) {
|
|
||||||
final amName = previousAm!.user.fullName.trim().isNotEmpty
|
|
||||||
? previousAm.user.fullName.trim()
|
|
||||||
: 'une autre assistante maternelle';
|
|
||||||
final gardeLabel =
|
|
||||||
(selected.gender ?? '').trim().toUpperCase() == 'F'
|
|
||||||
? 'déjà gardée'
|
|
||||||
: 'déjà gardé';
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('Changer d\'affectation'),
|
|
||||||
content: Text(
|
|
||||||
'${selected.fullName} est $gardeLabel par $amName.\n\n'
|
|
||||||
'Confirmer le transfert vers cette assistante ? '
|
|
||||||
'L\'enfant sera détaché de $amName.',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
|
||||||
style: ValidationModalTheme.primaryElevatedStyle,
|
|
||||||
child: const Text('Confirmer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (confirmed != true || !mounted) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_children = [
|
_children = [
|
||||||
..._children,
|
..._children,
|
||||||
ParentChildSummary.fromEnfant(selected),
|
ParentChildSummary.fromEnfant(selected),
|
||||||
];
|
];
|
||||||
if (isTransfer) {
|
|
||||||
_transferFromAmIds[selected.id] = previousAmId!;
|
|
||||||
}
|
|
||||||
_syncPlacesAfterChildrenChange();
|
_syncPlacesAfterChildrenChange();
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
});
|
});
|
||||||
@@ -745,7 +701,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
capacity: capacity,
|
capacity: capacity,
|
||||||
onOpen: _openChild,
|
onOpen: _openChild,
|
||||||
onDetach: _detachChild,
|
onDetach: _detachChild,
|
||||||
onAttachEmpty: _capacityFull ? null : _attachChild,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -753,7 +708,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
|
|
||||||
Widget _buildFooter() {
|
Widget _buildFooter() {
|
||||||
final isChildrenTab = _tabCtrl.index == 2;
|
final isChildrenTab = _tabCtrl.index == 2;
|
||||||
final canAttachChild = !_saving && !_capacityFull;
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -762,16 +716,11 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (isChildrenTab)
|
if (isChildrenTab)
|
||||||
Tooltip(
|
TextButton.icon(
|
||||||
message: _capacityFull
|
onPressed: _attachChild,
|
||||||
? 'Capacité maximale atteinte'
|
|
||||||
: 'Rattacher un enfant',
|
|
||||||
child: TextButton.icon(
|
|
||||||
onPressed: canAttachChild ? _attachChild : null,
|
|
||||||
icon: const Icon(Icons.link, size: 18),
|
icon: const Icon(Icons.link, size: 18),
|
||||||
label: const Text('Rattacher un enfant'),
|
label: const Text('Rattacher un enfant'),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (isChildrenTab) const SizedBox(width: 12),
|
if (isChildrenTab) const SizedBox(width: 12),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ValidationModalTheme.primaryElevatedStyle,
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import 'package:p_tits_pas/utils/date_display_utils.dart';
|
|||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.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_modal_theme.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
@@ -43,11 +41,10 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
bool _dirty = false;
|
bool _dirty = false;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _deleting = false;
|
bool _deleting = false;
|
||||||
|
bool _placementBusy = false;
|
||||||
bool _loadingAm = true;
|
bool _loadingAm = true;
|
||||||
String? _currentUserRole;
|
String? _currentUserRole;
|
||||||
AssistanteMaternelleModel? _linkedAm;
|
AssistanteMaternelleModel? _linkedAm;
|
||||||
/// AM rattachée au chargement (pour sync différé au Sauvegarder).
|
|
||||||
String? _baselineAmUserId;
|
|
||||||
|
|
||||||
static const double _modalWidth = 930;
|
static const double _modalWidth = 930;
|
||||||
static const double _mainRowHeight = 380;
|
static const double _mainRowHeight = 380;
|
||||||
@@ -76,7 +73,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
bool get _canDelete =>
|
bool get _canDelete =>
|
||||||
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
|
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
|
||||||
|
|
||||||
bool get _busy => _saving || _deleting;
|
bool get _busy => _saving || _deleting || _placementBusy;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -115,15 +112,11 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_linkedAm = am;
|
_linkedAm = am;
|
||||||
_baselineAmUserId = am?.user.id;
|
|
||||||
_loadingAm = false;
|
_loadingAm = false;
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() => _loadingAm = false);
|
||||||
_baselineAmUserId = null;
|
|
||||||
_loadingAm = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,85 +170,22 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
return '$fn $ln'.trim();
|
return '$fn $ln'.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<EnfantParentLink> get _parentLinks => widget.enfant.parentLinks
|
String? _parentsSubtitle() {
|
||||||
.where((l) => l.parentId.trim().isNotEmpty)
|
final names = widget.enfant.parentLinks
|
||||||
|
.map((l) {
|
||||||
|
final n = (l.parentName ?? '').trim();
|
||||||
|
return n.isNotEmpty ? n : 'Parent rattaché';
|
||||||
|
})
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
|
if (names.isEmpty) return null;
|
||||||
Future<void> _openParent(EnfantParentLink link) async {
|
return 'Responsables : ${names.join(', ')}';
|
||||||
if (_busy) return;
|
|
||||||
final id = link.parentId.trim();
|
|
||||||
if (id.isEmpty) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final parent = await UserService.getParent(id);
|
|
||||||
if (!mounted) return;
|
|
||||||
await showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AdminParentEditModal(
|
|
||||||
parent: parent,
|
|
||||||
onSaved: () => widget.onSaved?.call(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget? _parentsSubtitle() {
|
|
||||||
final links = _parentLinks;
|
|
||||||
if (links.isEmpty) return null;
|
|
||||||
|
|
||||||
return Wrap(
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Responsables : ',
|
|
||||||
style: TextStyle(fontSize: 13, color: Colors.black54),
|
|
||||||
),
|
|
||||||
for (var i = 0; i < links.length; i++) ...[
|
|
||||||
if (i > 0)
|
|
||||||
const Text(
|
|
||||||
', ',
|
|
||||||
style: TextStyle(fontSize: 13, color: Colors.black54),
|
|
||||||
),
|
|
||||||
_parentNameLink(links[i]),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _parentNameLink(EnfantParentLink link) {
|
|
||||||
final name = (link.parentName ?? '').trim();
|
|
||||||
final label = name.isNotEmpty ? name : 'Parent rattaché';
|
|
||||||
return InkWell(
|
|
||||||
onTap: _busy ? null : () => _openParent(link),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: ValidationModalTheme.primaryActionBackground,
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
decorationColor: ValidationModalTheme.primaryActionBackground
|
|
||||||
.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
if (!_dirty) return;
|
if (!_dirty) return;
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
await _syncAmPlacement();
|
|
||||||
|
|
||||||
await UserService.updateEnfant(
|
await UserService.updateEnfant(
|
||||||
enfantId: widget.enfant.id,
|
enfantId: widget.enfant.id,
|
||||||
body: {
|
body: {
|
||||||
@@ -276,7 +206,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
_saving = false;
|
_saving = false;
|
||||||
_baselineAmUserId = _linkedAm?.user.id;
|
|
||||||
});
|
});
|
||||||
widget.onSaved?.call();
|
widget.onSaved?.call();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -291,25 +220,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder).
|
|
||||||
Future<void> _syncAmPlacement() async {
|
|
||||||
final baselineId = _baselineAmUserId;
|
|
||||||
final currentId = _linkedAm?.user.id;
|
|
||||||
|
|
||||||
if (baselineId != null && baselineId != currentId) {
|
|
||||||
await UserService.detachEnfantFromAm(
|
|
||||||
amUserId: baselineId,
|
|
||||||
enfantId: widget.enfant.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (currentId != null && currentId != baselineId) {
|
|
||||||
await UserService.attachEnfantToAm(
|
|
||||||
amUserId: currentId,
|
|
||||||
enfantId: widget.enfant.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _delete() async {
|
Future<void> _delete() async {
|
||||||
if (!_canDelete || _deleting) return;
|
if (!_canDelete || _deleting) return;
|
||||||
|
|
||||||
@@ -366,69 +276,97 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AdminAmEditModal(
|
builder: (ctx) => AdminAmEditModal(
|
||||||
assistante: am,
|
assistante: am,
|
||||||
onSaved: () async {
|
onSaved: () {
|
||||||
await _reloadPlacementFromServer();
|
_loadLinkedAm();
|
||||||
widget.onSaved?.call();
|
widget.onSaved?.call();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recharge AM + statut après une modale externe (ex. fiche AM).
|
Future<void> _attachAm() async {
|
||||||
Future<void> _reloadPlacementFromServer() async {
|
List<AssistanteMaternelleModel> all;
|
||||||
try {
|
try {
|
||||||
final results = await Future.wait([
|
all = await UserService.getAssistantesMaternelles();
|
||||||
UserService.findAmForEnfant(widget.enfant.id),
|
} catch (e) {
|
||||||
UserService.getEnfant(widget.enfant.id),
|
|
||||||
]);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final am = results[0] as AssistanteMaternelleModel?;
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
final enfant = results[1] as EnfantAdminModel;
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
setState(() {
|
);
|
||||||
_linkedAm = am;
|
return;
|
||||||
_baselineAmUserId = am?.user.id;
|
|
||||||
_status = normalizeEnfantStatus(enfant.status);
|
|
||||||
if (!enfantStatusValues.contains(_status)) {
|
|
||||||
_status = 'sans_garde';
|
|
||||||
}
|
|
||||||
_coerceGenderForStatus();
|
|
||||||
});
|
|
||||||
} catch (_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
await _loadLinkedAm();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _attachAm() async {
|
|
||||||
if (_linkedAm != null && !_isSansGarde) {
|
if (_linkedAm != null && !_isSansGarde) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text('Détachez l\'assistante actuelle avant d\'en choisir une autre'),
|
||||||
'Détachez l\'assistante actuelle avant d\'en choisir une autre',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final candidates = all;
|
||||||
|
if (candidates.isEmpty) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final selected = await AdminSelectAmModal.show(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
const SnackBar(content: Text('Aucune assistante maternelle disponible')),
|
||||||
excludeIds: {
|
);
|
||||||
if (_linkedAm != null) _linkedAm!.user.id,
|
return;
|
||||||
},
|
}
|
||||||
title: 'Choisir une assistante maternelle',
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final selected = await showDialog<AssistanteMaternelleModel>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SimpleDialog(
|
||||||
|
title: const Text('Choisir une assistante maternelle'),
|
||||||
|
children: candidates
|
||||||
|
.map(
|
||||||
|
(am) => SimpleDialogOption(
|
||||||
|
onPressed: () => Navigator.pop(ctx, am),
|
||||||
|
child: Text(am.user.fullName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (selected == null || !mounted) return;
|
if (selected == null || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _placementBusy = true);
|
||||||
|
try {
|
||||||
|
// Sans garde : éventuel lien résiduel à clôturer avant rattachement.
|
||||||
|
final previousAm = _linkedAm;
|
||||||
|
if (previousAm != null) {
|
||||||
|
await UserService.detachEnfantFromAm(
|
||||||
|
amUserId: previousAm.user.id,
|
||||||
|
enfantId: widget.enfant.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await UserService.attachEnfantToAm(
|
||||||
|
amUserId: selected.user.id,
|
||||||
|
enfantId: widget.enfant.id,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_linkedAm = selected;
|
// Le back passe déjà sans_garde → garde à l'attach ; on aligne l'UI.
|
||||||
if (_status == 'sans_garde') {
|
if (_status == 'sans_garde') {
|
||||||
_status = 'garde';
|
_status = 'garde';
|
||||||
}
|
}
|
||||||
_dirty = true;
|
|
||||||
});
|
});
|
||||||
|
await _loadLinkedAm();
|
||||||
|
widget.onSaved?.call();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Assistante maternelle rattachée')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _placementBusy = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _detachAm() async {
|
Future<void> _detachAm() async {
|
||||||
@@ -440,8 +378,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Détacher l\'assistante'),
|
title: const Text('Détacher l\'assistante'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Retirer ${am.user.fullName} de la garde de cet enfant ?\n'
|
'Retirer ${am.user.fullName} de la garde de cet enfant ?',
|
||||||
'(Effectif après Sauvegarder.)',
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -458,22 +395,57 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
);
|
);
|
||||||
if (confirmed != true || !mounted) return;
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _placementBusy = true);
|
||||||
|
try {
|
||||||
|
await UserService.detachEnfantFromAm(
|
||||||
|
amUserId: am.user.id,
|
||||||
|
enfantId: widget.enfant.id,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_linkedAm = null;
|
// Le back repasse en sans_garde sauf a_naitre / scolarise.
|
||||||
if (_status != 'a_naitre' && _status != 'scolarise') {
|
if (_status != 'a_naitre' && _status != 'scolarise') {
|
||||||
_status = 'sans_garde';
|
_status = 'sans_garde';
|
||||||
}
|
}
|
||||||
_dirty = true;
|
_linkedAm = null;
|
||||||
});
|
});
|
||||||
|
widget.onSaved?.call();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Assistante maternelle détachée')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _placementBusy = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Passe en « Sans garde » : cadre vide local (détachement au Sauvegarder).
|
/// Passe en « Sans garde » : cadre vide + détachement AM si besoin.
|
||||||
void _applySansGardeStatus() {
|
Future<void> _applySansGardeStatus() async {
|
||||||
if (_linkedAm == null) return;
|
final am = _linkedAm;
|
||||||
setState(() {
|
if (am == null) return;
|
||||||
_linkedAm = null;
|
|
||||||
_dirty = true;
|
setState(() => _placementBusy = true);
|
||||||
});
|
try {
|
||||||
|
await UserService.detachEnfantFromAm(
|
||||||
|
amUserId: am.user.id,
|
||||||
|
enfantId: widget.enfant.id,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _linkedAm = null);
|
||||||
|
widget.onSaved?.call();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _placementBusy = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
InputDecoration _inputDecoration({String? hint}) {
|
InputDecoration _inputDecoration({String? hint}) {
|
||||||
@@ -696,7 +668,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
enfantStatusLabel('scolarise', gender: _gender),
|
'Scolarisé',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -918,7 +890,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
Widget _placementSection() {
|
Widget _placementSection() {
|
||||||
if (!_showsAmPlacement) return _scolariseCard();
|
if (!_showsAmPlacement) return _scolariseCard();
|
||||||
|
|
||||||
if (_loadingAm) {
|
if (_loadingAm || _placementBusy) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: _placementHeight,
|
height: _placementHeight,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -1022,7 +994,13 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
),
|
),
|
||||||
if (_parentsSubtitle() != null) ...[
|
if (_parentsSubtitle() != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
_parentsSubtitle()!,
|
_parentsSubtitle()!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
final List<String> subtitleLines;
|
final List<String> subtitleLines;
|
||||||
final List<Widget> actions;
|
final List<Widget> actions;
|
||||||
final VoidCallback? onCardTap;
|
final VoidCallback? onCardTap;
|
||||||
final EdgeInsetsGeometry? margin;
|
|
||||||
final EdgeInsetsGeometry? contentPadding;
|
|
||||||
|
|
||||||
const AdminEnfantUserCard({
|
const AdminEnfantUserCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -44,8 +42,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
required this.subtitleLines,
|
required this.subtitleLines,
|
||||||
this.actions = const [],
|
this.actions = const [],
|
||||||
this.onCardTap,
|
this.onCardTap,
|
||||||
this.margin,
|
|
||||||
this.contentPadding,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AdminEnfantUserCard.fromEnfant(
|
factory AdminEnfantUserCard.fromEnfant(
|
||||||
@@ -53,8 +49,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
List<String> extraSubtitleLines = const [],
|
List<String> extraSubtitleLines = const [],
|
||||||
List<Widget> actions = const [],
|
List<Widget> actions = const [],
|
||||||
VoidCallback? onCardTap,
|
VoidCallback? onCardTap,
|
||||||
EdgeInsetsGeometry? margin,
|
|
||||||
EdgeInsetsGeometry? contentPadding,
|
|
||||||
}) {
|
}) {
|
||||||
final parents = enfant.parentLinks
|
final parents = enfant.parentLinks
|
||||||
.map((l) => l.parentName ?? 'Parent')
|
.map((l) => l.parentName ?? 'Parent')
|
||||||
@@ -74,8 +68,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
actions: actions,
|
actions: actions,
|
||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
margin: margin,
|
|
||||||
contentPadding: contentPadding,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +76,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
List<String> extraSubtitleLines = const [],
|
List<String> extraSubtitleLines = const [],
|
||||||
List<Widget> actions = const [],
|
List<Widget> actions = const [],
|
||||||
VoidCallback? onCardTap,
|
VoidCallback? onCardTap,
|
||||||
EdgeInsetsGeometry? margin,
|
|
||||||
EdgeInsetsGeometry? contentPadding,
|
|
||||||
}) {
|
}) {
|
||||||
return AdminEnfantUserCard(
|
return AdminEnfantUserCard(
|
||||||
title: child.fullName,
|
title: child.fullName,
|
||||||
@@ -98,8 +88,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
actions: actions,
|
actions: actions,
|
||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
margin: margin,
|
|
||||||
contentPadding: contentPadding,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +100,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
subtitleLines: subtitleLines,
|
subtitleLines: subtitleLines,
|
||||||
actions: actions,
|
actions: actions,
|
||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
margin: margin,
|
|
||||||
contentPadding: contentPadding,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/models/parent_model.dart';
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
@@ -6,7 +7,6 @@ import 'package:p_tits_pas/models/user.dart';
|
|||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||||
@@ -113,73 +113,10 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
return '$fn $ln'.trim();
|
return '$fn $ln'.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _coParentName() {
|
String? _coParentSubtitle() {
|
||||||
final name = _coParent?.fullName.trim() ?? '';
|
final name = _coParent?.fullName.trim() ?? '';
|
||||||
return name.isEmpty ? null : name;
|
if (name.isEmpty) return null;
|
||||||
}
|
return 'Co-parent : $name';
|
||||||
|
|
||||||
Future<void> _openCoParent() async {
|
|
||||||
if (_saving) return;
|
|
||||||
final co = _coParent;
|
|
||||||
final id = (co?.id ?? '').trim();
|
|
||||||
if (co == null || id.isEmpty) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final parent = await UserService.getParent(id);
|
|
||||||
if (!mounted) return;
|
|
||||||
await showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AdminParentEditModal(
|
|
||||||
parent: parent,
|
|
||||||
onSaved: () async {
|
|
||||||
try {
|
|
||||||
final refreshed = await UserService.getParent(widget.parent.user.id);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _coParent = refreshed.coParent);
|
|
||||||
} catch (_) {}
|
|
||||||
widget.onSaved?.call();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget? _coParentSubtitle() {
|
|
||||||
final name = _coParentName();
|
|
||||||
if (name == null) return null;
|
|
||||||
|
|
||||||
return Wrap(
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Co-parent : ',
|
|
||||||
style: TextStyle(fontSize: 13, color: Colors.black54),
|
|
||||||
),
|
|
||||||
InkWell(
|
|
||||||
onTap: _saving ? null : _openCoParent,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
|
|
||||||
child: Text(
|
|
||||||
name,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: ValidationModalTheme.primaryActionBackground,
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
decorationColor: ValidationModalTheme.primaryActionBackground
|
|
||||||
.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
@@ -321,11 +258,41 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _attachChild() async {
|
Future<void> _attachChild() async {
|
||||||
|
List<EnfantAdminModel> all;
|
||||||
|
try {
|
||||||
|
all = await UserService.getEnfants();
|
||||||
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final selected = await AdminSelectEnfantModal.show(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
excludeIds: _children.map((c) => c.id).toSet(),
|
);
|
||||||
title: 'Rattacher un enfant',
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final linkedIds = _children.map((c) => c.id).toSet();
|
||||||
|
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
|
||||||
|
if (candidates.isEmpty) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final selected = await showDialog<EnfantAdminModel>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SimpleDialog(
|
||||||
|
title: const Text('Rattacher un enfant'),
|
||||||
|
children: candidates
|
||||||
|
.map(
|
||||||
|
(e) => SimpleDialogOption(
|
||||||
|
onPressed: () => Navigator.pop(ctx, e),
|
||||||
|
child: Text(e.fullName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (selected == null || !mounted) return;
|
if (selected == null || !mounted) return;
|
||||||
|
|
||||||
@@ -428,7 +395,13 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
),
|
),
|
||||||
if (_coParentSubtitle() != null) ...[
|
if (_coParentSubtitle() != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
_coParentSubtitle()!,
|
_coParentSubtitle()!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
import 'package:flutter/gestures.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
|
||||||
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
|
||||||
|
|
||||||
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
|
|
||||||
final lines = <String>[];
|
|
||||||
final zone = (am.residenceCity ?? '').trim();
|
|
||||||
if (zone.isNotEmpty) lines.add('Zone : $zone');
|
|
||||||
final agrement = (am.approvalNumber ?? '').trim();
|
|
||||||
if (agrement.isNotEmpty) lines.add('Agrément : $agrement');
|
|
||||||
final max = am.maxChildren;
|
|
||||||
final free = amExpectedPlacesAvailable(
|
|
||||||
maxChildren: max,
|
|
||||||
childrenCount: am.children.length,
|
|
||||||
) ??
|
|
||||||
am.placesAvailable;
|
|
||||||
if (free != null || max != null) {
|
|
||||||
lines.add('Places libres : ${free ?? '–'} / capa. ${max ?? '–'}');
|
|
||||||
}
|
|
||||||
lines.add('${am.children.length} enfant(s)');
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147.
|
|
||||||
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146).
|
|
||||||
class AdminSelectAmModal {
|
|
||||||
AdminSelectAmModal._();
|
|
||||||
|
|
||||||
static Future<AssistanteMaternelleModel?> show(
|
|
||||||
BuildContext context, {
|
|
||||||
Set<String> excludeIds = const {},
|
|
||||||
String title = 'Choisir une assistante maternelle',
|
|
||||||
}) {
|
|
||||||
return AdminSelectListModal.show<AssistanteMaternelleModel>(
|
|
||||||
context,
|
|
||||||
title: title,
|
|
||||||
searchHint: 'Rechercher par nom, prénom ou zone…',
|
|
||||||
emptyMessage: 'Aucune assistante maternelle disponible',
|
|
||||||
noResultsMessage: 'Aucune AM avec place libre pour cette recherche',
|
|
||||||
toggleFilter: const AdminSelectToggleFilter<AssistanteMaternelleModel>(
|
|
||||||
label: 'Libre',
|
|
||||||
initialValue: true,
|
|
||||||
whenEnabled: amHasFreePlace,
|
|
||||||
),
|
|
||||||
loadItems: () async {
|
|
||||||
final list = await UserService.getAssistantesMaternelles();
|
|
||||||
return list
|
|
||||||
.where((am) => !excludeIds.contains(am.user.id))
|
|
||||||
.toList()
|
|
||||||
..sort(
|
|
||||||
(a, b) => a.user.fullName
|
|
||||||
.toLowerCase()
|
|
||||||
.compareTo(b.user.fullName.toLowerCase()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
matchesQuery: (am, q) {
|
|
||||||
final u = am.user;
|
|
||||||
final name = u.fullName.toLowerCase();
|
|
||||||
final fn = (u.prenom ?? '').toLowerCase();
|
|
||||||
final ln = (u.nom ?? '').toLowerCase();
|
|
||||||
final zone = (am.residenceCity ?? '').toLowerCase();
|
|
||||||
final agrement = (am.approvalNumber ?? '').toLowerCase();
|
|
||||||
return name.contains(q) ||
|
|
||||||
fn.contains(q) ||
|
|
||||||
ln.contains(q) ||
|
|
||||||
zone.contains(q) ||
|
|
||||||
agrement.contains(q);
|
|
||||||
},
|
|
||||||
resolveSelect: (ctx, am, reload) =>
|
|
||||||
_resolveAmSelection(ctx, am, reload),
|
|
||||||
itemBuilder: (context, am, onSelect) {
|
|
||||||
final full = !amHasFreePlace(am);
|
|
||||||
return AdminUserCard(
|
|
||||||
title: am.user.fullName,
|
|
||||||
avatarUrl: am.user.photoUrl,
|
|
||||||
fallbackIcon: Icons.face,
|
|
||||||
subtitleLines: _amSelectSubtitleLines(am),
|
|
||||||
vigilanceTooltip: amPlacesVigilanceMessage(am),
|
|
||||||
onCardTap: onSelect,
|
|
||||||
margin: const EdgeInsets.only(bottom: 4),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 5,
|
|
||||||
),
|
|
||||||
backgroundColor: full ? const Color(0xFFFFEBEE) : null,
|
|
||||||
borderColor: full ? Colors.red.shade200 : null,
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.add_link),
|
|
||||||
tooltip: 'Rattacher',
|
|
||||||
onPressed: onSelect,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<AssistanteMaternelleModel?> _resolveAmSelection(
|
|
||||||
BuildContext context,
|
|
||||||
AssistanteMaternelleModel am,
|
|
||||||
Future<void> Function() reloadList,
|
|
||||||
) async {
|
|
||||||
if (amHasFreePlace(am)) return am;
|
|
||||||
|
|
||||||
final selected = await showDialog<AssistanteMaternelleModel>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => _AmNoPlaceWarningDialog(initialAm: am),
|
|
||||||
);
|
|
||||||
await reloadList();
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Avertissement AM saturée + lien vers la fiche pour ajuster les places.
|
|
||||||
class _AmNoPlaceWarningDialog extends StatefulWidget {
|
|
||||||
final AssistanteMaternelleModel initialAm;
|
|
||||||
|
|
||||||
const _AmNoPlaceWarningDialog({required this.initialAm});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_AmNoPlaceWarningDialog> createState() =>
|
|
||||||
_AmNoPlaceWarningDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> {
|
|
||||||
late AssistanteMaternelleModel _am;
|
|
||||||
TapGestureRecognizer? _linkRecognizer;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_am = widget.initialAm;
|
|
||||||
_linkRecognizer = TapGestureRecognizer()..onTap = _openAmFiche;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_linkRecognizer?.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openAmFiche() async {
|
|
||||||
if (!mounted) return;
|
|
||||||
await showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AdminAmEditModal(
|
|
||||||
assistante: _am,
|
|
||||||
onSaved: () async {
|
|
||||||
try {
|
|
||||||
final fresh =
|
|
||||||
await UserService.getAssistanteMaternelle(_am.user.id);
|
|
||||||
if (mounted) setState(() => _am = fresh);
|
|
||||||
} catch (_) {}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final fresh = await UserService.getAssistanteMaternelle(_am.user.id);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _am = fresh);
|
|
||||||
if (amHasFreePlace(fresh)) {
|
|
||||||
Navigator.of(context).pop(fresh);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final name = _am.user.fullName.trim().isNotEmpty
|
|
||||||
? _am.user.fullName.trim()
|
|
||||||
: 'Cette assistante maternelle';
|
|
||||||
const linkColor = ValidationModalTheme.primaryActionBackground;
|
|
||||||
|
|
||||||
return AlertDialog(
|
|
||||||
title: const Text('Plus de place disponible'),
|
|
||||||
content: Text.rich(
|
|
||||||
TextSpan(
|
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.black87, height: 1.4),
|
|
||||||
children: [
|
|
||||||
TextSpan(
|
|
||||||
text: '$name n\'a plus de place libre pour accueillir '
|
|
||||||
'un enfant supplémentaire.\n\n',
|
|
||||||
),
|
|
||||||
const TextSpan(text: 'Vous pouvez '),
|
|
||||||
TextSpan(
|
|
||||||
text: 'ouvrir sa fiche',
|
|
||||||
style: TextStyle(
|
|
||||||
color: linkColor,
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
recognizer: _linkRecognizer,
|
|
||||||
),
|
|
||||||
const TextSpan(
|
|
||||||
text: ' pour modifier la capacité ou les places, '
|
|
||||||
'puis la sélectionner si une place se libère.',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('Fermer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
|
||||||
|
|
||||||
/// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146.
|
|
||||||
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147).
|
|
||||||
class AdminSelectEnfantModal {
|
|
||||||
AdminSelectEnfantModal._();
|
|
||||||
|
|
||||||
static Future<EnfantAdminModel?> show(
|
|
||||||
BuildContext context, {
|
|
||||||
Set<String> excludeIds = const {},
|
|
||||||
String title = 'Rattacher un enfant',
|
|
||||||
/// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde.
|
|
||||||
bool showSansGardeFilter = false,
|
|
||||||
}) {
|
|
||||||
return AdminSelectListModal.show<EnfantAdminModel>(
|
|
||||||
context,
|
|
||||||
title: title,
|
|
||||||
searchHint: 'Rechercher par nom ou prénom…',
|
|
||||||
emptyMessage: 'Aucun enfant disponible à rattacher',
|
|
||||||
noResultsMessage: showSansGardeFilter
|
|
||||||
? 'Aucun enfant sans garde pour cette recherche'
|
|
||||||
: 'Aucun résultat pour cette recherche',
|
|
||||||
toggleFilter: showSansGardeFilter
|
|
||||||
? AdminSelectToggleFilter<EnfantAdminModel>(
|
|
||||||
label: 'Sans garde',
|
|
||||||
initialValue: true,
|
|
||||||
whenEnabled: (e) =>
|
|
||||||
normalizeEnfantStatus(e.status) == 'sans_garde',
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
loadItems: () async {
|
|
||||||
final list = await UserService.getEnfants();
|
|
||||||
return list
|
|
||||||
.where((e) => !excludeIds.contains(e.id))
|
|
||||||
.toList()
|
|
||||||
..sort(
|
|
||||||
(a, b) =>
|
|
||||||
a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
matchesQuery: (e, q) {
|
|
||||||
final name = e.fullName.toLowerCase();
|
|
||||||
final fn = (e.firstName ?? '').toLowerCase();
|
|
||||||
final ln = (e.lastName ?? '').toLowerCase();
|
|
||||||
return name.contains(q) || fn.contains(q) || ln.contains(q);
|
|
||||||
},
|
|
||||||
itemBuilder: (context, e, onSelect) {
|
|
||||||
return AdminEnfantUserCard.fromEnfant(
|
|
||||||
e,
|
|
||||||
onCardTap: onSelect,
|
|
||||||
margin: const EdgeInsets.only(bottom: 4),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 5,
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.add_link),
|
|
||||||
tooltip: 'Rattacher',
|
|
||||||
onPressed: onSelect,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
|
||||||
|
|
||||||
/// Filtre optionnel (switch) sur la même ligne que la barre de recherche.
|
|
||||||
class AdminSelectToggleFilter<T> {
|
|
||||||
final String label;
|
|
||||||
final bool initialValue;
|
|
||||||
|
|
||||||
/// Si le switch est activé, ne garde que les éléments pour lesquels
|
|
||||||
/// [whenEnabled] renvoie `true`.
|
|
||||||
final bool Function(T item) whenEnabled;
|
|
||||||
|
|
||||||
const AdminSelectToggleFilter({
|
|
||||||
required this.label,
|
|
||||||
required this.whenEnabled,
|
|
||||||
this.initialValue = true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shell générique « rechercher + liste + sélection » pour les modales admin.
|
|
||||||
/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147).
|
|
||||||
class AdminSelectListModal<T> extends StatefulWidget {
|
|
||||||
final String title;
|
|
||||||
final String searchHint;
|
|
||||||
final Future<List<T>> Function() loadItems;
|
|
||||||
final bool Function(T item, String query) matchesQuery;
|
|
||||||
final Widget Function(
|
|
||||||
BuildContext context,
|
|
||||||
T item,
|
|
||||||
VoidCallback onSelect,
|
|
||||||
) itemBuilder;
|
|
||||||
final String emptyMessage;
|
|
||||||
final String noResultsMessage;
|
|
||||||
final double modalWidth;
|
|
||||||
|
|
||||||
/// Hauteur d'une carte (pour dimensionner la liste à ≥ [minVisibleCards]).
|
|
||||||
final double cardExtent;
|
|
||||||
|
|
||||||
/// Nombre minimum de cartes visibles dans la zone scrollable.
|
|
||||||
final int minVisibleCards;
|
|
||||||
|
|
||||||
/// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »).
|
|
||||||
final AdminSelectToggleFilter<T>? toggleFilter;
|
|
||||||
|
|
||||||
/// Si fourni, appelé avant de valider la sélection.
|
|
||||||
/// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler.
|
|
||||||
final Future<T?> Function(
|
|
||||||
BuildContext context,
|
|
||||||
T item,
|
|
||||||
Future<void> Function() reload,
|
|
||||||
)? resolveSelect;
|
|
||||||
|
|
||||||
const AdminSelectListModal({
|
|
||||||
super.key,
|
|
||||||
required this.title,
|
|
||||||
required this.loadItems,
|
|
||||||
required this.matchesQuery,
|
|
||||||
required this.itemBuilder,
|
|
||||||
this.searchHint = 'Rechercher…',
|
|
||||||
this.emptyMessage = 'Aucun élément disponible',
|
|
||||||
this.noResultsMessage = 'Aucun résultat pour cette recherche',
|
|
||||||
this.modalWidth = 930,
|
|
||||||
this.cardExtent = 52,
|
|
||||||
this.minVisibleCards = 8,
|
|
||||||
this.toggleFilter,
|
|
||||||
this.resolveSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
static Future<T?> show<T>(
|
|
||||||
BuildContext context, {
|
|
||||||
required String title,
|
|
||||||
required Future<List<T>> Function() loadItems,
|
|
||||||
required bool Function(T item, String query) matchesQuery,
|
|
||||||
required Widget Function(
|
|
||||||
BuildContext context,
|
|
||||||
T item,
|
|
||||||
VoidCallback onSelect,
|
|
||||||
) itemBuilder,
|
|
||||||
String searchHint = 'Rechercher…',
|
|
||||||
String emptyMessage = 'Aucun élément disponible',
|
|
||||||
String noResultsMessage = 'Aucun résultat pour cette recherche',
|
|
||||||
double modalWidth = 930,
|
|
||||||
double cardExtent = 52,
|
|
||||||
int minVisibleCards = 8,
|
|
||||||
AdminSelectToggleFilter<T>? toggleFilter,
|
|
||||||
Future<T?> Function(
|
|
||||||
BuildContext context,
|
|
||||||
T item,
|
|
||||||
Future<void> Function() reload,
|
|
||||||
)? resolveSelect,
|
|
||||||
}) {
|
|
||||||
return showDialog<T>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AdminSelectListModal<T>(
|
|
||||||
title: title,
|
|
||||||
loadItems: loadItems,
|
|
||||||
matchesQuery: matchesQuery,
|
|
||||||
itemBuilder: itemBuilder,
|
|
||||||
searchHint: searchHint,
|
|
||||||
emptyMessage: emptyMessage,
|
|
||||||
noResultsMessage: noResultsMessage,
|
|
||||||
modalWidth: modalWidth,
|
|
||||||
cardExtent: cardExtent,
|
|
||||||
minVisibleCards: minVisibleCards,
|
|
||||||
toggleFilter: toggleFilter,
|
|
||||||
resolveSelect: resolveSelect,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<AdminSelectListModal<T>> createState() =>
|
|
||||||
_AdminSelectListModalState<T>();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> {
|
|
||||||
final _searchCtrl = TextEditingController();
|
|
||||||
List<T> _all = [];
|
|
||||||
bool _loading = true;
|
|
||||||
String? _error;
|
|
||||||
late bool _toggleOn;
|
|
||||||
bool _resolving = false;
|
|
||||||
|
|
||||||
double get _listHeight =>
|
|
||||||
widget.cardExtent * widget.minVisibleCards;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_toggleOn = widget.toggleFilter?.initialValue ?? false;
|
|
||||||
_searchCtrl.addListener(() => setState(() {}));
|
|
||||||
_load();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_searchCtrl.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _load() async {
|
|
||||||
setState(() {
|
|
||||||
_loading = true;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
final list = await widget.loadItems();
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_all = list;
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_loading = false;
|
|
||||||
_error = e.toString().replaceFirst('Exception: ', '');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleSelect(T item) async {
|
|
||||||
if (_resolving) return;
|
|
||||||
final resolve = widget.resolveSelect;
|
|
||||||
if (resolve == null) {
|
|
||||||
Navigator.of(context).pop(item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _resolving = true);
|
|
||||||
try {
|
|
||||||
final chosen = await resolve(context, item, _load);
|
|
||||||
if (!mounted || chosen == null) return;
|
|
||||||
Navigator.of(context).pop(chosen);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _resolving = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<T> get _filtered {
|
|
||||||
var list = _all;
|
|
||||||
final toggle = widget.toggleFilter;
|
|
||||||
if (toggle != null && _toggleOn) {
|
|
||||||
list = list.where(toggle.whenEnabled).toList();
|
|
||||||
}
|
|
||||||
final q = _searchCtrl.text.trim().toLowerCase();
|
|
||||||
if (q.isEmpty) return list;
|
|
||||||
return list.where((item) => widget.matchesQuery(item, q)).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final toggle = widget.toggleFilter;
|
|
||||||
|
|
||||||
return Dialog(
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(maxWidth: widget.modalWidth),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(18, 16, 4, 0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
widget.title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
tooltip: 'Fermer',
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: _searchCtrl,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
hintText: widget.searchHint,
|
|
||||||
prefixIcon: const Icon(Icons.search, size: 20),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (toggle != null) ...[
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
toggle.label,
|
|
||||||
style: const TextStyle(fontSize: 13),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Switch(
|
|
||||||
value: _toggleOn,
|
|
||||||
activeColor:
|
|
||||||
ValidationModalTheme.primaryActionBackground,
|
|
||||||
onChanged: (v) => setState(() => _toggleOn = v),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
SizedBox(
|
|
||||||
height: _listHeight,
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: _buildBody(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Spacer(),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildBody() {
|
|
||||||
if (_loading) {
|
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: ValidationModalTheme.primaryActionBackground,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_error != null) {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.error_outline, size: 40, color: Colors.red.shade400),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
_error!,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(color: Colors.red.shade700),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: _load,
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
label: const Text('Réessayer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final items = _filtered;
|
|
||||||
if (_all.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
widget.emptyMessage,
|
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.black54),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
widget.noResultsMessage,
|
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.black54),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
|
||||||
itemExtent: widget.cardExtent,
|
|
||||||
itemCount: items.length,
|
|
||||||
itemBuilder: (context, i) {
|
|
||||||
final item = items[i];
|
|
||||||
return widget.itemBuilder(
|
|
||||||
context,
|
|
||||||
item,
|
|
||||||
() => _handleSelect(item),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ class AdminUserCard extends StatefulWidget {
|
|||||||
final String? vigilanceTooltip;
|
final String? vigilanceTooltip;
|
||||||
final VoidCallback? onCardTap;
|
final VoidCallback? onCardTap;
|
||||||
final EdgeInsetsGeometry? margin;
|
final EdgeInsetsGeometry? margin;
|
||||||
final EdgeInsetsGeometry? contentPadding;
|
|
||||||
|
|
||||||
const AdminUserCard({
|
const AdminUserCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -31,7 +30,6 @@ class AdminUserCard extends StatefulWidget {
|
|||||||
this.vigilanceTooltip,
|
this.vigilanceTooltip,
|
||||||
this.onCardTap,
|
this.onCardTap,
|
||||||
this.margin,
|
this.margin,
|
||||||
this.contentPadding,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -71,8 +69,7 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
|||||||
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
|
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: widget.contentPadding ??
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_buildAvatar(avatarUrl),
|
_buildAvatar(avatarUrl),
|
||||||
@@ -91,10 +88,7 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// flex: 0 → largeur du nom ; le reste va aux infos
|
|
||||||
// (évite le partage 50/50 qui tronque « Responsables »).
|
|
||||||
Flexible(
|
Flexible(
|
||||||
flex: 0,
|
|
||||||
fit: FlexFit.loose,
|
fit: FlexFit.loose,
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.title.isNotEmpty ? widget.title : 'Sans nom',
|
widget.title.isNotEmpty ? widget.title : 'Sans nom',
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
value: 'garde',
|
value: 'garde',
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(left: 10),
|
padding: EdgeInsets.only(left: 10),
|
||||||
child: Text('Gardé', style: TextStyle(fontSize: 12)),
|
child: Text('En garde', style: TextStyle(fontSize: 12)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DropdownMenuItem<String?>(
|
DropdownMenuItem<String?>(
|
||||||
|
|||||||
Reference in New Issue
Block a user