UI changes review

This commit is contained in:
Surendiran 2026-07-16 15:18:59 +05:30
parent 5a28cfc0da
commit 3914e78465
44 changed files with 1767 additions and 1393 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 244 KiB

View File

@ -0,0 +1,38 @@
// Shared helpers so entity lookup dropdowns omit inactive records.
// Do not use these for status-picker dropdowns (Active / Inactive choices).
const _inactiveStatuses = {
'inactive',
'locked',
'blacklisted',
'disabled',
};
/// Returns true when a raw API row should appear in a lookup dropdown.
bool isActiveOptionRow(Map<String, dynamic> item) {
if (item['is_active'] == false) return false;
if (item['isActive'] == false) return false;
final status = (item['status']?.toString() ?? '').trim().toLowerCase();
if (status.isEmpty) return true;
return !_inactiveStatuses.contains(status);
}
/// Active user suitable for manager / assignee dropdowns.
bool isActiveUserOption({
required String status,
required bool isActive,
}) {
return isActive && status.trim().toLowerCase() == 'active';
}
/// Active vendor suitable for PO / asset vendor dropdowns.
bool isActiveVendorOption({
required bool isActive,
String? status,
}) {
if (!isActive) return false;
final normalized = (status ?? '').trim().toLowerCase();
if (normalized.isEmpty) return true;
return !_inactiveStatuses.contains(normalized);
}

View File

