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