PO calculation

This commit is contained in:
Surendiran 2026-07-20 11:58:17 +05:30
parent 09ffc5fb6a
commit 4046633340
26 changed files with 952 additions and 550 deletions

View File

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

View File

@ -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',

View File

@ -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(),
), ),
], ],
), ),

View File

@ -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',

View File

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

View File

@ -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.';

View File

@ -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',

View File

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

View File

@ -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>>(

View File

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

View File

@ -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,

View File

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

View File

@ -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),

View File

@ -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',

View File

@ -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> {
); );
}, },
), ),
),
), ),
); );
} }

View File

@ -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,

View File

@ -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',

View File

@ -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) {

View File

@ -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(

View File

@ -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,

View File

@ -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',

View File

@ -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',

View File

@ -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,

View File

@ -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(

View File

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

View File

@ -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;