@ -109,7 +109,9 @@ class AssetRemoteDataSource {
ApiEndpoints.itemCategories, ApiEndpoints.itemCategories,
queryParameters: const {'limit': 100, 'is_active': true}, queryParameters: const {'limit': 100, 'is_active': true},
); );
return _parseList(response.data, AssetCategoryModel.fromJson); return _parseList(response.data, AssetCategoryModel.fromJson)
.where((category) => category.isActive)
.toList();
} }
Future<AssetDropdownOptionsModel> getAssetOptions() async { Future<AssetDropdownOptionsModel> getAssetOptions() async {

View File

@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart'; import '../../../../core/constants/app_constants.dart';
import '../../../../core/network/api_handler.dart'; import '../../../../core/network/api_handler.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/purchase_order_model.dart';
@ -116,22 +117,41 @@ Future<List<FilterOptionModel>> _safeOptions(
Future<List<FilterOptionModel>> _safeVendorOptions(Ref ref) async { Future<List<FilterOptionModel>> _safeVendorOptions(Ref ref) async {
try { try {
final result = await ref.read(vendorRepositoryProvider).getVendors( final vendors = <FilterOptionModel>[];
const VendorListQuery( var page = 1;
page: 1, var totalPages = 1;
limit: AppConstants.maxPageSize,
status: 'active', while (page <= totalPages) {
), final result = await ref.read(vendorRepositoryProvider).getVendors(
); VendorListQuery(
if (result.failure != null || result.data == null) return const []; page: page,
return result.data!.items limit: AppConstants.maxPageSize,
.map( isActive: true,
(vendor) => FilterOptionModel( ),
id: vendor.id, );
name: vendor.vendorName, if (result.failure != null || result.data == null) return vendors;
),
) final data = result.data!;
.toList(); vendors.addAll(
data.items
.where(
(vendor) => isActiveVendorOption(
isActive: vendor.isActive,
status: vendor.status,
),
)
.map(
(vendor) => FilterOptionModel(
id: vendor.id,
name: vendor.vendorName,
),
),
);
totalPages = data.totalPages;
page++;
}
return vendors;
} catch (_) { } catch (_) {
return const []; return const [];
} }
@ -149,10 +169,12 @@ Future<List<FilterOptionModel>> _safeUserOptions(Ref ref) async {
); );
if (result.failure != null || result.data == null) return const []; if (result.failure != null || result.data == null) return const [];
return result.data!.items return result.data!.items
.where((user) { .where(
final status = user.status.trim().toLowerCase(); (user) => isActiveUserOption(
return status == 'active' && user.isActive; status: user.status,
}) isActive: user.isActive,
),
)
.map( .map(
(user) => FilterOptionModel( (user) => FilterOptionModel(
id: user.id, id: user.id,

View File

@ -18,6 +18,7 @@ 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_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/can_permission.dart';
@ -135,21 +136,15 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: LayoutBuilder( child: _AssetsFilterBar(
builder: (context, constraints) { query: state.query,
return _AssetsFilterBar( categories: allCategories,
wrapped: constraints.maxWidth < 1000, plants: allPlants,
query: state.query, statuses: lookups?.statuses ?? const [],
categories: allCategories, onSearch: notifier.setSearch,
plants: allPlants, onCategoryChanged: notifier.setCategoryFilter,
statuses: lookups?.statuses ?? const [], onPlantChanged: notifier.setPlantFilter,
onSearch: notifier.setSearch, onStatusChanged: notifier.setStatusFilter,
onCategoryChanged:
notifier.setCategoryFilter,
onPlantChanged: notifier.setPlantFilter,
onStatusChanged: notifier.setStatusFilter,
);
},
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
@ -275,7 +270,6 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
class _AssetsFilterBar extends StatelessWidget { class _AssetsFilterBar extends StatelessWidget {
const _AssetsFilterBar({ const _AssetsFilterBar({
required this.wrapped,
required this.query, required this.query,
required this.categories, required this.categories,
required this.plants, required this.plants,
@ -286,7 +280,6 @@ class _AssetsFilterBar extends StatelessWidget {
required this.onStatusChanged, required this.onStatusChanged,
}); });
final bool wrapped;
final AssetListQuery query; final AssetListQuery query;
final List<AssetCategoryModel> categories; final List<AssetCategoryModel> categories;
final List<FilterOptionModel> plants; final List<FilterOptionModel> plants;
@ -315,7 +308,7 @@ class _AssetsFilterBar extends StatelessWidget {
final categoryOptions = <AppDropdownOption<int?>>[ final categoryOptions = <AppDropdownOption<int?>>[
const AppDropdownOption(value: null, label: 'All Categories'), const AppDropdownOption(value: null, label: 'All Categories'),
for (final category in categories) for (final category in categories)
if (int.tryParse(category.id) != null) if (category.isActive && int.tryParse(category.id) != null)
AppDropdownOption( AppDropdownOption(
value: int.parse(category.id), value: int.parse(category.id),
label: category.name, label: category.name,
@ -371,29 +364,9 @@ class _AssetsFilterBar extends StatelessWidget {
), ),
]; ];
if (wrapped) { return AppResponsiveFilterBar(
return Column( search: searchField,
crossAxisAlignment: CrossAxisAlignment.stretch, filters: filters,
children: [
searchField,
const SizedBox(height: 12),
...filters.expand((f) => [f, const SizedBox(height: 12)]).toList()
..removeLast(),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[0]),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[1]),
const SizedBox(width: 12),
Expanded(child: filters[2]),
],
); );
} }
} }

View File

@ -12,6 +12,7 @@ import '../../../../shared/models/user_management_models.dart' show FilterOption
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_date_popup.dart'; import '../../../../shared/widgets/app_date_popup.dart';
import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
@ -880,10 +881,9 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SwitchListTile( AppFormToggleField(
contentPadding: EdgeInsets.zero, label: 'Active',
title: const Text('Active'), subtitle: 'Inactive assets are hidden from active lists',
subtitle: const Text('Inactive assets are hidden from active lists'),
value: _isActive, value: _isActive,
onChanged: (value) => setState(() => _isActive = value), onChanged: (value) => setState(() => _isActive = value),
), ),
@ -1079,7 +1079,9 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
} }
Widget _categoryDropdown(List<AssetCategoryModel> categories) { Widget _categoryDropdown(List<AssetCategoryModel> categories) {
final categoryIds = categories final activeCategories =
categories.where((category) => category.isActive).toList();
final categoryIds = activeCategories
.map((c) => int.tryParse(c.id)) .map((c) => int.tryParse(c.id))
.whereType<int>() .whereType<int>()
.toList(); .toList();
@ -1089,7 +1091,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
value: _dropdownValue(_categoryId, categoryIds), value: _dropdownValue(_categoryId, categoryIds),
searchHint: 'Search category...', searchHint: 'Search category...',
isDense: true, isDense: true,
options: categories options: activeCategories
.map( .map(
(c) => AppDropdownOption( (c) => AppDropdownOption(
value: int.tryParse(c.id) ?? 0, value: int.tryParse(c.id) ?? 0,
@ -1105,7 +1107,8 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
onChanged: (v) => setState(() { onChanged: (v) => setState(() {
_categoryId = v; _categoryId = v;
_subcategoryId = null; _subcategoryId = null;
final selectedCategory = categories.where((c) => int.tryParse(c.id) == v).firstOrNull; final selectedCategory =
activeCategories.where((c) => int.tryParse(c.id) == v).firstOrNull;
if (selectedCategory != null) { if (selectedCategory != null) {
if (selectedCategory.defaultDepreciationMethod != null && if (selectedCategory.defaultDepreciationMethod != null &&
selectedCategory.defaultDepreciationMethod!.trim().isNotEmpty) { selectedCategory.defaultDepreciationMethod!.trim().isNotEmpty) {

View File

@ -11,6 +11,7 @@ import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_date_popup.dart'; import '../../../../shared/widgets/app_date_popup.dart';
import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_text_field.dart';
@ -360,9 +361,8 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
maxLines: 3, maxLines: 3,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SwitchListTile( AppFormToggleField(
contentPadding: EdgeInsets.zero, label: 'Active',
title: const Text('Active'),
value: _isActive, value: _isActive,
onChanged: (value) => setState(() => _isActive = value), onChanged: (value) => setState(() => _isActive = value),
), ),
@ -804,9 +804,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
maxLines: 3, maxLines: 3,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SwitchListTile( AppFormToggleField(
contentPadding: EdgeInsets.zero, label: 'Under AMC',
title: const Text('Under AMC'),
value: _isUnderAmc, value: _isUnderAmc,
onChanged: (value) => setState(() => _isUnderAmc = value), onChanged: (value) => setState(() => _isUnderAmc = value),
), ),
@ -1151,21 +1150,18 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SwitchListTile( AppFormToggleField(
contentPadding: EdgeInsets.zero, label: 'Auto Renewal',
title: const Text('Auto Renewal'),
value: _isAutoRenewal, value: _isAutoRenewal,
onChanged: (value) => setState(() => _isAutoRenewal = value), onChanged: (value) => setState(() => _isAutoRenewal = value),
), ),
SwitchListTile( AppFormToggleField(
contentPadding: EdgeInsets.zero, label: 'Premium Paid',
title: const Text('Premium Paid'),
value: _premiumPaid, value: _premiumPaid,
onChanged: (value) => setState(() => _premiumPaid = value), onChanged: (value) => setState(() => _premiumPaid = value),
), ),
SwitchListTile( AppFormToggleField(
contentPadding: EdgeInsets.zero, label: 'Active',
title: const Text('Active'),
value: _isActive, value: _isActive,
onChanged: (value) => setState(() => _isActive = value), onChanged: (value) => setState(() => _isActive = value),
), ),

View File

@ -16,6 +16,8 @@ import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_filter_date_field.dart'; import '../../../../shared/widgets/app_filter_date_field.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
@ -151,24 +153,19 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
), ),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbar: LayoutBuilder( toolbar: _FiltersBar(
builder: (context, constraints) { searchController: _searchController,
return _FiltersBar( filters: state.filters,
searchController: _searchController, query: state.query,
filters: state.filters, onSearch: notifier.setSearch,
query: state.query, onTableChanged: notifier.setTableName,
wrapped: constraints.maxWidth < 1100, onActionChanged: notifier.setAction,
onSearch: notifier.setSearch, onPerformerChanged: notifier.setPerformedBy,
onTableChanged: notifier.setTableName, onPickDateRange: () => _pickDateRange(state.query),
onActionChanged: notifier.setAction, onClearDateRange: () => notifier.setDateRange(null, null),
onPerformerChanged: notifier.setPerformedBy, onReset: () {
onPickDateRange: () => _pickDateRange(state.query), _searchController.clear();
onClearDateRange: () => notifier.setDateRange(null, null), notifier.resetFilters();
onReset: () {
_searchController.clear();
notifier.resetFilters();
},
);
}, },
), ),
footer: AppPagination( footer: AppPagination(
@ -237,7 +234,6 @@ class _FiltersBar extends StatelessWidget {
required this.searchController, required this.searchController,
required this.filters, required this.filters,
required this.query, required this.query,
required this.wrapped,
required this.onSearch, required this.onSearch,
required this.onTableChanged, required this.onTableChanged,
required this.onActionChanged, required this.onActionChanged,
@ -250,7 +246,6 @@ class _FiltersBar extends StatelessWidget {
final TextEditingController searchController; final TextEditingController searchController;
final AuditLogFilterOptions filters; final AuditLogFilterOptions filters;
final AuditLogListQuery query; final AuditLogListQuery query;
final bool wrapped;
final ValueChanged<String> onSearch; final ValueChanged<String> onSearch;
final ValueChanged<String?> onTableChanged; final ValueChanged<String?> onTableChanged;
final ValueChanged<String?> onActionChanged; final ValueChanged<String?> onActionChanged;
@ -270,24 +265,11 @@ class _FiltersBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final searchField = TextField( final searchField = AppSearchField(
controller: searchController, controller: searchController,
hint: 'Search table, action, request ID...',
onChanged: onSearch, onChanged: onSearch,
decoration: InputDecoration( onClear: () => onSearch(''),
labelText: 'Search',
hintText: 'Search table, action, request ID...',
prefixIcon: const Icon(Icons.search),
isDense: true,
suffixIcon: searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
onSearch('');
},
)
: null,
),
); );
final tableDropdown = AppSearchableDropdown<String?>( final tableDropdown = AppSearchableDropdown<String?>(
@ -350,51 +332,17 @@ class _FiltersBar extends StatelessWidget {
child: const Text('Reset'), child: const Text('Reset'),
); );
if (wrapped) { return AppResponsiveFilterGrid(
return Column( fields: [
crossAxisAlignment: CrossAxisAlignment.stretch, searchField,
children: [ tableDropdown,
searchField, actionDropdown,
const SizedBox(height: 12), performerDropdown,
tableDropdown, dateField,
const SizedBox(height: 12), Align(
actionDropdown, alignment: Alignment.centerRight,
const SizedBox(height: 12), child: resetButton,
performerDropdown, ),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: dateField),
resetButton,
],
),
],
);
}
Widget row(List<Widget> cells) {
assert(cells.length == 4);
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < cells.length; i++) ...[
if (i > 0) const SizedBox(width: 12),
Expanded(child: cells[i]),
],
],
);
}
return Column(
children: [
row([searchField, tableDropdown, actionDropdown, performerDropdown]),
const SizedBox(height: 12),
row([
dateField,
const SizedBox.shrink(),
const SizedBox.shrink(),
Align(alignment: Alignment.centerRight, child: resetButton),
]),
], ],
); );
} }

View File

@ -44,6 +44,9 @@ class _DetailBody extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasOld = detail.oldValue != null || detail.hasOldValue;
final hasNew = detail.newValue != null || detail.hasNewValue;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -69,20 +72,7 @@ class _DetailBody extends StatelessWidget {
_DetailRow(label: 'Request ID', value: detail.requestId ?? ''), _DetailRow(label: 'Request ID', value: detail.requestId ?? ''),
], ],
), ),
if (detail.oldValue != null || detail.hasOldValue) if (hasOld && hasNew)
_ReadableValueSection(
title: 'OLD VALUE',
value: detail.oldValue,
emptyLabel: 'No previous value recorded',
),
if (detail.newValue != null || detail.hasNewValue)
_ReadableValueSection(
title: 'NEW VALUE',
value: detail.newValue,
emptyLabel: 'No new value recorded',
),
if ((detail.oldValue != null || detail.hasOldValue) &&
(detail.newValue != null || detail.hasNewValue))
_ChangedFieldsSection( _ChangedFieldsSection(
oldValue: detail.oldValue, oldValue: detail.oldValue,
newValue: detail.newValue, newValue: detail.newValue,
@ -135,56 +125,6 @@ class _DetailRow extends StatelessWidget {
} }
} }
class _ReadableValueSection extends StatelessWidget {
const _ReadableValueSection({
required this.title,
required this.value,
required this.emptyLabel,
});
final String title;
final Map<String, dynamic>? value;
final String emptyLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final rows = value == null ? const <_FieldRow>[] : _flattenFields(value!);
return SidePanelSection(
title: title,
children: [
if (rows.isEmpty)
Text(
emptyLabel,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
else
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(14, 14, 14, 2),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.12),
),
),
child: Column(
children: [
for (final row in rows)
_DetailRow(label: row.label, value: row.value),
],
),
),
],
);
}
}
class _ChangedFieldsSection extends StatelessWidget { class _ChangedFieldsSection extends StatelessWidget {
const _ChangedFieldsSection({ const _ChangedFieldsSection({
required this.oldValue, required this.oldValue,
@ -238,7 +178,10 @@ class _ChangedFieldsSection extends StatelessWidget {
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 18), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 18,
),
child: Icon( child: Icon(
Icons.arrow_forward, Icons.arrow_forward,
size: 16, size: 16,
@ -385,7 +328,10 @@ String? _nestedSummary(Map<String, dynamic> nested) {
if (nested.length <= 2) { if (nested.length <= 2) {
return nested.entries return nested.entries
.map((e) => '${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}') .map(
(e) =>
'${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}',
)
.join(', '); .join(', ');
} }

View File

@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart'; import '../../../../core/constants/app_constants.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart'; import '../../../masters/data/datasources/master_remote_data_source.dart';
@ -69,10 +70,12 @@ Future<List<FilterOptionModel>> _safeUserOptions(Ref ref) async {
); );
if (result.failure != null || result.data == null) return const []; if (result.failure != null || result.data == null) return const [];
return result.data!.items return result.data!.items
.where((user) { .where(
final status = user.status.trim().toLowerCase(); (user) => isActiveUserOption(
return status == 'active' && user.isActive; status: user.status,
}) isActive: user.isActive,
),
)
.map( .map(
(user) => FilterOptionModel( (user) => FilterOptionModel(
id: user.id, id: user.id,

View File

@ -318,7 +318,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
: const AsyncData<GrnModel?>(null); : const AsyncData<GrnModel?>(null);
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor, backgroundColor: Theme.of(context).colorScheme.surface,
body: lookupsAsync.when( body: lookupsAsync.when(
skipLoadingOnReload: true, skipLoadingOnReload: true,
loading: () => lookupsAsync.hasValue loading: () => lookupsAsync.hasValue
@ -388,236 +388,230 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
return SingleChildScrollView( return SingleChildScrollView(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Align( child: Form(
alignment: Alignment.topCenter, key: _formKey,
child: ConstrainedBox( child: Column(
constraints: const BoxConstraints(maxWidth: 1200), crossAxisAlignment: CrossAxisAlignment.stretch,
child: Form( children: [
key: _formKey, _buildHeader(existing),
child: Column( const SizedBox(height: 16),
crossAxisAlignment: CrossAxisAlignment.stretch, _SectionCard(
children: [ title: 'RECEIPT DETAILS',
_buildHeader(existing), child: Column(
const SizedBox(height: 16),
_SectionCard(
title: 'RECEIPT DETAILS',
child: Column(
children: [
FormRowFour(
children: [ children: [
_DateField( FormRowFour(
label: 'GRN Date *', children: [
value: _grnDate, _DateField(
enabled: !widget.isEditing, label: 'GRN Date *',
onTap: widget.isEditing value: _grnDate,
? null enabled: !widget.isEditing,
: () => _pickDate( onTap: widget.isEditing
current: _grnDate, ? null
onPicked: (d) => : () => _pickDate(
setState(() => _grnDate = d), current: _grnDate,
), onPicked: (d) =>
), setState(() => _grnDate = d),
if (!widget.isEditing) ),
AppSearchableDropdown<String>( ),
label: 'Purchase Order *', if (!widget.isEditing)
value: _selectedPoId, AppSearchableDropdown<String>(
hint: 'Select PO', label: 'Purchase Order *',
searchHint: 'Search PO...', value: _selectedPoId,
options: poOptions, hint: 'Select PO',
onChanged: (v) async { searchHint: 'Search PO...',
setState(() => _selectedPoId = v); options: poOptions,
if (v == null) { onChanged: (v) async {
for (final line in _lines) { setState(() => _selectedPoId = v);
line.dispose(); if (v == null) {
} for (final line in _lines) {
setState(() => _lines.clear()); line.dispose();
return; }
} setState(() => _lines.clear());
try { return;
final po = await ref.read( }
grnPurchaseOrderProvider(v).future, try {
); final po = await ref.read(
if (mounted && po != null) { grnPurchaseOrderProvider(v).future,
_loadLinesFromPo(po); );
} if (mounted && po != null) {
} catch (e) { _loadLinesFromPo(po);
if (!mounted) return; }
showAppToastFromSnackBar(context, } catch (e) {
SnackBar(content: Text(e.toString())), if (!mounted) return;
); showAppToastFromSnackBar(context,
} SnackBar(content: Text(e.toString())),
}, );
validator: (v) => v == null }
? 'Purchase Order Is Required' },
: null, validator: (v) => v == null
) ? 'Purchase Order Is Required'
else : null,
_ReadOnlyField( )
label: 'Purchase Order', else
value: existing?.poNumber ?? '', _ReadOnlyField(
), label: 'Purchase Order',
MasterQuickAddDropdown<int>( value: existing?.poNumber ?? '',
masterId: 'warehouses', ),
label: 'Warehouse *', MasterQuickAddDropdown<int>(
value: _dropdownValue(_warehouseId, warehouseIds), masterId: 'warehouses',
hint: 'Select warehouse', label: 'Warehouse *',
searchHint: 'Search warehouse...', value: _dropdownValue(_warehouseId, warehouseIds),
options: _intOptions(lookups.warehouses), hint: 'Select warehouse',
refreshLookups: () => searchHint: 'Search warehouse...',
ref.invalidate(grnLookupsProvider), options: _intOptions(lookups.warehouses),
parseCreatedId: int.tryParse, refreshLookups: () =>
onChanged: widget.isEditing ref.invalidate(grnLookupsProvider),
? (_) {} parseCreatedId: int.tryParse,
: (v) => setState(() => _warehouseId = v), onChanged: widget.isEditing
validator: widget.isEditing ? (_) {}
? null : (v) => setState(() => _warehouseId = v),
: (v) => validator: widget.isEditing
v == null ? 'Warehouse is required' : null, ? null
enabled: !widget.isEditing, : (v) =>
), v == null ? 'Warehouse is required' : null,
AppTextField( enabled: !widget.isEditing,
label: 'Vendor Invoice No', ),
controller: _vendorInvoiceNoController, AppTextField(
), label: 'Vendor Invoice No',
], controller: _vendorInvoiceNoController,
),
FormRowFour(
children: [
_DateField(
label: 'Vendor Invoice Date',
value: _vendorInvoiceDate,
onTap: () => _pickDate(
current: _vendorInvoiceDate,
onPicked: (d) =>
setState(() => _vendorInvoiceDate = d),
),
),
AppTextField(
label: 'Vendor Invoice Amount',
controller: _vendorInvoiceAmountController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d*\.?\d{0,2}'),
), ),
], ],
validator: (v) => Validators.optionalNonNegativeDouble(
v,
fieldName: 'Vendor Invoice Amount',
),
), ),
AppTextField( FormRowFour(
label: 'Vehicle No', children: [
controller: _vehicleNoController, _DateField(
label: 'Vendor Invoice Date',
value: _vendorInvoiceDate,
onTap: () => _pickDate(
current: _vendorInvoiceDate,
onPicked: (d) =>
setState(() => _vendorInvoiceDate = d),
),
),
AppTextField(
label: 'Vendor Invoice Amount',
controller: _vendorInvoiceAmountController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d*\.?\d{0,2}'),
),
],
validator: (v) => Validators.optionalNonNegativeDouble(
v,
fieldName: 'Vendor Invoice Amount',
),
),
AppTextField(
label: 'Vehicle No',
controller: _vehicleNoController,
),
AppTextField(
label: 'LR No',
controller: _lrNoController,
),
],
), ),
AppTextField( FormRowFour(
label: 'LR No', children: [
controller: _lrNoController, _DateField(
label: 'LR Date',
value: _lrDate,
onTap: () => _pickDate(
current: _lrDate,
onPicked: (d) => setState(() => _lrDate = d),
),
),
AppSearchableDropdown<int>(
label: 'Received By',
value: _dropdownValue(
_normalizeUserId(_receivedById),
userIds,
),
hint: 'Select user',
searchHint: 'Search user...',
options: _intOptions(lookups.users),
onChanged: (v) =>
setState(() => _receivedById = v),
),
AppSearchableDropdown<int>(
label: 'Quality Checked By',
value: _dropdownValue(
_normalizeUserId(_qualityCheckedById),
userIds,
),
hint: 'Select user',
searchHint: 'Search user...',
options: _intOptions(lookups.users),
onChanged: (v) =>
setState(() => _qualityCheckedById = v),
),
const SizedBox.shrink(),
],
), ),
], ],
), ),
FormRowFour( ),
children: [ const SizedBox(height: 16),
_DateField( if (!widget.isEditing)
label: 'LR Date', GrnLineItemsEditor(
value: _lrDate, items: _lines,
onTap: () => _pickDate( onChanged: () => setState(() {}),
current: _lrDate, )
onPicked: (d) => setState(() => _lrDate = d), else ...[
_SectionCard(
title: 'LINE ITEMS · ${existing?.items.length ?? 0}',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Line items cannot be changed after posting.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
), ),
), if (existing?.items.isNotEmpty == true) ...[
AppSearchableDropdown<int>( const SizedBox(height: 12),
label: 'Received By', GrnItemsTable(items: existing!.items),
value: _dropdownValue( ],
_normalizeUserId(_receivedById), ],
userIds, ),
),
hint: 'Select user',
searchHint: 'Search user...',
options: _intOptions(lookups.users),
onChanged: (v) =>
setState(() => _receivedById = v),
),
AppSearchableDropdown<int>(
label: 'Quality Checked By',
value: _dropdownValue(
_normalizeUserId(_qualityCheckedById),
userIds,
),
hint: 'Select user',
searchHint: 'Search user...',
options: _intOptions(lookups.users),
onChanged: (v) =>
setState(() => _qualityCheckedById = v),
),
const SizedBox.shrink(),
],
), ),
], ],
), const SizedBox(height: 16),
), _SectionCard(
const SizedBox(height: 16), title: 'ADDITIONAL DETAILS',
if (!widget.isEditing) child: AppTextField(
GrnLineItemsEditor( controller: _remarksController,
items: _lines, label: 'Remarks',
onChanged: () => setState(() {}), hint: 'Any additional notes for this receipt.',
) maxLines: 4,
else ...[ ),
_SectionCard( ),
title: 'LINE ITEMS · ${existing?.items.length ?? 0}', const SizedBox(height: 20),
child: Column( Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
'Line items cannot be changed after posting.', 'Fields marked * are required',
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
Text(
widget.isEditing
? '${existing?.items.length ?? 0} line item${(existing?.items.length ?? 0) == 1 ? '' : 's'} · editing header only'
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
if (existing?.items.isNotEmpty == true) ...[
const SizedBox(height: 12),
GrnItemsTable(items: existing!.items),
],
], ],
), ),
),
],
const SizedBox(height: 16),
_SectionCard(
title: 'ADDITIONAL DETAILS',
child: AppTextField(
controller: _remarksController,
label: 'Remarks',
hint: 'Any additional notes for this receipt.',
maxLines: 4,
),
),
const SizedBox(height: 20),
Row(
children: [
Text(
'Fields marked * are required',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
Text(
widget.isEditing
? '${existing?.items.length ?? 0} line item${(existing?.items.length ?? 0) == 1 ? '' : 's'} · editing header only'
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
], ],
), ),
],
),
),
),
), ),
); );
} }

View File

@ -15,6 +15,7 @@ import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_action_icon.dart';
@ -83,20 +84,15 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
), ),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbar: LayoutBuilder( toolbar: _FiltersBar(
builder: (context, constraints) { searchController: _searchController,
return _FiltersBar( query: state.query,
searchController: _searchController, showExport: canExport,
query: state.query, isExporting: state.isExporting,
wrapped: constraints.maxWidth < 900, onExport: _exportGrns,
showExport: canExport, onSearch: ref.read(grnListProvider.notifier).setSearch,
isExporting: state.isExporting, onStatusChanged:
onExport: _exportGrns, ref.read(grnListProvider.notifier).setStatusFilter,
onSearch: ref.read(grnListProvider.notifier).setSearch,
onStatusChanged:
ref.read(grnListProvider.notifier).setStatusFilter,
);
},
), ),
footer: AppPagination( footer: AppPagination(
currentPage: state.query.page, currentPage: state.query.page,
@ -191,7 +187,6 @@ class _FiltersBar extends StatelessWidget {
const _FiltersBar({ const _FiltersBar({
required this.searchController, required this.searchController,
required this.query, required this.query,
required this.wrapped,
required this.onSearch, required this.onSearch,
required this.onStatusChanged, required this.onStatusChanged,
this.showExport = false, this.showExport = false,
@ -201,7 +196,6 @@ class _FiltersBar extends StatelessWidget {
final TextEditingController searchController; final TextEditingController searchController;
final GrnListQuery query; final GrnListQuery query;
final bool wrapped;
final ValueChanged<String> onSearch; final ValueChanged<String> onSearch;
final ValueChanged<String?> onStatusChanged; final ValueChanged<String?> onStatusChanged;
final bool showExport; final bool showExport;
@ -212,25 +206,21 @@ class _FiltersBar extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final searchField = Padding( final searchField = Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: SizedBox( child: TextField(
width: wrapped ? double.infinity : null, controller: searchController,
child: TextField( onChanged: onSearch,
controller: searchController, decoration: const InputDecoration(
onChanged: onSearch, labelText: 'Search',
decoration: const InputDecoration( hintText: 'Search GRN number, PO, vendor...',
labelText: 'Search', prefixIcon: Icon(Icons.search, size: 20),
hintText: 'Search GRN number, PO, vendor...', floatingLabelBehavior: FloatingLabelBehavior.always,
prefixIcon: Icon(Icons.search, size: 20), isDense: true,
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
),
), ),
), ),
); );
final statusFilter = SizedBox( final filters = [
width: wrapped ? double.infinity : 180, AppSearchableDropdown<String?>(
child: AppSearchableDropdown<String?>(
label: 'Status', label: 'Status',
value: query.status, value: query.status,
searchHint: 'Search status...', searchHint: 'Search status...',
@ -243,46 +233,24 @@ class _FiltersBar extends StatelessWidget {
], ],
onChanged: onStatusChanged, onChanged: onStatusChanged,
), ),
); ];
final exportButton = OutlinedButton.icon( return AppResponsiveFilterBar(
onPressed: isExporting ? null : onExport, search: searchField,
icon: isExporting filters: filters,
? const SizedBox( trailing: showExport
width: 18, ? OutlinedButton.icon(
height: 18, onPressed: isExporting ? null : onExport,
child: CircularProgressIndicator(strokeWidth: 2), icon: isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
) )
: const Icon(Icons.download_outlined, size: 18), : null,
label: Text(isExporting ? 'Exporting...' : 'Export'),
);
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
statusFilter,
if (showExport) ...[
const SizedBox(height: 12),
Align(alignment: Alignment.centerRight, child: exportButton),
],
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: statusFilter),
if (showExport) ...[
const SizedBox(width: 12),
exportButton,
],
],
); );
} }
} }

View File

@ -664,11 +664,6 @@ class _GrnItemsHeader extends StatelessWidget {
child: Text('CURRENT', style: style, textAlign: TextAlign.right), child: Text('CURRENT', style: style, textAlign: TextAlign.right),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
SizedBox(
width: 80,
child: Text('RATE', style: style, textAlign: TextAlign.right),
),
const SizedBox(width: 12),
SizedBox(width: 90, child: Text('BATCH', style: style)), SizedBox(width: 90, child: Text('BATCH', style: style)),
const SizedBox(width: 12), const SizedBox(width: 12),
SizedBox(width: 90, child: Text('MFG', style: style)), SizedBox(width: 90, child: Text('MFG', style: style)),
@ -714,11 +709,38 @@ class _GrnItemRow extends StatelessWidget {
), ),
Expanded( Expanded(
flex: 3, flex: 3,
child: Text( child: Column(
item.itemName ?? item.itemCode ?? '', crossAxisAlignment: CrossAxisAlignment.start,
style: strong, children: [
maxLines: 2, Text(
overflow: TextOverflow.ellipsis, item.itemName ?? item.itemCode ?? '',
style: strong,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (item.remarks?.trim().isNotEmpty == true) ...[
const SizedBox(height: 4),
Text(
item.remarks!.trim(),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
if (item.rejectionReason?.trim().isNotEmpty == true) ...[
const SizedBox(height: 2),
Text(
'Reject: ${item.rejectionReason!.trim()}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@ -749,15 +771,6 @@ class _GrnItemRow extends StatelessWidget {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
SizedBox(
width: 80,
child: Text(
item.rate != null ? _formatQty(item.rate!) : '',
style: body,
textAlign: TextAlign.right,
),
),
const SizedBox(width: 12),
SizedBox( SizedBox(
width: 90, width: 90,
child: Text( child: Text(

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart'; import '../../../../core/constants/app_constants.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
import '../../domain/entities/master_definition.dart'; import '../../domain/entities/master_definition.dart';
@ -41,6 +42,7 @@ class MasterCrudRemoteDataSource {
int page = 1, int page = 1,
int limit = 20, int limit = 20,
String? search, String? search,
bool? isActive,
}) async { }) async {
final response = await dio.get( final response = await dio.get(
definition.apiPath, definition.apiPath,
@ -48,6 +50,7 @@ class MasterCrudRemoteDataSource {
'page': page, 'page': page,
'limit': limit, 'limit': limit,
if (search != null && search.isNotEmpty) 'search': search, if (search != null && search.isNotEmpty) 'search': search,
if (isActive != null) 'is_active': isActive,
}, },
); );
@ -85,10 +88,9 @@ class MasterCrudRemoteDataSource {
definition, definition,
page: page, page: page,
limit: AppConstants.defaultPageSize, limit: AppConstants.defaultPageSize,
isActive: true,
); );
allItems.addAll( allItems.addAll(result.items.where(isActiveOptionRow));
result.items.where((item) => item['is_active'] != false),
);
if (page >= result.totalPages) break; if (page >= result.totalPages) break;
page++; page++;
} }

View File

@ -189,18 +189,13 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
), ),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbar: LayoutBuilder( toolbar: AppSearchExportBar(
builder: (context, constraints) { searchController: _searchController,
return AppSearchExportBar( searchHint: _searchHint(def),
wrapped: constraints.maxWidth < 640, isExporting: state.isExporting,
searchController: _searchController, showExport: canExport,
searchHint: _searchHint(def), onSearch: notifier.setSearch,
isExporting: state.isExporting, onExport: _exportRecords,
showExport: canExport,
onSearch: notifier.setSearch,
onExport: _exportRecords,
);
},
), ),
footer: AppPagination( footer: AppPagination(
currentPage: state.page, currentPage: state.page,

View File

@ -5,6 +5,7 @@ import '../../../../core/errors/failure.dart';
import '../../../../core/utils/validators.dart'; import '../../../../core/utils/validators.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
@ -112,9 +113,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
switch (field.type) { switch (field.type) {
case MasterFieldType.boolean: case MasterFieldType.boolean:
if (field.key == 'is_active') { if (field.key == 'is_active') {
return SwitchListTile( return AppFormToggleField(
contentPadding: EdgeInsets.zero, label: field.label,
title: Text(field.label),
value: value == true, value: value == true,
onChanged: (checked) => notifier.updateValue(field.key, checked), onChanged: (checked) => notifier.updateValue(field.key, checked),
); );

View File

@ -5,6 +5,7 @@ import '../../../../core/errors/failure.dart';
import '../../../../core/utils/validators.dart'; import '../../../../core/utils/validators.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
@ -103,10 +104,8 @@ class _MasterInlineCreateFormState
switch (field.type) { switch (field.type) {
case MasterFieldType.boolean: case MasterFieldType.boolean:
if (field.key == 'is_active') { if (field.key == 'is_active') {
return SwitchListTile( return AppFormToggleField(
contentPadding: EdgeInsets.zero, label: field.label,
dense: true,
title: Text(field.label),
value: value == true, value: value == true,
onChanged: (checked) => notifier.updateValue(field.key, checked), onChanged: (checked) => notifier.updateValue(field.key, checked),
); );
@ -363,20 +362,38 @@ class _MasterInlineCreateFormState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final quickAddBackground = isDark
? theme.colorScheme.primary.withValues(alpha: 0.16)
: theme.colorScheme.primary.withValues(alpha: 0.08);
final quickAddBorder = isDark
? theme.colorScheme.primary.withValues(alpha: 0.65)
: theme.colorScheme.primary.withValues(alpha: 0.35);
final quickAddGlow = isDark
? theme.colorScheme.primary.withValues(alpha: 0.22)
: theme.colorScheme.primary.withValues(alpha: 0.12);
final formAsync = ref.watch(masterFormProvider(_args)); final formAsync = ref.watch(masterFormProvider(_args));
final def = _definition; final def = _definition;
final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false; final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
return Material( return Material(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), color: quickAddBackground,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.none, clipBehavior: Clip.none,
child: Ink( child: Ink(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.28), color: quickAddBorder,
width: 1.2,
), ),
boxShadow: [
BoxShadow(
color: quickAddGlow,
blurRadius: 12,
offset: const Offset(0, 2),
),
],
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
@ -447,29 +464,40 @@ class _MasterInlineCreateFormState
formState: formState, formState: formState,
twoColumns: useTwoColumns, twoColumns: useTwoColumns,
), ),
if (active != null) ...[
const SizedBox(height: 4),
_buildField(
field: active,
formState: formState,
),
],
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
TextButton( if (active != null)
onPressed: Expanded(
isSubmitting ? null : widget.onCancel, child: Align(
child: const Text('Cancel'), alignment: Alignment.centerLeft,
), child: _buildField(
const SizedBox(width: 8), field: active,
AppButton( formState: formState,
label: 'Add ${def.title}', ),
expand: false, ),
icon: Icons.check, )
isLoading: isSubmitting, else
onPressed: isSubmitting ? null : _submit, const Spacer(),
const SizedBox(width: 12),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed:
isSubmitting ? null : widget.onCancel,
child: const Text('Cancel'),
),
const SizedBox(width: 8),
AppButton(
label: 'Add ${def.title}',
expand: false,
icon: Icons.check,
isLoading: isSubmitting,
onPressed: isSubmitting ? null : _submit,
),
],
), ),
], ],
), ),

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -47,7 +49,9 @@ class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
final bool enabled; final bool enabled;
final bool isDense; final bool isDense;
final Map<String, dynamic>? initialValues; final Map<String, dynamic>? initialValues;
final VoidCallback? refreshLookups; /// Called after create succeeds; awaited before [onChanged] so callers can
/// reload dependent lookups (e.g. item UOM / GST defaults).
final FutureOr<void> Function()? refreshLookups;
final String? addNewLabel; final String? addNewLabel;
@override @override
@ -114,8 +118,14 @@ class _MasterQuickAddDropdownState<T>
}); });
} }
void _onSaved(String createdId) { Future<void> _onSaved(String createdId) async {
widget.refreshLookups?.call(); try {
final refresh = widget.refreshLookups;
if (refresh != null) await refresh();
} catch (_) {
// Still select the created row even if lookup refresh fails.
}
if (!mounted) return;
if (createdId != 'created') { if (createdId != 'created') {
final parsed = widget.parseCreatedId(createdId); final parsed = widget.parseCreatedId(createdId);
@ -127,7 +137,8 @@ class _MasterQuickAddDropdownState<T>
_collapse(); _collapse();
if (!mounted) return; if (!mounted) return;
showAppToastFromSnackBar(context, showAppToastFromSnackBar(
context,
SnackBar( SnackBar(
content: content:
Text('${_titleCase(masterQuickAddNoun(widget.masterId))} added'), Text('${_titleCase(masterQuickAddNoun(widget.masterId))} added'),

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/constants/app_constants.dart'; import '../../../../core/constants/app_constants.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
final masterRemoteDataSourceProvider = Provider<MasterRemoteDataSource>((ref) { final masterRemoteDataSourceProvider = Provider<MasterRemoteDataSource>((ref) {
@ -65,7 +66,7 @@ class MasterRemoteDataSource {
final options = <FilterOptionModel>[]; final options = <FilterOptionModel>[];
for (final item in rows) { for (final item in rows) {
if (item['is_active'] == false) continue; if (!isActiveOptionRow(item)) continue;
final id = item['id']?.toString() ?? ''; final id = item['id']?.toString() ?? '';
if (id.isEmpty) continue; if (id.isEmpty) continue;
@ -104,7 +105,7 @@ class MasterRemoteDataSource {
final pctById = <String, double>{}; final pctById = <String, double>{};
for (final item in rows) { for (final item in rows) {
if (item['is_active'] == false) continue; if (!isActiveOptionRow(item)) continue;
final id = item['id']?.toString() ?? ''; final id = item['id']?.toString() ?? '';
if (id.isEmpty) continue; if (id.isEmpty) continue;
@ -135,7 +136,7 @@ class MasterRemoteDataSource {
); );
final categoryFilter = itemCategoryId?.toString(); final categoryFilter = itemCategoryId?.toString();
return rows return rows
.where((item) => item['is_active'] != false) .where(isActiveOptionRow)
.where( .where(
(item) => (item) =>
categoryFilter == null || categoryFilter == null ||
@ -157,7 +158,7 @@ class MasterRemoteDataSource {
queryParameters: {'is_active': true}, queryParameters: {'is_active': true},
); );
return rows return rows
.where((item) => item['is_active'] != false) .where(isActiveOptionRow)
.map( .map(
(item) => FilterOptionModel( (item) => FilterOptionModel(
id: item['id']?.toString() ?? '', id: item['id']?.toString() ?? '',

View File

@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart'; import '../../../../core/constants/app_constants.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/models/vendor_model.dart'; import '../../../../shared/models/vendor_model.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart'; import '../../../masters/data/datasources/master_remote_data_source.dart';
@ -157,11 +158,12 @@ Future<List<FilterOptionModel>> _fetchActiveVendors(
final data = result.data!; final data = result.data!;
vendors.addAll( vendors.addAll(
data.items data.items
.where((vendor) { .where(
if (!vendor.isActive) return false; (vendor) => isActiveVendorOption(
final status = (vendor.status ?? '').trim().toLowerCase(); isActive: vendor.isActive,
return status != 'blacklisted'; status: vendor.status,
}) ),
)
.map( .map(
(vendor) => (vendor) =>
FilterOptionModel(id: vendor.id, name: vendor.vendorName), FilterOptionModel(id: vendor.id, name: vendor.vendorName),

View File

@ -366,243 +366,238 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
return SingleChildScrollView( return SingleChildScrollView(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Align( child: Form(
alignment: Alignment.topCenter, key: _formKey,
child: ConstrainedBox( child: Column(
constraints: const BoxConstraints(maxWidth: 1200), crossAxisAlignment: CrossAxisAlignment.stretch,
child: Form( children: [
key: _formKey, _buildHeader(existing),
child: Column( if (_showReapprovalWarning(existing)) ...[
crossAxisAlignment: CrossAxisAlignment.stretch, const SizedBox(height: 8),
children: [ _ReapprovalBanner(),
_buildHeader(existing),
if (_showReapprovalWarning(existing)) ...[
const SizedBox(height: 8),
_ReapprovalBanner(),
],
const SizedBox(height: 16),
_SectionCard(
title: 'ORDER DETAILS',
child: Column(
children: [
FormRowFour(
children: [
_DateField(
label: 'PO Date *',
value: _poDate,
onTap: () => _pickDate(
current: _poDate,
onPicked: (d) => setState(() => _poDate = d),
),
),
AppSearchableDropdown<String>(
label: 'PO Type *',
value: _poType,
hint: 'Select PO type',
searchHint: 'Search type...',
options: poTypeOptions
.map(
(e) => AppDropdownOption(
value: e.$1,
label: e.$2,
),
)
.toList(),
onChanged: (v) => setState(() => _poType = v),
validator: (v) =>
v == null ? 'PO type is required' : null,
),
AppSearchableDropdown<int>(
label: 'Vendor *',
value: _dropdownValue(_vendorId, vendorIds),
hint: 'Select vendor',
searchHint: 'Search vendor...',
options: _intOptions(lookups.vendors),
onChanged: (v) => setState(() => _vendorId = v),
validator: (v) =>
v == null ? 'Vendor is required' : null,
),
MasterQuickAddDropdown<int>(
masterId: 'plants',
label: 'Plant *',
value: _dropdownValue(_plantId, plantIds),
hint: 'Select plant',
searchHint: 'Search plant...',
options: _intOptions(lookups.plants),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _plantId = v),
validator: (v) =>
v == null ? 'Plant is required' : null,
),
],
),
FormRowFour(
children: [
MasterQuickAddDropdown<int?>(
masterId: 'warehouses',
label: 'Warehouse',
value: _warehouseId,
hint: 'Select warehouse',
searchHint: 'Search warehouse...',
options: _nullableIntOptions(lookups.warehouses),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _warehouseId = v),
),
MasterQuickAddDropdown<int?>(
masterId: 'brands',
label: 'Brand',
value: _brandId,
hint: 'Select brand',
searchHint: 'Search brand...',
options: _nullableIntOptions(lookups.brands),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _brandId = v),
),
MasterQuickAddDropdown<int?>(
masterId: 'payment_terms',
label: 'Payment Term',
value: _paymentTermId,
hint: 'Select payment term',
searchHint: 'Search payment term...',
options:
_nullableIntOptions(lookups.paymentTerms),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _paymentTermId = v),
),
MasterQuickAddDropdown<int?>(
masterId: 'delivery_terms',
label: 'Delivery Term',
value: _deliveryTermId,
hint: 'Select delivery term',
searchHint: 'Search delivery term...',
options:
_nullableIntOptions(lookups.deliveryTerms),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _deliveryTermId = v),
),
],
),
FormRow(
columnCount: 4,
children: [
_DateField(
label: 'Expected Delivery',
value: _expectedDeliveryDate,
onTap: () => _pickDate(
current: _expectedDeliveryDate,
onPicked: (d) => setState(
() => _expectedDeliveryDate = d,
),
),
),
],
),
], ],
), const SizedBox(height: 16),
), _SectionCard(
const SizedBox(height: 16), title: 'ORDER DETAILS',
PurchaseOrderLineItemsEditor(
lines: _lines,
items: lookups.items,
itemHsnById: lookups.itemHsnById,
itemUomById: lookups.itemUomById,
itemGstRateById: lookups.itemGstRateById,
uom: lookups.uom,
gstRates: lookups.gstRates,
gstRatePctById: lookups.gstRatePctById,
onAddLine: _addLine,
onRemoveLine: _removeLine,
onChanged: () => setState(() {}),
),
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 900;
final additional = _SectionCard(
title: 'ADDITIONAL DETAILS',
child: Column( child: Column(
children: [ children: [
AppTextField( FormRowFour(
controller: _termsController, children: [
label: 'Terms & Conditions', _DateField(
hint: 'Payment terms, inspection conditions, etc.', label: 'PO Date *',
maxLines: 5, value: _poDate,
onTap: () => _pickDate(
current: _poDate,
onPicked: (d) => setState(() => _poDate = d),
),
),
AppSearchableDropdown<String>(
label: 'PO Type *',
value: _poType,
hint: 'Select PO type',
searchHint: 'Search type...',
options: poTypeOptions
.map(
(e) => AppDropdownOption(
value: e.$1,
label: e.$2,
),
)
.toList(),
onChanged: (v) => setState(() => _poType = v),
validator: (v) =>
v == null ? 'PO type is required' : null,
),
AppSearchableDropdown<int>(
label: 'Vendor *',
value: _dropdownValue(_vendorId, vendorIds),
hint: 'Select vendor',
searchHint: 'Search vendor...',
options: _intOptions(lookups.vendors),
onChanged: (v) => setState(() => _vendorId = v),
validator: (v) =>
v == null ? 'Vendor is required' : null,
),
MasterQuickAddDropdown<int>(
masterId: 'plants',
label: 'Plant *',
value: _dropdownValue(_plantId, plantIds),
hint: 'Select plant',
searchHint: 'Search plant...',
options: _intOptions(lookups.plants),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _plantId = v),
validator: (v) =>
v == null ? 'Plant is required' : null,
),
],
), ),
AppTextField( FormRowFour(
controller: _remarksController, children: [
label: 'Remarks', MasterQuickAddDropdown<int?>(
hint: 'Any additional notes for this order.', masterId: 'warehouses',
maxLines: 4, label: 'Warehouse',
value: _warehouseId,
hint: 'Select warehouse',
searchHint: 'Search warehouse...',
options: _nullableIntOptions(lookups.warehouses),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _warehouseId = v),
),
MasterQuickAddDropdown<int?>(
masterId: 'brands',
label: 'Brand',
value: _brandId,
hint: 'Select brand',
searchHint: 'Search brand...',
options: _nullableIntOptions(lookups.brands),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _brandId = v),
),
MasterQuickAddDropdown<int?>(
masterId: 'payment_terms',
label: 'Payment Term',
value: _paymentTermId,
hint: 'Select payment term',
searchHint: 'Search payment term...',
options:
_nullableIntOptions(lookups.paymentTerms),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _paymentTermId = v),
),
MasterQuickAddDropdown<int?>(
masterId: 'delivery_terms',
label: 'Delivery Term',
value: _deliveryTermId,
hint: 'Select delivery term',
searchHint: 'Search delivery term...',
options:
_nullableIntOptions(lookups.deliveryTerms),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _deliveryTermId = v),
),
],
),
FormRow(
columnCount: 4,
children: [
_DateField(
label: 'Expected Delivery',
value: _expectedDeliveryDate,
onTap: () => _pickDate(
current: _expectedDeliveryDate,
onPicked: (d) => setState(
() => _expectedDeliveryDate = d,
),
),
),
],
), ),
], ],
), ),
);
final summary = _AmountSummaryCard(
totals: totals,
freightController: _freightController,
otherChargesController: _otherChargesController,
discountController: _discountController,
isEditing: widget.isEditing,
);
if (stack) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
additional,
const SizedBox(height: 16),
summary,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: additional),
const SizedBox(width: 16),
Expanded(flex: 2, child: summary),
],
);
},
),
const SizedBox(height: 20),
Row(
children: [
Text(
'Fields marked * are required',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
), ),
const Spacer(), const SizedBox(height: 16),
Text( PurchaseOrderLineItemsEditor(
widget.isEditing lines: _lines,
? '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · editing existing draft' items: lookups.items,
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved', itemHsnById: lookups.itemHsnById,
style: theme.textTheme.bodySmall?.copyWith( itemUomById: lookups.itemUomById,
color: theme.colorScheme.onSurfaceVariant, itemGstRateById: lookups.itemGstRateById,
), uom: lookups.uom,
gstRates: lookups.gstRates,
gstRatePctById: lookups.gstRatePctById,
onAddLine: _addLine,
onRemoveLine: _removeLine,
onChanged: () => setState(() {}),
),
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 900;
final additional = _SectionCard(
title: 'ADDITIONAL DETAILS',
child: Column(
children: [
AppTextField(
controller: _termsController,
label: 'Terms & Conditions',
hint:
'Payment terms, inspection conditions, etc.',
maxLines: 5,
),
AppTextField(
controller: _remarksController,
label: 'Remarks',
hint: 'Any additional notes for this order.',
maxLines: 4,
),
],
),
);
final summary = _AmountSummaryCard(
totals: totals,
freightController: _freightController,
otherChargesController: _otherChargesController,
discountController: _discountController,
isEditing: widget.isEditing,
);
if (stack) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
additional,
const SizedBox(height: 16),
summary,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: additional),
const SizedBox(width: 16),
Expanded(flex: 2, child: summary),
],
);
},
),
const SizedBox(height: 20),
Row(
children: [
Text(
'Fields marked * are required',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
Text(
widget.isEditing
? '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · editing existing draft'
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
), ),
], ],
), ),
],
),
),
),
), ),
); );
} }
@ -718,7 +713,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
final lookupsAsync = ref.watch(purchaseOrderLookupsProvider); final lookupsAsync = ref.watch(purchaseOrderLookupsProvider);
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor, backgroundColor: Theme.of(context).colorScheme.surface,
body: lookupsAsync.when( body: lookupsAsync.when(
loading: () => const AppLoadingView(message: 'Loading form options...'), loading: () => const AppLoadingView(message: 'Loading form options...'),
error: (e, _) => ErrorView.fromFailure( error: (e, _) => ErrorView.fromFailure(

View File

@ -16,6 +16,7 @@ import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/can_permission.dart';
@ -103,23 +104,18 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
), ),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbar: LayoutBuilder( toolbar: _FiltersBar(
builder: (context, constraints) { searchController: _searchController,
return _FiltersBar( query: state.query,
searchController: _searchController, statusOptions: _statusFilterOptions(state.orders),
query: state.query, showExport: canExport,
statusOptions: _statusFilterOptions(state.orders), isExporting: state.isExporting,
wrapped: constraints.maxWidth < 900, onExport: _exportPurchaseOrders,
showExport: canExport, onSearch: ref.read(purchaseOrdersListProvider.notifier).setSearch,
isExporting: state.isExporting, onStatusChanged:
onExport: _exportPurchaseOrders, ref.read(purchaseOrdersListProvider.notifier).setStatusFilter,
onSearch: ref.read(purchaseOrdersListProvider.notifier).setSearch, onPoTypeChanged:
onStatusChanged: ref.read(purchaseOrdersListProvider.notifier).setPoTypeFilter,
ref.read(purchaseOrdersListProvider.notifier).setStatusFilter,
onPoTypeChanged:
ref.read(purchaseOrdersListProvider.notifier).setPoTypeFilter,
);
},
), ),
footer: AppPagination( footer: AppPagination(
currentPage: state.query.page, currentPage: state.query.page,
@ -275,7 +271,6 @@ class _FiltersBar extends StatelessWidget {
required this.searchController, required this.searchController,
required this.query, required this.query,
required this.statusOptions, required this.statusOptions,
required this.wrapped,
required this.onSearch, required this.onSearch,
required this.onStatusChanged, required this.onStatusChanged,
required this.onPoTypeChanged, required this.onPoTypeChanged,
@ -287,7 +282,6 @@ class _FiltersBar extends StatelessWidget {
final TextEditingController searchController; final TextEditingController searchController;
final PurchaseOrderListQuery query; final PurchaseOrderListQuery query;
final List<AppDropdownOption<String?>> statusOptions; final List<AppDropdownOption<String?>> statusOptions;
final bool wrapped;
final ValueChanged<String> onSearch; final ValueChanged<String> onSearch;
final ValueChanged<String?> onStatusChanged; final ValueChanged<String?> onStatusChanged;
final ValueChanged<String?> onPoTypeChanged; final ValueChanged<String?> onPoTypeChanged;
@ -327,48 +321,22 @@ class _FiltersBar extends StatelessWidget {
), ),
]; ];
final exportButton = OutlinedButton.icon( return AppResponsiveFilterBar(
onPressed: isExporting ? null : onExport, search: searchField,
icon: isExporting filters: filters,
? const SizedBox( trailing: showExport
width: 18, ? OutlinedButton.icon(
height: 18, onPressed: isExporting ? null : onExport,
child: CircularProgressIndicator(strokeWidth: 2), icon: isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
) )
: const Icon(Icons.download_outlined, size: 18), : null,
label: Text(isExporting ? 'Exporting...' : 'Export'),
);
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
filters[0],
const SizedBox(height: 12),
filters[1],
if (showExport) ...[
const SizedBox(height: 12),
Align(alignment: Alignment.centerRight, child: exportButton),
],
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[0]),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[1]),
if (showExport) ...[
const SizedBox(width: 12),
exportButton,
],
],
); );
} }
} }

View File

@ -381,26 +381,78 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
setState(() {}); setState(() {});
} }
/// Prefer live provider maps so quick-add can autofill after lookups refresh.
Map<String, int?> get _itemUomById =>
ref.read(purchaseOrderLookupsProvider).valueOrNull?.itemUomById ??
widget.itemUomById;
Map<String, int?> get _itemGstRateById =>
ref.read(purchaseOrderLookupsProvider).valueOrNull?.itemGstRateById ??
widget.itemGstRateById;
Map<String, int?> get _itemHsnById =>
ref.read(purchaseOrderLookupsProvider).valueOrNull?.itemHsnById ??
widget.itemHsnById;
bool _fillMissingItemDefaults() {
final itemId = widget.line.itemId;
if (itemId == null) return false;
final key = itemId.toString();
var changed = false;
final defaultUom = _itemUomById[key];
if (widget.line.uomId == null && defaultUom != null) {
widget.line.uomId = defaultUom;
changed = true;
}
final defaultGst = _itemGstRateById[key];
if (widget.line.gstRateId == null && defaultGst != null) {
widget.line.gstRateId = defaultGst;
changed = true;
}
final defaultHsn = _itemHsnById[key];
if (widget.line.hsnCodeId == null && defaultHsn != null) {
widget.line.hsnCodeId = defaultHsn;
changed = true;
}
return changed;
}
void _onItemChanged(int? itemId) { void _onItemChanged(int? itemId) {
_updateLine(() { _updateLine(() {
widget.line.itemId = itemId; widget.line.itemId = itemId;
if (itemId == null) return; if (itemId == null) return;
final key = itemId.toString(); final key = itemId.toString();
final defaultUom = widget.itemUomById[key]; final defaultUom = _itemUomById[key];
if (defaultUom != null) { if (defaultUom != null) {
widget.line.uomId = defaultUom; widget.line.uomId = defaultUom;
} }
final defaultGst = widget.itemGstRateById[key]; final defaultGst = _itemGstRateById[key];
if (defaultGst != null) { if (defaultGst != null) {
widget.line.gstRateId = defaultGst; widget.line.gstRateId = defaultGst;
} }
final defaultHsn = widget.itemHsnById[key]; final defaultHsn = _itemHsnById[key];
if (defaultHsn != null) { if (defaultHsn != null) {
widget.line.hsnCodeId = defaultHsn; widget.line.hsnCodeId = defaultHsn;
} }
}); });
} }
@override
void didUpdateWidget(covariant _LineItemCard oldWidget) {
super.didUpdateWidget(oldWidget);
final mapsChanged = oldWidget.itemUomById != widget.itemUomById ||
oldWidget.itemGstRateById != widget.itemGstRateById ||
oldWidget.itemHsnById != widget.itemHsnById;
if (!mapsChanged) return;
if (!_fillMissingItemDefaults()) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
widget.onChanged();
setState(() {});
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -467,8 +519,10 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select item', hint: 'Select item',
searchHint: 'Search item name or code...', searchHint: 'Search item name or code...',
options: itemOptions, options: itemOptions,
refreshLookups: () => refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider), ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future);
},
parseCreatedId: int.tryParse, parseCreatedId: int.tryParse,
onChanged: _onItemChanged, onChanged: _onItemChanged,
validator: (v) => v == null ? 'Item is required' : null, validator: (v) => v == null ? 'Item is required' : null,
@ -500,8 +554,10 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select UOM', hint: 'Select UOM',
searchHint: 'Search UOM...', searchHint: 'Search UOM...',
options: uomOptions, options: uomOptions,
refreshLookups: () => refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider), ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future);
},
parseCreatedId: int.tryParse, parseCreatedId: int.tryParse,
onChanged: (v) => _updateLine(() => line.uomId = v), onChanged: (v) => _updateLine(() => line.uomId = v),
validator: (v) => v == null ? 'Required' : null, validator: (v) => v == null ? 'Required' : null,
@ -543,8 +599,10 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select', hint: 'Select',
searchHint: 'Search GST %...', searchHint: 'Search GST %...',
options: gstOptions, options: gstOptions,
refreshLookups: () => refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider), ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future);
},
parseCreatedId: int.tryParse, parseCreatedId: int.tryParse,
onChanged: (v) => _updateLine(() => line.gstRateId = v), onChanged: (v) => _updateLine(() => line.gstRateId = v),
), ),

View File

@ -1,5 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart'; import '../../../masters/data/datasources/master_remote_data_source.dart';
import '../../../roles/data/repositories/role_repository_impl.dart'; import '../../../roles/data/repositories/role_repository_impl.dart';
@ -67,11 +69,21 @@ class AddUserFormNotifier extends FamilyAsyncNotifier<AddUserFormState, String?>
final plants = await masterRemote.listPlants(); final plants = await masterRemote.listPlants();
final designations = await masterRemote.listDesignations(); final designations = await masterRemote.listDesignations();
final usersResult = await getUsers(const UserListQuery(limit: 100)); final usersResult = await getUsers(
const UserListQuery(
limit: AppConstants.maxPageSize,
status: 'active',
isActive: true,
),
);
if (usersResult.failure != null) throw usersResult.failure!; if (usersResult.failure != null) throw usersResult.failure!;
final managers = usersResult.data!.items final managers = usersResult.data!.items
.where((user) => isReportingManagerRole(user.roleName)) .where(
(user) =>
isReportingManagerRole(user.roleName) &&
isActiveUserOption(status: user.status, isActive: user.isActive),
)
.map((user) => FilterOptionModel(id: user.id, name: user.fullName)) .map((user) => FilterOptionModel(id: user.id, name: user.fullName))
.toList(); .toList();

View File

@ -13,6 +13,7 @@ import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../users/presentation/widgets/user_rich_data_table.dart'; import '../../../users/presentation/widgets/user_rich_data_table.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_data_table.dart';
@ -715,44 +716,38 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: LayoutBuilder( child: _UsersFilterBar(
builder: (context, constraints) { roleFilter: roleFilter,
final useWrappedFilters = constraints.maxWidth < 1000; departmentFilter: departmentFilter,
return _UsersFilterBar( statusFilter: statusFilter,
wrapped: useWrappedFilters, roles: roles,
roleFilter: roleFilter, departments: departments,
departmentFilter: departmentFilter, statuses: statuses,
statusFilter: statusFilter, isExporting: usersState.isExporting,
roles: roles, showExport: canExport,
departments: departments, searchController: _searchController,
statuses: statuses, onExport: _exportUsers,
isExporting: usersState.isExporting, onSearch: ref.read(usersListProvider.notifier).setSearch,
showExport: canExport, onRoleChanged: (value) {
searchController: _searchController, ref.read(usersListProvider.notifier).setRoleFilter(
onExport: _exportUsers, value == 'All Roles'
onSearch: ref.read(usersListProvider.notifier).setSearch, ? null
onRoleChanged: (value) { : _roleIdForName(value, filters),
ref.read(usersListProvider.notifier).setRoleFilter( );
value == 'All Roles' },
? null onDepartmentChanged: (value) {
: _roleIdForName(value, filters), ref.read(usersListProvider.notifier).setDepartmentFilter(
); value == 'All Departments'
}, ? null
onDepartmentChanged: (value) { : _departmentIdForName(value, filters),
ref.read(usersListProvider.notifier).setDepartmentFilter( );
value == 'All Departments' },
? null onStatusChanged: (value) {
: _departmentIdForName(value, filters), ref.read(usersListProvider.notifier).setStatusFilter(
); value == 'All Statuses'
}, ? null
onStatusChanged: (value) { : _statusValueForLabel(value, filters),
ref.read(usersListProvider.notifier).setStatusFilter( );
value == 'All Statuses'
? null
: _statusValueForLabel(value, filters),
);
},
);
}, },
), ),
), ),
@ -807,7 +802,6 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
class _UsersFilterBar extends StatelessWidget { class _UsersFilterBar extends StatelessWidget {
const _UsersFilterBar({ const _UsersFilterBar({
required this.wrapped,
required this.roleFilter, required this.roleFilter,
required this.departmentFilter, required this.departmentFilter,
required this.statusFilter, required this.statusFilter,
@ -824,7 +818,6 @@ class _UsersFilterBar extends StatelessWidget {
required this.onStatusChanged, required this.onStatusChanged,
}); });
final bool wrapped;
final String roleFilter; final String roleFilter;
final String departmentFilter; final String departmentFilter;
final String statusFilter; final String statusFilter;
@ -878,56 +871,26 @@ class _UsersFilterBar extends StatelessWidget {
), ),
]; ];
final exportButton = Padding( return AppResponsiveFilterBar(
padding: const EdgeInsets.only(top: 8), search: searchField,
child: OutlinedButton.icon( filters: filters,
onPressed: isExporting ? null : onExport, trailing: showExport
style: OutlinedButton.styleFrom( ? OutlinedButton.icon(
minimumSize: const Size(0, 48), onPressed: isExporting ? null : onExport,
padding: const EdgeInsets.symmetric(horizontal: 16), style: OutlinedButton.styleFrom(
), minimumSize: const Size(0, 48),
icon: isExporting padding: const EdgeInsets.symmetric(horizontal: 16),
? const SizedBox( ),
width: 18, icon: isExporting
height: 18, ? const SizedBox(
child: CircularProgressIndicator(strokeWidth: 2), width: 18,
) height: 18,
: const Icon(Icons.download_outlined, size: 18), child: CircularProgressIndicator(strokeWidth: 2),
label: Text(isExporting ? 'Exporting...' : 'Export'), )
), : const Icon(Icons.download_outlined, size: 18),
); label: Text(isExporting ? 'Exporting...' : 'Export'),
)
if (wrapped) { : null,
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
...filters.expand((f) => [f, const SizedBox(height: 12)]).toList()
..removeLast(),
if (showExport) ...[
const SizedBox(height: 12),
exportButton,
],
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[0]),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[1]),
const SizedBox(width: 12),
Expanded(child: filters[2]),
if (showExport) ...[
const SizedBox(width: 12),
exportButton,
],
],
); );
} }
} }
@ -1314,7 +1277,7 @@ class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Role permission matrix', 'Role Permission Matrix',
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleMedium .titleMedium
@ -1426,7 +1389,7 @@ class _PermissionMatrixTable extends ConsumerWidget {
AppDataColumn( AppDataColumn(
label: 'Module', label: 'Module',
flex: 3, flex: 3,
searchText: (module) => '${module.name} ${module.code}', enableSearch: false,
cellBuilder: (context, module) { cellBuilder: (context, module) {
final index = matrix.modules.indexOf(module); final index = matrix.modules.indexOf(module);
final appearance = final appearance =
@ -1465,10 +1428,7 @@ class _PermissionMatrixTable extends ConsumerWidget {
label: permissionActionLabel(action), label: permissionActionLabel(action),
flex: 1, flex: 1,
alignment: Alignment.center, alignment: Alignment.center,
searchText: (module) { enableSearch: false,
final checked = module.granted[action] ?? false;
return checked ? 'yes granted true' : 'no denied false';
},
cellBuilder: (context, module) { cellBuilder: (context, module) {
final checked = module.granted[action] ?? false; final checked = module.granted[action] ?? false;
return Checkbox( return Checkbox(

View File

@ -1,3 +1,4 @@
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/asset_model.dart';
class ReportFilterOption { class ReportFilterOption {
@ -65,6 +66,10 @@ class DepreciationReportFilters {
final raw = json[key]; final raw = json[key];
if (raw is List && raw.isNotEmpty) { if (raw is List && raw.isNotEmpty) {
return raw return raw
.where((item) {
if (item is! Map) return true;
return isActiveOptionRow(Map<String, dynamic>.from(item));
})
.map(ReportFilterOption.fromDynamic) .map(ReportFilterOption.fromDynamic)
.where((o) => o.value.isNotEmpty) .where((o) => o.value.isNotEmpty)
.toList(); .toList();

View File

@ -18,10 +18,12 @@ import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_filter_date_field.dart'; import '../../../../shared/widgets/app_filter_date_field.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../domain/entities/depreciation_report.dart'; import '../../domain/entities/depreciation_report.dart';
import '../providers/depreciation_report_provider.dart'; import '../providers/depreciation_report_provider.dart';
import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/app_toast.dart';
@ -158,7 +160,7 @@ class _DepreciationReportScreenState
) )
: const Icon(Icons.download_outlined), : const Icon(Icons.download_outlined),
label: Text( label: Text(
state.isExporting ? 'Exporting...' : 'Export CSV', state.isExporting ? 'Exporting...' : 'Export',
), ),
), ),
], ],
@ -167,32 +169,27 @@ class _DepreciationReportScreenState
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbar: LayoutBuilder( toolbar: _FiltersBar(
builder: (context, constraints) { searchController: _searchController,
return _FiltersBar( filters: state.filters,
searchController: _searchController, query: state.query,
filters: state.filters, onSearch: notifier.setSearch,
query: state.query, onPlantChanged: notifier.setPlantId,
wrapped: constraints.maxWidth < 1100, onCategoryChanged: notifier.setItemCategoryId,
onSearch: notifier.setSearch, onSubcategoryChanged: notifier.setItemSubcategoryId,
onPlantChanged: notifier.setPlantId, onDepartmentChanged: notifier.setDepartmentId,
onCategoryChanged: notifier.setItemCategoryId, onStatusChanged: notifier.setStatus,
onSubcategoryChanged: notifier.setItemSubcategoryId, onMethodChanged: notifier.setDepreciationMethod,
onDepartmentChanged: notifier.setDepartmentId, onIsActiveChanged: notifier.setIsActive,
onStatusChanged: notifier.setStatus, onPickAsOfDate: () => _pickAsOfDate(state.query),
onMethodChanged: notifier.setDepreciationMethod, onClearAsOfDate: () => notifier.setAsOfDate(null),
onIsActiveChanged: notifier.setIsActive, onPickPurchaseRange: () =>
onPickAsOfDate: () => _pickAsOfDate(state.query), _pickPurchaseDateRange(state.query),
onClearAsOfDate: () => notifier.setAsOfDate(null), onClearPurchaseRange: () =>
onPickPurchaseRange: () => notifier.setPurchaseDateRange(null, null),
_pickPurchaseDateRange(state.query), onReset: () {
onClearPurchaseRange: () => _searchController.clear();
notifier.setPurchaseDateRange(null, null), notifier.resetFilters();
onReset: () {
_searchController.clear();
notifier.resetFilters();
},
);
}, },
), ),
footer: AppPagination( footer: AppPagination(
@ -326,7 +323,6 @@ class _FiltersBar extends StatefulWidget {
required this.searchController, required this.searchController,
required this.filters, required this.filters,
required this.query, required this.query,
required this.wrapped,
required this.onSearch, required this.onSearch,
required this.onPlantChanged, required this.onPlantChanged,
required this.onCategoryChanged, required this.onCategoryChanged,
@ -345,7 +341,6 @@ class _FiltersBar extends StatefulWidget {
final TextEditingController searchController; final TextEditingController searchController;
final DepreciationReportFilters filters; final DepreciationReportFilters filters;
final DepreciationReportQuery query; final DepreciationReportQuery query;
final bool wrapped;
final ValueChanged<String> onSearch; final ValueChanged<String> onSearch;
final ValueChanged<String?> onPlantChanged; final ValueChanged<String?> onPlantChanged;
final ValueChanged<String?> onCategoryChanged; final ValueChanged<String?> onCategoryChanged;
@ -399,24 +394,11 @@ class _FiltersBarState extends State<_FiltersBar> {
.where((o) => o.parentId == query.itemCategoryId) .where((o) => o.parentId == query.itemCategoryId)
.toList(); .toList();
final searchField = TextField( final searchField = AppSearchField(
controller: widget.searchController, controller: widget.searchController,
hint: 'Search asset code or name...',
onChanged: widget.onSearch, onChanged: widget.onSearch,
decoration: InputDecoration( onClear: () => widget.onSearch(''),
labelText: 'Search',
hintText: 'Search asset code or name...',
prefixIcon: const Icon(Icons.search),
isDense: true,
suffixIcon: widget.searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
widget.searchController.clear();
widget.onSearch('');
},
)
: null,
),
); );
Widget dropdown({ Widget dropdown({
@ -536,19 +518,6 @@ class _FiltersBarState extends State<_FiltersBar> {
child: const Text('Reset'), child: const Text('Reset'),
); );
Widget row(List<Widget> cells) {
assert(cells.length == 4);
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < cells.length; i++) ...[
if (i > 0) const SizedBox(width: 12),
Expanded(child: cells[i]),
],
],
);
}
final actions = Row( final actions = Row(
children: [ children: [
moreButton, moreButton,
@ -556,58 +525,36 @@ class _FiltersBarState extends State<_FiltersBar> {
], ],
); );
if (widget.wrapped) { final moreFilters = _moreOpen
return Column( ? Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
searchField, Divider(
const SizedBox(height: 12), height: 1,
plant, color: theme.colorScheme.outline.withValues(alpha: 0.2),
const SizedBox(height: 12), ),
category, const SizedBox(height: 12),
const SizedBox(height: 12), AppResponsiveFilterGrid(
asOfField, fields: [
const SizedBox(height: 4), subcategory,
actions, department,
if (_moreOpen) ...[ status,
const SizedBox(height: 8), method,
Divider(color: theme.colorScheme.outline.withValues(alpha: 0.2)), active,
const SizedBox(height: 8), purchaseField,
subcategory, ],
const SizedBox(height: 12), ),
department, ],
const SizedBox(height: 12), )
status, : null;
const SizedBox(height: 12),
method,
const SizedBox(height: 12),
active,
const SizedBox(height: 12),
purchaseField,
],
],
);
}
return Column( return AppResponsiveFilterGrid(
children: [ fields: [searchField, plant, category, asOfField],
row([searchField, plant, category, asOfField]), footer: Align(
const SizedBox(height: 4), alignment: Alignment.centerRight,
Align(alignment: Alignment.centerRight, child: actions), child: actions,
if (_moreOpen) ...[ ),
const SizedBox(height: 8), extra: moreFilters,
Divider(height: 1, color: theme.colorScheme.outline.withValues(alpha: 0.2)),
const SizedBox(height: 12),
row([subcategory, department, status, method]),
const SizedBox(height: 12),
row([
active,
purchaseField,
const SizedBox.shrink(),
const SizedBox.shrink(),
]),
],
],
); );
} }
} }
@ -664,26 +611,6 @@ class _ReportTable extends StatelessWidget {
textAlign: TextAlign.right, textAlign: TextAlign.right,
), ),
), ),
AppDataColumn(
label: 'Method',
flex: 2,
searchText: (row) => row.depreciationMethod ?? '',
cellBuilder: (_, row) => AppTableCell.text(
row.depreciationMethod,
placeholder: '-',
),
),
AppDataColumn(
label: 'Rate %',
flex: 1,
alignment: Alignment.centerRight,
searchText: (row) => row.depreciationRate?.toStringAsFixed(2) ?? '',
cellBuilder: (_, row) => AppTableCell.text(
row.depreciationRate?.toStringAsFixed(2),
textAlign: TextAlign.right,
placeholder: '-',
),
),
AppDataColumn( AppDataColumn(
label: 'Annual', label: 'Annual',
flex: 2, flex: 2,

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/permission_matrix_models.dart'; import '../../../../shared/models/permission_matrix_models.dart';
import '../../../../shared/models/role_model.dart'; import '../../../../shared/models/role_model.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
@ -42,13 +43,25 @@ class RoleRemoteDataSource {
if (search != null && search.isNotEmpty) 'search': search, if (search != null && search.isNotEmpty) 'search': search,
}, },
); );
return _parseList( final raw = response.data['data'];
response.data['data'], final list = raw is List
(json) => FilterOptionModel( ? raw
id: json['id']?.toString() ?? '', : raw is Map
name: json['name'] as String? ?? '', ? (raw['items'] as List?) ?? const []
), : const [];
).where((role) => role.id.isNotEmpty && role.name.isNotEmpty).toList();
return list
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.where(isActiveOptionRow)
.map(
(json) => FilterOptionModel(
id: json['id']?.toString() ?? '',
name: json['name'] as String? ?? '',
),
)
.where((role) => role.id.isNotEmpty && role.name.isNotEmpty)
.toList();
} }
Future<RoleModel> getRoleById(String id) async { Future<RoleModel> getRoleById(String id) async {

View File

@ -91,7 +91,7 @@ class _MatrixGrid extends ConsumerWidget {
AppDataColumn( AppDataColumn(
label: 'Module', label: 'Module',
flex: 3, flex: 3,
searchText: (row) => row.name, enableSearch: false,
cellBuilder: (_, row) => AppTableCell.text(row.name), cellBuilder: (_, row) => AppTableCell.text(row.name),
), ),
...matrix.actionColumns.map( ...matrix.actionColumns.map(
@ -99,10 +99,7 @@ class _MatrixGrid extends ConsumerWidget {
label: permissionActionLabel(action), label: permissionActionLabel(action),
flex: 1, flex: 1,
alignment: Alignment.center, alignment: Alignment.center,
searchText: (row) { enableSearch: false,
final checked = row.granted[action] ?? false;
return checked ? 'yes granted true' : 'no denied false';
},
cellBuilder: (_, row) => Checkbox( cellBuilder: (_, row) => Checkbox(
value: row.granted[action] ?? false, value: row.granted[action] ?? false,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,

View File

@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../../../core/utils/responsive_utils.dart'; import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../../domain/entities/app_settings.dart'; import '../../domain/entities/app_settings.dart';
@ -117,10 +118,9 @@ class SettingsSwitchTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SwitchListTile( return AppFormToggleField(
contentPadding: EdgeInsets.zero, label: title,
title: Text(title), subtitle: subtitle,
subtitle: subtitle != null ? Text(subtitle!) : null,
value: value, value: value,
onChanged: onChanged, onChanged: onChanged,
); );

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
@ -31,9 +32,11 @@ class UserRemoteDataSource {
List<FilterOptionModel> parseIdNameOptions(List<dynamic>? list) { List<FilterOptionModel> parseIdNameOptions(List<dynamic>? list) {
return (list ?? []) return (list ?? [])
.map( .whereType<Map>()
(item) => FilterOptionModel.fromJson(item as Map<String, dynamic>), .map((item) => Map<String, dynamic>.from(item))
) .where(isActiveOptionRow)
.map(FilterOptionModel.fromJson)
.where((item) => item.id.isNotEmpty && item.name.isNotEmpty)
.toList(); .toList();
} }

View File

@ -12,6 +12,7 @@ import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
@ -272,29 +273,9 @@ class _FiltersBar extends StatelessWidget {
), ),
]; ];
if (context.isMobile) { return AppResponsiveFilterBar(
return Column( search: searchField,
crossAxisAlignment: CrossAxisAlignment.stretch, filters: dropdowns,
children: [
searchField,
const SizedBox(height: 12),
...dropdowns.expand((f) => [f, const SizedBox(height: 12)]).toList()
..removeLast(),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: dropdowns[0]),
const SizedBox(width: 12),
Expanded(flex: 2, child: dropdowns[1]),
const SizedBox(width: 12),
Expanded(flex: 2, child: dropdowns[2]),
],
); );
} }
} }

View File

@ -16,6 +16,7 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_action_icon.dart';
@ -90,22 +91,17 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
), ),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbar: LayoutBuilder( toolbar: _FiltersBar(
builder: (context, constraints) { searchController: _searchController,
return _FiltersBar( query: state.query,
searchController: _searchController, showExport: canExport,
query: state.query, isExporting: state.isExporting,
wrapped: constraints.maxWidth < 900, onExport: _exportVendors,
showExport: canExport, onSearch: ref.read(vendorsListProvider.notifier).setSearch,
isExporting: state.isExporting, onStatusChanged:
onExport: _exportVendors, ref.read(vendorsListProvider.notifier).setStatusFilter,
onSearch: ref.read(vendorsListProvider.notifier).setSearch, onVendorTypeChanged:
onStatusChanged: ref.read(vendorsListProvider.notifier).setVendorTypeFilter,
ref.read(vendorsListProvider.notifier).setStatusFilter,
onVendorTypeChanged:
ref.read(vendorsListProvider.notifier).setVendorTypeFilter,
);
},
), ),
footer: AppPagination( footer: AppPagination(
currentPage: state.query.page, currentPage: state.query.page,
@ -209,7 +205,6 @@ class _FiltersBar extends StatelessWidget {
const _FiltersBar({ const _FiltersBar({
required this.searchController, required this.searchController,
required this.query, required this.query,
required this.wrapped,
required this.onSearch, required this.onSearch,
required this.onStatusChanged, required this.onStatusChanged,
required this.onVendorTypeChanged, required this.onVendorTypeChanged,
@ -220,7 +215,6 @@ class _FiltersBar extends StatelessWidget {
final TextEditingController searchController; final TextEditingController searchController;
final VendorListQuery query; final VendorListQuery query;
final bool wrapped;
final ValueChanged<String> onSearch; final ValueChanged<String> onSearch;
final ValueChanged<String?> onStatusChanged; final ValueChanged<String?> onStatusChanged;
final ValueChanged<String?> onVendorTypeChanged; final ValueChanged<String?> onVendorTypeChanged;
@ -265,48 +259,22 @@ class _FiltersBar extends StatelessWidget {
), ),
]; ];
final exportButton = OutlinedButton.icon( return AppResponsiveFilterBar(
onPressed: isExporting ? null : onExport, search: searchField,
icon: isExporting filters: filters,
? const SizedBox( trailing: showExport
width: 18, ? OutlinedButton.icon(
height: 18, onPressed: isExporting ? null : onExport,
child: CircularProgressIndicator(strokeWidth: 2), icon: isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
) )
: const Icon(Icons.download_outlined, size: 18), : null,
label: Text(isExporting ? 'Exporting...' : 'Export'),
);
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
filters[0],
const SizedBox(height: 12),
filters[1],
if (showExport) ...[
const SizedBox(height: 12),
Align(alignment: Alignment.centerRight, child: exportButton),
],
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[0]),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[1]),
if (showExport) ...[
const SizedBox(width: 12),
exportButton,
],
],
); );
} }
} }

