bharat_erp/lib/modules/audit/presentation/screens/audit_logs_screen.dart

576 lines
20 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'),
),
),
],
),
),
);
},
);
}
}