576 lines
20 KiB
Dart
576 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../../../core/constants/enums.dart';
|
||
import '../../../../core/errors/failure.dart';
|
||
import '../../../../core/utils/formatters.dart';
|
||
import '../../../../core/utils/responsive_utils.dart';
|
||
import '../../../../shared/models/audit_log_model.dart';
|
||
import '../../../../shared/providers/permissions_provider.dart';
|
||
import '../../../../shared/utils/file_download_helper.dart';
|
||
import '../../../../shared/widgets/app_card.dart';
|
||
import '../../../../shared/widgets/app_data_table.dart';
|
||
import '../../../../shared/widgets/app_date_range_popup.dart';
|
||
import '../../../../shared/widgets/app_dropdown.dart';
|
||
import '../../../../shared/widgets/app_empty_state.dart';
|
||
import '../../../../shared/widgets/app_filter_date_field.dart';
|
||
import '../../../../shared/widgets/app_loading_view.dart';
|
||
import '../../../../shared/widgets/app_pagination.dart';
|
||
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
|
||
import '../../../../shared/widgets/app_search_field.dart';
|
||
import '../../../../shared/widgets/app_search_filter_toggle.dart';
|
||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||
import '../../../../shared/widgets/app_side_panel.dart';
|
||
import '../../../../shared/widgets/app_status_chip.dart';
|
||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||
import '../../../../shared/widgets/app_table_column_selector.dart';
|
||
import '../../../../shared/widgets/app_table_shell.dart';
|
||
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||
import '../../../../shared/widgets/error_view.dart';
|
||
import '../../../../shared/widgets/page_header.dart';
|
||
import '../providers/audit_provider.dart';
|
||
import '../widgets/audit_log_detail_panel.dart';
|
||
import '../../../../shared/widgets/app_toast.dart';
|
||
|
||
class AuditLogsScreen extends ConsumerStatefulWidget {
|
||
const AuditLogsScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<AuditLogsScreen> createState() => _AuditLogsScreenState();
|
||
}
|
||
|
||
class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
|
||
final _searchController = TextEditingController();
|
||
bool _filtersExpanded = false;
|
||
|
||
@override
|
||
void dispose() {
|
||
_searchController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _exportLogs() async {
|
||
final file = await ref.read(auditLogsListProvider.notifier).exportLogs();
|
||
if (!mounted) return;
|
||
|
||
if (file == null) {
|
||
final error = ref.read(auditLogsListProvider).valueOrNull?.actionError;
|
||
if (error != null) {
|
||
showAppToastFromSnackBar(context, SnackBar(content: Text(error)));
|
||
}
|
||
return;
|
||
}
|
||
|
||
final saved = await downloadFile(
|
||
bytes: file.bytes,
|
||
fileName: file.fileName,
|
||
);
|
||
if (!mounted) return;
|
||
|
||
showAppToastFromSnackBar(context,
|
||
SnackBar(
|
||
content: Text(saved ? 'Downloaded ${file.fileName}' : 'Export cancelled'),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _viewLog(AuditLogEntryModel log) async {
|
||
ref.invalidate(auditLogDetailProvider(log.id));
|
||
await showSidePanel(
|
||
context,
|
||
AuditLogDetailPanel(logId: log.id),
|
||
width: 560,
|
||
);
|
||
}
|
||
|
||
Future<void> _pickDateRange(AuditLogListQuery query) async {
|
||
final now = DateTime.now();
|
||
final initial = (query.dateFrom != null && query.dateTo != null)
|
||
? DateTimeRange(start: query.dateFrom!, end: query.dateTo!)
|
||
: null;
|
||
|
||
final picked = await showAppDateRangePopup(
|
||
context: context,
|
||
firstDate: DateTime(now.year - 5),
|
||
lastDate: DateTime(now.year + 1),
|
||
initialDateRange: initial,
|
||
helpText: 'Filter by date range',
|
||
);
|
||
if (picked == null) return;
|
||
|
||
final start = DateTime(picked.start.year, picked.start.month, picked.start.day);
|
||
final end = DateTime(
|
||
picked.end.year,
|
||
picked.end.month,
|
||
picked.end.day,
|
||
23,
|
||
59,
|
||
59,
|
||
);
|
||
ref.read(auditLogsListProvider.notifier).setDateRange(start, end);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final logsAsync = ref.watch(auditLogsListProvider);
|
||
final canExport = ref.can('audit_logs', PermissionAction.export);
|
||
|
||
ref.listen(auditLogsListProvider, (prev, next) {
|
||
final error = next.valueOrNull?.actionError;
|
||
if (error != null && error != prev?.valueOrNull?.actionError) {
|
||
showAppToastFromSnackBar(context, SnackBar(content: Text(error)));
|
||
}
|
||
});
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: logsAsync.when(
|
||
loading: () => const AppLoadingView(message: 'Loading audit logs...'),
|
||
error: (error, _) => ErrorView.fromFailure(
|
||
error is Failure ? error : Failure.unknown(message: error.toString()),
|
||
onRetry: () => ref.invalidate(auditLogsListProvider),
|
||
),
|
||
data: (state) {
|
||
final notifier = ref.read(auditLogsListProvider.notifier);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
PageHeader(
|
||
title: 'Audit Logs',
|
||
subtitle: 'System activity and change history',
|
||
actions: [
|
||
AppSearchFilterButton(
|
||
expanded: _filtersExpanded,
|
||
onPressed: () => setState(
|
||
() => _filtersExpanded = !_filtersExpanded,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
AppTableColumnSelectorButton(
|
||
tableId: _AuditDataTable.tableId,
|
||
columns: _AuditDataTable.columnOptions,
|
||
),
|
||
if (canExport) ...[
|
||
const SizedBox(width: 8),
|
||
OutlinedButton.icon(
|
||
onPressed: state.isExporting || !state.query.hasActiveFilter
|
||
? null
|
||
: _exportLogs,
|
||
icon: state.isExporting
|
||
? const SizedBox(
|
||
width: 18,
|
||
height: 18,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.download_outlined),
|
||
label: Text(state.isExporting ? 'Exporting...' : 'Export'),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
Expanded(
|
||
child: AppTableShell(
|
||
toolbarExpanded: _filtersExpanded,
|
||
toolbar: _FiltersBar(
|
||
searchController: _searchController,
|
||
filters: state.filters,
|
||
query: state.query,
|
||
onSearch: notifier.setSearch,
|
||
onTableChanged: notifier.setTableName,
|
||
onActionChanged: notifier.setAction,
|
||
onPerformerChanged: notifier.setPerformedBy,
|
||
onPickDateRange: () => _pickDateRange(state.query),
|
||
onClearDateRange: () => notifier.setDateRange(null, null),
|
||
onReset: () {
|
||
_searchController.clear();
|
||
notifier.resetFilters();
|
||
},
|
||
),
|
||
footer: AppPagination(
|
||
currentPage: state.query.page,
|
||
totalPages: state.totalPages,
|
||
totalItems: state.total,
|
||
pageSize: state.query.limit,
|
||
itemsOnPage: state.items.length,
|
||
itemLabel: 'audit logs',
|
||
onPageChanged: notifier.setPage,
|
||
onPageSizeChanged: notifier.setPageSize,
|
||
),
|
||
child: RefreshIndicator(
|
||
onRefresh: notifier.refresh,
|
||
child: state.filtersRequired && state.items.isEmpty
|
||
? ListView(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
children: const [
|
||
SizedBox(
|
||
height: 260,
|
||
child: AppEmptyState(
|
||
title: 'Apply a filter to view logs',
|
||
description:
|
||
'Select a table, action, user, or date range to load audit history.',
|
||
icon: Icons.filter_alt_outlined,
|
||
),
|
||
),
|
||
],
|
||
)
|
||
: context.isMobile
|
||
? (state.items.isEmpty
|
||
? ListView(
|
||
physics:
|
||
const AlwaysScrollableScrollPhysics(),
|
||
children: const [
|
||
SizedBox(
|
||
height: 260,
|
||
child: AppEmptyState(
|
||
title: 'No audit logs found',
|
||
description:
|
||
'Try adjusting filters or expanding the date range.',
|
||
icon: Icons.history_outlined,
|
||
),
|
||
),
|
||
],
|
||
)
|
||
: _AuditCardList(
|
||
items: state.items,
|
||
onView: _viewLog,
|
||
))
|
||
: _AuditDataTable(
|
||
items: state.items,
|
||
onView: _viewLog,
|
||
onEnsureFullDataset: () =>
|
||
notifier.ensureColumnSearchDataset(),
|
||
onColumnSearchCleared: () =>
|
||
notifier.clearColumnSearchDataset(),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _FiltersBar extends StatelessWidget {
|
||
const _FiltersBar({
|
||
required this.searchController,
|
||
required this.filters,
|
||
required this.query,
|
||
required this.onSearch,
|
||
required this.onTableChanged,
|
||
required this.onActionChanged,
|
||
required this.onPerformerChanged,
|
||
required this.onPickDateRange,
|
||
required this.onClearDateRange,
|
||
required this.onReset,
|
||
});
|
||
|
||
final TextEditingController searchController;
|
||
final AuditLogFilterOptions filters;
|
||
final AuditLogListQuery query;
|
||
final ValueChanged<String> onSearch;
|
||
final ValueChanged<String?> onTableChanged;
|
||
final ValueChanged<String?> onActionChanged;
|
||
final ValueChanged<int?> onPerformerChanged;
|
||
final VoidCallback onPickDateRange;
|
||
final VoidCallback onClearDateRange;
|
||
final VoidCallback onReset;
|
||
|
||
String get _dateValue {
|
||
if (query.dateFrom == null && query.dateTo == null) return '';
|
||
final from = DateFormatter.displayDate(query.dateFrom);
|
||
final to = DateFormatter.displayDate(query.dateTo);
|
||
return '$from – $to';
|
||
}
|
||
|
||
bool get _dateEmpty => query.dateFrom == null && query.dateTo == null;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final searchField = AppSearchField(
|
||
controller: searchController,
|
||
hint: 'Search table, action, request ID...',
|
||
onChanged: onSearch,
|
||
onClear: () => onSearch(''),
|
||
);
|
||
|
||
final tableDropdown = AppSearchableDropdown<String?>(
|
||
label: 'Table',
|
||
value: query.tableName,
|
||
searchHint: 'Search table...',
|
||
isDense: true,
|
||
options: [
|
||
const AppDropdownOption(value: null, label: 'All Tables'),
|
||
...filters.tableNames.map(
|
||
(name) => AppDropdownOption(
|
||
value: name,
|
||
label: humanizeLabel(name),
|
||
),
|
||
),
|
||
],
|
||
onChanged: onTableChanged,
|
||
);
|
||
|
||
final actionDropdown = AppSearchableDropdown<String?>(
|
||
label: 'Action',
|
||
value: query.action,
|
||
searchHint: 'Search action...',
|
||
isDense: true,
|
||
options: [
|
||
const AppDropdownOption(value: null, label: 'All Actions'),
|
||
...filters.actions.map(
|
||
(action) => AppDropdownOption(value: action, label: action),
|
||
),
|
||
],
|
||
onChanged: onActionChanged,
|
||
);
|
||
|
||
final performerDropdown = AppSearchableDropdown<int?>(
|
||
label: 'Performed By',
|
||
value: query.performedBy,
|
||
searchHint: 'Search user...',
|
||
isDense: true,
|
||
options: [
|
||
const AppDropdownOption(value: null, label: 'All Users'),
|
||
...filters.performers.map(
|
||
(user) => AppDropdownOption(
|
||
value: int.tryParse(user.id),
|
||
label: user.label,
|
||
),
|
||
),
|
||
],
|
||
onChanged: onPerformerChanged,
|
||
);
|
||
|
||
final dateField = AppFilterDateField(
|
||
label: 'Date Range',
|
||
value: _dateValue,
|
||
placeholder: 'Select range',
|
||
icon: Icons.date_range_outlined,
|
||
isEmpty: _dateEmpty,
|
||
onTap: onPickDateRange,
|
||
onClear: _dateEmpty ? null : onClearDateRange,
|
||
);
|
||
|
||
final theme = Theme.of(context);
|
||
final iconColor = theme.colorScheme.secondary;
|
||
final resetButton = IconButton(
|
||
tooltip: 'Reset',
|
||
color: iconColor,
|
||
disabledColor: iconColor.withValues(alpha: 0.38),
|
||
onPressed: query.hasActiveFilter ? onReset : null,
|
||
icon: const Icon(Icons.restart_alt, size: 20),
|
||
);
|
||
|
||
return AppResponsiveFilterGrid(
|
||
fields: [
|
||
searchField,
|
||
tableDropdown,
|
||
actionDropdown,
|
||
performerDropdown,
|
||
dateField,
|
||
],
|
||
trailing: resetButton,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _AuditDataTable extends ConsumerWidget {
|
||
const _AuditDataTable({
|
||
required this.items,
|
||
required this.onView,
|
||
this.onEnsureFullDataset,
|
||
this.onColumnSearchCleared,
|
||
});
|
||
|
||
static const tableId = 'audit_logs';
|
||
|
||
static List<AppTableColumnOption> get columnOptions => const [
|
||
AppTableColumnOption(id: 'when', label: 'When', required: true),
|
||
AppTableColumnOption(id: 'action', label: 'Action'),
|
||
AppTableColumnOption(id: 'table', label: 'Table'),
|
||
AppTableColumnOption(id: 'record', label: 'Record'),
|
||
AppTableColumnOption(id: 'performed_by', label: 'Performed By'),
|
||
AppTableColumnOption(id: 'request_id', label: 'Request ID'),
|
||
AppTableColumnOption(id: 'changes', label: 'Changes'),
|
||
];
|
||
|
||
final List<AuditLogEntryModel> items;
|
||
final void Function(AuditLogEntryModel log) onView;
|
||
final Future<void> Function()? onEnsureFullDataset;
|
||
final VoidCallback? onColumnSearchCleared;
|
||
|
||
static String _changesText(AuditLogEntryModel row) {
|
||
final parts = <String>[];
|
||
if (row.hasOldValue) parts.add('old');
|
||
if (row.hasNewValue) parts.add('new');
|
||
return parts.isEmpty ? '' : parts.join(' / ');
|
||
}
|
||
|
||
List<AppDataColumn<AuditLogEntryModel>> _allColumns() {
|
||
return [
|
||
AppDataColumn(
|
||
id: 'when',
|
||
label: 'When',
|
||
sortKey: 'when',
|
||
locked: true,
|
||
flex: 2,
|
||
searchText: (row) => DateFormatter.searchableDate(row.performedAt),
|
||
sortValue: (row) => row.performedAt,
|
||
cellBuilder: (_, row) => AppTableCell.text(
|
||
DateFormatter.displayDateTime(row.performedAt),
|
||
),
|
||
),
|
||
AppDataColumn(
|
||
id: 'action',
|
||
label: 'Action',
|
||
sortKey: 'action',
|
||
flex: 1,
|
||
searchText: (row) => row.action,
|
||
cellBuilder: (_, row) => AppTableCell.child(
|
||
AppStatusChip(
|
||
status: row.action,
|
||
compact: true,
|
||
forTable: true,
|
||
),
|
||
),
|
||
),
|
||
AppDataColumn(
|
||
id: 'table',
|
||
label: 'Table',
|
||
sortKey: 'table',
|
||
flex: 2,
|
||
searchText: (row) => humanizeLabel(row.tableName),
|
||
cellBuilder: (_, row) =>
|
||
AppTableCell.text(humanizeLabel(row.tableName)),
|
||
),
|
||
AppDataColumn(
|
||
id: 'record',
|
||
label: 'Record',
|
||
sortKey: 'record',
|
||
flex: 1,
|
||
searchText: (row) => row.recordId ?? '',
|
||
cellBuilder: (_, row) => AppTableCell.text(row.recordId),
|
||
),
|
||
AppDataColumn(
|
||
id: 'performed_by',
|
||
label: 'Performed By',
|
||
sortKey: 'performed_by',
|
||
flex: 2,
|
||
searchText: (row) => row.performerLabel,
|
||
cellBuilder: (_, row) => AppTableCell.text(row.performerLabel),
|
||
),
|
||
AppDataColumn(
|
||
id: 'request_id',
|
||
label: 'Request ID',
|
||
sortKey: 'request_id',
|
||
flex: 2,
|
||
searchText: (row) => row.requestId ?? '',
|
||
cellBuilder: (_, row) => AppTableCell.text(row.requestId),
|
||
),
|
||
AppDataColumn(
|
||
id: 'changes',
|
||
label: 'Changes',
|
||
sortKey: 'changes',
|
||
flex: 1,
|
||
searchText: _changesText,
|
||
cellBuilder: (_, row) {
|
||
final text = _changesText(row);
|
||
return AppTableCell.text(text.isEmpty ? '—' : text);
|
||
},
|
||
),
|
||
AppDataColumn(
|
||
id: 'actions',
|
||
label: 'Actions',
|
||
flex: 1,
|
||
alignment: Alignment.centerRight,
|
||
enableSearch: false,
|
||
locked: true,
|
||
includeInColumnSelector: false,
|
||
cellBuilder: (_, row) => AppTableActions(
|
||
children: [
|
||
AppTableActionIcon(
|
||
tooltip: 'View',
|
||
icon: Icons.visibility_outlined,
|
||
onPressed: () => onView(row),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
];
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
|
||
final columns = resolveAppDataColumns(_allColumns(), prefs);
|
||
|
||
return AppDataTable<AuditLogEntryModel>(
|
||
wrapInCard: false,
|
||
rows: items,
|
||
emptyMessage: 'No audit logs found',
|
||
onEnsureFullDataset: onEnsureFullDataset,
|
||
onColumnSearchCleared: onColumnSearchCleared,
|
||
columns: columns,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _AuditCardList extends StatelessWidget {
|
||
const _AuditCardList({
|
||
required this.items,
|
||
required this.onView,
|
||
});
|
||
|
||
final List<AuditLogEntryModel> items;
|
||
final void Function(AuditLogEntryModel log) onView;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListView.separated(
|
||
itemCount: items.length,
|
||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||
itemBuilder: (context, index) {
|
||
final log = items[index];
|
||
return AppCard(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
humanizeLabel(log.tableName),
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
),
|
||
),
|
||
AppStatusChip(status: log.action, compact: true),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(DateFormatter.displayDateTime(log.performedAt)),
|
||
Text('Record: ${log.recordId ?? '—'}'),
|
||
Text(log.performerLabel),
|
||
if (log.requestId != null) Text('Request: ${log.requestId}'),
|
||
const SizedBox(height: 8),
|
||
Align(
|
||
alignment: Alignment.centerRight,
|
||
child: TextButton.icon(
|
||
onPressed: () => onView(log),
|
||
icon: const Icon(Icons.visibility_outlined, size: 18),
|
||
label: const Text('View'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|