View File

@ -7,6 +7,7 @@ import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/models/vendor_model.dart'; import '../../../../shared/models/vendor_model.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
@ -329,9 +330,8 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
), ),
if (_showActiveSwitch) ...[ if (_showActiveSwitch) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile( AppFormToggleField(
contentPadding: EdgeInsets.zero, label: 'Active',
title: const Text('Active'),
value: _isActive, value: _isActive,
onChanged: (v) => setState(() => _isActive = v), onChanged: (v) => setState(() => _isActive = v),
), ),

View File

@ -5,6 +5,7 @@ import '../../../../core/utils/validators.dart';
import '../../../../shared/models/vendor_model.dart'; import '../../../../shared/models/vendor_model.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_text_field.dart';
import '../providers/vendors_provider.dart'; import '../providers/vendors_provider.dart';
@ -196,9 +197,8 @@ class _VendorAddressPanelState extends ConsumerState<VendorAddressPanel> {
inputFormatters: Validators.gstinInput, inputFormatters: Validators.gstinInput,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile( AppFormToggleField(
contentPadding: EdgeInsets.zero, label: 'Active',
title: const Text('Active'),
value: _isActive, value: _isActive,
onChanged: (v) => setState(() => _isActive = v), onChanged: (v) => setState(() => _isActive = v),
), ),
@ -325,15 +325,13 @@ class _VendorContactPanelState extends ConsumerState<VendorContactPanel> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile( AppFormToggleField(
contentPadding: EdgeInsets.zero, label: 'Primary Contact',
title: const Text('Primary Contact'),
value: _isPrimary, value: _isPrimary,
onChanged: (v) => setState(() => _isPrimary = v), onChanged: (v) => setState(() => _isPrimary = v),
), ),
SwitchListTile( AppFormToggleField(
contentPadding: EdgeInsets.zero, label: 'Active',
title: const Text('Active'),
value: _isActive, value: _isActive,
onChanged: (v) => setState(() => _isActive = v), onChanged: (v) => setState(() => _isActive = v),
), ),
@ -491,15 +489,13 @@ class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
onChanged: (v) => setState(() => _accountType = v), onChanged: (v) => setState(() => _accountType = v),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile( AppFormToggleField(
contentPadding: EdgeInsets.zero, label: 'Primary Account',
title: const Text('Primary Account'),
value: _isPrimary, value: _isPrimary,
onChanged: (v) => setState(() => _isPrimary = v), onChanged: (v) => setState(() => _isPrimary = v),
), ),
SwitchListTile( AppFormToggleField(
contentPadding: EdgeInsets.zero, label: 'Active',
title: const Text('Active'),
value: _isActive, value: _isActive,
onChanged: (v) => setState(() => _isActive = v), onChanged: (v) => setState(() => _isActive = v),
), ),

View File

@ -42,12 +42,6 @@ const List<MenuItem> appMenuItems = [
// route: RouteConstants.branches, // route: RouteConstants.branches,
// module: 'branches', // module: 'branches',
// ), // ),
MenuItem(
label: 'Users & Roles',
icon: Icons.admin_panel_settings_outlined,
route: RouteConstants.usersRoleManagement,
module: 'users',
),
MenuItem( MenuItem(
label: 'Assets', label: 'Assets',
icon: Icons.inventory_2_outlined, icon: Icons.inventory_2_outlined,
@ -86,18 +80,24 @@ const List<MenuItem> appMenuItems = [
route: RouteConstants.grn, route: RouteConstants.grn,
module: 'grn', module: 'grn',
), ),
MenuItem(
label: 'Master Data',
icon: Icons.dataset_outlined,
route: RouteConstants.masterData,
module: 'master_data',
),
MenuItem( MenuItem(
label: 'Reports', label: 'Reports',
icon: Icons.assessment_outlined, icon: Icons.assessment_outlined,
route: RouteConstants.reports, route: RouteConstants.reports,
module: 'reports', 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( MenuItem(
label: 'Audit Logs', label: 'Audit Logs',
icon: Icons.history_outlined, icon: Icons.history_outlined,
@ -112,6 +112,13 @@ const List<MenuItem> appMenuItems = [
), ),
]; ];
/// Routes rendered under the Support section (after primary ops menus).
const Set<String> supportMenuRoutes = {
RouteConstants.usersRoleManagement,
RouteConstants.masterData,
RouteConstants.auditLogs,
RouteConstants.settings,
};
List<MenuItem> getVisibleMenuItems({ List<MenuItem> getVisibleMenuItems({
required List<String> permissions, required List<String> permissions,
required UserRole role, required UserRole role,

View File

@ -13,6 +13,7 @@ class AppFilterDateField extends StatelessWidget {
this.placeholder = 'Select date', this.placeholder = 'Select date',
this.onClear, this.onClear,
this.isEmpty = false, this.isEmpty = false,
this.alignWithLabeledFields = true,
}); });
final String label; final String label;
@ -22,6 +23,7 @@ class AppFilterDateField extends StatelessWidget {
final String placeholder; final String placeholder;
final VoidCallback? onClear; final VoidCallback? onClear;
final bool isEmpty; final bool isEmpty;
final bool alignWithLabeledFields;
static const _suffixConstraints = BoxConstraints( static const _suffixConstraints = BoxConstraints(
minWidth: 40, minWidth: 40,
@ -35,7 +37,7 @@ class AppFilterDateField extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final showClear = onClear != null && !isEmpty; final showClear = onClear != null && !isEmpty;
return InputDecorator( final field = InputDecorator(
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
isDense: true, isDense: true,
@ -79,5 +81,11 @@ class AppFilterDateField extends StatelessWidget {
), ),
), ),
); );
if (!alignWithLabeledFields) return field;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: field,
);
} }
} }

View File

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
/// Compact, theme-aware toggle for forms.
///
/// Keeps the switch close to the label for better visual grouping.
class AppFormToggleField extends StatelessWidget {
const AppFormToggleField({
super.key,
required this.label,
required this.value,
required this.onChanged,
this.subtitle,
this.enabled = true,
});
final String label;
final String? subtitle;
final bool value;
final ValueChanged<bool>? onChanged;
final bool enabled;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: theme.textTheme.bodyLarge?.copyWith(
color: enabled
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 10),
Transform.scale(
scale: 0.80,
child: Switch(
value: value,
onChanged: enabled ? onChanged : null,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
if (subtitle != null && subtitle!.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 1),
child: Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import '../../core/utils/responsive_utils.dart';
/// Responsive list-page filter layout.
///
/// - Wide: search + filters (+ trailing) in one row
/// - Medium: search full width, filters wrap 2+ per row
/// - Narrow: one field per row
class AppResponsiveFilterBar extends StatelessWidget {
const AppResponsiveFilterBar({
super.key,
required this.search,
this.filters = const [],
this.trailing,
this.spacing = 12,
this.runSpacing = 12,
this.searchFlex = 3,
this.filterFlex = 2,
this.crossAxisAlignment = CrossAxisAlignment.start,
});
final Widget search;
final List<Widget> filters;
final Widget? trailing;
final double spacing;
final double runSpacing;
final int searchFlex;
final int filterFlex;
final CrossAxisAlignment crossAxisAlignment;
static const double _rowBreakpoint = 720;
static const double _stackBreakpoint = AppBreakpoints.tablet; // 600
static const double _minFilterWidth = 160;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final hasTrailing = trailing != null;
if (maxWidth >= _rowBreakpoint) {
return Row(
crossAxisAlignment: crossAxisAlignment,
children: [
Expanded(flex: searchFlex, child: search),
for (final filter in filters) ...[
SizedBox(width: spacing),
Expanded(flex: filterFlex, child: filter),
],
if (hasTrailing) ...[
SizedBox(width: spacing),
Padding(
padding: const EdgeInsets.only(top: 8),
child: trailing!,
),
],
],
);
}
if (maxWidth >= _stackBreakpoint) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
search,
SizedBox(height: runSpacing),
Wrap(
spacing: spacing,
runSpacing: runSpacing,
children: [
for (final filter in filters)
SizedBox(
width: _wrapChildWidth(
maxWidth: maxWidth,
itemCount: filters.length + (hasTrailing ? 1 : 0),
),
child: filter,
),
if (hasTrailing)
SizedBox(
width: _wrapChildWidth(
maxWidth: maxWidth,
itemCount: filters.length + 1,
),
child: Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: trailing!,
),
),
),
],
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
search,
for (final filter in filters) ...[
SizedBox(height: runSpacing),
filter,
],
if (hasTrailing) ...[
SizedBox(height: runSpacing),
Align(alignment: Alignment.centerRight, child: trailing!),
],
],
);
},
);
}
double _wrapChildWidth({
required double maxWidth,
required int itemCount,
}) {
if (itemCount <= 0) return maxWidth;
final preferredCols = maxWidth >= 840 ? 3 : 2;
final cols = preferredCols.clamp(1, itemCount);
final gaps = spacing * (cols - 1);
final width = (maxWidth - gaps) / cols;
return width < _minFilterWidth ? maxWidth : width;
}
}
/// Responsive grid for screens with many filter fields (audit, reports, etc.).
///
/// - Wide: up to [maxColumns] fields per row
/// - Medium: wrapped 23 fields per row
/// - Narrow: one field per row
class AppResponsiveFilterGrid extends StatelessWidget {
const AppResponsiveFilterGrid({
super.key,
required this.fields,
this.spacing = 12,
this.runSpacing = 12,
this.minFieldWidth = 160,
this.maxColumns = 4,
this.footer,
this.extra,
});
final List<Widget> fields;
final double spacing;
final double runSpacing;
final double minFieldWidth;
final int maxColumns;
final Widget? footer;
final Widget? extra;
static const double _rowBreakpoint = 720;
static const double _stackBreakpoint = AppBreakpoints.tablet;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
Widget content;
if (maxWidth >= _rowBreakpoint && fields.length <= maxColumns) {
content = Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < fields.length; i++) ...[
if (i > 0) SizedBox(width: spacing),
Expanded(child: fields[i]),
],
],
);
} else if (maxWidth >= _stackBreakpoint) {
final cols = maxWidth >= 900
? 3
: maxWidth >= _rowBreakpoint
? 2
: 2;
final itemWidth = _itemWidth(maxWidth, cols);
content = Wrap(
spacing: spacing,
runSpacing: runSpacing,
children: [
for (final field in fields)
SizedBox(width: itemWidth, child: field),
],
);
} else {
content = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < fields.length; i++) ...[
if (i > 0) SizedBox(height: runSpacing),
fields[i],
],
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
content,
if (footer != null) ...[
SizedBox(height: runSpacing),
footer!,
],
if (extra != null) ...[
SizedBox(height: runSpacing),
extra!,
],
],
);
},
);
}
double _itemWidth(double maxWidth, int cols) {
final gaps = spacing * (cols - 1);
final width = (maxWidth - gaps) / cols;
return width < minFieldWidth ? maxWidth : width;
}
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'app_responsive_filter_bar.dart';
/// Search field with export action matches the users list toolbar (no filters). /// Search field with export action matches the users list toolbar (no filters).
class AppSearchExportBar extends StatelessWidget { class AppSearchExportBar extends StatelessWidget {
const AppSearchExportBar({ const AppSearchExportBar({
@ -8,6 +10,7 @@ class AppSearchExportBar extends StatelessWidget {
required this.onSearch, required this.onSearch,
required this.onExport, required this.onExport,
this.isExporting = false, this.isExporting = false,
@Deprecated('Layout is always responsive via AppResponsiveFilterBar')
this.wrapped = false, this.wrapped = false,
this.searchController, this.searchController,
this.searchWidth = 320, this.searchWidth = 320,
@ -35,42 +38,21 @@ class AppSearchExportBar extends StatelessWidget {
onChanged: onSearch, onChanged: onSearch,
); );
final exportButton = OutlinedButton.icon( return AppResponsiveFilterBar(
onPressed: isExporting ? null : onExport, search: searchField,
icon: isExporting trailing: showExport
? const SizedBox( ? OutlinedButton.icon(
width: 18, onPressed: isExporting ? null : onExport,
height: 18, icon: isExporting
child: CircularProgressIndicator(strokeWidth: 2), ? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
) )
: const Icon(Icons.download_outlined, size: 18), : null,
label: Text(isExporting ? 'Exporting...' : 'Export'),
);
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
if (showExport) ...[
const SizedBox(height: 12),
Row(
children: [
const Spacer(),
exportButton,
],
),
],
],
);
}
return Row(
children: [
SizedBox(width: searchWidth, child: searchField),
const Spacer(),
if (showExport) exportButton,
],
); );
} }
} }

