bug fix
This commit is contained in:
parent
1d44187942
commit
0978f9bfac
11
lib/app.dart
11
lib/app.dart
@ -4,8 +4,10 @@ import 'package:responsive_framework/responsive_framework.dart';
|
||||
|
||||
import 'core/constants/app_constants.dart';
|
||||
import 'core/theme/theme_provider.dart';
|
||||
import 'modules/settings/presentation/providers/settings_provider.dart';
|
||||
import 'shared/routes/app_router.dart';
|
||||
import 'shared/widgets/app_toast.dart';
|
||||
import 'shared/widgets/sidebar_logo.dart';
|
||||
|
||||
class BharatErpApp extends ConsumerWidget {
|
||||
const BharatErpApp({super.key});
|
||||
@ -15,9 +17,16 @@ class BharatErpApp extends ConsumerWidget {
|
||||
final router = ref.watch(routerProvider);
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
final branding = ref.watch(brandingProvider);
|
||||
final companyProfile = ref.watch(appSettingsProvider).companyProfile;
|
||||
final appTitle = resolveSidebarTitle(
|
||||
companyName: companyProfile.companyName.isNotEmpty
|
||||
? companyProfile.companyName
|
||||
: (branding.companyName ?? ''),
|
||||
fallback: AppConstants.appName,
|
||||
);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: AppConstants.appName,
|
||||
title: appTitle,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildLightTheme(branding),
|
||||
darkTheme: buildDarkTheme(branding),
|
||||
|
||||
@ -213,4 +213,5 @@ class ApiEndpoints {
|
||||
|
||||
// Notifications
|
||||
static const String notifications = '/notifications';
|
||||
static const String notificationsTrigger = '/notifications/trigger';
|
||||
}
|
||||
|
||||
54
lib/core/utils/column_search_paging.dart
Normal file
54
lib/core/utils/column_search_paging.dart
Normal file
@ -0,0 +1,54 @@
|
||||
import 'table_search.dart';
|
||||
|
||||
/// Tracks full-dataset mode for table column search.
|
||||
///
|
||||
/// First activation loads every row with `limit = total`. Further typing
|
||||
/// filters client-side. Clearing restores the previous page size.
|
||||
class ColumnSearchPaging {
|
||||
ColumnSearchPaging({this.defaultLimit = 20});
|
||||
|
||||
final int defaultLimit;
|
||||
int? _previousLimit;
|
||||
bool _active = false;
|
||||
|
||||
bool get isActive => _active;
|
||||
|
||||
/// Marks full-dataset mode and returns the limit to fetch, or `null` if
|
||||
/// already active.
|
||||
int? beginFullDataset({
|
||||
required int currentLimit,
|
||||
required int total,
|
||||
}) {
|
||||
if (_active) return null;
|
||||
_active = true;
|
||||
_previousLimit ??= currentLimit;
|
||||
return total > 0 ? total : currentLimit;
|
||||
}
|
||||
|
||||
/// Returns the page size to restore, or `null` if not in full-dataset mode.
|
||||
int? endFullDataset() {
|
||||
if (!_active) return null;
|
||||
_active = false;
|
||||
final restored = _previousLimit ?? defaultLimit;
|
||||
_previousLimit = null;
|
||||
return restored;
|
||||
}
|
||||
|
||||
/// Legacy helper used by older `setColumnSearch` call sites.
|
||||
({String? search, int limit}) apply({
|
||||
required String search,
|
||||
required int currentLimit,
|
||||
required int total,
|
||||
}) {
|
||||
final normalized = TableSearch.normalize(search);
|
||||
if (normalized.isEmpty) {
|
||||
final restored = endFullDataset() ?? defaultLimit;
|
||||
return (search: null, limit: restored);
|
||||
}
|
||||
final limit = beginFullDataset(currentLimit: currentLimit, total: total);
|
||||
return (
|
||||
search: normalized,
|
||||
limit: limit ?? (total > 0 ? total : currentLimit),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -42,6 +42,22 @@ class DateFormatter {
|
||||
if (dayDiff < 7) return '$dayDiff days ago';
|
||||
return displayDateTime(local);
|
||||
}
|
||||
|
||||
/// Multiple date formats for client-side column search.
|
||||
static String searchableDate(DateTime? date) {
|
||||
if (date == null) return '';
|
||||
final local = date.toLocal();
|
||||
return [
|
||||
displayDate(local),
|
||||
displayDateTime(local),
|
||||
formatUserLastLogin(local),
|
||||
DateFormat('yyyy-MM-dd').format(local),
|
||||
DateFormat('dd-MM-yyyy').format(local),
|
||||
DateFormat('dd/MM/yy').format(local),
|
||||
DateFormat('d/M/yyyy').format(local),
|
||||
DateFormat('d/MM/yyyy').format(local),
|
||||
].join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
class CurrencyFormatter {
|
||||
@ -57,6 +73,12 @@ class CurrencyFormatter {
|
||||
if (amount == null) return '-';
|
||||
return _formatter.format(amount);
|
||||
}
|
||||
|
||||
/// Formatted + raw numeric text for client-side column search.
|
||||
static String searchable(double? amount) {
|
||||
if (amount == null) return '';
|
||||
return '${format(amount)} $amount';
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts `snake_case` / `kebab-case` keys into readable Title Case labels.
|
||||
|
||||
@ -8,6 +8,15 @@ class AppBreakpoints {
|
||||
static const double tablet = 600;
|
||||
static const double desktop = 1024;
|
||||
static const double wide = 1440;
|
||||
|
||||
/// Form grid: 2 cols from this width up.
|
||||
static const double formSmall = 600;
|
||||
|
||||
/// Form grid: 3 cols from this width up.
|
||||
static const double formMedium = 900;
|
||||
|
||||
/// Form grid: 4 cols from this width up.
|
||||
static const double formLarge = 1200;
|
||||
}
|
||||
|
||||
extension ResponsiveContext on BuildContext {
|
||||
@ -22,23 +31,27 @@ extension ResponsiveContext on BuildContext {
|
||||
return double.infinity;
|
||||
}
|
||||
|
||||
/// Form grid columns: 4 on medium+, 2 on small screens.
|
||||
/// Form grid columns: 4 / 3 / 2 / 1 by viewport width.
|
||||
int get formGridColumns {
|
||||
final width = MediaQuery.sizeOf(this).width;
|
||||
return formGridColumnsForWidth(width);
|
||||
}
|
||||
}
|
||||
|
||||
/// Responsive form field columns based on viewport width.
|
||||
/// Responsive form field columns:
|
||||
/// large ≥1200 → 4, medium ≥900 → 3, small ≥600 → 2, else → 1.
|
||||
int formGridColumnsForWidth(
|
||||
double width, {
|
||||
int xsColumns = 1,
|
||||
int smallColumns = 2,
|
||||
int mediumColumns = 4,
|
||||
int mediumColumns = 3,
|
||||
int largeColumns = 4,
|
||||
double mediumBreakpoint = AppBreakpoints.tablet,
|
||||
double largeBreakpoint = AppBreakpoints.desktop,
|
||||
double smallBreakpoint = AppBreakpoints.formSmall,
|
||||
double mediumBreakpoint = AppBreakpoints.formMedium,
|
||||
double largeBreakpoint = AppBreakpoints.formLarge,
|
||||
}) {
|
||||
if (width >= largeBreakpoint) return largeColumns;
|
||||
if (width >= mediumBreakpoint) return mediumColumns;
|
||||
return smallColumns;
|
||||
if (width >= smallBreakpoint) return smallColumns;
|
||||
return xsColumns;
|
||||
}
|
||||
|
||||
@ -67,6 +67,27 @@ final assetFormLookupsProvider =
|
||||
);
|
||||
});
|
||||
|
||||
/// Lightweight lookups for Asset Master list filters only.
|
||||
/// Avoids [assetFormLookupsProvider] (PO/GRN/vendors/users/options) on the list.
|
||||
final assetListFilterLookupsProvider = FutureProvider<
|
||||
({
|
||||
List<FilterOptionModel> locations,
|
||||
List<AssetDropdownOption> statuses,
|
||||
})>((ref) async {
|
||||
final master = ref.watch(masterRemoteDataSourceProvider);
|
||||
final locations = await _safeOptions(master.listLocations);
|
||||
|
||||
List<AssetDropdownOption> statuses = const [];
|
||||
try {
|
||||
final result = await ref.read(assetRepositoryProvider).getStatuses();
|
||||
if (result.failure == null && result.data != null) {
|
||||
statuses = result.data!;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return (locations: locations, statuses: statuses);
|
||||
});
|
||||
|
||||
final assetDropdownOptionsProvider =
|
||||
FutureProvider.autoDispose<AssetDropdownOptionsModel>((ref) async {
|
||||
return _safeAssetOptions(ref);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
@ -59,6 +60,8 @@ final assetsListProvider =
|
||||
);
|
||||
|
||||
class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
||||
final _columnSearch = ColumnSearchPaging();
|
||||
|
||||
@override
|
||||
Future<AssetsListState> build() async {
|
||||
return _load(const AssetListQuery(limit: 20));
|
||||
@ -134,6 +137,27 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.query.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.query.copyWith(search: null, page: 1, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
|
||||
}
|
||||
|
||||
void setStatusFilter(String? status) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
@ -199,6 +223,7 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
||||
return false;
|
||||
}
|
||||
await refresh();
|
||||
ref.invalidate(myMaintenanceProvider);
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncData(current.copyWith(actionSuccess: 'Asset deleted'));
|
||||
@ -331,6 +356,7 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
|
||||
if (result.failure != null) throw result.failure!;
|
||||
await reload();
|
||||
ref.invalidate(assetsListProvider);
|
||||
ref.invalidate(myMaintenanceProvider);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@ -339,6 +365,7 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
|
||||
final result = await repository.deleteAsset(arg);
|
||||
if (result.failure != null) return false;
|
||||
ref.invalidate(assetsListProvider);
|
||||
ref.invalidate(myMaintenanceProvider);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -476,6 +503,7 @@ class AssetFormNotifier extends AutoDisposeFamilyAsyncNotifier<AssetModel?, Stri
|
||||
final result = await repository.createAsset(data);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
ref.invalidate(assetsListProvider);
|
||||
ref.invalidate(myMaintenanceProvider);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@ -485,6 +513,7 @@ class AssetFormNotifier extends AutoDisposeFamilyAsyncNotifier<AssetModel?, Stri
|
||||
if (result.failure != null) throw result.failure!;
|
||||
ref.invalidate(assetsListProvider);
|
||||
ref.invalidate(assetDetailProvider(id));
|
||||
ref.invalidate(myMaintenanceProvider);
|
||||
|
||||
final fresh = await repository.getAssetById(id);
|
||||
final asset = fresh.data ?? result.data;
|
||||
@ -664,11 +693,11 @@ class MyMaintenanceState {
|
||||
}
|
||||
|
||||
final myMaintenanceProvider =
|
||||
AsyncNotifierProvider<MyMaintenanceNotifier, MyMaintenanceState>(
|
||||
AsyncNotifierProvider.autoDispose<MyMaintenanceNotifier, MyMaintenanceState>(
|
||||
MyMaintenanceNotifier.new,
|
||||
);
|
||||
|
||||
class MyMaintenanceNotifier extends AsyncNotifier<MyMaintenanceState> {
|
||||
class MyMaintenanceNotifier extends AutoDisposeAsyncNotifier<MyMaintenanceState> {
|
||||
@override
|
||||
Future<MyMaintenanceState> build() async {
|
||||
return _load(const MyMaintenanceState());
|
||||
|
||||
@ -22,7 +22,7 @@ import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../providers/assets_provider.dart';
|
||||
import '../providers/asset_form_lookups_provider.dart';
|
||||
import '../widgets/asset_form_panel.dart';
|
||||
import 'asset_form_screen.dart';
|
||||
import '../widgets/asset_maintenance_panel.dart';
|
||||
import '../widgets/asset_side_panels.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
@ -39,6 +39,7 @@ class AssetDetailScreen extends ConsumerStatefulWidget {
|
||||
class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -46,6 +47,23 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always hit GET /assets/{id} (+ related) when opening view.
|
||||
ref.invalidate(assetDetailProvider(widget.assetId));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AssetDetailScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.assetId != widget.assetId) {
|
||||
ref.invalidate(assetDetailProvider(widget.assetId));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
@ -81,7 +99,7 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
|
||||
if (canEdit)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
openAssetFormPanel(context, ref, assetId: widget.assetId),
|
||||
openAssetForm(context, ref, assetId: widget.assetId),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
@ -342,6 +360,7 @@ class _OverviewTab extends ConsumerWidget {
|
||||
asset: asset,
|
||||
);
|
||||
if (saved == true && context.mounted) {
|
||||
ref.invalidate(myMaintenanceProvider);
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(
|
||||
|
||||
1724
lib/modules/assets/presentation/screens/asset_form_screen.dart
Normal file
1724
lib/modules/assets/presentation/screens/asset_form_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
@ -29,7 +29,7 @@ import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../providers/asset_categories_provider.dart';
|
||||
import '../providers/asset_form_lookups_provider.dart';
|
||||
import '../providers/assets_provider.dart';
|
||||
import '../widgets/asset_form_panel.dart';
|
||||
import 'asset_form_screen.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
class AssetListScreen extends ConsumerStatefulWidget {
|
||||
@ -45,6 +45,10 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final assetsAsync = ref.watch(assetsListProvider);
|
||||
final filterLookups =
|
||||
ref.watch(assetListFilterLookupsProvider).valueOrNull;
|
||||
final allCategories =
|
||||
ref.watch(itemCategoriesProvider).valueOrNull ?? [];
|
||||
final canEdit = ref.can('assets', PermissionAction.update);
|
||||
final canDelete = ref.can('assets', PermissionAction.delete);
|
||||
final canExport = ref.can('assets', PermissionAction.export);
|
||||
@ -69,10 +73,8 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
onRetry: () => ref.invalidate(assetsListProvider),
|
||||
),
|
||||
data: (state) {
|
||||
final allCategories =
|
||||
ref.watch(itemCategoriesProvider).valueOrNull ?? [];
|
||||
final lookups = ref.watch(assetFormLookupsProvider).valueOrNull;
|
||||
final allLocations = lookups?.locations ?? const [];
|
||||
final allLocations = filterLookups?.locations ?? const [];
|
||||
final statuses = filterLookups?.statuses ?? const [];
|
||||
final notifier = ref.read(assetsListProvider.notifier);
|
||||
|
||||
return Column(
|
||||
@ -112,7 +114,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
module: 'assets',
|
||||
action: PermissionAction.create,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => openAssetFormPanel(context, ref),
|
||||
onPressed: () => openAssetForm(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Asset'),
|
||||
),
|
||||
@ -151,7 +153,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
query: state.query,
|
||||
categories: allCategories,
|
||||
locations: allLocations,
|
||||
statuses: lookups?.statuses ?? const [],
|
||||
statuses: statuses,
|
||||
onSearch: notifier.setSearch,
|
||||
onCategoryChanged: notifier.setCategoryFilter,
|
||||
onLocationChanged: notifier.setLocationFilter,
|
||||
@ -166,7 +168,10 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
onView: _viewAsset,
|
||||
onEdit: _editAsset,
|
||||
onDelete: _deleteAsset,
|
||||
onServerSearch: notifier.setSearch,
|
||||
onEnsureFullDataset: () =>
|
||||
notifier.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () =>
|
||||
notifier.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
@ -200,7 +205,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
}
|
||||
|
||||
void _editAsset(AssetModel asset) {
|
||||
openAssetFormPanel(context, ref, assetId: asset.id);
|
||||
openAssetForm(context, ref, assetId: asset.id);
|
||||
}
|
||||
|
||||
Future<void> _exportAssets() async {
|
||||
@ -374,7 +379,8 @@ class _AssetDataTable extends StatelessWidget {
|
||||
required this.onView,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
this.onServerSearch,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
final List<AssetModel> assets;
|
||||
@ -383,13 +389,15 @@ class _AssetDataTable extends StatelessWidget {
|
||||
final void Function(AssetModel asset) onView;
|
||||
final void Function(AssetModel asset) onEdit;
|
||||
final Future<void> Function(AssetModel asset) onDelete;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppDataTable<AssetModel>(
|
||||
wrapInCard: false,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'Asset Code',
|
||||
@ -398,7 +406,10 @@ class _AssetDataTable extends StatelessWidget {
|
||||
cellBuilder: (_, asset) {
|
||||
final code = asset.assetCode;
|
||||
if (code == null || code.isEmpty) return const Text('—');
|
||||
return _AssetCodeBadge(code: code);
|
||||
return _AssetCodeBadge(
|
||||
code: code,
|
||||
onTap: () => onView(asset),
|
||||
);
|
||||
},
|
||||
),
|
||||
AppDataColumn(
|
||||
@ -423,7 +434,7 @@ class _AssetDataTable extends StatelessWidget {
|
||||
label: 'Warranty',
|
||||
flex: 1,
|
||||
searchText: (asset) =>
|
||||
DateFormatter.displayDate(asset.warrantyExpiryDate),
|
||||
DateFormatter.searchableDate(asset.warrantyExpiryDate),
|
||||
cellBuilder: (_, asset) => Text(
|
||||
DateFormatter.displayDate(asset.warrantyExpiryDate),
|
||||
),
|
||||
@ -528,7 +539,10 @@ class _AssetMobileList extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (asset.assetCode != null && asset.assetCode!.isNotEmpty)
|
||||
_AssetCodeBadge(code: asset.assetCode!)
|
||||
_AssetCodeBadge(
|
||||
code: asset.assetCode!,
|
||||
onTap: () => onView(asset),
|
||||
)
|
||||
else
|
||||
const Text('—'),
|
||||
Text('${asset.assetCategoryName ?? '—'} · ${asset.locationName ?? '—'}'),
|
||||
@ -565,26 +579,33 @@ class _AssetMobileList extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _AssetCodeBadge extends StatelessWidget {
|
||||
const _AssetCodeBadge({required this.code});
|
||||
const _AssetCodeBadge({required this.code, this.onTap});
|
||||
|
||||
final String code;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
final badge = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: AppTableCell.text(
|
||||
child: AppTableCell.link(
|
||||
code,
|
||||
onTap: onTap,
|
||||
underlined: false,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (onTap == null) return badge;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: badge,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,11 +20,29 @@ import '../../../../shared/widgets/page_header.dart';
|
||||
import '../providers/assets_provider.dart';
|
||||
import '../widgets/asset_maintenance_panel.dart';
|
||||
|
||||
class AssetMaintenanceScreen extends ConsumerWidget {
|
||||
class AssetMaintenanceScreen extends ConsumerStatefulWidget {
|
||||
const AssetMaintenanceScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<AssetMaintenanceScreen> createState() =>
|
||||
_AssetMaintenanceScreenState();
|
||||
}
|
||||
|
||||
class _AssetMaintenanceScreenState
|
||||
extends ConsumerState<AssetMaintenanceScreen> {
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always refetch My Maintenance when opening this screen.
|
||||
ref.invalidate(myMaintenanceProvider);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maintenanceAsync = ref.watch(myMaintenanceProvider);
|
||||
|
||||
return Padding(
|
||||
@ -53,7 +71,10 @@ class _MyMaintenanceBody extends ConsumerWidget {
|
||||
AssetModel asset,
|
||||
) async {
|
||||
final saved = await openSubmitMaintenancePanel(context, ref, asset: asset);
|
||||
if (saved == true && context.mounted) {
|
||||
if (!context.mounted) return;
|
||||
if (saved == true) {
|
||||
await ref.read(myMaintenanceProvider.notifier).refresh();
|
||||
if (!context.mounted) return;
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(content: Text('Maintenance log submitted')),
|
||||
@ -174,7 +195,10 @@ class _MaintenanceTable extends StatelessWidget {
|
||||
label: 'Asset Code',
|
||||
flex: 1,
|
||||
searchText: (asset) => asset.assetCode ?? '',
|
||||
cellBuilder: (_, asset) => Text(asset.assetCode ?? '—'),
|
||||
cellBuilder: (_, asset) => AppTableCell.link(
|
||||
asset.assetCode,
|
||||
onTap: () => context.push('${RouteConstants.assets}/${asset.id}'),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Asset Name',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -45,13 +45,14 @@ class SubmitMaintenanceLogPanel extends ConsumerStatefulWidget {
|
||||
|
||||
class _ChecklistRowState {
|
||||
_ChecklistRowState({
|
||||
required this.keyName,
|
||||
required this.label,
|
||||
});
|
||||
required this.required,
|
||||
}) : status = required ? null : 'OK';
|
||||
|
||||
final String keyName;
|
||||
final String label;
|
||||
String status = 'OK';
|
||||
final bool required;
|
||||
/// Null until the user picks a status (required items must choose explicitly).
|
||||
String? status;
|
||||
final TextEditingController remarksController = TextEditingController();
|
||||
|
||||
void dispose() => remarksController.dispose();
|
||||
@ -74,10 +75,11 @@ class _SubmitMaintenanceLogPanelState
|
||||
super.initState();
|
||||
final checklist = checklistForAsset(widget.asset);
|
||||
_rows = checklist
|
||||
.where((item) => item.label.trim().isNotEmpty)
|
||||
.map(
|
||||
(item) => _ChecklistRowState(
|
||||
keyName: item.key.isNotEmpty ? item.key : item.label,
|
||||
label: item.label.isNotEmpty ? item.label : item.key,
|
||||
label: item.label.trim(),
|
||||
required: item.required,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
@ -133,17 +135,34 @@ class _SubmitMaintenanceLogPanelState
|
||||
return;
|
||||
}
|
||||
|
||||
final missingRequired = _rows.where((row) {
|
||||
if (!row.required) return false;
|
||||
final statusMissing = row.status == null || row.status!.trim().isEmpty;
|
||||
final remarksMissing = row.remarksController.text.trim().isEmpty;
|
||||
return statusMissing || remarksMissing;
|
||||
}).toList();
|
||||
if (missingRequired.isNotEmpty) {
|
||||
showSidePanelSnackBar(
|
||||
context,
|
||||
'Complete required checklist items: ${missingRequired.map((r) => r.label).join(', ')}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final payload = <String, dynamic>{
|
||||
'performed_date': DateFormatter.toApiDate(_performedDate),
|
||||
'checklist_json': _rows
|
||||
.map(
|
||||
(row) => {
|
||||
'key': row.keyName,
|
||||
'status': row.status,
|
||||
if (row.remarksController.text.trim().isNotEmpty)
|
||||
'remarks': row.remarksController.text.trim(),
|
||||
(row) {
|
||||
final remarks = row.remarksController.text.trim();
|
||||
return <String, dynamic>{
|
||||
'label': row.label,
|
||||
'status': row.status,
|
||||
'remarks': remarks.isEmpty ? null : remarks,
|
||||
'required': row.required,
|
||||
};
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
@ -376,27 +395,37 @@ class _ChecklistItemCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
row.label,
|
||||
row.required ? '${row.label} *' : row.label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
AppDropdown<String>(
|
||||
label: 'Status',
|
||||
label: row.required ? 'Status *' : 'Status',
|
||||
isDense: true,
|
||||
value: row.status,
|
||||
hint: 'Select status',
|
||||
options: const [
|
||||
AppDropdownOption(value: 'OK', label: 'OK'),
|
||||
AppDropdownOption(value: 'NOT_OK', label: 'Not OK'),
|
||||
AppDropdownOption(value: 'NA', label: 'N/A'),
|
||||
],
|
||||
onChanged: onStatusChanged,
|
||||
validator: row.required
|
||||
? (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Status is required' : null
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
AppTextField(
|
||||
controller: row.remarksController,
|
||||
label: 'Item remarks',
|
||||
label: row.required ? 'Item remarks *' : 'Item remarks',
|
||||
validator: row.required
|
||||
? (v) => (v == null || v.trim().isEmpty)
|
||||
? 'Remarks are required for this checklist item'
|
||||
: null
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -454,12 +483,19 @@ class _MaintenanceLogTile extends StatelessWidget {
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: log.checklistJson.map((item) {
|
||||
final key = item['key']?.toString() ?? 'Item';
|
||||
final status = item['status']?.toString() ?? '—';
|
||||
final label = item['label']?.toString().trim();
|
||||
final rawStatus = item['status']?.toString().trim() ?? '—';
|
||||
final status = rawStatus.replaceAll('_', ' ');
|
||||
final remarks = item['remarks']?.toString().trim();
|
||||
final title =
|
||||
(label != null && label.isNotEmpty) ? label : 'Item';
|
||||
final chipText = (remarks != null && remarks.isNotEmpty)
|
||||
? '$title: $status — $remarks'
|
||||
: '$title: $status';
|
||||
return Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(
|
||||
'$key: $status',
|
||||
chipText,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
|
||||
@ -135,12 +135,20 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
Future<void> _pickDate({
|
||||
required DateTime? current,
|
||||
required void Function(DateTime date) onPicked,
|
||||
DateTime? firstDate,
|
||||
DateTime? lastDate,
|
||||
}) async {
|
||||
final first = firstDate ?? DateTime(2000);
|
||||
final last = lastDate ?? DateTime(2100);
|
||||
var initial = current ?? DateTime.now();
|
||||
if (initial.isBefore(first)) initial = first;
|
||||
if (initial.isAfter(last)) initial = last;
|
||||
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: current ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
initialDate: initial,
|
||||
firstDate: first,
|
||||
lastDate: last,
|
||||
helpText: 'Select date',
|
||||
);
|
||||
if (picked != null) {
|
||||
@ -148,12 +156,38 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onStartDatePicked(DateTime date) {
|
||||
final clearedEnd = _endDate != null &&
|
||||
DateTime(_endDate!.year, _endDate!.month, _endDate!.day)
|
||||
.isBefore(DateTime(date.year, date.month, date.day));
|
||||
_startDate = date;
|
||||
if (clearedEnd) {
|
||||
_endDate = null;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
showSidePanelSnackBar(
|
||||
context,
|
||||
'End date cleared. Please select an end date on or after the start date.',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_startDate == null || _endDate == null) {
|
||||
showSidePanelSnackBar(context, 'Please select start and end dates');
|
||||
return;
|
||||
}
|
||||
if (DateTime(_endDate!.year, _endDate!.month, _endDate!.day).isBefore(
|
||||
DateTime(_startDate!.year, _startDate!.month, _startDate!.day),
|
||||
)) {
|
||||
showSidePanelSnackBar(
|
||||
context,
|
||||
'End date cannot be earlier than start date',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_vendorId == null) {
|
||||
showSidePanelSnackBar(context, 'Please select a vendor');
|
||||
@ -262,7 +296,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
value: _startDate,
|
||||
onPick: () => _pickDate(
|
||||
current: _startDate,
|
||||
onPicked: (date) => setState(() => _startDate = date),
|
||||
onPicked: _onStartDatePicked,
|
||||
),
|
||||
),
|
||||
right: _SidePanelDateField(
|
||||
@ -271,7 +305,8 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
value: _endDate,
|
||||
onPick: () => _pickDate(
|
||||
current: _endDate,
|
||||
onPicked: (date) => setState(() => _endDate = date),
|
||||
firstDate: _startDate ?? DateTime(2000),
|
||||
onPicked: (date) => _endDate = date,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -282,7 +317,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
value: _renewalDate,
|
||||
onPick: () => _pickDate(
|
||||
current: _renewalDate,
|
||||
onPicked: (date) => setState(() => _renewalDate = date),
|
||||
onPicked: (date) => _renewalDate = date,
|
||||
),
|
||||
),
|
||||
right: AppTextField(
|
||||
@ -928,12 +963,20 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
Future<void> _pickDate({
|
||||
required DateTime? current,
|
||||
required void Function(DateTime date) onPicked,
|
||||
DateTime? firstDate,
|
||||
DateTime? lastDate,
|
||||
}) async {
|
||||
final first = firstDate ?? DateTime(2000);
|
||||
final last = lastDate ?? DateTime(2100);
|
||||
var initial = current ?? DateTime.now();
|
||||
if (initial.isBefore(first)) initial = first;
|
||||
if (initial.isAfter(last)) initial = last;
|
||||
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: current ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
initialDate: initial,
|
||||
firstDate: first,
|
||||
lastDate: last,
|
||||
helpText: 'Select date',
|
||||
);
|
||||
if (picked != null) {
|
||||
@ -941,6 +984,23 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onStartDatePicked(DateTime date) {
|
||||
final clearedEnd = _endDate != null &&
|
||||
DateTime(_endDate!.year, _endDate!.month, _endDate!.day)
|
||||
.isBefore(DateTime(date.year, date.month, date.day));
|
||||
_startDate = date;
|
||||
if (clearedEnd) {
|
||||
_endDate = null;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
showSidePanelSnackBar(
|
||||
context,
|
||||
'End date cleared. Please select an end date on or after the start date.',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPayload() {
|
||||
final sumInsured = double.tryParse(_sumInsuredController.text.trim());
|
||||
final annualPremium = double.tryParse(_annualPremiumController.text.trim());
|
||||
@ -977,6 +1037,15 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
showSidePanelSnackBar(context, 'Please select start and end dates');
|
||||
return;
|
||||
}
|
||||
if (DateTime(_endDate!.year, _endDate!.month, _endDate!.day).isBefore(
|
||||
DateTime(_startDate!.year, _startDate!.month, _startDate!.day),
|
||||
)) {
|
||||
showSidePanelSnackBar(
|
||||
context,
|
||||
'End date cannot be earlier than start date',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
@ -1096,7 +1165,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
value: _startDate,
|
||||
onPick: () => _pickDate(
|
||||
current: _startDate,
|
||||
onPicked: (date) => setState(() => _startDate = date),
|
||||
onPicked: _onStartDatePicked,
|
||||
),
|
||||
),
|
||||
right: _SidePanelDateField(
|
||||
@ -1105,7 +1174,8 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
value: _endDate,
|
||||
onPick: () => _pickDate(
|
||||
current: _endDate,
|
||||
onPicked: (date) => setState(() => _endDate = date),
|
||||
firstDate: _startDate ?? DateTime(2000),
|
||||
onPicked: (date) => _endDate = date,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -1116,7 +1186,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
value: _renewalDate,
|
||||
onPick: () => _pickDate(
|
||||
current: _renewalDate,
|
||||
onPicked: (date) => setState(() => _renewalDate = date),
|
||||
onPicked: (date) => _renewalDate = date,
|
||||
),
|
||||
),
|
||||
right: _SidePanelDateField(
|
||||
@ -1124,7 +1194,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
value: _premiumPaidDate,
|
||||
onPick: () => _pickDate(
|
||||
current: _premiumPaidDate,
|
||||
onPicked: (date) => setState(() => _premiumPaidDate = date),
|
||||
onPicked: (date) => _premiumPaidDate = date,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
|
||||
import '../../../../shared/models/audit_log_model.dart';
|
||||
@ -82,6 +83,8 @@ final auditLogsListProvider =
|
||||
);
|
||||
|
||||
class AuditLogsListNotifier extends AsyncNotifier<AuditLogsListState> {
|
||||
final _columnSearch = ColumnSearchPaging();
|
||||
|
||||
@override
|
||||
Future<AuditLogsListState> build() async {
|
||||
ref.keepAlive();
|
||||
@ -148,6 +151,27 @@ class AuditLogsListNotifier extends AsyncNotifier<AuditLogsListState> {
|
||||
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.query.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.query.copyWith(search: null, page: 1, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
|
||||
@ -230,15 +230,10 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
|
||||
: _AuditDataTable(
|
||||
items: state.items,
|
||||
onView: _viewLog,
|
||||
onServerSearch: (value) {
|
||||
_searchController.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: value.length,
|
||||
),
|
||||
);
|
||||
notifier.setSearch(value);
|
||||
},
|
||||
onEnsureFullDataset: () =>
|
||||
notifier.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () =>
|
||||
notifier.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -379,12 +374,14 @@ class _AuditDataTable extends StatelessWidget {
|
||||
const _AuditDataTable({
|
||||
required this.items,
|
||||
required this.onView,
|
||||
this.onServerSearch,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
final List<AuditLogEntryModel> items;
|
||||
final void Function(AuditLogEntryModel log) onView;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -392,12 +389,13 @@ class _AuditDataTable extends StatelessWidget {
|
||||
wrapInCard: false,
|
||||
rows: items,
|
||||
emptyMessage: 'No audit logs found',
|
||||
onServerSearchChanged: onServerSearch,
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'When',
|
||||
flex: 2,
|
||||
searchText: (row) => DateFormatter.displayDateTime(row.performedAt),
|
||||
searchText: (row) => DateFormatter.searchableDate(row.performedAt),
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
DateFormatter.displayDateTime(row.performedAt),
|
||||
),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
@ -61,6 +62,8 @@ final grnListProvider =
|
||||
);
|
||||
|
||||
class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
|
||||
final _columnSearch = ColumnSearchPaging();
|
||||
|
||||
@override
|
||||
Future<GrnListState> build() async {
|
||||
return _load(const GrnListQuery(limit: 20));
|
||||
@ -172,6 +175,27 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
|
||||
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.query.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.query.copyWith(search: null, page: 1, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
|
||||
}
|
||||
|
||||
void setStatusFilter(String? status) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
|
||||
@ -32,6 +32,24 @@ class GrnDetailScreen extends ConsumerStatefulWidget {
|
||||
class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
bool _isWorking = false;
|
||||
bool _isDownloadingPdf = false;
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always hit GET /grn/{id} when opening view.
|
||||
ref.invalidate(grnDetailProvider(widget.grnId));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant GrnDetailScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.grnId != widget.grnId) {
|
||||
ref.invalidate(grnDetailProvider(widget.grnId));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -52,13 +70,10 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
data: (grn) {
|
||||
final lookups = lookupsAsync.asData?.value;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1200),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_DetailHeader(
|
||||
grn: grn,
|
||||
isWorking: _isWorking,
|
||||
@ -100,8 +115,6 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
const SizedBox(height: 20),
|
||||
_DetailFooter(grn: grn),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -56,18 +56,29 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
final List<GrnLineItemDraft> _lines = [];
|
||||
bool _isSubmitting = false;
|
||||
String? _populatedSignature;
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.isEditing) {
|
||||
_grnDate = DateTime.now();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) ref.invalidate(grnLookupsProvider);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad) return;
|
||||
_requestedFreshLoad = true;
|
||||
if (!widget.isEditing) {
|
||||
ref.invalidate(grnLookupsProvider);
|
||||
return;
|
||||
}
|
||||
// Always hit GET /grn/{id} when opening edit.
|
||||
ref.invalidate(grnFormProvider(widget.grnId));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
|
||||
@ -158,15 +158,12 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
||||
grns: state.grns,
|
||||
onView: _viewGrn,
|
||||
onEdit: canEdit ? _editGrn : null,
|
||||
onServerSearch: (value) {
|
||||
_searchController.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: value.length,
|
||||
),
|
||||
);
|
||||
ref.read(grnListProvider.notifier).setSearch(value);
|
||||
},
|
||||
onEnsureFullDataset: () => ref
|
||||
.read(grnListProvider.notifier)
|
||||
.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () => ref
|
||||
.read(grnListProvider.notifier)
|
||||
.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -297,37 +294,50 @@ class _GrnDataTable extends StatelessWidget {
|
||||
required this.grns,
|
||||
required this.onView,
|
||||
this.onEdit,
|
||||
this.onServerSearch,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
final List<GrnModel> grns;
|
||||
final ValueChanged<GrnModel> onView;
|
||||
final ValueChanged<GrnModel>? onEdit;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppDataTable<GrnModel>(
|
||||
wrapInCard: false,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'GRN Number',
|
||||
flex: 2,
|
||||
searchText: (grn) => grn.grnNumber ?? '',
|
||||
cellBuilder: (_, grn) => Text(grn.grnNumber ?? '—'),
|
||||
cellBuilder: (_, grn) => AppTableCell.link(
|
||||
grn.grnNumber,
|
||||
onTap: () => onView(grn),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Date',
|
||||
flex: 1,
|
||||
searchText: (grn) => DateFormatter.displayDate(grn.grnDate),
|
||||
searchText: (grn) => DateFormatter.searchableDate(grn.grnDate),
|
||||
cellBuilder: (_, grn) => Text(DateFormatter.displayDate(grn.grnDate)),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'PO Number',
|
||||
flex: 2,
|
||||
searchText: (grn) => grn.poNumber ?? '',
|
||||
cellBuilder: (_, grn) => Text(grn.poNumber ?? '—'),
|
||||
cellBuilder: (_, grn) => AppTableCell.link(
|
||||
grn.poNumber,
|
||||
onTap: grn.poId == null
|
||||
? null
|
||||
: () => context.push(
|
||||
'${RouteConstants.purchaseOrders}/${grn.poId}',
|
||||
),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Vendor',
|
||||
@ -404,18 +414,26 @@ class _GrnCardList extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
child: AppTableCell.link(
|
||||
grn.grnNumber ?? 'GRN #${grn.id}',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
onTap: () => onView(grn),
|
||||
),
|
||||
),
|
||||
GrnStatusChip(status: grn.status, compact: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('PO: ${grn.poNumber ?? '—'}'),
|
||||
if (grn.poNumber != null && grn.poNumber!.trim().isNotEmpty)
|
||||
AppTableCell.link(
|
||||
'PO: ${grn.poNumber}',
|
||||
onTap: grn.poId == null
|
||||
? null
|
||||
: () => context.push(
|
||||
'${RouteConstants.purchaseOrders}/${grn.poId}',
|
||||
),
|
||||
)
|
||||
else
|
||||
const Text('PO: —'),
|
||||
Text('Vendor: ${grn.vendorName ?? '—'}'),
|
||||
Text('Location: ${grn.locationName ?? '—'}'),
|
||||
Text('Date: ${DateFormatter.displayDate(grn.grnDate)}'),
|
||||
|
||||
@ -226,13 +226,14 @@ const masterDefinitions = <MasterDefinition>[
|
||||
showInList: true,
|
||||
showInForm: false,
|
||||
),
|
||||
MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'is_asset_item',
|
||||
label: 'Asset Item',
|
||||
type: MasterFieldType.boolean,
|
||||
required: true,
|
||||
showInList: true,
|
||||
),
|
||||
MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'item_category_id',
|
||||
label: 'Category',
|
||||
@ -591,6 +592,10 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
|
||||
final value = row[field.key];
|
||||
if (value == null || value == '') return '—';
|
||||
|
||||
if (field.key == 'is_asset_item') {
|
||||
return masterIsAssetItem(value) ? 'Asset' : 'Stock';
|
||||
}
|
||||
|
||||
if (field.key == 'tags') {
|
||||
if (value is List) {
|
||||
final tags = value
|
||||
@ -670,6 +675,13 @@ String masterStatusValue(Map<String, dynamic> row) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
bool masterIsAssetItem(Object? value) {
|
||||
if (value == true || value == 1) return true;
|
||||
if (value == false || value == 0) return false;
|
||||
final text = value?.toString().trim().toLowerCase() ?? '';
|
||||
return text == 'true' || text == '1' || text == 'yes';
|
||||
}
|
||||
|
||||
/// Category list filter for Items form: ASSET when Asset Item is checked.
|
||||
String itemCategoryTypeForValues(Map<String, dynamic> values) =>
|
||||
values['is_asset_item'] == true ? 'ASSET' : 'STOCK';
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
@ -95,6 +96,10 @@ final masterListProvider = AsyncNotifierProvider.family<
|
||||
MasterListNotifier, MasterListState, String>(MasterListNotifier.new);
|
||||
|
||||
class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
|
||||
final _columnSearch = ColumnSearchPaging(
|
||||
defaultLimit: AppConstants.defaultPageSize,
|
||||
);
|
||||
|
||||
MasterDefinition get _definition {
|
||||
final def = masterDefinitionById(arg);
|
||||
if (def == null) throw StateError('Unknown master: $arg');
|
||||
@ -184,6 +189,25 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
|
||||
await _reload(page: 1, search: search);
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await _reload(page: 1, limit: limit, search: '');
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
_reload(page: 1, limit: limit, search: '');
|
||||
}
|
||||
|
||||
/// Clears generic search and reloads the full list.
|
||||
Future<void> clearSearch() async {
|
||||
final current = state.valueOrNull;
|
||||
|
||||
@ -264,15 +264,10 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
canDelete: canDelete,
|
||||
onEdit: (id) => _openFormPanel(recordId: id),
|
||||
onDelete: _deleteRecord,
|
||||
onServerSearch: (value) {
|
||||
_searchController.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: value.length,
|
||||
),
|
||||
);
|
||||
notifier.setSearch(value);
|
||||
},
|
||||
onEnsureFullDataset: () =>
|
||||
notifier.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () =>
|
||||
notifier.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -294,7 +289,8 @@ class _MasterListTable extends StatelessWidget {
|
||||
required this.canDelete,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
this.onServerSearch,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
final MasterDefinition definition;
|
||||
@ -304,7 +300,8 @@ class _MasterListTable extends StatelessWidget {
|
||||
final bool canDelete;
|
||||
final ValueChanged<String> onEdit;
|
||||
final ValueChanged<Map<String, dynamic>> onDelete;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -312,15 +309,28 @@ class _MasterListTable extends StatelessWidget {
|
||||
|
||||
return AppDataTable<Map<String, dynamic>>(
|
||||
wrapInCard: false,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
emptyMessage: 'No ${definition.title.toLowerCase()} found',
|
||||
columns: [
|
||||
...definition.listFields.map(
|
||||
(field) => AppDataColumn<Map<String, dynamic>>(
|
||||
label: field.label,
|
||||
label: field.key == 'is_asset_item' ? 'Type' : field.label,
|
||||
flex: _columnFlex(field),
|
||||
searchText: (row) => masterCellValue(row, field),
|
||||
cellBuilder: (_, row) => Text(masterCellValue(row, field)),
|
||||
cellBuilder: (_, row) {
|
||||
if (field.key == 'is_asset_item') {
|
||||
final isAsset = masterIsAssetItem(row['is_asset_item']);
|
||||
return TableStatusBadge(
|
||||
label: isAsset ? 'Asset' : 'Stock',
|
||||
color: isAsset
|
||||
? const Color(0xFF2563EB)
|
||||
: const Color(0xFF64748B),
|
||||
compact: true,
|
||||
);
|
||||
}
|
||||
return Text(masterCellValue(row, field));
|
||||
},
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
@ -369,6 +379,7 @@ class _MasterListTable extends StatelessWidget {
|
||||
int _columnFlex(MasterFieldDef field) {
|
||||
return switch (field.key) {
|
||||
'code' || 'item_code' || 'series_code' || 'hsn_code_id' => 1,
|
||||
'is_asset_item' => 1,
|
||||
'name' || 'item_name' || 'description' || 'term_name' => 3,
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
@ -106,6 +106,39 @@ class MasterRemoteDataSource {
|
||||
Future<List<FilterOptionModel>> listPaymentTerms() =>
|
||||
_listOptions(ApiEndpoints.paymentTerms);
|
||||
|
||||
/// Payment terms with `credit_days` for vendor credit-period autofill.
|
||||
Future<
|
||||
({
|
||||
List<FilterOptionModel> options,
|
||||
Map<int, int> creditDaysById,
|
||||
})> listPaymentTermsWithCreditDays() async {
|
||||
final rows = await _listAllMaps(ApiEndpoints.paymentTerms);
|
||||
final options = <FilterOptionModel>[];
|
||||
final creditDaysById = <int, int>{};
|
||||
|
||||
for (final item in rows) {
|
||||
if (!isActiveOptionRow(item)) continue;
|
||||
final id = item['id']?.toString() ?? '';
|
||||
if (id.isEmpty) continue;
|
||||
final name = _optionLabel(item);
|
||||
if (name.isEmpty) continue;
|
||||
|
||||
final idInt = int.tryParse(id);
|
||||
final creditDays = _asInt(
|
||||
item['credit_days'] ??
|
||||
item['credit_period_days'] ??
|
||||
item['days'],
|
||||
);
|
||||
if (idInt != null && creditDays != null) {
|
||||
creditDaysById[idInt] = creditDays;
|
||||
}
|
||||
|
||||
options.add(FilterOptionModel(id: id, name: name));
|
||||
}
|
||||
|
||||
return (options: options, creditDaysById: creditDaysById);
|
||||
}
|
||||
|
||||
Future<List<FilterOptionModel>> listDeliveryTerms() =>
|
||||
_listOptions(ApiEndpoints.deliveryTerms);
|
||||
|
||||
|
||||
@ -171,6 +171,25 @@ class PurchaseOrderRemoteDataSource {
|
||||
);
|
||||
}
|
||||
|
||||
/// POST /notifications/trigger — send PO_SUBMIT_APPROVAL emails.
|
||||
Future<String> triggerApprovalNotification(String poId) async {
|
||||
final response = await dio.post(
|
||||
ApiEndpoints.notificationsTrigger,
|
||||
data: {
|
||||
'template_code': 'PO_SUBMIT_APPROVAL',
|
||||
'po_id': int.tryParse(poId) ?? poId,
|
||||
},
|
||||
);
|
||||
final body = response.data;
|
||||
if (body is Map) {
|
||||
final message = body['message'];
|
||||
if (message is String && message.trim().isNotEmpty) {
|
||||
return message.trim();
|
||||
}
|
||||
}
|
||||
return 'Approval notification sent';
|
||||
}
|
||||
|
||||
Future<List<int>> downloadPurchaseOrderPdf(String id) async {
|
||||
final response = await dio.get<List<int>>(
|
||||
ApiEndpoints.purchaseOrderPdf(id),
|
||||
|
||||
@ -121,6 +121,11 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository {
|
||||
return safeApiCall(() => dataSource.cancelPurchaseOrder(id, remarks: remarks));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<String>> triggerApprovalNotification(String poId) {
|
||||
return safeApiCall(() => dataSource.triggerApprovalNotification(poId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<List<int>>> downloadPurchaseOrderPdf(String id) {
|
||||
return safeApiCall(() => dataSource.downloadPurchaseOrderPdf(id));
|
||||
|
||||
@ -34,6 +34,11 @@ abstract class PurchaseOrderRepository {
|
||||
Map<String, dynamic>? data,
|
||||
});
|
||||
Future<Result<PurchaseOrderModel>> cancelPurchaseOrder(String id, {String? remarks});
|
||||
|
||||
/// Triggers `PO_SUBMIT_APPROVAL` email for a pending-approval PO.
|
||||
/// Returns the API success message when available.
|
||||
Future<Result<String>> triggerApprovalNotification(String poId);
|
||||
|
||||
Future<Result<List<int>>> downloadPurchaseOrderPdf(String id);
|
||||
Future<Result<List<EntityAttachmentModel>>> listAttachments(String poId);
|
||||
Future<Result<EntityAttachmentModel>> uploadAttachment(
|
||||
|
||||
@ -1,14 +1,25 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/utils/active_option.dart';
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../../shared/models/vendor_model.dart';
|
||||
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
||||
import '../../../vendors/data/repositories/vendor_repository_impl.dart';
|
||||
import '../../data/repositories/purchase_order_repository_impl.dart';
|
||||
import 'purchase_order_lookups_provider.dart';
|
||||
|
||||
/// Cached vendor options for list search enrichment only (not full PO lookups).
|
||||
final _poListVendorOptionsProvider =
|
||||
FutureProvider<List<VendorModel>>((ref) async {
|
||||
final result = await ref.watch(vendorRepositoryProvider).listVendorOptions();
|
||||
if (result.failure != null) return const [];
|
||||
return result.data ?? const [];
|
||||
});
|
||||
|
||||
class PurchaseOrdersListState {
|
||||
const PurchaseOrdersListState({
|
||||
@ -68,6 +79,8 @@ final pendingApprovalPurchaseOrdersListProvider =
|
||||
|
||||
class PurchaseOrdersListNotifier
|
||||
extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> {
|
||||
final _columnSearch = ColumnSearchPaging();
|
||||
|
||||
@override
|
||||
Future<PurchaseOrdersListState> build() async {
|
||||
return _load(const PurchaseOrderListQuery(limit: 20));
|
||||
@ -120,6 +133,28 @@ class PurchaseOrdersListNotifier
|
||||
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
|
||||
}
|
||||
|
||||
/// Column search: load all rows once (`limit = total`), then filter client-side.
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.query.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.query.copyWith(search: null, page: 1, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
|
||||
}
|
||||
|
||||
void setStatusFilter(String? status) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
@ -181,10 +216,34 @@ class PurchaseOrdersListNotifier
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> approvePurchaseOrder(String id, {String? remarks}) async {
|
||||
final repository = ref.read(purchaseOrderRepositoryProvider);
|
||||
final result = await repository.approvePurchaseOrder(id, remarks: remarks);
|
||||
if (result.failure != null) {
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncData(current.copyWith(actionError: result.failure!.message));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
ref.invalidate(pendingApprovalPurchaseOrdersListProvider);
|
||||
ref.invalidate(grnLookupsProvider);
|
||||
await refresh();
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncData(
|
||||
current.copyWith(actionSuccess: 'Purchase order approved'),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class PendingApprovalPurchaseOrdersListNotifier
|
||||
extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> {
|
||||
final _columnSearch = ColumnSearchPaging();
|
||||
|
||||
@override
|
||||
Future<PurchaseOrdersListState> build() async {
|
||||
return _load(const PurchaseOrderListQuery(limit: 20));
|
||||
@ -237,6 +296,28 @@ class PendingApprovalPurchaseOrdersListNotifier
|
||||
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
|
||||
}
|
||||
|
||||
/// Column search: load all rows once (`limit = total`), then filter client-side.
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.query.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.query.copyWith(search: null, page: 1, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
@ -346,6 +427,14 @@ class PurchaseOrderDetailNotifier
|
||||
return result.data!;
|
||||
}
|
||||
|
||||
/// Sends PO_SUBMIT_APPROVAL notification emails to approvers.
|
||||
Future<String> triggerApprovalNotification() async {
|
||||
final repository = ref.read(purchaseOrderRepositoryProvider);
|
||||
final result = await repository.triggerApprovalNotification(arg);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
return result.data!;
|
||||
}
|
||||
|
||||
Future<List<int>> downloadPdf() async {
|
||||
final repository = ref.read(purchaseOrderRepositoryProvider);
|
||||
final result = await repository.downloadPurchaseOrderPdf(arg);
|
||||
@ -455,10 +544,17 @@ Future<List<PurchaseOrderModel>> _enrichPurchaseOrderSearch({
|
||||
|
||||
var merged = List<PurchaseOrderModel>.from(items);
|
||||
try {
|
||||
final lookups = await ref.read(purchaseOrderLookupsProvider.future);
|
||||
// Vendor-only cache — do not pull full PO master lookups on every search.
|
||||
final vendors = await ref.read(_poListVendorOptionsProvider.future);
|
||||
var vendorMatches = 0;
|
||||
for (final vendor in lookups.vendors) {
|
||||
if (!TableSearch.matches(search, [vendor.name])) continue;
|
||||
for (final vendor in vendors) {
|
||||
if (!isActiveVendorOption(
|
||||
isActive: vendor.isActive,
|
||||
status: vendor.status,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
if (!TableSearch.matches(search, [vendor.vendorName])) continue;
|
||||
if (++vendorMatches > 5) break;
|
||||
final vendorId = int.tryParse(vendor.id);
|
||||
if (vendorId == null) continue;
|
||||
@ -474,7 +570,7 @@ Future<List<PurchaseOrderModel>> _enrichPurchaseOrderSearch({
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Lookups/vendor enrichment is best-effort.
|
||||
// Vendor enrichment is best-effort.
|
||||
}
|
||||
|
||||
return merged;
|
||||
|
||||
@ -38,6 +38,26 @@ class _PurchaseOrderDetailScreenState
|
||||
extends ConsumerState<PurchaseOrderDetailScreen> {
|
||||
bool _isWorking = false;
|
||||
bool _isDownloadingPdf = false;
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always hit GET /purchase-orders/{id} when opening view.
|
||||
ref.invalidate(purchaseOrderDetailProvider(widget.purchaseOrderId));
|
||||
ref.invalidate(purchaseOrderAttachmentsProvider(widget.purchaseOrderId));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PurchaseOrderDetailScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.purchaseOrderId != widget.purchaseOrderId) {
|
||||
ref.invalidate(purchaseOrderDetailProvider(widget.purchaseOrderId));
|
||||
ref.invalidate(purchaseOrderAttachmentsProvider(widget.purchaseOrderId));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -82,6 +102,7 @@ class _PurchaseOrderDetailScreenState
|
||||
'${RouteConstants.purchaseOrders}/${order.id}/edit',
|
||||
),
|
||||
onSubmit: () => _submit(order),
|
||||
onNotify: () => _notifyApprovers(order),
|
||||
onApprove: () => _approve(order),
|
||||
onReject: () => _reject(order),
|
||||
onAmend: () => _amend(order),
|
||||
@ -162,6 +183,37 @@ class _PurchaseOrderDetailScreenState
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _notifyApprovers(PurchaseOrderModel order) async {
|
||||
if (!order.canNotifyApprovers) {
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(
|
||||
content: Text('Notifications can only be sent for pending approval POs'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _isWorking = true);
|
||||
try {
|
||||
final message = await ref
|
||||
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
|
||||
.triggerApprovalNotification();
|
||||
if (mounted) {
|
||||
showAppToastFromSnackBar(context, SnackBar(content: Text(message)));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
final message = e is Failure ? e.message : e.toString();
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isWorking = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _approve(PurchaseOrderModel order) async {
|
||||
await _runWorkflow(
|
||||
() => ref
|
||||
@ -386,6 +438,7 @@ class _DetailHeader extends StatelessWidget {
|
||||
required this.onPdf,
|
||||
required this.onEdit,
|
||||
required this.onSubmit,
|
||||
required this.onNotify,
|
||||
required this.onApprove,
|
||||
required this.onReject,
|
||||
required this.onAmend,
|
||||
@ -404,6 +457,7 @@ class _DetailHeader extends StatelessWidget {
|
||||
final VoidCallback onPdf;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onNotify;
|
||||
final VoidCallback onApprove;
|
||||
final VoidCallback onReject;
|
||||
final VoidCallback onAmend;
|
||||
@ -446,6 +500,12 @@ class _DetailHeader extends StatelessWidget {
|
||||
filled: true,
|
||||
onPressed: isWorking ? null : onSubmit,
|
||||
),
|
||||
if (canEdit && order.canNotifyApprovers)
|
||||
_HeaderActionButton(
|
||||
label: 'Notify',
|
||||
icon: Icons.notifications_outlined,
|
||||
onPressed: isWorking ? null : onNotify,
|
||||
),
|
||||
if (canApprove && order.canApprove)
|
||||
_HeaderActionButton(
|
||||
label: 'Approve',
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@ -62,6 +63,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
bool _isSubmitting = false;
|
||||
String? _populatedSignature;
|
||||
bool _defaultTermsApplied = false;
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -75,6 +77,15 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
_otherChargesController.addListener(_onChargesChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad || !widget.isEditing) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always hit GET /purchase-orders/{id} when opening edit.
|
||||
ref.invalidate(purchaseOrderFormProvider(widget.purchaseOrderId));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_discountController.removeListener(_onChargesChanged);
|
||||
@ -206,6 +217,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPayload() {
|
||||
final discount =
|
||||
(double.tryParse(_discountController.text.trim()) ?? 0).clamp(0, double.infinity);
|
||||
final freight =
|
||||
(double.tryParse(_freightController.text.trim()) ?? 0).clamp(0, double.infinity);
|
||||
final other =
|
||||
(double.tryParse(_otherChargesController.text.trim()) ?? 0).clamp(0, double.infinity);
|
||||
return {
|
||||
'po_date': DateFormatter.toApiDate(_poDate ?? DateTime.now()),
|
||||
'vendor_id': _vendorId,
|
||||
@ -216,12 +233,9 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
if (_expectedDeliveryDate != null)
|
||||
'expected_delivery_date':
|
||||
DateFormatter.toApiDate(_expectedDeliveryDate!),
|
||||
'discount_amount':
|
||||
double.tryParse(_discountController.text.trim()) ?? 0,
|
||||
'freight_charges':
|
||||
double.tryParse(_freightController.text.trim()) ?? 0,
|
||||
'other_charges':
|
||||
double.tryParse(_otherChargesController.text.trim()) ?? 0,
|
||||
'discount_amount': discount,
|
||||
'freight_charges': freight,
|
||||
'other_charges': other,
|
||||
if (_termsController.text.trim().isNotEmpty)
|
||||
'terms_and_conditions': _termsController.text.trim(),
|
||||
if (_remarksController.text.trim().isNotEmpty)
|
||||
@ -242,6 +256,28 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
if (rate == null || rate < 0) {
|
||||
return 'Enter a valid rate for line ${line.lineNo}';
|
||||
}
|
||||
final discPct = double.tryParse(line.discountController.text.trim()) ?? 0;
|
||||
if (discPct < 0 || discPct > 100) {
|
||||
return 'Discount % on line ${line.lineNo} must be between 0 and 100';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _chargesError(PoOrderTotals totals) {
|
||||
final freight = double.tryParse(_freightController.text.trim());
|
||||
if (freight == null) return 'Enter a valid freight amount';
|
||||
if (freight < 0) return 'Freight charges cannot be negative';
|
||||
|
||||
final other = double.tryParse(_otherChargesController.text.trim());
|
||||
if (other == null) return 'Enter a valid other charges amount';
|
||||
if (other < 0) return 'Other charges cannot be negative';
|
||||
|
||||
final discount = double.tryParse(_discountController.text.trim());
|
||||
if (discount == null) return 'Enter a valid discount amount';
|
||||
if (discount < 0) return 'Discount amount cannot be negative';
|
||||
if (discount > totals.maxDiscountAmount) {
|
||||
return 'Discount cannot exceed ${CurrencyFormatter.format(totals.maxDiscountAmount)}';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -260,6 +296,20 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
return;
|
||||
}
|
||||
|
||||
if (_poDate != null &&
|
||||
_expectedDeliveryDate != null &&
|
||||
_expectedDeliveryDate!.isBefore(_poDate!)) {
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Expected delivery date cannot be earlier than PO date',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_vendorId == null || _billingId == null || _shippingId == null) {
|
||||
showAppToastFromSnackBar(context,
|
||||
const SnackBar(content: Text('Please complete all required fields')),
|
||||
@ -282,6 +332,14 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
return;
|
||||
}
|
||||
|
||||
final lookups = ref.read(purchaseOrderLookupsProvider).valueOrNull;
|
||||
final totals = _computeTotals(lookups?.gstRatePctById ?? const {});
|
||||
final chargesError = _chargesError(totals);
|
||||
if (chargesError != null) {
|
||||
showAppToastFromSnackBar(context, SnackBar(content: Text(chargesError)));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final payload = _buildPayload();
|
||||
@ -330,12 +388,14 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
Future<void> _pickDate({
|
||||
required DateTime? current,
|
||||
required ValueChanged<DateTime?> onPicked,
|
||||
DateTime? firstDate,
|
||||
DateTime? lastDate,
|
||||
}) async {
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: current ?? DateTime.now(),
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2100),
|
||||
firstDate: firstDate ?? DateTime(2020),
|
||||
lastDate: lastDate ?? DateTime(2100),
|
||||
helpText: 'Select date',
|
||||
);
|
||||
if (picked != null) onPicked(picked);
|
||||
@ -398,9 +458,11 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
child: QuickAddInlineHost(
|
||||
child: QuickAddBlockable(
|
||||
child: ResponsiveFormGrid(
|
||||
xsColumns: 1,
|
||||
smallColumns: 1,
|
||||
mediumColumns: 2,
|
||||
largeColumns: 4,
|
||||
smallBreakpoint: 640,
|
||||
mediumBreakpoint: 640,
|
||||
largeBreakpoint: 1100,
|
||||
children: [
|
||||
@ -529,6 +591,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
value: _expectedDeliveryDate,
|
||||
onTap: () => _pickDate(
|
||||
current: _expectedDeliveryDate,
|
||||
firstDate: _poDate ?? DateTime(2020),
|
||||
onPicked: (d) => setState(
|
||||
() => _expectedDeliveryDate = d,
|
||||
),
|
||||
@ -852,6 +915,15 @@ class _AmountSummaryCard extends StatelessWidget {
|
||||
final bool isEditing;
|
||||
final bool isInterState;
|
||||
|
||||
String? _validateNonNegativeAmount(String? value, String fieldName) {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.isEmpty) return null;
|
||||
final amount = double.tryParse(text);
|
||||
if (amount == null) return 'Enter a valid amount';
|
||||
if (amount < 0) return 'Cannot be negative';
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateDiscount(String? value) {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.isEmpty) return null;
|
||||
@ -915,10 +987,14 @@ class _AmountSummaryCard extends StatelessWidget {
|
||||
_SummaryInputRow(
|
||||
label: 'Freight Charges',
|
||||
controller: freightController,
|
||||
validator: (v) => _validateNonNegativeAmount(v, 'Freight'),
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
),
|
||||
_SummaryInputRow(
|
||||
label: 'Other Charges',
|
||||
controller: otherChargesController,
|
||||
validator: (v) => _validateNonNegativeAmount(v, 'Other charges'),
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
),
|
||||
_SummaryInputRow(
|
||||
label: 'Discount Amount',
|
||||
@ -1057,6 +1133,9 @@ class _SummaryInputRow extends StatelessWidget {
|
||||
controller: controller,
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
||||
],
|
||||
textAlign: TextAlign.right,
|
||||
autovalidateMode: autovalidateMode,
|
||||
validator: validator,
|
||||
|
||||
@ -27,6 +27,7 @@ import '../../../../shared/widgets/app_table_shell.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../../data/repositories/purchase_order_repository_impl.dart';
|
||||
import '../providers/purchase_orders_provider.dart';
|
||||
import '../widgets/po_status_chip.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
@ -76,8 +77,10 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
!pendingOnly && ref.can('purchase_orders', PermissionAction.delete);
|
||||
final canExport =
|
||||
!pendingOnly && ref.can('purchase_orders', PermissionAction.export);
|
||||
final canApprove =
|
||||
pendingOnly && ref.can('purchase_orders', PermissionAction.approve);
|
||||
final canApprove = ref.can('purchase_orders', PermissionAction.approve);
|
||||
// API: notifications/trigger requires PURCHASE_ORDER edit.
|
||||
final canNotify =
|
||||
ref.can('purchase_orders', PermissionAction.update);
|
||||
|
||||
void listenListMessages(
|
||||
AsyncValue<PurchaseOrdersListState>? prev,
|
||||
@ -241,6 +244,8 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
onEdit: canEdit ? _editOrder : null,
|
||||
onDelete: canDelete ? _deleteOrder : null,
|
||||
onApprove: canApprove ? _approveOrder : null,
|
||||
onNotify:
|
||||
canNotify ? _notifyApprovers : null,
|
||||
))
|
||||
: _PoDataTable(
|
||||
orders: state.orders,
|
||||
@ -248,24 +253,32 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
onEdit: canEdit ? _editOrder : null,
|
||||
onDelete: canDelete ? _deleteOrder : null,
|
||||
onApprove: canApprove ? _approveOrder : null,
|
||||
onServerSearch: (value) {
|
||||
_searchController.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: value.length,
|
||||
),
|
||||
);
|
||||
onNotify: canNotify ? _notifyApprovers : null,
|
||||
onEnsureFullDataset: () {
|
||||
if (pendingOnly) {
|
||||
return ref
|
||||
.read(
|
||||
pendingApprovalPurchaseOrdersListProvider
|
||||
.notifier,
|
||||
)
|
||||
.ensureColumnSearchDataset();
|
||||
}
|
||||
return ref
|
||||
.read(purchaseOrdersListProvider.notifier)
|
||||
.ensureColumnSearchDataset();
|
||||
},
|
||||
onColumnSearchCleared: () {
|
||||
if (pendingOnly) {
|
||||
ref
|
||||
.read(
|
||||
pendingApprovalPurchaseOrdersListProvider
|
||||
.notifier,
|
||||
)
|
||||
.setSearch(value);
|
||||
.clearColumnSearchDataset();
|
||||
} else {
|
||||
ref
|
||||
.read(purchaseOrdersListProvider.notifier)
|
||||
.setSearch(value);
|
||||
.clearColumnSearchDataset();
|
||||
}
|
||||
},
|
||||
),
|
||||
@ -280,8 +293,6 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
}
|
||||
|
||||
void _viewOrder(PurchaseOrderModel order) {
|
||||
// Always hit GET /purchase-orders/{id} for the detail screen.
|
||||
ref.invalidate(purchaseOrderDetailProvider(order.id));
|
||||
context.push('${RouteConstants.purchaseOrders}/${order.id}');
|
||||
}
|
||||
|
||||
@ -342,9 +353,40 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
confirmLabel: 'Approve',
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
await ref
|
||||
.read(pendingApprovalPurchaseOrdersListProvider.notifier)
|
||||
.approvePurchaseOrder(order.id);
|
||||
if (widget.pendingApprovalOnly) {
|
||||
await ref
|
||||
.read(pendingApprovalPurchaseOrdersListProvider.notifier)
|
||||
.approvePurchaseOrder(order.id);
|
||||
return;
|
||||
}
|
||||
await ref.read(purchaseOrdersListProvider.notifier).approvePurchaseOrder(order.id);
|
||||
}
|
||||
|
||||
Future<void> _notifyApprovers(PurchaseOrderModel order) async {
|
||||
if (!order.canNotifyApprovers) {
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(
|
||||
content: Text('Notifications can only be sent for pending approval POs'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final result = await ref
|
||||
.read(purchaseOrderRepositoryProvider)
|
||||
.triggerApprovalNotification(order.id);
|
||||
if (!mounted) return;
|
||||
if (result.failure != null) {
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(result.failure!.message)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(result.data ?? 'Approval notification sent')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteOrder(PurchaseOrderModel order) async {
|
||||
@ -447,7 +489,9 @@ class _PoDataTable extends StatelessWidget {
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onApprove,
|
||||
this.onServerSearch,
|
||||
this.onNotify,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
final List<PurchaseOrderModel> orders;
|
||||
@ -455,25 +499,31 @@ class _PoDataTable extends StatelessWidget {
|
||||
final ValueChanged<PurchaseOrderModel>? onEdit;
|
||||
final ValueChanged<PurchaseOrderModel>? onDelete;
|
||||
final ValueChanged<PurchaseOrderModel>? onApprove;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
final ValueChanged<PurchaseOrderModel>? onNotify;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AppDataTable<PurchaseOrderModel>(
|
||||
wrapInCard: false,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'PO Number',
|
||||
flex: 2,
|
||||
searchText: (order) => order.poNo ?? '',
|
||||
cellBuilder: (_, order) => Text(order.poNo ?? '—'),
|
||||
cellBuilder: (_, order) => AppTableCell.link(
|
||||
order.poNo,
|
||||
onTap: () => onView(order),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Date',
|
||||
flex: 1,
|
||||
searchText: (order) => DateFormatter.displayDate(order.poDate),
|
||||
searchText: (order) => DateFormatter.searchableDate(order.poDate),
|
||||
cellBuilder: (_, order) => Text(DateFormatter.displayDate(order.poDate)),
|
||||
),
|
||||
AppDataColumn(
|
||||
@ -485,7 +535,7 @@ class _PoDataTable extends StatelessWidget {
|
||||
AppDataColumn(
|
||||
label: 'Total',
|
||||
flex: 1,
|
||||
searchText: (order) => CurrencyFormatter.format(order.totalAmount),
|
||||
searchText: (order) => CurrencyFormatter.searchable(order.totalAmount),
|
||||
cellBuilder: (_, order) => SizedBox(
|
||||
width: double.infinity,
|
||||
child: AppTableCell.text(
|
||||
@ -506,7 +556,7 @@ class _PoDataTable extends StatelessWidget {
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Actions',
|
||||
flex: onApprove != null ? 2 : 1,
|
||||
width: 88,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
cellBuilder: (_, order) => AppTableActions(
|
||||
@ -516,6 +566,12 @@ class _PoDataTable extends StatelessWidget {
|
||||
icon: Icons.visibility_outlined,
|
||||
onPressed: () => onView(order),
|
||||
),
|
||||
if (onNotify != null && order.canNotifyApprovers)
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Notify approvers',
|
||||
icon: Icons.notifications_outlined,
|
||||
onPressed: () => onNotify!(order),
|
||||
),
|
||||
if (onApprove != null && order.canApprove)
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Approve',
|
||||
@ -551,6 +607,7 @@ class _PoCardList extends StatelessWidget {
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onApprove,
|
||||
this.onNotify,
|
||||
});
|
||||
|
||||
final List<PurchaseOrderModel> orders;
|
||||
@ -558,6 +615,7 @@ class _PoCardList extends StatelessWidget {
|
||||
final ValueChanged<PurchaseOrderModel>? onEdit;
|
||||
final ValueChanged<PurchaseOrderModel>? onDelete;
|
||||
final ValueChanged<PurchaseOrderModel>? onApprove;
|
||||
final ValueChanged<PurchaseOrderModel>? onNotify;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -568,14 +626,26 @@ class _PoCardList extends StatelessWidget {
|
||||
final order = orders[index];
|
||||
return AppCard(
|
||||
child: ListTile(
|
||||
title: Text(order.poNo ?? 'PO #${order.id}'),
|
||||
title: AppTableCell.link(
|
||||
order.poNo ?? 'PO #${order.id}',
|
||||
onTap: () => onView(order),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${order.vendorName ?? '—'} · ${vendorTypeLabel(order.vendorType)}',
|
||||
),
|
||||
onTap: () => onView(order),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PoStatusChip(status: order.status, compact: true),
|
||||
if (onNotify != null && order.canNotifyApprovers) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: 'Notify approvers',
|
||||
icon: const Icon(Icons.notifications_outlined),
|
||||
onPressed: () => onNotify!(order),
|
||||
),
|
||||
],
|
||||
if (onApprove != null && order.canApprove) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
@ -589,7 +659,6 @@ class _PoCardList extends StatelessWidget {
|
||||
],
|
||||
],
|
||||
),
|
||||
onTap: () => onView(order),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
@ -41,15 +42,19 @@ class PoLineCalculation {
|
||||
required double discPct,
|
||||
required double gstPct,
|
||||
}) {
|
||||
final baseAmount = qty * rate;
|
||||
final discountAmount = baseAmount * discPct / 100;
|
||||
final qtySafe = qty < 0 ? 0.0 : qty;
|
||||
final rateSafe = rate < 0 ? 0.0 : rate;
|
||||
final discSafe = discPct < 0 ? 0.0 : (discPct > 100 ? 100.0 : discPct);
|
||||
final gstSafe = gstPct < 0 ? 0.0 : gstPct;
|
||||
final baseAmount = qtySafe * rateSafe;
|
||||
final discountAmount = baseAmount * discSafe / 100;
|
||||
final lineAmount = baseAmount - discountAmount;
|
||||
final gstAmount = lineAmount * gstPct / 100;
|
||||
final gstAmount = lineAmount * gstSafe / 100;
|
||||
return PoLineCalculation(
|
||||
baseAmount: baseAmount,
|
||||
discountAmount: discountAmount,
|
||||
lineAmount: lineAmount,
|
||||
gstAmount: gstAmount,
|
||||
lineAmount: lineAmount < 0 ? 0 : lineAmount,
|
||||
gstAmount: gstAmount < 0 ? 0 : gstAmount,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -72,7 +77,7 @@ class PoOrderTotals {
|
||||
final double taxAmount;
|
||||
final double grandTotal;
|
||||
|
||||
/// Sub Total + Tax + Freight + Other (discount cannot exceed this).
|
||||
/// Sub Total + Freight + Other (discount cannot exceed this).
|
||||
final double maxDiscountAmount;
|
||||
|
||||
static const zero = PoOrderTotals(
|
||||
@ -99,20 +104,24 @@ class PoOrderTotals {
|
||||
subTotal += line.lineAmount;
|
||||
lineTax += line.gstAmount;
|
||||
}
|
||||
// Keep order-level money fields non-negative.
|
||||
final subSafe = subTotal < 0 ? 0.0 : subTotal;
|
||||
final lineTaxSafe = lineTax < 0 ? 0.0 : lineTax;
|
||||
final freightSafe = freight < 0 ? 0.0 : freight;
|
||||
final otherSafe = otherCharges < 0 ? 0.0 : otherCharges;
|
||||
final clampedDiscount = discountAmount < 0 ? 0.0 : discountAmount;
|
||||
final taxableRaw = subTotal + freightSafe + otherSafe - clampedDiscount;
|
||||
final taxable = taxableRaw < 0 ? 0.0 : taxableRaw;
|
||||
final maxDiscount = subSafe + freightSafe + otherSafe;
|
||||
final clampedDiscount = discountAmount < 0
|
||||
? 0.0
|
||||
: (discountAmount > maxDiscount ? maxDiscount : discountAmount);
|
||||
final taxable = subSafe + freightSafe + otherSafe - clampedDiscount;
|
||||
// Apply the blended line GST rate to Taxable Amount (not Sub Total),
|
||||
// so freight / other / discount are included in the tax base.
|
||||
final tax = subTotal > 0 ? lineTax * (taxable / subTotal) : 0.0;
|
||||
final maxDiscount = subTotal + lineTax + freightSafe + otherSafe;
|
||||
final tax = subSafe > 0 ? lineTaxSafe * (taxable / subSafe) : 0.0;
|
||||
final grandTotal = taxable + tax;
|
||||
return PoOrderTotals(
|
||||
subTotal: subTotal,
|
||||
taxableAmount: taxable,
|
||||
taxAmount: tax,
|
||||
subTotal: subSafe,
|
||||
taxableAmount: taxable < 0 ? 0 : taxable,
|
||||
taxAmount: tax < 0 ? 0 : tax,
|
||||
grandTotal: grandTotal < 0 ? 0 : grandTotal,
|
||||
maxDiscountAmount: maxDiscount < 0 ? 0 : maxDiscount,
|
||||
);
|
||||
@ -122,9 +131,12 @@ class PoOrderTotals {
|
||||
class PoLineItemDraft {
|
||||
PoLineItemDraft({
|
||||
this.itemId,
|
||||
this.itemName,
|
||||
this.itemCode,
|
||||
required this.lineNo,
|
||||
TextEditingController? qtyController,
|
||||
this.uomId,
|
||||
this.uomName,
|
||||
TextEditingController? rateController,
|
||||
TextEditingController? discountController,
|
||||
this.gstRateId,
|
||||
@ -135,9 +147,14 @@ class PoLineItemDraft {
|
||||
discountController ?? TextEditingController(text: '0');
|
||||
|
||||
int? itemId;
|
||||
/// Kept so edit can show the label even if the item is inactive / missing
|
||||
/// from the active dropdown lookups.
|
||||
String? itemName;
|
||||
String? itemCode;
|
||||
int lineNo;
|
||||
final TextEditingController qtyController;
|
||||
int? uomId;
|
||||
String? uomName;
|
||||
final TextEditingController rateController;
|
||||
final TextEditingController discountController;
|
||||
int? gstRateId;
|
||||
@ -146,10 +163,13 @@ class PoLineItemDraft {
|
||||
factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) {
|
||||
return PoLineItemDraft(
|
||||
itemId: item.itemId,
|
||||
itemName: item.itemName,
|
||||
itemCode: item.itemCode,
|
||||
lineNo: item.lineNo ?? 1,
|
||||
qtyController:
|
||||
TextEditingController(text: item.orderedQty?.toString() ?? ''),
|
||||
uomId: item.uomId,
|
||||
uomName: item.uomName,
|
||||
rateController: TextEditingController(text: item.rate?.toString() ?? ''),
|
||||
discountController:
|
||||
TextEditingController(text: item.discountPct?.toString() ?? '0'),
|
||||
@ -451,7 +471,22 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
void _onItemChanged(int? itemId) {
|
||||
_updateLine(() {
|
||||
widget.line.itemId = itemId;
|
||||
if (itemId == null) return;
|
||||
if (itemId == null) {
|
||||
widget.line.itemName = null;
|
||||
widget.line.itemCode = null;
|
||||
return;
|
||||
}
|
||||
FilterOptionModel? selected;
|
||||
for (final e in widget.items) {
|
||||
if (_parseId(e.id) == itemId) {
|
||||
selected = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selected != null) {
|
||||
widget.line.itemName = selected.name;
|
||||
widget.line.itemCode = selected.slug;
|
||||
}
|
||||
final key = itemId.toString();
|
||||
final defaultUom = _itemUomById[key];
|
||||
if (defaultUom != null) {
|
||||
@ -471,6 +506,61 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
});
|
||||
}
|
||||
|
||||
List<AppDropdownOption<int>> _itemOptionsWithSelected() {
|
||||
final options = widget.items
|
||||
.map((e) {
|
||||
final id = _parseId(e.id);
|
||||
if (id == null) return null;
|
||||
final code = e.slug?.trim();
|
||||
return AppDropdownOption(
|
||||
value: id,
|
||||
label: e.name,
|
||||
subtitle: (code == null || code.isEmpty) ? null : code,
|
||||
);
|
||||
})
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList();
|
||||
|
||||
final selectedId = widget.line.itemId;
|
||||
if (selectedId == null) return options;
|
||||
if (options.any((o) => o.value == selectedId)) return options;
|
||||
|
||||
final name = widget.line.itemName?.trim();
|
||||
final code = widget.line.itemCode?.trim();
|
||||
return [
|
||||
AppDropdownOption(
|
||||
value: selectedId,
|
||||
label: (name != null && name.isNotEmpty) ? name : 'Item #$selectedId',
|
||||
subtitle: (code == null || code.isEmpty) ? null : code,
|
||||
),
|
||||
...options,
|
||||
];
|
||||
}
|
||||
|
||||
List<AppDropdownOption<int>> _uomOptionsWithSelected() {
|
||||
final options = widget.uom
|
||||
.map((e) {
|
||||
final id = _parseId(e.id);
|
||||
if (id == null) return null;
|
||||
return AppDropdownOption(value: id, label: e.name);
|
||||
})
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList();
|
||||
|
||||
final selectedId = widget.line.uomId;
|
||||
if (selectedId == null) return options;
|
||||
if (options.any((o) => o.value == selectedId)) return options;
|
||||
|
||||
final name = widget.line.uomName?.trim();
|
||||
return [
|
||||
AppDropdownOption(
|
||||
value: selectedId,
|
||||
label: (name != null && name.isNotEmpty) ? name : 'UOM #$selectedId',
|
||||
),
|
||||
...options,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _LineItemCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@ -500,27 +590,8 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
alpha: isDark ? 0.18 : 0.08,
|
||||
);
|
||||
|
||||
final itemOptions = widget.items
|
||||
.map((e) {
|
||||
final id = _parseId(e.id);
|
||||
if (id == null) return null;
|
||||
final code = e.slug?.trim();
|
||||
return AppDropdownOption(
|
||||
value: id,
|
||||
label: e.name,
|
||||
subtitle: (code == null || code.isEmpty) ? null : code,
|
||||
);
|
||||
})
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList();
|
||||
final uomOptions = widget.uom
|
||||
.map((e) {
|
||||
final id = _parseId(e.id);
|
||||
if (id == null) return null;
|
||||
return AppDropdownOption(value: id, label: e.name);
|
||||
})
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList();
|
||||
final itemOptions = _itemOptionsWithSelected();
|
||||
final uomOptions = _uomOptionsWithSelected();
|
||||
final gstOptions = [
|
||||
const AppDropdownOption<int?>(value: null, label: 'Select GST Rate'),
|
||||
...widget.gstRates.map((e) {
|
||||
@ -559,6 +630,9 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
label: 'Qty *',
|
||||
hint: '0',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
||||
],
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Required';
|
||||
final qty = double.tryParse(v);
|
||||
@ -589,6 +663,9 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
label: 'Rate *',
|
||||
hint: '0.00',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
||||
],
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Required';
|
||||
final rate = double.tryParse(v);
|
||||
@ -602,6 +679,17 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
label: 'Disc %',
|
||||
hint: '0',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
||||
],
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return null;
|
||||
final disc = double.tryParse(v);
|
||||
if (disc == null) return 'Invalid';
|
||||
if (disc < 0) return 'Cannot be negative';
|
||||
if (disc > 100) return 'Max 100%';
|
||||
return null;
|
||||
},
|
||||
);
|
||||
final gstField = MasterQuickAddDropdown<int?>(
|
||||
key: ValueKey('$lineKey-gst'),
|
||||
@ -662,9 +750,11 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
// Medium / narrow: wrapping grid (2–3 columns)
|
||||
return ResponsiveFormGrid(
|
||||
spacing: spacing,
|
||||
xsColumns: 1,
|
||||
smallColumns: 1,
|
||||
mediumColumns: 2,
|
||||
largeColumns: 3,
|
||||
smallBreakpoint: 520,
|
||||
mediumBreakpoint: 520,
|
||||
largeBreakpoint: 800,
|
||||
children: [
|
||||
|
||||
@ -837,13 +837,12 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
child: UserRichDataTable(
|
||||
wrapInCard: false,
|
||||
users: usersState.users,
|
||||
onServerSearchChanged: (value) {
|
||||
_searchController.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(offset: value.length),
|
||||
);
|
||||
ref.read(usersListProvider.notifier).setSearch(value);
|
||||
},
|
||||
onEnsureFullDataset: () => ref
|
||||
.read(usersListProvider.notifier)
|
||||
.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () => ref
|
||||
.read(usersListProvider.notifier)
|
||||
.clearColumnSearchDataset(),
|
||||
actionsBuilder: (_, user) => UserTableActions(
|
||||
user: user,
|
||||
canEdit: canEditUser,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../data/repositories/reports_repository_impl.dart';
|
||||
import '../../domain/entities/depreciation_report.dart';
|
||||
@ -73,6 +73,10 @@ final depreciationReportProvider = AsyncNotifierProvider<
|
||||
|
||||
class DepreciationReportNotifier
|
||||
extends AsyncNotifier<DepreciationReportState> {
|
||||
final _columnSearch = ColumnSearchPaging(
|
||||
defaultLimit: AppConstants.defaultPageSize,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<DepreciationReportState> build() async {
|
||||
ref.keepAlive();
|
||||
@ -145,6 +149,39 @@ class DepreciationReportNotifier
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final currentState = state.valueOrNull;
|
||||
final current = currentState?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.limit,
|
||||
total: currentState?.total ?? 0,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.copyWith(
|
||||
page: 1,
|
||||
limit: limit,
|
||||
clearSearch: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final currentState = state.valueOrNull;
|
||||
final current = currentState?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(
|
||||
current.copyWith(
|
||||
page: 1,
|
||||
limit: limit,
|
||||
clearSearch: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setLocationId(String? value) async {
|
||||
final current = state.valueOrNull?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
|
||||
@ -234,15 +234,10 @@ class _DepreciationReportScreenState
|
||||
: _MobileList(items: state.items))
|
||||
: _ReportTable(
|
||||
items: state.items,
|
||||
onServerSearch: (value) {
|
||||
_searchController.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: value.length,
|
||||
),
|
||||
);
|
||||
notifier.setSearch(value);
|
||||
},
|
||||
onEnsureFullDataset: () =>
|
||||
notifier.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () =>
|
||||
notifier.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -594,24 +589,32 @@ class _FiltersBarState extends State<_FiltersBar> {
|
||||
class _ReportTable extends StatelessWidget {
|
||||
const _ReportTable({
|
||||
required this.items,
|
||||
this.onServerSearch,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
final List<DepreciationReportRow> items;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppDataTable<DepreciationReportRow>(
|
||||
wrapInCard: false,
|
||||
rows: items,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'Asset Code',
|
||||
flex: 2,
|
||||
searchText: (row) => row.assetCode ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.assetCode),
|
||||
cellBuilder: (_, row) => AppTableCell.link(
|
||||
row.assetCode,
|
||||
onTap: row.id.trim().isEmpty
|
||||
? null
|
||||
: () => context.push('${RouteConstants.assets}/${row.id}'),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Asset Name',
|
||||
@ -634,7 +637,7 @@ class _ReportTable extends StatelessWidget {
|
||||
AppDataColumn(
|
||||
label: 'Purchase Date',
|
||||
flex: 2,
|
||||
searchText: (row) => DateFormatter.displayDate(row.purchaseDate),
|
||||
searchText: (row) => DateFormatter.searchableDate(row.purchaseDate),
|
||||
cellBuilder: (_, row) =>
|
||||
AppTableCell.text(DateFormatter.displayDate(row.purchaseDate)),
|
||||
),
|
||||
@ -642,7 +645,7 @@ class _ReportTable extends StatelessWidget {
|
||||
label: 'Purchase Cost',
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
searchText: (row) => CurrencyFormatter.format(row.purchaseCost),
|
||||
searchText: (row) => CurrencyFormatter.searchable(row.purchaseCost),
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.purchaseCost),
|
||||
textAlign: TextAlign.right,
|
||||
@ -653,7 +656,7 @@ class _ReportTable extends StatelessWidget {
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
searchText: (row) =>
|
||||
CurrencyFormatter.format(row.annualDepreciation),
|
||||
CurrencyFormatter.searchable(row.annualDepreciation),
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.annualDepreciation),
|
||||
textAlign: TextAlign.right,
|
||||
@ -664,7 +667,7 @@ class _ReportTable extends StatelessWidget {
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
searchText: (row) =>
|
||||
CurrencyFormatter.format(row.accumulatedDepreciation),
|
||||
CurrencyFormatter.searchable(row.accumulatedDepreciation),
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.accumulatedDepreciation),
|
||||
textAlign: TextAlign.right,
|
||||
@ -674,7 +677,7 @@ class _ReportTable extends StatelessWidget {
|
||||
label: 'Book Value',
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
searchText: (row) => CurrencyFormatter.format(row.bookValue),
|
||||
searchText: (row) => CurrencyFormatter.searchable(row.bookValue),
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.bookValue),
|
||||
textAlign: TextAlign.right,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
|
||||
import '../../../../shared/models/permission_matrix_models.dart';
|
||||
@ -86,6 +87,8 @@ final rolesListProvider =
|
||||
AsyncNotifierProvider<RolesListNotifier, RolesListState>(RolesListNotifier.new);
|
||||
|
||||
class RolesListNotifier extends AsyncNotifier<RolesListState> {
|
||||
final _columnSearch = ColumnSearchPaging(defaultLimit: 10);
|
||||
|
||||
@override
|
||||
Future<RolesListState> build() async {
|
||||
ref.keepAlive();
|
||||
@ -130,6 +133,36 @@ class RolesListNotifier extends AsyncNotifier<RolesListState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final loaded = await _load(search: '');
|
||||
state = AsyncData(
|
||||
loaded.copyWith(
|
||||
page: 1,
|
||||
limit: loaded.total > 0 ? loaded.total : limit,
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
state = AsyncData(current.copyWith(page: 1, limit: limit, search: ''));
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
@ -175,9 +208,16 @@ class PermissionMatrixNotifier
|
||||
final granted = Map<String, bool>.from(row.granted);
|
||||
granted[normalizedAction] = value;
|
||||
|
||||
// CREATE / EDIT imply VIEW.
|
||||
// CREATE / EDIT / DELETE / APPROVE / EXPORT imply VIEW.
|
||||
const impliesView = {
|
||||
'create',
|
||||
'edit',
|
||||
'delete',
|
||||
'approve',
|
||||
'export',
|
||||
};
|
||||
if (value &&
|
||||
(normalizedAction == 'create' || normalizedAction == 'edit') &&
|
||||
impliesView.contains(normalizedAction) &&
|
||||
isPermissionActionApplicable(
|
||||
row.code,
|
||||
'view',
|
||||
|
||||
@ -108,15 +108,10 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
|
||||
: _RoleDataTable(
|
||||
roles: roles,
|
||||
onOpen: _openRole,
|
||||
onServerSearch: (value) {
|
||||
_searchController.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: value.length,
|
||||
),
|
||||
);
|
||||
notifier.setSearch(value);
|
||||
},
|
||||
onEnsureFullDataset: () =>
|
||||
notifier.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () =>
|
||||
notifier.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -137,18 +132,21 @@ class _RoleDataTable extends StatelessWidget {
|
||||
const _RoleDataTable({
|
||||
required this.roles,
|
||||
required this.onOpen,
|
||||
this.onServerSearch,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
final List<RoleCardModel> roles;
|
||||
final void Function(RoleCardModel role) onOpen;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppDataTable<RoleCardModel>(
|
||||
wrapInCard: false,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: [
|
||||
AppDataColumn(label: 'Role Name', flex: 2, searchText: (r) => r.name, cellBuilder: (_, r) => Text(r.name)),
|
||||
AppDataColumn(
|
||||
|
||||
@ -140,6 +140,15 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
return result.failure;
|
||||
}
|
||||
|
||||
/// Fetches Company Profile + Email Settings after login / session restore
|
||||
/// and applies them app-wide (logo, favicon, company name, email config).
|
||||
Future<void> syncCompanyAndEmailFromServer() async {
|
||||
await Future.wait([
|
||||
refreshCompanyProfile(),
|
||||
refreshEmailSettings(),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _persist(AppSettings settings) async {
|
||||
state = settings;
|
||||
final result = await _saveSettings(settings);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
@ -100,6 +101,8 @@ final usersListProvider =
|
||||
AsyncNotifierProvider<UsersListNotifier, UsersListState>(UsersListNotifier.new);
|
||||
|
||||
class UsersListNotifier extends AsyncNotifier<UsersListState> {
|
||||
final _columnSearch = ColumnSearchPaging(defaultLimit: 10);
|
||||
|
||||
@override
|
||||
Future<UsersListState> build() async {
|
||||
ref.keepAlive();
|
||||
@ -152,6 +155,27 @@ class UsersListNotifier extends AsyncNotifier<UsersListState> {
|
||||
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.query.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.query.copyWith(search: null, page: 1, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
|
||||
@ -15,13 +15,38 @@ import '../../../../shared/models/user_management_models.dart';
|
||||
import '../providers/users_provider.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
class UserDetailScreen extends ConsumerWidget {
|
||||
class UserDetailScreen extends ConsumerStatefulWidget {
|
||||
const UserDetailScreen({super.key, required this.userId});
|
||||
|
||||
final String userId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<UserDetailScreen> createState() => _UserDetailScreenState();
|
||||
}
|
||||
|
||||
class _UserDetailScreenState extends ConsumerState<UserDetailScreen> {
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always hit GET user-by-id when opening view.
|
||||
ref.invalidate(userDetailProvider(widget.userId));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant UserDetailScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.userId != widget.userId) {
|
||||
ref.invalidate(userDetailProvider(widget.userId));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userId = widget.userId;
|
||||
final userAsync = ref.watch(userDetailProvider(userId));
|
||||
|
||||
return Padding(
|
||||
@ -48,7 +73,7 @@ class UserDetailScreen extends ConsumerWidget {
|
||||
AppButton(
|
||||
label: 'Deactivate',
|
||||
expand: false,
|
||||
onPressed: () => _deactivate(context, ref),
|
||||
onPressed: () => _deactivate(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -87,7 +112,7 @@ class UserDetailScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deactivate(BuildContext context, WidgetRef ref) async {
|
||||
Future<void> _deactivate(BuildContext context) async {
|
||||
final confirmed = await showAppConfirmationDialog(
|
||||
context: context,
|
||||
title: 'Deactivate user',
|
||||
@ -97,11 +122,15 @@ class UserDetailScreen extends ConsumerWidget {
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
final success = await ref.read(userDetailProvider(userId).notifier).deactivate();
|
||||
final success =
|
||||
await ref.read(userDetailProvider(widget.userId).notifier).deactivate();
|
||||
if (!context.mounted) return;
|
||||
|
||||
showAppToastFromSnackBar(context,
|
||||
SnackBar(content: Text(success ? 'User deactivated' : 'Failed to deactivate')),
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(
|
||||
content: Text(success ? 'User deactivated' : 'Failed to deactivate'),
|
||||
),
|
||||
);
|
||||
if (success) context.pop();
|
||||
}
|
||||
|
||||
@ -37,9 +37,19 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
|
||||
String _selectedStatus = 'active';
|
||||
bool _isSubmitting = false;
|
||||
bool _prefilled = false;
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
bool get isEditing => widget.userId != null;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad || !isEditing) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always hit GET user-by-id when opening edit.
|
||||
ref.invalidate(userFormProvider(widget.userId));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_employeeIdController.dispose();
|
||||
|
||||
@ -143,17 +143,12 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
|
||||
onEdit: _editUser,
|
||||
onToggleStatus: _toggleStatus,
|
||||
onDeactivate: _deactivateUser,
|
||||
onServerSearch: (value) {
|
||||
_searchController.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: value.length,
|
||||
),
|
||||
);
|
||||
ref
|
||||
.read(usersListProvider.notifier)
|
||||
.setSearch(value);
|
||||
},
|
||||
onEnsureFullDataset: () => ref
|
||||
.read(usersListProvider.notifier)
|
||||
.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () => ref
|
||||
.read(usersListProvider.notifier)
|
||||
.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -312,7 +307,8 @@ class _UserDataTable extends StatelessWidget {
|
||||
required this.onEdit,
|
||||
required this.onToggleStatus,
|
||||
required this.onDeactivate,
|
||||
this.onServerSearch,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
final List<ManagedUserModel> users;
|
||||
@ -323,7 +319,8 @@ class _UserDataTable extends StatelessWidget {
|
||||
final void Function(ManagedUserModel user) onEdit;
|
||||
final Future<void> Function(ManagedUserModel user) onToggleStatus;
|
||||
final Future<void> Function(ManagedUserModel user) onDeactivate;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -333,7 +330,8 @@ class _UserDataTable extends StatelessWidget {
|
||||
sortAscending: sortOrder == 'asc',
|
||||
onSort: onSort,
|
||||
wrapInCard: false,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
actionsBuilder: (_, user) => _UserActions(
|
||||
user: user,
|
||||
onView: onView,
|
||||
|
||||
@ -21,6 +21,8 @@ class UserRichDataTable extends StatelessWidget {
|
||||
this.onSort,
|
||||
this.wrapInCard = false,
|
||||
this.onServerSearchChanged,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
final List<ManagedUserModel> users;
|
||||
@ -30,6 +32,8 @@ class UserRichDataTable extends StatelessWidget {
|
||||
final void Function(String column, bool ascending)? onSort;
|
||||
final bool wrapInCard;
|
||||
final ValueChanged<String>? onServerSearchChanged;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -41,6 +45,8 @@ class UserRichDataTable extends StatelessWidget {
|
||||
sortAscending: sortAscending,
|
||||
onSort: onSort,
|
||||
onServerSearchChanged: onServerSearchChanged,
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'User',
|
||||
@ -62,20 +68,21 @@ class UserRichDataTable extends StatelessWidget {
|
||||
label: 'Role',
|
||||
flex: 2,
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
searchText: (user) => user.roleNames.join(' '),
|
||||
searchText: (user) =>
|
||||
'${user.roleNames.join(' ')} ${user.roleLabel}'.trim(),
|
||||
cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Department',
|
||||
flex: 2,
|
||||
searchText: (user) => user.departmentLabel,
|
||||
searchText: (user) => user.departmentName ?? '',
|
||||
cellBuilder: (_, user) => Text(user.departmentLabel),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Last Login',
|
||||
flex: 2,
|
||||
searchText: (user) =>
|
||||
DateFormatter.formatUserLastLogin(user.lastLoginAt),
|
||||
DateFormatter.searchableDate(user.lastLoginAt),
|
||||
cellBuilder: (_, user) => Text(
|
||||
DateFormatter.formatUserLastLogin(user.lastLoginAt),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
@ -57,6 +58,8 @@ final vendorsListProvider =
|
||||
);
|
||||
|
||||
class VendorsListNotifier extends AutoDisposeAsyncNotifier<VendorsListState> {
|
||||
final _columnSearch = ColumnSearchPaging();
|
||||
|
||||
@override
|
||||
Future<VendorsListState> build() async {
|
||||
return _load(const VendorListQuery(limit: 20));
|
||||
@ -103,6 +106,27 @@ class VendorsListNotifier extends AutoDisposeAsyncNotifier<VendorsListState> {
|
||||
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.query.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.query.copyWith(search: null, page: 1, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
|
||||
}
|
||||
|
||||
void setStatusFilter(String? status) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
|
||||
@ -31,6 +31,7 @@ class VendorDetailScreen extends ConsumerStatefulWidget {
|
||||
class _VendorDetailScreenState extends ConsumerState<VendorDetailScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -38,6 +39,23 @@ class _VendorDetailScreenState extends ConsumerState<VendorDetailScreen>
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always hit GET /vendors/{id} when opening view.
|
||||
ref.invalidate(vendorDetailProvider(widget.vendorId));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant VendorDetailScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.vendorId != widget.vendorId) {
|
||||
ref.invalidate(vendorDetailProvider(widget.vendorId));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
|
||||
@ -164,17 +164,12 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
|
||||
onView: _viewVendor,
|
||||
onEdit: canEdit ? _editVendor : null,
|
||||
onDelete: canDelete ? _deleteVendor : null,
|
||||
onServerSearch: (value) {
|
||||
_searchController.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: value.length,
|
||||
),
|
||||
);
|
||||
ref
|
||||
.read(vendorsListProvider.notifier)
|
||||
.setSearch(value);
|
||||
},
|
||||
onEnsureFullDataset: () => ref
|
||||
.read(vendorsListProvider.notifier)
|
||||
.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () => ref
|
||||
.read(vendorsListProvider.notifier)
|
||||
.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -299,26 +294,32 @@ class _VendorDataTable extends StatelessWidget {
|
||||
required this.onView,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onServerSearch,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
final List<VendorModel> vendors;
|
||||
final ValueChanged<VendorModel> onView;
|
||||
final ValueChanged<VendorModel>? onEdit;
|
||||
final ValueChanged<VendorModel>? onDelete;
|
||||
final ValueChanged<String>? onServerSearch;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppDataTable<VendorModel>(
|
||||
wrapInCard: false,
|
||||
onServerSearchChanged: onServerSearch,
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'Code',
|
||||
flex: 1,
|
||||
searchText: (vendor) => vendor.vendorCode ?? '',
|
||||
cellBuilder: (_, vendor) => Text(vendor.vendorCode ?? '—'),
|
||||
cellBuilder: (_, vendor) => AppTableCell.link(
|
||||
vendor.vendorCode,
|
||||
onTap: () => onView(vendor),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Name',
|
||||
@ -405,9 +406,13 @@ class _VendorCardList extends StatelessWidget {
|
||||
return AppCard(
|
||||
child: ListTile(
|
||||
title: Text(vendor.vendorName),
|
||||
subtitle: Text(
|
||||
subtitle: AppTableCell.link(
|
||||
'${vendor.vendorCode ?? '—'} · ${vendorTypeLabel(vendor.vendorType)}',
|
||||
onTap: vendor.vendorCode == null || vendor.vendorCode!.isEmpty
|
||||
? null
|
||||
: () => onView(vendor),
|
||||
),
|
||||
onTap: () => onView(vendor),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@ -437,7 +442,6 @@ class _VendorCardList extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => onView(vendor),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -235,7 +235,12 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
|
||||
}
|
||||
|
||||
Widget _buildForm(
|
||||
AsyncValue<List<FilterOptionModel>> paymentTermsAsync,
|
||||
AsyncValue<
|
||||
({
|
||||
List<FilterOptionModel> options,
|
||||
Map<int, int> creditDaysById,
|
||||
})>
|
||||
paymentTermsAsync,
|
||||
AsyncValue<List<FilterOptionModel>> gstTreatmentsAsync,
|
||||
AsyncValue<List<FilterOptionModel>> sourceOfSupplyAsync,
|
||||
) {
|
||||
@ -319,7 +324,7 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
|
||||
left: paymentTermsAsync.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('Failed to load payment terms'),
|
||||
data: (terms) => _paymentTermDropdown(terms),
|
||||
data: (terms) => _paymentTermDropdown(terms.options),
|
||||
),
|
||||
right: AppTextField(
|
||||
controller: _creditDaysController,
|
||||
@ -346,6 +351,23 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
void _applyPaymentTerm(int? termId) {
|
||||
final creditDaysById =
|
||||
ref.read(vendorPaymentTermsProvider).valueOrNull?.creditDaysById ??
|
||||
const <int, int>{};
|
||||
setState(() {
|
||||
_paymentTermId = termId;
|
||||
if (termId == null) {
|
||||
_creditDaysController.clear();
|
||||
return;
|
||||
}
|
||||
final days = creditDaysById[termId];
|
||||
if (days != null) {
|
||||
_creditDaysController.text = days.toString();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _paymentTermDropdown(List<FilterOptionModel> terms) {
|
||||
final termIds = terms.map((t) => int.tryParse(t.id)).whereType<int>().toList();
|
||||
final value = _paymentTermId != null && termIds.contains(_paymentTermId)
|
||||
@ -365,17 +387,23 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
|
||||
)
|
||||
.where((option) => option.value != 0)
|
||||
.toList(),
|
||||
refreshLookups: () => ref.invalidate(vendorPaymentTermsProvider),
|
||||
refreshLookups: () async {
|
||||
ref.invalidate(vendorPaymentTermsProvider);
|
||||
await ref.read(vendorPaymentTermsProvider.future);
|
||||
},
|
||||
parseCreatedId: int.tryParse,
|
||||
onChanged: (v) => setState(() => _paymentTermId = v),
|
||||
onChanged: _applyPaymentTerm,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final vendorPaymentTermsProvider =
|
||||
FutureProvider<List<FilterOptionModel>>((ref) async {
|
||||
final vendorPaymentTermsProvider = FutureProvider<
|
||||
({
|
||||
List<FilterOptionModel> options,
|
||||
Map<int, int> creditDaysById,
|
||||
})>((ref) async {
|
||||
final dataSource = ref.watch(masterRemoteDataSourceProvider);
|
||||
return dataSource.listPaymentTerms();
|
||||
return dataSource.listPaymentTermsWithCreditDays();
|
||||
});
|
||||
|
||||
final vendorGstTreatmentsProvider =
|
||||
|
||||
@ -225,25 +225,23 @@ class AssetModel with _$AssetModel {
|
||||
|
||||
class AssetMaintenanceChecklistItem {
|
||||
const AssetMaintenanceChecklistItem({
|
||||
required this.key,
|
||||
required this.label,
|
||||
this.required = false,
|
||||
this.required = true,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final String label;
|
||||
final bool required;
|
||||
|
||||
factory AssetMaintenanceChecklistItem.fromJson(Map<String, dynamic> json) {
|
||||
final label = json['label']?.toString().trim() ?? '';
|
||||
return AssetMaintenanceChecklistItem(
|
||||
key: json['key']?.toString() ?? '',
|
||||
label: json['label']?.toString() ?? '',
|
||||
required: json['required'] == true,
|
||||
label: label,
|
||||
// API default is true when omitted.
|
||||
required: json['required'] == null ? true : json['required'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'key': key,
|
||||
'label': label,
|
||||
'required': required,
|
||||
};
|
||||
@ -580,7 +578,7 @@ List<AssetMaintenanceChecklistItem>? _checklistFromJson(Object? value) {
|
||||
Map<String, dynamic>.from(item),
|
||||
),
|
||||
)
|
||||
.where((item) => item.key.isNotEmpty)
|
||||
.where((item) => item.label.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
@ -76,6 +76,14 @@ Object? _readBillingName(Map<dynamic, dynamic> json, String key) =>
|
||||
Object? _readShippingName(Map<dynamic, dynamic> json, String key) =>
|
||||
_readLocationDisplayName(json, 'shipping_name', 'shipping');
|
||||
|
||||
Object? _readItemId(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['item_id'];
|
||||
if (flat != null) return flat;
|
||||
final nested = json['item'];
|
||||
if (nested is Map) return nested['id'] ?? nested['item_id'];
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _readItemName(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['item_name'];
|
||||
if (flat is String && flat.isNotEmpty) return flat;
|
||||
@ -210,8 +218,7 @@ class PurchaseOrderModel with _$PurchaseOrderModel {
|
||||
factory PurchaseOrderModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$PurchaseOrderModelFromJson(json);
|
||||
|
||||
bool get canEdit =>
|
||||
status.toUpperCase() == 'DRAFT' || status.toUpperCase() == 'REJECTED';
|
||||
bool get canEdit => status.toUpperCase() == 'DRAFT';
|
||||
|
||||
bool get canDelete => canEdit;
|
||||
|
||||
@ -219,9 +226,15 @@ class PurchaseOrderModel with _$PurchaseOrderModel {
|
||||
|
||||
bool get canApprove {
|
||||
final s = status.toUpperCase();
|
||||
return s == 'SUBMITTED' || s == 'PENDING_APPROVAL' || s == 'PENDING';
|
||||
return s == 'SUBMITTED' ||
|
||||
s == 'PENDING_APPROVAL' ||
|
||||
s == 'PENDING' ||
|
||||
s == 'REJECTED';
|
||||
}
|
||||
|
||||
/// Notify approvers via `POST /notifications/trigger` (PO_SUBMIT_APPROVAL).
|
||||
bool get canNotifyApprovers => status.toUpperCase() == 'PENDING_APPROVAL';
|
||||
|
||||
bool get canReject => canApprove;
|
||||
|
||||
bool get canAmend => status.toUpperCase() == 'APPROVED';
|
||||
@ -237,7 +250,12 @@ class PurchaseOrderItemModel with _$PurchaseOrderItemModel {
|
||||
const factory PurchaseOrderItemModel({
|
||||
@JsonKey(fromJson: _idFromJson) required String id,
|
||||
@JsonKey(name: 'po_id', fromJson: _idFromJson) String? poId,
|
||||
@JsonKey(name: 'item_id', fromJson: _intFromJsonNullable) int? itemId,
|
||||
@JsonKey(
|
||||
name: 'item_id',
|
||||
readValue: _readItemId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? itemId,
|
||||
@JsonKey(name: 'item_code', readValue: _readItemCode) String? itemCode,
|
||||
@JsonKey(name: 'item_name', readValue: _readItemName) String? itemName,
|
||||
@JsonKey(name: 'line_no', fromJson: _intFromJsonNullable) int? lineNo,
|
||||
|
||||
@ -85,7 +85,7 @@ _$PurchaseOrderItemModelImpl _$$PurchaseOrderItemModelImplFromJson(
|
||||
) => _$PurchaseOrderItemModelImpl(
|
||||
id: _idFromJson(json['id']),
|
||||
poId: _idFromJson(json['po_id']),
|
||||
itemId: _intFromJsonNullable(json['item_id']),
|
||||
itemId: _intFromJsonNullable(_readItemId(json, 'item_id')),
|
||||
itemCode: _readItemCode(json, 'item_code') as String?,
|
||||
itemName: _readItemName(json, 'item_name') as String?,
|
||||
lineNo: _intFromJsonNullable(json['line_no']),
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/config/dev_config.dart';
|
||||
import '../../modules/auth/data/repositories/auth_repository_impl.dart';
|
||||
import '../../modules/auth/domain/repositories/auth_repository.dart';
|
||||
import '../../modules/settings/presentation/providers/settings_provider.dart';
|
||||
import '../models/user_model.dart';
|
||||
import 'dev_user.dart';
|
||||
|
||||
@ -33,15 +36,28 @@ class AuthState {
|
||||
}
|
||||
|
||||
final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||
return AuthNotifier(ref.watch(authRepositoryProvider));
|
||||
return AuthNotifier(ref);
|
||||
});
|
||||
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
AuthNotifier(this._repository) : super(const AuthState()) {
|
||||
AuthNotifier(this._ref) : super(const AuthState()) {
|
||||
checkAuth();
|
||||
}
|
||||
|
||||
final AuthRepository _repository;
|
||||
final Ref _ref;
|
||||
|
||||
AuthRepository get _repository => _ref.read(authRepositoryProvider);
|
||||
|
||||
/// Pull Company + Email settings once the session is authenticated.
|
||||
Future<void> _syncAppSettingsAfterAuth() async {
|
||||
try {
|
||||
await _ref
|
||||
.read(appSettingsProvider.notifier)
|
||||
.syncCompanyAndEmailFromServer();
|
||||
} catch (_) {
|
||||
// Settings sync must not block login / session restore.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> checkAuth() async {
|
||||
state = state.copyWith(status: AuthStatus.loading);
|
||||
@ -57,10 +73,13 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
return;
|
||||
}
|
||||
state = AuthState(status: AuthStatus.authenticated, user: result.data);
|
||||
unawaited(_syncAppSettingsAfterAuth());
|
||||
}
|
||||
|
||||
void loginAsDemo() {
|
||||
state = const AuthState(status: AuthStatus.authenticated, user: demoUser);
|
||||
// Demo has no backend session — still try sync (may no-op / fail quietly).
|
||||
unawaited(_syncAppSettingsAfterAuth());
|
||||
}
|
||||
|
||||
Future<bool> login(LoginRequest request) async {
|
||||
@ -93,6 +112,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
status: AuthStatus.authenticated,
|
||||
user: loginResponse.user,
|
||||
);
|
||||
unawaited(_syncAppSettingsAfterAuth());
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import '../../core/constants/route_constants.dart';
|
||||
import '../../modules/dashboard/presentation/screens/dashboard_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_alerts_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_detail_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_form_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_list_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_maintenance_screen.dart';
|
||||
import '../../modules/auth/presentation/screens/change_password_screen.dart';
|
||||
@ -310,6 +311,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: 'maintenance',
|
||||
builder: (context, state) => const AssetMaintenanceScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'add',
|
||||
builder: (context, state) => const AssetFormScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':id/edit',
|
||||
builder: (context, state) => AssetFormScreen(
|
||||
assetId: state.pathParameters['id']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
builder: (context, state) =>
|
||||
|
||||
@ -18,6 +18,7 @@ class AppDataColumn<T> {
|
||||
required this.cellBuilder,
|
||||
this.sortKey,
|
||||
this.flex = 1,
|
||||
this.width,
|
||||
this.alignment = Alignment.centerLeft,
|
||||
this.padding = EdgeInsets.zero,
|
||||
this.searchText,
|
||||
@ -28,6 +29,10 @@ class AppDataColumn<T> {
|
||||
final Widget Function(BuildContext context, T row) cellBuilder;
|
||||
final String? sortKey;
|
||||
final int flex;
|
||||
|
||||
/// When set, column uses a fixed width instead of [flex].
|
||||
final double? width;
|
||||
|
||||
final Alignment alignment;
|
||||
final EdgeInsets padding;
|
||||
|
||||
@ -41,6 +46,19 @@ class AppDataColumn<T> {
|
||||
bool get isSearchable => enableSearch ?? searchText != null;
|
||||
}
|
||||
|
||||
/// Shared column sizing: fixed [AppDataColumn.width] or flexible [AppDataColumn.flex].
|
||||
Widget _appTableColumnSlot<T>({
|
||||
required AppDataColumn<T> column,
|
||||
required Widget child,
|
||||
}) {
|
||||
final padded = Padding(padding: column.padding, child: child);
|
||||
final width = column.width;
|
||||
if (width != null) {
|
||||
return SizedBox(width: width, child: padded);
|
||||
}
|
||||
return Expanded(flex: column.flex, child: padded);
|
||||
}
|
||||
|
||||
/// Helpers for table cell content — single-line text with ellipsis and tooltip.
|
||||
class AppTableCell {
|
||||
AppTableCell._();
|
||||
@ -63,6 +81,54 @@ class AppTableCell {
|
||||
);
|
||||
}
|
||||
|
||||
/// Clickable sequential number / code that navigates to a detail view.
|
||||
static Widget link(
|
||||
String? value, {
|
||||
required VoidCallback? onTap,
|
||||
TextStyle? style,
|
||||
String placeholder = '—',
|
||||
TextAlign? textAlign,
|
||||
bool underlined = true,
|
||||
}) {
|
||||
final display =
|
||||
(value == null || value.trim().isEmpty) ? placeholder : value.trim();
|
||||
if (onTap == null || display == placeholder) {
|
||||
return text(
|
||||
display,
|
||||
style: style,
|
||||
placeholder: placeholder,
|
||||
textAlign: textAlign,
|
||||
);
|
||||
}
|
||||
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
final linkStyle = (style ?? theme.textTheme.bodyMedium)?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: underlined ? TextDecoration.underline : TextDecoration.none,
|
||||
decorationColor: underlined
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.45)
|
||||
: null,
|
||||
);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: _EllipsisTooltipText(
|
||||
text: display,
|
||||
style: linkStyle,
|
||||
textAlign: textAlign,
|
||||
showTooltip: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Wraps non-text cell widgets (chips, actions) inside the row height budget.
|
||||
static Widget child(Widget widget) => widget;
|
||||
}
|
||||
@ -80,6 +146,8 @@ class AppDataTable<T> extends StatefulWidget {
|
||||
this.wrapInCard = true,
|
||||
this.shrinkWrap = false,
|
||||
this.onServerSearchChanged,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
final List<AppDataColumn<T>> columns;
|
||||
@ -94,13 +162,18 @@ class AppDataTable<T> extends StatefulWidget {
|
||||
/// Set true when the table is placed inside another scrollable.
|
||||
final bool shrinkWrap;
|
||||
|
||||
/// When set, column filters are sent to the parent for API search instead of
|
||||
/// filtering only the currently loaded [rows] (current page).
|
||||
///
|
||||
/// The callback receives a normalized query (trimmed). Empty string means
|
||||
/// clear search and reload the full paginated list.
|
||||
/// Deprecated: prefer [onEnsureFullDataset] + [onColumnSearchCleared].
|
||||
/// Kept so older call sites still compile; ignored for filtering.
|
||||
final ValueChanged<String>? onServerSearchChanged;
|
||||
|
||||
/// Called once when the first column filter becomes active.
|
||||
/// Parent should load all rows (`limit = total`) with no search query.
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
|
||||
/// Called when all column filters are cleared (or the table is disposed
|
||||
/// while filters were active). Parent should restore normal page size.
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
@override
|
||||
State<AppDataTable<T>> createState() => _AppDataTableState<T>();
|
||||
}
|
||||
@ -109,15 +182,21 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
||||
/// Column index → search query (raw, including spaces until applied).
|
||||
final Map<int, String> _queries = {};
|
||||
final Map<int, TextEditingController> _controllers = {};
|
||||
final SearchDebouncer _serverSearchDebouncer = SearchDebouncer();
|
||||
int? _lastEditedColumnIndex;
|
||||
String _lastEmittedServerSearch = '';
|
||||
final SearchDebouncer _ensureDatasetDebouncer = SearchDebouncer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
);
|
||||
bool _fullDatasetActive = false;
|
||||
bool _ensureInFlight = false;
|
||||
|
||||
bool get _serverSideSearch => widget.onServerSearchChanged != null;
|
||||
bool get _usesFullDatasetMode =>
|
||||
widget.onEnsureFullDataset != null || widget.onColumnSearchCleared != null;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverSearchDebouncer.dispose();
|
||||
_ensureDatasetDebouncer.dispose();
|
||||
if (_fullDatasetActive || _ensureInFlight) {
|
||||
widget.onColumnSearchCleared?.call();
|
||||
}
|
||||
for (final c in _controllers.values) {
|
||||
c.dispose();
|
||||
}
|
||||
@ -128,38 +207,48 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
||||
return _controllers.putIfAbsent(index, TextEditingController.new);
|
||||
}
|
||||
|
||||
String _composeServerSearch() {
|
||||
if (_lastEditedColumnIndex != null) {
|
||||
final latest = (_queries[_lastEditedColumnIndex!] ?? '').trim();
|
||||
if (latest.isNotEmpty) return latest;
|
||||
bool get _hasActiveColumnFilters =>
|
||||
_queries.values.any((q) => q.trim().isNotEmpty);
|
||||
|
||||
Future<void> _syncFullDatasetMode() async {
|
||||
if (!_usesFullDatasetMode) return;
|
||||
|
||||
if (_hasActiveColumnFilters && !_fullDatasetActive) {
|
||||
final ensure = widget.onEnsureFullDataset;
|
||||
if (ensure != null && !_ensureInFlight) {
|
||||
_ensureInFlight = true;
|
||||
try {
|
||||
await ensure();
|
||||
if (mounted && _hasActiveColumnFilters) {
|
||||
setState(() => _fullDatasetActive = true);
|
||||
}
|
||||
} finally {
|
||||
_ensureInFlight = false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (final entry in _queries.entries) {
|
||||
final q = entry.value.trim();
|
||||
if (q.isNotEmpty) return q;
|
||||
|
||||
if (!_hasActiveColumnFilters && _fullDatasetActive) {
|
||||
_fullDatasetActive = false;
|
||||
widget.onColumnSearchCleared?.call();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
void _onColumnQueryChanged(int index, String value) {
|
||||
setState(() {
|
||||
_queries[index] = value;
|
||||
_lastEditedColumnIndex = index;
|
||||
});
|
||||
|
||||
if (!_serverSideSearch) return;
|
||||
if (!_usesFullDatasetMode) return;
|
||||
|
||||
_serverSearchDebouncer.run(value, (_) {
|
||||
final composed = _composeServerSearch();
|
||||
if (composed == _lastEmittedServerSearch) return;
|
||||
_lastEmittedServerSearch = composed;
|
||||
widget.onServerSearchChanged!(composed);
|
||||
// Debounce ensure/clear so rapid typing doesn't thrash the API.
|
||||
_ensureDatasetDebouncer.run(value, (_) {
|
||||
_syncFullDatasetMode();
|
||||
});
|
||||
}
|
||||
|
||||
List<T> get _filteredRows {
|
||||
// Server-side mode: parent already fetched matching rows from the API.
|
||||
if (_serverSideSearch) return widget.rows;
|
||||
|
||||
final active = <int, String>{};
|
||||
for (final entry in _queries.entries) {
|
||||
final q = entry.value.trim().toLowerCase();
|
||||
@ -180,9 +269,6 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
bool get _hasActiveColumnFilters =>
|
||||
_queries.values.any((q) => q.trim().isNotEmpty);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showFilterRow = widget.columns.any((c) => c.isSearchable);
|
||||
@ -228,7 +314,7 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
_hasActiveColumnFilters || _lastEmittedServerSearch.isNotEmpty
|
||||
_hasActiveColumnFilters
|
||||
? widget.noMatchMessage
|
||||
: widget.emptyMessage,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
@ -324,19 +410,16 @@ class _TableHeaderRow<T> extends StatelessWidget {
|
||||
children: [
|
||||
for (var i = 0; i < columns.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: kAppTableColumnGap),
|
||||
Expanded(
|
||||
flex: columns[i].flex,
|
||||
child: Padding(
|
||||
padding: columns[i].padding,
|
||||
child: Align(
|
||||
alignment: columns[i].alignment,
|
||||
child: _buildHeaderCell(
|
||||
theme: theme,
|
||||
col: columns[i],
|
||||
sortColumn: sortColumn,
|
||||
sortAscending: sortAscending,
|
||||
onSort: onSort,
|
||||
),
|
||||
_appTableColumnSlot(
|
||||
column: columns[i],
|
||||
child: Align(
|
||||
alignment: columns[i].alignment,
|
||||
child: _buildHeaderCell(
|
||||
theme: theme,
|
||||
col: columns[i],
|
||||
sortColumn: sortColumn,
|
||||
sortAscending: sortAscending,
|
||||
onSort: onSort,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -430,18 +513,15 @@ class _TableFilterRow<T> extends StatelessWidget {
|
||||
children: [
|
||||
for (var i = 0; i < columns.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: kAppTableColumnGap),
|
||||
Expanded(
|
||||
flex: columns[i].flex,
|
||||
child: Padding(
|
||||
padding: columns[i].padding,
|
||||
child: columns[i].isSearchable
|
||||
? _ColumnSearchField(
|
||||
controller: controllerFor(i),
|
||||
query: queryFor(i),
|
||||
onChanged: (v) => onQueryChanged(i, v),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
_appTableColumnSlot(
|
||||
column: columns[i],
|
||||
child: columns[i].isSearchable
|
||||
? _ColumnSearchField(
|
||||
controller: controllerFor(i),
|
||||
query: queryFor(i),
|
||||
onChanged: (v) => onQueryChanged(i, v),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
],
|
||||
@ -584,21 +664,18 @@ class _TableDataRow<T> extends StatelessWidget {
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
for (var i = 0; i < columns.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: kAppTableColumnGap),
|
||||
Expanded(
|
||||
flex: columns[i].flex,
|
||||
child: Padding(
|
||||
padding: columns[i].padding,
|
||||
child: Align(
|
||||
_appTableColumnSlot(
|
||||
column: columns[i],
|
||||
child: Align(
|
||||
alignment: columns[i].alignment,
|
||||
child: _TableCellSlot(
|
||||
alignment: columns[i].alignment,
|
||||
child: _TableCellSlot(
|
||||
alignment: columns[i].alignment,
|
||||
child: columns[i].cellBuilder(context, row),
|
||||
),
|
||||
child: columns[i].cellBuilder(context, row),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -45,14 +45,33 @@ class _AppDateDialogState extends State<_AppDateDialog> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected = DateTime(
|
||||
widget.initialDate.year,
|
||||
widget.initialDate.month,
|
||||
widget.initialDate.day,
|
||||
final clamped = _clampDate(
|
||||
DateTime(
|
||||
widget.initialDate.year,
|
||||
widget.initialDate.month,
|
||||
widget.initialDate.day,
|
||||
),
|
||||
);
|
||||
_selected = clamped;
|
||||
_displayedMonth = DateTime(_selected.year, _selected.month);
|
||||
}
|
||||
|
||||
DateTime _clampDate(DateTime d) {
|
||||
final first = DateTime(
|
||||
widget.firstDate.year,
|
||||
widget.firstDate.month,
|
||||
widget.firstDate.day,
|
||||
);
|
||||
final last = DateTime(
|
||||
widget.lastDate.year,
|
||||
widget.lastDate.month,
|
||||
widget.lastDate.day,
|
||||
);
|
||||
if (d.isBefore(first)) return first;
|
||||
if (d.isAfter(last)) return last;
|
||||
return d;
|
||||
}
|
||||
|
||||
void _shiftMonth(int delta) {
|
||||
setState(() {
|
||||
_displayedMonth = DateTime(
|
||||
@ -106,7 +125,7 @@ class _AppDateDialogState extends State<_AppDateDialog> {
|
||||
firstDate: widget.firstDate,
|
||||
lastDate: widget.lastDate,
|
||||
selected: _selected,
|
||||
onSelected: (day) => setState(() => _selected = day),
|
||||
onSelected: (day) => setState(() => _selected = _clampDate(day)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
|
||||
@ -59,10 +59,14 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
: <menu.MenuItem>[];
|
||||
|
||||
if (context.isMobile) {
|
||||
final companyName =
|
||||
ref.watch(appSettingsProvider).companyProfile.companyName.trim();
|
||||
return Scaffold(
|
||||
key: _scaffoldKey,
|
||||
appBar: AppBar(
|
||||
title: const Text(AppConstants.appName),
|
||||
title: Text(
|
||||
companyName.isNotEmpty ? companyName : AppConstants.appName,
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.menu),
|
||||
onPressed: () => _scaffoldKey.currentState?.openDrawer(),
|
||||
|
||||
@ -452,6 +452,7 @@ class SidePanelSection extends StatelessWidget {
|
||||
child is FormRow ||
|
||||
child is FormRowThree ||
|
||||
child is FormRowFour ||
|
||||
child is ResponsiveFormGrid ||
|
||||
child is QuickAddInlineHost;
|
||||
}
|
||||
|
||||
@ -570,6 +571,9 @@ class FormRowThree extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// Responsive row with up to four equal-width form fields.
|
||||
///
|
||||
/// Columns by width: 4 (large) → 3 (medium) → 2 (small) → 1 (xs).
|
||||
/// When [spans] is set, falls back to a fixed 4-col [FormRow].
|
||||
class FormRowFour extends StatelessWidget {
|
||||
const FormRowFour({
|
||||
super.key,
|
||||
@ -588,37 +592,48 @@ class FormRowFour extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FormRow(
|
||||
columnCount: 4,
|
||||
spans: spans,
|
||||
if (spans != null) {
|
||||
return FormRow(
|
||||
columnCount: 4,
|
||||
spans: spans,
|
||||
spacing: spacing,
|
||||
horizontalPadding: horizontalPadding,
|
||||
stackBelowWidth: stackBelowWidth,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
return ResponsiveFormGrid(
|
||||
spacing: spacing,
|
||||
horizontalPadding: horizontalPadding,
|
||||
stackBelowWidth: stackBelowWidth,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Responsive form grid: 4 cols (medium+), 2 cols (small).
|
||||
/// Responsive form grid: 4 / 3 / 2 / 1 columns by viewport width.
|
||||
class ResponsiveFormGrid extends StatelessWidget {
|
||||
const ResponsiveFormGrid({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.fullWidthChildren = const [],
|
||||
this.spacing = 12,
|
||||
this.xsColumns = 1,
|
||||
this.smallColumns = 2,
|
||||
this.mediumColumns = 4,
|
||||
this.mediumColumns = 3,
|
||||
this.largeColumns = 4,
|
||||
this.mediumBreakpoint = AppBreakpoints.tablet,
|
||||
this.largeBreakpoint = AppBreakpoints.desktop,
|
||||
this.smallBreakpoint = AppBreakpoints.formSmall,
|
||||
this.mediumBreakpoint = AppBreakpoints.formMedium,
|
||||
this.largeBreakpoint = AppBreakpoints.formLarge,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
final List<Widget> fullWidthChildren;
|
||||
final double spacing;
|
||||
final int xsColumns;
|
||||
final int smallColumns;
|
||||
final int mediumColumns;
|
||||
final int largeColumns;
|
||||
final double smallBreakpoint;
|
||||
final double mediumBreakpoint;
|
||||
final double largeBreakpoint;
|
||||
|
||||
@ -634,53 +649,59 @@ class ResponsiveFormGrid extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth.isFinite && constraints.maxWidth > 0
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.sizeOf(context).width;
|
||||
final columns = formGridColumnsForWidth(
|
||||
width,
|
||||
smallColumns: smallColumns,
|
||||
mediumColumns: mediumColumns,
|
||||
largeColumns: largeColumns,
|
||||
mediumBreakpoint: mediumBreakpoint,
|
||||
largeBreakpoint: largeBreakpoint,
|
||||
);
|
||||
final rows = _chunk(children, columns);
|
||||
final columnWidth = (width - (columns - 1) * spacing) / columns;
|
||||
return QuickAddInlineHost(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth.isFinite && constraints.maxWidth > 0
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.sizeOf(context).width;
|
||||
final columns = formGridColumnsForWidth(
|
||||
width,
|
||||
xsColumns: xsColumns,
|
||||
smallColumns: smallColumns,
|
||||
mediumColumns: mediumColumns,
|
||||
largeColumns: largeColumns,
|
||||
smallBreakpoint: smallBreakpoint,
|
||||
mediumBreakpoint: mediumBreakpoint,
|
||||
largeBreakpoint: largeBreakpoint,
|
||||
);
|
||||
final rows = _chunk(children, columns);
|
||||
final columnWidth = columns <= 0
|
||||
? width
|
||||
: (width - (columns - 1) * spacing) / columns;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final row in rows)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: spacing),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var col = 0; col < columns; col++) ...[
|
||||
if (col > 0) SizedBox(width: spacing),
|
||||
SizedBox(
|
||||
width: columnWidth,
|
||||
child: col < row.length
|
||||
? row[col]
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final row in rows)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: spacing),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var col = 0; col < columns; col++) ...[
|
||||
if (col > 0) SizedBox(width: spacing),
|
||||
SizedBox(
|
||||
width: columnWidth,
|
||||
child: col < row.length
|
||||
? QuickAddBlockable(child: row[col])
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
for (var i = 0; i < fullWidthChildren.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: i == fullWidthChildren.length - 1 ? 0 : spacing,
|
||||
for (var i = 0; i < fullWidthChildren.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: i == fullWidthChildren.length - 1 ? 0 : spacing,
|
||||
),
|
||||
child: QuickAddBlockable(child: fullWidthChildren[i]),
|
||||
),
|
||||
child: fullWidthChildren[i],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class _TableActionInkWell extends StatelessWidget {
|
||||
@ -14,7 +16,7 @@ class _TableActionInkWell extends StatelessWidget {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
@ -52,6 +54,8 @@ class AppTableActionIcon extends StatelessWidget {
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
preferBelow: true,
|
||||
child: _TableActionInkWell(
|
||||
onTap: enabled ? onPressed : null,
|
||||
child: Padding(
|
||||
@ -63,7 +67,27 @@ class AppTableActionIcon extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures only one row's action chip is expanded at a time (fast hover).
|
||||
class _AppTableActionsGate {
|
||||
static _AppTableActionsState? _active;
|
||||
|
||||
static void claim(_AppTableActionsState next) {
|
||||
final prev = _active;
|
||||
if (prev != null && !identical(prev, next)) {
|
||||
prev._forceClose(releaseGate: false);
|
||||
}
|
||||
_active = next;
|
||||
}
|
||||
|
||||
static void release(_AppTableActionsState state) {
|
||||
if (identical(_active, state)) _active = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapsed three-dot trigger that expands inline action icons on hover (or tap).
|
||||
///
|
||||
/// Expanded icons are shown in an [Overlay] so they stay hoverable/clickable
|
||||
/// even when they paint over neighboring columns (status, etc.).
|
||||
class AppTableActions extends StatefulWidget {
|
||||
const AppTableActions({
|
||||
super.key,
|
||||
@ -80,86 +104,209 @@ class AppTableActions extends StatefulWidget {
|
||||
State<AppTableActions> createState() => _AppTableActionsState();
|
||||
}
|
||||
|
||||
/// Room for expanded icons to grow left of the ⋮ trigger without clipping.
|
||||
const double _kExpandedActionsOverflow = 108;
|
||||
|
||||
class _AppTableActionsState extends State<AppTableActions> {
|
||||
final LayerLink _link = LayerLink();
|
||||
final OverlayPortalController _portalController = OverlayPortalController();
|
||||
|
||||
bool _hovering = false;
|
||||
bool _pinned = false;
|
||||
Timer? _closeTimer;
|
||||
|
||||
bool get _expanded => _hovering || _pinned;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_closeTimer?.cancel();
|
||||
_AppTableActionsGate.release(this);
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _forceClose({bool releaseGate = true}) {
|
||||
_closeTimer?.cancel();
|
||||
_pinned = false;
|
||||
_hovering = false;
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
if (releaseGate) _AppTableActionsGate.release(this);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _open() {
|
||||
_AppTableActionsGate.claim(this);
|
||||
_closeTimer?.cancel();
|
||||
if (!_hovering) {
|
||||
setState(() => _hovering = true);
|
||||
}
|
||||
if (!_portalController.isShowing) {
|
||||
_portalController.show();
|
||||
}
|
||||
}
|
||||
|
||||
void _onEnter() => _open();
|
||||
|
||||
void _onExit() {
|
||||
if (_pinned) return;
|
||||
_closeTimer?.cancel();
|
||||
// Brief delay so the pointer can move from the cell trigger into the overlay chip.
|
||||
_closeTimer = Timer(const Duration(milliseconds: 80), () {
|
||||
if (!mounted || _pinned) return;
|
||||
// Another row may have claimed the gate already.
|
||||
if (!identical(_AppTableActionsGate._active, this)) return;
|
||||
setState(() => _hovering = false);
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
_AppTableActionsGate.release(this);
|
||||
});
|
||||
}
|
||||
|
||||
void _togglePinned() {
|
||||
_closeTimer?.cancel();
|
||||
if (_pinned) {
|
||||
_forceClose();
|
||||
return;
|
||||
}
|
||||
_AppTableActionsGate.claim(this);
|
||||
setState(() {
|
||||
_pinned = true;
|
||||
_hovering = true;
|
||||
});
|
||||
if (!_portalController.isShowing) {
|
||||
_portalController.show();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildChip({required bool expanded, required ColorScheme scheme}) {
|
||||
final iconColor = scheme.onSurfaceVariant;
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: expanded ? 4 : 0,
|
||||
vertical: expanded ? 2 : 0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: expanded ? scheme.surface : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: expanded
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.12),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
]
|
||||
: const [],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.centerRight,
|
||||
clipBehavior: Clip.none,
|
||||
child: expanded
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: widget.children,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
Tooltip(
|
||||
message: 'Actions',
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
preferBelow: true,
|
||||
child: _TableActionInkWell(
|
||||
onTap: _togglePinned,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(Icons.more_vert, size: 18, color: iconColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final children = widget.children;
|
||||
if (children.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final iconColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
final actionRow = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.centerRight,
|
||||
clipBehavior: Clip.none,
|
||||
child: _expanded
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: children,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
Tooltip(
|
||||
message: 'Actions',
|
||||
child: _TableActionInkWell(
|
||||
onTap: () => setState(() => _pinned = !_pinned),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(Icons.more_vert, size: 18, color: iconColor),
|
||||
final portal = OverlayPortal(
|
||||
controller: _portalController,
|
||||
overlayChildBuilder: (context) {
|
||||
// Overlay gives full-screen constraints; shrink-wrap to the chip only.
|
||||
return CompositedTransformFollower(
|
||||
link: _link,
|
||||
showWhenUnlinked: false,
|
||||
targetAnchor: Alignment.centerRight,
|
||||
followerAnchor: Alignment.centerRight,
|
||||
child: UnconstrainedBox(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TapRegion(
|
||||
onTapOutside: (_) {
|
||||
if (_pinned) _forceClose();
|
||||
},
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => _onEnter(),
|
||||
onExit: (_) => _onExit(),
|
||||
child: _buildChip(expanded: true, scheme: scheme),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: CompositedTransformTarget(
|
||||
link: _link,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => _onEnter(),
|
||||
onExit: (_) => _onExit(),
|
||||
// Keep a stable hit target; hide when overlay chip is showing.
|
||||
child: Opacity(
|
||||
opacity: _expanded ? 0 : 1,
|
||||
child: IgnorePointer(
|
||||
ignoring: _expanded,
|
||||
child: _buildChip(expanded: false, scheme: scheme),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final interactive = TapRegion(
|
||||
onTapOutside: (_) {
|
||||
if (_pinned) setState(() => _pinned = false);
|
||||
},
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovering = true),
|
||||
onExit: (_) => setState(() => _hovering = false),
|
||||
child: actionRow,
|
||||
),
|
||||
);
|
||||
|
||||
if (!widget.expandHitArea) return interactive;
|
||||
if (!widget.expandHitArea) return portal;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
if (!width.isFinite || width <= 0) return interactive;
|
||||
if (!width.isFinite || width <= 0) return portal;
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: OverflowBox(
|
||||
maxWidth: width + _kExpandedActionsOverflow,
|
||||
alignment: Alignment.centerRight,
|
||||
child: TapRegion(
|
||||
onTapOutside: (_) {
|
||||
if (_pinned) setState(() => _pinned = false);
|
||||
},
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovering = true),
|
||||
onExit: (_) => setState(() => _hovering = false),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: actionRow,
|
||||
),
|
||||
),
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => _onEnter(),
|
||||
onExit: (_) => _onExit(),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: portal,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user