import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; import 'package:manager_app/Components/common_loader.dart'; import 'package:manager_app/Models/managerContext.dart'; import 'package:manager_app/Screens/Audit/audit_entry.dart'; import 'package:manager_app/app_context.dart'; import 'package:manager_app/constants.dart'; import 'package:manager_app/l10n/app_localizations.dart'; import 'package:provider/provider.dart'; /// Les types journalisés par `MyInfoMateDbContext.AuditedTypes`. La liste est fixe : /// l'endpoint ne rend pas les valeurs distinctes présentes en base, et un filtre /// construit sur la page courante ne proposerait que ce qui y est déjà visible. const _kAuditEntityTypes = ['Section', 'Resource', 'Configuration', 'Device', 'User', 'Instance']; const _kAuditPageSize = 50; class AuditScreen extends StatefulWidget { const AuditScreen({super.key}); @override State createState() => _AuditScreenState(); } class _AuditScreenState extends State { AuditPage? _page; bool _loading = true; bool _failed = false; int _pageNumber = 1; String? _instanceId; String? _entityType; String? _userId; DateTime? _from; DateTime? _to; /// Les deux annuaires servent à afficher un nom là où le journal ne garde qu'un id. /// Un id absent de l'annuaire (instance ou utilisateur supprimé depuis) reste affiché /// tel quel : le journal doit rester lisible après la disparition de ce qu'il décrit. Map _instanceNames = {}; Map _userNames = {}; ManagerAppContext get _managerContext => Provider.of(context, listen: false).getContext() as ManagerAppContext; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _loadDirectories(); _load(); }); } Future _loadDirectories() async { final ctx = _managerContext; if (ctx.clientAPI == null) return; try { final instances = await ctx.clientAPI!.instanceApi!.instanceGet(); final users = await ctx.clientAPI!.userApi!.userGet(); if (!mounted) return; setState(() { _instanceNames = {for (final i in instances ?? []) i.id: i.name}; _userNames = { for (final u in users ?? []) if (u.id != null) u.id!: _fullName(u.firstName, u.lastName, u.email), }; }); } catch (_) { // Les filtres retombent sur les identifiants bruts, la consultation reste possible. } } static String _fullName(String? firstName, String? lastName, String? email) { final name = [firstName, lastName].where((p) => (p ?? '').isNotEmpty).join(' '); return name.isEmpty ? (email ?? '') : name; } Future _load() async { final ctx = _managerContext; setState(() { _loading = true; _failed = false; }); final query = { 'page': '$_pageNumber', 'limit': '$_kAuditPageSize', if (_instanceId != null) 'instanceId': _instanceId!, if (_entityType != null) 'entityType': _entityType!, if (_userId != null) 'userId': _userId!, if (_from != null) 'from': _from!.toUtc().toIso8601String(), // Borne haute prise à la fin de la journée choisie : un `to` au 12/08 doit // inclure ce qui s'est passé le 12/08, pas s'arrêter à minuit. if (_to != null) 'to': _endOfDay(_to!).toUtc().toIso8601String(), }; try { final response = await http.get( Uri.parse('${ctx.host}/api/Audit').replace(queryParameters: query), headers: {'Authorization': 'Bearer ${ctx.accessToken}'}, ); if (!mounted) return; if (response.statusCode != 200) { setState(() { _loading = false; _failed = true; }); return; } setState(() { _page = AuditPage.fromJson( jsonDecode(utf8.decode(response.bodyBytes)) as Map); _loading = false; }); } catch (_) { if (!mounted) return; setState(() { _loading = false; _failed = true; }); } } static DateTime _endOfDay(DateTime day) => DateTime(day.year, day.month, day.day, 23, 59, 59, 999); /// Tout changement de filtre ramène à la première page : rester en page 4 d'un /// résultat qui n'en compte plus qu'une afficherait une liste vide sans raison visible. void _applyFilter(VoidCallback change) { setState(() { change(); _pageNumber = 1; }); _load(); } void _resetFilters() => _applyFilter(() { _instanceId = null; _entityType = null; _userId = null; _from = null; _to = null; }); bool get _hasFilters => _instanceId != null || _entityType != null || _userId != null || _from != null || _to != null; @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l.auditTitle, style: kTitleScreen), const SizedBox(height: kSpace1), Text(l.auditSubtitle, style: kSubtitleCard), const SizedBox(height: kSpace5), _filters(l), const SizedBox(height: kSpace5), Expanded(child: _body(l)), ], ); } Widget _filters(AppLocalizations l) { return Wrap( spacing: kSpace4, runSpacing: kSpace4, crossAxisAlignment: WrapCrossAlignment.center, children: [ _dropdown( label: l.auditFilterInstance, value: _instanceId, emptyLabel: l.auditAllInstances, entries: _instanceNames.entries.map((e) => MapEntry(e.key, e.value)).toList(), onChanged: (v) => _applyFilter(() => _instanceId = v), ), _dropdown( label: l.auditFilterEntity, value: _entityType, emptyLabel: l.auditAllEntities, entries: _kAuditEntityTypes.map((t) => MapEntry(t, _entityLabel(l, t))).toList(), onChanged: (v) => _applyFilter(() => _entityType = v), ), _dropdown( label: l.auditFilterUser, value: _userId, emptyLabel: l.auditAllUsers, entries: _userNames.entries.map((e) => MapEntry(e.key, e.value)).toList(), onChanged: (v) => _applyFilter(() => _userId = v), ), _dateField(l.auditFilterFrom, _from, (d) => _applyFilter(() => _from = d)), _dateField(l.auditFilterTo, _to, (d) => _applyFilter(() => _to = d)), if (_hasFilters) TextButton.icon( onPressed: _resetFilters, icon: const Icon(Icons.filter_alt_off_outlined, size: 18), label: Text(l.auditReset), ), ], ); } Widget _dropdown({ required String label, required T? value, required String emptyLabel, required List> entries, required ValueChanged onChanged, }) { return SizedBox( width: 220, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: kLabelField), const SizedBox(height: kSpace1), // `DropdownButton` piloté plutôt que `DropdownButtonFormField` : ce dernier // ne relit pas `initialValue` sur reconstruction, donc « Réinitialiser les // filtres » aurait vidé la requête sans vider l'affichage. Container( padding: const EdgeInsets.symmetric(horizontal: kSpace4), decoration: BoxDecoration( border: Border.all(color: kLine), borderRadius: BorderRadius.circular(kRadiusInput), ), child: DropdownButtonHideUnderline( child: DropdownButton( value: value, isExpanded: true, isDense: true, padding: const EdgeInsets.symmetric(vertical: 11), style: kTextSmall, items: [ DropdownMenuItem(value: null, child: Text(emptyLabel, style: kTextSmall)), for (final entry in entries) DropdownMenuItem( value: entry.key, child: Text(entry.value, style: kTextSmall, overflow: TextOverflow.ellipsis), ), ], onChanged: onChanged, ), ), ), ], ), ); } Widget _dateField(String label, DateTime? value, ValueChanged onChanged) { return SizedBox( width: 170, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: kLabelField), const SizedBox(height: kSpace1), InkWell( borderRadius: BorderRadius.circular(kRadiusInput), onTap: () async { final picked = await showDatePicker( context: context, initialDate: value ?? DateTime.now(), firstDate: DateTime(2020), lastDate: DateTime.now().add(const Duration(days: 1)), ); if (picked != null) onChanged(picked); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: kSpace4, vertical: 11), decoration: BoxDecoration( border: Border.all(color: kLine), borderRadius: BorderRadius.circular(kRadiusInput), ), child: Row( children: [ Expanded( child: Text( value == null ? '—' : DateFormat('dd/MM/yyyy').format(value), style: kTextSmall, ), ), if (value != null) InkWell( onTap: () => onChanged(null), child: const Icon(Icons.close, size: 16, color: kInk3), ) else const Icon(Icons.calendar_today_outlined, size: 15, color: kInk3), ], ), ), ), ], ), ); } Widget _body(AppLocalizations l) { if (_loading) return const CommonLoader(); if (_failed) return Center(child: Text(l.auditLoadError, style: kTextBody)); final page = _page; if (page == null || page.items.isEmpty) { return Center(child: Text(l.auditEmpty, style: kTextBody)); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Container( width: double.infinity, decoration: BoxDecoration( color: kSurface, border: Border.all(color: kLine), borderRadius: BorderRadius.circular(kRadiusCard), ), clipBehavior: Clip.antiAlias, child: SingleChildScrollView( child: DataTable( horizontalMargin: kSpace5, columnSpacing: kSpace7, headingRowColor: WidgetStateProperty.all(kSurface2), dividerThickness: 1, columns: [ DataColumn(label: Text(l.auditColDate, style: kLabelField)), DataColumn(label: Text(l.auditColInstance, style: kLabelField)), DataColumn(label: Text(l.auditColEntity, style: kLabelField)), DataColumn(label: Text(l.auditColAction, style: kLabelField)), DataColumn(label: Text(l.auditColUser, style: kLabelField)), DataColumn(label: Text(l.auditColEntityId, style: kLabelField)), ], rows: page.items .map((entry) => DataRow( onSelectChanged: (_) => _showDetail(entry), cells: [ DataCell(Text( DateFormat('dd/MM/yyyy HH:mm').format(entry.timestamp), style: kTextSmall.copyWith(fontFamily: kMonoFamily), )), DataCell(Text(_instanceLabel(entry.instanceId), style: kTextSmall)), DataCell(Text(_entityLabel(l, entry.entityType), style: kTextSmall)), DataCell(_actionChip(l, entry.action)), DataCell(Text(_userLabel(entry.userId), style: kTextSmall)), DataCell(Text( entry.entityId, style: kOverlineMono.copyWith(color: kInk2), )), ], )) .toList(), ), ), ), ), const SizedBox(height: kSpace4), _pagination(l, page), ], ); } Widget _pagination(AppLocalizations l, AuditPage page) { final first = (page.page - 1) * page.limit + 1; final last = first + page.items.length - 1; return Row( children: [ Text(l.auditRange(first, last, page.total), style: kTextHint), const Spacer(), IconButton( onPressed: page.page <= 1 ? null : () { setState(() => _pageNumber = page.page - 1); _load(); }, icon: const Icon(Icons.chevron_left), tooltip: l.auditPrevious, ), Text('${page.page} / ${page.pageCount}', style: kTextSmall), IconButton( onPressed: page.page >= page.pageCount ? null : () { setState(() => _pageNumber = page.page + 1); _load(); }, icon: const Icon(Icons.chevron_right), tooltip: l.auditNext, ), ], ); } Widget _actionChip(AppLocalizations l, String action) { final color = switch (action) { 'Create' => kGood, 'Delete' => kSerious, _ => kBrand, }; return Container( padding: const EdgeInsets.symmetric(horizontal: kSpace3, vertical: 3), decoration: BoxDecoration( color: color.withValues(alpha: 0.08), border: Border.all(color: color.withValues(alpha: 0.3)), borderRadius: BorderRadius.circular(kRadiusPill), ), child: Text(_actionLabel(l, action), style: kTextHint.copyWith(color: color)), ); } String _actionLabel(AppLocalizations l, String action) => switch (action) { 'Create' => l.auditActionCreate, 'Update' => l.auditActionUpdate, 'Delete' => l.auditActionDelete, _ => action, }; String _entityLabel(AppLocalizations l, String entityType) => switch (entityType) { 'Section' => l.auditEntitySection, 'Resource' => l.auditEntityResource, 'Configuration' => l.auditEntityConfiguration, 'Device' => l.auditEntityDevice, 'User' => l.auditEntityUser, 'Instance' => l.auditEntityInstance, _ => entityType, }; String _instanceLabel(String? id) => id == null ? '—' : (_instanceNames[id] ?? id); String _userLabel(String? id) => id == null ? '—' : (_userNames[id] ?? id); void _showDetail(AuditEntry entry) { final l = AppLocalizations.of(context)!; final changes = entry.changes; showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(l.auditDetailTitle, style: kTitleCard), content: SizedBox( width: 720, child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( '${_entityLabel(l, entry.entityType)} · ${_actionLabel(l, entry.action)} · ' '${DateFormat('dd/MM/yyyy HH:mm:ss').format(entry.timestamp)}', style: kSubtitleCard, ), const SizedBox(height: kSpace1), Text('${_instanceLabel(entry.instanceId)} · ${_userLabel(entry.userId)}', style: kSubtitleCard), const SizedBox(height: kSpace5), if (changes.isEmpty) Text(l.auditDetailNoValues, style: kTextBody) else Table( border: TableBorder.all(color: kLineSoft), columnWidths: const { 0: FlexColumnWidth(2), 1: FlexColumnWidth(3), 2: FlexColumnWidth(3), }, children: [ TableRow( decoration: const BoxDecoration(color: kSurface2), children: [ _detailCell(l.auditDetailField, kLabelField), _detailCell(l.auditDetailBefore, kLabelField), _detailCell(l.auditDetailAfter, kLabelField), ], ), for (final change in changes) TableRow(children: [ _detailCell(change.field, kTextSmall), _detailCell(change.before ?? '—', kTextSmall.copyWith(color: kInk3)), _detailCell(change.after ?? '—', kTextSmall), ]), ], ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: Text(l.close), ), ], ), ); } Widget _detailCell(String text, TextStyle style) => Padding( padding: const EdgeInsets.symmetric(horizontal: kSpace3, vertical: kSpace2), child: SelectableText(text, style: style), ); }