review changes done
This commit is contained in:
parent
048ecab961
commit
ef0f33ecbb
@ -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';
|
||||
|
||||
@ -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_<tableId>`).
|
||||
static const String tableColumnsPrefix = 'table_columns_';
|
||||
|
||||
static String tableColumns(String tableId) => '$tableColumnsPrefix$tableId';
|
||||
}
|
||||
|
||||
@ -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 = <Widget>[
|
||||
_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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<AssetFormScreen> {
|
||||
)
|
||||
.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<AssetCategoryModel> 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<FilterOptionModel> vendors) {
|
||||
final vendorIds = vendors
|
||||
.map((vendor) => int.tryParse(vendor.id))
|
||||
|
||||
@ -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<AssetListScreen> {
|
||||
),
|
||||
),
|
||||
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<AppTableColumnOption> 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<AssetModel> assets;
|
||||
final bool canEdit;
|
||||
final bool canDelete;
|
||||
@ -392,111 +422,141 @@ class _AssetDataTable extends StatelessWidget {
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
List<AppDataColumn<AssetModel>> _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<AssetModel>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<AppTableColumnOption> 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<AssetModel> assets;
|
||||
final void Function(AssetModel asset) onSubmit;
|
||||
final void Function(AssetModel asset) onView;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
List<AppDataColumn<AssetModel>> _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<AssetModel>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<Widget>? leadingActions;
|
||||
|
||||
/// Optional actions shown after Recent Logs (e.g. Transfer History).
|
||||
final List<Widget>? trailingActions;
|
||||
|
||||
@override
|
||||
ConsumerState<AssetRecentMaintenanceLogsSection> createState() =>
|
||||
_AssetRecentMaintenanceLogsSectionState();
|
||||
@ -352,6 +356,7 @@ class _AssetRecentMaintenanceLogsSectionState
|
||||
),
|
||||
label: const Text('Recent Logs'),
|
||||
),
|
||||
...?widget.trailingActions,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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<AuditLogsScreen> {
|
||||
() => _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<AppTableColumnOption> get columnOptions => const [
|
||||
AppTableColumnOption(id: 'when', label: 'When', required: true),
|
||||
AppTableColumnOption(id: 'action', label: 'Action'),
|
||||
AppTableColumnOption(id: 'table', label: 'Table'),
|
||||
AppTableColumnOption(id: 'record', label: 'Record'),
|
||||
AppTableColumnOption(id: 'performed_by', label: 'Performed By'),
|
||||
AppTableColumnOption(id: 'request_id', label: 'Request ID'),
|
||||
AppTableColumnOption(id: 'changes', label: 'Changes'),
|
||||
];
|
||||
|
||||
final List<AuditLogEntryModel> items;
|
||||
final void Function(AuditLogEntryModel log) onView;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
static String _changesText(AuditLogEntryModel row) {
|
||||
final parts = <String>[];
|
||||
if (row.hasOldValue) parts.add('old');
|
||||
if (row.hasNewValue) parts.add('new');
|
||||
return parts.isEmpty ? '' : parts.join(' / ');
|
||||
}
|
||||
|
||||
List<AppDataColumn<AuditLogEntryModel>> _allColumns() {
|
||||
return [
|
||||
AppDataColumn(
|
||||
id: 'when',
|
||||
label: 'When',
|
||||
sortKey: 'when',
|
||||
locked: true,
|
||||
flex: 2,
|
||||
searchText: (row) => DateFormatter.searchableDate(row.performedAt),
|
||||
sortValue: (row) => row.performedAt,
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
DateFormatter.displayDateTime(row.performedAt),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'action',
|
||||
label: 'Action',
|
||||
sortKey: 'action',
|
||||
flex: 1,
|
||||
searchText: (row) => row.action,
|
||||
cellBuilder: (_, row) => AppTableCell.child(
|
||||
AppStatusChip(
|
||||
status: row.action,
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'table',
|
||||
label: 'Table',
|
||||
sortKey: 'table',
|
||||
flex: 2,
|
||||
searchText: (row) => humanizeLabel(row.tableName),
|
||||
cellBuilder: (_, row) =>
|
||||
AppTableCell.text(humanizeLabel(row.tableName)),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'record',
|
||||
label: 'Record',
|
||||
sortKey: 'record',
|
||||
flex: 1,
|
||||
searchText: (row) => row.recordId ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.recordId),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'performed_by',
|
||||
label: 'Performed By',
|
||||
sortKey: 'performed_by',
|
||||
flex: 2,
|
||||
searchText: (row) => row.performerLabel,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.performerLabel),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'request_id',
|
||||
label: 'Request ID',
|
||||
sortKey: 'request_id',
|
||||
flex: 2,
|
||||
searchText: (row) => row.requestId ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.requestId),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'changes',
|
||||
label: 'Changes',
|
||||
sortKey: 'changes',
|
||||
flex: 1,
|
||||
searchText: _changesText,
|
||||
cellBuilder: (_, row) {
|
||||
final text = _changesText(row);
|
||||
return AppTableCell.text(text.isEmpty ? '—' : text);
|
||||
},
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
locked: true,
|
||||
includeInColumnSelector: false,
|
||||
cellBuilder: (_, row) => AppTableActions(
|
||||
children: [
|
||||
AppTableActionIcon(
|
||||
tooltip: 'View',
|
||||
icon: Icons.visibility_outlined,
|
||||
onPressed: () => onView(row),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
|
||||
final columns = resolveAppDataColumns(_allColumns(), prefs);
|
||||
|
||||
return AppDataTable<AuditLogEntryModel>(
|
||||
wrapInCard: false,
|
||||
rows: items,
|
||||
emptyMessage: 'No audit logs found',
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: [
|
||||
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 = <String>[];
|
||||
if (row.hasOldValue) parts.add('old');
|
||||
if (row.hasNewValue) parts.add('new');
|
||||
return parts.isEmpty ? '' : parts.join(' / ');
|
||||
},
|
||||
cellBuilder: (_, row) {
|
||||
final parts = <String>[];
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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<GrnListScreen> {
|
||||
() => _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<AppTableColumnOption> 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<GrnModel> grns;
|
||||
final ValueChanged<GrnModel> onView;
|
||||
final ValueChanged<GrnModel>? onEdit;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
List<AppDataColumn<GrnModel>> _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<GrnModel>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<MasterListScreen> {
|
||||
() => _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<MasterListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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<void> 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<AppTableColumnOption> 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<AppDataColumn<Map<String, dynamic>>> _allColumns(ThemeData theme) {
|
||||
final fields = definition.listFields;
|
||||
return [
|
||||
for (var i = 0; i < fields.length; i++)
|
||||
AppDataColumn<Map<String, dynamic>>(
|
||||
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<Map<String, dynamic>>(
|
||||
wrapInCard: false,
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
emptyMessage: 'No ${definition.title.toLowerCase()} found',
|
||||
columns: [
|
||||
...definition.listFields.map(
|
||||
(field) => AppDataColumn<Map<String, dynamic>>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -201,12 +201,10 @@ class _MasterQuickAddDropdownState<T>
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
showAppToastFromSnackBar(
|
||||
showAppToast(
|
||||
context,
|
||||
SnackBar(
|
||||
content:
|
||||
Text('${_titleCase(masterQuickAddNoun(widget.masterId))} added'),
|
||||
),
|
||||
'${_titleCase(masterQuickAddNoun(widget.masterId))} added',
|
||||
type: AppToastType.success,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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<PurchaseOrderListScree
|
||||
() => _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<AppTableColumnOption> 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<PurchaseOrderModel> orders;
|
||||
final ValueChanged<PurchaseOrderModel> onView;
|
||||
final ValueChanged<PurchaseOrderModel>? onEdit;
|
||||
@ -503,106 +524,129 @@ class _PoDataTable extends StatelessWidget {
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
List<AppDataColumn<PurchaseOrderModel>> _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<PurchaseOrderModel>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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<AppTableColumnOption> 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<DepreciationReportRow> items;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
List<AppDataColumn<DepreciationReportRow>> _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<DepreciationReportRow>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<RoleListScreen> {
|
||||
() => _filtersExpanded = !_filtersExpanded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AppTableColumnSelectorButton(
|
||||
tableId: _RoleDataTable.tableId,
|
||||
columns: _RoleDataTable.columnOptions,
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
@ -128,7 +135,7 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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<AppTableColumnOption> 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<RoleCardModel> roles;
|
||||
final void Function(RoleCardModel role) onOpen;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
List<AppDataColumn<RoleCardModel>> _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<RoleCardModel>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<UserListScreen> {
|
||||
),
|
||||
),
|
||||
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),
|
||||
|
||||
@ -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<ManagedUserModel> users;
|
||||
final UserTableActionsBuilder actionsBuilder;
|
||||
final String? sortColumn;
|
||||
@ -35,9 +40,92 @@ class UserRichDataTable extends StatelessWidget {
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
static List<AppTableColumnOption> 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<AppDataColumn<ManagedUserModel>> _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<ManagedUserModel>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<VendorListScreen> {
|
||||
() => _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<AppTableColumnOption> 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<VendorModel> vendors;
|
||||
final ValueChanged<VendorModel> onView;
|
||||
final ValueChanged<VendorModel>? onEdit;
|
||||
@ -305,79 +326,100 @@ class _VendorDataTable extends StatelessWidget {
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
List<AppDataColumn<VendorModel>> _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<VendorModel>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
70
lib/shared/models/table_column_prefs.dart
Normal file
70
lib/shared/models/table_column_prefs.dart
Normal file
@ -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<String> order;
|
||||
|
||||
/// Column ids that are hidden (must not include required/locked columns).
|
||||
final Set<String> hidden;
|
||||
|
||||
/// User-locked column ids (cannot be hidden until unlocked).
|
||||
final Set<String> 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<String>? order,
|
||||
Set<String>? hidden,
|
||||
Set<String>? locked,
|
||||
}) {
|
||||
return TableColumnPrefs(
|
||||
order: order ?? this.order,
|
||||
hidden: hidden ?? this.hidden,
|
||||
locked: locked ?? this.locked,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'order': order,
|
||||
'hidden': hidden.toList(),
|
||||
'locked': locked.toList(),
|
||||
};
|
||||
|
||||
factory TableColumnPrefs.fromJson(Map<String, dynamic> 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<String, dynamic>.from(map));
|
||||
} catch (_) {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
51
lib/shared/providers/table_column_prefs_provider.dart
Normal file
51
lib/shared/providers/table_column_prefs_provider.dart
Normal file
@ -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<TableColumnPrefs> {
|
||||
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<void> _persist(TableColumnPrefs next) async {
|
||||
state = next;
|
||||
await _prefs.setString(_key, next.encode());
|
||||
}
|
||||
|
||||
Future<void> setOrder(List<String> order) =>
|
||||
_persist(state.copyWith(order: List<String>.from(order)));
|
||||
|
||||
Future<void> setHidden(Set<String> hidden) =>
|
||||
_persist(state.copyWith(hidden: Set<String>.from(hidden)));
|
||||
|
||||
Future<void> setLocked(Set<String> locked) =>
|
||||
_persist(state.copyWith(locked: Set<String>.from(locked)));
|
||||
|
||||
Future<void> replace(TableColumnPrefs prefs) => _persist(prefs);
|
||||
|
||||
Future<void> reset() async {
|
||||
state = TableColumnPrefs.empty;
|
||||
await _prefs.remove(_key);
|
||||
}
|
||||
}
|
||||
@ -89,6 +89,12 @@ final routerProvider = Provider<GoRouter>((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: [
|
||||
|
||||
@ -106,10 +106,10 @@ const List<MenuItem> 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<MenuItem> 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<MenuItem> 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<String> supportMenuRoutes = {
|
||||
/// Child routes that belong under Manage.
|
||||
const Set<String> 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<String> supportMenuRoutes = administratorMenuRoutes;
|
||||
List<MenuItem> getVisibleMenuItems({
|
||||
required List<String> permissions,
|
||||
required UserRole role,
|
||||
|
||||
@ -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<T> {
|
||||
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<T> {
|
||||
/// 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<AppDataColumn<T>> resolveAppDataColumns<T>(
|
||||
List<AppDataColumn<T>> columns,
|
||||
TableColumnPrefs prefs,
|
||||
) {
|
||||
if (columns.isEmpty) return columns;
|
||||
|
||||
final byId = <String, AppDataColumn<T>>{
|
||||
for (final c in columns) c.columnId: c,
|
||||
};
|
||||
final ordered = <AppDataColumn<T>>[];
|
||||
final seen = <String>{};
|
||||
|
||||
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<T> extends State<AppDataTable<T>> {
|
||||
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<T> extends State<AppDataTable<T>> {
|
||||
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<T> extends State<AppDataTable<T>> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<T> get _displayRows {
|
||||
final rows = List<T>.from(_filteredRows);
|
||||
final sortKey = _activeSortColumn;
|
||||
if (sortKey == null) return rows;
|
||||
|
||||
AppDataColumn<T>? 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<T> extends State<AppDataTable<T>> {
|
||||
|
||||
final header = _TableHeaderRow<T>(
|
||||
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<T> 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<T> 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -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<AppSidebar> {
|
||||
final Set<String> _manuallyCollapsedMenus = <String>{};
|
||||
|
||||
List<menu.MenuItem> 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<menu.MenuItem> get _supportMenuItems {
|
||||
final items = <menu.MenuItem>[];
|
||||
/// 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<AppSidebar> {
|
||||
return _expandedMenus.contains(item.route) || _isGroupActive(item);
|
||||
}
|
||||
|
||||
Iterable<menu.MenuItem> get _allGroupItems =>
|
||||
[..._primaryMenuItems, ..._supportMenuItems]
|
||||
.where((item) => item.children.isNotEmpty);
|
||||
Iterable<menu.MenuItem> 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<AppSidebar> {
|
||||
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<AppSidebar> {
|
||||
List<Widget> _buildNavItems(
|
||||
List<menu.MenuItem> items, {
|
||||
required bool isNarrow,
|
||||
bool expandUpward = false,
|
||||
}) {
|
||||
return items.map((item) {
|
||||
if (isNarrow && item.children.isNotEmpty) {
|
||||
@ -260,33 +264,46 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
491
lib/shared/widgets/app_table_column_selector.dart
Normal file
491
lib/shared/widgets/app_table_column_selector.dart
Normal file
@ -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<T>(AppDataColumn<T> 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<AppTableColumnOption> columns;
|
||||
final String tooltip;
|
||||
|
||||
@override
|
||||
ConsumerState<AppTableColumnSelectorButton> createState() =>
|
||||
_AppTableColumnSelectorButtonState();
|
||||
}
|
||||
|
||||
class _AppTableColumnSelectorButtonState
|
||||
extends ConsumerState<AppTableColumnSelectorButton> {
|
||||
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<double>(
|
||||
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<AppTableColumnOption> columns;
|
||||
|
||||
@override
|
||||
ConsumerState<_ColumnSelectorPanel> createState() =>
|
||||
_ColumnSelectorPanelState();
|
||||
}
|
||||
|
||||
class _ColumnSelectorPanelState extends ConsumerState<_ColumnSelectorPanel> {
|
||||
late List<String> _order;
|
||||
late Set<String> _hidden;
|
||||
late Set<String> _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 = <String>[];
|
||||
final seen = <String>{};
|
||||
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<String>.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<void> _persist() async {
|
||||
await ref.read(tableColumnPrefsProvider(widget.tableId).notifier).replace(
|
||||
TableColumnPrefs(
|
||||
order: List<String>.from(_order),
|
||||
hidden: Set<String>.from(_hidden),
|
||||
locked: Set<String>.from(_locked),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleVisible(String id, bool visible) async {
|
||||
if (_isLocked(id) && !visible) return;
|
||||
setState(() {
|
||||
if (visible) {
|
||||
_hidden.remove(id);
|
||||
} else {
|
||||
_hidden.add(id);
|
||||
}
|
||||
});
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _reorder(int oldIndex, int newIndex) async {
|
||||
setState(() {
|
||||
final id = _order.removeAt(oldIndex);
|
||||
_order.insert(newIndex, id);
|
||||
});
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> _selectAll() async {
|
||||
setState(() => _hidden.clear());
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> _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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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') ||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user