PO calculation
This commit is contained in:
parent
09ffc5fb6a
commit
4046633340
@ -93,25 +93,14 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
||||
}
|
||||
}
|
||||
|
||||
items = TableSearch.filter(
|
||||
items,
|
||||
search,
|
||||
(asset) => [
|
||||
asset.assetCode,
|
||||
asset.assetName,
|
||||
asset.assetCategoryName,
|
||||
asset.locationName,
|
||||
asset.status,
|
||||
asset.condition,
|
||||
],
|
||||
);
|
||||
// Keep API page results + enriched matches; pagination stays server-driven.
|
||||
}
|
||||
|
||||
return AssetsListState(
|
||||
assets: items,
|
||||
query: query,
|
||||
total: search.isEmpty ? page.total : items.length,
|
||||
totalPages: search.isEmpty ? page.totalPages : 1,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -158,33 +158,17 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
onStatusChanged: notifier.setStatusFilter,
|
||||
),
|
||||
),
|
||||
if (state.assets.isEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No assets found',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: _AssetDataTable(
|
||||
assets: state.assets,
|
||||
canEdit: canEdit,
|
||||
canDelete: canDelete,
|
||||
onView: _viewAsset,
|
||||
onEdit: _editAsset,
|
||||
onDelete: _deleteAsset,
|
||||
),
|
||||
Expanded(
|
||||
child: _AssetDataTable(
|
||||
assets: state.assets,
|
||||
canEdit: canEdit,
|
||||
canDelete: canDelete,
|
||||
onView: _viewAsset,
|
||||
onEdit: _editAsset,
|
||||
onDelete: _deleteAsset,
|
||||
onServerSearch: notifier.setSearch,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@ -390,6 +374,7 @@ class _AssetDataTable extends StatelessWidget {
|
||||
required this.onView,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
this.onServerSearch,
|
||||
});
|
||||
|
||||
final List<AssetModel> assets;
|
||||
@ -398,11 +383,13 @@ class _AssetDataTable extends StatelessWidget {
|
||||
final void Function(AssetModel asset) onView;
|
||||
final void Function(AssetModel asset) onEdit;
|
||||
final Future<void> Function(AssetModel asset) onDelete;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppDataTable<AssetModel>(
|
||||
wrapInCard: false,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'Asset Code',
|
||||
|
||||
@ -637,10 +637,13 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
lookupsAsync.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('Failed to load locations'),
|
||||
data: (lookups) => _locationDropdown(lookups.locations),
|
||||
SidePanelFormRow(
|
||||
left: lookupsAsync.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
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),
|
||||
_optionalLookupDropdown(
|
||||
label: 'Assigned To',
|
||||
value: _assignedToUserId,
|
||||
options: lookups.users,
|
||||
onChanged: (v) => setState(() => _assignedToUserId = v),
|
||||
emptyHint: 'Unassigned',
|
||||
SidePanelFormRow(
|
||||
left: _optionalLookupDropdown(
|
||||
label: 'Assigned To',
|
||||
value: _assignedToUserId,
|
||||
options: lookups.users,
|
||||
onChanged: (v) => setState(() => _assignedToUserId = v),
|
||||
emptyHint: 'Unassigned',
|
||||
),
|
||||
right: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -206,30 +206,40 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
|
||||
),
|
||||
],
|
||||
)
|
||||
: state.items.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: AppEmptyState(
|
||||
title: 'No audit logs found',
|
||||
description:
|
||||
'Try adjusting filters or expanding the date range.',
|
||||
icon: Icons.history_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: context.isMobile
|
||||
? _AuditCardList(
|
||||
items: state.items,
|
||||
onView: _viewLog,
|
||||
: context.isMobile
|
||||
? (state.items.isEmpty
|
||||
? ListView(
|
||||
physics:
|
||||
const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: AppEmptyState(
|
||||
title: 'No audit logs found',
|
||||
description:
|
||||
'Try adjusting filters or expanding the date range.',
|
||||
icon: Icons.history_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _AuditDataTable(
|
||||
: _AuditCardList(
|
||||
items: state.items,
|
||||
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({
|
||||
required this.items,
|
||||
required this.onView,
|
||||
this.onServerSearch,
|
||||
});
|
||||
|
||||
final List<AuditLogEntryModel> items;
|
||||
final void Function(AuditLogEntryModel log) onView;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -380,6 +392,7 @@ class _AuditDataTable extends StatelessWidget {
|
||||
wrapInCard: false,
|
||||
rows: items,
|
||||
emptyMessage: 'No audit logs found',
|
||||
onServerSearchChanged: onServerSearch,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'When',
|
||||
|
||||
@ -134,25 +134,13 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
|
||||
} catch (_) {
|
||||
// Lookups enrichment is best-effort.
|
||||
}
|
||||
|
||||
items = TableSearch.filter(
|
||||
items,
|
||||
search,
|
||||
(grn) => [
|
||||
grn.grnNumber,
|
||||
grn.poNumber,
|
||||
grn.vendorName,
|
||||
grn.locationName,
|
||||
grn.status,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return GrnListState(
|
||||
grns: items,
|
||||
query: query,
|
||||
total: search.isEmpty ? page.total : items.length,
|
||||
totalPages: search.isEmpty ? page.totalPages : 1,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -626,7 +626,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
final theme = Theme.of(context);
|
||||
final title = widget.isEditing
|
||||
? 'Edit ${existing?.grnNumber ?? 'GRN'}'
|
||||
: 'Create Goods Received Note';
|
||||
: 'Create Purchase Receipt';
|
||||
final subtitle = widget.isEditing
|
||||
? null
|
||||
: 'Select an approved purchase order, enter receipt details, then confirm quantities.';
|
||||
|
||||
@ -73,7 +73,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PageHeader(
|
||||
title: 'Goods Received Notes',
|
||||
title: 'Purchase Receipt',
|
||||
subtitle: 'Record and track purchase order receipts',
|
||||
actions: [
|
||||
AppSearchFilterButton(
|
||||
@ -133,32 +133,41 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => ref.read(grnListProvider.notifier).refresh(),
|
||||
child: state.grns.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No GRNs found',
|
||||
description:
|
||||
'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,
|
||||
child: context.isMobile
|
||||
? (state.grns.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No GRNs found',
|
||||
description:
|
||||
'Try adjusting filters or create a new goods received note.',
|
||||
icon: Icons.inventory_2_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _GrnDataTable(
|
||||
: _GrnCardList(
|
||||
grns: state.grns,
|
||||
onView: _viewGrn,
|
||||
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.onView,
|
||||
this.onEdit,
|
||||
this.onServerSearch,
|
||||
});
|
||||
|
||||
final List<GrnModel> grns;
|
||||
final ValueChanged<GrnModel> onView;
|
||||
final ValueChanged<GrnModel>? onEdit;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppDataTable<GrnModel>(
|
||||
wrapInCard: false,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'GRN Number',
|
||||
|
||||
@ -152,14 +152,7 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
|
||||
}
|
||||
}
|
||||
|
||||
items = TableSearch.filter(
|
||||
items,
|
||||
nextSearch,
|
||||
(row) => [
|
||||
..._definition.listFields.map((field) => masterCellValue(row, field)),
|
||||
masterStatusValue(row),
|
||||
],
|
||||
);
|
||||
// Keep API page results + enriched matches; pagination stays server-driven.
|
||||
}
|
||||
|
||||
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
|
||||
// even if API meta omits or mismatches `limit`.
|
||||
limit: nextLimit,
|
||||
total: nextSearch.isEmpty ? data.total : items.length,
|
||||
totalPages: nextSearch.isEmpty
|
||||
? resolveTotalPages(total: data.total, limit: nextLimit)
|
||||
: resolveTotalPages(total: items.length, limit: nextLimit),
|
||||
total: data.total,
|
||||
totalPages: resolveTotalPages(total: data.total, limit: nextLimit),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@ import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../../../../shared/providers/permissions_provider.dart';
|
||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_pagination.dart';
|
||||
import '../../../../shared/widgets/app_search_export_bar.dart';
|
||||
@ -257,30 +256,24 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: notifier.refresh,
|
||||
child: state.items.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No ${def.title.toLowerCase()} found',
|
||||
description:
|
||||
'Add your first ${def.title.toLowerCase()} record to get started.',
|
||||
icon: def.icon,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _MasterListTable(
|
||||
definition: def,
|
||||
items: state.items,
|
||||
isDeleting: state.isDeleting,
|
||||
canEdit: canEdit,
|
||||
canDelete: canDelete,
|
||||
onEdit: (id) => _openFormPanel(recordId: id),
|
||||
onDelete: _deleteRecord,
|
||||
child: _MasterListTable(
|
||||
definition: def,
|
||||
items: state.items,
|
||||
isDeleting: state.isDeleting,
|
||||
canEdit: canEdit,
|
||||
canDelete: canDelete,
|
||||
onEdit: (id) => _openFormPanel(recordId: id),
|
||||
onDelete: _deleteRecord,
|
||||
onServerSearch: (value) {
|
||||
_searchController.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: value.length,
|
||||
),
|
||||
);
|
||||
notifier.setSearch(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -301,6 +294,7 @@ class _MasterListTable extends StatelessWidget {
|
||||
required this.canDelete,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
this.onServerSearch,
|
||||
});
|
||||
|
||||
final MasterDefinition definition;
|
||||
@ -310,6 +304,7 @@ class _MasterListTable extends StatelessWidget {
|
||||
final bool canDelete;
|
||||
final ValueChanged<String> onEdit;
|
||||
final ValueChanged<Map<String, dynamic>> onDelete;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -317,6 +312,8 @@ class _MasterListTable extends StatelessWidget {
|
||||
|
||||
return AppDataTable<Map<String, dynamic>>(
|
||||
wrapInCard: false,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
emptyMessage: 'No ${definition.title.toLowerCase()} found',
|
||||
columns: [
|
||||
...definition.listFields.map(
|
||||
(field) => AppDataColumn<Map<String, dynamic>>(
|
||||
|
||||
@ -412,11 +412,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
final fullWidth = left.multiline || left.key == 'is_asset_item';
|
||||
if (fullWidth) {
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _buildField(context, field: left, formState: formState),
|
||||
QuickAddBlockable(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _buildField(context, field: left, formState: formState),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -438,11 +440,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
i += 2;
|
||||
} else {
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _buildField(context, field: left, formState: formState),
|
||||
QuickAddBlockable(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _buildField(context, field: left, formState: formState),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -452,11 +456,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
|
||||
if (activeField != null) {
|
||||
widgets.add(
|
||||
SidePanelSection(
|
||||
title: 'STATUS',
|
||||
children: [
|
||||
_buildField(context, field: activeField, formState: formState),
|
||||
],
|
||||
QuickAddBlockable(
|
||||
child: SidePanelSection(
|
||||
title: 'STATUS',
|
||||
children: [
|
||||
_buildField(context, field: activeField, formState: formState),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -148,8 +148,14 @@ class _MasterQuickAddDropdownState<T>
|
||||
}
|
||||
|
||||
void _collapse() {
|
||||
if (!_expanded) return;
|
||||
_host?.dismiss(this);
|
||||
final host = _host ?? QuickAddInlineScope.maybeOf(context);
|
||||
// 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(() {
|
||||
_expanded = false;
|
||||
_sessionId = null;
|
||||
@ -161,24 +167,32 @@ class _MasterQuickAddDropdownState<T>
|
||||
|
||||
Future<void> _onSaved(String createdId) async {
|
||||
await _applyCreated(createdId);
|
||||
_collapse();
|
||||
if (mounted) _collapse();
|
||||
}
|
||||
|
||||
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 {
|
||||
final refresh = widget.refreshLookups;
|
||||
if (refresh != null) await refresh();
|
||||
} catch (_) {
|
||||
// Still select the created row even if lookup refresh fails.
|
||||
// Still keep the created selection even if lookup refresh fails.
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Prefer a real id from create; fall back only when API omits it.
|
||||
if (createdId.isNotEmpty && createdId != 'created') {
|
||||
final parsed = widget.parseCreatedId(createdId);
|
||||
if (parsed != null) {
|
||||
widget.onChanged(parsed);
|
||||
}
|
||||
// Re-apply after reload in case options refresh cleared dependent values.
|
||||
if (parsed != null) {
|
||||
widget.onChanged(parsed);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
@ -207,7 +221,7 @@ class _MasterQuickAddDropdownState<T>
|
||||
final host = _host ?? QuickAddInlineScope.maybeOf(context);
|
||||
if (host == null) return;
|
||||
_host = host;
|
||||
host.present(owner: this, form: _buildCreateForm());
|
||||
host.present(owner: this, formBuilder: _buildCreateForm);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -246,7 +260,14 @@ class _MasterQuickAddDropdownState<T>
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
dropdown,
|
||||
IgnorePointer(
|
||||
ignoring: true,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
opacity: 0.45,
|
||||
child: dropdown,
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 280),
|
||||
curve: Curves.easeOutCubic,
|
||||
|
||||
@ -84,12 +84,11 @@ class PurchaseOrdersListNotifier
|
||||
items: page.items,
|
||||
load: repository.getPurchaseOrders,
|
||||
);
|
||||
final search = TableSearch.normalize(query.search);
|
||||
return PurchaseOrdersListState(
|
||||
orders: orders,
|
||||
query: query,
|
||||
total: search.isEmpty ? page.total : orders.length,
|
||||
totalPages: search.isEmpty ? page.totalPages : 1,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
);
|
||||
}
|
||||
|
||||
@ -202,12 +201,11 @@ class PendingApprovalPurchaseOrdersListNotifier
|
||||
items: page.items,
|
||||
load: repository.getPendingApprovalPurchaseOrders,
|
||||
);
|
||||
final search = TableSearch.normalize(query.search);
|
||||
return PurchaseOrdersListState(
|
||||
orders: orders,
|
||||
query: query,
|
||||
total: search.isEmpty ? page.total : orders.length,
|
||||
totalPages: search.isEmpty ? page.totalPages : 1,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
);
|
||||
}
|
||||
|
||||
@ -479,15 +477,5 @@ Future<List<PurchaseOrderModel>> _enrichPurchaseOrderSearch({
|
||||
// Lookups/vendor enrichment is best-effort.
|
||||
}
|
||||
|
||||
return TableSearch.filter(
|
||||
merged,
|
||||
search,
|
||||
(order) => [
|
||||
order.poNo,
|
||||
order.vendorName,
|
||||
order.status,
|
||||
order.billingName,
|
||||
order.shippingName,
|
||||
],
|
||||
);
|
||||
return merged;
|
||||
}
|
||||
|
||||
@ -396,7 +396,8 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
_SectionCard(
|
||||
title: 'ORDER DETAILS',
|
||||
child: QuickAddInlineHost(
|
||||
child: ResponsiveFormGrid(
|
||||
child: QuickAddBlockable(
|
||||
child: ResponsiveFormGrid(
|
||||
smallColumns: 1,
|
||||
mediumColumns: 2,
|
||||
largeColumns: 4,
|
||||
@ -535,6 +536,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@ -214,41 +214,61 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
)
|
||||
.refresh()
|
||||
: ref.read(purchaseOrdersListProvider.notifier).refresh(),
|
||||
child: state.orders.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: pendingOnly
|
||||
? 'No pending approvals'
|
||||
: 'No purchase orders found',
|
||||
description: pendingOnly
|
||||
? 'There are no purchase orders waiting for approval.'
|
||||
: 'Try adjusting filters or create a new purchase order.',
|
||||
icon: pendingOnly
|
||||
? 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,
|
||||
child: context.isMobile
|
||||
? (state.orders.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: pendingOnly
|
||||
? 'No pending approvals'
|
||||
: 'No purchase orders found',
|
||||
description: pendingOnly
|
||||
? 'There are no purchase orders waiting for approval.'
|
||||
: 'Try adjusting filters or create a new purchase order.',
|
||||
icon: pendingOnly
|
||||
? Icons.pending_actions_outlined
|
||||
: Icons.receipt_long_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _PoDataTable(
|
||||
: _PoCardList(
|
||||
orders: state.orders,
|
||||
onView: _viewOrder,
|
||||
onEdit: canEdit ? _editOrder : null,
|
||||
onDelete: canDelete ? _deleteOrder : 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.onDelete,
|
||||
this.onApprove,
|
||||
this.onServerSearch,
|
||||
});
|
||||
|
||||
final List<PurchaseOrderModel> orders;
|
||||
@ -434,12 +455,14 @@ class _PoDataTable extends StatelessWidget {
|
||||
final ValueChanged<PurchaseOrderModel>? onEdit;
|
||||
final ValueChanged<PurchaseOrderModel>? onDelete;
|
||||
final ValueChanged<PurchaseOrderModel>? onApprove;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AppDataTable<PurchaseOrderModel>(
|
||||
wrapInCard: false,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'PO Number',
|
||||
|
||||
@ -85,7 +85,7 @@ class PoOrderTotals {
|
||||
|
||||
/// Sub Total = sum of line amounts
|
||||
/// 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
|
||||
factory PoOrderTotals.compute({
|
||||
required Iterable<PoLineCalculation> lines,
|
||||
@ -94,17 +94,20 @@ class PoOrderTotals {
|
||||
required double discountAmount,
|
||||
}) {
|
||||
var subTotal = 0.0;
|
||||
var tax = 0.0;
|
||||
var lineTax = 0.0;
|
||||
for (final line in lines) {
|
||||
subTotal += line.lineAmount;
|
||||
tax += line.gstAmount;
|
||||
lineTax += line.gstAmount;
|
||||
}
|
||||
final freightSafe = freight < 0 ? 0.0 : freight;
|
||||
final otherSafe = otherCharges < 0 ? 0.0 : otherCharges;
|
||||
final clampedDiscount = discountAmount < 0 ? 0.0 : discountAmount;
|
||||
final taxableRaw = subTotal + freightSafe + otherSafe - clampedDiscount;
|
||||
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;
|
||||
return PoOrderTotals(
|
||||
subTotal: subTotal,
|
||||
@ -629,7 +632,8 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: QuickAddInlineHost(
|
||||
child: LayoutBuilder(
|
||||
child: QuickAddBlockable(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
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(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No users found',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: UserRichDataTable(
|
||||
wrapInCard: false,
|
||||
users: usersState.users,
|
||||
actionsBuilder: (_, user) => UserTableActions(
|
||||
user: user,
|
||||
canEdit: canEditUser,
|
||||
canResetPassword: canEditUser,
|
||||
canDeactivate: canDeleteUser,
|
||||
onEdit: () => _editUser(user),
|
||||
onResetPassword: () => _resetPassword(user),
|
||||
onDeactivate: () => _deactivateUser(user),
|
||||
),
|
||||
Expanded(
|
||||
child: UserRichDataTable(
|
||||
wrapInCard: false,
|
||||
users: usersState.users,
|
||||
onServerSearchChanged: (value) {
|
||||
_searchController.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(offset: value.length),
|
||||
);
|
||||
ref.read(usersListProvider.notifier).setSearch(value);
|
||||
},
|
||||
actionsBuilder: (_, user) => UserTableActions(
|
||||
user: user,
|
||||
canEdit: canEditUser,
|
||||
canResetPassword: canEditUser,
|
||||
canDeactivate: canDeleteUser,
|
||||
onEdit: () => _editUser(user),
|
||||
onResetPassword: () => _resetPassword(user),
|
||||
onDeactivate: () => _deactivateUser(user),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
AppPagination(
|
||||
currentPage: page,
|
||||
|
||||
@ -215,24 +215,35 @@ class _DepreciationReportScreenState
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: notifier.refresh,
|
||||
child: state.items.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: AppEmptyState(
|
||||
title: 'No depreciation data',
|
||||
description:
|
||||
'Try adjusting filters or the as-of date.',
|
||||
icon: Icons.trending_down_outlined,
|
||||
child: context.isMobile
|
||||
? (state.items.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: AppEmptyState(
|
||||
title: 'No depreciation data',
|
||||
description:
|
||||
'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,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: context.isMobile
|
||||
? _MobileList(items: state.items)
|
||||
: _ReportTable(items: state.items),
|
||||
);
|
||||
notifier.setSearch(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -581,15 +592,20 @@ class _FiltersBarState extends State<_FiltersBar> {
|
||||
}
|
||||
|
||||
class _ReportTable extends StatelessWidget {
|
||||
const _ReportTable({required this.items});
|
||||
const _ReportTable({
|
||||
required this.items,
|
||||
this.onServerSearch,
|
||||
});
|
||||
|
||||
final List<DepreciationReportRow> items;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppDataTable<DepreciationReportRow>(
|
||||
wrapInCard: false,
|
||||
rows: items,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'Asset Code',
|
||||
|
||||
@ -21,15 +21,8 @@ class RolesListState {
|
||||
final int limit;
|
||||
|
||||
List<RoleCardModel> get filteredRoles {
|
||||
if (search.isEmpty) return roles;
|
||||
final q = search.toLowerCase();
|
||||
return roles
|
||||
.where(
|
||||
(role) =>
|
||||
role.name.toLowerCase().contains(q) ||
|
||||
(role.description?.toLowerCase().contains(q) ?? false),
|
||||
)
|
||||
.toList();
|
||||
// Roles are already filtered by the API when [search] is set.
|
||||
return roles;
|
||||
}
|
||||
|
||||
int get total => filteredRoles.length;
|
||||
@ -124,10 +117,17 @@ class RolesListNotifier extends AsyncNotifier<RolesListState> {
|
||||
return true;
|
||||
}
|
||||
|
||||
void setSearch(String search) {
|
||||
Future<void> setSearch(String search) async {
|
||||
final current = state.valueOrNull;
|
||||
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) {
|
||||
|
||||
@ -88,24 +88,36 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: notifier.refresh,
|
||||
child: roles.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No roles found',
|
||||
description:
|
||||
'Roles from the API will appear here.',
|
||||
icon: Icons.security_outlined,
|
||||
child: context.isMobile
|
||||
? (roles.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No roles found',
|
||||
description:
|
||||
'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,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: context.isMobile
|
||||
? _RoleCardList(roles: roles, onOpen: _openRole)
|
||||
: _RoleDataTable(roles: roles, onOpen: _openRole),
|
||||
);
|
||||
notifier.setSearch(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -122,15 +134,21 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
|
||||
}
|
||||
|
||||
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 void Function(RoleCardModel role) onOpen;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppDataTable<RoleCardModel>(
|
||||
wrapInCard: false,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
columns: [
|
||||
AppDataColumn(label: 'Role Name', flex: 2, searchText: (r) => r.name, cellBuilder: (_, r) => Text(r.name)),
|
||||
AppDataColumn(
|
||||
|
||||
@ -109,41 +109,52 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => ref.read(usersListProvider.notifier).refresh(),
|
||||
child: state.users.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No users found',
|
||||
description:
|
||||
'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,
|
||||
child: context.isMobile
|
||||
? (state.users.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No users found',
|
||||
description:
|
||||
'Try adjusting filters or add a new user.',
|
||||
icon: Icons.people_outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _UserDataTable(
|
||||
: _UserCardList(
|
||||
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,
|
||||
),
|
||||
))
|
||||
: _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.onToggleStatus,
|
||||
required this.onDeactivate,
|
||||
this.onServerSearch,
|
||||
});
|
||||
|
||||
final List<ManagedUserModel> users;
|
||||
@ -311,6 +323,7 @@ class _UserDataTable extends StatelessWidget {
|
||||
final void Function(ManagedUserModel user) onEdit;
|
||||
final Future<void> Function(ManagedUserModel user) onToggleStatus;
|
||||
final Future<void> Function(ManagedUserModel user) onDeactivate;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -320,6 +333,7 @@ class _UserDataTable extends StatelessWidget {
|
||||
sortAscending: sortOrder == 'asc',
|
||||
onSort: onSort,
|
||||
wrapInCard: false,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
actionsBuilder: (_, user) => _UserActions(
|
||||
user: user,
|
||||
onView: onView,
|
||||
|
||||
@ -20,6 +20,7 @@ class UserRichDataTable extends StatelessWidget {
|
||||
this.sortAscending = true,
|
||||
this.onSort,
|
||||
this.wrapInCard = false,
|
||||
this.onServerSearchChanged,
|
||||
});
|
||||
|
||||
final List<ManagedUserModel> users;
|
||||
@ -28,6 +29,7 @@ class UserRichDataTable extends StatelessWidget {
|
||||
final bool sortAscending;
|
||||
final void Function(String column, bool ascending)? onSort;
|
||||
final bool wrapInCard;
|
||||
final ValueChanged<String>? onServerSearchChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -38,6 +40,7 @@ class UserRichDataTable extends StatelessWidget {
|
||||
sortColumn: sortColumn,
|
||||
sortAscending: sortAscending,
|
||||
onSort: onSort,
|
||||
onServerSearchChanged: onServerSearchChanged,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'User',
|
||||
|
||||
@ -137,34 +137,45 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => ref.read(vendorsListProvider.notifier).refresh(),
|
||||
child: state.vendors.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No vendors found',
|
||||
description:
|
||||
'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,
|
||||
child: context.isMobile
|
||||
? (state.vendors.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No vendors found',
|
||||
description:
|
||||
'Try adjusting filters or add a new vendor.',
|
||||
icon: Icons.store_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _VendorDataTable(
|
||||
: _VendorCardList(
|
||||
vendors: state.vendors,
|
||||
onView: _viewVendor,
|
||||
onEdit: canEdit ? _editVendor : 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,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onServerSearch,
|
||||
});
|
||||
|
||||
final List<VendorModel> vendors;
|
||||
final ValueChanged<VendorModel> onView;
|
||||
final ValueChanged<VendorModel>? onEdit;
|
||||
final ValueChanged<VendorModel>? onDelete;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppDataTable<VendorModel>(
|
||||
wrapInCard: false,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'Code',
|
||||
|
||||
@ -46,6 +46,27 @@ const List<MenuItem> appMenuItems = [
|
||||
// route: RouteConstants.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(
|
||||
label: 'Assets',
|
||||
icon: Icons.inventory_2_outlined,
|
||||
@ -73,34 +94,7 @@ const List<MenuItem> appMenuItems = [
|
||||
],
|
||||
),
|
||||
MenuItem(
|
||||
label: 'Vendors',
|
||||
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',
|
||||
label: 'Purchase Receipt',
|
||||
icon: Icons.inventory_2_outlined,
|
||||
route: RouteConstants.grn,
|
||||
module: 'grn',
|
||||
@ -112,33 +106,52 @@ const List<MenuItem> appMenuItems = [
|
||||
module: 'reports',
|
||||
),
|
||||
MenuItem(
|
||||
label: 'Users & Roles',
|
||||
icon: Icons.admin_panel_settings_outlined,
|
||||
route: RouteConstants.usersRoleManagement,
|
||||
module: 'users',
|
||||
),
|
||||
MenuItem(
|
||||
label: 'Master Data',
|
||||
icon: Icons.dataset_outlined,
|
||||
route: RouteConstants.masterData,
|
||||
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',
|
||||
label: 'Support',
|
||||
icon: Icons.support_agent_outlined,
|
||||
route: RouteConstants.vendors,
|
||||
module: 'support',
|
||||
children: [
|
||||
MenuItem(
|
||||
label: 'Vendors',
|
||||
icon: Icons.store_outlined,
|
||||
route: RouteConstants.vendors,
|
||||
module: 'vendors',
|
||||
),
|
||||
MenuItem(
|
||||
label: 'Users & Roles',
|
||||
icon: Icons.admin_panel_settings_outlined,
|
||||
route: RouteConstants.usersRoleManagement,
|
||||
module: 'users',
|
||||
),
|
||||
MenuItem(
|
||||
label: 'Master Data',
|
||||
icon: Icons.dataset_outlined,
|
||||
route: RouteConstants.masterData,
|
||||
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 = {
|
||||
RouteConstants.vendors,
|
||||
RouteConstants.usersRoleManagement,
|
||||
RouteConstants.masterData,
|
||||
RouteConstants.auditLogs,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/utils/table_search.dart';
|
||||
import 'app_card.dart';
|
||||
|
||||
/// 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.wrapInCard = true,
|
||||
this.shrinkWrap = false,
|
||||
this.onServerSearchChanged,
|
||||
});
|
||||
|
||||
final List<AppDataColumn<T>> columns;
|
||||
@ -92,6 +94,13 @@ class AppDataTable<T> extends StatefulWidget {
|
||||
/// Set true when the table is placed inside another scrollable.
|
||||
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
|
||||
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).
|
||||
final Map<int, String> _queries = {};
|
||||
final Map<int, TextEditingController> _controllers = {};
|
||||
final SearchDebouncer _serverSearchDebouncer = SearchDebouncer();
|
||||
int? _lastEditedColumnIndex;
|
||||
String _lastEmittedServerSearch = '';
|
||||
|
||||
bool get _serverSideSearch => widget.onServerSearchChanged != null;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverSearchDebouncer.dispose();
|
||||
for (final c in _controllers.values) {
|
||||
c.dispose();
|
||||
}
|
||||
@ -113,7 +128,38 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
||||
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 {
|
||||
// Server-side mode: parent already fetched matching rows from the API.
|
||||
if (_serverSideSearch) return widget.rows;
|
||||
|
||||
final active = <int, String>{};
|
||||
for (final entry in _queries.entries) {
|
||||
final q = entry.value.trim().toLowerCase();
|
||||
@ -139,7 +185,12 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
||||
|
||||
@override
|
||||
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(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
@ -155,9 +206,6 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
||||
return AppCard(clipBehavior: Clip.antiAlias, child: empty);
|
||||
}
|
||||
|
||||
final filtered = _filteredRows;
|
||||
final showFilterRow = widget.columns.any((c) => c.isSearchable);
|
||||
|
||||
final header = _TableHeaderRow<T>(
|
||||
columns: widget.columns,
|
||||
sortColumn: widget.sortColumn,
|
||||
@ -170,9 +218,7 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
||||
columns: widget.columns,
|
||||
controllerFor: _controllerFor,
|
||||
queryFor: (i) => _queries[i] ?? '',
|
||||
onQueryChanged: (index, value) {
|
||||
setState(() => _queries[index] = value);
|
||||
},
|
||||
onQueryChanged: _onColumnQueryChanged,
|
||||
)
|
||||
: null;
|
||||
|
||||
@ -182,7 +228,7 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
_hasActiveColumnFilters
|
||||
_hasActiveColumnFilters || _lastEmittedServerSearch.isNotEmpty
|
||||
? widget.noMatchMessage
|
||||
: widget.emptyMessage,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
|
||||
@ -3,31 +3,117 @@ import 'package:flutter/material.dart';
|
||||
import '../../core/utils/responsive_utils.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).
|
||||
class QuickAddInlineController extends ChangeNotifier {
|
||||
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;
|
||||
_form = form;
|
||||
_formBuilder = formBuilder;
|
||||
if (wasClosed) blocker?.push();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void dismiss(Object owner) {
|
||||
if (_owner != owner) return;
|
||||
_owner = null;
|
||||
_form = null;
|
||||
notifyListeners();
|
||||
_clear(notify: true);
|
||||
}
|
||||
|
||||
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;
|
||||
_form = null;
|
||||
notifyListeners();
|
||||
_formBuilder = null;
|
||||
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.
|
||||
///
|
||||
/// 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 {
|
||||
const QuickAddInlineHost({super.key, required this.child});
|
||||
|
||||
@ -57,41 +147,112 @@ class QuickAddInlineHost extends StatefulWidget {
|
||||
|
||||
class _QuickAddInlineHostState extends State<QuickAddInlineHost> {
|
||||
final _controller = QuickAddInlineController();
|
||||
QuickAddBlockerController? _ownedBlocker;
|
||||
bool _wasOpen = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_wasOpen) {
|
||||
_controller.blocker?.pop();
|
||||
}
|
||||
_ownedBlocker?.dispose();
|
||||
_controller.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
|
||||
Widget build(BuildContext context) {
|
||||
return QuickAddInlineScope(
|
||||
final theme = Theme.of(context);
|
||||
final blocker = _controller.blocker;
|
||||
|
||||
Widget host = QuickAddInlineScope(
|
||||
controller: _controller,
|
||||
child: ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) {
|
||||
final form = _controller.form;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
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: form,
|
||||
),
|
||||
),
|
||||
],
|
||||
Widget buildColumn({required bool blockedByOther}) {
|
||||
final form = _controller.buildForm();
|
||||
final isOpen = form != null;
|
||||
if (isOpen && !_wasOpen) {
|
||||
_wasOpen = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
});
|
||||
} else if (!isOpen) {
|
||||
_wasOpen = false;
|
||||
}
|
||||
|
||||
final dimParent = isOpen || blockedByOther;
|
||||
|
||||
// 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({
|
||||
super.key,
|
||||
required this.title,
|
||||
@ -176,9 +337,22 @@ class SidePanelScaffold extends StatelessWidget {
|
||||
final Widget? footer;
|
||||
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) {
|
||||
if (onClose != null) {
|
||||
onClose!();
|
||||
if (widget.onClose != null) {
|
||||
widget.onClose!();
|
||||
return;
|
||||
}
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
@ -186,40 +360,77 @@ class SidePanelScaffold extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 12, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
return QuickAddBlockerScope(
|
||||
controller: _blocker,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListenableBuilder(
|
||||
listenable: _blocker,
|
||||
builder: (context, _) {
|
||||
final blocked = _blocker.isBlocked;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 12, 16),
|
||||
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),
|
||||
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 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
|
||||
Widget build(BuildContext context) {
|
||||
final dividerColor =
|
||||
@ -242,18 +469,25 @@ class SidePanelSection extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
QuickAddBlockable(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
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),
|
||||
...children,
|
||||
...children.map(_blockable),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
@ -275,36 +509,38 @@ class SidePanelFormRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuickAddInlineHost(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final stack = constraints.maxWidth < 420;
|
||||
child: QuickAddBlockable(
|
||||
child: LayoutBuilder(
|
||||
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(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
left,
|
||||
const SizedBox(height: 12),
|
||||
right,
|
||||
Expanded(child: left),
|
||||
SizedBox(width: spacing),
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return QuickAddInlineHost(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final contentPadding = EdgeInsets.fromLTRB(
|
||||
horizontalPadding,
|
||||
0,
|
||||
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],
|
||||
],
|
||||
],
|
||||
),
|
||||
child: QuickAddBlockable(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final contentPadding = EdgeInsets.fromLTRB(
|
||||
horizontalPadding,
|
||||
0,
|
||||
horizontalPadding,
|
||||
spacing,
|
||||
);
|
||||
}
|
||||
|
||||
if (columnCount != null) {
|
||||
final slots = _columnSlots();
|
||||
final cols = columnCount!;
|
||||
final innerWidth = constraints.maxWidth - (horizontalPadding * 2);
|
||||
final columnWidth = (innerWidth - (cols - 1) * spacing) / cols;
|
||||
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) {
|
||||
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(
|
||||
padding: contentPadding,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var i = 0; i < slots.length; i++) ...[
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
if (i > 0) SizedBox(width: spacing),
|
||||
SizedBox(
|
||||
width: _spanWidth(columnWidth, slots[i].span),
|
||||
child: slots[i].child,
|
||||
),
|
||||
Expanded(child: children[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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>{};
|
||||
|
||||
List<menu.MenuItem> get _primaryMenuItems => widget.menuItems
|
||||
.where((item) => !menu.supportMenuRoutes.contains(item.route))
|
||||
.where((item) => !menu.isSupportMenuItem(item))
|
||||
.toList();
|
||||
|
||||
List<menu.MenuItem> get _supportMenuItems => widget.menuItems
|
||||
.where((item) => menu.supportMenuRoutes.contains(item.route))
|
||||
.toList();
|
||||
/// Sidebar shows Support children flat under the SUPPORT section label.
|
||||
/// Top nav keeps the Support parent as a dropdown (via [menuItems] as-is).
|
||||
List<menu.MenuItem> get _supportMenuItems {
|
||||
final items = <menu.MenuItem>[];
|
||||
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 =>
|
||||
_supportMenuItems.isNotEmpty || AppConstants.showNotificationsMenu;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user