View File

@ -230,6 +230,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
decoration: InputDecoration( decoration: InputDecoration(
labelText: widget.label, labelText: widget.label,
hintText: displayLabel == null ? effectiveHint : null, hintText: displayLabel == null ? effectiveHint : null,
hintMaxLines: 1,
floatingLabelBehavior: FloatingLabelBehavior.always, floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: widget.isDense, isDense: widget.isDense,
errorText: field.errorText, errorText: field.errorText,
@ -612,6 +613,7 @@ class _AppSearchableLookupFieldState<T>
decoration: InputDecoration( decoration: InputDecoration(
labelText: widget.label, labelText: widget.label,
hintText: displayLabel == null ? effectiveHint : null, hintText: displayLabel == null ? effectiveHint : null,
hintMaxLines: 1,
floatingLabelBehavior: FloatingLabelBehavior.always, floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: widget.isDense, isDense: widget.isDense,
suffixIcon: Icon( suffixIcon: Icon(

View File

@ -57,8 +57,16 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
final Set<String> _expandedMenus = <String>{}; final Set<String> _expandedMenus = <String>{};
final Set<String> _manuallyCollapsedMenus = <String>{}; final Set<String> _manuallyCollapsedMenus = <String>{};
List<menu.MenuItem> get _mainMenuItems => List<menu.MenuItem> get _primaryMenuItems => widget.menuItems
widget.menuItems.where((item) => item.route != RouteConstants.settings).toList(); .where((item) => !menu.supportMenuRoutes.contains(item.route))
.toList();
List<menu.MenuItem> get _supportMenuItems => widget.menuItems
.where((item) => menu.supportMenuRoutes.contains(item.route))
.toList();
bool get _hasSupportSection =>
_supportMenuItems.isNotEmpty || AppConstants.showNotificationsMenu;
bool _routeMatches(String route) { bool _routeMatches(String route) {
final current = widget.currentRoute; final current = widget.currentRoute;
@ -104,7 +112,7 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
void didUpdateWidget(covariant AppSidebar oldWidget) { void didUpdateWidget(covariant AppSidebar oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.currentRoute != widget.currentRoute) { if (oldWidget.currentRoute != widget.currentRoute) {
for (final item in _mainMenuItems) { for (final item in [..._primaryMenuItems, ..._supportMenuItems]) {
if (item.children.isNotEmpty && _isGroupActive(item)) { if (item.children.isNotEmpty && _isGroupActive(item)) {
_expandedMenus.add(item.route); _expandedMenus.add(item.route);
_manuallyCollapsedMenus.remove(item.route); _manuallyCollapsedMenus.remove(item.route);
@ -167,84 +175,25 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (!isNarrow) if (isNarrow) const SizedBox(height: 4),
const _SectionLabel(label: 'MAIN MENU') ..._buildNavItems(_primaryMenuItems, isNarrow: isNarrow),
else if (_hasSupportSection) ...[
const SizedBox(height: 4), const SizedBox(height: 16),
..._mainMenuItems.map((item) { if (!isNarrow) const _SectionLabel(label: 'SUPPORT'),
if (isNarrow && item.children.isNotEmpty) { if (AppConstants.showNotificationsMenu)
return _CollapsedFlyoutNavItem( _SidebarNavItem(
item: item, icon: Icons.notifications_outlined,
selected: _isGroupActive(item), label: 'Notifications',
isChildSelected: (route) => selected: false,
_isSelectedAmongSiblings(route, item.children), collapsed: isNarrow,
onChildTap: widget.onItemTap, badge: isNarrow ? null : '3',
); onTap: () {},
} ),
..._buildNavItems(
if (item.children.isNotEmpty && !isNarrow) { _supportMenuItems,
final expanded = _isGroupExpanded(item); isNarrow: isNarrow,
final active = _isGroupActive(item);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SidebarNavItem(
icon: item.icon,
label: item.label,
selected: active,
collapsed: false,
showChevron: true,
chevronExpanded: expanded,
onTap: () => _toggleGroup(item),
),
if (expanded)
...item.children.map(
(child) => _SidebarNavItem(
icon: child.icon,
label: child.label,
selected: _isSelectedAmongSiblings(
child.route,
item.children,
),
collapsed: false,
indent: _sidebarChildIndent,
onTap: () => widget.onItemTap(child.route),
),
),
],
);
}
return _SidebarNavItem(
icon: item.icon,
label: item.label,
selected: _isSelected(item.route) ||
(isNarrow && _isGroupActive(item)),
collapsed: isNarrow,
onTap: () {
widget.onItemTap(item.route);
},
);
}),
const SizedBox(height: 16),
if (!isNarrow) const _SectionLabel(label: 'SUPPORT'),
if (AppConstants.showNotificationsMenu)
_SidebarNavItem(
icon: Icons.notifications_outlined,
label: 'Notifications',
selected: false,
collapsed: isNarrow,
badge: isNarrow ? null : '3',
onTap: () {},
),
if (_hasSettings)
_SidebarNavItem(
icon: Icons.settings_outlined,
label: 'Settings',
selected: _isSelected(RouteConstants.settings),
collapsed: isNarrow,
onTap: () => widget.onItemTap(RouteConstants.settings),
), ),
],
], ],
), ),
), ),
@ -259,8 +208,66 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
); );
} }
bool get _hasSettings => List<Widget> _buildNavItems(
widget.menuItems.any((item) => item.route == RouteConstants.settings); List<menu.MenuItem> items, {
required bool isNarrow,
}) {
return items.map((item) {
if (isNarrow && item.children.isNotEmpty) {
return _CollapsedFlyoutNavItem(
item: item,
selected: _isGroupActive(item),
isChildSelected: (route) =>
_isSelectedAmongSiblings(route, item.children),
onChildTap: widget.onItemTap,
);
}
if (item.children.isNotEmpty && !isNarrow) {
final expanded = _isGroupExpanded(item);
final active = _isGroupActive(item);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SidebarNavItem(
icon: item.icon,
label: item.label,
selected: active,
collapsed: false,
showChevron: true,
chevronExpanded: expanded,
onTap: () => _toggleGroup(item),
),
if (expanded)
...item.children.map(
(child) => _SidebarNavItem(
icon: child.icon,
label: child.label,
selected: _isSelectedAmongSiblings(
child.route,
item.children,
),
collapsed: false,
indent: _sidebarChildIndent,
onTap: () => widget.onItemTap(child.route),
),
),
],
);
}
return _SidebarNavItem(
icon: item.icon,
label: item.label,
selected: _isSelected(item.route) ||
(isNarrow && _isGroupActive(item)),
collapsed: isNarrow,
onTap: () {
widget.onItemTap(item.route);
},
);
}).toList();
}
Widget _buildHeader(BuildContext context, {required bool isNarrow}) { Widget _buildHeader(BuildContext context, {required bool isNarrow}) {
final theme = Theme.of(context); final theme = Theme.of(context);

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/validators.dart'; import '../../core/utils/validators.dart';
import '../../modules/master_data/data/repositories/master_repository_impl.dart'; import '../../modules/master_data/data/repositories/master_repository_impl.dart';
import '../../modules/master_data/domain/entities/master_definition.dart'; import '../../modules/master_data/domain/entities/master_definition.dart';
import 'app_form_toggle_field.dart';
/// Compact master create form for use inside a dropdown panel. /// Compact master create form for use inside a dropdown panel.
class MasterInlineQuickAddForm extends ConsumerStatefulWidget { class MasterInlineQuickAddForm extends ConsumerStatefulWidget {
@ -176,10 +177,8 @@ class _MasterInlineQuickAddFormState
switch (field.type) { switch (field.type) {
case MasterFieldType.boolean: case MasterFieldType.boolean:
if (field.key == 'is_active') { if (field.key == 'is_active') {
return SwitchListTile( return AppFormToggleField(
contentPadding: EdgeInsets.zero, label: field.label,
dense: true,
title: Text(field.label, style: const TextStyle(fontSize: 13)),
value: _values[field.key] == true, value: _values[field.key] == true,
onChanged: _submitting onChanged: _submitting
? null ? null
@ -253,6 +252,9 @@ class _MasterInlineQuickAddFormState
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final fields = _definition.formFields; final fields = _definition.formFields;
final activeField = fields.where((field) => field.key == 'is_active').firstOrNull;
final regularFields =
fields.where((field) => field.key != 'is_active').toList();
return Material( return Material(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
@ -297,7 +299,7 @@ class _MasterInlineQuickAddFormState
), ),
) )
else ...[ else ...[
for (final field in fields) ...[ for (final field in regularFields) ...[
_buildField(field), _buildField(field),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
@ -312,25 +314,40 @@ class _MasterInlineQuickAddFormState
), ),
), ),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Spacer(), if (activeField != null)
TextButton( Expanded(
onPressed: _submitting ? null : widget.onCancel, child: Align(
child: const Text('Cancel'), alignment: Alignment.centerLeft,
), child: _buildField(activeField),
const SizedBox(width: 4), ),
FilledButton( )
onPressed: _submitting ? null : _submit, else
child: _submitting const Spacer(),
? const SizedBox( const SizedBox(width: 12),
width: 16, Row(
height: 16, mainAxisSize: MainAxisSize.min,
child: CircularProgressIndicator( children: [
strokeWidth: 2, TextButton(
color: Colors.white, onPressed: _submitting ? null : widget.onCancel,
), child: const Text('Cancel'),
) ),
: Text('Add ${_definition.title}'), const SizedBox(width: 4),
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('Add ${_definition.title}'),
),
],
), ),
], ],
), ),

View File

@ -1,4 +1,7 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -31,6 +34,7 @@ class SidebarLogo extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isLight = theme.brightness == Brightness.light;
final fallback = Image.asset( final fallback = Image.asset(
AppConstants.defaultLogoAsset, AppConstants.defaultLogoAsset,
width: _width, width: _width,
@ -43,7 +47,7 @@ class SidebarLogo extends StatelessWidget {
), ),
); );
final content = _buildLogoContent(fallback); final content = _buildLogoContent(fallback, knockOutBlack: isLight);
if (!showBackground) { if (!showBackground) {
return SizedBox(width: _width, height: _height, child: content); return SizedBox(width: _width, height: _height, child: content);
@ -61,39 +65,50 @@ class SidebarLogo extends StatelessWidget {
); );
} }
Widget _buildLogoContent(Widget fallback) { Widget _buildLogoContent(Widget fallback, {required bool knockOutBlack}) {
final url = resolveMediaUrl(logoUrl); final url = resolveMediaUrl(logoUrl);
if (url == null || url.isEmpty) { if (url == null || url.isEmpty) {
return fallback; return _maybeKnockOut(fallbackProvider: const AssetImage(AppConstants.defaultLogoAsset), fallback: fallback, knockOutBlack: knockOutBlack);
} }
if (url.startsWith('assets/')) { if (url.startsWith('assets/')) {
return Image.asset( final image = Image.asset(
url, url,
width: _width, width: _width,
height: _height, height: _height,
fit: fit, fit: fit,
errorBuilder: (_, __, ___) => fallback, errorBuilder: (_, __, ___) => fallback,
); );
return _maybeKnockOut(
fallbackProvider: AssetImage(url),
fallback: image,
knockOutBlack: knockOutBlack,
);
} }
if (url.startsWith('data:image')) { if (url.startsWith('data:image')) {
try { try {
final base64Str = url.contains(',') ? url.split(',').last : url; final base64Str = url.contains(',') ? url.split(',').last : url;
return Image.memory( final bytes = base64Decode(base64Str);
base64Decode(base64Str), final image = Image.memory(
bytes,
width: _width, width: _width,
height: _height, height: _height,
fit: fit, fit: fit,
errorBuilder: (_, __, ___) => fallback, errorBuilder: (_, __, ___) => fallback,
); );
return _maybeKnockOut(
fallbackProvider: MemoryImage(bytes),
fallback: image,
knockOutBlack: knockOutBlack,
);
} catch (_) { } catch (_) {
return fallback; return fallback;
} }
} }
if (url.startsWith('http://') || url.startsWith('https://')) { if (url.startsWith('http://') || url.startsWith('https://')) {
return CachedNetworkImage( final image = CachedNetworkImage(
imageUrl: url, imageUrl: url,
width: _width, width: _width,
height: _height, height: _height,
@ -107,10 +122,224 @@ class SidebarLogo extends StatelessWidget {
), ),
errorWidget: (_, __, ___) => fallback, errorWidget: (_, __, ___) => fallback,
); );
return _maybeKnockOut(
fallbackProvider: CachedNetworkImageProvider(url),
fallback: image,
knockOutBlack: knockOutBlack,
);
} }
return fallback; return fallback;
} }
Widget _maybeKnockOut({
required ImageProvider fallbackProvider,
required Widget fallback,
required bool knockOutBlack,
}) {
if (!knockOutBlack) return fallback;
return _BlackBackgroundKnockOut(
provider: fallbackProvider,
width: _width,
height: _height,
fit: fit,
fallback: fallback,
);
}
}
/// Removes a solid black logo backdrop in light mode when corners are black.
class _BlackBackgroundKnockOut extends StatefulWidget {
const _BlackBackgroundKnockOut({
required this.provider,
required this.width,
required this.height,
required this.fit,
required this.fallback,
});
final ImageProvider provider;
final double width;
final double height;
final BoxFit fit;
final Widget fallback;
@override
State<_BlackBackgroundKnockOut> createState() =>
_BlackBackgroundKnockOutState();
}
class _BlackBackgroundKnockOutState extends State<_BlackBackgroundKnockOut> {
static final Map<String, ui.Image> _cache = {};
ui.Image? _processed;
bool _failed = false;
Object? _providerKey;
@override
void initState() {
super.initState();
_load();
}
@override
void didUpdateWidget(covariant _BlackBackgroundKnockOut oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.provider != widget.provider) {
_load();
}
}
Future<void> _load() async {
final key = _cacheKey(widget.provider);
_providerKey = key;
final cached = _cache[key];
if (cached != null) {
if (mounted) {
setState(() {
_processed = cached;
_failed = false;
});
}
return;
}
try {
final source = await _resolveImage(widget.provider);
final processed = await _knockOutBlackBackground(source);
source.dispose();
_cache[key] = processed;
if (!mounted || _providerKey != key) {
// Another load superseded this one; keep cache entry.
return;
}
setState(() {
_processed = processed;
_failed = false;
});
} catch (_) {
if (mounted && _providerKey == key) {
setState(() {
_processed = null;
_failed = true;
});
}
}
}
String _cacheKey(ImageProvider provider) {
if (provider is AssetImage) return 'asset:${provider.assetName}';
if (provider is MemoryImage) {
return 'memory:${provider.bytes.length}:${Object.hashAll(provider.bytes.take(64))}';
}
if (provider is NetworkImage) return 'net:${provider.url}';
if (provider is CachedNetworkImageProvider) return 'cnet:${provider.url}';
return 'other:${provider.runtimeType}:$provider';
}
Future<ui.Image> _resolveImage(ImageProvider provider) async {
final completer = Completer<ui.Image>();
final stream = provider.resolve(const ImageConfiguration());
late final ImageStreamListener listener;
listener = ImageStreamListener(
(info, _) {
stream.removeListener(listener);
completer.complete(info.image.clone());
},
onError: (error, stack) {
stream.removeListener(listener);
completer.completeError(error, stack);
},
);
stream.addListener(listener);
return completer.future;
}
Future<ui.Image> _knockOutBlackBackground(ui.Image source) async {
final byteData =
await source.toByteData(format: ui.ImageByteFormat.rawRgba);
if (byteData == null) return source.clone();
final pixels = byteData.buffer.asUint8List();
final width = source.width;
final height = source.height;
if (!_hasBlackBackdrop(pixels, width, height)) {
return source.clone();
}
const threshold = 28;
for (var i = 0; i < pixels.length; i += 4) {
final r = pixels[i];
final g = pixels[i + 1];
final b = pixels[i + 2];
if (r <= threshold && g <= threshold && b <= threshold) {
pixels[i + 3] = 0;
}
}
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
pixels,
width,
height,
ui.PixelFormat.rgba8888,
completer.complete,
);
return completer.future;
}
bool _hasBlackBackdrop(Uint8List pixels, int width, int height) {
if (width < 2 || height < 2) return false;
final samples = <(int, int)>[
(0, 0),
(width - 1, 0),
(0, height - 1),
(width - 1, height - 1),
(width ~/ 2, 0),
(width ~/ 2, height - 1),
(0, height ~/ 2),
(width - 1, height ~/ 2),
];
var blackSamples = 0;
for (final (x, y) in samples) {
final i = (y * width + x) * 4;
final r = pixels[i];
final g = pixels[i + 1];
final b = pixels[i + 2];
final a = pixels[i + 3];
// Opaque near-black corner/edge => baked-in backdrop.
if (a > 200 && r <= 28 && g <= 28 && b <= 28) {
blackSamples++;
}
}
return blackSamples >= 5;
}
@override
Widget build(BuildContext context) {
if (_failed || _processed == null) {
return widget.fallback;
}
return SizedBox(
width: widget.width,
height: widget.height,
child: FittedBox(
fit: widget.fit,
child: SizedBox(
width: _processed!.width.toDouble(),
height: _processed!.height.toDouble(),
child: RawImage(
image: _processed,
fit: BoxFit.fill,
),
),
),
);
}
} }
/// Resolves logo URL from company profile, branding, or bundled default. /// Resolves logo URL from company profile, branding, or bundled default.