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 grnEdit = '/grn/:id/edit';
|
||||||
static const String grnDetail = '/grn/:id';
|
static const String grnDetail = '/grn/:id';
|
||||||
|
|
||||||
// Assets
|
// Assets (Asset Master)
|
||||||
static const String assets = '/assets';
|
static const String assets = '/assetsmaster';
|
||||||
static const String assetAdd = '/assets/add';
|
static const String assetAdd = '/assetsmaster/add';
|
||||||
static const String assetEdit = '/assets/:id/edit';
|
static const String assetEdit = '/assetsmaster/:id/edit';
|
||||||
static const String assetDetail = '/assets/:id';
|
static const String assetDetail = '/assetsmaster/:id';
|
||||||
static const String assetAlerts = '/assets/alerts';
|
static const String assetAlerts = '/assetsmaster/alerts';
|
||||||
static const String assetMaintenance = '/assets/maintenance';
|
static const String assetMaintenance = '/assetsmaster/maintenance';
|
||||||
|
|
||||||
// Master Data
|
// Master Data
|
||||||
static const String masterData = '/master-data';
|
static const String masterData = '/master-data';
|
||||||
|
|||||||
@ -13,4 +13,9 @@ class StorageKeys {
|
|||||||
static const String appSettings = 'app_settings';
|
static const String appSettings = 'app_settings';
|
||||||
static const String rememberMe = 'remember_me';
|
static const String rememberMe = 'remember_me';
|
||||||
static const String rememberedEmail = 'remembered_email';
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Text(
|
||||||
children: [
|
'Asset Details',
|
||||||
Text(
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
'Asset Details',
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
fontWeight: FontWeight.w600,
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 1) Top Summary
|
// 1) Top Summary
|
||||||
_AssetOverviewSection(
|
_AssetOverviewSection(
|
||||||
title: 'Summary',
|
title: 'Summary',
|
||||||
child: _AssetInfoGrid(
|
child: _AssetSummaryStrip(
|
||||||
items: [
|
status: asset.status ?? 'IN_USE',
|
||||||
_AssetInfo.widget(
|
maintenanceDue: asset.maintenance?.isDue,
|
||||||
'Status',
|
currentValue: asset.resolvedCurrentValue,
|
||||||
AppStatusChip(status: asset.status ?? 'IN_USE'),
|
isActive: asset.isActive,
|
||||||
),
|
daysUntilDueDisplay: daysUntilDueDisplay,
|
||||||
_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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@ -344,56 +315,43 @@ class _OverviewTab extends ConsumerWidget {
|
|||||||
// 3) Maintenance
|
// 3) Maintenance
|
||||||
_AssetOverviewSection(
|
_AssetOverviewSection(
|
||||||
title: 'Maintenance',
|
title: 'Maintenance',
|
||||||
child: Column(
|
child: _AssetInfoGrid(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
items: [
|
||||||
children: [
|
_AssetInfo(
|
||||||
_AssetInfoGrid(
|
'Maintenance Incharge',
|
||||||
items: [
|
maintenanceInchargeLabel,
|
||||||
_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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
if (asset.maintenance != null) ...[
|
_AssetInfo(
|
||||||
const SizedBox(height: 12),
|
'Maintenance Frequency',
|
||||||
_MaintenanceDueCallout(
|
asset.maintenanceFrequencyInDays != null
|
||||||
isDue: asset.maintenance!.isDue,
|
? '${asset.maintenanceFrequencyInDays} days'
|
||||||
daysUntilDue: asset.maintenance!.daysUntilDue,
|
: '—',
|
||||||
display: daysUntilDueDisplay,
|
),
|
||||||
|
_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'),
|
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) ...[
|
if (asset.remarks?.trim().isNotEmpty == true) ...[
|
||||||
@ -573,82 +548,220 @@ class _AssetOverviewSection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ActiveDotIndicator extends StatelessWidget {
|
/// Stylish KPI strip for Overview → Summary (theme primary / secondary accents).
|
||||||
const _ActiveDotIndicator({required this.isActive});
|
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 bool isActive;
|
||||||
|
final MaintenanceDueDisplay? daysUntilDueDisplay;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final color =
|
final scheme = theme.colorScheme;
|
||||||
isActive ? const Color(0xFF16A34A) : theme.colorScheme.onSurfaceVariant;
|
final dueColor = maintenanceDue == null
|
||||||
return Row(
|
? scheme.onSurfaceVariant
|
||||||
mainAxisSize: MainAxisSize.min,
|
: maintenanceDue!
|
||||||
children: [
|
? scheme.error
|
||||||
Container(
|
: (daysUntilDueDisplay?.color ?? const Color(0xFF16A34A));
|
||||||
width: 6,
|
final dueLabel = maintenanceDue == null
|
||||||
height: 6,
|
? '—'
|
||||||
decoration: BoxDecoration(
|
: maintenanceDue!
|
||||||
color: color,
|
? 'Yes'
|
||||||
shape: BoxShape.circle,
|
: '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),
|
border: Border.all(
|
||||||
Text(
|
color: scheme.primary.withValues(alpha: 0.14),
|
||||||
isActive ? 'Active' : 'Inactive',
|
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: color,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
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 {
|
class _SummaryMetricTile extends StatelessWidget {
|
||||||
const _MaintenanceDueCallout({
|
const _SummaryMetricTile({
|
||||||
required this.isDue,
|
required this.width,
|
||||||
required this.daysUntilDue,
|
required this.icon,
|
||||||
required this.display,
|
required this.label,
|
||||||
|
required this.accent,
|
||||||
|
required this.child,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool isDue;
|
final double width;
|
||||||
final int? daysUntilDue;
|
final IconData icon;
|
||||||
final MaintenanceDueDisplay? display;
|
final String label;
|
||||||
|
final Color accent;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final color = display?.color ?? theme.colorScheme.onSurfaceVariant;
|
final scheme = theme.colorScheme;
|
||||||
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 text = suffix == null
|
return SizedBox(
|
||||||
? 'Maintenance Due: ${isDue ? 'Yes' : 'No'}'
|
width: width,
|
||||||
: 'Maintenance Due: ${isDue ? 'Yes' : 'No'} — $suffix';
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
|
||||||
return Container(
|
decoration: BoxDecoration(
|
||||||
width: double.infinity,
|
color: scheme.surface.withValues(alpha: 0.92),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
borderRadius: BorderRadius.circular(12),
|
||||||
decoration: BoxDecoration(
|
border: Border.all(color: accent.withValues(alpha: 0.18)),
|
||||||
color: color.withValues(alpha: 0.08),
|
boxShadow: [
|
||||||
borderRadius: BorderRadius.circular(8),
|
BoxShadow(
|
||||||
border: Border.all(color: color.withValues(alpha: 0.28)),
|
color: scheme.shadow.withValues(alpha: 0.04),
|
||||||
),
|
blurRadius: 10,
|
||||||
child: Text(
|
offset: const Offset(0, 3),
|
||||||
text,
|
),
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
],
|
||||||
color: color,
|
),
|
||||||
fontWeight: FontWeight.w600,
|
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});
|
const _AssetInfoGrid({required this.items});
|
||||||
|
|
||||||
final List<_AssetInfo> items;
|
final List<_AssetInfo> items;
|
||||||
static const int _columns = 3;
|
static const int _columns = 4;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final maxWidth = constraints.maxWidth;
|
final maxWidth = constraints.maxWidth;
|
||||||
final cols = maxWidth < 600
|
final cols = maxWidth < 560
|
||||||
? 1
|
? 1
|
||||||
: maxWidth < 900
|
: maxWidth < 780
|
||||||
? 2
|
? 2
|
||||||
: _columns;
|
: maxWidth < 1000
|
||||||
|
? 3
|
||||||
|
: _columns;
|
||||||
const spacing = 16.0;
|
const spacing = 16.0;
|
||||||
final colWidth = (maxWidth - spacing * (cols - 1)) / cols;
|
final colWidth = (maxWidth - spacing * (cols - 1)) / cols;
|
||||||
|
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: spacing,
|
spacing: spacing,
|
||||||
runSpacing: 16,
|
runSpacing: 18,
|
||||||
children: items
|
children: items
|
||||||
.map(
|
.map(
|
||||||
(item) => SizedBox(
|
(item) => SizedBox(
|
||||||
@ -965,14 +1080,22 @@ class _AssetDetailTile extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label.toUpperCase(),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85),
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: 0.45,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 6),
|
||||||
valueWidget ?? Text(value ?? '—', style: theme.textTheme.bodyLarge),
|
valueWidget ??
|
||||||
|
Text(
|
||||||
|
value ?? '—',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ void openAssetForm(
|
|||||||
if (assetId == null) {
|
if (assetId == null) {
|
||||||
context.push(RouteConstants.assetAdd);
|
context.push(RouteConstants.assetAdd);
|
||||||
} else {
|
} 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)
|
.where((option) => option.value != 0)
|
||||||
.toList(),
|
.toList(),
|
||||||
refreshLookups: () {
|
refreshLookups: () async {
|
||||||
ref.invalidate(itemCategoriesFormProvider);
|
ref.invalidate(itemCategoriesFormProvider);
|
||||||
|
await ref.read(itemCategoriesFormProvider.future);
|
||||||
},
|
},
|
||||||
parseCreatedId: int.tryParse,
|
parseCreatedId: int.tryParse,
|
||||||
onChanged: (v) => setState(() {
|
onChanged: (v) {
|
||||||
_categoryId = v;
|
setState(() {
|
||||||
_subcategoryId = null;
|
_categoryId = v;
|
||||||
final selectedCategory =
|
_subcategoryId = null;
|
||||||
activeCategories.where((c) => int.tryParse(c.id) == v).firstOrNull;
|
});
|
||||||
if (selectedCategory != null) {
|
// Prefer the latest provider list so Quick Add (after refresh) can
|
||||||
if (selectedCategory.defaultDepreciationMethod != null &&
|
// apply default useful life / depreciation method.
|
||||||
selectedCategory.defaultDepreciationMethod!.trim().isNotEmpty) {
|
_applyCategoryDepreciationDefaults(
|
||||||
_depreciationMethod =
|
v,
|
||||||
selectedCategory.defaultDepreciationMethod!.trim().toUpperCase();
|
fallbackCategories: activeCategories,
|
||||||
}
|
);
|
||||||
if (selectedCategory.defaultUsefulLifeYears != null &&
|
|
||||||
selectedCategory.defaultUsefulLifeYears! > 0) {
|
|
||||||
_usefulLifeController.text =
|
|
||||||
selectedCategory.defaultUsefulLifeYears!.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_triggerPreviewRecalculation();
|
_triggerPreviewRecalculation();
|
||||||
}),
|
},
|
||||||
validator: (v) => v == null ? 'Category is required' : null,
|
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) {
|
Widget _vendorDropdown(List<FilterOptionModel> vendors) {
|
||||||
final vendorIds = vendors
|
final vendorIds = vendors
|
||||||
.map((vendor) => int.tryParse(vendor.id))
|
.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_search_filter_toggle.dart';
|
||||||
import '../../../../shared/widgets/app_status_chip.dart';
|
import '../../../../shared/widgets/app_status_chip.dart';
|
||||||
import '../../../../shared/widgets/app_table_action_icon.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/can_permission.dart';
|
||||||
|
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
import '../../../../shared/utils/file_download_helper.dart';
|
import '../../../../shared/utils/file_download_helper.dart';
|
||||||
@ -91,6 +93,11 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
AppTableColumnSelectorButton(
|
||||||
|
tableId: _AssetDataTable.tableId,
|
||||||
|
columns: _AssetDataTable.columnOptions,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
if (canExport)
|
if (canExport)
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: state.isExporting ? null : _exportAssets,
|
onPressed: state.isExporting ? null : _exportAssets,
|
||||||
@ -371,7 +378,7 @@ class _AssetsFilterBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AssetDataTable extends StatelessWidget {
|
class _AssetDataTable extends ConsumerWidget {
|
||||||
const _AssetDataTable({
|
const _AssetDataTable({
|
||||||
required this.assets,
|
required this.assets,
|
||||||
required this.canEdit,
|
required this.canEdit,
|
||||||
@ -383,6 +390,29 @@ class _AssetDataTable extends StatelessWidget {
|
|||||||
this.onColumnSearchCleared,
|
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 List<AssetModel> assets;
|
||||||
final bool canEdit;
|
final bool canEdit;
|
||||||
final bool canDelete;
|
final bool canDelete;
|
||||||
@ -392,111 +422,141 @@ class _AssetDataTable extends StatelessWidget {
|
|||||||
final Future<void> Function()? onEnsureFullDataset;
|
final Future<void> Function()? onEnsureFullDataset;
|
||||||
final VoidCallback? onColumnSearchCleared;
|
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
|
@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>(
|
return AppDataTable<AssetModel>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
onEnsureFullDataset: onEnsureFullDataset,
|
onEnsureFullDataset: onEnsureFullDataset,
|
||||||
onColumnSearchCleared: onColumnSearchCleared,
|
onColumnSearchCleared: onColumnSearchCleared,
|
||||||
columns: [
|
columns: 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
rows: assets,
|
rows: assets,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,12 +8,14 @@ import '../../../../core/errors/failure.dart';
|
|||||||
import '../../../../core/utils/formatters.dart';
|
import '../../../../core/utils/formatters.dart';
|
||||||
import '../../../../core/utils/responsive_utils.dart';
|
import '../../../../core/utils/responsive_utils.dart';
|
||||||
import '../../../../shared/models/asset_model.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_data_table.dart';
|
||||||
import '../../../../shared/widgets/app_empty_state.dart';
|
import '../../../../shared/widgets/app_empty_state.dart';
|
||||||
import '../../../../shared/widgets/app_form_toggle_field.dart';
|
import '../../../../shared/widgets/app_form_toggle_field.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_pagination.dart';
|
import '../../../../shared/widgets/app_pagination.dart';
|
||||||
import '../../../../shared/widgets/app_table_action_icon.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/app_toast.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
@ -95,6 +97,11 @@ class _MyMaintenanceBody extends ConsumerWidget {
|
|||||||
title: 'My Maintenance',
|
title: 'My Maintenance',
|
||||||
subtitle: 'Assets assigned to you for checklist-based maintenance',
|
subtitle: 'Assets assigned to you for checklist-based maintenance',
|
||||||
actions: [
|
actions: [
|
||||||
|
AppTableColumnSelectorButton(
|
||||||
|
tableId: _MaintenanceTable.tableId,
|
||||||
|
columns: _MaintenanceTable.columnOptions,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () => context.go(RouteConstants.assets),
|
onPressed: () => context.go(RouteConstants.assets),
|
||||||
icon: const Icon(Icons.inventory_2_outlined),
|
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({
|
const _MaintenanceTable({
|
||||||
required this.assets,
|
required this.assets,
|
||||||
required this.onSubmit,
|
required this.onSubmit,
|
||||||
required this.onView,
|
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 List<AssetModel> assets;
|
||||||
final void Function(AssetModel asset) onSubmit;
|
final void Function(AssetModel asset) onSubmit;
|
||||||
final void Function(AssetModel asset) onView;
|
final void Function(AssetModel asset) onView;
|
||||||
|
|
||||||
@override
|
List<AppDataColumn<AssetModel>> _allColumns() {
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final dateFormat = DateFormat('dd MMM yyyy');
|
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>(
|
return AppDataTable<AssetModel>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
columns: [
|
columns: 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
rows: assets,
|
rows: assets,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -273,14 +273,18 @@ class AssetRecentMaintenanceLogsSection extends ConsumerStatefulWidget {
|
|||||||
required this.assetId,
|
required this.assetId,
|
||||||
this.limit = 5,
|
this.limit = 5,
|
||||||
this.leadingActions,
|
this.leadingActions,
|
||||||
|
this.trailingActions,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String assetId;
|
final String assetId;
|
||||||
final int limit;
|
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;
|
final List<Widget>? leadingActions;
|
||||||
|
|
||||||
|
/// Optional actions shown after Recent Logs (e.g. Transfer History).
|
||||||
|
final List<Widget>? trailingActions;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<AssetRecentMaintenanceLogsSection> createState() =>
|
ConsumerState<AssetRecentMaintenanceLogsSection> createState() =>
|
||||||
_AssetRecentMaintenanceLogsSectionState();
|
_AssetRecentMaintenanceLogsSectionState();
|
||||||
@ -352,6 +356,7 @@ class _AssetRecentMaintenanceLogsSectionState
|
|||||||
),
|
),
|
||||||
label: const Text('Recent Logs'),
|
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_side_panel.dart';
|
||||||
import '../../../../shared/widgets/app_status_chip.dart';
|
import '../../../../shared/widgets/app_status_chip.dart';
|
||||||
import '../../../../shared/widgets/app_table_action_icon.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/app_table_shell.dart';
|
||||||
|
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
import '../providers/audit_provider.dart';
|
import '../providers/audit_provider.dart';
|
||||||
@ -143,6 +145,11 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
|
|||||||
() => _filtersExpanded = !_filtersExpanded,
|
() => _filtersExpanded = !_filtersExpanded,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
AppTableColumnSelectorButton(
|
||||||
|
tableId: _AuditDataTable.tableId,
|
||||||
|
columns: _AuditDataTable.columnOptions,
|
||||||
|
),
|
||||||
if (canExport) ...[
|
if (canExport) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
@ -370,7 +377,7 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AuditDataTable extends StatelessWidget {
|
class _AuditDataTable extends ConsumerWidget {
|
||||||
const _AuditDataTable({
|
const _AuditDataTable({
|
||||||
required this.items,
|
required this.items,
|
||||||
required this.onView,
|
required this.onView,
|
||||||
@ -378,99 +385,135 @@ class _AuditDataTable extends StatelessWidget {
|
|||||||
this.onColumnSearchCleared,
|
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 List<AuditLogEntryModel> items;
|
||||||
final void Function(AuditLogEntryModel log) onView;
|
final void Function(AuditLogEntryModel log) onView;
|
||||||
final Future<void> Function()? onEnsureFullDataset;
|
final Future<void> Function()? onEnsureFullDataset;
|
||||||
final VoidCallback? onColumnSearchCleared;
|
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
|
@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>(
|
return AppDataTable<AuditLogEntryModel>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
rows: items,
|
rows: items,
|
||||||
emptyMessage: 'No audit logs found',
|
emptyMessage: 'No audit logs found',
|
||||||
onEnsureFullDataset: onEnsureFullDataset,
|
onEnsureFullDataset: onEnsureFullDataset,
|
||||||
onColumnSearchCleared: onColumnSearchCleared,
|
onColumnSearchCleared: onColumnSearchCleared,
|
||||||
columns: [
|
columns: 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,7 +77,7 @@ final _entries = [
|
|||||||
),
|
),
|
||||||
// Assets
|
// Assets
|
||||||
_GalleryEntry(title: 'Asset List', route: RouteConstants.assets, group: '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'),
|
_GalleryEntry(title: 'Alerts', route: RouteConstants.assetAlerts, group: 'Assets'),
|
||||||
// Master data
|
// Master data
|
||||||
_GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: '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/app_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/can_permission.dart';
|
import '../../../../shared/widgets/can_permission.dart';
|
||||||
import '../../../../shared/widgets/app_table_action_icon.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/app_table_shell.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
|
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||||||
import '../../../../shared/utils/file_download_helper.dart';
|
import '../../../../shared/utils/file_download_helper.dart';
|
||||||
import '../providers/grn_lookups_provider.dart';
|
import '../providers/grn_lookups_provider.dart';
|
||||||
import '../providers/grn_provider.dart';
|
import '../providers/grn_provider.dart';
|
||||||
@ -82,6 +84,11 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
|||||||
() => _filtersExpanded = !_filtersExpanded,
|
() => _filtersExpanded = !_filtersExpanded,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
AppTableColumnSelectorButton(
|
||||||
|
tableId: _GrnDataTable.tableId,
|
||||||
|
columns: _GrnDataTable.columnOptions,
|
||||||
|
),
|
||||||
if (canExport) ...[
|
if (canExport) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
@ -289,7 +296,7 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GrnDataTable extends StatelessWidget {
|
class _GrnDataTable extends ConsumerWidget {
|
||||||
const _GrnDataTable({
|
const _GrnDataTable({
|
||||||
required this.grns,
|
required this.grns,
|
||||||
required this.onView,
|
required this.onView,
|
||||||
@ -298,90 +305,129 @@ class _GrnDataTable extends StatelessWidget {
|
|||||||
this.onColumnSearchCleared,
|
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 List<GrnModel> grns;
|
||||||
final ValueChanged<GrnModel> onView;
|
final ValueChanged<GrnModel> onView;
|
||||||
final ValueChanged<GrnModel>? onEdit;
|
final ValueChanged<GrnModel>? onEdit;
|
||||||
final Future<void> Function()? onEnsureFullDataset;
|
final Future<void> Function()? onEnsureFullDataset;
|
||||||
final VoidCallback? onColumnSearchCleared;
|
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
|
@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>(
|
return AppDataTable<GrnModel>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
onEnsureFullDataset: onEnsureFullDataset,
|
onEnsureFullDataset: onEnsureFullDataset,
|
||||||
onColumnSearchCleared: onColumnSearchCleared,
|
onColumnSearchCleared: onColumnSearchCleared,
|
||||||
columns: [
|
columns: 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
rows: grns,
|
rows: grns,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import '../../../../core/constants/route_constants.dart';
|
|||||||
import '../../../../core/errors/failure.dart';
|
import '../../../../core/errors/failure.dart';
|
||||||
import '../../../../shared/utils/file_download_helper.dart';
|
import '../../../../shared/utils/file_download_helper.dart';
|
||||||
import '../../../../shared/providers/permissions_provider.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_confirmation_dialog.dart';
|
||||||
import '../../../../shared/widgets/app_data_table.dart';
|
import '../../../../shared/widgets/app_data_table.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.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_search_filter_toggle.dart';
|
||||||
import '../../../../shared/widgets/app_status_chip.dart';
|
import '../../../../shared/widgets/app_status_chip.dart';
|
||||||
import '../../../../shared/widgets/app_table_action_icon.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/app_table_shell.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/app_side_panel.dart';
|
import '../../../../shared/widgets/app_side_panel.dart';
|
||||||
@ -204,6 +206,11 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
|||||||
() => _filtersExpanded = !_filtersExpanded,
|
() => _filtersExpanded = !_filtersExpanded,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
AppTableColumnSelectorButton(
|
||||||
|
tableId: _MasterListTable.tableIdFor(def),
|
||||||
|
columns: _MasterListTable.columnOptionsFor(def),
|
||||||
|
),
|
||||||
if (canExport) ...[
|
if (canExport) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
@ -285,7 +292,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MasterListTable extends StatelessWidget {
|
class _MasterListTable extends ConsumerWidget {
|
||||||
const _MasterListTable({
|
const _MasterListTable({
|
||||||
required this.definition,
|
required this.definition,
|
||||||
required this.items,
|
required this.items,
|
||||||
@ -308,75 +315,111 @@ class _MasterListTable extends StatelessWidget {
|
|||||||
final Future<void> Function()? onEnsureFullDataset;
|
final Future<void> Function()? onEnsureFullDataset;
|
||||||
final VoidCallback? onColumnSearchCleared;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final prefs = ref.watch(tableColumnPrefsProvider(tableIdFor(definition)));
|
||||||
|
final columns = resolveAppDataColumns(_allColumns(theme), prefs);
|
||||||
|
|
||||||
return AppDataTable<Map<String, dynamic>>(
|
return AppDataTable<Map<String, dynamic>>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
onEnsureFullDataset: onEnsureFullDataset,
|
onEnsureFullDataset: onEnsureFullDataset,
|
||||||
onColumnSearchCleared: onColumnSearchCleared,
|
onColumnSearchCleared: onColumnSearchCleared,
|
||||||
emptyMessage: 'No ${definition.title.toLowerCase()} found',
|
emptyMessage: 'No ${definition.title.toLowerCase()} found',
|
||||||
columns: [
|
columns: 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
rows: items,
|
rows: items,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -201,12 +201,10 @@ class _MasterQuickAddDropdownState<T>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showAppToastFromSnackBar(
|
showAppToast(
|
||||||
context,
|
context,
|
||||||
SnackBar(
|
'${_titleCase(masterQuickAddNoun(widget.masterId))} added',
|
||||||
content:
|
type: AppToastType.success,
|
||||||
Text('${_titleCase(masterQuickAddNoun(widget.masterId))} added'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -23,9 +23,11 @@ import '../../../../shared/widgets/app_search_filter_toggle.dart';
|
|||||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/can_permission.dart';
|
import '../../../../shared/widgets/can_permission.dart';
|
||||||
import '../../../../shared/widgets/app_table_action_icon.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/app_table_shell.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
|
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||||||
import '../../../../shared/utils/file_download_helper.dart';
|
import '../../../../shared/utils/file_download_helper.dart';
|
||||||
import '../../data/repositories/purchase_order_repository_impl.dart';
|
import '../../data/repositories/purchase_order_repository_impl.dart';
|
||||||
import '../providers/purchase_orders_provider.dart';
|
import '../providers/purchase_orders_provider.dart';
|
||||||
@ -133,6 +135,11 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
|||||||
() => _filtersExpanded = !_filtersExpanded,
|
() => _filtersExpanded = !_filtersExpanded,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
AppTableColumnSelectorButton(
|
||||||
|
tableId: _PoDataTable.tableId,
|
||||||
|
columns: _PoDataTable.columnOptions,
|
||||||
|
),
|
||||||
if (canExport) ...[
|
if (canExport) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
@ -482,7 +489,7 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PoDataTable extends StatelessWidget {
|
class _PoDataTable extends ConsumerWidget {
|
||||||
const _PoDataTable({
|
const _PoDataTable({
|
||||||
required this.orders,
|
required this.orders,
|
||||||
required this.onView,
|
required this.onView,
|
||||||
@ -494,6 +501,20 @@ class _PoDataTable extends StatelessWidget {
|
|||||||
this.onColumnSearchCleared,
|
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 List<PurchaseOrderModel> orders;
|
||||||
final ValueChanged<PurchaseOrderModel> onView;
|
final ValueChanged<PurchaseOrderModel> onView;
|
||||||
final ValueChanged<PurchaseOrderModel>? onEdit;
|
final ValueChanged<PurchaseOrderModel>? onEdit;
|
||||||
@ -503,106 +524,129 @@ class _PoDataTable extends StatelessWidget {
|
|||||||
final Future<void> Function()? onEnsureFullDataset;
|
final Future<void> Function()? onEnsureFullDataset;
|
||||||
final VoidCallback? onColumnSearchCleared;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
|
||||||
|
final columns = resolveAppDataColumns(_allColumns(theme), prefs);
|
||||||
|
|
||||||
return AppDataTable<PurchaseOrderModel>(
|
return AppDataTable<PurchaseOrderModel>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
onEnsureFullDataset: onEnsureFullDataset,
|
onEnsureFullDataset: onEnsureFullDataset,
|
||||||
onColumnSearchCleared: onColumnSearchCleared,
|
onColumnSearchCleared: onColumnSearchCleared,
|
||||||
columns: [
|
columns: 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
rows: orders,
|
rows: orders,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1457,9 +1457,12 @@ class _PermissionMatrixTable extends ConsumerWidget {
|
|||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
columns: [
|
columns: [
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
|
id: 'module',
|
||||||
label: 'Module',
|
label: 'Module',
|
||||||
|
sortKey: 'module',
|
||||||
flex: 3,
|
flex: 3,
|
||||||
enableSearch: false,
|
enableSearch: false,
|
||||||
|
searchText: (module) => module.name,
|
||||||
cellBuilder: (context, module) {
|
cellBuilder: (context, module) {
|
||||||
final index = matrix.modules.indexOf(module);
|
final index = matrix.modules.indexOf(module);
|
||||||
final appearance =
|
final appearance =
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import '../../../../core/utils/responsive_utils.dart';
|
|||||||
import '../../../../shared/providers/permissions_provider.dart';
|
import '../../../../shared/providers/permissions_provider.dart';
|
||||||
import '../../../../shared/utils/file_download_helper.dart';
|
import '../../../../shared/utils/file_download_helper.dart';
|
||||||
import '../../../rbac/presentation/widgets/rbac_widgets.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_data_table.dart';
|
||||||
import '../../../../shared/widgets/app_date_popup.dart';
|
import '../../../../shared/widgets/app_date_popup.dart';
|
||||||
import '../../../../shared/widgets/app_date_range_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_responsive_filter_bar.dart';
|
||||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_search_filter_toggle.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_shell.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
@ -157,6 +159,11 @@ class _DepreciationReportScreenState
|
|||||||
() => _filtersExpanded = !_filtersExpanded,
|
() => _filtersExpanded = !_filtersExpanded,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
AppTableColumnSelectorButton(
|
||||||
|
tableId: _ReportTable.tableId,
|
||||||
|
columns: _ReportTable.columnOptions,
|
||||||
|
),
|
||||||
if (canExport) ...[
|
if (canExport) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
@ -586,104 +593,153 @@ class _FiltersBarState extends State<_FiltersBar> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ReportTable extends StatelessWidget {
|
class _ReportTable extends ConsumerWidget {
|
||||||
const _ReportTable({
|
const _ReportTable({
|
||||||
required this.items,
|
required this.items,
|
||||||
this.onEnsureFullDataset,
|
this.onEnsureFullDataset,
|
||||||
this.onColumnSearchCleared,
|
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 List<DepreciationReportRow> items;
|
||||||
final Future<void> Function()? onEnsureFullDataset;
|
final Future<void> Function()? onEnsureFullDataset;
|
||||||
final VoidCallback? onColumnSearchCleared;
|
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
|
@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>(
|
return AppDataTable<DepreciationReportRow>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
rows: items,
|
rows: items,
|
||||||
onEnsureFullDataset: onEnsureFullDataset,
|
onEnsureFullDataset: onEnsureFullDataset,
|
||||||
onColumnSearchCleared: onColumnSearchCleared,
|
onColumnSearchCleared: onColumnSearchCleared,
|
||||||
columns: [
|
columns: 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -89,9 +89,12 @@ class _MatrixGrid extends ConsumerWidget {
|
|||||||
wrapInCard: true,
|
wrapInCard: true,
|
||||||
columns: [
|
columns: [
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
|
id: 'module',
|
||||||
label: 'Module',
|
label: 'Module',
|
||||||
|
sortKey: 'module',
|
||||||
flex: 3,
|
flex: 3,
|
||||||
enableSearch: false,
|
enableSearch: false,
|
||||||
|
searchText: (row) => row.name,
|
||||||
cellBuilder: (_, row) => AppTableCell.text(row.name),
|
cellBuilder: (_, row) => AppTableCell.text(row.name),
|
||||||
),
|
),
|
||||||
...matrix.actionColumns.map(
|
...matrix.actionColumns.map(
|
||||||
|
|||||||
@ -6,12 +6,14 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../../../core/errors/failure.dart';
|
import '../../../../core/errors/failure.dart';
|
||||||
import '../../../../core/utils/responsive_utils.dart';
|
import '../../../../core/utils/responsive_utils.dart';
|
||||||
import '../../../../shared/models/user_management_models.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_data_table.dart';
|
||||||
import '../../../../shared/widgets/app_empty_state.dart';
|
import '../../../../shared/widgets/app_empty_state.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_pagination.dart';
|
import '../../../../shared/widgets/app_pagination.dart';
|
||||||
import '../../../../shared/widgets/app_search_field.dart';
|
import '../../../../shared/widgets/app_search_field.dart';
|
||||||
import '../../../../shared/widgets/app_search_filter_toggle.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_shell.dart';
|
||||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
@ -63,6 +65,11 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
|
|||||||
() => _filtersExpanded = !_filtersExpanded,
|
() => _filtersExpanded = !_filtersExpanded,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
AppTableColumnSelectorButton(
|
||||||
|
tableId: _RoleDataTable.tableId,
|
||||||
|
columns: _RoleDataTable.columnOptions,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -128,7 +135,7 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RoleDataTable extends StatelessWidget {
|
class _RoleDataTable extends ConsumerWidget {
|
||||||
const _RoleDataTable({
|
const _RoleDataTable({
|
||||||
required this.roles,
|
required this.roles,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
@ -136,47 +143,82 @@ class _RoleDataTable extends StatelessWidget {
|
|||||||
this.onColumnSearchCleared,
|
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 List<RoleCardModel> roles;
|
||||||
final void Function(RoleCardModel role) onOpen;
|
final void Function(RoleCardModel role) onOpen;
|
||||||
final Future<void> Function()? onEnsureFullDataset;
|
final Future<void> Function()? onEnsureFullDataset;
|
||||||
final VoidCallback? onColumnSearchCleared;
|
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
|
@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>(
|
return AppDataTable<RoleCardModel>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
onEnsureFullDataset: onEnsureFullDataset,
|
onEnsureFullDataset: onEnsureFullDataset,
|
||||||
onColumnSearchCleared: onColumnSearchCleared,
|
onColumnSearchCleared: onColumnSearchCleared,
|
||||||
columns: [
|
columns: 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
rows: roles,
|
rows: roles,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import '../../../../shared/widgets/error_view.dart';
|
|||||||
import '../../../../shared/widgets/kpi_card.dart';
|
import '../../../../shared/widgets/kpi_card.dart';
|
||||||
import '../../../../shared/widgets/app_table_shell.dart';
|
import '../../../../shared/widgets/app_table_shell.dart';
|
||||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||||
|
import '../../../../shared/widgets/app_table_column_selector.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
import '../widgets/user_rich_data_table.dart';
|
import '../widgets/user_rich_data_table.dart';
|
||||||
import '../providers/users_provider.dart';
|
import '../providers/users_provider.dart';
|
||||||
@ -69,6 +70,11 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
AppTableColumnSelectorButton(
|
||||||
|
tableId: UserRichDataTable.tableId,
|
||||||
|
columns: UserRichDataTable.columnOptions,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () => context.push(RouteConstants.userAdd),
|
onPressed: () => context.push(RouteConstants.userAdd),
|
||||||
icon: const Icon(Icons.person_add),
|
icon: const Icon(Icons.person_add),
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../../core/utils/formatters.dart';
|
import '../../../../core/utils/formatters.dart';
|
||||||
import '../../../../shared/models/user_management_models.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_data_table.dart';
|
||||||
import '../../../../shared/widgets/app_status_chip.dart';
|
import '../../../../shared/widgets/app_status_chip.dart';
|
||||||
|
import '../../../../shared/widgets/app_table_column_selector.dart';
|
||||||
import '../../../rbac/presentation/widgets/rbac_widgets.dart';
|
import '../../../rbac/presentation/widgets/rbac_widgets.dart';
|
||||||
|
|
||||||
typedef UserTableActionsBuilder = Widget Function(
|
typedef UserTableActionsBuilder = Widget Function(
|
||||||
@ -11,7 +14,7 @@ typedef UserTableActionsBuilder = Widget Function(
|
|||||||
ManagedUserModel user,
|
ManagedUserModel user,
|
||||||
);
|
);
|
||||||
|
|
||||||
class UserRichDataTable extends StatelessWidget {
|
class UserRichDataTable extends ConsumerWidget {
|
||||||
const UserRichDataTable({
|
const UserRichDataTable({
|
||||||
super.key,
|
super.key,
|
||||||
required this.users,
|
required this.users,
|
||||||
@ -25,6 +28,8 @@ class UserRichDataTable extends StatelessWidget {
|
|||||||
this.onColumnSearchCleared,
|
this.onColumnSearchCleared,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static const tableId = 'users_list';
|
||||||
|
|
||||||
final List<ManagedUserModel> users;
|
final List<ManagedUserModel> users;
|
||||||
final UserTableActionsBuilder actionsBuilder;
|
final UserTableActionsBuilder actionsBuilder;
|
||||||
final String? sortColumn;
|
final String? sortColumn;
|
||||||
@ -35,9 +40,92 @@ class UserRichDataTable extends StatelessWidget {
|
|||||||
final Future<void> Function()? onEnsureFullDataset;
|
final Future<void> Function()? onEnsureFullDataset;
|
||||||
final VoidCallback? onColumnSearchCleared;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
|
||||||
|
final columns = resolveAppDataColumns(_allColumns(theme), prefs);
|
||||||
|
|
||||||
return AppDataTable<ManagedUserModel>(
|
return AppDataTable<ManagedUserModel>(
|
||||||
wrapInCard: wrapInCard,
|
wrapInCard: wrapInCard,
|
||||||
@ -47,67 +135,7 @@ class UserRichDataTable extends StatelessWidget {
|
|||||||
onServerSearchChanged: onServerSearchChanged,
|
onServerSearchChanged: onServerSearchChanged,
|
||||||
onEnsureFullDataset: onEnsureFullDataset,
|
onEnsureFullDataset: onEnsureFullDataset,
|
||||||
onColumnSearchCleared: onColumnSearchCleared,
|
onColumnSearchCleared: onColumnSearchCleared,
|
||||||
columns: [
|
columns: 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
rows: users,
|
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_search_filter_toggle.dart';
|
||||||
import '../../../../shared/widgets/app_status_chip.dart';
|
import '../../../../shared/widgets/app_status_chip.dart';
|
||||||
import '../../../../shared/widgets/app_table_action_icon.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/can_permission.dart';
|
||||||
import '../../../../shared/widgets/app_table_shell.dart';
|
import '../../../../shared/widgets/app_table_shell.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
|
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||||||
import '../../../../shared/utils/file_download_helper.dart';
|
import '../../../../shared/utils/file_download_helper.dart';
|
||||||
import '../providers/vendors_provider.dart';
|
import '../providers/vendors_provider.dart';
|
||||||
import '../widgets/vendor_form_panel.dart';
|
import '../widgets/vendor_form_panel.dart';
|
||||||
@ -86,6 +88,11 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
|
|||||||
() => _filtersExpanded = !_filtersExpanded,
|
() => _filtersExpanded = !_filtersExpanded,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
AppTableColumnSelectorButton(
|
||||||
|
tableId: _VendorDataTable.tableId,
|
||||||
|
columns: _VendorDataTable.columnOptions,
|
||||||
|
),
|
||||||
if (canExport) ...[
|
if (canExport) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
@ -288,7 +295,7 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VendorDataTable extends StatelessWidget {
|
class _VendorDataTable extends ConsumerWidget {
|
||||||
const _VendorDataTable({
|
const _VendorDataTable({
|
||||||
required this.vendors,
|
required this.vendors,
|
||||||
required this.onView,
|
required this.onView,
|
||||||
@ -298,6 +305,20 @@ class _VendorDataTable extends StatelessWidget {
|
|||||||
this.onColumnSearchCleared,
|
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 List<VendorModel> vendors;
|
||||||
final ValueChanged<VendorModel> onView;
|
final ValueChanged<VendorModel> onView;
|
||||||
final ValueChanged<VendorModel>? onEdit;
|
final ValueChanged<VendorModel>? onEdit;
|
||||||
@ -305,79 +326,100 @@ class _VendorDataTable extends StatelessWidget {
|
|||||||
final Future<void> Function()? onEnsureFullDataset;
|
final Future<void> Function()? onEnsureFullDataset;
|
||||||
final VoidCallback? onColumnSearchCleared;
|
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
|
@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>(
|
return AppDataTable<VendorModel>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
onEnsureFullDataset: onEnsureFullDataset,
|
onEnsureFullDataset: onEnsureFullDataset,
|
||||||
onColumnSearchCleared: onColumnSearchCleared,
|
onColumnSearchCleared: onColumnSearchCleared,
|
||||||
columns: [
|
columns: 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
rows: vendors,
|
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;
|
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;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
|
|||||||
@ -106,10 +106,10 @@ const List<MenuItem> appMenuItems = [
|
|||||||
module: 'reports',
|
module: 'reports',
|
||||||
),
|
),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'Support',
|
label: 'Manage',
|
||||||
icon: Icons.support_agent_outlined,
|
icon: Icons.admin_panel_settings_outlined,
|
||||||
route: RouteConstants.vendors,
|
route: RouteConstants.vendors,
|
||||||
module: 'support',
|
module: 'administrator',
|
||||||
children: [
|
children: [
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'Vendors',
|
label: 'Vendors',
|
||||||
@ -119,7 +119,7 @@ const List<MenuItem> appMenuItems = [
|
|||||||
),
|
),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'Users & Roles',
|
label: 'Users & Roles',
|
||||||
icon: Icons.admin_panel_settings_outlined,
|
icon: Icons.manage_accounts_outlined,
|
||||||
route: RouteConstants.usersRoleManagement,
|
route: RouteConstants.usersRoleManagement,
|
||||||
module: 'users',
|
module: 'users',
|
||||||
),
|
),
|
||||||
@ -145,18 +145,28 @@ const List<MenuItem> appMenuItems = [
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// True when [item] is the Support group (top-nav dropdown / sidebar section).
|
/// True when [item] is the Manage group (sidebar / top-nav).
|
||||||
bool isSupportMenuItem(MenuItem item) =>
|
bool isAdministratorMenuItem(MenuItem item) =>
|
||||||
item.module == 'support' || item.label == 'Support';
|
item.module == 'administrator' ||
|
||||||
|
item.module == 'support' ||
|
||||||
|
item.label == 'Manage' ||
|
||||||
|
item.label == 'Administrator' ||
|
||||||
|
item.label == 'Support';
|
||||||
|
|
||||||
/// Child routes that belong under Support (sidebar section flattening).
|
/// Child routes that belong under Manage.
|
||||||
const Set<String> supportMenuRoutes = {
|
const Set<String> administratorMenuRoutes = {
|
||||||
RouteConstants.vendors,
|
RouteConstants.vendors,
|
||||||
RouteConstants.usersRoleManagement,
|
RouteConstants.usersRoleManagement,
|
||||||
RouteConstants.masterData,
|
RouteConstants.masterData,
|
||||||
RouteConstants.auditLogs,
|
RouteConstants.auditLogs,
|
||||||
RouteConstants.settings,
|
RouteConstants.settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@Deprecated('Use isAdministratorMenuItem')
|
||||||
|
bool isSupportMenuItem(MenuItem item) => isAdministratorMenuItem(item);
|
||||||
|
|
||||||
|
@Deprecated('Use administratorMenuRoutes')
|
||||||
|
const Set<String> supportMenuRoutes = administratorMenuRoutes;
|
||||||
List<MenuItem> getVisibleMenuItems({
|
List<MenuItem> getVisibleMenuItems({
|
||||||
required List<String> permissions,
|
required List<String> permissions,
|
||||||
required UserRole role,
|
required UserRole role,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/utils/table_search.dart';
|
import '../../core/utils/table_search.dart';
|
||||||
|
import '../models/table_column_prefs.dart';
|
||||||
import 'app_card.dart';
|
import 'app_card.dart';
|
||||||
|
|
||||||
/// Fixed height for every data row in [AppDataTable] and themed [DataTable] widgets.
|
/// Fixed height for every data row in [AppDataTable] and themed [DataTable] widgets.
|
||||||
@ -16,18 +17,30 @@ class AppDataColumn<T> {
|
|||||||
const AppDataColumn({
|
const AppDataColumn({
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.cellBuilder,
|
required this.cellBuilder,
|
||||||
|
this.id,
|
||||||
this.sortKey,
|
this.sortKey,
|
||||||
|
this.sortValue,
|
||||||
this.flex = 1,
|
this.flex = 1,
|
||||||
this.width,
|
this.width,
|
||||||
this.alignment = Alignment.centerLeft,
|
this.alignment = Alignment.centerLeft,
|
||||||
this.padding = EdgeInsets.zero,
|
this.padding = EdgeInsets.zero,
|
||||||
this.searchText,
|
this.searchText,
|
||||||
this.enableSearch,
|
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 String label;
|
||||||
final Widget Function(BuildContext context, T row) cellBuilder;
|
final Widget Function(BuildContext context, T row) cellBuilder;
|
||||||
final String? sortKey;
|
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;
|
final int flex;
|
||||||
|
|
||||||
/// When set, column uses a fixed width instead of [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.
|
/// When null, search is enabled only if [searchText] is provided.
|
||||||
final bool? enableSearch;
|
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;
|
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].
|
/// 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 _fullDatasetActive = false;
|
||||||
bool _ensureInFlight = false;
|
bool _ensureInFlight = false;
|
||||||
|
|
||||||
|
/// Client-side sort when [AppDataTable.onSort] is null.
|
||||||
|
String? _clientSortColumn;
|
||||||
|
bool _clientSortAscending = true;
|
||||||
|
|
||||||
bool get _usesFullDatasetMode =>
|
bool get _usesFullDatasetMode =>
|
||||||
widget.onEnsureFullDataset != null || widget.onColumnSearchCleared != null;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_ensureDatasetDebouncer.dispose();
|
_ensureDatasetDebouncer.dispose();
|
||||||
@ -253,6 +328,7 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
|||||||
for (final entry in _queries.entries) {
|
for (final entry in _queries.entries) {
|
||||||
final q = entry.value.trim().toLowerCase();
|
final q = entry.value.trim().toLowerCase();
|
||||||
if (q.isEmpty) continue;
|
if (q.isEmpty) continue;
|
||||||
|
if (entry.key < 0 || entry.key >= widget.columns.length) continue;
|
||||||
final col = widget.columns[entry.key];
|
final col = widget.columns[entry.key];
|
||||||
if (!col.isSearchable || col.searchText == null) continue;
|
if (!col.isSearchable || col.searchText == null) continue;
|
||||||
active[entry.key] = q;
|
active[entry.key] = q;
|
||||||
@ -269,10 +345,47 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
|||||||
}).toList();
|
}).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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final showFilterRow = widget.columns.any((c) => c.isSearchable);
|
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,
|
// Keep header + column filters visible even when the API returns no rows,
|
||||||
// so users can refine or clear a server-side search.
|
// 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>(
|
final header = _TableHeaderRow<T>(
|
||||||
columns: widget.columns,
|
columns: widget.columns,
|
||||||
sortColumn: widget.sortColumn,
|
sortColumn: _activeSortColumn,
|
||||||
sortAscending: widget.sortAscending,
|
sortAscending: _activeSortAscending,
|
||||||
onSort: widget.onSort,
|
onSort: _handleSort,
|
||||||
);
|
);
|
||||||
|
|
||||||
final filterRow = showFilterRow
|
final filterRow = showFilterRow
|
||||||
@ -453,6 +566,16 @@ class _TableHeaderRow<T> extends StatelessWidget {
|
|||||||
|
|
||||||
if (col.sortKey == null || onSort == null) return label;
|
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(
|
return InkWell(
|
||||||
onTap: () => onSort(
|
onTap: () => onSort(
|
||||||
col.sortKey!,
|
col.sortKey!,
|
||||||
@ -462,14 +585,8 @@ class _TableHeaderRow<T> extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Flexible(child: label),
|
Flexible(child: label),
|
||||||
if (isSorted) ...[
|
const SizedBox(width: 4),
|
||||||
const SizedBox(width: 4),
|
Icon(sortIcon, size: 14, color: sortIconColor),
|
||||||
Icon(
|
|
||||||
sortAscending ? Icons.arrow_upward : Icons.arrow_downward,
|
|
||||||
size: 14,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@ -58,32 +59,20 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
final Set<String> _manuallyCollapsedMenus = <String>{};
|
final Set<String> _manuallyCollapsedMenus = <String>{};
|
||||||
|
|
||||||
List<menu.MenuItem> get _primaryMenuItems => widget.menuItems
|
List<menu.MenuItem> get _primaryMenuItems => widget.menuItems
|
||||||
.where((item) => !menu.isSupportMenuItem(item))
|
.where((item) => !menu.isAdministratorMenuItem(item))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
/// Sidebar shows Support children flat under the SUPPORT section label.
|
/// Collapsible Administrator group pinned above the user profile.
|
||||||
/// Top nav keeps the Support parent as a dropdown (via [menuItems] as-is).
|
menu.MenuItem? get _administratorMenuItem {
|
||||||
List<menu.MenuItem> get _supportMenuItems {
|
|
||||||
final items = <menu.MenuItem>[];
|
|
||||||
for (final item in widget.menuItems) {
|
for (final item in widget.menuItems) {
|
||||||
if (menu.isSupportMenuItem(item)) {
|
if (menu.isAdministratorMenuItem(item) && item.children.isNotEmpty) {
|
||||||
if (item.children.isNotEmpty) {
|
return item;
|
||||||
items.addAll(item.children);
|
|
||||||
} else {
|
|
||||||
items.add(item);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (menu.supportMenuRoutes.contains(item.route) &&
|
|
||||||
item.children.isEmpty) {
|
|
||||||
items.add(item);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return items;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _hasSupportSection =>
|
bool get _hasAdministratorMenu => _administratorMenuItem != null;
|
||||||
_supportMenuItems.isNotEmpty || AppConstants.showNotificationsMenu;
|
|
||||||
|
|
||||||
bool _routeMatches(String route) {
|
bool _routeMatches(String route) {
|
||||||
final current = widget.currentRoute;
|
final current = widget.currentRoute;
|
||||||
@ -113,9 +102,13 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
return _expandedMenus.contains(item.route) || _isGroupActive(item);
|
return _expandedMenus.contains(item.route) || _isGroupActive(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
Iterable<menu.MenuItem> get _allGroupItems =>
|
Iterable<menu.MenuItem> get _allGroupItems sync* {
|
||||||
[..._primaryMenuItems, ..._supportMenuItems]
|
for (final item in _primaryMenuItems) {
|
||||||
.where((item) => item.children.isNotEmpty);
|
if (item.children.isNotEmpty) yield item;
|
||||||
|
}
|
||||||
|
final admin = _administratorMenuItem;
|
||||||
|
if (admin != null) yield admin;
|
||||||
|
}
|
||||||
|
|
||||||
/// Accordion: keep only [keepRoute] expanded; collapse every other group.
|
/// Accordion: keep only [keepRoute] expanded; collapse every other group.
|
||||||
void _expandOnly(String keepRoute) {
|
void _expandOnly(String keepRoute) {
|
||||||
@ -211,27 +204,37 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
children: [
|
children: [
|
||||||
if (isNarrow) const SizedBox(height: 4),
|
if (isNarrow) const SizedBox(height: 4),
|
||||||
..._buildNavItems(_primaryMenuItems, isNarrow: isNarrow),
|
..._buildNavItems(_primaryMenuItems, isNarrow: isNarrow),
|
||||||
if (_hasSupportSection) ...[
|
if (AppConstants.showNotificationsMenu)
|
||||||
const SizedBox(height: 16),
|
_SidebarNavItem(
|
||||||
if (!isNarrow) const _SectionLabel(label: 'SUPPORT'),
|
icon: Icons.notifications_outlined,
|
||||||
if (AppConstants.showNotificationsMenu)
|
label: 'Notifications',
|
||||||
_SidebarNavItem(
|
selected: false,
|
||||||
icon: Icons.notifications_outlined,
|
collapsed: isNarrow,
|
||||||
label: 'Notifications',
|
badge: isNarrow ? null : '3',
|
||||||
selected: false,
|
onTap: () {},
|
||||||
collapsed: isNarrow,
|
|
||||||
badge: isNarrow ? null : '3',
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
..._buildNavItems(
|
|
||||||
_supportMenuItems,
|
|
||||||
isNarrow: isNarrow,
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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),
|
_buildUserProfile(context, isNarrow: isNarrow),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -245,6 +248,7 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
List<Widget> _buildNavItems(
|
List<Widget> _buildNavItems(
|
||||||
List<menu.MenuItem> items, {
|
List<menu.MenuItem> items, {
|
||||||
required bool isNarrow,
|
required bool isNarrow,
|
||||||
|
bool expandUpward = false,
|
||||||
}) {
|
}) {
|
||||||
return items.map((item) {
|
return items.map((item) {
|
||||||
if (isNarrow && item.children.isNotEmpty) {
|
if (isNarrow && item.children.isNotEmpty) {
|
||||||
@ -260,33 +264,46 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
if (item.children.isNotEmpty && !isNarrow) {
|
if (item.children.isNotEmpty && !isNarrow) {
|
||||||
final expanded = _isGroupExpanded(item);
|
final expanded = _isGroupExpanded(item);
|
||||||
final active = _isGroupActive(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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: expandUpward
|
||||||
_SidebarNavItem(
|
? [childrenPanel, header]
|
||||||
icon: item.icon,
|
: [header, childrenPanel],
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -554,6 +571,32 @@ class _CollapsedFlyoutNavItemState extends State<_CollapsedFlyoutNavItem> {
|
|||||||
final anchorTopLeft = renderBox.localToGlobal(Offset.zero);
|
final anchorTopLeft = renderBox.localToGlobal(Offset.zero);
|
||||||
final anchorSize = renderBox.size;
|
final anchorSize = renderBox.size;
|
||||||
const panelWidth = 220.0;
|
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(
|
_overlayEntry = OverlayEntry(
|
||||||
builder: (overlayContext) {
|
builder: (overlayContext) {
|
||||||
@ -561,8 +604,8 @@ class _CollapsedFlyoutNavItemState extends State<_CollapsedFlyoutNavItem> {
|
|||||||
final primary = theme.colorScheme.primary;
|
final primary = theme.colorScheme.primary;
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: anchorTopLeft.dx,
|
left: left,
|
||||||
top: anchorTopLeft.dy,
|
top: top,
|
||||||
child: TapRegion(
|
child: TapRegion(
|
||||||
onTapOutside: (_) => _removeOverlay(),
|
onTapOutside: (_) => _removeOverlay(),
|
||||||
child: MouseRegion(
|
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 {
|
class _SidebarNavItem extends StatelessWidget {
|
||||||
const _SidebarNavItem({
|
const _SidebarNavItem({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
@ -940,12 +962,15 @@ class _SidebarNavItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showChevron)
|
if (showChevron)
|
||||||
Icon(
|
AnimatedRotation(
|
||||||
chevronExpanded
|
turns: chevronExpanded ? 0.5 : 0,
|
||||||
? Icons.keyboard_arrow_up
|
duration: const Duration(milliseconds: 220),
|
||||||
: Icons.keyboard_arrow_down,
|
curve: Curves.easeInOutCubic,
|
||||||
size: 18,
|
child: Icon(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
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('saved') ||
|
||||||
lower.contains('updated') ||
|
lower.contains('updated') ||
|
||||||
lower.contains('created') ||
|
lower.contains('created') ||
|
||||||
|
lower.contains('added') ||
|
||||||
lower.contains('deleted') ||
|
lower.contains('deleted') ||
|
||||||
lower.contains('uploaded') ||
|
lower.contains('uploaded') ||
|
||||||
lower.contains('downloaded') ||
|
lower.contains('downloaded') ||
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user