Lot F : écran d'audit log et compteur d'utilisateurs
Écran « Activité » sur GET /api/Audit, réservé au SuperAdmin comme la policy de l'endpoint (menuId 13, conditionné à role.value == 0). Filtres instance, type d'entité, utilisateur et plage de dates, pagination 50, clic sur une ligne pour le détail avant/après en table plutôt que le JSON brut. Les ids d'instance et d'utilisateur sont résolus en noms, un id absent de l'annuaire restant affiché tel quel : le journal doit rester lisible après la suppression de ce qu'il décrit. manager_api_new n'est pas touché. L'écran passe par un http.get direct au Bearer, comme _loadKnowledge / _loadInsights / _reindex du Guide IA : une lecture seule ne justifie pas d'étendre un client qui s'édite à la main, et le déclencheur de génération n'existe plus depuis le lot A. Côté utilisateurs, compteur « X / 5 » et bouton d'ajout désactivé au plafond. Le SuperAdmin en est exclu : son GET /api/User renvoie toutes les instances, compter cette liste contre un plafond par instance n'aurait aucun sens. Le masquage du rôle SuperAdmin pour un InstanceAdmin, resté en question dans todo-features.md, était déjà fait — _allowedRoles filtre sur r >= callerRole. Trois pièges relevés en câblant : - AuditController renvoie les entités brutes, pas un DTO — les champs sont ceux d'AuditLog en camelCase. - invokeAPI ne lève pas sur un code d'erreur et son résultat était ignoré dans users_screen : un e-mail déjà utilisé (409) ou un rôle refusé (403) ne produisait aucun message. Le statut est désormais testé et le corps affiché, ce qui rendra visible sans retouche le futur 422 du plafond. - DropdownButtonFormField ne relit pas initialValue sur reconstruction (FormField.didUpdateWidget ne traite que forceErrorText) : « Réinitialiser les filtres » vidait la requête sans vider l'affichage. Remplacé par un DropdownButton piloté. 40 clés i18n FR/EN/NL, flutter gen-l10n relancé. flutter analyze propre sur les dossiers travaillés, flutter build web vert. Deux dettes serveur restent ouvertes, hors périmètre de ce commit : le plafond de 5 n'est pas appliqué par UserController (ce qui est livré ici est un garde-fou d'interface, un POST direct passe toujours), et aucune section n'est journalisée — AuditedTypes.Contains exige l'égalité exacte alors que Section est abstraite. Détail dans DOCS/todo-features.md et v1-plan.md lot F. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
b39854afb0
commit
762448c654
117
lib/Screens/Audit/audit_entry.dart
Normal file
117
lib/Screens/Audit/audit_entry.dart
Normal file
@ -0,0 +1,117 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Une ligne d'`AuditLog` telle que la rend `GET /api/Audit`.
|
||||
///
|
||||
/// Le contrôleur renvoie les entités brutes (pas de DTO), donc les noms de champs
|
||||
/// sont ceux de `ManagerService.Data.AuditLog` sérialisés en camelCase.
|
||||
class AuditEntry {
|
||||
final String id;
|
||||
final String entityType;
|
||||
final String entityId;
|
||||
final String action;
|
||||
final String? userId;
|
||||
final String? instanceId;
|
||||
final DateTime timestamp;
|
||||
final String? oldValues;
|
||||
final String? newValues;
|
||||
|
||||
const AuditEntry({
|
||||
required this.id,
|
||||
required this.entityType,
|
||||
required this.entityId,
|
||||
required this.action,
|
||||
required this.userId,
|
||||
required this.instanceId,
|
||||
required this.timestamp,
|
||||
required this.oldValues,
|
||||
required this.newValues,
|
||||
});
|
||||
|
||||
factory AuditEntry.fromJson(Map<String, dynamic> json) => AuditEntry(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
entityType: (json['entityType'] ?? '').toString(),
|
||||
entityId: (json['entityId'] ?? '').toString(),
|
||||
action: (json['action'] ?? '').toString(),
|
||||
userId: json['userId'] as String?,
|
||||
instanceId: json['instanceId'] as String?,
|
||||
timestamp: DateTime.tryParse((json['timestamp'] ?? '').toString())?.toLocal() ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
oldValues: json['oldValues'] as String?,
|
||||
newValues: json['newValues'] as String?,
|
||||
);
|
||||
|
||||
/// Les colonnes réellement touchées, dans l'ordre alphabétique.
|
||||
///
|
||||
/// `OldValues` est nul sur une création et `NewValues` sur une suppression :
|
||||
/// l'union des deux clés est la seule façon d'obtenir la liste complète sans
|
||||
/// supposer laquelle des deux est remplie.
|
||||
List<AuditFieldChange> get changes {
|
||||
final before = _decode(oldValues);
|
||||
final after = _decode(newValues);
|
||||
final keys = {...before.keys, ...after.keys}.toList()..sort();
|
||||
return keys
|
||||
.map((k) => AuditFieldChange(k, _render(before[k]), _render(after[k])))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Un JSON illisible est traité comme absent : la ligne du journal reste
|
||||
/// consultable même si la sérialisation d'une colonne exotique a échoué côté serveur.
|
||||
static Map<String, dynamic> _decode(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return const {};
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
return decoded is Map<String, dynamic> ? decoded : const {};
|
||||
} catch (_) {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Les valeurs multilingues arrivent en listes d'objets `{language, value}` :
|
||||
/// les rendre en JSON brut donnerait une ligne illisible dans le détail.
|
||||
static String? _render(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is String) return value;
|
||||
if (value is List) {
|
||||
final translations = value
|
||||
.whereType<Map>()
|
||||
.where((m) => m.containsKey('language') && m.containsKey('value'))
|
||||
.map((m) => '${m['language']} : ${m['value']}')
|
||||
.toList();
|
||||
if (translations.isNotEmpty) return translations.join('\n');
|
||||
}
|
||||
return jsonEncode(value);
|
||||
}
|
||||
}
|
||||
|
||||
class AuditFieldChange {
|
||||
final String field;
|
||||
final String? before;
|
||||
final String? after;
|
||||
|
||||
const AuditFieldChange(this.field, this.before, this.after);
|
||||
}
|
||||
|
||||
class AuditPage {
|
||||
final int total;
|
||||
final int page;
|
||||
final int limit;
|
||||
final List<AuditEntry> items;
|
||||
|
||||
const AuditPage({
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.limit,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
factory AuditPage.fromJson(Map<String, dynamic> json) => AuditPage(
|
||||
total: (json['total'] as num?)?.toInt() ?? 0,
|
||||
page: (json['page'] as num?)?.toInt() ?? 1,
|
||||
limit: (json['limit'] as num?)?.toInt() ?? 50,
|
||||
items: ((json['items'] as List?) ?? const [])
|
||||
.map((e) => AuditEntry.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
int get pageCount => limit == 0 ? 1 : (total + limit - 1) ~/ limit;
|
||||
}
|
||||
510
lib/Screens/Audit/audit_screen.dart
Normal file
510
lib/Screens/Audit/audit_screen.dart
Normal file
@ -0,0 +1,510 @@
|
||||
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<AuditScreen> createState() => _AuditScreenState();
|
||||
}
|
||||
|
||||
class _AuditScreenState extends State<AuditScreen> {
|
||||
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<String, String> _instanceNames = {};
|
||||
Map<String, String> _userNames = {};
|
||||
|
||||
ManagerAppContext get _managerContext =>
|
||||
Provider.of<AppContext>(context, listen: false).getContext() as ManagerAppContext;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadDirectories();
|
||||
_load();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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<void> _load() async {
|
||||
final ctx = _managerContext;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_failed = false;
|
||||
});
|
||||
|
||||
final query = <String, String>{
|
||||
'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<String, dynamic>);
|
||||
_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<String>(
|
||||
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<String>(
|
||||
label: l.auditFilterEntity,
|
||||
value: _entityType,
|
||||
emptyLabel: l.auditAllEntities,
|
||||
entries: _kAuditEntityTypes.map((t) => MapEntry(t, _entityLabel(l, t))).toList(),
|
||||
onChanged: (v) => _applyFilter(() => _entityType = v),
|
||||
),
|
||||
_dropdown<String>(
|
||||
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<T>({
|
||||
required String label,
|
||||
required T? value,
|
||||
required String emptyLabel,
|
||||
required List<MapEntry<T, String>> entries,
|
||||
required ValueChanged<T?> 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<T?>(
|
||||
value: value,
|
||||
isExpanded: true,
|
||||
isDense: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||
style: kTextSmall,
|
||||
items: [
|
||||
DropdownMenuItem<T?>(value: null, child: Text(emptyLabel, style: kTextSmall)),
|
||||
for (final entry in entries)
|
||||
DropdownMenuItem<T?>(
|
||||
value: entry.key,
|
||||
child: Text(entry.value, style: kTextSmall, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dateField(String label, DateTime? value, ValueChanged<DateTime?> 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),
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,7 @@ import 'package:manager_app/Models/managerContext.dart';
|
||||
import 'package:manager_app/Models/menu.dart';
|
||||
import 'package:manager_app/Models/menuSection.dart';
|
||||
import 'package:manager_app/Screens/ApiKeys/api_keys_screen.dart';
|
||||
import 'package:manager_app/Screens/Audit/audit_screen.dart';
|
||||
import 'package:manager_app/Screens/Configurations/configurations_screen.dart';
|
||||
import 'package:manager_app/Screens/Kiosk_devices/kiosk_screen.dart';
|
||||
import 'package:manager_app/Screens/Resources/resources_screen.dart';
|
||||
@ -280,6 +281,7 @@ class _MainScreenState extends State<MainScreen> {
|
||||
case 'notifications': return l.menuNotifications;
|
||||
case 'users': return l.menuUsers;
|
||||
case 'apikeys': return l.menuApiKeys;
|
||||
case 'audit': return l.menuAudit;
|
||||
case 'subscription': return l.menuSubscription;
|
||||
default: return type;
|
||||
}
|
||||
@ -295,6 +297,7 @@ class _MainScreenState extends State<MainScreen> {
|
||||
case 'notifications': return Icons.notifications_none;
|
||||
case 'users': return Icons.people_outline;
|
||||
case 'apikeys': return Icons.vpn_key_outlined;
|
||||
case 'audit': return Icons.history;
|
||||
case 'subscription': return Icons.workspace_premium_outlined;
|
||||
default: return Icons.circle_outlined;
|
||||
}
|
||||
@ -533,6 +536,14 @@ class _MainScreenState extends State<MainScreen> {
|
||||
} else if ((role == null || role.value > 1) && hasAdminItems) {
|
||||
menu.sections!.removeWhere((s) => s.menuId == 8 || s.menuId == 9);
|
||||
}
|
||||
// Le journal d'activité couvre toutes les instances et expose les valeurs avant/après :
|
||||
// l'endpoint est sous policy SuperAdmin, l'entrée de menu suit la même règle.
|
||||
final hasAuditItem = menu.sections!.any((s) => s.menuId == 13);
|
||||
if (role != null && role.value == 0 && !hasAuditItem) {
|
||||
menu.sections!.add(MenuSection(name: "Activité", type: "audit", menuId: 13, subMenu: []));
|
||||
} else if ((role == null || role.value != 0) && hasAuditItem) {
|
||||
menu.sections!.removeWhere((s) => s.menuId == 13);
|
||||
}
|
||||
|
||||
Size size = MediaQuery.of(context).size;
|
||||
bool isMobile = size.width < 850;
|
||||
@ -623,6 +634,9 @@ class _MainScreenState extends State<MainScreen> {
|
||||
case "guide-ia":
|
||||
currentPosition = 12;
|
||||
break;
|
||||
case "audit":
|
||||
currentPosition = 13;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@ -697,6 +711,11 @@ class _MainScreenState extends State<MainScreen> {
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: ApiKeysScreen()
|
||||
);
|
||||
case 'audit':
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: AuditScreen()
|
||||
);
|
||||
case 'notifications':
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
|
||||
@ -65,8 +65,20 @@ class _UsersScreenState extends State<UsersScreen> {
|
||||
'lastName': lastName,
|
||||
'role': roleValue,
|
||||
};
|
||||
await ctx.clientAPI!.apiApi!.invokeAPI(
|
||||
final response = await ctx.clientAPI!.apiApi!.invokeAPI(
|
||||
'/api/User', 'POST', [], body, {}, {}, 'application/json');
|
||||
|
||||
// `invokeAPI` ne lève pas sur un code d'erreur : sans ce test, un e-mail déjà
|
||||
// utilisé (409) ou un rôle refusé (403) laissaient la liste inchangée sans
|
||||
// qu'aucun message n'explique pourquoi.
|
||||
if (response.statusCode != 200 && mounted) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(l.userCreateError(utf8.decode(response.bodyBytes))),
|
||||
backgroundColor: kError,
|
||||
));
|
||||
return;
|
||||
}
|
||||
await _loadUsers(ctx);
|
||||
}
|
||||
|
||||
@ -236,17 +248,41 @@ class _UsersScreenState extends State<UsersScreen> {
|
||||
final appContext = Provider.of<AppContext>(context);
|
||||
final managerCtx = appContext.getContext() as ManagerAppContext;
|
||||
|
||||
// Le SuperAdmin voit les utilisateurs de toutes les instances : compter cette
|
||||
// liste contre un plafond par instance n'aurait aucun sens, et le plafond ne
|
||||
// lui est de toute façon pas opposable.
|
||||
final isSuperAdmin = _roleToInt(managerCtx.role?.value) == 0;
|
||||
final quotaReached = !isSuperAdmin && _users.length >= kMaxUsersPerInstance;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(l.usersTitle, style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: kPrimaryColor)),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showCreateDialog(context, managerCtx),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(l.createUserBtn),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(l.usersTitle, style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: kPrimaryColor)),
|
||||
if (!_loading && !isSuperAdmin) ...[
|
||||
const SizedBox(width: kSpace4),
|
||||
Text(
|
||||
l.usersCount(_users.length, kMaxUsersPerInstance),
|
||||
style: kTextHint.copyWith(color: quotaReached ? kWarning : kInk3),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Tooltip(
|
||||
message: quotaReached
|
||||
? '${l.usersQuotaReached(kMaxUsersPerInstance)} — ${l.usersQuotaHint}'
|
||||
: '',
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: quotaReached ? null : () => _showCreateDialog(context, managerCtx),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(l.createUserBtn),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -166,6 +166,11 @@ const List<String> languages = ["FR", "NL", "EN", "DE", "IT", "ES", "CN", "PL",
|
||||
const List<String> map_types_mapBox = ["standard", "streets", "outdoors", "light", "dark", "satellite", "satellite_streets"];
|
||||
const List<String> map_providers = ["Google", "MapBox"];
|
||||
|
||||
/// Plafond d'utilisateurs par instance, SuperAdmin exclu.
|
||||
/// ⚠️ Contrôle d'interface uniquement : `UserController` ne compte rien côté serveur,
|
||||
/// un POST direct sur l'API passe toujours.
|
||||
const kMaxUsersPerInstance = 5;
|
||||
|
||||
const kTitleMaxLength = 165;
|
||||
const kDescriptionMaxLength = 2500;
|
||||
|
||||
|
||||
@ -706,5 +706,69 @@
|
||||
"saveStatusSaved": "Saved",
|
||||
"saveStatusFailed": "Could not save",
|
||||
"retry": "Retry",
|
||||
"mapProviderMobileOnlyNote": "The map provider and type apply to the mobile and tablet apps. The web app always renders its own map."
|
||||
"mapProviderMobileOnlyNote": "The map provider and type apply to the mobile and tablet apps. The web app always renders its own map.",
|
||||
"menuAudit": "Activity",
|
||||
"auditTitle": "Activity",
|
||||
"auditSubtitle": "Who changed what, and when",
|
||||
"auditFilterInstance": "Instance",
|
||||
"auditFilterEntity": "Item type",
|
||||
"auditFilterUser": "User",
|
||||
"auditFilterFrom": "From",
|
||||
"auditFilterTo": "To",
|
||||
"auditAllInstances": "All instances",
|
||||
"auditAllEntities": "All types",
|
||||
"auditAllUsers": "All users",
|
||||
"auditReset": "Clear filters",
|
||||
"auditColDate": "Date",
|
||||
"auditColInstance": "Instance",
|
||||
"auditColEntity": "Item",
|
||||
"auditColAction": "Action",
|
||||
"auditColUser": "User",
|
||||
"auditColEntityId": "Identifier",
|
||||
"auditActionCreate": "Created",
|
||||
"auditActionUpdate": "Updated",
|
||||
"auditActionDelete": "Deleted",
|
||||
"auditEntitySection": "Section",
|
||||
"auditEntityResource": "Resource",
|
||||
"auditEntityConfiguration": "Configuration",
|
||||
"auditEntityDevice": "Device",
|
||||
"auditEntityUser": "User",
|
||||
"auditEntityInstance": "Instance",
|
||||
"auditEmpty": "No activity for these filters",
|
||||
"auditLoadError": "The activity log could not be loaded",
|
||||
"auditRange": "{first} – {last} of {total}",
|
||||
"@auditRange": {
|
||||
"placeholders": {
|
||||
"first": { "type": "int" },
|
||||
"last": { "type": "int" },
|
||||
"total": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"auditPrevious": "Previous page",
|
||||
"auditNext": "Next page",
|
||||
"auditDetailTitle": "Change detail",
|
||||
"auditDetailField": "Field",
|
||||
"auditDetailBefore": "Before",
|
||||
"auditDetailAfter": "After",
|
||||
"auditDetailNoValues": "The log does not keep the detail of this operation.",
|
||||
"usersCount": "{count} / {max} users",
|
||||
"@usersCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"max": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"usersQuotaReached": "Limit of {max} users reached",
|
||||
"@usersQuotaReached": {
|
||||
"placeholders": {
|
||||
"max": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"usersQuotaHint": "Delete a user to invite another one.",
|
||||
"userCreateError": "Creation failed: {message}",
|
||||
"@userCreateError": {
|
||||
"placeholders": {
|
||||
"message": { "type": "String" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -706,5 +706,69 @@
|
||||
"saveStatusSaved": "Enregistré",
|
||||
"saveStatusFailed": "Échec de l'enregistrement",
|
||||
"retry": "Réessayer",
|
||||
"mapProviderMobileOnlyNote": "Le fournisseur et le type de carte s'appliquent aux applications mobile et tablette. L'application web affiche toujours sa propre carte."
|
||||
"mapProviderMobileOnlyNote": "Le fournisseur et le type de carte s'appliquent aux applications mobile et tablette. L'application web affiche toujours sa propre carte.",
|
||||
"menuAudit": "Activité",
|
||||
"auditTitle": "Activité",
|
||||
"auditSubtitle": "Qui a modifié quoi, et quand",
|
||||
"auditFilterInstance": "Instance",
|
||||
"auditFilterEntity": "Type d'élément",
|
||||
"auditFilterUser": "Utilisateur",
|
||||
"auditFilterFrom": "Depuis le",
|
||||
"auditFilterTo": "Jusqu'au",
|
||||
"auditAllInstances": "Toutes les instances",
|
||||
"auditAllEntities": "Tous les types",
|
||||
"auditAllUsers": "Tous les utilisateurs",
|
||||
"auditReset": "Réinitialiser les filtres",
|
||||
"auditColDate": "Date",
|
||||
"auditColInstance": "Instance",
|
||||
"auditColEntity": "Élément",
|
||||
"auditColAction": "Action",
|
||||
"auditColUser": "Utilisateur",
|
||||
"auditColEntityId": "Identifiant",
|
||||
"auditActionCreate": "Création",
|
||||
"auditActionUpdate": "Modification",
|
||||
"auditActionDelete": "Suppression",
|
||||
"auditEntitySection": "Section",
|
||||
"auditEntityResource": "Ressource",
|
||||
"auditEntityConfiguration": "Configuration",
|
||||
"auditEntityDevice": "Appareil",
|
||||
"auditEntityUser": "Utilisateur",
|
||||
"auditEntityInstance": "Instance",
|
||||
"auditEmpty": "Aucune activité pour ces filtres",
|
||||
"auditLoadError": "Le journal d'activité n'a pas pu être chargé",
|
||||
"auditRange": "{first} – {last} sur {total}",
|
||||
"@auditRange": {
|
||||
"placeholders": {
|
||||
"first": { "type": "int" },
|
||||
"last": { "type": "int" },
|
||||
"total": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"auditPrevious": "Page précédente",
|
||||
"auditNext": "Page suivante",
|
||||
"auditDetailTitle": "Détail de la modification",
|
||||
"auditDetailField": "Champ",
|
||||
"auditDetailBefore": "Avant",
|
||||
"auditDetailAfter": "Après",
|
||||
"auditDetailNoValues": "Le journal ne conserve pas le détail de cette opération.",
|
||||
"usersCount": "{count} / {max} utilisateurs",
|
||||
"@usersCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"max": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"usersQuotaReached": "Plafond de {max} utilisateurs atteint",
|
||||
"@usersQuotaReached": {
|
||||
"placeholders": {
|
||||
"max": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"usersQuotaHint": "Supprimez un utilisateur pour en inviter un autre.",
|
||||
"userCreateError": "La création a échoué : {message}",
|
||||
"@userCreateError": {
|
||||
"placeholders": {
|
||||
"message": { "type": "String" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3387,6 +3387,252 @@ abstract class AppLocalizations {
|
||||
/// In fr, this message translates to:
|
||||
/// **'Le fournisseur et le type de carte s\'appliquent aux applications mobile et tablette. L\'application web affiche toujours sa propre carte.'**
|
||||
String get mapProviderMobileOnlyNote;
|
||||
|
||||
/// No description provided for @menuAudit.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Activité'**
|
||||
String get menuAudit;
|
||||
|
||||
/// No description provided for @auditTitle.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Activité'**
|
||||
String get auditTitle;
|
||||
|
||||
/// No description provided for @auditSubtitle.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Qui a modifié quoi, et quand'**
|
||||
String get auditSubtitle;
|
||||
|
||||
/// No description provided for @auditFilterInstance.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Instance'**
|
||||
String get auditFilterInstance;
|
||||
|
||||
/// No description provided for @auditFilterEntity.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Type d\'élément'**
|
||||
String get auditFilterEntity;
|
||||
|
||||
/// No description provided for @auditFilterUser.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Utilisateur'**
|
||||
String get auditFilterUser;
|
||||
|
||||
/// No description provided for @auditFilterFrom.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Depuis le'**
|
||||
String get auditFilterFrom;
|
||||
|
||||
/// No description provided for @auditFilterTo.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Jusqu\'au'**
|
||||
String get auditFilterTo;
|
||||
|
||||
/// No description provided for @auditAllInstances.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Toutes les instances'**
|
||||
String get auditAllInstances;
|
||||
|
||||
/// No description provided for @auditAllEntities.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Tous les types'**
|
||||
String get auditAllEntities;
|
||||
|
||||
/// No description provided for @auditAllUsers.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Tous les utilisateurs'**
|
||||
String get auditAllUsers;
|
||||
|
||||
/// No description provided for @auditReset.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Réinitialiser les filtres'**
|
||||
String get auditReset;
|
||||
|
||||
/// No description provided for @auditColDate.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Date'**
|
||||
String get auditColDate;
|
||||
|
||||
/// No description provided for @auditColInstance.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Instance'**
|
||||
String get auditColInstance;
|
||||
|
||||
/// No description provided for @auditColEntity.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Élément'**
|
||||
String get auditColEntity;
|
||||
|
||||
/// No description provided for @auditColAction.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Action'**
|
||||
String get auditColAction;
|
||||
|
||||
/// No description provided for @auditColUser.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Utilisateur'**
|
||||
String get auditColUser;
|
||||
|
||||
/// No description provided for @auditColEntityId.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Identifiant'**
|
||||
String get auditColEntityId;
|
||||
|
||||
/// No description provided for @auditActionCreate.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Création'**
|
||||
String get auditActionCreate;
|
||||
|
||||
/// No description provided for @auditActionUpdate.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Modification'**
|
||||
String get auditActionUpdate;
|
||||
|
||||
/// No description provided for @auditActionDelete.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Suppression'**
|
||||
String get auditActionDelete;
|
||||
|
||||
/// No description provided for @auditEntitySection.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Section'**
|
||||
String get auditEntitySection;
|
||||
|
||||
/// No description provided for @auditEntityResource.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Ressource'**
|
||||
String get auditEntityResource;
|
||||
|
||||
/// No description provided for @auditEntityConfiguration.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Configuration'**
|
||||
String get auditEntityConfiguration;
|
||||
|
||||
/// No description provided for @auditEntityDevice.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Appareil'**
|
||||
String get auditEntityDevice;
|
||||
|
||||
/// No description provided for @auditEntityUser.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Utilisateur'**
|
||||
String get auditEntityUser;
|
||||
|
||||
/// No description provided for @auditEntityInstance.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Instance'**
|
||||
String get auditEntityInstance;
|
||||
|
||||
/// No description provided for @auditEmpty.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Aucune activité pour ces filtres'**
|
||||
String get auditEmpty;
|
||||
|
||||
/// No description provided for @auditLoadError.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Le journal d\'activité n\'a pas pu être chargé'**
|
||||
String get auditLoadError;
|
||||
|
||||
/// No description provided for @auditRange.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'{first} – {last} sur {total}'**
|
||||
String auditRange(int first, int last, int total);
|
||||
|
||||
/// No description provided for @auditPrevious.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Page précédente'**
|
||||
String get auditPrevious;
|
||||
|
||||
/// No description provided for @auditNext.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Page suivante'**
|
||||
String get auditNext;
|
||||
|
||||
/// No description provided for @auditDetailTitle.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Détail de la modification'**
|
||||
String get auditDetailTitle;
|
||||
|
||||
/// No description provided for @auditDetailField.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Champ'**
|
||||
String get auditDetailField;
|
||||
|
||||
/// No description provided for @auditDetailBefore.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Avant'**
|
||||
String get auditDetailBefore;
|
||||
|
||||
/// No description provided for @auditDetailAfter.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Après'**
|
||||
String get auditDetailAfter;
|
||||
|
||||
/// No description provided for @auditDetailNoValues.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Le journal ne conserve pas le détail de cette opération.'**
|
||||
String get auditDetailNoValues;
|
||||
|
||||
/// No description provided for @usersCount.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'{count} / {max} utilisateurs'**
|
||||
String usersCount(int count, int max);
|
||||
|
||||
/// No description provided for @usersQuotaReached.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Plafond de {max} utilisateurs atteint'**
|
||||
String usersQuotaReached(int max);
|
||||
|
||||
/// No description provided for @usersQuotaHint.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Supprimez un utilisateur pour en inviter un autre.'**
|
||||
String get usersQuotaHint;
|
||||
|
||||
/// No description provided for @userCreateError.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'La création a échoué : {message}'**
|
||||
String userCreateError(String message);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@ -1794,4 +1794,136 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get mapProviderMobileOnlyNote =>
|
||||
'The map provider and type apply to the mobile and tablet apps. The web app always renders its own map.';
|
||||
|
||||
@override
|
||||
String get menuAudit => 'Activity';
|
||||
|
||||
@override
|
||||
String get auditTitle => 'Activity';
|
||||
|
||||
@override
|
||||
String get auditSubtitle => 'Who changed what, and when';
|
||||
|
||||
@override
|
||||
String get auditFilterInstance => 'Instance';
|
||||
|
||||
@override
|
||||
String get auditFilterEntity => 'Item type';
|
||||
|
||||
@override
|
||||
String get auditFilterUser => 'User';
|
||||
|
||||
@override
|
||||
String get auditFilterFrom => 'From';
|
||||
|
||||
@override
|
||||
String get auditFilterTo => 'To';
|
||||
|
||||
@override
|
||||
String get auditAllInstances => 'All instances';
|
||||
|
||||
@override
|
||||
String get auditAllEntities => 'All types';
|
||||
|
||||
@override
|
||||
String get auditAllUsers => 'All users';
|
||||
|
||||
@override
|
||||
String get auditReset => 'Clear filters';
|
||||
|
||||
@override
|
||||
String get auditColDate => 'Date';
|
||||
|
||||
@override
|
||||
String get auditColInstance => 'Instance';
|
||||
|
||||
@override
|
||||
String get auditColEntity => 'Item';
|
||||
|
||||
@override
|
||||
String get auditColAction => 'Action';
|
||||
|
||||
@override
|
||||
String get auditColUser => 'User';
|
||||
|
||||
@override
|
||||
String get auditColEntityId => 'Identifier';
|
||||
|
||||
@override
|
||||
String get auditActionCreate => 'Created';
|
||||
|
||||
@override
|
||||
String get auditActionUpdate => 'Updated';
|
||||
|
||||
@override
|
||||
String get auditActionDelete => 'Deleted';
|
||||
|
||||
@override
|
||||
String get auditEntitySection => 'Section';
|
||||
|
||||
@override
|
||||
String get auditEntityResource => 'Resource';
|
||||
|
||||
@override
|
||||
String get auditEntityConfiguration => 'Configuration';
|
||||
|
||||
@override
|
||||
String get auditEntityDevice => 'Device';
|
||||
|
||||
@override
|
||||
String get auditEntityUser => 'User';
|
||||
|
||||
@override
|
||||
String get auditEntityInstance => 'Instance';
|
||||
|
||||
@override
|
||||
String get auditEmpty => 'No activity for these filters';
|
||||
|
||||
@override
|
||||
String get auditLoadError => 'The activity log could not be loaded';
|
||||
|
||||
@override
|
||||
String auditRange(int first, int last, int total) {
|
||||
return '$first – $last of $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get auditPrevious => 'Previous page';
|
||||
|
||||
@override
|
||||
String get auditNext => 'Next page';
|
||||
|
||||
@override
|
||||
String get auditDetailTitle => 'Change detail';
|
||||
|
||||
@override
|
||||
String get auditDetailField => 'Field';
|
||||
|
||||
@override
|
||||
String get auditDetailBefore => 'Before';
|
||||
|
||||
@override
|
||||
String get auditDetailAfter => 'After';
|
||||
|
||||
@override
|
||||
String get auditDetailNoValues =>
|
||||
'The log does not keep the detail of this operation.';
|
||||
|
||||
@override
|
||||
String usersCount(int count, int max) {
|
||||
return '$count / $max users';
|
||||
}
|
||||
|
||||
@override
|
||||
String usersQuotaReached(int max) {
|
||||
return 'Limit of $max users reached';
|
||||
}
|
||||
|
||||
@override
|
||||
String get usersQuotaHint => 'Delete a user to invite another one.';
|
||||
|
||||
@override
|
||||
String userCreateError(String message) {
|
||||
return 'Creation failed: $message';
|
||||
}
|
||||
}
|
||||
|
||||
@ -1832,4 +1832,137 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get mapProviderMobileOnlyNote =>
|
||||
'Le fournisseur et le type de carte s\'appliquent aux applications mobile et tablette. L\'application web affiche toujours sa propre carte.';
|
||||
|
||||
@override
|
||||
String get menuAudit => 'Activité';
|
||||
|
||||
@override
|
||||
String get auditTitle => 'Activité';
|
||||
|
||||
@override
|
||||
String get auditSubtitle => 'Qui a modifié quoi, et quand';
|
||||
|
||||
@override
|
||||
String get auditFilterInstance => 'Instance';
|
||||
|
||||
@override
|
||||
String get auditFilterEntity => 'Type d\'élément';
|
||||
|
||||
@override
|
||||
String get auditFilterUser => 'Utilisateur';
|
||||
|
||||
@override
|
||||
String get auditFilterFrom => 'Depuis le';
|
||||
|
||||
@override
|
||||
String get auditFilterTo => 'Jusqu\'au';
|
||||
|
||||
@override
|
||||
String get auditAllInstances => 'Toutes les instances';
|
||||
|
||||
@override
|
||||
String get auditAllEntities => 'Tous les types';
|
||||
|
||||
@override
|
||||
String get auditAllUsers => 'Tous les utilisateurs';
|
||||
|
||||
@override
|
||||
String get auditReset => 'Réinitialiser les filtres';
|
||||
|
||||
@override
|
||||
String get auditColDate => 'Date';
|
||||
|
||||
@override
|
||||
String get auditColInstance => 'Instance';
|
||||
|
||||
@override
|
||||
String get auditColEntity => 'Élément';
|
||||
|
||||
@override
|
||||
String get auditColAction => 'Action';
|
||||
|
||||
@override
|
||||
String get auditColUser => 'Utilisateur';
|
||||
|
||||
@override
|
||||
String get auditColEntityId => 'Identifiant';
|
||||
|
||||
@override
|
||||
String get auditActionCreate => 'Création';
|
||||
|
||||
@override
|
||||
String get auditActionUpdate => 'Modification';
|
||||
|
||||
@override
|
||||
String get auditActionDelete => 'Suppression';
|
||||
|
||||
@override
|
||||
String get auditEntitySection => 'Section';
|
||||
|
||||
@override
|
||||
String get auditEntityResource => 'Ressource';
|
||||
|
||||
@override
|
||||
String get auditEntityConfiguration => 'Configuration';
|
||||
|
||||
@override
|
||||
String get auditEntityDevice => 'Appareil';
|
||||
|
||||
@override
|
||||
String get auditEntityUser => 'Utilisateur';
|
||||
|
||||
@override
|
||||
String get auditEntityInstance => 'Instance';
|
||||
|
||||
@override
|
||||
String get auditEmpty => 'Aucune activité pour ces filtres';
|
||||
|
||||
@override
|
||||
String get auditLoadError => 'Le journal d\'activité n\'a pas pu être chargé';
|
||||
|
||||
@override
|
||||
String auditRange(int first, int last, int total) {
|
||||
return '$first – $last sur $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get auditPrevious => 'Page précédente';
|
||||
|
||||
@override
|
||||
String get auditNext => 'Page suivante';
|
||||
|
||||
@override
|
||||
String get auditDetailTitle => 'Détail de la modification';
|
||||
|
||||
@override
|
||||
String get auditDetailField => 'Champ';
|
||||
|
||||
@override
|
||||
String get auditDetailBefore => 'Avant';
|
||||
|
||||
@override
|
||||
String get auditDetailAfter => 'Après';
|
||||
|
||||
@override
|
||||
String get auditDetailNoValues =>
|
||||
'Le journal ne conserve pas le détail de cette opération.';
|
||||
|
||||
@override
|
||||
String usersCount(int count, int max) {
|
||||
return '$count / $max utilisateurs';
|
||||
}
|
||||
|
||||
@override
|
||||
String usersQuotaReached(int max) {
|
||||
return 'Plafond de $max utilisateurs atteint';
|
||||
}
|
||||
|
||||
@override
|
||||
String get usersQuotaHint =>
|
||||
'Supprimez un utilisateur pour en inviter un autre.';
|
||||
|
||||
@override
|
||||
String userCreateError(String message) {
|
||||
return 'La création a échoué : $message';
|
||||
}
|
||||
}
|
||||
|
||||
@ -1812,4 +1812,138 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get mapProviderMobileOnlyNote =>
|
||||
'De kaartprovider en het kaarttype gelden voor de mobiele en tablet-apps. De web-app toont altijd zijn eigen kaart.';
|
||||
|
||||
@override
|
||||
String get menuAudit => 'Activiteit';
|
||||
|
||||
@override
|
||||
String get auditTitle => 'Activiteit';
|
||||
|
||||
@override
|
||||
String get auditSubtitle => 'Wie heeft wat gewijzigd, en wanneer';
|
||||
|
||||
@override
|
||||
String get auditFilterInstance => 'Instantie';
|
||||
|
||||
@override
|
||||
String get auditFilterEntity => 'Type element';
|
||||
|
||||
@override
|
||||
String get auditFilterUser => 'Gebruiker';
|
||||
|
||||
@override
|
||||
String get auditFilterFrom => 'Vanaf';
|
||||
|
||||
@override
|
||||
String get auditFilterTo => 'Tot';
|
||||
|
||||
@override
|
||||
String get auditAllInstances => 'Alle instanties';
|
||||
|
||||
@override
|
||||
String get auditAllEntities => 'Alle types';
|
||||
|
||||
@override
|
||||
String get auditAllUsers => 'Alle gebruikers';
|
||||
|
||||
@override
|
||||
String get auditReset => 'Filters wissen';
|
||||
|
||||
@override
|
||||
String get auditColDate => 'Datum';
|
||||
|
||||
@override
|
||||
String get auditColInstance => 'Instantie';
|
||||
|
||||
@override
|
||||
String get auditColEntity => 'Element';
|
||||
|
||||
@override
|
||||
String get auditColAction => 'Actie';
|
||||
|
||||
@override
|
||||
String get auditColUser => 'Gebruiker';
|
||||
|
||||
@override
|
||||
String get auditColEntityId => 'Identificatie';
|
||||
|
||||
@override
|
||||
String get auditActionCreate => 'Aangemaakt';
|
||||
|
||||
@override
|
||||
String get auditActionUpdate => 'Gewijzigd';
|
||||
|
||||
@override
|
||||
String get auditActionDelete => 'Verwijderd';
|
||||
|
||||
@override
|
||||
String get auditEntitySection => 'Sectie';
|
||||
|
||||
@override
|
||||
String get auditEntityResource => 'Bron';
|
||||
|
||||
@override
|
||||
String get auditEntityConfiguration => 'Configuratie';
|
||||
|
||||
@override
|
||||
String get auditEntityDevice => 'Toestel';
|
||||
|
||||
@override
|
||||
String get auditEntityUser => 'Gebruiker';
|
||||
|
||||
@override
|
||||
String get auditEntityInstance => 'Instantie';
|
||||
|
||||
@override
|
||||
String get auditEmpty => 'Geen activiteit voor deze filters';
|
||||
|
||||
@override
|
||||
String get auditLoadError =>
|
||||
'Het activiteitenlogboek kon niet worden geladen';
|
||||
|
||||
@override
|
||||
String auditRange(int first, int last, int total) {
|
||||
return '$first – $last van $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get auditPrevious => 'Vorige pagina';
|
||||
|
||||
@override
|
||||
String get auditNext => 'Volgende pagina';
|
||||
|
||||
@override
|
||||
String get auditDetailTitle => 'Detail van de wijziging';
|
||||
|
||||
@override
|
||||
String get auditDetailField => 'Veld';
|
||||
|
||||
@override
|
||||
String get auditDetailBefore => 'Voor';
|
||||
|
||||
@override
|
||||
String get auditDetailAfter => 'Na';
|
||||
|
||||
@override
|
||||
String get auditDetailNoValues =>
|
||||
'Het logboek bewaart het detail van deze bewerking niet.';
|
||||
|
||||
@override
|
||||
String usersCount(int count, int max) {
|
||||
return '$count / $max gebruikers';
|
||||
}
|
||||
|
||||
@override
|
||||
String usersQuotaReached(int max) {
|
||||
return 'Limiet van $max gebruikers bereikt';
|
||||
}
|
||||
|
||||
@override
|
||||
String get usersQuotaHint =>
|
||||
'Verwijder een gebruiker om er een andere uit te nodigen.';
|
||||
|
||||
@override
|
||||
String userCreateError(String message) {
|
||||
return 'Aanmaken mislukt: $message';
|
||||
}
|
||||
}
|
||||
|
||||
@ -706,5 +706,69 @@
|
||||
"saveStatusSaved": "Opgeslagen",
|
||||
"saveStatusFailed": "Opslaan mislukt",
|
||||
"retry": "Opnieuw proberen",
|
||||
"mapProviderMobileOnlyNote": "De kaartprovider en het kaarttype gelden voor de mobiele en tablet-apps. De web-app toont altijd zijn eigen kaart."
|
||||
"mapProviderMobileOnlyNote": "De kaartprovider en het kaarttype gelden voor de mobiele en tablet-apps. De web-app toont altijd zijn eigen kaart.",
|
||||
"menuAudit": "Activiteit",
|
||||
"auditTitle": "Activiteit",
|
||||
"auditSubtitle": "Wie heeft wat gewijzigd, en wanneer",
|
||||
"auditFilterInstance": "Instantie",
|
||||
"auditFilterEntity": "Type element",
|
||||
"auditFilterUser": "Gebruiker",
|
||||
"auditFilterFrom": "Vanaf",
|
||||
"auditFilterTo": "Tot",
|
||||
"auditAllInstances": "Alle instanties",
|
||||
"auditAllEntities": "Alle types",
|
||||
"auditAllUsers": "Alle gebruikers",
|
||||
"auditReset": "Filters wissen",
|
||||
"auditColDate": "Datum",
|
||||
"auditColInstance": "Instantie",
|
||||
"auditColEntity": "Element",
|
||||
"auditColAction": "Actie",
|
||||
"auditColUser": "Gebruiker",
|
||||
"auditColEntityId": "Identificatie",
|
||||
"auditActionCreate": "Aangemaakt",
|
||||
"auditActionUpdate": "Gewijzigd",
|
||||
"auditActionDelete": "Verwijderd",
|
||||
"auditEntitySection": "Sectie",
|
||||
"auditEntityResource": "Bron",
|
||||
"auditEntityConfiguration": "Configuratie",
|
||||
"auditEntityDevice": "Toestel",
|
||||
"auditEntityUser": "Gebruiker",
|
||||
"auditEntityInstance": "Instantie",
|
||||
"auditEmpty": "Geen activiteit voor deze filters",
|
||||
"auditLoadError": "Het activiteitenlogboek kon niet worden geladen",
|
||||
"auditRange": "{first} – {last} van {total}",
|
||||
"@auditRange": {
|
||||
"placeholders": {
|
||||
"first": { "type": "int" },
|
||||
"last": { "type": "int" },
|
||||
"total": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"auditPrevious": "Vorige pagina",
|
||||
"auditNext": "Volgende pagina",
|
||||
"auditDetailTitle": "Detail van de wijziging",
|
||||
"auditDetailField": "Veld",
|
||||
"auditDetailBefore": "Voor",
|
||||
"auditDetailAfter": "Na",
|
||||
"auditDetailNoValues": "Het logboek bewaart het detail van deze bewerking niet.",
|
||||
"usersCount": "{count} / {max} gebruikers",
|
||||
"@usersCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" },
|
||||
"max": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"usersQuotaReached": "Limiet van {max} gebruikers bereikt",
|
||||
"@usersQuotaReached": {
|
||||
"placeholders": {
|
||||
"max": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"usersQuotaHint": "Verwijder een gebruiker om er een andere uit te nodigen.",
|
||||
"userCreateError": "Aanmaken mislukt: {message}",
|
||||
"@userCreateError": {
|
||||
"placeholders": {
|
||||
"message": { "type": "String" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user