diff --git a/lib/core/constants/route_constants.dart b/lib/core/constants/route_constants.dart index afcc1f1..a774aff 100644 --- a/lib/core/constants/route_constants.dart +++ b/lib/core/constants/route_constants.dart @@ -59,13 +59,13 @@ class RouteConstants { static const String grnEdit = '/grn/:id/edit'; static const String grnDetail = '/grn/:id'; - // Assets - static const String assets = '/assets'; - static const String assetAdd = '/assets/add'; - static const String assetEdit = '/assets/:id/edit'; - static const String assetDetail = '/assets/:id'; - static const String assetAlerts = '/assets/alerts'; - static const String assetMaintenance = '/assets/maintenance'; + // Assets (Asset Master) + static const String assets = '/assetsmaster'; + static const String assetAdd = '/assetsmaster/add'; + static const String assetEdit = '/assetsmaster/:id/edit'; + static const String assetDetail = '/assetsmaster/:id'; + static const String assetAlerts = '/assetsmaster/alerts'; + static const String assetMaintenance = '/assetsmaster/maintenance'; // Master Data static const String masterData = '/master-data'; diff --git a/lib/core/constants/storage_keys.dart b/lib/core/constants/storage_keys.dart index ff29ac1..0b5bba7 100644 --- a/lib/core/constants/storage_keys.dart +++ b/lib/core/constants/storage_keys.dart @@ -13,4 +13,9 @@ class StorageKeys { static const String appSettings = 'app_settings'; static const String rememberMe = 'remember_me'; static const String rememberedEmail = 'remembered_email'; + + /// Prefix for per-table column visibility/order prefs (`table_columns_`). + static const String tableColumnsPrefix = 'table_columns_'; + + static String tableColumns(String tableId) => '$tableColumnsPrefix$tableId'; } diff --git a/lib/modules/assets/presentation/screens/asset_detail_screen.dart b/lib/modules/assets/presentation/screens/asset_detail_screen.dart index 2c18849..a988b6b 100644 --- a/lib/modules/assets/presentation/screens/asset_detail_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_detail_screen.dart @@ -259,53 +259,24 @@ class _OverviewTab extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Text( - 'Asset Details', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - TextButton.icon( - onPressed: onOpenTransferHistory, - icon: const Icon(Icons.history, size: 18), - label: const Text('Transfer History'), - ), - ], + Text( + 'Asset Details', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 16), // 1) Top Summary _AssetOverviewSection( title: 'Summary', - child: _AssetInfoGrid( - items: [ - _AssetInfo.widget( - 'Status', - AppStatusChip(status: asset.status ?? 'IN_USE'), - ), - _AssetInfo( - 'Maintenance Due', - asset.maintenance == null - ? '—' - : (asset.maintenance!.isDue ? 'Yes' : 'No'), - ), - _AssetInfo( - 'Current Value', - asset.resolvedCurrentValue != null - ? CurrencyFormatter.format( - asset.resolvedCurrentValue, - ) - : '—', - ), - _AssetInfo.widget( - 'Active', - _ActiveDotIndicator(isActive: asset.isActive), - ), - ], + child: _AssetSummaryStrip( + status: asset.status ?? 'IN_USE', + maintenanceDue: asset.maintenance?.isDue, + currentValue: asset.resolvedCurrentValue, + isActive: asset.isActive, + daysUntilDueDisplay: daysUntilDueDisplay, ), ), @@ -344,56 +315,43 @@ class _OverviewTab extends ConsumerWidget { // 3) Maintenance _AssetOverviewSection( title: 'Maintenance', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _AssetInfoGrid( - items: [ - _AssetInfo( - 'Maintenance Incharge', - maintenanceInchargeLabel, - ), - _AssetInfo( - 'Maintenance Frequency', - asset.maintenanceFrequencyInDays != null - ? '${asset.maintenanceFrequencyInDays} days' - : '—', - ), - _AssetInfo( - 'Next Due Date', - asset.maintenance?.nextDueDate != null - ? dateFormat.format( - asset.maintenance!.nextDueDate!, - ) - : '—', - ), - if (daysUntilDueDisplay == null) - const _AssetInfo('Days Until Due', '—') - else - _AssetInfo.widget( - 'Days Until Due', - Text( - daysUntilDueDisplay.label, - style: TextStyle( - color: daysUntilDueDisplay.color, - fontWeight: FontWeight.w600, - ), - ), - ), - _AssetInfo( - 'Condition', - assetConditionLabel(asset.condition), - ), - ], + child: _AssetInfoGrid( + items: [ + _AssetInfo( + 'Maintenance Incharge', + maintenanceInchargeLabel, ), - if (asset.maintenance != null) ...[ - const SizedBox(height: 12), - _MaintenanceDueCallout( - isDue: asset.maintenance!.isDue, - daysUntilDue: asset.maintenance!.daysUntilDue, - display: daysUntilDueDisplay, + _AssetInfo( + 'Maintenance Frequency', + asset.maintenanceFrequencyInDays != null + ? '${asset.maintenanceFrequencyInDays} days' + : '—', + ), + _AssetInfo( + 'Next Due Date', + asset.maintenance?.nextDueDate != null + ? dateFormat.format( + asset.maintenance!.nextDueDate!, + ) + : '—', + ), + if (daysUntilDueDisplay == null) + const _AssetInfo('Days Until Due', '—') + else + _AssetInfo.widget( + 'Days Until Due', + Text( + daysUntilDueDisplay.label, + style: TextStyle( + color: daysUntilDueDisplay.color, + fontWeight: FontWeight.w600, + ), + ), ), - ], + _AssetInfo( + 'Condition', + assetConditionLabel(asset.condition), + ), ], ), ), @@ -479,6 +437,23 @@ class _OverviewTab extends ConsumerWidget { label: const Text('Log Maintenance'), ), ], + trailingActions: [ + OutlinedButton.icon( + onPressed: onOpenTransferHistory, + icon: const Icon(Icons.history, size: 18), + label: const Text('Transfer History'), + ), + ], + ), + ] else ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: onOpenTransferHistory, + icon: const Icon(Icons.history, size: 18), + label: const Text('Transfer History'), + ), ), ], if (asset.remarks?.trim().isNotEmpty == true) ...[ @@ -573,82 +548,220 @@ class _AssetOverviewSection extends StatelessWidget { } } -class _ActiveDotIndicator extends StatelessWidget { - const _ActiveDotIndicator({required this.isActive}); +/// Stylish KPI strip for Overview → Summary (theme primary / secondary accents). +class _AssetSummaryStrip extends StatelessWidget { + const _AssetSummaryStrip({ + required this.status, + required this.maintenanceDue, + required this.currentValue, + required this.isActive, + required this.daysUntilDueDisplay, + }); + final String status; + final bool? maintenanceDue; + final double? currentValue; final bool isActive; + final MaintenanceDueDisplay? daysUntilDueDisplay; @override Widget build(BuildContext context) { final theme = Theme.of(context); - final color = - isActive ? const Color(0xFF16A34A) : theme.colorScheme.onSurfaceVariant; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), + final scheme = theme.colorScheme; + final dueColor = maintenanceDue == null + ? scheme.onSurfaceVariant + : maintenanceDue! + ? scheme.error + : (daysUntilDueDisplay?.color ?? const Color(0xFF16A34A)); + final dueLabel = maintenanceDue == null + ? '—' + : maintenanceDue! + ? 'Yes' + : 'No'; + final activeColor = + isActive ? const Color(0xFF16A34A) : scheme.onSurfaceVariant; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + scheme.primary.withValues(alpha: 0.07), + scheme.secondary.withValues(alpha: 0.06), + scheme.surfaceContainerHighest.withValues(alpha: 0.35), + ], ), - const SizedBox(width: 6), - Text( - isActive ? 'Active' : 'Inactive', - style: theme.textTheme.bodyLarge?.copyWith( - color: color, - fontWeight: FontWeight.w600, - ), + border: Border.all( + color: scheme.primary.withValues(alpha: 0.14), ), - ], + ), + child: LayoutBuilder( + builder: (context, constraints) { + final cols = constraints.maxWidth < 560 + ? 1 + : constraints.maxWidth < 820 + ? 2 + : 4; + const gap = 12.0; + final tileWidth = + (constraints.maxWidth - gap * (cols - 1)) / cols; + + final tiles = [ + _SummaryMetricTile( + width: tileWidth, + icon: Icons.flag_outlined, + label: 'Status', + accent: scheme.secondary, + child: AppStatusChip(status: status, compact: true), + ), + _SummaryMetricTile( + width: tileWidth, + icon: Icons.build_circle_outlined, + label: 'Maintenance Due', + accent: dueColor, + child: Text( + dueLabel, + style: theme.textTheme.titleMedium?.copyWith( + color: dueColor, + fontWeight: FontWeight.w700, + ), + ), + ), + _SummaryMetricTile( + width: tileWidth, + icon: Icons.payments_outlined, + label: 'Current Value', + accent: scheme.primary, + child: Text( + currentValue != null + ? CurrencyFormatter.format(currentValue) + : '—', + style: theme.textTheme.titleMedium?.copyWith( + color: scheme.onSurface, + fontWeight: FontWeight.w800, + ), + ), + ), + _SummaryMetricTile( + width: tileWidth, + icon: isActive + ? Icons.check_circle_outline + : Icons.pause_circle_outline, + label: 'Active', + accent: activeColor, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: activeColor, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: activeColor.withValues(alpha: 0.35), + blurRadius: 6, + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + isActive ? 'Active' : 'Inactive', + style: theme.textTheme.titleMedium?.copyWith( + color: activeColor, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ]; + + return Wrap( + spacing: gap, + runSpacing: gap, + children: tiles, + ); + }, + ), ); } } -class _MaintenanceDueCallout extends StatelessWidget { - const _MaintenanceDueCallout({ - required this.isDue, - required this.daysUntilDue, - required this.display, +class _SummaryMetricTile extends StatelessWidget { + const _SummaryMetricTile({ + required this.width, + required this.icon, + required this.label, + required this.accent, + required this.child, }); - final bool isDue; - final int? daysUntilDue; - final MaintenanceDueDisplay? display; + final double width; + final IconData icon; + final String label; + final Color accent; + final Widget child; @override Widget build(BuildContext context) { final theme = Theme.of(context); - final color = display?.color ?? theme.colorScheme.onSurfaceVariant; - final suffix = () { - if (daysUntilDue == null) return null; - if (daysUntilDue! > 0) { - return 'next service in $daysUntilDue day${daysUntilDue == 1 ? '' : 's'}'; - } - if (daysUntilDue == 0) return 'due today'; - final overdueBy = daysUntilDue!.abs(); - return 'overdue by $overdueBy day${overdueBy == 1 ? '' : 's'}'; - }(); + final scheme = theme.colorScheme; - final text = suffix == null - ? 'Maintenance Due: ${isDue ? 'Yes' : 'No'}' - : 'Maintenance Due: ${isDue ? 'Yes' : 'No'} — $suffix'; - - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withValues(alpha: 0.28)), - ), - child: Text( - text, - style: theme.textTheme.bodyMedium?.copyWith( - color: color, - fontWeight: FontWeight.w600, + return SizedBox( + width: width, + child: Container( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), + decoration: BoxDecoration( + color: scheme.surface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: accent.withValues(alpha: 0.18)), + boxShadow: [ + BoxShadow( + color: scheme.shadow.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 16, color: accent), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + label.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + child, + ], ), ), ); @@ -910,24 +1023,26 @@ class _AssetInfoGrid extends StatelessWidget { const _AssetInfoGrid({required this.items}); final List<_AssetInfo> items; - static const int _columns = 3; + static const int _columns = 4; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final maxWidth = constraints.maxWidth; - final cols = maxWidth < 600 + final cols = maxWidth < 560 ? 1 - : maxWidth < 900 + : maxWidth < 780 ? 2 - : _columns; + : maxWidth < 1000 + ? 3 + : _columns; const spacing = 16.0; final colWidth = (maxWidth - spacing * (cols - 1)) / cols; return Wrap( spacing: spacing, - runSpacing: 16, + runSpacing: 18, children: items .map( (item) => SizedBox( @@ -965,14 +1080,22 @@ class _AssetDetailTile extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - label, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + label.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85), fontWeight: FontWeight.w500, + letterSpacing: 0.45, ), ), - const SizedBox(height: 4), - valueWidget ?? Text(value ?? '—', style: theme.textTheme.bodyLarge), + const SizedBox(height: 6), + valueWidget ?? + Text( + value ?? '—', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), ], ); } diff --git a/lib/modules/assets/presentation/screens/asset_form_screen.dart b/lib/modules/assets/presentation/screens/asset_form_screen.dart index 675826e..2718ab3 100644 --- a/lib/modules/assets/presentation/screens/asset_form_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_form_screen.dart @@ -43,7 +43,7 @@ void openAssetForm( if (assetId == null) { context.push(RouteConstants.assetAdd); } else { - context.push('/assets/$assetId/edit'); + context.push('${RouteConstants.assets}/$assetId/edit'); } } @@ -1559,33 +1559,58 @@ class _AssetFormScreenState extends ConsumerState { ) .where((option) => option.value != 0) .toList(), - refreshLookups: () { + refreshLookups: () async { ref.invalidate(itemCategoriesFormProvider); + await ref.read(itemCategoriesFormProvider.future); }, parseCreatedId: int.tryParse, - onChanged: (v) => setState(() { - _categoryId = v; - _subcategoryId = null; - final selectedCategory = - activeCategories.where((c) => int.tryParse(c.id) == v).firstOrNull; - if (selectedCategory != null) { - if (selectedCategory.defaultDepreciationMethod != null && - selectedCategory.defaultDepreciationMethod!.trim().isNotEmpty) { - _depreciationMethod = - selectedCategory.defaultDepreciationMethod!.trim().toUpperCase(); - } - if (selectedCategory.defaultUsefulLifeYears != null && - selectedCategory.defaultUsefulLifeYears! > 0) { - _usefulLifeController.text = - selectedCategory.defaultUsefulLifeYears!.toString(); - } - } + onChanged: (v) { + setState(() { + _categoryId = v; + _subcategoryId = null; + }); + // Prefer the latest provider list so Quick Add (after refresh) can + // apply default useful life / depreciation method. + _applyCategoryDepreciationDefaults( + v, + fallbackCategories: activeCategories, + ); _triggerPreviewRecalculation(); - }), + }, validator: (v) => v == null ? 'Category is required' : null, ); } + void _applyCategoryDepreciationDefaults( + int? categoryId, { + List fallbackCategories = const [], + }) { + if (categoryId == null) return; + + final fromProvider = + ref.read(itemCategoriesFormProvider).valueOrNull ?? const []; + final pool = fromProvider.isNotEmpty ? fromProvider : fallbackCategories; + final selectedCategory = pool + .where((c) => int.tryParse(c.id) == categoryId) + .firstOrNull; + if (selectedCategory == null) return; + + var changed = false; + if (selectedCategory.defaultDepreciationMethod != null && + selectedCategory.defaultDepreciationMethod!.trim().isNotEmpty) { + _depreciationMethod = + selectedCategory.defaultDepreciationMethod!.trim().toUpperCase(); + changed = true; + } + if (selectedCategory.defaultUsefulLifeYears != null && + selectedCategory.defaultUsefulLifeYears! > 0) { + _usefulLifeController.text = + selectedCategory.defaultUsefulLifeYears!.toString(); + changed = true; + } + if (changed && mounted) setState(() {}); + } + Widget _vendorDropdown(List vendors) { final vendorIds = vendors .map((vendor) => int.tryParse(vendor.id)) diff --git a/lib/modules/assets/presentation/screens/asset_list_screen.dart b/lib/modules/assets/presentation/screens/asset_list_screen.dart index c525802..0dbd0dd 100644 --- a/lib/modules/assets/presentation/screens/asset_list_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_list_screen.dart @@ -22,7 +22,9 @@ import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_search_filter_toggle.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/can_permission.dart'; +import '../../../../shared/providers/table_column_prefs_provider.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/utils/file_download_helper.dart'; @@ -91,6 +93,11 @@ class _AssetListScreenState extends ConsumerState { ), ), const SizedBox(width: 8), + AppTableColumnSelectorButton( + tableId: _AssetDataTable.tableId, + columns: _AssetDataTable.columnOptions, + ), + const SizedBox(width: 8), if (canExport) OutlinedButton.icon( onPressed: state.isExporting ? null : _exportAssets, @@ -371,7 +378,7 @@ class _AssetsFilterBar extends StatelessWidget { } } -class _AssetDataTable extends StatelessWidget { +class _AssetDataTable extends ConsumerWidget { const _AssetDataTable({ required this.assets, required this.canEdit, @@ -383,6 +390,29 @@ class _AssetDataTable extends StatelessWidget { this.onColumnSearchCleared, }); + static const tableId = 'assets_list'; + + static List get columnOptions => const [ + AppTableColumnOption( + id: 'asset_code', + label: 'Asset Code', + required: true, + ), + AppTableColumnOption(id: 'asset_name', label: 'Asset Name'), + AppTableColumnOption(id: 'category', label: 'Category'), + AppTableColumnOption(id: 'location', label: 'Location'), + AppTableColumnOption(id: 'current_value', label: 'Current Value'), + AppTableColumnOption( + id: 'depreciated_amount', + label: 'Depreciated Amount', + ), + AppTableColumnOption( + id: 'warranty_validity', + label: 'Warranty Validity', + ), + AppTableColumnOption(id: 'status', label: 'Status'), + ]; + final List assets; final bool canEdit; final bool canDelete; @@ -392,111 +422,141 @@ class _AssetDataTable extends StatelessWidget { final Future Function()? onEnsureFullDataset; final VoidCallback? onColumnSearchCleared; + List> _allColumns() { + return [ + AppDataColumn( + id: 'asset_code', + label: 'Asset Code', + sortKey: 'asset_code', + locked: true, + flex: 1, + searchText: (asset) => asset.assetCode ?? '', + cellBuilder: (_, asset) { + final code = asset.assetCode; + if (code == null || code.isEmpty) return const Text('—'); + return AppTableCell.link(code, onTap: () => onView(asset)); + }, + ), + AppDataColumn( + id: 'asset_name', + label: 'Asset Name', + sortKey: 'asset_name', + flex: 2, + searchText: (asset) => asset.assetName, + cellBuilder: (_, asset) => Text(asset.assetName), + ), + AppDataColumn( + id: 'category', + label: 'Category', + sortKey: 'category', + flex: 2, + searchText: (asset) => asset.assetCategoryName ?? '', + cellBuilder: (_, asset) => Text(asset.assetCategoryName ?? '—'), + ), + AppDataColumn( + id: 'location', + label: 'Location', + sortKey: 'location', + flex: 1, + searchText: (asset) => asset.locationName ?? '', + cellBuilder: (_, asset) => Text(asset.locationName ?? '—'), + ), + AppDataColumn( + id: 'current_value', + label: 'Current Value', + sortKey: 'current_value', + flex: 1, + searchText: (asset) => + CurrencyFormatter.searchable(asset.resolvedCurrentValue), + sortValue: (asset) => asset.resolvedCurrentValue, + cellBuilder: (_, asset) => Text( + asset.resolvedCurrentValue != null + ? CurrencyFormatter.format(asset.resolvedCurrentValue) + : '—', + ), + ), + AppDataColumn( + id: 'depreciated_amount', + label: 'Depreciated Amount', + sortKey: 'depreciated_amount', + flex: 1, + searchText: (asset) => + CurrencyFormatter.searchable(asset.depreciatedAmount), + sortValue: (asset) => asset.depreciatedAmount, + cellBuilder: (_, asset) => Text( + asset.depreciatedAmount != null + ? CurrencyFormatter.format(asset.depreciatedAmount) + : '—', + ), + ), + AppDataColumn( + id: 'warranty_validity', + label: 'Warranty Validity', + sortKey: 'warranty_validity', + flex: 1, + searchText: (asset) => + DateFormatter.searchableDate(asset.warrantyExpiryDate), + sortValue: (asset) => asset.warrantyExpiryDate, + cellBuilder: (_, asset) => Text( + DateFormatter.displayDate(asset.warrantyExpiryDate), + ), + ), + AppDataColumn( + id: 'status', + label: 'Status', + sortKey: 'status', + flex: 1, + searchText: (asset) => + asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active', + cellBuilder: (_, asset) => AppStatusChip( + status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active', + compact: true, + forTable: true, + ), + ), + AppDataColumn( + id: 'actions', + label: 'Actions', + flex: 1, + alignment: Alignment.centerRight, + enableSearch: false, + locked: true, + includeInColumnSelector: false, + cellBuilder: (_, asset) => AppTableActions( + children: [ + AppTableActionIcon( + tooltip: 'View', + icon: Icons.visibility_outlined, + onPressed: () => onView(asset), + ), + if (canEdit) + AppTableActionIcon( + tooltip: 'Edit', + icon: Icons.edit_outlined, + onPressed: () => onEdit(asset), + ), + if (canDelete) + AppTableActionIcon( + tooltip: 'Delete', + icon: Icons.delete_outline, + onPressed: () => onDelete(asset), + ), + ], + ), + ), + ]; + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final prefs = ref.watch(tableColumnPrefsProvider(tableId)); + final columns = resolveAppDataColumns(_allColumns(), prefs); + return AppDataTable( wrapInCard: false, onEnsureFullDataset: onEnsureFullDataset, onColumnSearchCleared: onColumnSearchCleared, - columns: [ - AppDataColumn( - label: 'Asset Code', - flex: 1, - searchText: (asset) => asset.assetCode ?? '', - cellBuilder: (_, asset) { - final code = asset.assetCode; - if (code == null || code.isEmpty) return const Text('—'); - return AppTableCell.link(code, onTap: () => onView(asset)); - }, - ), - AppDataColumn( - label: 'Asset Name', - flex: 2, - searchText: (asset) => asset.assetName, - cellBuilder: (_, asset) => Text(asset.assetName), - ), - AppDataColumn( - label: 'Category', - flex: 2, - searchText: (asset) => asset.assetCategoryName ?? '', - cellBuilder: (_, asset) => Text(asset.assetCategoryName ?? '—'), - ), - AppDataColumn( - label: 'Location', - flex: 1, - searchText: (asset) => asset.locationName ?? '', - cellBuilder: (_, asset) => Text(asset.locationName ?? '—'), - ), - AppDataColumn( - label: 'Current Value', - flex: 1, - searchText: (asset) => - CurrencyFormatter.searchable(asset.resolvedCurrentValue), - cellBuilder: (_, asset) => Text( - asset.resolvedCurrentValue != null - ? CurrencyFormatter.format(asset.resolvedCurrentValue) - : '—', - ), - ), - AppDataColumn( - label: 'Depreciated Amount', - flex: 1, - searchText: (asset) => - CurrencyFormatter.searchable(asset.depreciatedAmount), - cellBuilder: (_, asset) => Text( - asset.depreciatedAmount != null - ? CurrencyFormatter.format(asset.depreciatedAmount) - : '—', - ), - ), - AppDataColumn( - label: 'Warranty Validity', - flex: 1, - searchText: (asset) => - DateFormatter.searchableDate(asset.warrantyExpiryDate), - cellBuilder: (_, asset) => Text( - DateFormatter.displayDate(asset.warrantyExpiryDate), - ), - ), - AppDataColumn( - label: 'Status', - flex: 1, - searchText: (asset) => - asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active', - cellBuilder: (_, asset) => AppStatusChip( - status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active', - compact: true, - forTable: true, - ), - ), - AppDataColumn( - label: 'Actions', - flex: 1, - alignment: Alignment.centerRight, - enableSearch: false, - cellBuilder: (_, asset) => AppTableActions( - children: [ - AppTableActionIcon( - tooltip: 'View', - icon: Icons.visibility_outlined, - onPressed: () => onView(asset), - ), - if (canEdit) - AppTableActionIcon( - tooltip: 'Edit', - icon: Icons.edit_outlined, - onPressed: () => onEdit(asset), - ), - if (canDelete) - AppTableActionIcon( - tooltip: 'Delete', - icon: Icons.delete_outline, - onPressed: () => onDelete(asset), - ), - ], - ), - ), - ], + columns: columns, rows: assets, ); } diff --git a/lib/modules/assets/presentation/screens/asset_maintenance_screen.dart b/lib/modules/assets/presentation/screens/asset_maintenance_screen.dart index b946205..1ed012f 100644 --- a/lib/modules/assets/presentation/screens/asset_maintenance_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_maintenance_screen.dart @@ -8,12 +8,14 @@ import '../../../../core/errors/failure.dart'; import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/responsive_utils.dart'; import '../../../../shared/models/asset_model.dart'; +import '../../../../shared/providers/table_column_prefs_provider.dart'; import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_form_toggle_field.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_table_action_icon.dart'; +import '../../../../shared/widgets/app_table_column_selector.dart'; import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; @@ -95,6 +97,11 @@ class _MyMaintenanceBody extends ConsumerWidget { title: 'My Maintenance', subtitle: 'Assets assigned to you for checklist-based maintenance', actions: [ + AppTableColumnSelectorButton( + tableId: _MaintenanceTable.tableId, + columns: _MaintenanceTable.columnOptions, + ), + const SizedBox(width: 8), OutlinedButton.icon( onPressed: () => context.go(RouteConstants.assets), icon: const Icon(Icons.inventory_2_outlined), @@ -174,83 +181,116 @@ class _MyMaintenanceBody extends ConsumerWidget { } } -class _MaintenanceTable extends StatelessWidget { +class _MaintenanceTable extends ConsumerWidget { const _MaintenanceTable({ required this.assets, required this.onSubmit, required this.onView, }); + static const tableId = 'my_maintenance'; + + static List get columnOptions => const [ + AppTableColumnOption( + id: 'asset_code', + label: 'Asset Code', + required: true, + ), + AppTableColumnOption(id: 'asset_name', label: 'Asset Name'), + AppTableColumnOption(id: 'next_due', label: 'Next Due'), + AppTableColumnOption(id: 'status', label: 'Status'), + ]; + final List assets; final void Function(AssetModel asset) onSubmit; final void Function(AssetModel asset) onView; - @override - Widget build(BuildContext context) { + List> _allColumns() { final dateFormat = DateFormat('dd MMM yyyy'); + return [ + AppDataColumn( + id: 'asset_code', + label: 'Asset Code', + sortKey: 'asset_code', + locked: true, + flex: 1, + searchText: (asset) => asset.assetCode ?? '', + cellBuilder: (_, asset) => AppTableCell.link( + asset.assetCode, + onTap: () => onView(asset), + ), + ), + AppDataColumn( + id: 'asset_name', + label: 'Asset Name', + sortKey: 'asset_name', + flex: 2, + searchText: (asset) => asset.assetName, + cellBuilder: (_, asset) => Text(asset.assetName), + ), + AppDataColumn( + id: 'next_due', + label: 'Next Due', + sortKey: 'next_due', + flex: 1, + searchText: (asset) => DateFormatter.displayDate( + asset.maintenance?.nextDueDate, + ), + sortValue: (asset) => asset.maintenance?.nextDueDate, + cellBuilder: (_, asset) { + final nextDue = asset.maintenance?.nextDueDate; + return Text( + nextDue != null ? dateFormat.format(nextDue) : '—', + ); + }, + ), + AppDataColumn( + id: 'status', + label: 'Status', + sortKey: 'status', + flex: 1, + searchText: (asset) => + asset.maintenance?.isDue == true ? 'due' : 'ok', + cellBuilder: (_, asset) => _DueBadge( + isDue: asset.maintenance?.isDue == true, + daysUntilDue: asset.maintenance?.daysUntilDue, + ), + ), + AppDataColumn( + id: 'actions', + label: 'Actions', + flex: 1, + alignment: Alignment.centerRight, + enableSearch: false, + locked: true, + includeInColumnSelector: false, + cellBuilder: (_, asset) => AppTableActions( + children: [ + AppTableActionIcon( + tooltip: 'Submit checklist', + icon: Icons.checklist_outlined, + onPressed: () => onSubmit(asset), + ), + AppTableActionIcon( + tooltip: 'View asset', + icon: Icons.visibility_outlined, + onPressed: () => onView(asset), + ), + ], + ), + ), + ]; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final prefs = ref.watch(tableColumnPrefsProvider(tableId)); + final columns = resolveAppDataColumns(_allColumns(), prefs); + return AppDataTable( wrapInCard: false, - columns: [ - AppDataColumn( - label: 'Asset Code', - flex: 1, - searchText: (asset) => asset.assetCode ?? '', - cellBuilder: (_, asset) => AppTableCell.link( - asset.assetCode, - onTap: () => context.push('${RouteConstants.assets}/${asset.id}'), - ), - ), - AppDataColumn( - label: 'Asset Name', - flex: 2, - searchText: (asset) => asset.assetName, - cellBuilder: (_, asset) => Text(asset.assetName), - ), - AppDataColumn( - label: 'Next Due', - flex: 1, - searchText: (asset) => DateFormatter.displayDate( - asset.maintenance?.nextDueDate, - ), - cellBuilder: (_, asset) { - final nextDue = asset.maintenance?.nextDueDate; - return Text( - nextDue != null ? dateFormat.format(nextDue) : '—', - ); - }, - ), - AppDataColumn( - label: 'Status', - flex: 1, - searchText: (asset) => - asset.maintenance?.isDue == true ? 'due' : 'ok', - cellBuilder: (_, asset) => _DueBadge( - isDue: asset.maintenance?.isDue == true, - daysUntilDue: asset.maintenance?.daysUntilDue, - ), - ), - AppDataColumn( - label: 'Actions', - flex: 1, - alignment: Alignment.centerRight, - enableSearch: false, - cellBuilder: (_, asset) => AppTableActions( - children: [ - AppTableActionIcon( - tooltip: 'Submit checklist', - icon: Icons.checklist_outlined, - onPressed: () => onSubmit(asset), - ), - AppTableActionIcon( - tooltip: 'View asset', - icon: Icons.visibility_outlined, - onPressed: () => onView(asset), - ), - ], - ), - ), - ], + columns: columns, rows: assets, ); } diff --git a/lib/modules/assets/presentation/widgets/asset_maintenance_panel.dart b/lib/modules/assets/presentation/widgets/asset_maintenance_panel.dart index 1ad64e7..15436bf 100644 --- a/lib/modules/assets/presentation/widgets/asset_maintenance_panel.dart +++ b/lib/modules/assets/presentation/widgets/asset_maintenance_panel.dart @@ -273,14 +273,18 @@ class AssetRecentMaintenanceLogsSection extends ConsumerStatefulWidget { required this.assetId, this.limit = 5, this.leadingActions, + this.trailingActions, }); final String assetId; final int limit; - /// Optional actions shown on the same row (e.g. Log Maintenance). + /// Optional actions shown before Recent Logs (e.g. Log Maintenance). final List? leadingActions; + /// Optional actions shown after Recent Logs (e.g. Transfer History). + final List? trailingActions; + @override ConsumerState createState() => _AssetRecentMaintenanceLogsSectionState(); @@ -352,6 +356,7 @@ class _AssetRecentMaintenanceLogsSectionState ), label: const Text('Recent Logs'), ), + ...?widget.trailingActions, ], ), ), diff --git a/lib/modules/audit/presentation/screens/audit_logs_screen.dart b/lib/modules/audit/presentation/screens/audit_logs_screen.dart index 514e217..c548419 100644 --- a/lib/modules/audit/presentation/screens/audit_logs_screen.dart +++ b/lib/modules/audit/presentation/screens/audit_logs_screen.dart @@ -23,7 +23,9 @@ 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'; @@ -143,6 +145,11 @@ class _AuditLogsScreenState extends ConsumerState { () => _filtersExpanded = !_filtersExpanded, ), ), + const SizedBox(width: 8), + AppTableColumnSelectorButton( + tableId: _AuditDataTable.tableId, + columns: _AuditDataTable.columnOptions, + ), if (canExport) ...[ const SizedBox(width: 8), OutlinedButton.icon( @@ -370,7 +377,7 @@ class _FiltersBar extends StatelessWidget { } } -class _AuditDataTable extends StatelessWidget { +class _AuditDataTable extends ConsumerWidget { const _AuditDataTable({ required this.items, required this.onView, @@ -378,99 +385,135 @@ class _AuditDataTable extends StatelessWidget { this.onColumnSearchCleared, }); + static const tableId = 'audit_logs'; + + static List 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 items; final void Function(AuditLogEntryModel log) onView; final Future Function()? onEnsureFullDataset; final VoidCallback? onColumnSearchCleared; + static String _changesText(AuditLogEntryModel row) { + final parts = []; + if (row.hasOldValue) parts.add('old'); + if (row.hasNewValue) parts.add('new'); + return parts.isEmpty ? '' : parts.join(' / '); + } + + List> _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) { + Widget build(BuildContext context, WidgetRef ref) { + final prefs = ref.watch(tableColumnPrefsProvider(tableId)); + final columns = resolveAppDataColumns(_allColumns(), prefs); + return AppDataTable( wrapInCard: false, rows: items, emptyMessage: 'No audit logs found', onEnsureFullDataset: onEnsureFullDataset, onColumnSearchCleared: onColumnSearchCleared, - columns: [ - AppDataColumn( - label: 'When', - flex: 2, - searchText: (row) => DateFormatter.searchableDate(row.performedAt), - cellBuilder: (_, row) => AppTableCell.text( - DateFormatter.displayDateTime(row.performedAt), - ), - ), - AppDataColumn( - label: 'Action', - flex: 1, - searchText: (row) => row.action, - cellBuilder: (_, row) => AppTableCell.child( - AppStatusChip( - status: row.action, - compact: true, - forTable: true, - ), - ), - ), - AppDataColumn( - label: 'Table', - flex: 2, - searchText: (row) => humanizeLabel(row.tableName), - cellBuilder: (_, row) => - AppTableCell.text(humanizeLabel(row.tableName)), - ), - AppDataColumn( - label: 'Record', - flex: 1, - searchText: (row) => row.recordId ?? '', - cellBuilder: (_, row) => AppTableCell.text(row.recordId), - ), - AppDataColumn( - label: 'Performed By', - flex: 2, - searchText: (row) => row.performerLabel, - cellBuilder: (_, row) => AppTableCell.text(row.performerLabel), - ), - AppDataColumn( - label: 'Request ID', - flex: 2, - searchText: (row) => row.requestId ?? '', - cellBuilder: (_, row) => AppTableCell.text(row.requestId), - ), - AppDataColumn( - label: 'Changes', - flex: 1, - searchText: (row) { - final parts = []; - if (row.hasOldValue) parts.add('old'); - if (row.hasNewValue) parts.add('new'); - return parts.isEmpty ? '' : parts.join(' / '); - }, - cellBuilder: (_, row) { - final parts = []; - if (row.hasOldValue) parts.add('old'); - if (row.hasNewValue) parts.add('new'); - return AppTableCell.text( - parts.isEmpty ? '—' : parts.join(' / '), - ); - }, - ), - AppDataColumn( - label: 'Actions', - flex: 1, - alignment: Alignment.centerRight, - enableSearch: false, - cellBuilder: (_, row) => AppTableActions( - children: [ - AppTableActionIcon( - tooltip: 'View', - icon: Icons.visibility_outlined, - onPressed: () => onView(row), - ), - ], - ), - ), - ], + columns: columns, ); } } diff --git a/lib/modules/dev/presentation/screens/screen_gallery_screen.dart b/lib/modules/dev/presentation/screens/screen_gallery_screen.dart index f0048e0..f63fe65 100644 --- a/lib/modules/dev/presentation/screens/screen_gallery_screen.dart +++ b/lib/modules/dev/presentation/screens/screen_gallery_screen.dart @@ -77,7 +77,7 @@ final _entries = [ ), // Assets _GalleryEntry(title: 'Asset List', route: RouteConstants.assets, group: 'Assets'), - _GalleryEntry(title: 'Asset Detail', route: '/assets/demo-asset', group: 'Assets'), + _GalleryEntry(title: 'Asset Detail', route: '${RouteConstants.assets}/demo-asset', group: 'Assets'), _GalleryEntry(title: 'Alerts', route: RouteConstants.assetAlerts, group: 'Assets'), // Master data _GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'), diff --git a/lib/modules/grn/presentation/screens/grn_list_screen.dart b/lib/modules/grn/presentation/screens/grn_list_screen.dart index b3c5a10..c73b44f 100644 --- a/lib/modules/grn/presentation/screens/grn_list_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_list_screen.dart @@ -21,9 +21,11 @@ import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/can_permission.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/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; +import '../../../../shared/providers/table_column_prefs_provider.dart'; import '../../../../shared/utils/file_download_helper.dart'; import '../providers/grn_lookups_provider.dart'; import '../providers/grn_provider.dart'; @@ -82,6 +84,11 @@ class _GrnListScreenState extends ConsumerState { () => _filtersExpanded = !_filtersExpanded, ), ), + const SizedBox(width: 8), + AppTableColumnSelectorButton( + tableId: _GrnDataTable.tableId, + columns: _GrnDataTable.columnOptions, + ), if (canExport) ...[ const SizedBox(width: 8), OutlinedButton.icon( @@ -289,7 +296,7 @@ class _FiltersBar extends StatelessWidget { } } -class _GrnDataTable extends StatelessWidget { +class _GrnDataTable extends ConsumerWidget { const _GrnDataTable({ required this.grns, required this.onView, @@ -298,90 +305,129 @@ class _GrnDataTable extends StatelessWidget { this.onColumnSearchCleared, }); + static const tableId = 'grn_list'; + + static List get columnOptions => const [ + AppTableColumnOption( + id: 'grn_number', + label: 'GRN Number', + required: true, + ), + AppTableColumnOption(id: 'date', label: 'Date'), + AppTableColumnOption(id: 'po_number', label: 'PO Number'), + AppTableColumnOption(id: 'vendor', label: 'Vendor'), + AppTableColumnOption(id: 'location', label: 'Location'), + AppTableColumnOption(id: 'status', label: 'Status'), + ]; + final List grns; final ValueChanged onView; final ValueChanged? onEdit; final Future Function()? onEnsureFullDataset; final VoidCallback? onColumnSearchCleared; + List> _allColumns(BuildContext context) { + return [ + AppDataColumn( + id: 'grn_number', + label: 'GRN Number', + sortKey: 'grn_number', + locked: true, + flex: 2, + searchText: (grn) => grn.grnNumber ?? '', + cellBuilder: (_, grn) => AppTableCell.link( + grn.grnNumber, + onTap: () => onView(grn), + ), + ), + AppDataColumn( + id: 'date', + label: 'Date', + sortKey: 'date', + flex: 1, + searchText: (grn) => DateFormatter.searchableDate(grn.grnDate), + sortValue: (grn) => grn.grnDate, + cellBuilder: (_, grn) => Text(DateFormatter.displayDate(grn.grnDate)), + ), + AppDataColumn( + id: 'po_number', + label: 'PO Number', + sortKey: 'po_number', + flex: 2, + searchText: (grn) => grn.poNumber ?? '', + cellBuilder: (_, grn) => AppTableCell.link( + grn.poNumber, + onTap: grn.poId == null + ? null + : () => context.push( + '${RouteConstants.purchaseOrders}/${grn.poId}', + ), + ), + ), + AppDataColumn( + id: 'vendor', + label: 'Vendor', + sortKey: 'vendor', + flex: 2, + searchText: (grn) => grn.vendorName ?? '', + cellBuilder: (_, grn) => Text(grn.vendorName ?? '—'), + ), + AppDataColumn( + id: 'location', + label: 'Location', + sortKey: 'location', + flex: 2, + searchText: (grn) => grn.locationName ?? '', + cellBuilder: (_, grn) => Text(grn.locationName ?? '—'), + ), + AppDataColumn( + id: 'status', + label: 'Status', + sortKey: 'status', + flex: 1, + searchText: (grn) => grn.status, + cellBuilder: (_, grn) => GrnStatusChip( + status: grn.status, + compact: true, + forTable: true, + ), + ), + AppDataColumn( + id: 'actions', + label: 'Actions', + flex: 1, + enableSearch: false, + locked: true, + includeInColumnSelector: false, + cellBuilder: (_, grn) => AppTableActions( + children: [ + AppTableActionIcon( + icon: Icons.visibility_outlined, + tooltip: 'View', + onPressed: () => onView(grn), + ), + if (onEdit != null && grn.canEdit) + AppTableActionIcon( + icon: Icons.edit_outlined, + tooltip: 'Edit', + onPressed: () => onEdit!(grn), + ), + ], + ), + ), + ]; + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final prefs = ref.watch(tableColumnPrefsProvider(tableId)); + final columns = resolveAppDataColumns(_allColumns(context), prefs); + return AppDataTable( wrapInCard: false, onEnsureFullDataset: onEnsureFullDataset, onColumnSearchCleared: onColumnSearchCleared, - columns: [ - AppDataColumn( - label: 'GRN Number', - flex: 2, - searchText: (grn) => grn.grnNumber ?? '', - cellBuilder: (_, grn) => AppTableCell.link( - grn.grnNumber, - onTap: () => onView(grn), - ), - ), - AppDataColumn( - label: 'Date', - flex: 1, - searchText: (grn) => DateFormatter.searchableDate(grn.grnDate), - cellBuilder: (_, grn) => Text(DateFormatter.displayDate(grn.grnDate)), - ), - AppDataColumn( - label: 'PO Number', - flex: 2, - searchText: (grn) => grn.poNumber ?? '', - cellBuilder: (_, grn) => AppTableCell.link( - grn.poNumber, - onTap: grn.poId == null - ? null - : () => context.push( - '${RouteConstants.purchaseOrders}/${grn.poId}', - ), - ), - ), - AppDataColumn( - label: 'Vendor', - flex: 2, - searchText: (grn) => grn.vendorName ?? '', - cellBuilder: (_, grn) => Text(grn.vendorName ?? '—'), - ), - AppDataColumn( - label: 'Location', - flex: 2, - searchText: (grn) => grn.locationName ?? '', - cellBuilder: (_, grn) => Text(grn.locationName ?? '—'), - ), - AppDataColumn( - label: 'Status', - flex: 1, - searchText: (grn) => grn.status, - cellBuilder: (_, grn) => GrnStatusChip( - status: grn.status, - compact: true, - forTable: true, - ), - ), - AppDataColumn( - label: 'Actions', - flex: 1, - enableSearch: false, - cellBuilder: (_, grn) => AppTableActions( - children: [ - AppTableActionIcon( - icon: Icons.visibility_outlined, - tooltip: 'View', - onPressed: () => onView(grn), - ), - if (onEdit != null && grn.canEdit) - AppTableActionIcon( - icon: Icons.edit_outlined, - tooltip: 'Edit', - onPressed: () => onEdit!(grn), - ), - ], - ), - ), - ], + columns: columns, rows: grns, ); } diff --git a/lib/modules/master_data/presentation/screens/master_list_screen.dart b/lib/modules/master_data/presentation/screens/master_list_screen.dart index 324fd3a..8800862 100644 --- a/lib/modules/master_data/presentation/screens/master_list_screen.dart +++ b/lib/modules/master_data/presentation/screens/master_list_screen.dart @@ -7,6 +7,7 @@ import '../../../../core/constants/route_constants.dart'; import '../../../../core/errors/failure.dart'; import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/providers/permissions_provider.dart'; +import '../../../../shared/providers/table_column_prefs_provider.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_loading_view.dart'; @@ -15,6 +16,7 @@ import '../../../../shared/widgets/app_search_export_bar.dart'; import '../../../../shared/widgets/app_search_filter_toggle.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/widgets/error_view.dart'; import '../../../../shared/widgets/app_side_panel.dart'; @@ -204,6 +206,11 @@ class _MasterListScreenState extends ConsumerState { () => _filtersExpanded = !_filtersExpanded, ), ), + const SizedBox(width: 8), + AppTableColumnSelectorButton( + tableId: _MasterListTable.tableIdFor(def), + columns: _MasterListTable.columnOptionsFor(def), + ), if (canExport) ...[ const SizedBox(width: 8), OutlinedButton.icon( @@ -285,7 +292,7 @@ class _MasterListScreenState extends ConsumerState { } } -class _MasterListTable extends StatelessWidget { +class _MasterListTable extends ConsumerWidget { const _MasterListTable({ required this.definition, required this.items, @@ -308,75 +315,111 @@ class _MasterListTable extends StatelessWidget { final Future Function()? onEnsureFullDataset; final VoidCallback? onColumnSearchCleared; + static String tableIdFor(MasterDefinition definition) => + 'master_${definition.id}'; + + static String _fieldLabel(MasterFieldDef field) => + field.key == 'is_asset_item' ? 'Type' : field.label; + + static List columnOptionsFor( + MasterDefinition definition, + ) { + final fields = definition.listFields; + return [ + for (var i = 0; i < fields.length; i++) + AppTableColumnOption( + id: fields[i].key, + label: _fieldLabel(fields[i]), + required: i == 0, + ), + const AppTableColumnOption(id: 'status', label: 'Status'), + ]; + } + + List>> _allColumns(ThemeData theme) { + final fields = definition.listFields; + return [ + for (var i = 0; i < fields.length; i++) + AppDataColumn>( + id: fields[i].key, + label: _fieldLabel(fields[i]), + sortKey: fields[i].key, + locked: i == 0, + flex: _columnFlex(fields[i]), + searchText: (row) => masterCellValue(row, fields[i]), + cellBuilder: (_, row) { + final field = fields[i]; + if (field.key == 'is_asset_item') { + final isAsset = masterIsAssetItem(row['is_asset_item']); + return TableStatusBadge( + label: isAsset ? 'Asset' : 'Stock', + color: isAsset + ? const Color(0xFF2563EB) + : const Color(0xFF64748B), + compact: true, + ); + } + return Text(masterCellValue(row, field)); + }, + ), + AppDataColumn( + id: 'status', + label: 'Status', + sortKey: 'status', + flex: 1, + searchText: (row) => masterStatusValue(row), + cellBuilder: (_, row) => AppStatusChip( + status: masterStatusValue(row), + compact: true, + forTable: true, + ), + ), + AppDataColumn( + id: 'actions', + label: 'Actions', + flex: 1, + alignment: Alignment.centerRight, + enableSearch: false, + locked: true, + includeInColumnSelector: false, + cellBuilder: (_, row) { + final id = row['id']?.toString(); + return AppTableActions( + children: [ + if (canEdit) + AppTableActionIcon( + tooltip: 'Edit', + icon: Icons.edit_outlined, + enabled: id != null, + onPressed: () => onEdit(id!), + ), + if (canDelete) + AppTableActionIcon( + tooltip: 'Delete', + icon: Icons.delete_outline, + color: theme.colorScheme.error, + enabled: !isDeleting, + onPressed: () => onDelete(row), + ), + ], + ); + }, + ), + ]; + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); + final prefs = ref.watch(tableColumnPrefsProvider(tableIdFor(definition))); + final columns = resolveAppDataColumns(_allColumns(theme), prefs); return AppDataTable>( wrapInCard: false, onEnsureFullDataset: onEnsureFullDataset, onColumnSearchCleared: onColumnSearchCleared, emptyMessage: 'No ${definition.title.toLowerCase()} found', - columns: [ - ...definition.listFields.map( - (field) => AppDataColumn>( - label: field.key == 'is_asset_item' ? 'Type' : field.label, - flex: _columnFlex(field), - searchText: (row) => masterCellValue(row, field), - cellBuilder: (_, row) { - if (field.key == 'is_asset_item') { - final isAsset = masterIsAssetItem(row['is_asset_item']); - return TableStatusBadge( - label: isAsset ? 'Asset' : 'Stock', - color: isAsset - ? const Color(0xFF2563EB) - : const Color(0xFF64748B), - compact: true, - ); - } - return Text(masterCellValue(row, field)); - }, - ), - ), - AppDataColumn( - label: 'Status', - flex: 1, - searchText: (row) => masterStatusValue(row), - cellBuilder: (_, row) => AppStatusChip( - status: masterStatusValue(row), - compact: true, - forTable: true, - ), - ), - AppDataColumn( - label: 'Actions', - flex: 1, - alignment: Alignment.centerRight, - enableSearch: false, - cellBuilder: (_, row) { - final id = row['id']?.toString(); - return AppTableActions( - children: [ - if (canEdit) - AppTableActionIcon( - tooltip: 'Edit', - icon: Icons.edit_outlined, - enabled: id != null, - onPressed: () => onEdit(id!), - ), - if (canDelete) - AppTableActionIcon( - tooltip: 'Delete', - icon: Icons.delete_outline, - color: theme.colorScheme.error, - enabled: !isDeleting, - onPressed: () => onDelete(row), - ), - ], - ); - }, - ), - ], + columns: columns, rows: items, ); } diff --git a/lib/modules/master_data/presentation/widgets/master_quick_add.dart b/lib/modules/master_data/presentation/widgets/master_quick_add.dart index dd5f578..8007b12 100644 --- a/lib/modules/master_data/presentation/widgets/master_quick_add.dart +++ b/lib/modules/master_data/presentation/widgets/master_quick_add.dart @@ -201,12 +201,10 @@ class _MasterQuickAddDropdownState } if (!mounted) return; - showAppToastFromSnackBar( + showAppToast( context, - SnackBar( - content: - Text('${_titleCase(masterQuickAddNoun(widget.masterId))} added'), - ), + '${_titleCase(masterQuickAddNoun(widget.masterId))} added', + type: AppToastType.success, ); } diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart index 58dccd7..c809e3f 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart @@ -23,9 +23,11 @@ import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/can_permission.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/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; +import '../../../../shared/providers/table_column_prefs_provider.dart'; import '../../../../shared/utils/file_download_helper.dart'; import '../../data/repositories/purchase_order_repository_impl.dart'; import '../providers/purchase_orders_provider.dart'; @@ -133,6 +135,11 @@ class _PurchaseOrderListScreenState extends ConsumerState _filtersExpanded = !_filtersExpanded, ), ), + const SizedBox(width: 8), + AppTableColumnSelectorButton( + tableId: _PoDataTable.tableId, + columns: _PoDataTable.columnOptions, + ), if (canExport) ...[ const SizedBox(width: 8), OutlinedButton.icon( @@ -482,7 +489,7 @@ class _FiltersBar extends StatelessWidget { } } -class _PoDataTable extends StatelessWidget { +class _PoDataTable extends ConsumerWidget { const _PoDataTable({ required this.orders, required this.onView, @@ -494,6 +501,20 @@ class _PoDataTable extends StatelessWidget { this.onColumnSearchCleared, }); + static const tableId = 'purchase_orders_list'; + + static List get columnOptions => const [ + AppTableColumnOption( + id: 'po_number', + label: 'PO Number', + required: true, + ), + AppTableColumnOption(id: 'date', label: 'Date'), + AppTableColumnOption(id: 'vendor', label: 'Vendor'), + AppTableColumnOption(id: 'total', label: 'Total'), + AppTableColumnOption(id: 'status', label: 'Status'), + ]; + final List orders; final ValueChanged onView; final ValueChanged? onEdit; @@ -503,106 +524,129 @@ class _PoDataTable extends StatelessWidget { final Future Function()? onEnsureFullDataset; final VoidCallback? onColumnSearchCleared; + List> _allColumns(ThemeData theme) { + return [ + AppDataColumn( + id: 'po_number', + label: 'PO Number', + sortKey: 'po_number', + locked: true, + flex: 2, + searchText: (order) => order.poNo ?? '', + cellBuilder: (_, order) => AppTableCell.link( + order.poNo, + onTap: () => onView(order), + ), + ), + AppDataColumn( + id: 'date', + label: 'Date', + sortKey: 'date', + flex: 1, + searchText: (order) => DateFormatter.searchableDate(order.poDate), + sortValue: (order) => order.poDate, + cellBuilder: (_, order) => Text(DateFormatter.displayDate(order.poDate)), + ), + AppDataColumn( + id: 'vendor', + label: 'Vendor', + sortKey: 'vendor', + flex: 2, + searchText: (order) => order.vendorName ?? '', + cellBuilder: (_, order) => Text(order.vendorName ?? '—'), + ), + AppDataColumn( + id: 'total', + label: 'Total', + sortKey: 'total', + flex: 1, + searchText: (order) => CurrencyFormatter.searchable(order.totalAmount), + sortValue: (order) => order.totalAmount, + cellBuilder: (_, order) => SizedBox( + width: double.infinity, + child: AppTableCell.text( + CurrencyFormatter.format(order.totalAmount), + textAlign: TextAlign.right, + ), + ), + ), + AppDataColumn( + id: 'status', + label: 'Status', + sortKey: 'status', + flex: 1, + searchText: (order) => order.status, + cellBuilder: (_, order) { + final chip = PoStatusChip( + status: order.status, + compact: true, + forTable: true, + ); + final reason = order.rejectReasonForDisplay; + if (reason == null) return chip; + return Tooltip( + message: 'Reject reason: $reason', + child: chip, + ); + }, + ), + AppDataColumn( + id: 'actions', + label: 'Actions', + width: 88, + alignment: Alignment.centerRight, + enableSearch: false, + locked: true, + includeInColumnSelector: false, + cellBuilder: (_, order) => AppTableActions( + children: [ + AppTableActionIcon( + tooltip: 'View', + icon: Icons.visibility_outlined, + onPressed: () => onView(order), + ), + if (onNotify != null && order.canNotifyApprovers) + AppTableActionIcon( + tooltip: 'Notify approvers', + icon: Icons.notifications_outlined, + onPressed: () => onNotify!(order), + ), + if (onApprove != null && order.canApprove) + AppTableActionIcon( + tooltip: 'Approve', + icon: Icons.check_circle_outline, + color: theme.colorScheme.primary, + onPressed: () => onApprove!(order), + ), + if (onEdit != null && order.canEdit) + AppTableActionIcon( + tooltip: 'Edit', + icon: Icons.edit_outlined, + onPressed: () => onEdit!(order), + ), + if (onDelete != null && order.canDelete) + AppTableActionIcon( + tooltip: 'Delete', + icon: Icons.delete_outline, + onPressed: () => onDelete!(order), + ), + ], + ), + ), + ]; + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); + final prefs = ref.watch(tableColumnPrefsProvider(tableId)); + final columns = resolveAppDataColumns(_allColumns(theme), prefs); + return AppDataTable( wrapInCard: false, onEnsureFullDataset: onEnsureFullDataset, onColumnSearchCleared: onColumnSearchCleared, - columns: [ - AppDataColumn( - label: 'PO Number', - flex: 2, - searchText: (order) => order.poNo ?? '', - cellBuilder: (_, order) => AppTableCell.link( - order.poNo, - onTap: () => onView(order), - ), - ), - AppDataColumn( - label: 'Date', - flex: 1, - searchText: (order) => DateFormatter.searchableDate(order.poDate), - cellBuilder: (_, order) => Text(DateFormatter.displayDate(order.poDate)), - ), - AppDataColumn( - label: 'Vendor', - flex: 2, - searchText: (order) => order.vendorName ?? '', - cellBuilder: (_, order) => Text(order.vendorName ?? '—'), - ), - AppDataColumn( - label: 'Total', - flex: 1, - searchText: (order) => CurrencyFormatter.searchable(order.totalAmount), - cellBuilder: (_, order) => SizedBox( - width: double.infinity, - child: AppTableCell.text( - CurrencyFormatter.format(order.totalAmount), - textAlign: TextAlign.right, - ), - ), - ), - AppDataColumn( - label: 'Status', - flex: 1, - searchText: (order) => order.status, - cellBuilder: (_, order) { - final chip = PoStatusChip( - status: order.status, - compact: true, - forTable: true, - ); - final reason = order.rejectReasonForDisplay; - if (reason == null) return chip; - return Tooltip( - message: 'Reject reason: $reason', - child: chip, - ); - }, - ), - AppDataColumn( - label: 'Actions', - width: 88, - alignment: Alignment.centerRight, - enableSearch: false, - cellBuilder: (_, order) => AppTableActions( - children: [ - AppTableActionIcon( - tooltip: 'View', - icon: Icons.visibility_outlined, - onPressed: () => onView(order), - ), - if (onNotify != null && order.canNotifyApprovers) - AppTableActionIcon( - tooltip: 'Notify approvers', - icon: Icons.notifications_outlined, - onPressed: () => onNotify!(order), - ), - if (onApprove != null && order.canApprove) - AppTableActionIcon( - tooltip: 'Approve', - icon: Icons.check_circle_outline, - color: theme.colorScheme.primary, - onPressed: () => onApprove!(order), - ), - if (onEdit != null && order.canEdit) - AppTableActionIcon( - tooltip: 'Edit', - icon: Icons.edit_outlined, - onPressed: () => onEdit!(order), - ), - if (onDelete != null && order.canDelete) - AppTableActionIcon( - tooltip: 'Delete', - icon: Icons.delete_outline, - onPressed: () => onDelete!(order), - ), - ], - ), - ), - ], + columns: columns, rows: orders, ); } diff --git a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart index c6fa2e2..e4ecdb1 100644 --- a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart +++ b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart @@ -1457,9 +1457,12 @@ class _PermissionMatrixTable extends ConsumerWidget { wrapInCard: false, columns: [ AppDataColumn( + id: 'module', label: 'Module', + sortKey: 'module', flex: 3, enableSearch: false, + searchText: (module) => module.name, cellBuilder: (context, module) { final index = matrix.modules.indexOf(module); final appearance = diff --git a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart index 4e9a4eb..247d278 100644 --- a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart +++ b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart @@ -10,6 +10,7 @@ import '../../../../core/utils/responsive_utils.dart'; import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/utils/file_download_helper.dart'; import '../../../rbac/presentation/widgets/rbac_widgets.dart'; +import '../../../../shared/providers/table_column_prefs_provider.dart'; import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_date_popup.dart'; import '../../../../shared/widgets/app_date_range_popup.dart'; @@ -21,6 +22,7 @@ import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_search_filter_toggle.dart'; +import '../../../../shared/widgets/app_table_column_selector.dart'; import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; @@ -157,6 +159,11 @@ class _DepreciationReportScreenState () => _filtersExpanded = !_filtersExpanded, ), ), + const SizedBox(width: 8), + AppTableColumnSelectorButton( + tableId: _ReportTable.tableId, + columns: _ReportTable.columnOptions, + ), if (canExport) ...[ const SizedBox(width: 8), OutlinedButton.icon( @@ -586,104 +593,153 @@ class _FiltersBarState extends State<_FiltersBar> { } } -class _ReportTable extends StatelessWidget { +class _ReportTable extends ConsumerWidget { const _ReportTable({ required this.items, this.onEnsureFullDataset, this.onColumnSearchCleared, }); + static const tableId = 'depreciation_report'; + + static List get columnOptions => const [ + AppTableColumnOption( + id: 'asset_code', + label: 'Asset Code', + required: true, + ), + AppTableColumnOption(id: 'asset_name', label: 'Asset Name'), + AppTableColumnOption(id: 'category', label: 'Category'), + AppTableColumnOption(id: 'location', label: 'Location'), + AppTableColumnOption(id: 'purchase_date', label: 'Purchase Date'), + AppTableColumnOption(id: 'purchase_cost', label: 'Purchase Cost'), + AppTableColumnOption(id: 'annual', label: 'Annual'), + AppTableColumnOption(id: 'accumulated', label: 'Accumulated'), + AppTableColumnOption(id: 'book_value', label: 'Book Value'), + ]; + final List items; final Future Function()? onEnsureFullDataset; final VoidCallback? onColumnSearchCleared; + List> _allColumns(BuildContext context) { + return [ + AppDataColumn( + id: 'asset_code', + label: 'Asset Code', + sortKey: 'asset_code', + locked: true, + flex: 2, + searchText: (row) => row.assetCode ?? '', + cellBuilder: (_, row) => AppTableCell.link( + row.assetCode, + onTap: row.id.trim().isEmpty + ? null + : () => context.push('${RouteConstants.assets}/${row.id}'), + ), + ), + AppDataColumn( + id: 'asset_name', + label: 'Asset Name', + sortKey: 'asset_name', + flex: 3, + searchText: (row) => row.assetName ?? '', + cellBuilder: (_, row) => AppTableCell.text(row.assetName), + ), + AppDataColumn( + id: 'category', + label: 'Category', + sortKey: 'category', + flex: 2, + searchText: (row) => row.categoryName ?? '', + cellBuilder: (_, row) => AppTableCell.text(row.categoryName), + ), + AppDataColumn( + id: 'location', + label: 'Location', + sortKey: 'location', + flex: 2, + searchText: (row) => row.locationName ?? '', + cellBuilder: (_, row) => AppTableCell.text(row.locationName), + ), + AppDataColumn( + id: 'purchase_date', + label: 'Purchase Date', + sortKey: 'purchase_date', + flex: 2, + searchText: (row) => DateFormatter.searchableDate(row.purchaseDate), + sortValue: (row) => row.purchaseDate, + cellBuilder: (_, row) => + AppTableCell.text(DateFormatter.displayDate(row.purchaseDate)), + ), + AppDataColumn( + id: 'purchase_cost', + label: 'Purchase Cost', + sortKey: 'purchase_cost', + flex: 2, + alignment: Alignment.centerRight, + searchText: (row) => CurrencyFormatter.searchable(row.purchaseCost), + sortValue: (row) => row.purchaseCost, + cellBuilder: (_, row) => AppTableCell.text( + CurrencyFormatter.format(row.purchaseCost), + textAlign: TextAlign.right, + ), + ), + AppDataColumn( + id: 'annual', + label: 'Annual', + sortKey: 'annual', + flex: 2, + alignment: Alignment.centerRight, + searchText: (row) => + CurrencyFormatter.searchable(row.annualDepreciation), + sortValue: (row) => row.annualDepreciation, + cellBuilder: (_, row) => AppTableCell.text( + CurrencyFormatter.format(row.annualDepreciation), + textAlign: TextAlign.right, + ), + ), + AppDataColumn( + id: 'accumulated', + label: 'Accumulated', + sortKey: 'accumulated', + flex: 2, + alignment: Alignment.centerRight, + searchText: (row) => + CurrencyFormatter.searchable(row.accumulatedDepreciation), + sortValue: (row) => row.accumulatedDepreciation, + cellBuilder: (_, row) => AppTableCell.text( + CurrencyFormatter.format(row.accumulatedDepreciation), + textAlign: TextAlign.right, + ), + ), + AppDataColumn( + id: 'book_value', + label: 'Book Value', + sortKey: 'book_value', + flex: 2, + alignment: Alignment.centerRight, + searchText: (row) => CurrencyFormatter.searchable(row.bookValue), + sortValue: (row) => row.bookValue, + cellBuilder: (_, row) => AppTableCell.text( + CurrencyFormatter.format(row.bookValue), + textAlign: TextAlign.right, + ), + ), + ]; + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final prefs = ref.watch(tableColumnPrefsProvider(tableId)); + final columns = resolveAppDataColumns(_allColumns(context), prefs); + return AppDataTable( wrapInCard: false, rows: items, onEnsureFullDataset: onEnsureFullDataset, onColumnSearchCleared: onColumnSearchCleared, - columns: [ - AppDataColumn( - label: 'Asset Code', - flex: 2, - searchText: (row) => row.assetCode ?? '', - cellBuilder: (_, row) => AppTableCell.link( - row.assetCode, - onTap: row.id.trim().isEmpty - ? null - : () => context.push('${RouteConstants.assets}/${row.id}'), - ), - ), - AppDataColumn( - label: 'Asset Name', - flex: 3, - searchText: (row) => row.assetName ?? '', - cellBuilder: (_, row) => AppTableCell.text(row.assetName), - ), - AppDataColumn( - label: 'Category', - flex: 2, - searchText: (row) => row.categoryName ?? '', - cellBuilder: (_, row) => AppTableCell.text(row.categoryName), - ), - AppDataColumn( - label: 'Location', - flex: 2, - searchText: (row) => row.locationName ?? '', - cellBuilder: (_, row) => AppTableCell.text(row.locationName), - ), - AppDataColumn( - label: 'Purchase Date', - flex: 2, - searchText: (row) => DateFormatter.searchableDate(row.purchaseDate), - cellBuilder: (_, row) => - AppTableCell.text(DateFormatter.displayDate(row.purchaseDate)), - ), - AppDataColumn( - label: 'Purchase Cost', - flex: 2, - alignment: Alignment.centerRight, - searchText: (row) => CurrencyFormatter.searchable(row.purchaseCost), - cellBuilder: (_, row) => AppTableCell.text( - CurrencyFormatter.format(row.purchaseCost), - textAlign: TextAlign.right, - ), - ), - AppDataColumn( - label: 'Annual', - flex: 2, - alignment: Alignment.centerRight, - searchText: (row) => - CurrencyFormatter.searchable(row.annualDepreciation), - cellBuilder: (_, row) => AppTableCell.text( - CurrencyFormatter.format(row.annualDepreciation), - textAlign: TextAlign.right, - ), - ), - AppDataColumn( - label: 'Accumulated', - flex: 2, - alignment: Alignment.centerRight, - searchText: (row) => - CurrencyFormatter.searchable(row.accumulatedDepreciation), - cellBuilder: (_, row) => AppTableCell.text( - CurrencyFormatter.format(row.accumulatedDepreciation), - textAlign: TextAlign.right, - ), - ), - AppDataColumn( - label: 'Book Value', - flex: 2, - alignment: Alignment.centerRight, - searchText: (row) => CurrencyFormatter.searchable(row.bookValue), - cellBuilder: (_, row) => AppTableCell.text( - CurrencyFormatter.format(row.bookValue), - textAlign: TextAlign.right, - ), - ), - ], + columns: columns, ); } } diff --git a/lib/modules/roles/presentation/screens/permission_matrix_screen.dart b/lib/modules/roles/presentation/screens/permission_matrix_screen.dart index 83f6b48..8e0071b 100644 --- a/lib/modules/roles/presentation/screens/permission_matrix_screen.dart +++ b/lib/modules/roles/presentation/screens/permission_matrix_screen.dart @@ -89,9 +89,12 @@ class _MatrixGrid extends ConsumerWidget { wrapInCard: true, columns: [ AppDataColumn( + id: 'module', label: 'Module', + sortKey: 'module', flex: 3, enableSearch: false, + searchText: (row) => row.name, cellBuilder: (_, row) => AppTableCell.text(row.name), ), ...matrix.actionColumns.map( diff --git a/lib/modules/roles/presentation/screens/role_list_screen.dart b/lib/modules/roles/presentation/screens/role_list_screen.dart index 458b5c6..da0f5c6 100644 --- a/lib/modules/roles/presentation/screens/role_list_screen.dart +++ b/lib/modules/roles/presentation/screens/role_list_screen.dart @@ -6,12 +6,14 @@ import 'package:go_router/go_router.dart'; import '../../../../core/errors/failure.dart'; import '../../../../core/utils/responsive_utils.dart'; import '../../../../shared/models/user_management_models.dart'; +import '../../../../shared/providers/table_column_prefs_provider.dart'; import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_search_filter_toggle.dart'; +import '../../../../shared/widgets/app_table_column_selector.dart'; import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/error_view.dart'; @@ -63,6 +65,11 @@ class _RoleListScreenState extends ConsumerState { () => _filtersExpanded = !_filtersExpanded, ), ), + const SizedBox(width: 8), + AppTableColumnSelectorButton( + tableId: _RoleDataTable.tableId, + columns: _RoleDataTable.columnOptions, + ), ], ), Expanded( @@ -128,7 +135,7 @@ class _RoleListScreenState extends ConsumerState { } } -class _RoleDataTable extends StatelessWidget { +class _RoleDataTable extends ConsumerWidget { const _RoleDataTable({ required this.roles, required this.onOpen, @@ -136,47 +143,82 @@ class _RoleDataTable extends StatelessWidget { this.onColumnSearchCleared, }); + static const tableId = 'roles_list'; + + static List get columnOptions => const [ + AppTableColumnOption( + id: 'role_name', + label: 'Role Name', + required: true, + ), + AppTableColumnOption(id: 'description', label: 'Description'), + AppTableColumnOption(id: 'users_count', label: 'Users Count'), + ]; + final List roles; final void Function(RoleCardModel role) onOpen; final Future Function()? onEnsureFullDataset; final VoidCallback? onColumnSearchCleared; + List> _allColumns() { + return [ + AppDataColumn( + id: 'role_name', + label: 'Role Name', + sortKey: 'role_name', + locked: true, + flex: 2, + searchText: (r) => r.name, + cellBuilder: (_, r) => Text(r.name), + ), + AppDataColumn( + id: 'description', + label: 'Description', + sortKey: 'description', + flex: 3, + searchText: (r) => r.description ?? '', + cellBuilder: (_, r) => Text(r.description ?? '—'), + ), + AppDataColumn( + id: 'users_count', + label: 'Users Count', + sortKey: 'users_count', + flex: 1, + searchText: (r) => '${r.userCount}', + sortValue: (r) => r.userCount, + cellBuilder: (_, r) => Text('${r.userCount}'), + ), + AppDataColumn( + id: 'actions', + label: 'Actions', + flex: 1, + alignment: Alignment.centerRight, + enableSearch: false, + locked: true, + includeInColumnSelector: false, + cellBuilder: (_, r) => AppTableActions( + children: [ + AppTableActionIcon( + tooltip: 'View Matrix', + icon: Icons.grid_view_outlined, + onPressed: () => onOpen(r), + ), + ], + ), + ), + ]; + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final prefs = ref.watch(tableColumnPrefsProvider(tableId)); + final columns = resolveAppDataColumns(_allColumns(), prefs); + return AppDataTable( wrapInCard: false, onEnsureFullDataset: onEnsureFullDataset, onColumnSearchCleared: onColumnSearchCleared, - columns: [ - AppDataColumn(label: 'Role Name', flex: 2, searchText: (r) => r.name, cellBuilder: (_, r) => Text(r.name)), - AppDataColumn( - label: 'Description', - flex: 3, - searchText: (r) => r.description ?? '', - cellBuilder: (_, r) => Text(r.description ?? '—'), - ), - AppDataColumn( - label: 'Users Count', - flex: 1, - searchText: (r) => '${r.userCount}', - cellBuilder: (_, r) => Text('${r.userCount}'), - ), - AppDataColumn( - label: 'Actions', - flex: 1, - alignment: Alignment.centerRight, - enableSearch: false, - cellBuilder: (_, r) => AppTableActions( - children: [ - AppTableActionIcon( - tooltip: 'View Matrix', - icon: Icons.grid_view_outlined, - onPressed: () => onOpen(r), - ), - ], - ), - ), - ], + columns: columns, rows: roles, ); } diff --git a/lib/modules/users/presentation/screens/user_list_screen.dart b/lib/modules/users/presentation/screens/user_list_screen.dart index 568a6cf..2de0900 100644 --- a/lib/modules/users/presentation/screens/user_list_screen.dart +++ b/lib/modules/users/presentation/screens/user_list_screen.dart @@ -21,6 +21,7 @@ import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/kpi_card.dart'; import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/app_table_action_icon.dart'; +import '../../../../shared/widgets/app_table_column_selector.dart'; import '../../../../shared/widgets/page_header.dart'; import '../widgets/user_rich_data_table.dart'; import '../providers/users_provider.dart'; @@ -69,6 +70,11 @@ class _UserListScreenState extends ConsumerState { ), ), const SizedBox(width: 8), + AppTableColumnSelectorButton( + tableId: UserRichDataTable.tableId, + columns: UserRichDataTable.columnOptions, + ), + const SizedBox(width: 8), ElevatedButton.icon( onPressed: () => context.push(RouteConstants.userAdd), icon: const Icon(Icons.person_add), diff --git a/lib/modules/users/presentation/widgets/user_rich_data_table.dart b/lib/modules/users/presentation/widgets/user_rich_data_table.dart index dedd59f..90e0894 100644 --- a/lib/modules/users/presentation/widgets/user_rich_data_table.dart +++ b/lib/modules/users/presentation/widgets/user_rich_data_table.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/utils/formatters.dart'; import '../../../../shared/models/user_management_models.dart'; +import '../../../../shared/providers/table_column_prefs_provider.dart'; import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_status_chip.dart'; +import '../../../../shared/widgets/app_table_column_selector.dart'; import '../../../rbac/presentation/widgets/rbac_widgets.dart'; typedef UserTableActionsBuilder = Widget Function( @@ -11,7 +14,7 @@ typedef UserTableActionsBuilder = Widget Function( ManagedUserModel user, ); -class UserRichDataTable extends StatelessWidget { +class UserRichDataTable extends ConsumerWidget { const UserRichDataTable({ super.key, required this.users, @@ -25,6 +28,8 @@ class UserRichDataTable extends StatelessWidget { this.onColumnSearchCleared, }); + static const tableId = 'users_list'; + final List users; final UserTableActionsBuilder actionsBuilder; final String? sortColumn; @@ -35,9 +40,92 @@ class UserRichDataTable extends StatelessWidget { final Future Function()? onEnsureFullDataset; final VoidCallback? onColumnSearchCleared; + static List get columnOptions => const [ + AppTableColumnOption(id: 'user', label: 'User', required: true), + AppTableColumnOption(id: 'employee_code', label: 'Employee Code'), + AppTableColumnOption(id: 'role', label: 'Role'), + AppTableColumnOption(id: 'department', label: 'Department'), + AppTableColumnOption(id: 'last_login', label: 'Last Login'), + AppTableColumnOption(id: 'status', label: 'Status'), + ]; + + List> _allColumns(ThemeData theme) { + return [ + AppDataColumn( + id: 'user', + label: 'User', + sortKey: 'full_name', + locked: true, + flex: 3, + searchText: (user) => '${user.fullName} ${user.email}'.trim(), + cellBuilder: (_, user) => UserTableUserCell(user: user), + ), + AppDataColumn( + id: 'employee_code', + label: 'Employee Code', + sortKey: 'employee_code', + flex: 1, + alignment: Alignment.centerRight, + searchText: (user) => user.employeeCode, + cellBuilder: (_, user) => EmployeeCodeBadge(code: user.employeeCode), + ), + AppDataColumn( + id: 'role', + label: 'Role', + flex: 2, + padding: const EdgeInsets.only(left: 8), + searchText: (user) => + '${user.roleNames.join(' ')} ${user.roleLabel}'.trim(), + cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames), + ), + AppDataColumn( + id: 'department', + label: 'Department', + flex: 2, + searchText: (user) => user.departmentName ?? '', + cellBuilder: (_, user) => Text(user.departmentLabel), + ), + AppDataColumn( + id: 'last_login', + label: 'Last Login', + flex: 2, + searchText: (user) => DateFormatter.searchableDate(user.lastLoginAt), + cellBuilder: (_, user) => Text( + DateFormatter.formatUserLastLogin(user.lastLoginAt), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + AppDataColumn( + id: 'status', + label: 'Status', + flex: 1, + searchText: (user) => user.status, + cellBuilder: (_, user) => AppStatusChip( + status: user.status, + compact: true, + forTable: true, + ), + ), + AppDataColumn( + id: 'actions', + label: 'Actions', + flex: 1, + alignment: Alignment.centerRight, + enableSearch: false, + locked: true, + includeInColumnSelector: false, + cellBuilder: (context, user) => actionsBuilder(context, user), + ), + ]; + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); + final prefs = ref.watch(tableColumnPrefsProvider(tableId)); + final columns = resolveAppDataColumns(_allColumns(theme), prefs); return AppDataTable( wrapInCard: wrapInCard, @@ -47,67 +135,7 @@ class UserRichDataTable extends StatelessWidget { onServerSearchChanged: onServerSearchChanged, onEnsureFullDataset: onEnsureFullDataset, onColumnSearchCleared: onColumnSearchCleared, - columns: [ - AppDataColumn( - label: 'User', - sortKey: 'full_name', - flex: 3, - searchText: (user) => - '${user.fullName} ${user.email}'.trim(), - cellBuilder: (_, user) => UserTableUserCell(user: user), - ), - AppDataColumn( - label: 'Employee Code', - sortKey: 'employee_code', - flex: 1, - alignment: Alignment.centerRight, - searchText: (user) => user.employeeCode, - cellBuilder: (_, user) => EmployeeCodeBadge(code: user.employeeCode), - ), - AppDataColumn( - label: 'Role', - flex: 2, - padding: const EdgeInsets.only(left: 8), - searchText: (user) => - '${user.roleNames.join(' ')} ${user.roleLabel}'.trim(), - cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames), - ), - AppDataColumn( - label: 'Department', - flex: 2, - searchText: (user) => user.departmentName ?? '', - cellBuilder: (_, user) => Text(user.departmentLabel), - ), - AppDataColumn( - label: 'Last Login', - flex: 2, - searchText: (user) => - DateFormatter.searchableDate(user.lastLoginAt), - cellBuilder: (_, user) => Text( - DateFormatter.formatUserLastLogin(user.lastLoginAt), - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - AppDataColumn( - label: 'Status', - flex: 1, - searchText: (user) => user.status, - cellBuilder: (_, user) => AppStatusChip( - status: user.status, - compact: true, - forTable: true, - ), - ), - AppDataColumn( - label: 'Actions', - flex: 1, - alignment: Alignment.centerRight, - enableSearch: false, - cellBuilder: (context, user) => actionsBuilder(context, user), - ), - ], + columns: columns, rows: users, ); } diff --git a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart index 4a2c09a..361fbfd 100644 --- a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart @@ -21,10 +21,12 @@ import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_search_filter_toggle.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/can_permission.dart'; import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; +import '../../../../shared/providers/table_column_prefs_provider.dart'; import '../../../../shared/utils/file_download_helper.dart'; import '../providers/vendors_provider.dart'; import '../widgets/vendor_form_panel.dart'; @@ -86,6 +88,11 @@ class _VendorListScreenState extends ConsumerState { () => _filtersExpanded = !_filtersExpanded, ), ), + const SizedBox(width: 8), + AppTableColumnSelectorButton( + tableId: _VendorDataTable.tableId, + columns: _VendorDataTable.columnOptions, + ), if (canExport) ...[ const SizedBox(width: 8), OutlinedButton.icon( @@ -288,7 +295,7 @@ class _FiltersBar extends StatelessWidget { } } -class _VendorDataTable extends StatelessWidget { +class _VendorDataTable extends ConsumerWidget { const _VendorDataTable({ required this.vendors, required this.onView, @@ -298,6 +305,20 @@ class _VendorDataTable extends StatelessWidget { this.onColumnSearchCleared, }); + static const tableId = 'vendors_list'; + + static List get columnOptions => const [ + AppTableColumnOption( + id: 'code', + label: 'Code', + required: true, + ), + AppTableColumnOption(id: 'name', label: 'Name'), + AppTableColumnOption(id: 'type', label: 'Type'), + AppTableColumnOption(id: 'gstin', label: 'GSTIN'), + AppTableColumnOption(id: 'status', label: 'Status'), + ]; + final List vendors; final ValueChanged onView; final ValueChanged? onEdit; @@ -305,79 +326,100 @@ class _VendorDataTable extends StatelessWidget { final Future Function()? onEnsureFullDataset; final VoidCallback? onColumnSearchCleared; + List> _allColumns() { + return [ + AppDataColumn( + id: 'code', + label: 'Code', + sortKey: 'vendor_code', + locked: true, + flex: 1, + searchText: (vendor) => vendor.vendorCode ?? '', + cellBuilder: (_, vendor) => AppTableCell.link( + vendor.vendorCode, + onTap: () => onView(vendor), + ), + ), + AppDataColumn( + id: 'name', + label: 'Name', + sortKey: 'vendor_name', + flex: 2, + searchText: (vendor) => vendor.vendorName, + cellBuilder: (_, vendor) => Text(vendor.vendorName), + ), + AppDataColumn( + id: 'type', + label: 'Type', + sortKey: 'vendor_type', + flex: 2, + searchText: (vendor) => vendorTypeLabel(vendor.vendorType), + cellBuilder: (_, vendor) => Text(vendorTypeLabel(vendor.vendorType)), + ), + AppDataColumn( + id: 'gstin', + label: 'GSTIN', + sortKey: 'gstin', + flex: 2, + searchText: (vendor) => vendor.gstin ?? '', + cellBuilder: (_, vendor) => Text(vendor.gstin ?? '—'), + ), + AppDataColumn( + id: 'status', + label: 'Status', + sortKey: 'status', + flex: 1, + searchText: (vendor) => + vendor.status ?? (vendor.isActive ? 'active' : 'inactive'), + cellBuilder: (_, vendor) => AppStatusChip( + status: vendor.status ?? (vendor.isActive ? 'active' : 'inactive'), + compact: true, + forTable: true, + ), + ), + AppDataColumn( + id: 'actions', + label: 'Actions', + flex: 1, + alignment: Alignment.centerRight, + enableSearch: false, + locked: true, + includeInColumnSelector: false, + cellBuilder: (_, vendor) => AppTableActions( + children: [ + AppTableActionIcon( + tooltip: 'View', + icon: Icons.visibility_outlined, + onPressed: () => onView(vendor), + ), + if (onEdit != null) + AppTableActionIcon( + tooltip: 'Edit', + icon: Icons.edit_outlined, + onPressed: () => onEdit!(vendor), + ), + if (onDelete != null) + AppTableActionIcon( + tooltip: 'Delete', + icon: Icons.delete_outline, + onPressed: () => onDelete!(vendor), + ), + ], + ), + ), + ]; + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final prefs = ref.watch(tableColumnPrefsProvider(tableId)); + final columns = resolveAppDataColumns(_allColumns(), prefs); + return AppDataTable( wrapInCard: false, onEnsureFullDataset: onEnsureFullDataset, onColumnSearchCleared: onColumnSearchCleared, - columns: [ - AppDataColumn( - label: 'Code', - flex: 1, - searchText: (vendor) => vendor.vendorCode ?? '', - cellBuilder: (_, vendor) => AppTableCell.link( - vendor.vendorCode, - onTap: () => onView(vendor), - ), - ), - AppDataColumn( - label: 'Name', - flex: 2, - searchText: (vendor) => vendor.vendorName, - cellBuilder: (_, vendor) => Text(vendor.vendorName), - ), - AppDataColumn( - label: 'Type', - flex: 2, - searchText: (vendor) => vendorTypeLabel(vendor.vendorType), - cellBuilder: (_, vendor) => Text(vendorTypeLabel(vendor.vendorType)), - ), - AppDataColumn( - label: 'GSTIN', - flex: 2, - searchText: (vendor) => vendor.gstin ?? '', - cellBuilder: (_, vendor) => Text(vendor.gstin ?? '—'), - ), - AppDataColumn( - label: 'Status', - flex: 1, - searchText: (vendor) => - vendor.status ?? (vendor.isActive ? 'active' : 'inactive'), - cellBuilder: (_, vendor) => AppStatusChip( - status: vendor.status ?? (vendor.isActive ? 'active' : 'inactive'), - compact: true, - forTable: true, - ), - ), - AppDataColumn( - label: 'Actions', - flex: 1, - alignment: Alignment.centerRight, - enableSearch: false, - cellBuilder: (_, vendor) => AppTableActions( - children: [ - AppTableActionIcon( - tooltip: 'View', - icon: Icons.visibility_outlined, - onPressed: () => onView(vendor), - ), - if (onEdit != null) - AppTableActionIcon( - tooltip: 'Edit', - icon: Icons.edit_outlined, - onPressed: () => onEdit!(vendor), - ), - if (onDelete != null) - AppTableActionIcon( - tooltip: 'Delete', - icon: Icons.delete_outline, - onPressed: () => onDelete!(vendor), - ), - ], - ), - ), - ], + columns: columns, rows: vendors, ); } diff --git a/lib/shared/models/table_column_prefs.dart b/lib/shared/models/table_column_prefs.dart new file mode 100644 index 0000000..c7a2e76 --- /dev/null +++ b/lib/shared/models/table_column_prefs.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +/// Persisted visibility + order for one data table. +class TableColumnPrefs { + const TableColumnPrefs({ + this.order = const [], + this.hidden = const {}, + this.locked = const {}, + }); + + /// Column ids in display order. Missing ids append in definition order. + final List order; + + /// Column ids that are hidden (must not include required/locked columns). + final Set hidden; + + /// User-locked column ids (cannot be hidden until unlocked). + final Set locked; + + static const empty = TableColumnPrefs(); + + bool isHidden(String id) => hidden.contains(id); + + bool isLocked(String id, {required bool required}) => + required || locked.contains(id); + + TableColumnPrefs copyWith({ + List? order, + Set? hidden, + Set? locked, + }) { + return TableColumnPrefs( + order: order ?? this.order, + hidden: hidden ?? this.hidden, + locked: locked ?? this.locked, + ); + } + + Map toJson() => { + 'order': order, + 'hidden': hidden.toList(), + 'locked': locked.toList(), + }; + + factory TableColumnPrefs.fromJson(Map json) { + return TableColumnPrefs( + order: (json['order'] as List?)?.map((e) => e.toString()).toList() ?? + const [], + hidden: { + for (final e in (json['hidden'] as List?) ?? const []) e.toString(), + }, + locked: { + for (final e in (json['locked'] as List?) ?? const []) e.toString(), + }, + ); + } + + String encode() => jsonEncode(toJson()); + + static TableColumnPrefs decode(String? raw) { + if (raw == null || raw.trim().isEmpty) return empty; + try { + final map = jsonDecode(raw); + if (map is! Map) return empty; + return TableColumnPrefs.fromJson(Map.from(map)); + } catch (_) { + return empty; + } + } +} diff --git a/lib/shared/providers/table_column_prefs_provider.dart b/lib/shared/providers/table_column_prefs_provider.dart new file mode 100644 index 0000000..e0f46a1 --- /dev/null +++ b/lib/shared/providers/table_column_prefs_provider.dart @@ -0,0 +1,51 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../core/constants/storage_keys.dart'; +import '../../core/theme/theme_provider.dart'; +import '../models/table_column_prefs.dart'; + +final tableColumnPrefsProvider = StateNotifierProvider.family< + TableColumnPrefsNotifier, TableColumnPrefs, String>( + (ref, tableId) => TableColumnPrefsNotifier( + ref.watch(sharedPreferencesProvider), + tableId, + ), +); + +class TableColumnPrefsNotifier extends StateNotifier { + TableColumnPrefsNotifier(this._prefs, this.tableId) + : super(TableColumnPrefs.empty) { + _load(); + } + + final SharedPreferences _prefs; + final String tableId; + + String get _key => StorageKeys.tableColumns(tableId); + + void _load() { + state = TableColumnPrefs.decode(_prefs.getString(_key)); + } + + Future _persist(TableColumnPrefs next) async { + state = next; + await _prefs.setString(_key, next.encode()); + } + + Future setOrder(List order) => + _persist(state.copyWith(order: List.from(order))); + + Future setHidden(Set hidden) => + _persist(state.copyWith(hidden: Set.from(hidden))); + + Future setLocked(Set locked) => + _persist(state.copyWith(locked: Set.from(locked))); + + Future replace(TableColumnPrefs prefs) => _persist(prefs); + + Future reset() async { + state = TableColumnPrefs.empty; + await _prefs.remove(_key); + } +} diff --git a/lib/shared/routes/app_router.dart b/lib/shared/routes/app_router.dart index 1be11d6..bcbedfa 100644 --- a/lib/shared/routes/app_router.dart +++ b/lib/shared/routes/app_router.dart @@ -89,6 +89,12 @@ final routerProvider = Provider((ref) { return RouteConstants.dashboard; } + // Legacy /assets → /assetsmaster + final fullPath = state.uri.path; + if (fullPath == '/assets' || fullPath.startsWith('/assets/')) { + return fullPath.replaceFirst('/assets', RouteConstants.assets); + } + return null; }, routes: [ diff --git a/lib/shared/routes/menu_config.dart b/lib/shared/routes/menu_config.dart index 82b371d..ad299e7 100644 --- a/lib/shared/routes/menu_config.dart +++ b/lib/shared/routes/menu_config.dart @@ -106,10 +106,10 @@ const List appMenuItems = [ module: 'reports', ), MenuItem( - label: 'Support', - icon: Icons.support_agent_outlined, + label: 'Manage', + icon: Icons.admin_panel_settings_outlined, route: RouteConstants.vendors, - module: 'support', + module: 'administrator', children: [ MenuItem( label: 'Vendors', @@ -119,7 +119,7 @@ const List appMenuItems = [ ), MenuItem( label: 'Users & Roles', - icon: Icons.admin_panel_settings_outlined, + icon: Icons.manage_accounts_outlined, route: RouteConstants.usersRoleManagement, module: 'users', ), @@ -145,18 +145,28 @@ const List appMenuItems = [ ), ]; -/// True when [item] is the Support group (top-nav dropdown / sidebar section). -bool isSupportMenuItem(MenuItem item) => - item.module == 'support' || item.label == 'Support'; +/// True when [item] is the Manage group (sidebar / top-nav). +bool isAdministratorMenuItem(MenuItem item) => + item.module == 'administrator' || + item.module == 'support' || + item.label == 'Manage' || + item.label == 'Administrator' || + item.label == 'Support'; -/// Child routes that belong under Support (sidebar section flattening). -const Set supportMenuRoutes = { +/// Child routes that belong under Manage. +const Set administratorMenuRoutes = { RouteConstants.vendors, RouteConstants.usersRoleManagement, RouteConstants.masterData, RouteConstants.auditLogs, RouteConstants.settings, }; + +@Deprecated('Use isAdministratorMenuItem') +bool isSupportMenuItem(MenuItem item) => isAdministratorMenuItem(item); + +@Deprecated('Use administratorMenuRoutes') +const Set supportMenuRoutes = administratorMenuRoutes; List getVisibleMenuItems({ required List permissions, required UserRole role, diff --git a/lib/shared/widgets/app_data_table.dart b/lib/shared/widgets/app_data_table.dart index 402d271..5eb682b 100644 --- a/lib/shared/widgets/app_data_table.dart +++ b/lib/shared/widgets/app_data_table.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../core/utils/table_search.dart'; +import '../models/table_column_prefs.dart'; import 'app_card.dart'; /// Fixed height for every data row in [AppDataTable] and themed [DataTable] widgets. @@ -16,18 +17,30 @@ class AppDataColumn { const AppDataColumn({ required this.label, required this.cellBuilder, + this.id, this.sortKey, + this.sortValue, this.flex = 1, this.width, this.alignment = Alignment.centerLeft, this.padding = EdgeInsets.zero, this.searchText, this.enableSearch, + this.locked = false, + this.includeInColumnSelector = true, }); + /// Stable id for column prefs (visibility / order). Defaults to [sortKey] or [label]. + final String? id; + final String label; final Widget Function(BuildContext context, T row) cellBuilder; final String? sortKey; + + /// Optional comparable used for client-side sorting when [AppDataTable.onSort] + /// is not provided. Falls back to [searchText] string comparison. + final Comparable? Function(T row)? sortValue; + final int flex; /// When set, column uses a fixed width instead of [flex]. @@ -43,7 +56,46 @@ class AppDataColumn { /// When null, search is enabled only if [searchText] is provided. final bool? enableSearch; + /// When true, column cannot be hidden and shows a locked padlock in the selector. + final bool locked; + + /// When false, column is always shown and omitted from the column selector + /// (typical for Actions). + final bool includeInColumnSelector; + bool get isSearchable => enableSearch ?? searchText != null; + + String get columnId => id ?? sortKey ?? label; +} + +/// Applies [prefs] order + visibility to [columns]. +List> resolveAppDataColumns( + List> columns, + TableColumnPrefs prefs, +) { + if (columns.isEmpty) return columns; + + final byId = >{ + for (final c in columns) c.columnId: c, + }; + final ordered = >[]; + final seen = {}; + + for (final id in prefs.order) { + final col = byId[id]; + if (col == null || seen.contains(id)) continue; + ordered.add(col); + seen.add(id); + } + for (final col in columns) { + if (seen.add(col.columnId)) ordered.add(col); + } + + return ordered.where((col) { + if (!col.includeInColumnSelector) return true; + if (col.locked || prefs.locked.contains(col.columnId)) return true; + return !prefs.hidden.contains(col.columnId); + }).toList(); } /// Shared column sizing: fixed [AppDataColumn.width] or flexible [AppDataColumn.flex]. @@ -188,9 +240,32 @@ class _AppDataTableState extends State> { bool _fullDatasetActive = false; bool _ensureInFlight = false; + /// Client-side sort when [AppDataTable.onSort] is null. + String? _clientSortColumn; + bool _clientSortAscending = true; + bool get _usesFullDatasetMode => widget.onEnsureFullDataset != null || widget.onColumnSearchCleared != null; + bool get _usesClientSort => widget.onSort == null; + + String? get _activeSortColumn => + _usesClientSort ? _clientSortColumn : widget.sortColumn; + + bool get _activeSortAscending => + _usesClientSort ? _clientSortAscending : widget.sortAscending; + + void _handleSort(String column, bool ascending) { + if (widget.onSort != null) { + widget.onSort!(column, ascending); + return; + } + setState(() { + _clientSortColumn = column; + _clientSortAscending = ascending; + }); + } + @override void dispose() { _ensureDatasetDebouncer.dispose(); @@ -253,6 +328,7 @@ class _AppDataTableState extends State> { for (final entry in _queries.entries) { final q = entry.value.trim().toLowerCase(); if (q.isEmpty) continue; + if (entry.key < 0 || entry.key >= widget.columns.length) continue; final col = widget.columns[entry.key]; if (!col.isSearchable || col.searchText == null) continue; active[entry.key] = q; @@ -269,10 +345,47 @@ class _AppDataTableState extends State> { }).toList(); } + List get _displayRows { + final rows = List.from(_filteredRows); + final sortKey = _activeSortColumn; + if (sortKey == null) return rows; + + AppDataColumn? col; + for (final c in widget.columns) { + if (c.sortKey == sortKey) { + col = c; + break; + } + } + if (col == null) return rows; + + Comparable? valueOf(T row) { + if (col!.sortValue != null) return col.sortValue!(row); + if (col.searchText != null) return col.searchText!(row).toLowerCase(); + return null; + } + + rows.sort((a, b) { + final av = valueOf(a); + final bv = valueOf(b); + if (av == null && bv == null) return 0; + if (av == null) return 1; + if (bv == null) return -1; + try { + final cmp = av.compareTo(bv); + return _activeSortAscending ? cmp : -cmp; + } catch (_) { + final cmp = av.toString().compareTo(bv.toString()); + return _activeSortAscending ? cmp : -cmp; + } + }); + return rows; + } + @override Widget build(BuildContext context) { final showFilterRow = widget.columns.any((c) => c.isSearchable); - final filtered = _filteredRows; + final filtered = _displayRows; // Keep header + column filters visible even when the API returns no rows, // so users can refine or clear a server-side search. @@ -294,9 +407,9 @@ class _AppDataTableState extends State> { final header = _TableHeaderRow( columns: widget.columns, - sortColumn: widget.sortColumn, - sortAscending: widget.sortAscending, - onSort: widget.onSort, + sortColumn: _activeSortColumn, + sortAscending: _activeSortAscending, + onSort: _handleSort, ); final filterRow = showFilterRow @@ -453,6 +566,16 @@ class _TableHeaderRow extends StatelessWidget { if (col.sortKey == null || onSort == null) return label; + final IconData sortIcon; + final Color sortIconColor; + if (isSorted) { + sortIcon = sortAscending ? Icons.arrow_upward : Icons.arrow_downward; + sortIconColor = theme.colorScheme.primary; + } else { + sortIcon = Icons.unfold_more; + sortIconColor = theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.55); + } + return InkWell( onTap: () => onSort( col.sortKey!, @@ -462,14 +585,8 @@ class _TableHeaderRow extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Flexible(child: label), - if (isSorted) ...[ - const SizedBox(width: 4), - Icon( - sortAscending ? Icons.arrow_upward : Icons.arrow_downward, - size: 14, - color: theme.colorScheme.onSurfaceVariant, - ), - ], + const SizedBox(width: 4), + Icon(sortIcon, size: 14, color: sortIconColor), ], ), ); diff --git a/lib/shared/widgets/app_sidebar.dart b/lib/shared/widgets/app_sidebar.dart index d5da160..23fe4a5 100644 --- a/lib/shared/widgets/app_sidebar.dart +++ b/lib/shared/widgets/app_sidebar.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -58,32 +59,20 @@ class _AppSidebarState extends ConsumerState { final Set _manuallyCollapsedMenus = {}; List get _primaryMenuItems => widget.menuItems - .where((item) => !menu.isSupportMenuItem(item)) + .where((item) => !menu.isAdministratorMenuItem(item)) .toList(); - /// Sidebar shows Support children flat under the SUPPORT section label. - /// Top nav keeps the Support parent as a dropdown (via [menuItems] as-is). - List get _supportMenuItems { - final items = []; + /// Collapsible Administrator group pinned above the user profile. + menu.MenuItem? get _administratorMenuItem { for (final item in widget.menuItems) { - if (menu.isSupportMenuItem(item)) { - if (item.children.isNotEmpty) { - items.addAll(item.children); - } else { - items.add(item); - } - continue; - } - if (menu.supportMenuRoutes.contains(item.route) && - item.children.isEmpty) { - items.add(item); + if (menu.isAdministratorMenuItem(item) && item.children.isNotEmpty) { + return item; } } - return items; + return null; } - bool get _hasSupportSection => - _supportMenuItems.isNotEmpty || AppConstants.showNotificationsMenu; + bool get _hasAdministratorMenu => _administratorMenuItem != null; bool _routeMatches(String route) { final current = widget.currentRoute; @@ -113,9 +102,13 @@ class _AppSidebarState extends ConsumerState { return _expandedMenus.contains(item.route) || _isGroupActive(item); } - Iterable get _allGroupItems => - [..._primaryMenuItems, ..._supportMenuItems] - .where((item) => item.children.isNotEmpty); + Iterable get _allGroupItems sync* { + for (final item in _primaryMenuItems) { + if (item.children.isNotEmpty) yield item; + } + final admin = _administratorMenuItem; + if (admin != null) yield admin; + } /// Accordion: keep only [keepRoute] expanded; collapse every other group. void _expandOnly(String keepRoute) { @@ -211,27 +204,37 @@ class _AppSidebarState extends ConsumerState { children: [ if (isNarrow) const SizedBox(height: 4), ..._buildNavItems(_primaryMenuItems, isNarrow: isNarrow), - if (_hasSupportSection) ...[ - const SizedBox(height: 16), - if (!isNarrow) const _SectionLabel(label: 'SUPPORT'), - if (AppConstants.showNotificationsMenu) - _SidebarNavItem( - icon: Icons.notifications_outlined, - label: 'Notifications', - selected: false, - collapsed: isNarrow, - badge: isNarrow ? null : '3', - onTap: () {}, - ), - ..._buildNavItems( - _supportMenuItems, - isNarrow: isNarrow, + if (AppConstants.showNotificationsMenu) + _SidebarNavItem( + icon: Icons.notifications_outlined, + label: 'Notifications', + selected: false, + collapsed: isNarrow, + badge: isNarrow ? null : '3', + onTap: () {}, ), - ], ], ), ), ), + if (_hasAdministratorMenu) ...[ + Padding( + padding: EdgeInsets.fromLTRB( + isNarrow ? 8 : 12, + 4, + isNarrow ? 8 : 12, + 4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _buildNavItems( + [_administratorMenuItem!], + isNarrow: isNarrow, + expandUpward: true, + ), + ), + ), + ], _buildUserProfile(context, isNarrow: isNarrow), ], ); @@ -245,6 +248,7 @@ class _AppSidebarState extends ConsumerState { List _buildNavItems( List items, { required bool isNarrow, + bool expandUpward = false, }) { return items.map((item) { if (isNarrow && item.children.isNotEmpty) { @@ -260,33 +264,46 @@ class _AppSidebarState extends ConsumerState { if (item.children.isNotEmpty && !isNarrow) { final expanded = _isGroupExpanded(item); final active = _isGroupActive(item); + final childrenPanel = AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOutCubic, + alignment: + expandUpward ? Alignment.bottomCenter : Alignment.topCenter, + child: expanded + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final child in item.children) + _SidebarNavItem( + icon: child.icon, + label: child.label, + selected: _isSelectedAmongSiblings( + child.route, + item.children, + ), + collapsed: false, + indent: _sidebarChildIndent, + onTap: () => widget.onItemTap(child.route), + ), + ], + ) + : const SizedBox(width: double.infinity), + ); + final header = _SidebarNavItem( + icon: item.icon, + label: item.label, + selected: active, + collapsed: false, + showChevron: true, + chevronExpanded: expanded, + onTap: () => _toggleGroup(item), + ); + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _SidebarNavItem( - icon: item.icon, - label: item.label, - selected: active, - collapsed: false, - showChevron: true, - chevronExpanded: expanded, - onTap: () => _toggleGroup(item), - ), - if (expanded) - ...item.children.map( - (child) => _SidebarNavItem( - icon: child.icon, - label: child.label, - selected: _isSelectedAmongSiblings( - child.route, - item.children, - ), - collapsed: false, - indent: _sidebarChildIndent, - onTap: () => widget.onItemTap(child.route), - ), - ), - ], + children: expandUpward + ? [childrenPanel, header] + : [header, childrenPanel], ); } @@ -554,6 +571,32 @@ class _CollapsedFlyoutNavItemState extends State<_CollapsedFlyoutNavItem> { final anchorTopLeft = renderBox.localToGlobal(Offset.zero); final anchorSize = renderBox.size; const panelWidth = 220.0; + const panelHeaderHeight = 42.0; + const panelDividerHeight = 1.0; + final panelHeight = panelHeaderHeight + + panelDividerHeight + + (widget.item.children.length * _sidebarItemHeight); + + final media = MediaQuery.of(context); + final screenSize = media.size; + final viewPadding = media.viewPadding; + const edgePadding = 8.0; + final minTop = viewPadding.top + edgePadding; + final maxBottom = screenSize.height - viewPadding.bottom - edgePadding; + final belowSpace = maxBottom - anchorTopLeft.dy; + final aboveSpace = anchorTopLeft.dy + anchorSize.height - minTop; + final showAbove = belowSpace < panelHeight && aboveSpace > belowSpace; + + var top = showAbove + ? (anchorTopLeft.dy + anchorSize.height - panelHeight) + : anchorTopLeft.dy; + top = top.clamp(minTop, math.max(minTop, maxBottom - panelHeight)); + + final maxLeft = math.max( + edgePadding, + screenSize.width - panelWidth - anchorSize.width - edgePadding, + ); + final left = anchorTopLeft.dx.clamp(edgePadding, maxLeft); _overlayEntry = OverlayEntry( builder: (overlayContext) { @@ -561,8 +604,8 @@ class _CollapsedFlyoutNavItemState extends State<_CollapsedFlyoutNavItem> { final primary = theme.colorScheme.primary; return Positioned( - left: anchorTopLeft.dx, - top: anchorTopLeft.dy, + left: left, + top: top, child: TapRegion( onTapOutside: (_) => _removeOverlay(), child: MouseRegion( @@ -807,27 +850,6 @@ class _LogoDivider extends StatelessWidget { } } -class _SectionLabel extends StatelessWidget { - const _SectionLabel({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(left: 12, bottom: 8), - child: Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - letterSpacing: 0.8, - ), - ), - ); - } -} - class _SidebarNavItem extends StatelessWidget { const _SidebarNavItem({ required this.icon, @@ -940,12 +962,15 @@ class _SidebarNavItem extends StatelessWidget { ), ), if (showChevron) - Icon( - chevronExpanded - ? Icons.keyboard_arrow_up - : Icons.keyboard_arrow_down, - size: 18, - color: theme.colorScheme.onSurfaceVariant, + AnimatedRotation( + turns: chevronExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOutCubic, + child: Icon( + Icons.keyboard_arrow_down, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), ), ], ), diff --git a/lib/shared/widgets/app_table_column_selector.dart b/lib/shared/widgets/app_table_column_selector.dart new file mode 100644 index 0000000..0baf5f0 --- /dev/null +++ b/lib/shared/widgets/app_table_column_selector.dart @@ -0,0 +1,491 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/theme/app_typography.dart'; +import '../models/table_column_prefs.dart'; +import '../providers/table_column_prefs_provider.dart'; +import 'app_data_table.dart'; + +/// Column definition used by the selector (label + flags, no cell builders). +class AppTableColumnOption { + const AppTableColumnOption({ + required this.id, + required this.label, + this.required = false, + }); + + final String id; + final String label; + + /// Permanently locked — cannot hide or unlock. + final bool required; +} + +AppTableColumnOption appTableColumnOptionFrom(AppDataColumn column) { + return AppTableColumnOption( + id: column.columnId, + label: column.label, + required: column.locked, + ); +} + +/// Toolbar button that opens the column visibility / reorder menu. +class AppTableColumnSelectorButton extends ConsumerStatefulWidget { + const AppTableColumnSelectorButton({ + super.key, + required this.tableId, + required this.columns, + this.tooltip = 'Columns', + }); + + final String tableId; + final List columns; + final String tooltip; + + @override + ConsumerState createState() => + _AppTableColumnSelectorButtonState(); +} + +class _AppTableColumnSelectorButtonState + extends ConsumerState { + static const double _menuWidth = 340; + + final OverlayPortalController _portalController = OverlayPortalController(); + bool _isOpen = false; + + void _toggle() { + if (_portalController.isShowing) { + _close(); + } else { + _portalController.show(); + setState(() => _isOpen = true); + } + } + + void _close() { + if (!_portalController.isShowing && !_isOpen) return; + if (_portalController.isShowing) { + _portalController.hide(); + } + if (_isOpen) { + setState(() => _isOpen = false); + } + } + + Widget _buildOverlay(BuildContext context, OverlayChildLayoutInfo info) { + final scheme = Theme.of(context).colorScheme; + final anchorRect = MatrixUtils.transformRect( + info.childPaintTransform, + Offset.zero & info.childSize, + ); + + final maxLeft = (info.overlaySize.width - _menuWidth - 8) + .clamp(8.0, double.infinity); + final left = (anchorRect.right - _menuWidth).clamp(8.0, maxLeft); + final top = (anchorRect.bottom + 6).clamp( + 8.0, + math.max(8.0, info.overlaySize.height - 120), + ); + + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _close, + ), + ), + Positioned( + left: left.toDouble(), + top: top.toDouble(), + width: _menuWidth, + child: _ColumnSelectorOpenAnimation( + child: Material( + elevation: 8, + shadowColor: scheme.shadow.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + color: scheme.surface, + clipBehavior: Clip.antiAlias, + child: _ColumnSelectorPanel( + tableId: widget.tableId, + columns: widget.columns, + ), + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final prefs = ref.watch(tableColumnPrefsProvider(widget.tableId)); + final selectedCount = widget.columns.where((c) { + if (c.required || prefs.locked.contains(c.id)) return true; + return !prefs.hidden.contains(c.id); + }).length; + final activeColor = theme.colorScheme.secondary; + + return OverlayPortal.overlayChildLayoutBuilder( + controller: _portalController, + overlayChildBuilder: _buildOverlay, + child: Tooltip( + message: widget.tooltip, + child: OutlinedButton.icon( + onPressed: _toggle, + style: OutlinedButton.styleFrom( + backgroundColor: + _isOpen ? activeColor.withValues(alpha: 0.08) : null, + foregroundColor: + _isOpen ? activeColor : theme.colorScheme.onSurface, + side: BorderSide( + color: _isOpen + ? activeColor.withValues(alpha: 0.45) + : theme.colorScheme.outline.withValues(alpha: 0.35), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + icon: Icon( + Icons.view_column_outlined, + size: 18, + color: _isOpen ? activeColor : null, + ), + label: Text('Columns ($selectedCount)'), + ), + ), + ); + } +} + +/// Fade + slight slide/scale when the columns menu opens. +class _ColumnSelectorOpenAnimation extends StatelessWidget { + const _ColumnSelectorOpenAnimation({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + builder: (context, t, child) { + return Opacity( + opacity: t, + child: Transform.translate( + offset: Offset(0, (1 - t) * -6), + child: Transform.scale( + alignment: Alignment.topRight, + scale: 0.96 + (0.04 * t), + child: child, + ), + ), + ); + }, + child: child, + ); + } +} + +class _ColumnSelectorPanel extends ConsumerStatefulWidget { + const _ColumnSelectorPanel({ + required this.tableId, + required this.columns, + }); + + final String tableId; + final List columns; + + @override + ConsumerState<_ColumnSelectorPanel> createState() => + _ColumnSelectorPanelState(); +} + +class _ColumnSelectorPanelState extends ConsumerState<_ColumnSelectorPanel> { + late List _order; + late Set _hidden; + late Set _locked; + + @override + void initState() { + super.initState(); + _syncFromPrefs(ref.read(tableColumnPrefsProvider(widget.tableId))); + } + + void _syncFromPrefs(TableColumnPrefs prefs) { + final byId = {for (final c in widget.columns) c.id: c}; + final ordered = []; + final seen = {}; + for (final id in prefs.order) { + if (byId.containsKey(id) && seen.add(id)) ordered.add(id); + } + for (final c in widget.columns) { + if (seen.add(c.id)) ordered.add(c.id); + } + _order = ordered; + _hidden = Set.from(prefs.hidden) + ..removeWhere((id) => byId[id]?.required == true); + _locked = { + for (final id in prefs.locked) + if (byId.containsKey(id) && byId[id]?.required != true) id, + }; + } + + AppTableColumnOption _option(String id) => + widget.columns.firstWhere((c) => c.id == id); + + bool _isLocked(String id) { + final col = _option(id); + return col.required || _locked.contains(id); + } + + bool _isVisible(String id) { + if (_isLocked(id)) return true; + return !_hidden.contains(id); + } + + int get _selectedCount => + _order.where(_isVisible).length; + + Future _persist() async { + await ref.read(tableColumnPrefsProvider(widget.tableId).notifier).replace( + TableColumnPrefs( + order: List.from(_order), + hidden: Set.from(_hidden), + locked: Set.from(_locked), + ), + ); + } + + Future _toggleVisible(String id, bool visible) async { + if (_isLocked(id) && !visible) return; + setState(() { + if (visible) { + _hidden.remove(id); + } else { + _hidden.add(id); + } + }); + await _persist(); + } + + Future _toggleLocked(String id) async { + final col = _option(id); + if (col.required) return; + setState(() { + if (_locked.contains(id)) { + _locked.remove(id); + } else { + _locked.add(id); + _hidden.remove(id); + } + }); + await _persist(); + } + + Future _reorder(int oldIndex, int newIndex) async { + setState(() { + final id = _order.removeAt(oldIndex); + _order.insert(newIndex, id); + }); + await _persist(); + } + + Future _selectAll() async { + setState(() => _hidden.clear()); + await _persist(); + } + + Future _hideAll() async { + setState(() { + _hidden = { + for (final id in _order) + if (!_isLocked(id)) id, + }; + }); + await _persist(); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Material( + color: scheme.surface, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 360), + child: ReorderableListView.builder( + shrinkWrap: true, + buildDefaultDragHandles: false, + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + itemCount: _order.length, + onReorderItem: _reorder, + itemBuilder: (context, index) { + final id = _order[index]; + final col = _option(id); + final locked = _isLocked(id); + final visible = _isVisible(id); + + return Material( + key: ValueKey(id), + color: Colors.transparent, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + ReorderableDragStartListener( + index: index, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Icon( + Icons.drag_handle, + size: 20, + color: scheme.onSurfaceVariant.withValues( + alpha: 0.55, + ), + ), + ), + ), + SizedBox( + height: 22, + width: 36, + child: FittedBox( + fit: BoxFit.contain, + child: Switch( + value: visible, + onChanged: locked && visible + ? null + : (v) => _toggleVisible(id, v), + activeThumbColor: scheme.onPrimary, + activeTrackColor: scheme.primary, + inactiveThumbColor: scheme.surface, + inactiveTrackColor: scheme.onSurfaceVariant + .withValues(alpha: 0.28), + trackOutlineColor: + WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.selected)) { + return Colors.transparent; + } + return scheme.outline.withValues(alpha: 0.4); + }, + ), + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + col.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.body3( + weight: AppTypography.medium, + color: scheme.onSurface, + ), + ), + ), + IconButton( + visualDensity: VisualDensity.compact, + onPressed: + col.required ? null : () => _toggleLocked(id), + icon: Icon( + locked + ? Icons.lock_outline + : Icons.lock_open_outlined, + size: 18, + color: locked + ? scheme.onSurface + : scheme.onSurfaceVariant.withValues( + alpha: 0.55, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 12), + child: Row( + children: [ + InkWell( + onTap: _selectAll, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 2, + vertical: 2, + ), + child: Text( + 'Select All', + style: AppTypography.label2( + weight: AppTypography.semiBold, + color: scheme.primary, + ), + ), + ), + ), + Text( + ' / ', + style: AppTypography.label2( + weight: AppTypography.semiBold, + color: scheme.primary, + ), + ), + InkWell( + onTap: _hideAll, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 2, + vertical: 2, + ), + child: Text( + 'Hide All', + style: AppTypography.label2( + weight: AppTypography.semiBold, + color: scheme.primary, + ), + ), + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '$_selectedCount selected', + style: AppTypography.caption1( + weight: AppTypography.semiBold, + color: scheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/app_toast.dart b/lib/shared/widgets/app_toast.dart index 753c238..22fa921 100644 --- a/lib/shared/widgets/app_toast.dart +++ b/lib/shared/widgets/app_toast.dart @@ -136,6 +136,7 @@ AppToastType inferToastType( lower.contains('saved') || lower.contains('updated') || lower.contains('created') || + lower.contains('added') || lower.contains('deleted') || lower.contains('uploaded') || lower.contains('downloaded') ||