PO calculation
This commit is contained in:
parent
09ffc5fb6a
commit
4046633340
@ -93,25 +93,14 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items = TableSearch.filter(
|
// Keep API page results + enriched matches; pagination stays server-driven.
|
||||||
items,
|
|
||||||
search,
|
|
||||||
(asset) => [
|
|
||||||
asset.assetCode,
|
|
||||||
asset.assetName,
|
|
||||||
asset.assetCategoryName,
|
|
||||||
asset.locationName,
|
|
||||||
asset.status,
|
|
||||||
asset.condition,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return AssetsListState(
|
return AssetsListState(
|
||||||
assets: items,
|
assets: items,
|
||||||
query: query,
|
query: query,
|
||||||
total: search.isEmpty ? page.total : items.length,
|
total: page.total,
|
||||||
totalPages: search.isEmpty ? page.totalPages : 1,
|
totalPages: page.totalPages,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -158,33 +158,17 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
|||||||
onStatusChanged: notifier.setStatusFilter,
|
onStatusChanged: notifier.setStatusFilter,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (state.assets.isEmpty)
|
Expanded(
|
||||||
Expanded(
|
child: _AssetDataTable(
|
||||||
child: Center(
|
assets: state.assets,
|
||||||
child: Text(
|
canEdit: canEdit,
|
||||||
'No assets found',
|
canDelete: canDelete,
|
||||||
style: Theme.of(context)
|
onView: _viewAsset,
|
||||||
.textTheme
|
onEdit: _editAsset,
|
||||||
.bodyLarge
|
onDelete: _deleteAsset,
|
||||||
?.copyWith(
|
onServerSearch: notifier.setSearch,
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Expanded(
|
|
||||||
child: _AssetDataTable(
|
|
||||||
assets: state.assets,
|
|
||||||
canEdit: canEdit,
|
|
||||||
canDelete: canDelete,
|
|
||||||
onView: _viewAsset,
|
|
||||||
onEdit: _editAsset,
|
|
||||||
onDelete: _deleteAsset,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@ -390,6 +374,7 @@ class _AssetDataTable extends StatelessWidget {
|
|||||||
required this.onView,
|
required this.onView,
|
||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
|
this.onServerSearch,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<AssetModel> assets;
|
final List<AssetModel> assets;
|
||||||
@ -398,11 +383,13 @@ class _AssetDataTable extends StatelessWidget {
|
|||||||
final void Function(AssetModel asset) onView;
|
final void Function(AssetModel asset) onView;
|
||||||
final void Function(AssetModel asset) onEdit;
|
final void Function(AssetModel asset) onEdit;
|
||||||
final Future<void> Function(AssetModel asset) onDelete;
|
final Future<void> Function(AssetModel asset) onDelete;
|
||||||
|
final ValueChanged<String>? onServerSearch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppDataTable<AssetModel>(
|
return AppDataTable<AssetModel>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
|
onServerSearchChanged: onServerSearch,
|
||||||
columns: [
|
columns: [
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'Asset Code',
|
label: 'Asset Code',
|
||||||
|
|||||||
@ -637,10 +637,13 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
lookupsAsync.when(
|
SidePanelFormRow(
|
||||||
loading: () => const LinearProgressIndicator(),
|
left: lookupsAsync.when(
|
||||||
error: (_, __) => const Text('Failed to load locations'),
|
loading: () => const LinearProgressIndicator(),
|
||||||
data: (lookups) => _locationDropdown(lookups.locations),
|
error: (_, __) => const Text('Failed to load locations'),
|
||||||
|
data: (lookups) => _locationDropdown(lookups.locations),
|
||||||
|
),
|
||||||
|
right: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -744,12 +747,15 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_optionalLookupDropdown(
|
SidePanelFormRow(
|
||||||
label: 'Assigned To',
|
left: _optionalLookupDropdown(
|
||||||
value: _assignedToUserId,
|
label: 'Assigned To',
|
||||||
options: lookups.users,
|
value: _assignedToUserId,
|
||||||
onChanged: (v) => setState(() => _assignedToUserId = v),
|
options: lookups.users,
|
||||||
emptyHint: 'Unassigned',
|
onChanged: (v) => setState(() => _assignedToUserId = v),
|
||||||
|
emptyHint: 'Unassigned',
|
||||||
|
),
|
||||||
|
right: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -206,30 +206,40 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: state.items.isEmpty
|
: context.isMobile
|
||||||
? ListView(
|
? (state.items.isEmpty
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
? ListView(
|
||||||
children: const [
|
physics:
|
||||||
SizedBox(
|
const AlwaysScrollableScrollPhysics(),
|
||||||
height: 260,
|
children: const [
|
||||||
child: AppEmptyState(
|
SizedBox(
|
||||||
title: 'No audit logs found',
|
height: 260,
|
||||||
description:
|
child: AppEmptyState(
|
||||||
'Try adjusting filters or expanding the date range.',
|
title: 'No audit logs found',
|
||||||
icon: Icons.history_outlined,
|
description:
|
||||||
),
|
'Try adjusting filters or expanding the date range.',
|
||||||
),
|
icon: Icons.history_outlined,
|
||||||
],
|
),
|
||||||
)
|
),
|
||||||
: context.isMobile
|
],
|
||||||
? _AuditCardList(
|
|
||||||
items: state.items,
|
|
||||||
onView: _viewLog,
|
|
||||||
)
|
)
|
||||||
: _AuditDataTable(
|
: _AuditCardList(
|
||||||
items: state.items,
|
items: state.items,
|
||||||
onView: _viewLog,
|
onView: _viewLog,
|
||||||
),
|
))
|
||||||
|
: _AuditDataTable(
|
||||||
|
items: state.items,
|
||||||
|
onView: _viewLog,
|
||||||
|
onServerSearch: (value) {
|
||||||
|
_searchController.value = TextEditingValue(
|
||||||
|
text: value,
|
||||||
|
selection: TextSelection.collapsed(
|
||||||
|
offset: value.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
notifier.setSearch(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -369,10 +379,12 @@ class _AuditDataTable extends StatelessWidget {
|
|||||||
const _AuditDataTable({
|
const _AuditDataTable({
|
||||||
required this.items,
|
required this.items,
|
||||||
required this.onView,
|
required this.onView,
|
||||||
|
this.onServerSearch,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<AuditLogEntryModel> items;
|
final List<AuditLogEntryModel> items;
|
||||||
final void Function(AuditLogEntryModel log) onView;
|
final void Function(AuditLogEntryModel log) onView;
|
||||||
|
final ValueChanged<String>? onServerSearch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -380,6 +392,7 @@ class _AuditDataTable extends StatelessWidget {
|
|||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
rows: items,
|
rows: items,
|
||||||
emptyMessage: 'No audit logs found',
|
emptyMessage: 'No audit logs found',
|
||||||
|
onServerSearchChanged: onServerSearch,
|
||||||
columns: [
|
columns: [
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'When',
|
label: 'When',
|
||||||
|
|||||||
@ -134,25 +134,13 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Lookups enrichment is best-effort.
|
// Lookups enrichment is best-effort.
|
||||||
}
|
}
|
||||||
|
|
||||||
items = TableSearch.filter(
|
|
||||||
items,
|
|
||||||
search,
|
|
||||||
(grn) => [
|
|
||||||
grn.grnNumber,
|
|
||||||
grn.poNumber,
|
|
||||||
grn.vendorName,
|
|
||||||
grn.locationName,
|
|
||||||
grn.status,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return GrnListState(
|
return GrnListState(
|
||||||
grns: items,
|
grns: items,
|
||||||
query: query,
|
query: query,
|
||||||
total: search.isEmpty ? page.total : items.length,
|
total: page.total,
|
||||||
totalPages: search.isEmpty ? page.totalPages : 1,
|
totalPages: page.totalPages,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -626,7 +626,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final title = widget.isEditing
|
final title = widget.isEditing
|
||||||
? 'Edit ${existing?.grnNumber ?? 'GRN'}'
|
? 'Edit ${existing?.grnNumber ?? 'GRN'}'
|
||||||
: 'Create Goods Received Note';
|
: 'Create Purchase Receipt';
|
||||||
final subtitle = widget.isEditing
|
final subtitle = widget.isEditing
|
||||||
? null
|
? null
|
||||||
: 'Select an approved purchase order, enter receipt details, then confirm quantities.';
|
: 'Select an approved purchase order, enter receipt details, then confirm quantities.';
|
||||||
|
|||||||
@ -73,7 +73,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
PageHeader(
|
PageHeader(
|
||||||
title: 'Goods Received Notes',
|
title: 'Purchase Receipt',
|
||||||
subtitle: 'Record and track purchase order receipts',
|
subtitle: 'Record and track purchase order receipts',
|
||||||
actions: [
|
actions: [
|
||||||
AppSearchFilterButton(
|
AppSearchFilterButton(
|
||||||
@ -133,32 +133,41 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
|||||||
),
|
),
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: () => ref.read(grnListProvider.notifier).refresh(),
|
onRefresh: () => ref.read(grnListProvider.notifier).refresh(),
|
||||||
child: state.grns.isEmpty
|
child: context.isMobile
|
||||||
? ListView(
|
? (state.grns.isEmpty
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
? ListView(
|
||||||
children: const [
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
SizedBox(
|
children: const [
|
||||||
height: 240,
|
SizedBox(
|
||||||
child: AppEmptyState(
|
height: 240,
|
||||||
title: 'No GRNs found',
|
child: AppEmptyState(
|
||||||
description:
|
title: 'No GRNs found',
|
||||||
'Try adjusting filters or create a new goods received note.',
|
description:
|
||||||
icon: Icons.inventory_2_outlined,
|
'Try adjusting filters or create a new goods received note.',
|
||||||
),
|
icon: Icons.inventory_2_outlined,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
)
|
],
|
||||||
: context.isMobile
|
|
||||||
? _GrnCardList(
|
|
||||||
grns: state.grns,
|
|
||||||
onView: _viewGrn,
|
|
||||||
onEdit: canEdit ? _editGrn : null,
|
|
||||||
)
|
)
|
||||||
: _GrnDataTable(
|
: _GrnCardList(
|
||||||
grns: state.grns,
|
grns: state.grns,
|
||||||
onView: _viewGrn,
|
onView: _viewGrn,
|
||||||
onEdit: canEdit ? _editGrn : null,
|
onEdit: canEdit ? _editGrn : null,
|
||||||
),
|
))
|
||||||
|
: _GrnDataTable(
|
||||||
|
grns: state.grns,
|
||||||
|
onView: _viewGrn,
|
||||||
|
onEdit: canEdit ? _editGrn : null,
|
||||||
|
onServerSearch: (value) {
|
||||||
|
_searchController.value = TextEditingValue(
|
||||||
|
text: value,
|
||||||
|
selection: TextSelection.collapsed(
|
||||||
|
offset: value.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ref.read(grnListProvider.notifier).setSearch(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -288,16 +297,19 @@ class _GrnDataTable extends StatelessWidget {
|
|||||||
required this.grns,
|
required this.grns,
|
||||||
required this.onView,
|
required this.onView,
|
||||||
this.onEdit,
|
this.onEdit,
|
||||||
|
this.onServerSearch,
|
||||||
});
|
});
|
||||||
|
|
||||||
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 ValueChanged<String>? onServerSearch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppDataTable<GrnModel>(
|
return AppDataTable<GrnModel>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
|
onServerSearchChanged: onServerSearch,
|
||||||
columns: [
|
columns: [
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'GRN Number',
|
label: 'GRN Number',
|
||||||
|
|||||||
@ -152,14 +152,7 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items = TableSearch.filter(
|
// Keep API page results + enriched matches; pagination stays server-driven.
|
||||||
items,
|
|
||||||
nextSearch,
|
|
||||||
(row) => [
|
|
||||||
..._definition.listFields.map((field) => masterCellValue(row, field)),
|
|
||||||
masterStatusValue(row),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final data = result.data!;
|
final data = result.data!;
|
||||||
@ -170,10 +163,8 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
|
|||||||
// Keep the requested page size so the /page dropdown stays valid
|
// Keep the requested page size so the /page dropdown stays valid
|
||||||
// even if API meta omits or mismatches `limit`.
|
// even if API meta omits or mismatches `limit`.
|
||||||
limit: nextLimit,
|
limit: nextLimit,
|
||||||
total: nextSearch.isEmpty ? data.total : items.length,
|
total: data.total,
|
||||||
totalPages: nextSearch.isEmpty
|
totalPages: resolveTotalPages(total: data.total, limit: nextLimit),
|
||||||
? resolveTotalPages(total: data.total, limit: nextLimit)
|
|
||||||
: resolveTotalPages(total: items.length, limit: nextLimit),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import '../../../../shared/utils/file_download_helper.dart';
|
|||||||
import '../../../../shared/providers/permissions_provider.dart';
|
import '../../../../shared/providers/permissions_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_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_export_bar.dart';
|
import '../../../../shared/widgets/app_search_export_bar.dart';
|
||||||
@ -257,30 +256,24 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
|||||||
),
|
),
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: notifier.refresh,
|
onRefresh: notifier.refresh,
|
||||||
child: state.items.isEmpty
|
child: _MasterListTable(
|
||||||
? ListView(
|
definition: def,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
items: state.items,
|
||||||
children: [
|
isDeleting: state.isDeleting,
|
||||||
SizedBox(
|
canEdit: canEdit,
|
||||||
height: 240,
|
canDelete: canDelete,
|
||||||
child: AppEmptyState(
|
onEdit: (id) => _openFormPanel(recordId: id),
|
||||||
title: 'No ${def.title.toLowerCase()} found',
|
onDelete: _deleteRecord,
|
||||||
description:
|
onServerSearch: (value) {
|
||||||
'Add your first ${def.title.toLowerCase()} record to get started.',
|
_searchController.value = TextEditingValue(
|
||||||
icon: def.icon,
|
text: value,
|
||||||
),
|
selection: TextSelection.collapsed(
|
||||||
),
|
offset: value.length,
|
||||||
],
|
|
||||||
)
|
|
||||||
: _MasterListTable(
|
|
||||||
definition: def,
|
|
||||||
items: state.items,
|
|
||||||
isDeleting: state.isDeleting,
|
|
||||||
canEdit: canEdit,
|
|
||||||
canDelete: canDelete,
|
|
||||||
onEdit: (id) => _openFormPanel(recordId: id),
|
|
||||||
onDelete: _deleteRecord,
|
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
notifier.setSearch(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -301,6 +294,7 @@ class _MasterListTable extends StatelessWidget {
|
|||||||
required this.canDelete,
|
required this.canDelete,
|
||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
|
this.onServerSearch,
|
||||||
});
|
});
|
||||||
|
|
||||||
final MasterDefinition definition;
|
final MasterDefinition definition;
|
||||||
@ -310,6 +304,7 @@ class _MasterListTable extends StatelessWidget {
|
|||||||
final bool canDelete;
|
final bool canDelete;
|
||||||
final ValueChanged<String> onEdit;
|
final ValueChanged<String> onEdit;
|
||||||
final ValueChanged<Map<String, dynamic>> onDelete;
|
final ValueChanged<Map<String, dynamic>> onDelete;
|
||||||
|
final ValueChanged<String>? onServerSearch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -317,6 +312,8 @@ class _MasterListTable extends StatelessWidget {
|
|||||||
|
|
||||||
return AppDataTable<Map<String, dynamic>>(
|
return AppDataTable<Map<String, dynamic>>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
|
onServerSearchChanged: onServerSearch,
|
||||||
|
emptyMessage: 'No ${definition.title.toLowerCase()} found',
|
||||||
columns: [
|
columns: [
|
||||||
...definition.listFields.map(
|
...definition.listFields.map(
|
||||||
(field) => AppDataColumn<Map<String, dynamic>>(
|
(field) => AppDataColumn<Map<String, dynamic>>(
|
||||||
|
|||||||
@ -412,11 +412,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
|||||||
final fullWidth = left.multiline || left.key == 'is_asset_item';
|
final fullWidth = left.multiline || left.key == 'is_asset_item';
|
||||||
if (fullWidth) {
|
if (fullWidth) {
|
||||||
widgets.add(
|
widgets.add(
|
||||||
Padding(
|
QuickAddBlockable(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
child: Padding(
|
||||||
child: SizedBox(
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
width: double.infinity,
|
child: SizedBox(
|
||||||
child: _buildField(context, field: left, formState: formState),
|
width: double.infinity,
|
||||||
|
child: _buildField(context, field: left, formState: formState),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -438,11 +440,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
|||||||
i += 2;
|
i += 2;
|
||||||
} else {
|
} else {
|
||||||
widgets.add(
|
widgets.add(
|
||||||
Padding(
|
QuickAddBlockable(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
child: Padding(
|
||||||
child: SizedBox(
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
width: double.infinity,
|
child: SizedBox(
|
||||||
child: _buildField(context, field: left, formState: formState),
|
width: double.infinity,
|
||||||
|
child: _buildField(context, field: left, formState: formState),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -452,11 +456,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
|||||||
|
|
||||||
if (activeField != null) {
|
if (activeField != null) {
|
||||||
widgets.add(
|
widgets.add(
|
||||||
SidePanelSection(
|
QuickAddBlockable(
|
||||||
title: 'STATUS',
|
child: SidePanelSection(
|
||||||
children: [
|
title: 'STATUS',
|
||||||
_buildField(context, field: activeField, formState: formState),
|
children: [
|
||||||
],
|
_buildField(context, field: activeField, formState: formState),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -148,8 +148,14 @@ class _MasterQuickAddDropdownState<T>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _collapse() {
|
void _collapse() {
|
||||||
if (!_expanded) return;
|
final host = _host ?? QuickAddInlineScope.maybeOf(context);
|
||||||
_host?.dismiss(this);
|
// Prefer owner dismiss; fall back to dismissAll so Cancel/Close always works
|
||||||
|
// even if the host state was remounted while the form was open.
|
||||||
|
if (host != null) {
|
||||||
|
host.dismiss(this);
|
||||||
|
if (host.isOpen) host.dismissAll();
|
||||||
|
}
|
||||||
|
if (!_expanded && _sessionId == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_expanded = false;
|
_expanded = false;
|
||||||
_sessionId = null;
|
_sessionId = null;
|
||||||
@ -161,24 +167,32 @@ class _MasterQuickAddDropdownState<T>
|
|||||||
|
|
||||||
Future<void> _onSaved(String createdId) async {
|
Future<void> _onSaved(String createdId) async {
|
||||||
await _applyCreated(createdId);
|
await _applyCreated(createdId);
|
||||||
_collapse();
|
if (mounted) _collapse();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _applyCreated(String createdId) async {
|
Future<void> _applyCreated(String createdId) async {
|
||||||
|
final parsed = (createdId.isNotEmpty && createdId != 'created')
|
||||||
|
? widget.parseCreatedId(createdId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Select first so parent form keeps the new id even if this State is
|
||||||
|
// disposed during the options reload rebuild.
|
||||||
|
if (parsed != null) {
|
||||||
|
widget.onChanged(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final refresh = widget.refreshLookups;
|
final refresh = widget.refreshLookups;
|
||||||
if (refresh != null) await refresh();
|
if (refresh != null) await refresh();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Still select the created row even if lookup refresh fails.
|
// Still keep the created selection even if lookup refresh fails.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Prefer a real id from create; fall back only when API omits it.
|
// Re-apply after reload in case options refresh cleared dependent values.
|
||||||
if (createdId.isNotEmpty && createdId != 'created') {
|
if (parsed != null) {
|
||||||
final parsed = widget.parseCreatedId(createdId);
|
widget.onChanged(parsed);
|
||||||
if (parsed != null) {
|
|
||||||
widget.onChanged(parsed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@ -207,7 +221,7 @@ class _MasterQuickAddDropdownState<T>
|
|||||||
final host = _host ?? QuickAddInlineScope.maybeOf(context);
|
final host = _host ?? QuickAddInlineScope.maybeOf(context);
|
||||||
if (host == null) return;
|
if (host == null) return;
|
||||||
_host = host;
|
_host = host;
|
||||||
host.present(owner: this, form: _buildCreateForm());
|
host.present(owner: this, formBuilder: _buildCreateForm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -246,7 +260,14 @@ class _MasterQuickAddDropdownState<T>
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
dropdown,
|
IgnorePointer(
|
||||||
|
ignoring: true,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
opacity: 0.45,
|
||||||
|
child: dropdown,
|
||||||
|
),
|
||||||
|
),
|
||||||
AnimatedSize(
|
AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 280),
|
duration: const Duration(milliseconds: 280),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
|
|||||||
@ -84,12 +84,11 @@ class PurchaseOrdersListNotifier
|
|||||||
items: page.items,
|
items: page.items,
|
||||||
load: repository.getPurchaseOrders,
|
load: repository.getPurchaseOrders,
|
||||||
);
|
);
|
||||||
final search = TableSearch.normalize(query.search);
|
|
||||||
return PurchaseOrdersListState(
|
return PurchaseOrdersListState(
|
||||||
orders: orders,
|
orders: orders,
|
||||||
query: query,
|
query: query,
|
||||||
total: search.isEmpty ? page.total : orders.length,
|
total: page.total,
|
||||||
totalPages: search.isEmpty ? page.totalPages : 1,
|
totalPages: page.totalPages,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,12 +201,11 @@ class PendingApprovalPurchaseOrdersListNotifier
|
|||||||
items: page.items,
|
items: page.items,
|
||||||
load: repository.getPendingApprovalPurchaseOrders,
|
load: repository.getPendingApprovalPurchaseOrders,
|
||||||
);
|
);
|
||||||
final search = TableSearch.normalize(query.search);
|
|
||||||
return PurchaseOrdersListState(
|
return PurchaseOrdersListState(
|
||||||
orders: orders,
|
orders: orders,
|
||||||
query: query,
|
query: query,
|
||||||
total: search.isEmpty ? page.total : orders.length,
|
total: page.total,
|
||||||
totalPages: search.isEmpty ? page.totalPages : 1,
|
totalPages: page.totalPages,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -479,15 +477,5 @@ Future<List<PurchaseOrderModel>> _enrichPurchaseOrderSearch({
|
|||||||
// Lookups/vendor enrichment is best-effort.
|
// Lookups/vendor enrichment is best-effort.
|
||||||
}
|
}
|
||||||
|
|
||||||
return TableSearch.filter(
|
return merged;
|
||||||
merged,
|
|
||||||
search,
|
|
||||||
(order) => [
|
|
||||||
order.poNo,
|
|
||||||
order.vendorName,
|
|
||||||
order.status,
|
|
||||||
order.billingName,
|
|
||||||
order.shippingName,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -396,7 +396,8 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
_SectionCard(
|
_SectionCard(
|
||||||
title: 'ORDER DETAILS',
|
title: 'ORDER DETAILS',
|
||||||
child: QuickAddInlineHost(
|
child: QuickAddInlineHost(
|
||||||
child: ResponsiveFormGrid(
|
child: QuickAddBlockable(
|
||||||
|
child: ResponsiveFormGrid(
|
||||||
smallColumns: 1,
|
smallColumns: 1,
|
||||||
mediumColumns: 2,
|
mediumColumns: 2,
|
||||||
largeColumns: 4,
|
largeColumns: 4,
|
||||||
@ -535,6 +536,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@ -214,41 +214,61 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
|||||||
)
|
)
|
||||||
.refresh()
|
.refresh()
|
||||||
: ref.read(purchaseOrdersListProvider.notifier).refresh(),
|
: ref.read(purchaseOrdersListProvider.notifier).refresh(),
|
||||||
child: state.orders.isEmpty
|
child: context.isMobile
|
||||||
? ListView(
|
? (state.orders.isEmpty
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
? ListView(
|
||||||
children: [
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
SizedBox(
|
children: [
|
||||||
height: 240,
|
SizedBox(
|
||||||
child: AppEmptyState(
|
height: 240,
|
||||||
title: pendingOnly
|
child: AppEmptyState(
|
||||||
? 'No pending approvals'
|
title: pendingOnly
|
||||||
: 'No purchase orders found',
|
? 'No pending approvals'
|
||||||
description: pendingOnly
|
: 'No purchase orders found',
|
||||||
? 'There are no purchase orders waiting for approval.'
|
description: pendingOnly
|
||||||
: 'Try adjusting filters or create a new purchase order.',
|
? 'There are no purchase orders waiting for approval.'
|
||||||
icon: pendingOnly
|
: 'Try adjusting filters or create a new purchase order.',
|
||||||
? Icons.pending_actions_outlined
|
icon: pendingOnly
|
||||||
: Icons.receipt_long_outlined,
|
? Icons.pending_actions_outlined
|
||||||
),
|
: Icons.receipt_long_outlined,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
)
|
],
|
||||||
: context.isMobile
|
|
||||||
? _PoCardList(
|
|
||||||
orders: state.orders,
|
|
||||||
onView: _viewOrder,
|
|
||||||
onEdit: canEdit ? _editOrder : null,
|
|
||||||
onDelete: canDelete ? _deleteOrder : null,
|
|
||||||
onApprove: canApprove ? _approveOrder : null,
|
|
||||||
)
|
)
|
||||||
: _PoDataTable(
|
: _PoCardList(
|
||||||
orders: state.orders,
|
orders: state.orders,
|
||||||
onView: _viewOrder,
|
onView: _viewOrder,
|
||||||
onEdit: canEdit ? _editOrder : null,
|
onEdit: canEdit ? _editOrder : null,
|
||||||
onDelete: canDelete ? _deleteOrder : null,
|
onDelete: canDelete ? _deleteOrder : null,
|
||||||
onApprove: canApprove ? _approveOrder : null,
|
onApprove: canApprove ? _approveOrder : null,
|
||||||
),
|
))
|
||||||
|
: _PoDataTable(
|
||||||
|
orders: state.orders,
|
||||||
|
onView: _viewOrder,
|
||||||
|
onEdit: canEdit ? _editOrder : null,
|
||||||
|
onDelete: canDelete ? _deleteOrder : null,
|
||||||
|
onApprove: canApprove ? _approveOrder : null,
|
||||||
|
onServerSearch: (value) {
|
||||||
|
_searchController.value = TextEditingValue(
|
||||||
|
text: value,
|
||||||
|
selection: TextSelection.collapsed(
|
||||||
|
offset: value.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (pendingOnly) {
|
||||||
|
ref
|
||||||
|
.read(
|
||||||
|
pendingApprovalPurchaseOrdersListProvider
|
||||||
|
.notifier,
|
||||||
|
)
|
||||||
|
.setSearch(value);
|
||||||
|
} else {
|
||||||
|
ref
|
||||||
|
.read(purchaseOrdersListProvider.notifier)
|
||||||
|
.setSearch(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -427,6 +447,7 @@ class _PoDataTable extends StatelessWidget {
|
|||||||
this.onEdit,
|
this.onEdit,
|
||||||
this.onDelete,
|
this.onDelete,
|
||||||
this.onApprove,
|
this.onApprove,
|
||||||
|
this.onServerSearch,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<PurchaseOrderModel> orders;
|
final List<PurchaseOrderModel> orders;
|
||||||
@ -434,12 +455,14 @@ class _PoDataTable extends StatelessWidget {
|
|||||||
final ValueChanged<PurchaseOrderModel>? onEdit;
|
final ValueChanged<PurchaseOrderModel>? onEdit;
|
||||||
final ValueChanged<PurchaseOrderModel>? onDelete;
|
final ValueChanged<PurchaseOrderModel>? onDelete;
|
||||||
final ValueChanged<PurchaseOrderModel>? onApprove;
|
final ValueChanged<PurchaseOrderModel>? onApprove;
|
||||||
|
final ValueChanged<String>? onServerSearch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return AppDataTable<PurchaseOrderModel>(
|
return AppDataTable<PurchaseOrderModel>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
|
onServerSearchChanged: onServerSearch,
|
||||||
columns: [
|
columns: [
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'PO Number',
|
label: 'PO Number',
|
||||||
|
|||||||
@ -85,7 +85,7 @@ class PoOrderTotals {
|
|||||||
|
|
||||||
/// Sub Total = sum of line amounts
|
/// Sub Total = sum of line amounts
|
||||||
/// Taxable = Sub Total + Freight + Other − Discount
|
/// Taxable = Sub Total + Freight + Other − Discount
|
||||||
/// Tax = sum of line GST amounts
|
/// Tax = Taxable × effective GST rate (from line GST ÷ Sub Total)
|
||||||
/// Grand Total = Taxable + Tax
|
/// Grand Total = Taxable + Tax
|
||||||
factory PoOrderTotals.compute({
|
factory PoOrderTotals.compute({
|
||||||
required Iterable<PoLineCalculation> lines,
|
required Iterable<PoLineCalculation> lines,
|
||||||
@ -94,17 +94,20 @@ class PoOrderTotals {
|
|||||||
required double discountAmount,
|
required double discountAmount,
|
||||||
}) {
|
}) {
|
||||||
var subTotal = 0.0;
|
var subTotal = 0.0;
|
||||||
var tax = 0.0;
|
var lineTax = 0.0;
|
||||||
for (final line in lines) {
|
for (final line in lines) {
|
||||||
subTotal += line.lineAmount;
|
subTotal += line.lineAmount;
|
||||||
tax += line.gstAmount;
|
lineTax += line.gstAmount;
|
||||||
}
|
}
|
||||||
final freightSafe = freight < 0 ? 0.0 : freight;
|
final freightSafe = freight < 0 ? 0.0 : freight;
|
||||||
final otherSafe = otherCharges < 0 ? 0.0 : otherCharges;
|
final otherSafe = otherCharges < 0 ? 0.0 : otherCharges;
|
||||||
final clampedDiscount = discountAmount < 0 ? 0.0 : discountAmount;
|
final clampedDiscount = discountAmount < 0 ? 0.0 : discountAmount;
|
||||||
final taxableRaw = subTotal + freightSafe + otherSafe - clampedDiscount;
|
final taxableRaw = subTotal + freightSafe + otherSafe - clampedDiscount;
|
||||||
final taxable = taxableRaw < 0 ? 0.0 : taxableRaw;
|
final taxable = taxableRaw < 0 ? 0.0 : taxableRaw;
|
||||||
final maxDiscount = subTotal + tax + freightSafe + otherSafe;
|
// Apply the blended line GST rate to Taxable Amount (not Sub Total),
|
||||||
|
// so freight / other / discount are included in the tax base.
|
||||||
|
final tax = subTotal > 0 ? lineTax * (taxable / subTotal) : 0.0;
|
||||||
|
final maxDiscount = subTotal + lineTax + freightSafe + otherSafe;
|
||||||
final grandTotal = taxable + tax;
|
final grandTotal = taxable + tax;
|
||||||
return PoOrderTotals(
|
return PoOrderTotals(
|
||||||
subTotal: subTotal,
|
subTotal: subTotal,
|
||||||
@ -629,7 +632,8 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
|||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: QuickAddInlineHost(
|
child: QuickAddInlineHost(
|
||||||
child: LayoutBuilder(
|
child: QuickAddBlockable(
|
||||||
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final width = constraints.maxWidth;
|
final width = constraints.maxWidth;
|
||||||
|
|
||||||
@ -675,6 +679,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -833,33 +833,28 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (usersState.users.isEmpty)
|
Expanded(
|
||||||
Expanded(
|
child: UserRichDataTable(
|
||||||
child: Center(
|
wrapInCard: false,
|
||||||
child: Text(
|
users: usersState.users,
|
||||||
'No users found',
|
onServerSearchChanged: (value) {
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
_searchController.value = TextEditingValue(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
text: value,
|
||||||
),
|
selection: TextSelection.collapsed(offset: value.length),
|
||||||
),
|
);
|
||||||
),
|
ref.read(usersListProvider.notifier).setSearch(value);
|
||||||
)
|
},
|
||||||
else
|
actionsBuilder: (_, user) => UserTableActions(
|
||||||
Expanded(
|
user: user,
|
||||||
child: UserRichDataTable(
|
canEdit: canEditUser,
|
||||||
wrapInCard: false,
|
canResetPassword: canEditUser,
|
||||||
users: usersState.users,
|
canDeactivate: canDeleteUser,
|
||||||
actionsBuilder: (_, user) => UserTableActions(
|
onEdit: () => _editUser(user),
|
||||||
user: user,
|
onResetPassword: () => _resetPassword(user),
|
||||||
canEdit: canEditUser,
|
onDeactivate: () => _deactivateUser(user),
|
||||||
canResetPassword: canEditUser,
|
|
||||||
canDeactivate: canDeleteUser,
|
|
||||||
onEdit: () => _editUser(user),
|
|
||||||
onResetPassword: () => _resetPassword(user),
|
|
||||||
onDeactivate: () => _deactivateUser(user),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
AppPagination(
|
AppPagination(
|
||||||
currentPage: page,
|
currentPage: page,
|
||||||
|
|||||||
@ -215,24 +215,35 @@ class _DepreciationReportScreenState
|
|||||||
),
|
),
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: notifier.refresh,
|
onRefresh: notifier.refresh,
|
||||||
child: state.items.isEmpty
|
child: context.isMobile
|
||||||
? ListView(
|
? (state.items.isEmpty
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
? ListView(
|
||||||
children: const [
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
SizedBox(
|
children: const [
|
||||||
height: 260,
|
SizedBox(
|
||||||
child: AppEmptyState(
|
height: 260,
|
||||||
title: 'No depreciation data',
|
child: AppEmptyState(
|
||||||
description:
|
title: 'No depreciation data',
|
||||||
'Try adjusting filters or the as-of date.',
|
description:
|
||||||
icon: Icons.trending_down_outlined,
|
'Try adjusting filters or the as-of date.',
|
||||||
|
icon: Icons.trending_down_outlined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: _MobileList(items: state.items))
|
||||||
|
: _ReportTable(
|
||||||
|
items: state.items,
|
||||||
|
onServerSearch: (value) {
|
||||||
|
_searchController.value = TextEditingValue(
|
||||||
|
text: value,
|
||||||
|
selection: TextSelection.collapsed(
|
||||||
|
offset: value.length,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
],
|
notifier.setSearch(value);
|
||||||
)
|
},
|
||||||
: context.isMobile
|
),
|
||||||
? _MobileList(items: state.items)
|
|
||||||
: _ReportTable(items: state.items),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -581,15 +592,20 @@ class _FiltersBarState extends State<_FiltersBar> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ReportTable extends StatelessWidget {
|
class _ReportTable extends StatelessWidget {
|
||||||
const _ReportTable({required this.items});
|
const _ReportTable({
|
||||||
|
required this.items,
|
||||||
|
this.onServerSearch,
|
||||||
|
});
|
||||||
|
|
||||||
final List<DepreciationReportRow> items;
|
final List<DepreciationReportRow> items;
|
||||||
|
final ValueChanged<String>? onServerSearch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppDataTable<DepreciationReportRow>(
|
return AppDataTable<DepreciationReportRow>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
rows: items,
|
rows: items,
|
||||||
|
onServerSearchChanged: onServerSearch,
|
||||||
columns: [
|
columns: [
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'Asset Code',
|
label: 'Asset Code',
|
||||||
|
|||||||
@ -21,15 +21,8 @@ class RolesListState {
|
|||||||
final int limit;
|
final int limit;
|
||||||
|
|
||||||
List<RoleCardModel> get filteredRoles {
|
List<RoleCardModel> get filteredRoles {
|
||||||
if (search.isEmpty) return roles;
|
// Roles are already filtered by the API when [search] is set.
|
||||||
final q = search.toLowerCase();
|
return roles;
|
||||||
return roles
|
|
||||||
.where(
|
|
||||||
(role) =>
|
|
||||||
role.name.toLowerCase().contains(q) ||
|
|
||||||
(role.description?.toLowerCase().contains(q) ?? false),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int get total => filteredRoles.length;
|
int get total => filteredRoles.length;
|
||||||
@ -124,10 +117,17 @@ class RolesListNotifier extends AsyncNotifier<RolesListState> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setSearch(String search) {
|
Future<void> setSearch(String search) async {
|
||||||
final current = state.valueOrNull;
|
final current = state.valueOrNull;
|
||||||
if (current == null) return;
|
if (current == null) return;
|
||||||
state = AsyncData(current.copyWith(search: TableSearch.normalize(search), page: 1));
|
final normalized = TableSearch.normalize(search);
|
||||||
|
state = const AsyncLoading();
|
||||||
|
try {
|
||||||
|
final loaded = await _load(search: normalized);
|
||||||
|
state = AsyncData(loaded.copyWith(page: 1, limit: current.limit));
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncError(e, st);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void setPage(int page) {
|
void setPage(int page) {
|
||||||
|
|||||||
@ -88,24 +88,36 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
|
|||||||
),
|
),
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: notifier.refresh,
|
onRefresh: notifier.refresh,
|
||||||
child: roles.isEmpty
|
child: context.isMobile
|
||||||
? ListView(
|
? (roles.isEmpty
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
? ListView(
|
||||||
children: const [
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
SizedBox(
|
children: const [
|
||||||
height: 240,
|
SizedBox(
|
||||||
child: AppEmptyState(
|
height: 240,
|
||||||
title: 'No roles found',
|
child: AppEmptyState(
|
||||||
description:
|
title: 'No roles found',
|
||||||
'Roles from the API will appear here.',
|
description:
|
||||||
icon: Icons.security_outlined,
|
'Roles from the API will appear here.',
|
||||||
|
icon: Icons.security_outlined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: _RoleCardList(roles: roles, onOpen: _openRole))
|
||||||
|
: _RoleDataTable(
|
||||||
|
roles: roles,
|
||||||
|
onOpen: _openRole,
|
||||||
|
onServerSearch: (value) {
|
||||||
|
_searchController.value = TextEditingValue(
|
||||||
|
text: value,
|
||||||
|
selection: TextSelection.collapsed(
|
||||||
|
offset: value.length,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
],
|
notifier.setSearch(value);
|
||||||
)
|
},
|
||||||
: context.isMobile
|
),
|
||||||
? _RoleCardList(roles: roles, onOpen: _openRole)
|
|
||||||
: _RoleDataTable(roles: roles, onOpen: _openRole),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -122,15 +134,21 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RoleDataTable extends StatelessWidget {
|
class _RoleDataTable extends StatelessWidget {
|
||||||
const _RoleDataTable({required this.roles, required this.onOpen});
|
const _RoleDataTable({
|
||||||
|
required this.roles,
|
||||||
|
required this.onOpen,
|
||||||
|
this.onServerSearch,
|
||||||
|
});
|
||||||
|
|
||||||
final List<RoleCardModel> roles;
|
final List<RoleCardModel> roles;
|
||||||
final void Function(RoleCardModel role) onOpen;
|
final void Function(RoleCardModel role) onOpen;
|
||||||
|
final ValueChanged<String>? onServerSearch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppDataTable<RoleCardModel>(
|
return AppDataTable<RoleCardModel>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
|
onServerSearchChanged: onServerSearch,
|
||||||
columns: [
|
columns: [
|
||||||
AppDataColumn(label: 'Role Name', flex: 2, searchText: (r) => r.name, cellBuilder: (_, r) => Text(r.name)),
|
AppDataColumn(label: 'Role Name', flex: 2, searchText: (r) => r.name, cellBuilder: (_, r) => Text(r.name)),
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
|
|||||||
@ -109,41 +109,52 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
|
|||||||
),
|
),
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: () => ref.read(usersListProvider.notifier).refresh(),
|
onRefresh: () => ref.read(usersListProvider.notifier).refresh(),
|
||||||
child: state.users.isEmpty
|
child: context.isMobile
|
||||||
? ListView(
|
? (state.users.isEmpty
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
? ListView(
|
||||||
children: const [
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
SizedBox(
|
children: const [
|
||||||
height: 240,
|
SizedBox(
|
||||||
child: AppEmptyState(
|
height: 240,
|
||||||
title: 'No users found',
|
child: AppEmptyState(
|
||||||
description:
|
title: 'No users found',
|
||||||
'Try adjusting filters or add a new user.',
|
description:
|
||||||
icon: Icons.people_outline,
|
'Try adjusting filters or add a new user.',
|
||||||
),
|
icon: Icons.people_outline,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
)
|
],
|
||||||
: context.isMobile
|
|
||||||
? _UserCardList(
|
|
||||||
users: state.users,
|
|
||||||
onView: _viewUser,
|
|
||||||
onEdit: _editUser,
|
|
||||||
onToggleStatus: _toggleStatus,
|
|
||||||
onDeactivate: _deactivateUser,
|
|
||||||
)
|
)
|
||||||
: _UserDataTable(
|
: _UserCardList(
|
||||||
users: state.users,
|
users: state.users,
|
||||||
sortBy: state.query.sortBy,
|
|
||||||
sortOrder: state.query.sortOrder,
|
|
||||||
onSort: (column, ascending) => ref
|
|
||||||
.read(usersListProvider.notifier)
|
|
||||||
.setSort(column, ascending ? 'asc' : 'desc'),
|
|
||||||
onView: _viewUser,
|
onView: _viewUser,
|
||||||
onEdit: _editUser,
|
onEdit: _editUser,
|
||||||
onToggleStatus: _toggleStatus,
|
onToggleStatus: _toggleStatus,
|
||||||
onDeactivate: _deactivateUser,
|
onDeactivate: _deactivateUser,
|
||||||
),
|
))
|
||||||
|
: _UserDataTable(
|
||||||
|
users: state.users,
|
||||||
|
sortBy: state.query.sortBy,
|
||||||
|
sortOrder: state.query.sortOrder,
|
||||||
|
onSort: (column, ascending) => ref
|
||||||
|
.read(usersListProvider.notifier)
|
||||||
|
.setSort(column, ascending ? 'asc' : 'desc'),
|
||||||
|
onView: _viewUser,
|
||||||
|
onEdit: _editUser,
|
||||||
|
onToggleStatus: _toggleStatus,
|
||||||
|
onDeactivate: _deactivateUser,
|
||||||
|
onServerSearch: (value) {
|
||||||
|
_searchController.value = TextEditingValue(
|
||||||
|
text: value,
|
||||||
|
selection: TextSelection.collapsed(
|
||||||
|
offset: value.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ref
|
||||||
|
.read(usersListProvider.notifier)
|
||||||
|
.setSearch(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -301,6 +312,7 @@ class _UserDataTable extends StatelessWidget {
|
|||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onToggleStatus,
|
required this.onToggleStatus,
|
||||||
required this.onDeactivate,
|
required this.onDeactivate,
|
||||||
|
this.onServerSearch,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<ManagedUserModel> users;
|
final List<ManagedUserModel> users;
|
||||||
@ -311,6 +323,7 @@ class _UserDataTable extends StatelessWidget {
|
|||||||
final void Function(ManagedUserModel user) onEdit;
|
final void Function(ManagedUserModel user) onEdit;
|
||||||
final Future<void> Function(ManagedUserModel user) onToggleStatus;
|
final Future<void> Function(ManagedUserModel user) onToggleStatus;
|
||||||
final Future<void> Function(ManagedUserModel user) onDeactivate;
|
final Future<void> Function(ManagedUserModel user) onDeactivate;
|
||||||
|
final ValueChanged<String>? onServerSearch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -320,6 +333,7 @@ class _UserDataTable extends StatelessWidget {
|
|||||||
sortAscending: sortOrder == 'asc',
|
sortAscending: sortOrder == 'asc',
|
||||||
onSort: onSort,
|
onSort: onSort,
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
|
onServerSearchChanged: onServerSearch,
|
||||||
actionsBuilder: (_, user) => _UserActions(
|
actionsBuilder: (_, user) => _UserActions(
|
||||||
user: user,
|
user: user,
|
||||||
onView: onView,
|
onView: onView,
|
||||||
|
|||||||
@ -20,6 +20,7 @@ class UserRichDataTable extends StatelessWidget {
|
|||||||
this.sortAscending = true,
|
this.sortAscending = true,
|
||||||
this.onSort,
|
this.onSort,
|
||||||
this.wrapInCard = false,
|
this.wrapInCard = false,
|
||||||
|
this.onServerSearchChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<ManagedUserModel> users;
|
final List<ManagedUserModel> users;
|
||||||
@ -28,6 +29,7 @@ class UserRichDataTable extends StatelessWidget {
|
|||||||
final bool sortAscending;
|
final bool sortAscending;
|
||||||
final void Function(String column, bool ascending)? onSort;
|
final void Function(String column, bool ascending)? onSort;
|
||||||
final bool wrapInCard;
|
final bool wrapInCard;
|
||||||
|
final ValueChanged<String>? onServerSearchChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -38,6 +40,7 @@ class UserRichDataTable extends StatelessWidget {
|
|||||||
sortColumn: sortColumn,
|
sortColumn: sortColumn,
|
||||||
sortAscending: sortAscending,
|
sortAscending: sortAscending,
|
||||||
onSort: onSort,
|
onSort: onSort,
|
||||||
|
onServerSearchChanged: onServerSearchChanged,
|
||||||
columns: [
|
columns: [
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'User',
|
label: 'User',
|
||||||
|
|||||||
@ -137,34 +137,45 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
|
|||||||
),
|
),
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: () => ref.read(vendorsListProvider.notifier).refresh(),
|
onRefresh: () => ref.read(vendorsListProvider.notifier).refresh(),
|
||||||
child: state.vendors.isEmpty
|
child: context.isMobile
|
||||||
? ListView(
|
? (state.vendors.isEmpty
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
? ListView(
|
||||||
children: const [
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
SizedBox(
|
children: const [
|
||||||
height: 240,
|
SizedBox(
|
||||||
child: AppEmptyState(
|
height: 240,
|
||||||
title: 'No vendors found',
|
child: AppEmptyState(
|
||||||
description:
|
title: 'No vendors found',
|
||||||
'Try adjusting filters or add a new vendor.',
|
description:
|
||||||
icon: Icons.store_outlined,
|
'Try adjusting filters or add a new vendor.',
|
||||||
),
|
icon: Icons.store_outlined,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
)
|
],
|
||||||
: context.isMobile
|
|
||||||
? _VendorCardList(
|
|
||||||
vendors: state.vendors,
|
|
||||||
onView: _viewVendor,
|
|
||||||
onEdit: canEdit ? _editVendor : null,
|
|
||||||
onDelete: canDelete ? _deleteVendor : null,
|
|
||||||
)
|
)
|
||||||
: _VendorDataTable(
|
: _VendorCardList(
|
||||||
vendors: state.vendors,
|
vendors: state.vendors,
|
||||||
onView: _viewVendor,
|
onView: _viewVendor,
|
||||||
onEdit: canEdit ? _editVendor : null,
|
onEdit: canEdit ? _editVendor : null,
|
||||||
onDelete: canDelete ? _deleteVendor : null,
|
onDelete: canDelete ? _deleteVendor : null,
|
||||||
),
|
))
|
||||||
|
: _VendorDataTable(
|
||||||
|
vendors: state.vendors,
|
||||||
|
onView: _viewVendor,
|
||||||
|
onEdit: canEdit ? _editVendor : null,
|
||||||
|
onDelete: canDelete ? _deleteVendor : null,
|
||||||
|
onServerSearch: (value) {
|
||||||
|
_searchController.value = TextEditingValue(
|
||||||
|
text: value,
|
||||||
|
selection: TextSelection.collapsed(
|
||||||
|
offset: value.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ref
|
||||||
|
.read(vendorsListProvider.notifier)
|
||||||
|
.setSearch(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -288,17 +299,20 @@ class _VendorDataTable extends StatelessWidget {
|
|||||||
required this.onView,
|
required this.onView,
|
||||||
this.onEdit,
|
this.onEdit,
|
||||||
this.onDelete,
|
this.onDelete,
|
||||||
|
this.onServerSearch,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<VendorModel> vendors;
|
final List<VendorModel> vendors;
|
||||||
final ValueChanged<VendorModel> onView;
|
final ValueChanged<VendorModel> onView;
|
||||||
final ValueChanged<VendorModel>? onEdit;
|
final ValueChanged<VendorModel>? onEdit;
|
||||||
final ValueChanged<VendorModel>? onDelete;
|
final ValueChanged<VendorModel>? onDelete;
|
||||||
|
final ValueChanged<String>? onServerSearch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppDataTable<VendorModel>(
|
return AppDataTable<VendorModel>(
|
||||||
wrapInCard: false,
|
wrapInCard: false,
|
||||||
|
onServerSearchChanged: onServerSearch,
|
||||||
columns: [
|
columns: [
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'Code',
|
label: 'Code',
|
||||||
|
|||||||
@ -46,6 +46,27 @@ const List<MenuItem> appMenuItems = [
|
|||||||
// route: RouteConstants.branches,
|
// route: RouteConstants.branches,
|
||||||
// module: 'branches',
|
// module: 'branches',
|
||||||
// ),
|
// ),
|
||||||
|
MenuItem(
|
||||||
|
label: 'Purchase Orders',
|
||||||
|
icon: Icons.receipt_long_outlined,
|
||||||
|
route: RouteConstants.purchaseOrders,
|
||||||
|
module: 'purchase_orders',
|
||||||
|
children: [
|
||||||
|
MenuItem(
|
||||||
|
label: 'All Orders',
|
||||||
|
icon: Icons.receipt_long_outlined,
|
||||||
|
route: RouteConstants.purchaseOrders,
|
||||||
|
module: 'purchase_orders',
|
||||||
|
),
|
||||||
|
MenuItem(
|
||||||
|
label: 'Pending Approval',
|
||||||
|
icon: Icons.pending_actions_outlined,
|
||||||
|
route: RouteConstants.purchaseOrdersPendingApproval,
|
||||||
|
module: 'purchase_orders',
|
||||||
|
requiredAction: PermissionAction.approve,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'Assets',
|
label: 'Assets',
|
||||||
icon: Icons.inventory_2_outlined,
|
icon: Icons.inventory_2_outlined,
|
||||||
@ -73,34 +94,7 @@ const List<MenuItem> appMenuItems = [
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'Vendors',
|
label: 'Purchase Receipt',
|
||||||
icon: Icons.store_outlined,
|
|
||||||
route: RouteConstants.vendors,
|
|
||||||
module: 'vendors',
|
|
||||||
),
|
|
||||||
MenuItem(
|
|
||||||
label: 'Purchase Orders',
|
|
||||||
icon: Icons.receipt_long_outlined,
|
|
||||||
route: RouteConstants.purchaseOrders,
|
|
||||||
module: 'purchase_orders',
|
|
||||||
children: [
|
|
||||||
MenuItem(
|
|
||||||
label: 'All Orders',
|
|
||||||
icon: Icons.receipt_long_outlined,
|
|
||||||
route: RouteConstants.purchaseOrders,
|
|
||||||
module: 'purchase_orders',
|
|
||||||
),
|
|
||||||
MenuItem(
|
|
||||||
label: 'Pending Approval',
|
|
||||||
icon: Icons.pending_actions_outlined,
|
|
||||||
route: RouteConstants.purchaseOrdersPendingApproval,
|
|
||||||
module: 'purchase_orders',
|
|
||||||
requiredAction: PermissionAction.approve,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
MenuItem(
|
|
||||||
label: 'GRN',
|
|
||||||
icon: Icons.inventory_2_outlined,
|
icon: Icons.inventory_2_outlined,
|
||||||
route: RouteConstants.grn,
|
route: RouteConstants.grn,
|
||||||
module: 'grn',
|
module: 'grn',
|
||||||
@ -112,33 +106,52 @@ const List<MenuItem> appMenuItems = [
|
|||||||
module: 'reports',
|
module: 'reports',
|
||||||
),
|
),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'Users & Roles',
|
label: 'Support',
|
||||||
icon: Icons.admin_panel_settings_outlined,
|
icon: Icons.support_agent_outlined,
|
||||||
route: RouteConstants.usersRoleManagement,
|
route: RouteConstants.vendors,
|
||||||
module: 'users',
|
module: 'support',
|
||||||
),
|
children: [
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'Master Data',
|
label: 'Vendors',
|
||||||
icon: Icons.dataset_outlined,
|
icon: Icons.store_outlined,
|
||||||
route: RouteConstants.masterData,
|
route: RouteConstants.vendors,
|
||||||
module: 'master_data',
|
module: 'vendors',
|
||||||
),
|
),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'Audit Logs',
|
label: 'Users & Roles',
|
||||||
icon: Icons.history_outlined,
|
icon: Icons.admin_panel_settings_outlined,
|
||||||
route: RouteConstants.auditLogs,
|
route: RouteConstants.usersRoleManagement,
|
||||||
module: 'audit_logs',
|
module: 'users',
|
||||||
),
|
),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'Settings',
|
label: 'Master Data',
|
||||||
icon: Icons.settings_outlined,
|
icon: Icons.dataset_outlined,
|
||||||
route: RouteConstants.settings,
|
route: RouteConstants.masterData,
|
||||||
module: 'settings',
|
module: 'master_data',
|
||||||
|
),
|
||||||
|
MenuItem(
|
||||||
|
label: 'Audit Logs',
|
||||||
|
icon: Icons.history_outlined,
|
||||||
|
route: RouteConstants.auditLogs,
|
||||||
|
module: 'audit_logs',
|
||||||
|
),
|
||||||
|
MenuItem(
|
||||||
|
label: 'Settings',
|
||||||
|
icon: Icons.settings_outlined,
|
||||||
|
route: RouteConstants.settings,
|
||||||
|
module: 'settings',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Routes rendered under the Support section (after primary ops menus).
|
/// True when [item] is the Support group (top-nav dropdown / sidebar section).
|
||||||
|
bool isSupportMenuItem(MenuItem item) =>
|
||||||
|
item.module == 'support' || item.label == 'Support';
|
||||||
|
|
||||||
|
/// Child routes that belong under Support (sidebar section flattening).
|
||||||
const Set<String> supportMenuRoutes = {
|
const Set<String> supportMenuRoutes = {
|
||||||
|
RouteConstants.vendors,
|
||||||
RouteConstants.usersRoleManagement,
|
RouteConstants.usersRoleManagement,
|
||||||
RouteConstants.masterData,
|
RouteConstants.masterData,
|
||||||
RouteConstants.auditLogs,
|
RouteConstants.auditLogs,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/table_search.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.
|
||||||
@ -78,6 +79,7 @@ class AppDataTable<T> extends StatefulWidget {
|
|||||||
this.noMatchMessage = 'No matching records',
|
this.noMatchMessage = 'No matching records',
|
||||||
this.wrapInCard = true,
|
this.wrapInCard = true,
|
||||||
this.shrinkWrap = false,
|
this.shrinkWrap = false,
|
||||||
|
this.onServerSearchChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<AppDataColumn<T>> columns;
|
final List<AppDataColumn<T>> columns;
|
||||||
@ -92,6 +94,13 @@ class AppDataTable<T> extends StatefulWidget {
|
|||||||
/// Set true when the table is placed inside another scrollable.
|
/// Set true when the table is placed inside another scrollable.
|
||||||
final bool shrinkWrap;
|
final bool shrinkWrap;
|
||||||
|
|
||||||
|
/// When set, column filters are sent to the parent for API search instead of
|
||||||
|
/// filtering only the currently loaded [rows] (current page).
|
||||||
|
///
|
||||||
|
/// The callback receives a normalized query (trimmed). Empty string means
|
||||||
|
/// clear search and reload the full paginated list.
|
||||||
|
final ValueChanged<String>? onServerSearchChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AppDataTable<T>> createState() => _AppDataTableState<T>();
|
State<AppDataTable<T>> createState() => _AppDataTableState<T>();
|
||||||
}
|
}
|
||||||
@ -100,9 +109,15 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
|||||||
/// Column index → search query (raw, including spaces until applied).
|
/// Column index → search query (raw, including spaces until applied).
|
||||||
final Map<int, String> _queries = {};
|
final Map<int, String> _queries = {};
|
||||||
final Map<int, TextEditingController> _controllers = {};
|
final Map<int, TextEditingController> _controllers = {};
|
||||||
|
final SearchDebouncer _serverSearchDebouncer = SearchDebouncer();
|
||||||
|
int? _lastEditedColumnIndex;
|
||||||
|
String _lastEmittedServerSearch = '';
|
||||||
|
|
||||||
|
bool get _serverSideSearch => widget.onServerSearchChanged != null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_serverSearchDebouncer.dispose();
|
||||||
for (final c in _controllers.values) {
|
for (final c in _controllers.values) {
|
||||||
c.dispose();
|
c.dispose();
|
||||||
}
|
}
|
||||||
@ -113,7 +128,38 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
|||||||
return _controllers.putIfAbsent(index, TextEditingController.new);
|
return _controllers.putIfAbsent(index, TextEditingController.new);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _composeServerSearch() {
|
||||||
|
if (_lastEditedColumnIndex != null) {
|
||||||
|
final latest = (_queries[_lastEditedColumnIndex!] ?? '').trim();
|
||||||
|
if (latest.isNotEmpty) return latest;
|
||||||
|
}
|
||||||
|
for (final entry in _queries.entries) {
|
||||||
|
final q = entry.value.trim();
|
||||||
|
if (q.isNotEmpty) return q;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onColumnQueryChanged(int index, String value) {
|
||||||
|
setState(() {
|
||||||
|
_queries[index] = value;
|
||||||
|
_lastEditedColumnIndex = index;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!_serverSideSearch) return;
|
||||||
|
|
||||||
|
_serverSearchDebouncer.run(value, (_) {
|
||||||
|
final composed = _composeServerSearch();
|
||||||
|
if (composed == _lastEmittedServerSearch) return;
|
||||||
|
_lastEmittedServerSearch = composed;
|
||||||
|
widget.onServerSearchChanged!(composed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
List<T> get _filteredRows {
|
List<T> get _filteredRows {
|
||||||
|
// Server-side mode: parent already fetched matching rows from the API.
|
||||||
|
if (_serverSideSearch) return widget.rows;
|
||||||
|
|
||||||
final active = <int, String>{};
|
final active = <int, String>{};
|
||||||
for (final entry in _queries.entries) {
|
for (final entry in _queries.entries) {
|
||||||
final q = entry.value.trim().toLowerCase();
|
final q = entry.value.trim().toLowerCase();
|
||||||
@ -139,7 +185,12 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (widget.rows.isEmpty) {
|
final showFilterRow = widget.columns.any((c) => c.isSearchable);
|
||||||
|
final filtered = _filteredRows;
|
||||||
|
|
||||||
|
// Keep header + column filters visible even when the API returns no rows,
|
||||||
|
// so users can refine or clear a server-side search.
|
||||||
|
if (widget.rows.isEmpty && !showFilterRow) {
|
||||||
final empty = Center(
|
final empty = Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
@ -155,9 +206,6 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
|||||||
return AppCard(clipBehavior: Clip.antiAlias, child: empty);
|
return AppCard(clipBehavior: Clip.antiAlias, child: empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
final filtered = _filteredRows;
|
|
||||||
final showFilterRow = widget.columns.any((c) => c.isSearchable);
|
|
||||||
|
|
||||||
final header = _TableHeaderRow<T>(
|
final header = _TableHeaderRow<T>(
|
||||||
columns: widget.columns,
|
columns: widget.columns,
|
||||||
sortColumn: widget.sortColumn,
|
sortColumn: widget.sortColumn,
|
||||||
@ -170,9 +218,7 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
|||||||
columns: widget.columns,
|
columns: widget.columns,
|
||||||
controllerFor: _controllerFor,
|
controllerFor: _controllerFor,
|
||||||
queryFor: (i) => _queries[i] ?? '',
|
queryFor: (i) => _queries[i] ?? '',
|
||||||
onQueryChanged: (index, value) {
|
onQueryChanged: _onColumnQueryChanged,
|
||||||
setState(() => _queries[index] = value);
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@ -182,7 +228,7 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Text(
|
child: Text(
|
||||||
_hasActiveColumnFilters
|
_hasActiveColumnFilters || _lastEmittedServerSearch.isNotEmpty
|
||||||
? widget.noMatchMessage
|
? widget.noMatchMessage
|
||||||
: widget.emptyMessage,
|
: widget.emptyMessage,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
|||||||
@ -3,31 +3,117 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../core/utils/responsive_utils.dart';
|
import '../../core/utils/responsive_utils.dart';
|
||||||
import 'app_toast.dart';
|
import 'app_toast.dart';
|
||||||
|
|
||||||
|
/// Tracks whether any Quick Add inline form is open in the current panel/scope.
|
||||||
|
///
|
||||||
|
/// Used to dim and block the parent form (and footer) while Quick Add is active.
|
||||||
|
class QuickAddBlockerController extends ChangeNotifier {
|
||||||
|
int _depth = 0;
|
||||||
|
|
||||||
|
bool get isBlocked => _depth > 0;
|
||||||
|
|
||||||
|
void push() {
|
||||||
|
_depth += 1;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void pop() {
|
||||||
|
if (_depth <= 0) return;
|
||||||
|
_depth -= 1;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
if (_depth == 0) return;
|
||||||
|
_depth = 0;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuickAddBlockerScope extends InheritedNotifier<QuickAddBlockerController> {
|
||||||
|
const QuickAddBlockerScope({
|
||||||
|
super.key,
|
||||||
|
required QuickAddBlockerController controller,
|
||||||
|
required super.child,
|
||||||
|
}) : super(notifier: controller);
|
||||||
|
|
||||||
|
static QuickAddBlockerController? maybeOf(BuildContext context) {
|
||||||
|
return context
|
||||||
|
.dependOnInheritedWidgetOfExactType<QuickAddBlockerScope>()
|
||||||
|
?.notifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dims and blocks pointer events on [child] while any Quick Add is open.
|
||||||
|
class QuickAddBlockable extends StatelessWidget {
|
||||||
|
const QuickAddBlockable({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.blockedOpacity = 0.45,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final double blockedOpacity;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final blocker = QuickAddBlockerScope.maybeOf(context);
|
||||||
|
if (blocker == null) return child;
|
||||||
|
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: blocker,
|
||||||
|
builder: (context, _) {
|
||||||
|
final blocked = blocker.isBlocked;
|
||||||
|
return AbsorbPointer(
|
||||||
|
absorbing: blocked,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
opacity: blocked ? blockedOpacity : 1,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Holds a full-width Quick Add form below a form row (so it stays clickable).
|
/// Holds a full-width Quick Add form below a form row (so it stays clickable).
|
||||||
class QuickAddInlineController extends ChangeNotifier {
|
class QuickAddInlineController extends ChangeNotifier {
|
||||||
Object? _owner;
|
Object? _owner;
|
||||||
Widget? _form;
|
Widget Function()? _formBuilder;
|
||||||
|
QuickAddBlockerController? blocker;
|
||||||
|
|
||||||
Widget? get form => _form;
|
bool get isOpen => _formBuilder != null;
|
||||||
|
|
||||||
void present({required Object owner, required Widget form}) {
|
Widget? buildForm() => _formBuilder?.call();
|
||||||
|
|
||||||
|
void present({
|
||||||
|
required Object owner,
|
||||||
|
required Widget Function() formBuilder,
|
||||||
|
}) {
|
||||||
|
final wasClosed = _formBuilder == null;
|
||||||
_owner = owner;
|
_owner = owner;
|
||||||
_form = form;
|
_formBuilder = formBuilder;
|
||||||
|
if (wasClosed) blocker?.push();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void dismiss(Object owner) {
|
void dismiss(Object owner) {
|
||||||
if (_owner != owner) return;
|
if (_owner != owner) return;
|
||||||
_owner = null;
|
_clear(notify: true);
|
||||||
_form = null;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void dismissAll() {
|
void dismissAll() {
|
||||||
if (_owner == null && _form == null) return;
|
_clear(notify: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clear({required bool notify}) {
|
||||||
|
final wasOpen = _formBuilder != null;
|
||||||
|
if (_owner == null && _formBuilder == null) return;
|
||||||
_owner = null;
|
_owner = null;
|
||||||
_form = null;
|
_formBuilder = null;
|
||||||
notifyListeners();
|
if (wasOpen) blocker?.pop();
|
||||||
|
if (notify) notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,6 +132,10 @@ class QuickAddInlineScope extends InheritedNotifier<QuickAddInlineController> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Wraps a form row and renders any active Quick Add form at full row width.
|
/// Wraps a form row and renders any active Quick Add form at full row width.
|
||||||
|
///
|
||||||
|
/// While Quick Add is open, the parent row fields are dimmed and non-interactive.
|
||||||
|
/// Sibling fields / footer use [QuickAddBlockerScope] from [SidePanelScaffold]
|
||||||
|
/// (or a host-owned blocker) for the same lock behavior.
|
||||||
class QuickAddInlineHost extends StatefulWidget {
|
class QuickAddInlineHost extends StatefulWidget {
|
||||||
const QuickAddInlineHost({super.key, required this.child});
|
const QuickAddInlineHost({super.key, required this.child});
|
||||||
|
|
||||||
@ -57,41 +147,112 @@ class QuickAddInlineHost extends StatefulWidget {
|
|||||||
|
|
||||||
class _QuickAddInlineHostState extends State<QuickAddInlineHost> {
|
class _QuickAddInlineHostState extends State<QuickAddInlineHost> {
|
||||||
final _controller = QuickAddInlineController();
|
final _controller = QuickAddInlineController();
|
||||||
|
QuickAddBlockerController? _ownedBlocker;
|
||||||
|
bool _wasOpen = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
if (_wasOpen) {
|
||||||
|
_controller.blocker?.pop();
|
||||||
|
}
|
||||||
|
_ownedBlocker?.dispose();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final parentBlocker = QuickAddBlockerScope.maybeOf(context);
|
||||||
|
if (parentBlocker != null) {
|
||||||
|
_controller.blocker = parentBlocker;
|
||||||
|
} else {
|
||||||
|
_ownedBlocker ??= QuickAddBlockerController();
|
||||||
|
_controller.blocker = _ownedBlocker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuickAddInlineScope(
|
final theme = Theme.of(context);
|
||||||
|
final blocker = _controller.blocker;
|
||||||
|
|
||||||
|
Widget host = QuickAddInlineScope(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
child: ListenableBuilder(
|
child: ListenableBuilder(
|
||||||
listenable: _controller,
|
listenable: _controller,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final form = _controller.form;
|
Widget buildColumn({required bool blockedByOther}) {
|
||||||
return Column(
|
final form = _controller.buildForm();
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
final isOpen = form != null;
|
||||||
children: [
|
if (isOpen && !_wasOpen) {
|
||||||
widget.child,
|
_wasOpen = true;
|
||||||
AnimatedSize(
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
duration: const Duration(milliseconds: 280),
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
curve: Curves.easeOutCubic,
|
});
|
||||||
alignment: Alignment.topCenter,
|
} else if (!isOpen) {
|
||||||
child: form == null
|
_wasOpen = false;
|
||||||
? const SizedBox.shrink()
|
}
|
||||||
: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 10, bottom: 4),
|
final dimParent = isOpen || blockedByOther;
|
||||||
child: form,
|
|
||||||
),
|
// Field dimming is handled by [QuickAddBlockable] inside form rows /
|
||||||
),
|
// sections. The host only blocks interaction as a safety net for
|
||||||
],
|
// custom hosts that wrap plain children (e.g. PO form grid).
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
AbsorbPointer(
|
||||||
|
absorbing: dimParent,
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 280),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: form == null
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10, bottom: 4),
|
||||||
|
child: Material(
|
||||||
|
elevation: 2,
|
||||||
|
color: Colors.transparent,
|
||||||
|
shadowColor: theme.colorScheme.shadow
|
||||||
|
.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: form,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocker == null) {
|
||||||
|
return buildColumn(blockedByOther: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: blocker,
|
||||||
|
builder: (context, _) {
|
||||||
|
final blockedByOther =
|
||||||
|
!_controller.isOpen && blocker.isBlocked;
|
||||||
|
return buildColumn(blockedByOther: blockedByOther);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (_ownedBlocker != null &&
|
||||||
|
QuickAddBlockerScope.maybeOf(context) == null) {
|
||||||
|
host = QuickAddBlockerScope(
|
||||||
|
controller: _ownedBlocker!,
|
||||||
|
child: host,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return host;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,7 +323,7 @@ void showSidePanelSnackBar(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SidePanelScaffold extends StatelessWidget {
|
class SidePanelScaffold extends StatefulWidget {
|
||||||
const SidePanelScaffold({
|
const SidePanelScaffold({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
@ -176,9 +337,22 @@ class SidePanelScaffold extends StatelessWidget {
|
|||||||
final Widget? footer;
|
final Widget? footer;
|
||||||
final VoidCallback? onClose;
|
final VoidCallback? onClose;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SidePanelScaffold> createState() => _SidePanelScaffoldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SidePanelScaffoldState extends State<SidePanelScaffold> {
|
||||||
|
final _blocker = QuickAddBlockerController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_blocker.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
void _close(BuildContext context) {
|
void _close(BuildContext context) {
|
||||||
if (onClose != null) {
|
if (widget.onClose != null) {
|
||||||
onClose!();
|
widget.onClose!();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Navigator.of(context, rootNavigator: true).pop();
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
@ -186,40 +360,77 @@ class SidePanelScaffold extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return QuickAddBlockerScope(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
controller: _blocker,
|
||||||
children: [
|
child: Column(
|
||||||
Padding(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
padding: const EdgeInsets.fromLTRB(24, 20, 12, 16),
|
children: [
|
||||||
child: Row(
|
ListenableBuilder(
|
||||||
children: [
|
listenable: _blocker,
|
||||||
Expanded(
|
builder: (context, _) {
|
||||||
child: Text(
|
final blocked = _blocker.isBlocked;
|
||||||
title,
|
return Padding(
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
padding: const EdgeInsets.fromLTRB(24, 20, 12, 16),
|
||||||
fontWeight: FontWeight.w700,
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
opacity: blocked ? 0.45 : 1,
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleLarge
|
||||||
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
AbsorbPointer(
|
||||||
|
absorbing: blocked,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
opacity: blocked ? 0.45 : 1,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => _close(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
IconButton(
|
},
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
onPressed: () => _close(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
Expanded(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (footer != null) ...[
|
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Padding(padding: const EdgeInsets.all(24), child: footer),
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.footer != null) ...[
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListenableBuilder(
|
||||||
|
listenable: _blocker,
|
||||||
|
builder: (context, _) {
|
||||||
|
final blocked = _blocker.isBlocked;
|
||||||
|
return AbsorbPointer(
|
||||||
|
absorbing: blocked,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
opacity: blocked ? 0.45 : 1,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: widget.footer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -234,6 +445,22 @@ class SidePanelSection extends StatelessWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
|
|
||||||
|
/// Rows that already host Quick Add — do not wrap or the inline form
|
||||||
|
/// would be blocked too.
|
||||||
|
static bool _hostsQuickAdd(Widget child) {
|
||||||
|
return child is SidePanelFormRow ||
|
||||||
|
child is FormRow ||
|
||||||
|
child is FormRowThree ||
|
||||||
|
child is FormRowFour ||
|
||||||
|
child is QuickAddInlineHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Widget _blockable(Widget child) {
|
||||||
|
if (child is SizedBox && child.child == null) return child;
|
||||||
|
if (_hostsQuickAdd(child)) return child;
|
||||||
|
return QuickAddBlockable(child: child);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final dividerColor =
|
final dividerColor =
|
||||||
@ -242,18 +469,25 @@ class SidePanelSection extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
QuickAddBlockable(
|
||||||
title,
|
child: Column(
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
fontWeight: FontWeight.w700,
|
children: [
|
||||||
letterSpacing: 0.8,
|
Text(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
title,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Divider(height: 1, color: dividerColor),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
Divider(height: 1, color: dividerColor),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
...children,
|
...children.map(_blockable),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -275,36 +509,38 @@ class SidePanelFormRow extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuickAddInlineHost(
|
return QuickAddInlineHost(
|
||||||
child: LayoutBuilder(
|
child: QuickAddBlockable(
|
||||||
builder: (context, constraints) {
|
child: LayoutBuilder(
|
||||||
final stack = constraints.maxWidth < 420;
|
builder: (context, constraints) {
|
||||||
|
final stack = constraints.maxWidth < 420;
|
||||||
|
|
||||||
|
if (stack) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
left,
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
right,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (stack) {
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
left,
|
Expanded(child: left),
|
||||||
const SizedBox(height: 12),
|
SizedBox(width: spacing),
|
||||||
right,
|
Expanded(child: right),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
),
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(child: left),
|
|
||||||
SizedBox(width: spacing),
|
|
||||||
Expanded(child: right),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -499,66 +735,68 @@ class FormRow extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuickAddInlineHost(
|
return QuickAddInlineHost(
|
||||||
child: LayoutBuilder(
|
child: QuickAddBlockable(
|
||||||
builder: (context, constraints) {
|
child: LayoutBuilder(
|
||||||
final contentPadding = EdgeInsets.fromLTRB(
|
builder: (context, constraints) {
|
||||||
horizontalPadding,
|
final contentPadding = EdgeInsets.fromLTRB(
|
||||||
0,
|
horizontalPadding,
|
||||||
horizontalPadding,
|
0,
|
||||||
spacing,
|
horizontalPadding,
|
||||||
);
|
spacing,
|
||||||
|
|
||||||
if (constraints.maxWidth < stackBelowWidth) {
|
|
||||||
return Padding(
|
|
||||||
padding: contentPadding,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
for (var i = 0; i < children.length; i++) ...[
|
|
||||||
if (i > 0) SizedBox(height: spacing),
|
|
||||||
children[i],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (columnCount != null) {
|
if (constraints.maxWidth < stackBelowWidth) {
|
||||||
final slots = _columnSlots();
|
return Padding(
|
||||||
final cols = columnCount!;
|
padding: contentPadding,
|
||||||
final innerWidth = constraints.maxWidth - (horizontalPadding * 2);
|
child: Column(
|
||||||
final columnWidth = (innerWidth - (cols - 1) * spacing) / cols;
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < children.length; i++) ...[
|
||||||
|
if (i > 0) SizedBox(height: spacing),
|
||||||
|
children[i],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (columnCount != null) {
|
||||||
|
final slots = _columnSlots();
|
||||||
|
final cols = columnCount!;
|
||||||
|
final innerWidth = constraints.maxWidth - (horizontalPadding * 2);
|
||||||
|
final columnWidth = (innerWidth - (cols - 1) * spacing) / cols;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: contentPadding,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < slots.length; i++) ...[
|
||||||
|
if (i > 0) SizedBox(width: spacing),
|
||||||
|
SizedBox(
|
||||||
|
width: _spanWidth(columnWidth, slots[i].span),
|
||||||
|
child: slots[i].child,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: contentPadding,
|
padding: contentPadding,
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < slots.length; i++) ...[
|
for (var i = 0; i < children.length; i++) ...[
|
||||||
if (i > 0) SizedBox(width: spacing),
|
if (i > 0) SizedBox(width: spacing),
|
||||||
SizedBox(
|
Expanded(child: children[i]),
|
||||||
width: _spanWidth(columnWidth, slots[i].span),
|
|
||||||
child: slots[i].child,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
),
|
||||||
return Padding(
|
|
||||||
padding: contentPadding,
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
for (var i = 0; i < children.length; i++) ...[
|
|
||||||
if (i > 0) SizedBox(width: spacing),
|
|
||||||
Expanded(child: children[i]),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,12 +58,29 @@ 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.supportMenuRoutes.contains(item.route))
|
.where((item) => !menu.isSupportMenuItem(item))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
List<menu.MenuItem> get _supportMenuItems => widget.menuItems
|
/// Sidebar shows Support children flat under the SUPPORT section label.
|
||||||
.where((item) => menu.supportMenuRoutes.contains(item.route))
|
/// Top nav keeps the Support parent as a dropdown (via [menuItems] as-is).
|
||||||
.toList();
|
List<menu.MenuItem> get _supportMenuItems {
|
||||||
|
final items = <menu.MenuItem>[];
|
||||||
|
for (final item in widget.menuItems) {
|
||||||
|
if (menu.isSupportMenuItem(item)) {
|
||||||
|
if (item.children.isNotEmpty) {
|
||||||
|
items.addAll(item.children);
|
||||||
|
} else {
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (menu.supportMenuRoutes.contains(item.route) &&
|
||||||
|
item.children.isEmpty) {
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
bool get _hasSupportSection =>
|
bool get _hasSupportSection =>
|
||||||
_supportMenuItems.isNotEmpty || AppConstants.showNotificationsMenu;
|
_supportMenuItems.isNotEmpty || AppConstants.showNotificationsMenu;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user