diff --git a/lib/app.dart b/lib/app.dart index bb3bd85..a2ee53d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -4,8 +4,10 @@ import 'package:responsive_framework/responsive_framework.dart'; import 'core/constants/app_constants.dart'; import 'core/theme/theme_provider.dart'; +import 'modules/settings/presentation/providers/settings_provider.dart'; import 'shared/routes/app_router.dart'; import 'shared/widgets/app_toast.dart'; +import 'shared/widgets/sidebar_logo.dart'; class BharatErpApp extends ConsumerWidget { const BharatErpApp({super.key}); @@ -15,9 +17,16 @@ class BharatErpApp extends ConsumerWidget { final router = ref.watch(routerProvider); final themeMode = ref.watch(themeModeProvider); final branding = ref.watch(brandingProvider); + final companyProfile = ref.watch(appSettingsProvider).companyProfile; + final appTitle = resolveSidebarTitle( + companyName: companyProfile.companyName.isNotEmpty + ? companyProfile.companyName + : (branding.companyName ?? ''), + fallback: AppConstants.appName, + ); return MaterialApp.router( - title: AppConstants.appName, + title: appTitle, debugShowCheckedModeBanner: false, theme: buildLightTheme(branding), darkTheme: buildDarkTheme(branding), diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index ab5013e..8d8685b 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -213,4 +213,5 @@ class ApiEndpoints { // Notifications static const String notifications = '/notifications'; + static const String notificationsTrigger = '/notifications/trigger'; } diff --git a/lib/core/utils/column_search_paging.dart b/lib/core/utils/column_search_paging.dart new file mode 100644 index 0000000..35861ed --- /dev/null +++ b/lib/core/utils/column_search_paging.dart @@ -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), + ); + } +} diff --git a/lib/core/utils/formatters.dart b/lib/core/utils/formatters.dart index c382569..b5daed2 100644 --- a/lib/core/utils/formatters.dart +++ b/lib/core/utils/formatters.dart @@ -42,6 +42,22 @@ class DateFormatter { if (dayDiff < 7) return '$dayDiff days ago'; return displayDateTime(local); } + + /// Multiple date formats for client-side column search. + static String searchableDate(DateTime? date) { + if (date == null) return ''; + final local = date.toLocal(); + return [ + displayDate(local), + displayDateTime(local), + formatUserLastLogin(local), + DateFormat('yyyy-MM-dd').format(local), + DateFormat('dd-MM-yyyy').format(local), + DateFormat('dd/MM/yy').format(local), + DateFormat('d/M/yyyy').format(local), + DateFormat('d/MM/yyyy').format(local), + ].join(' '); + } } class CurrencyFormatter { @@ -57,6 +73,12 @@ class CurrencyFormatter { if (amount == null) return '-'; return _formatter.format(amount); } + + /// Formatted + raw numeric text for client-side column search. + static String searchable(double? amount) { + if (amount == null) return ''; + return '${format(amount)} $amount'; + } } /// Converts `snake_case` / `kebab-case` keys into readable Title Case labels. diff --git a/lib/core/utils/responsive_utils.dart b/lib/core/utils/responsive_utils.dart index 5f15e81..90f564a 100644 --- a/lib/core/utils/responsive_utils.dart +++ b/lib/core/utils/responsive_utils.dart @@ -8,6 +8,15 @@ class AppBreakpoints { static const double tablet = 600; static const double desktop = 1024; static const double wide = 1440; + + /// Form grid: 2 cols from this width up. + static const double formSmall = 600; + + /// Form grid: 3 cols from this width up. + static const double formMedium = 900; + + /// Form grid: 4 cols from this width up. + static const double formLarge = 1200; } extension ResponsiveContext on BuildContext { @@ -22,23 +31,27 @@ extension ResponsiveContext on BuildContext { return double.infinity; } - /// Form grid columns: 4 on medium+, 2 on small screens. + /// Form grid columns: 4 / 3 / 2 / 1 by viewport width. int get formGridColumns { final width = MediaQuery.sizeOf(this).width; return formGridColumnsForWidth(width); } } -/// Responsive form field columns based on viewport width. +/// Responsive form field columns: +/// large ≥1200 → 4, medium ≥900 → 3, small ≥600 → 2, else → 1. int formGridColumnsForWidth( double width, { + int xsColumns = 1, int smallColumns = 2, - int mediumColumns = 4, + int mediumColumns = 3, int largeColumns = 4, - double mediumBreakpoint = AppBreakpoints.tablet, - double largeBreakpoint = AppBreakpoints.desktop, + double smallBreakpoint = AppBreakpoints.formSmall, + double mediumBreakpoint = AppBreakpoints.formMedium, + double largeBreakpoint = AppBreakpoints.formLarge, }) { if (width >= largeBreakpoint) return largeColumns; if (width >= mediumBreakpoint) return mediumColumns; - return smallColumns; + if (width >= smallBreakpoint) return smallColumns; + return xsColumns; } diff --git a/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart b/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart index f6f1034..8490da2 100644 --- a/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart +++ b/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart @@ -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 locations, + List statuses, + })>((ref) async { + final master = ref.watch(masterRemoteDataSourceProvider); + final locations = await _safeOptions(master.listLocations); + + List statuses = const []; + try { + final result = await ref.read(assetRepositoryProvider).getStatuses(); + if (result.failure == null && result.data != null) { + statuses = result.data!; + } + } catch (_) {} + + return (locations: locations, statuses: statuses); +}); + final assetDropdownOptionsProvider = FutureProvider.autoDispose((ref) async { return _safeAssetOptions(ref); diff --git a/lib/modules/assets/presentation/providers/assets_provider.dart b/lib/modules/assets/presentation/providers/assets_provider.dart index 5d59086..077c548 100644 --- a/lib/modules/assets/presentation/providers/assets_provider.dart +++ b/lib/modules/assets/presentation/providers/assets_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/utils/column_search_paging.dart'; import '../../../../core/utils/table_search.dart'; import '../../../../shared/models/asset_model.dart'; @@ -59,6 +60,8 @@ final assetsListProvider = ); class AssetsListNotifier extends AutoDisposeAsyncNotifier { + final _columnSearch = ColumnSearchPaging(); + @override Future build() async { return _load(const AssetListQuery(limit: 20)); @@ -134,6 +137,27 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier { ); } + Future ensureColumnSearchDataset() async { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.beginFullDataset( + currentLimit: current.query.limit, + total: current.total, + ); + if (limit == null) return; + await applyQuery( + current.query.copyWith(search: null, page: 1, limit: limit), + ); + } + + void clearColumnSearchDataset() { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.endFullDataset(); + if (limit == null) return; + applyQuery(current.query.copyWith(search: null, page: 1, limit: limit)); + } + void setStatusFilter(String? status) { final current = state.valueOrNull; if (current == null) return; @@ -199,6 +223,7 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier { return false; } await refresh(); + ref.invalidate(myMaintenanceProvider); final current = state.valueOrNull; if (current != null) { state = AsyncData(current.copyWith(actionSuccess: 'Asset deleted')); @@ -331,6 +356,7 @@ class AssetDetailNotifier extends FamilyAsyncNotifier if (result.failure != null) throw result.failure!; await reload(); ref.invalidate(assetsListProvider); + ref.invalidate(myMaintenanceProvider); return result.data; } @@ -339,6 +365,7 @@ class AssetDetailNotifier extends FamilyAsyncNotifier final result = await repository.deleteAsset(arg); if (result.failure != null) return false; ref.invalidate(assetsListProvider); + ref.invalidate(myMaintenanceProvider); return true; } @@ -476,6 +503,7 @@ class AssetFormNotifier extends AutoDisposeFamilyAsyncNotifier( + AsyncNotifierProvider.autoDispose( MyMaintenanceNotifier.new, ); -class MyMaintenanceNotifier extends AsyncNotifier { +class MyMaintenanceNotifier extends AutoDisposeAsyncNotifier { @override Future build() async { return _load(const MyMaintenanceState()); diff --git a/lib/modules/assets/presentation/screens/asset_detail_screen.dart b/lib/modules/assets/presentation/screens/asset_detail_screen.dart index 6462d3e..5d4ee30 100644 --- a/lib/modules/assets/presentation/screens/asset_detail_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_detail_screen.dart @@ -22,7 +22,7 @@ import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../providers/assets_provider.dart'; import '../providers/asset_form_lookups_provider.dart'; -import '../widgets/asset_form_panel.dart'; +import 'asset_form_screen.dart'; import '../widgets/asset_maintenance_panel.dart'; import '../widgets/asset_side_panels.dart'; import '../../../../shared/widgets/app_toast.dart'; @@ -39,6 +39,7 @@ class AssetDetailScreen extends ConsumerStatefulWidget { class _AssetDetailScreenState extends ConsumerState with SingleTickerProviderStateMixin { late final TabController _tabController; + bool _requestedFreshLoad = false; @override void initState() { @@ -46,6 +47,23 @@ class _AssetDetailScreenState extends ConsumerState _tabController = TabController(length: 4, vsync: this); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_requestedFreshLoad) return; + _requestedFreshLoad = true; + // Always hit GET /assets/{id} (+ related) when opening view. + ref.invalidate(assetDetailProvider(widget.assetId)); + } + + @override + void didUpdateWidget(covariant AssetDetailScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.assetId != widget.assetId) { + ref.invalidate(assetDetailProvider(widget.assetId)); + } + } + @override void dispose() { _tabController.dispose(); @@ -81,7 +99,7 @@ class _AssetDetailScreenState extends ConsumerState if (canEdit) OutlinedButton.icon( onPressed: () => - openAssetFormPanel(context, ref, assetId: widget.assetId), + openAssetForm(context, ref, assetId: widget.assetId), icon: const Icon(Icons.edit_outlined), label: const Text('Edit'), ), @@ -342,6 +360,7 @@ class _OverviewTab extends ConsumerWidget { asset: asset, ); if (saved == true && context.mounted) { + ref.invalidate(myMaintenanceProvider); showAppToastFromSnackBar( context, const SnackBar( diff --git a/lib/modules/assets/presentation/screens/asset_form_screen.dart b/lib/modules/assets/presentation/screens/asset_form_screen.dart new file mode 100644 index 0000000..632cd74 --- /dev/null +++ b/lib/modules/assets/presentation/screens/asset_form_screen.dart @@ -0,0 +1,1724 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../../../core/constants/route_constants.dart'; +import '../../../../core/errors/failure.dart'; +import '../../../../core/utils/formatters.dart'; +import '../../../../core/utils/validators.dart'; +import '../../../../shared/models/asset_model.dart'; +import '../../../../shared/models/user_management_models.dart' show FilterOptionModel; +import '../../../../shared/utils/navigation_utils.dart'; +import '../../../../shared/widgets/app_button.dart'; +import '../../../../shared/widgets/app_date_popup.dart'; +import '../../../../shared/widgets/app_dropdown.dart'; +import '../../../../shared/widgets/app_form_toggle_field.dart'; +import '../../../../shared/widgets/app_searchable_dropdown.dart'; +import '../../../../shared/widgets/app_loading_view.dart'; +import '../../../../shared/widgets/app_side_panel.dart'; +import '../../../../shared/widgets/app_text_field.dart'; +import '../../../../shared/widgets/app_toast.dart'; +import '../../../../shared/widgets/error_view.dart'; +import '../../../masters/data/datasources/master_remote_data_source.dart'; +import '../../../master_data/presentation/widgets/master_quick_add.dart'; +import '../../data/repositories/asset_repository_impl.dart'; +import '../providers/asset_categories_provider.dart'; +import '../providers/asset_form_lookups_provider.dart'; +import '../providers/assets_provider.dart'; + +/// Opens the full-page asset create/edit screen (same pattern as GRN form). +/// [ref] is kept for call-site compatibility; edit refetch happens in [AssetFormScreen]. +void openAssetForm( + BuildContext context, + WidgetRef ref, { + String? assetId, +}) { + if (assetId == null) { + context.push(RouteConstants.assetAdd); + } else { + context.push('/assets/$assetId/edit'); + } +} + +@Deprecated('Use openAssetForm') +void openAssetFormPanel( + BuildContext context, + WidgetRef ref, { + String? assetId, +}) => + openAssetForm(context, ref, assetId: assetId); + +class AssetFormScreen extends ConsumerStatefulWidget { + const AssetFormScreen({super.key, this.assetId}); + + final String? assetId; + + bool get isEditing => assetId != null; + + @override + ConsumerState createState() => _AssetFormScreenState(); +} + +class _AssetFormScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _scrollController = ScrollController(); + final _nameController = TextEditingController(); + final _costController = TextEditingController(); + final _serialController = TextEditingController(); + final _partNumberController = TextEditingController(); + final _brandModelController = TextEditingController(); + final _manufacturerController = TextEditingController(); + final _locationDetailController = TextEditingController(); + final _qrCodeController = TextEditingController(); + final _disposalReasonController = TextEditingController(); + final _disposalValueController = TextEditingController(); + final _usefulLifeController = TextEditingController(); + final _depreciationRateController = TextEditingController(); + final _salvageValueController = TextEditingController(); + final _remarksController = TextEditingController(); + final _frequencyController = TextEditingController(); + int? _categoryId; + int? _subcategoryId; + int? _locationId; + int? _departmentId; + int? _assignedToUserId; + int? _maintenanceInchargeUserId; + int? _vendorId; + int? _poId; + int? _grnId; + int? _grnItemId; + String? _status; + String? _condition; + String? _depreciationMethod; + DateTime? _warrantyExpiry; + DateTime? _purchaseDate; + DateTime? _commencementDate; + DateTime? _disposalDate; + List _checklistItems = []; + bool _isActive = true; + bool _isSubmitting = false; + String? _populatedSignature; + Timer? _depreciationPreviewDebounce; + AssetDepreciationPreviewModel? _depreciationPreview; + String? _depreciationPreviewError; + bool _isDepreciationPreviewLoading = false; + int _depreciationPreviewRequestId = 0; + bool _isPopulatingForm = false; + bool _requestedFreshLoad = false; + + @override + void initState() { + super.initState(); + _costController.addListener(_onDepreciationFieldChanged); + _salvageValueController.addListener(_onDepreciationFieldChanged); + _usefulLifeController.addListener(_onDepreciationFieldChanged); + _depreciationRateController.addListener(_onDepreciationFieldChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_requestedFreshLoad || !widget.isEditing) return; + _requestedFreshLoad = true; + // Always hit GET /assets/{id} when opening edit. + ref.invalidate(assetFormProvider(widget.assetId)); + } + + @override + void dispose() { + _depreciationPreviewDebounce?.cancel(); + _scrollController.dispose(); + _costController.removeListener(_onDepreciationFieldChanged); + _salvageValueController.removeListener(_onDepreciationFieldChanged); + _usefulLifeController.removeListener(_onDepreciationFieldChanged); + _depreciationRateController.removeListener(_onDepreciationFieldChanged); + _nameController.dispose(); + _costController.dispose(); + _serialController.dispose(); + _partNumberController.dispose(); + _brandModelController.dispose(); + _manufacturerController.dispose(); + _locationDetailController.dispose(); + _qrCodeController.dispose(); + _disposalReasonController.dispose(); + _disposalValueController.dispose(); + _usefulLifeController.dispose(); + _depreciationRateController.dispose(); + _salvageValueController.dispose(); + _remarksController.dispose(); + _frequencyController.dispose(); + super.dispose(); + } + + void _onDepreciationFieldChanged() { + _triggerPreviewRecalculation(); + } + + String _assetSignature(AssetModel asset) => + '${asset.id}:${asset.assetCategoryId}:${asset.assetSubcategoryId}:' + '${asset.locationId}:${asset.status}:${asset.assetName}'; + + int? _nullablePositiveId(int? id) { + if (id == null || id <= 0) return null; + return id; + } + + void _putOptionalId(Map payload, String key, int? value) { + final normalized = _nullablePositiveId(value); + if (normalized != null) payload[key] = normalized; + } + + void _putOptionalText( + Map payload, + String key, + String value, + ) { + final trimmed = value.trim(); + if (trimmed.isNotEmpty) payload[key] = trimmed; + } + + void _putOptionalDouble( + Map payload, + String key, + String value, + ) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return; + final parsed = double.tryParse(trimmed); + if (parsed != null) payload[key] = parsed; + } + + int? _dropdownValue(int? selected, Iterable validIds) { + if (selected == null) return null; + return validIds.contains(selected) ? selected : null; + } + + bool _isAssignableUser(int? userId) { + if (userId == null || userId <= 0) return false; + final users = ref.read(assetFormLookupsProvider).valueOrNull?.users ?? const []; + return users.any((user) => int.tryParse(user.id) == userId); + } + + void _populateFromAsset(AssetModel asset) { + _isPopulatingForm = true; + setState(() { + _nameController.text = asset.assetName; + _categoryId = asset.assetCategoryId; + _subcategoryId = asset.assetSubcategoryId; + _locationId = asset.locationId; + _departmentId = _nullablePositiveId(asset.departmentId); + _assignedToUserId = _nullablePositiveId(asset.assignedToUserId); + _maintenanceInchargeUserId = + _nullablePositiveId(asset.maintenanceInchargeUserId); + _vendorId = _nullablePositiveId(asset.vendorId); + _poId = _nullablePositiveId(asset.poId); + _grnId = _nullablePositiveId(asset.grnId); + _grnItemId = _nullablePositiveId(asset.grnItemId); + _status = asset.status; + _condition = asset.condition; + _depreciationMethod = asset.depreciationMethod; + _warrantyExpiry = asset.warrantyExpiryDate; + _purchaseDate = asset.purchaseDate; + _commencementDate = asset.commencementDate; + _disposalDate = asset.disposalDate; + _checklistItems = [ + ...(asset.maintenanceChecklistJson ?? const []), + ]; + _isActive = asset.isActive; + _serialController.text = asset.serialNumber ?? ''; + _partNumberController.text = asset.partNumber ?? ''; + _brandModelController.text = asset.brandModel ?? ''; + _manufacturerController.text = asset.manufacturer ?? ''; + _locationDetailController.text = asset.locationDetail ?? ''; + _qrCodeController.text = asset.qrCodeValue ?? ''; + _disposalReasonController.text = asset.disposalReason ?? ''; + _disposalValueController.text = asset.disposalValue?.toString() ?? ''; + _usefulLifeController.text = asset.usefulLifeYears?.toString() ?? ''; + _depreciationRateController.text = asset.depreciationRate?.toString() ?? ''; + _salvageValueController.text = asset.salvageValue?.toString() ?? ''; + _remarksController.text = asset.remarks ?? ''; + _frequencyController.text = + asset.maintenanceFrequencyInDays?.toString() ?? ''; + if (asset.purchaseCost != null) { + _costController.text = asset.purchaseCost!.toStringAsFixed( + asset.purchaseCost! % 1 == 0 ? 0 : 2, + ); + } else { + _costController.clear(); + } + }); + _isPopulatingForm = false; + _triggerPreviewRecalculation(); + } + + void _triggerPreviewRecalculation() { + if (_isPopulatingForm) return; + _depreciationPreviewDebounce?.cancel(); + _depreciationPreviewDebounce = Timer( + const Duration(milliseconds: 400), + _calculateDepreciationPreview, + ); + } + + Map? _buildDepreciationPreviewPayload() { + final method = _depreciationMethod?.trim().toUpperCase(); + if (method == null || method.isEmpty) { + return null; + } + + final rate = double.tryParse(_depreciationRateController.text.trim()); + if (method == 'OTHER' && rate == null) { + return null; + } + + final payload = { + 'depreciation_method': method, + if (rate != null) 'depreciation_rate': rate, + }; + + final purchaseCost = double.tryParse(_costController.text.trim()); + if (purchaseCost != null) payload['purchase_cost'] = purchaseCost; + + final salvageValue = double.tryParse(_salvageValueController.text.trim()); + if (salvageValue != null) payload['salvage_value'] = salvageValue; + + final usefulLife = int.tryParse(_usefulLifeController.text.trim()); + if (usefulLife != null) payload['useful_life_years'] = usefulLife; + + if (_commencementDate != null) { + payload['commencement_date'] = + DateFormat('yyyy-MM-dd').format(_commencementDate!); + } + if (_purchaseDate != null) { + payload['purchase_date'] = DateFormat('yyyy-MM-dd').format(_purchaseDate!); + } + + return payload; + } + + Future _calculateDepreciationPreview() async { + final payload = _buildDepreciationPreviewPayload(); + + if (payload == null) { + if (!mounted) return; + setState(() { + _isDepreciationPreviewLoading = false; + _depreciationPreview = null; + _depreciationPreviewError = _depreciationMethod?.trim().toUpperCase() == 'OTHER' + ? 'Depreciation rate is required for OTHER' + : null; + }); + return; + } + + final requestId = ++_depreciationPreviewRequestId; + if (mounted) { + setState(() { + _isDepreciationPreviewLoading = true; + _depreciationPreviewError = null; + }); + } + + final result = + await ref.read(assetRepositoryProvider).calculateDepreciationPreview(payload); + if (!mounted || requestId != _depreciationPreviewRequestId) return; + + setState(() { + _isDepreciationPreviewLoading = false; + if (result.failure != null) { + _depreciationPreview = null; + _depreciationPreviewError = result.failure!.message; + } else { + _depreciationPreview = result.data; + _depreciationPreviewError = null; + } + }); + } + + Map _buildPayload() { + final payload = { + 'asset_name': _nameController.text.trim(), + 'item_category_id': _categoryId, + 'item_subcategory_id': _subcategoryId, + 'location_id': _locationId, + 'is_active': _isActive, + }; + + _putOptionalId(payload, 'department_id', _departmentId); + if (_isAssignableUser(_assignedToUserId)) { + _putOptionalId(payload, 'assigned_to_user_id', _assignedToUserId); + } + if (_isAssignableUser(_maintenanceInchargeUserId)) { + _putOptionalId( + payload, + 'maintenance_incharge_user_id', + _maintenanceInchargeUserId, + ); + } + _putOptionalId(payload, 'vendor_id', _vendorId); + _putOptionalId(payload, 'po_id', _poId); + _putOptionalId(payload, 'grn_id', _grnId); + _putOptionalId(payload, 'grn_item_id', _grnItemId); + + _putOptionalText(payload, 'serial_number', _serialController.text); + _putOptionalText(payload, 'part_number', _partNumberController.text); + _putOptionalText(payload, 'brand_model', _brandModelController.text); + _putOptionalText(payload, 'manufacturer', _manufacturerController.text); + _putOptionalText(payload, 'location_detail', _locationDetailController.text); + _putOptionalText(payload, 'qr_code_value', _qrCodeController.text); + _putOptionalText(payload, 'remarks', _remarksController.text); + _putOptionalText(payload, 'disposal_reason', _disposalReasonController.text); + + if (_purchaseDate != null) { + payload['purchase_date'] = DateFormat('yyyy-MM-dd').format(_purchaseDate!); + } + if (_commencementDate != null) { + payload['commencement_date'] = + DateFormat('yyyy-MM-dd').format(_commencementDate!); + } + if (_warrantyExpiry != null) { + payload['warranty_expiry_date'] = + DateFormat('yyyy-MM-dd').format(_warrantyExpiry!); + } + if (_disposalDate != null) { + payload['disposal_date'] = DateFormat('yyyy-MM-dd').format(_disposalDate!); + } + + _putOptionalDouble(payload, 'purchase_cost', _costController.text); + _putOptionalDouble(payload, 'salvage_value', _salvageValueController.text); + _putOptionalDouble(payload, 'disposal_value', _disposalValueController.text); + + final usefulLife = int.tryParse(_usefulLifeController.text.trim()); + if (usefulLife != null) payload['useful_life_years'] = usefulLife; + + final frequency = int.tryParse(_frequencyController.text.trim()); + if (frequency != null) payload['maintenance_frequency_in_days'] = frequency; + + final checklistPayload = _checklistItems + .map((item) { + final label = item.label.trim(); + if (label.isEmpty) return null; + return { + 'label': label, + 'required': item.required, + }; + }) + .whereType>() + .toList(); + if (checklistPayload.isNotEmpty) { + payload['maintenance_checklist_json'] = checklistPayload; + } + + if (_depreciationMethod != null) { + payload['depreciation_method'] = _depreciationMethod; + } + + final depreciationRate = + double.tryParse(_depreciationRateController.text.trim()); + if (depreciationRate != null) payload['depreciation_rate'] = depreciationRate; + + if (_condition != null) payload['condition'] = _condition; + if (_status != null) payload['status'] = _status; + + return payload; + } + + Future _pickDate(void Function(DateTime) onPicked, DateTime? current) async { + final picked = await showAppDatePopup( + context: context, + initialDate: current ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + helpText: 'Select date', + ); + if (picked != null) { + setState(() => onPicked(picked)); + _triggerPreviewRecalculation(); + } + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + final businessError = _validateBusinessRules(); + if (businessError != null) { + showAppToastFromSnackBar(context, SnackBar(content: Text(businessError))); + return; + } + + setState(() => _isSubmitting = true); + try { + final notifier = ref.read(assetFormProvider(widget.assetId).notifier); + final payload = _buildPayload(); + final AssetModel? saved; + if (widget.isEditing) { + saved = await notifier.submitUpdate(widget.assetId!, payload); + } else { + saved = await notifier.submitCreate(payload); + } + + if (!mounted) return; + showAppToastFromSnackBar( + context, + SnackBar( + content: Text( + widget.isEditing + ? 'Asset updated successfully' + : 'Asset created successfully', + ), + ), + ); + + final destination = widget.isEditing + ? '${RouteConstants.assets}/${widget.assetId}' + : (saved?.id != null && saved!.id.isNotEmpty) + ? '${RouteConstants.assets}/${saved.id}' + : RouteConstants.assets; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + goAndDismissOverlays(context, destination); + }); + } catch (e) { + if (mounted) { + showAppToastFromSnackBar( + context, + SnackBar(content: Text(e.toString())), + ); + } + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + + void _goBack() { + if (widget.isEditing) { + context.go('${RouteConstants.assets}/${widget.assetId}'); + } else if (context.canPop()) { + context.pop(); + } else { + context.go(RouteConstants.assets); + } + } + + String? _validateBusinessRules() { + if (_purchaseDate != null && + _warrantyExpiry != null && + _warrantyExpiry!.isBefore(_purchaseDate!)) { + return 'Warranty expiry must be on or after purchase date'; + } + + final purchaseCost = double.tryParse(_costController.text.trim()); + final salvageValue = double.tryParse(_salvageValueController.text.trim()); + if (purchaseCost != null && + salvageValue != null && + salvageValue > purchaseCost) { + return 'Salvage value cannot exceed purchase cost'; + } + + final hasDepreciationMethod = + _depreciationMethod != null && _depreciationMethod!.trim().isNotEmpty; + if (hasDepreciationMethod) { + if (_usefulLifeController.text.trim().isEmpty) { + return 'Useful life is required when depreciation method is set'; + } + final method = _depreciationMethod!.trim().toUpperCase(); + if (method == 'OTHER' && _depreciationRateController.text.trim().isEmpty) { + return 'Depreciation rate is required when depreciation method is OTHER'; + } + } + + final isDisposed = _status == 'DISPOSED' || _status == 'SCRAPPED'; + if (isDisposed) { + if (_disposalDate == null) { + return 'Disposal date is required for disposed or scrapped assets'; + } + if (_disposalReasonController.text.trim().isEmpty) { + return 'Disposal reason is required for disposed or scrapped assets'; + } + } + + final hasDisposalInput = _disposalDate != null || + _disposalReasonController.text.trim().isNotEmpty || + _disposalValueController.text.trim().isNotEmpty; + if (hasDisposalInput && !isDisposed) { + return 'Set status to Disposed or Scrapped when entering disposal details'; + } + + if (_grnId != null) { + final grnItems = + ref.read(assetGrnItemsProvider(_grnId)).valueOrNull ?? const []; + if (grnItems.isNotEmpty && _grnItemId == null) { + return 'Please select a GRN item when a GRN is linked'; + } + } + + return null; + } + + @override + Widget build(BuildContext context) { + final categoriesAsync = ref.watch(itemCategoriesFormProvider); + + if (widget.isEditing) { + ref.listen(assetFormProvider(widget.assetId), (prev, next) { + next.whenData((asset) { + if (asset == null || !mounted) return; + final signature = _assetSignature(asset); + if (_populatedSignature != signature) { + _populatedSignature = signature; + _populateFromAsset(asset); + } + }); + }); + } + + final formAsync = + widget.isEditing ? ref.watch(assetFormProvider(widget.assetId)) : null; + + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + body: widget.isEditing && formAsync != null + ? formAsync.when( + loading: () => const AppLoadingView(message: 'Loading asset...'), + error: (e, _) => ErrorView.fromFailure( + e is Failure ? e : Failure.unknown(message: e.toString()), + onRetry: () => + ref.invalidate(assetFormProvider(widget.assetId)), + ), + data: (_) => _buildForm(categoriesAsync), + ) + : _buildForm(categoriesAsync), + ); + } + + Widget _buildHeader() { + final theme = Theme.of(context); + final title = widget.isEditing ? 'Edit Asset' : 'Add Asset'; + final subtitle = widget.isEditing + ? 'Update asset details, maintenance checklist, and related fields.' + : 'Enter asset details, location, and maintenance checklist.'; + + final actions = Row( + mainAxisSize: MainAxisSize.min, + children: [ + OutlinedButton( + onPressed: _isSubmitting ? null : _goBack, + child: const Text('Cancel'), + ), + const SizedBox(width: 12), + AppButton( + label: widget.isEditing ? 'Update Asset' : 'Save Asset', + expand: false, + icon: Icons.check, + isLoading: _isSubmitting, + onPressed: _isSubmitting ? null : _submit, + ), + ], + ); + + final titleBlock = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.headlineSmall), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: LayoutBuilder( + builder: (context, constraints) { + final stack = constraints.maxWidth < 720; + if (stack) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + tooltip: 'Back', + onPressed: _isSubmitting ? null : _goBack, + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: 4), + Expanded(child: titleBlock), + ], + ), + const SizedBox(height: 12), + Align(alignment: Alignment.centerRight, child: actions), + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + tooltip: 'Back', + onPressed: _isSubmitting ? null : _goBack, + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: 4), + Expanded(child: titleBlock), + const SizedBox(width: 12), + actions, + ], + ); + }, + ), + ); + } + + Widget _buildForm( + AsyncValue> categoriesAsync, + ) { + final subcategoriesAsync = ref.watch(itemSubcategoriesProvider(_categoryId)); + final lookupsAsync = ref.watch(assetFormLookupsProvider); + final grnItemsAsync = ref.watch(assetGrnItemsProvider(_grnId)); + final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel(); + final depreciationMethods = options.depreciationMethods; + final conditions = options.conditions; + final statuses = options.statuses; + + // Resolve create defaults once options load from API. + if (!widget.isEditing && lookupsAsync.hasValue) { + final nextCondition = resolveAssetOptionValue( + _condition, + conditions, + preferFirstWhenEmpty: true, + ); + final nextStatus = resolveAssetOptionValue( + _status, + statuses, + preferFirstWhenEmpty: true, + ); + if (nextCondition != _condition || nextStatus != _status) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() { + _condition = nextCondition; + _status = nextStatus; + }); + }); + } + } + + return Form( + key: _formKey, + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(), + const SizedBox(height: 8), + SidePanelSection( + title: 'BASIC DETAILS', + children: [ + FormRowFour( + children: [ + AppTextField( + isDense: true, + controller: _nameController, + label: 'Asset Name *', + validator: (v) { + final requiredError = + Validators.required(v, fieldName: 'Asset Name'); + if (requiredError != null) return requiredError; + return Validators.minLength( + v!.trim(), + 2, + fieldName: 'Asset Name', + ); + }, + ), + categoriesAsync.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('Failed to load categories'), + data: (categories) => _categoryDropdown(categories), + ), + subcategoriesAsync.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => + const Text('Failed to load subcategories'), + data: (subcategories) => + _subcategoryDropdown(subcategories), + ), + lookupsAsync.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('Failed to load locations'), + data: (lookups) => _locationDropdown(lookups.locations), + ), + ], + ), + ], + ), + SidePanelSection( + title: 'IDENTIFICATION', + children: [ + FormRowFour( + children: [ + AppTextField( + isDense: true, + controller: _serialController, + label: 'Serial Number', + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + return Validators.minLength( + v.trim(), + 2, + fieldName: 'Serial Number', + ); + }, + ), + AppTextField( + isDense: true, + controller: _partNumberController, + label: 'Part Number', + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + return Validators.minLength( + v.trim(), + 2, + fieldName: 'Part Number', + ); + }, + ), + AppTextField( + isDense: true, + controller: _brandModelController, + label: 'Brand / Model', + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + return Validators.minLength( + v.trim(), + 2, + fieldName: 'Brand / Model', + ); + }, + ), + AppTextField( + isDense: true, + controller: _manufacturerController, + label: 'Manufacturer', + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + return Validators.minLength( + v.trim(), + 2, + fieldName: 'Manufacturer', + ); + }, + ), + ], + ), + ], + ), + lookupsAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: LinearProgressIndicator(), + ), + error: (_, __) => const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('Failed to load lookup options'), + ), + data: (lookups) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SidePanelSection( + title: 'LOCATION & ASSIGNMENT', + children: [ + FormRowFour( + children: [ + _optionalLookupDropdown( + label: 'Department', + value: _departmentId, + options: lookups.departments, + masterId: 'departments', + onChanged: (v) => + setState(() => _departmentId = v), + ), + AppTextField( + isDense: true, + controller: _locationDetailController, + label: 'Location Detail', + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + return Validators.minLength( + v.trim(), + 2, + fieldName: 'Location Detail', + ); + }, + ), + _optionalLookupDropdown( + label: 'Assigned To', + value: _assignedToUserId, + options: lookups.users, + onChanged: (v) => + setState(() => _assignedToUserId = v), + emptyHint: 'Unassigned', + ), + ], + ), + ], + ), + SidePanelSection( + title: 'MAINTENANCE', + children: [ + FormRowFour( + children: [ + _optionalLookupDropdown( + label: 'Maintenance Incharge', + value: _maintenanceInchargeUserId, + options: lookups.users, + onChanged: (v) => setState( + () => _maintenanceInchargeUserId = v, + ), + emptyHint: 'Unassigned', + ), + AppTextField( + isDense: true, + controller: _frequencyController, + label: 'Frequency (Days)', + keyboardType: TextInputType.number, + validator: (v) => Validators.optionalPositiveInt( + v, + fieldName: 'Frequency', + ), + ), + ], + ), + _buildChecklistEditor(), + ], + ), + SidePanelSection( + title: 'PROCUREMENT', + children: [ + FormRowFour( + children: [ + _optionalLookupDropdown( + label: 'Vendor', + value: _vendorId, + options: lookups.vendors, + onChanged: (v) => setState(() => _vendorId = v), + ), + _optionalLookupDropdown( + label: 'Purchase Order', + value: _poId, + options: lookups.purchaseOrders, + onChanged: (v) => setState(() => _poId = v), + ), + _optionalLookupDropdown( + label: 'GRN', + value: _grnId, + options: lookups.grns, + onChanged: (v) => setState(() { + _grnId = v; + _grnItemId = null; + }), + ), + grnItemsAsync.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => _optionalLookupDropdown( + label: 'GRN Item', + value: _grnItemId, + options: const [], + onChanged: (v) => + setState(() => _grnItemId = v), + enabled: false, + ), + data: (items) => _optionalLookupDropdown( + label: 'GRN Item', + value: _grnItemId, + options: items, + onChanged: (v) => + setState(() => _grnItemId = v), + enabled: _grnId != null && items.isNotEmpty, + emptyHint: _grnId == null + ? 'Select GRN first' + : 'None', + required: _grnId != null && items.isNotEmpty, + ), + ), + ], + ), + ], + ), + ], + ), + ), + SidePanelSection( + title: 'PURCHASE & DEPRECIATION', + children: [ + FormRowFour( + children: [ + _AssetFormDateField( + label: 'Purchase Date', + value: _purchaseDate, + onPick: () => + _pickDate((d) => _purchaseDate = d, _purchaseDate), + ), + _AssetFormDateField( + label: 'Commencement Date', + value: _commencementDate, + onPick: () => _pickDate( + (d) => _commencementDate = d, + _commencementDate, + ), + ), + _AssetFormDateField( + label: 'Warranty Expiry Date', + value: _warrantyExpiry, + onPick: () => + _pickDate((d) => _warrantyExpiry = d, _warrantyExpiry), + ), + AppTextField( + isDense: true, + controller: _costController, + label: 'Purchase Cost', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + validator: (v) => Validators.optionalPositiveDouble( + v, + fieldName: 'Purchase Cost', + ), + ), + ], + ), + FormRowFour( + children: [ + AppTextField( + isDense: true, + controller: _usefulLifeController, + label: 'Useful Life (Years)', + keyboardType: TextInputType.number, + validator: (v) => Validators.optionalPositiveInt( + v, + fieldName: 'Useful Life', + ), + ), + AppDropdown( + isDense: true, + label: 'Depreciation Method', + value: _depreciationMethod, + options: assetOptionDropdowns(depreciationMethods), + enabled: depreciationMethods.isNotEmpty, + hint: depreciationMethods.isEmpty + ? 'Loading depreciation methods...' + : null, + onChanged: (v) => setState(() { + _depreciationMethod = v; + _triggerPreviewRecalculation(); + }), + ), + AppTextField( + isDense: true, + controller: _depreciationRateController, + label: 'Depreciation Rate (%)', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + validator: (v) => Validators.optionalPercentage( + v, + fieldName: 'Depreciation Rate', + ), + ), + AppTextField( + isDense: true, + controller: _salvageValueController, + label: 'Salvage Value', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + validator: (v) => Validators.optionalNonNegativeDouble( + v, + fieldName: 'Salvage Value', + ), + ), + ], + ), + _DepreciationPreviewCard( + preview: _depreciationPreview, + isLoading: _isDepreciationPreviewLoading, + error: _depreciationPreviewError, + ), + ], + ), + SidePanelSection( + title: 'STATUS', + children: [ + FormRowFour( + children: [ + AppDropdown( + isDense: true, + label: 'Condition *', + value: _condition, + options: assetOptionDropdowns(conditions), + enabled: conditions.isNotEmpty, + onChanged: (v) => setState(() => _condition = v), + validator: (v) => + v == null ? 'Condition is required' : null, + ), + AppDropdown( + isDense: true, + label: 'Status *', + value: _status, + options: assetOptionDropdowns(statuses), + enabled: statuses.isNotEmpty, + onChanged: (v) => setState(() => _status = v), + validator: (v) => v == null ? 'Status is required' : null, + ), + AppFormToggleField( + label: 'Active', + subtitle: 'Hidden from active lists when off', + value: _isActive, + onChanged: (value) => setState(() => _isActive = value), + ), + ], + ), + ], + ), + SidePanelSection( + title: 'DISPOSAL', + children: [ + FormRowFour( + children: [ + _AssetFormDateField( + label: 'Disposal Date', + value: _disposalDate, + onPick: () => + _pickDate((d) => _disposalDate = d, _disposalDate), + validator: () { + final isDisposed = + _status == 'DISPOSED' || _status == 'SCRAPPED'; + if (isDisposed && _disposalDate == null) { + return 'Disposal date is required'; + } + return null; + }, + ), + AppTextField( + isDense: true, + controller: _disposalValueController, + label: 'Disposal Value', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + validator: (v) => Validators.optionalNonNegativeDouble( + v, + fieldName: 'Disposal Value', + ), + ), + AppTextField( + isDense: true, + controller: _disposalReasonController, + label: 'Disposal Reason', + maxLines: 2, + validator: (v) { + final isDisposed = + _status == 'DISPOSED' || _status == 'SCRAPPED'; + if (isDisposed) { + return Validators.required( + v, + fieldName: 'Disposal Reason', + ); + } + if (v == null || v.trim().isEmpty) return null; + return Validators.minLength( + v.trim(), + 3, + fieldName: 'Disposal Reason', + ); + }, + ), + ], + ), + ], + ), + SidePanelSection( + title: 'OTHER', + children: [ + FormRowFour( + children: [ + AppTextField( + isDense: true, + controller: _qrCodeController, + label: 'QR Code Value', + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + return Validators.minLength( + v.trim(), + 2, + fieldName: 'QR Code Value', + ); + }, + ), + AppTextField( + isDense: true, + controller: _remarksController, + label: 'Remarks', + maxLines: 2, + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + return Validators.minLength( + v.trim(), + 2, + fieldName: 'Remarks', + ); + }, + ), + ], + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildChecklistEditor() { + final theme = Theme.of(context); + final borderColor = theme.colorScheme.outline.withValues(alpha: 0.35); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + ), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Checklist Template', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + TextButton.icon( + onPressed: () { + setState(() { + _checklistItems = [ + ..._checklistItems, + const AssetMaintenanceChecklistItem( + label: '', + ), + ]; + }); + }, + icon: const Icon(Icons.add, size: 18), + label: const Text('Add Item'), + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.primary, + ), + ), + ], + ), + if (_checklistItems.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'No checklist items. Add items for maintenance checks.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + for (var i = 0; i < _checklistItems.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + _ChecklistItemRow( + key: ValueKey('checklist-row-$i'), + item: _checklistItems[i], + onChanged: (updated) { + setState(() { + _checklistItems = [ + for (var j = 0; j < _checklistItems.length; j++) + if (j == i) updated else _checklistItems[j], + ]; + }); + }, + onRemove: () { + setState(() { + _checklistItems = [ + for (var j = 0; j < _checklistItems.length; j++) + if (j != i) _checklistItems[j], + ]; + }); + }, + ), + ], + ], + ), + ); + } + + Widget _optionalLookupDropdown({ + required String label, + required int? value, + required List options, + required ValueChanged onChanged, + String? masterId, + String? emptyHint, + bool enabled = true, + bool required = false, + }) { + final dropdownOptions = >[ + AppDropdownOption(value: null, label: emptyHint ?? 'None'), + ...options + .map((option) { + final id = int.tryParse(option.id); + if (id == null || id <= 0) return null; + return AppDropdownOption(value: id, label: option.name); + }) + .whereType>(), + ]; + final validIds = dropdownOptions + .map((option) => option.value) + .whereType() + .toList(); + final resolvedValue = + value != null && validIds.contains(value) ? value : null; + final fieldEnabled = + enabled && (options.isNotEmpty || masterId != null); + + if (masterId == null) { + return AppSearchableDropdown( + label: required ? '$label *' : label, + value: resolvedValue, + hint: emptyHint ?? 'None', + searchHint: 'Search ${label.toLowerCase()}...', + isDense: true, + enabled: fieldEnabled, + options: dropdownOptions, + onChanged: onChanged, + validator: + required ? (v) => v == null ? '$label is required' : null : null, + ); + } + + return MasterQuickAddDropdown( + masterId: masterId, + label: required ? '$label *' : label, + value: resolvedValue, + hint: emptyHint ?? 'None', + searchHint: 'Search ${label.toLowerCase()}...', + isDense: true, + enabled: fieldEnabled, + options: dropdownOptions, + refreshLookups: () { + ref.invalidate(assetFormLookupsProvider); + }, + parseCreatedId: int.tryParse, + onChanged: onChanged, + validator: + required ? (v) => v == null ? '$label is required' : null : null, + ); + } + + Widget _subcategoryDropdown(List subcategories) { + final ids = subcategories.map((c) => int.tryParse(c.id)).whereType().toList(); + final hasCategory = _categoryId != null; + final options = subcategories + .map( + (c) => AppDropdownOption( + value: int.tryParse(c.id) ?? 0, + label: c.name, + ), + ) + .where((option) => option.value != 0) + .toList(); + return MasterQuickAddDropdown( + masterId: 'item_subcategories', + label: 'Subcategory *', + value: _dropdownValue(_subcategoryId, ids), + hint: !hasCategory + ? 'Select category first' + : options.isEmpty + ? 'No subcategories found' + : 'Select subcategory *', + searchHint: 'Search subcategory...', + isDense: true, + enabled: hasCategory, + options: options, + initialValues: {'item_category_id': _categoryId}, + refreshLookups: () { + ref.invalidate(itemSubcategoriesProvider(_categoryId)); + }, + parseCreatedId: int.tryParse, + onChanged: (v) => setState(() => _subcategoryId = v), + validator: (v) => v == null ? 'Subcategory is required' : null, + ); + } + + Widget _categoryDropdown(List categories) { + final activeCategories = + categories.where((category) => category.isActive).toList(); + final categoryIds = activeCategories + .map((c) => int.tryParse(c.id)) + .whereType() + .toList(); + return MasterQuickAddDropdown( + masterId: 'item_categories', + label: 'Category *', + value: _dropdownValue(_categoryId, categoryIds), + searchHint: 'Search category...', + isDense: true, + initialValues: const {'category_type': 'ASSET'}, + options: activeCategories + .map( + (c) => AppDropdownOption( + value: int.tryParse(c.id) ?? 0, + label: '${c.code} — ${c.name}', + ), + ) + .where((option) => option.value != 0) + .toList(), + refreshLookups: () { + ref.invalidate(itemCategoriesFormProvider); + }, + parseCreatedId: int.tryParse, + onChanged: (v) => setState(() { + _categoryId = v; + _subcategoryId = null; + final selectedCategory = + activeCategories.where((c) => int.tryParse(c.id) == v).firstOrNull; + if (selectedCategory != null) { + if (selectedCategory.defaultDepreciationMethod != null && + selectedCategory.defaultDepreciationMethod!.trim().isNotEmpty) { + _depreciationMethod = + selectedCategory.defaultDepreciationMethod!.trim().toUpperCase(); + } + if (selectedCategory.defaultUsefulLifeYears != null && + selectedCategory.defaultUsefulLifeYears! > 0) { + _usefulLifeController.text = + selectedCategory.defaultUsefulLifeYears!.toString(); + } + } + _triggerPreviewRecalculation(); + }), + validator: (v) => v == null ? 'Category is required' : null, + ); + } + + Widget _locationDropdown(List locations) { + final locationIds = locations + .map((location) => int.tryParse(location.id)) + .whereType() + .toList(); + return AppSearchableDropdown( + label: 'Location *', + value: _dropdownValue(_locationId, locationIds), + searchHint: 'Search plant or warehouse...', + isDense: true, + options: locations + .map( + (location) => AppDropdownOption( + value: int.tryParse(location.id) ?? 0, + label: location.name, + ), + ) + .where((option) => option.value != 0) + .toList(), + onChanged: (v) => setState(() => _locationId = v), + validator: (v) => v == null ? 'Location is required' : null, + ); + } +} + +class _ChecklistItemRow extends StatefulWidget { + const _ChecklistItemRow({ + super.key, + required this.item, + required this.onChanged, + required this.onRemove, + }); + + final AssetMaintenanceChecklistItem item; + final ValueChanged onChanged; + final VoidCallback onRemove; + + @override + State<_ChecklistItemRow> createState() => _ChecklistItemRowState(); +} + +class _ChecklistItemRowState extends State<_ChecklistItemRow> { + late final TextEditingController _labelController; + + @override + void initState() { + super.initState(); + _labelController = TextEditingController(text: widget.item.label); + } + + @override + void didUpdateWidget(covariant _ChecklistItemRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.item.label != widget.item.label && + _labelController.text != widget.item.label) { + _labelController.text = widget.item.label; + } + } + + @override + void dispose() { + _labelController.dispose(); + super.dispose(); + } + + void _emit({String? label, bool? required}) { + widget.onChanged( + AssetMaintenanceChecklistItem( + label: label ?? _labelController.text, + required: required ?? widget.item.required, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final borderColor = theme.colorScheme.outline.withValues(alpha: 0.35); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: borderColor), + ), + padding: const EdgeInsets.fromLTRB(10, 8, 6, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: AppTextField( + isDense: true, + controller: _labelController, + label: 'Checklist item', + onChanged: (value) => _emit(label: value), + ), + ), + const SizedBox(width: 12), + AppFormToggleField( + label: 'Required', + value: widget.item.required, + onChanged: (value) => _emit(required: value), + ), + IconButton( + tooltip: 'Remove item', + onPressed: widget.onRemove, + icon: Icon( + Icons.delete_outline, + size: 20, + color: theme.colorScheme.error, + ), + ), + ], + ), + ); + } +} + +class _AssetFormDateField extends StatefulWidget { + const _AssetFormDateField({ + required this.label, + required this.value, + required this.onPick, + this.validator, + }); + + final String label; + final DateTime? value; + final VoidCallback onPick; + final String? Function()? validator; + + @override + State<_AssetFormDateField> createState() => _AssetFormDateFieldState(); +} + +class _DepreciationPreviewCard extends StatelessWidget { + const _DepreciationPreviewCard({ + required this.preview, + required this.isLoading, + required this.error, + }); + + final AssetDepreciationPreviewModel? preview; + final bool isLoading; + final String? error; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (isLoading) { + return const LinearProgressIndicator(); + } + + if (error != null && error!.trim().isNotEmpty) { + return Text( + error!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ); + } + + if (preview == null) { + return Text( + 'Enter depreciation fields to preview calculated values.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ); + } + + String money(double value) => NumberFormat.currency( + locale: 'en_IN', + symbol: '₹', + decimalDigits: 2, + ).format(value); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.28), + ), + ), + child: Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _PreviewItem( + label: 'Resolved Rate', + value: '${preview!.depreciationRate.toStringAsFixed(2)}%', + ), + _PreviewItem( + label: 'Annual Depreciation', + value: money(preview!.annualDepreciation), + ), + _PreviewItem( + label: 'Accumulated Depreciation', + value: money(preview!.accumulatedDepreciation), + ), + _PreviewItem( + label: 'Current Book Value', + value: money(preview!.bookValue), + ), + _PreviewItem( + label: 'Years Elapsed', + value: preview!.yearsElapsed.toString(), + ), + ], + ), + ); + } +} + +class _PreviewItem extends StatelessWidget { + const _PreviewItem({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: 220, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _AssetFormDateFieldState extends State<_AssetFormDateField> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: _displayText); + } + + @override + void didUpdateWidget(covariant _AssetFormDateField oldWidget) { + super.didUpdateWidget(oldWidget); + final text = _displayText; + if (_controller.text != text) { + _controller.text = text; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + String get _displayText => + widget.value != null ? DateFormatter.displayDate(widget.value) : ''; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: TextFormField( + readOnly: true, + onTap: widget.onPick, + controller: _controller, + validator: (_) => widget.validator?.call(), + decoration: InputDecoration( + labelText: widget.label, + hintText: 'Select date', + floatingLabelBehavior: FloatingLabelBehavior.always, + isDense: true, + suffixIcon: const Icon(Icons.calendar_today_outlined, size: 20), + ), + ), + ); + } +} + +final itemSubcategoriesProvider = + FutureProvider.family, int?>((ref, categoryId) async { + if (categoryId == null) return []; + final dataSource = ref.watch(masterRemoteDataSourceProvider); + return dataSource.listItemSubcategories(itemCategoryId: categoryId); +}); diff --git a/lib/modules/assets/presentation/screens/asset_list_screen.dart b/lib/modules/assets/presentation/screens/asset_list_screen.dart index 7825bd9..31e633f 100644 --- a/lib/modules/assets/presentation/screens/asset_list_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_list_screen.dart @@ -29,7 +29,7 @@ import '../../../../shared/utils/file_download_helper.dart'; import '../providers/asset_categories_provider.dart'; import '../providers/asset_form_lookups_provider.dart'; import '../providers/assets_provider.dart'; -import '../widgets/asset_form_panel.dart'; +import 'asset_form_screen.dart'; import '../../../../shared/widgets/app_toast.dart'; class AssetListScreen extends ConsumerStatefulWidget { @@ -45,6 +45,10 @@ class _AssetListScreenState extends ConsumerState { @override Widget build(BuildContext context) { final assetsAsync = ref.watch(assetsListProvider); + final filterLookups = + ref.watch(assetListFilterLookupsProvider).valueOrNull; + final allCategories = + ref.watch(itemCategoriesProvider).valueOrNull ?? []; final canEdit = ref.can('assets', PermissionAction.update); final canDelete = ref.can('assets', PermissionAction.delete); final canExport = ref.can('assets', PermissionAction.export); @@ -69,10 +73,8 @@ class _AssetListScreenState extends ConsumerState { onRetry: () => ref.invalidate(assetsListProvider), ), data: (state) { - final allCategories = - ref.watch(itemCategoriesProvider).valueOrNull ?? []; - final lookups = ref.watch(assetFormLookupsProvider).valueOrNull; - final allLocations = lookups?.locations ?? const []; + final allLocations = filterLookups?.locations ?? const []; + final statuses = filterLookups?.statuses ?? const []; final notifier = ref.read(assetsListProvider.notifier); return Column( @@ -112,7 +114,7 @@ class _AssetListScreenState extends ConsumerState { module: 'assets', action: PermissionAction.create, child: ElevatedButton.icon( - onPressed: () => openAssetFormPanel(context, ref), + onPressed: () => openAssetForm(context, ref), icon: const Icon(Icons.add), label: const Text('Add Asset'), ), @@ -151,7 +153,7 @@ class _AssetListScreenState extends ConsumerState { query: state.query, categories: allCategories, locations: allLocations, - statuses: lookups?.statuses ?? const [], + statuses: statuses, onSearch: notifier.setSearch, onCategoryChanged: notifier.setCategoryFilter, onLocationChanged: notifier.setLocationFilter, @@ -166,7 +168,10 @@ class _AssetListScreenState extends ConsumerState { onView: _viewAsset, onEdit: _editAsset, onDelete: _deleteAsset, - onServerSearch: notifier.setSearch, + onEnsureFullDataset: () => + notifier.ensureColumnSearchDataset(), + onColumnSearchCleared: () => + notifier.clearColumnSearchDataset(), ), ), const Divider(height: 1), @@ -200,7 +205,7 @@ class _AssetListScreenState extends ConsumerState { } void _editAsset(AssetModel asset) { - openAssetFormPanel(context, ref, assetId: asset.id); + openAssetForm(context, ref, assetId: asset.id); } Future _exportAssets() async { @@ -374,7 +379,8 @@ class _AssetDataTable extends StatelessWidget { required this.onView, required this.onEdit, required this.onDelete, - this.onServerSearch, + this.onEnsureFullDataset, + this.onColumnSearchCleared, }); final List assets; @@ -383,13 +389,15 @@ class _AssetDataTable extends StatelessWidget { final void Function(AssetModel asset) onView; final void Function(AssetModel asset) onEdit; final Future Function(AssetModel asset) onDelete; - final ValueChanged? onServerSearch; + final Future Function()? onEnsureFullDataset; + final VoidCallback? onColumnSearchCleared; @override Widget build(BuildContext context) { return AppDataTable( wrapInCard: false, - onServerSearchChanged: onServerSearch, + onEnsureFullDataset: onEnsureFullDataset, + onColumnSearchCleared: onColumnSearchCleared, columns: [ AppDataColumn( label: 'Asset Code', @@ -398,7 +406,10 @@ class _AssetDataTable extends StatelessWidget { cellBuilder: (_, asset) { final code = asset.assetCode; if (code == null || code.isEmpty) return const Text('—'); - return _AssetCodeBadge(code: code); + return _AssetCodeBadge( + code: code, + onTap: () => onView(asset), + ); }, ), AppDataColumn( @@ -423,7 +434,7 @@ class _AssetDataTable extends StatelessWidget { label: 'Warranty', flex: 1, searchText: (asset) => - DateFormatter.displayDate(asset.warrantyExpiryDate), + DateFormatter.searchableDate(asset.warrantyExpiryDate), cellBuilder: (_, asset) => Text( DateFormatter.displayDate(asset.warrantyExpiryDate), ), @@ -528,7 +539,10 @@ class _AssetMobileList extends StatelessWidget { ), const SizedBox(height: 4), if (asset.assetCode != null && asset.assetCode!.isNotEmpty) - _AssetCodeBadge(code: asset.assetCode!) + _AssetCodeBadge( + code: asset.assetCode!, + onTap: () => onView(asset), + ) else const Text('—'), Text('${asset.assetCategoryName ?? '—'} · ${asset.locationName ?? '—'}'), @@ -565,26 +579,33 @@ class _AssetMobileList extends StatelessWidget { } class _AssetCodeBadge extends StatelessWidget { - const _AssetCodeBadge({required this.code}); + const _AssetCodeBadge({required this.code, this.onTap}); final String code; + final VoidCallback? onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Container( + final badge = Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(20), ), - child: AppTableCell.text( + child: AppTableCell.link( code, + onTap: onTap, + underlined: false, style: theme.textTheme.labelSmall?.copyWith( fontWeight: FontWeight.w600, - color: theme.colorScheme.onSurfaceVariant, ), ), ); + if (onTap == null) return badge; + return MouseRegion( + cursor: SystemMouseCursors.click, + child: badge, + ); } } diff --git a/lib/modules/assets/presentation/screens/asset_maintenance_screen.dart b/lib/modules/assets/presentation/screens/asset_maintenance_screen.dart index 1acede5..9dc8def 100644 --- a/lib/modules/assets/presentation/screens/asset_maintenance_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_maintenance_screen.dart @@ -20,11 +20,29 @@ import '../../../../shared/widgets/page_header.dart'; import '../providers/assets_provider.dart'; import '../widgets/asset_maintenance_panel.dart'; -class AssetMaintenanceScreen extends ConsumerWidget { +class AssetMaintenanceScreen extends ConsumerStatefulWidget { const AssetMaintenanceScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _AssetMaintenanceScreenState(); +} + +class _AssetMaintenanceScreenState + extends ConsumerState { + bool _requestedFreshLoad = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_requestedFreshLoad) return; + _requestedFreshLoad = true; + // Always refetch My Maintenance when opening this screen. + ref.invalidate(myMaintenanceProvider); + } + + @override + Widget build(BuildContext context) { final maintenanceAsync = ref.watch(myMaintenanceProvider); return Padding( @@ -53,7 +71,10 @@ class _MyMaintenanceBody extends ConsumerWidget { AssetModel asset, ) async { final saved = await openSubmitMaintenancePanel(context, ref, asset: asset); - if (saved == true && context.mounted) { + if (!context.mounted) return; + if (saved == true) { + await ref.read(myMaintenanceProvider.notifier).refresh(); + if (!context.mounted) return; showAppToastFromSnackBar( context, const SnackBar(content: Text('Maintenance log submitted')), @@ -174,7 +195,10 @@ class _MaintenanceTable extends StatelessWidget { label: 'Asset Code', flex: 1, searchText: (asset) => asset.assetCode ?? '', - cellBuilder: (_, asset) => Text(asset.assetCode ?? '—'), + cellBuilder: (_, asset) => AppTableCell.link( + asset.assetCode, + onTap: () => context.push('${RouteConstants.assets}/${asset.id}'), + ), ), AppDataColumn( label: 'Asset Name', diff --git a/lib/modules/assets/presentation/widgets/asset_form_panel.dart b/lib/modules/assets/presentation/widgets/asset_form_panel.dart index e25fb93..11322d2 100644 --- a/lib/modules/assets/presentation/widgets/asset_form_panel.dart +++ b/lib/modules/assets/presentation/widgets/asset_form_panel.dart @@ -1,1597 +1,2 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; - -import '../../../../core/errors/failure.dart'; -import '../../../../core/utils/formatters.dart'; -import '../../../../core/utils/validators.dart'; -import '../../../../shared/models/asset_model.dart'; -import '../../../../shared/models/user_management_models.dart' show FilterOptionModel; -import '../../../../shared/widgets/app_button.dart'; -import '../../../../shared/widgets/app_date_popup.dart'; -import '../../../../shared/widgets/app_dropdown.dart'; -import '../../../../shared/widgets/app_form_toggle_field.dart'; -import '../../../../shared/widgets/app_searchable_dropdown.dart'; -import '../../../../shared/widgets/app_loading_view.dart'; -import '../../../../shared/widgets/app_side_panel.dart'; -import '../../../../shared/widgets/app_text_field.dart'; -import '../../../../shared/widgets/error_view.dart'; -import '../../../masters/data/datasources/master_remote_data_source.dart'; -import '../../../master_data/presentation/widgets/master_quick_add.dart'; -import '../../data/repositories/asset_repository_impl.dart'; -import '../providers/asset_categories_provider.dart'; -import '../providers/asset_form_lookups_provider.dart'; -import '../providers/assets_provider.dart'; -import '../../../../shared/widgets/app_toast.dart'; - -Future openAssetFormPanel( - BuildContext context, - WidgetRef ref, { - String? assetId, -}) async { - ref.invalidate(assetFormProvider(assetId)); - final panelWidth = MediaQuery.sizeOf(context).width * 0.4; - final saved = await showSidePanel( - context, - AssetFormPanel(assetId: assetId), - width: panelWidth, - ); - if (saved == true && context.mounted) { - showAppToastFromSnackBar(context, - SnackBar( - content: Text( - assetId == null - ? 'Asset created successfully' - : 'Asset updated successfully', - ), - ), - ); - } -} - -class AssetFormPanel extends ConsumerStatefulWidget { - const AssetFormPanel({super.key, this.assetId}); - - final String? assetId; - - bool get isEditing => assetId != null; - - @override - ConsumerState createState() => _AssetFormPanelState(); -} - -class _AssetFormPanelState extends ConsumerState { - final _formKey = GlobalKey(); - final _nameController = TextEditingController(); - final _costController = TextEditingController(); - final _serialController = TextEditingController(); - final _partNumberController = TextEditingController(); - final _brandModelController = TextEditingController(); - final _manufacturerController = TextEditingController(); - final _locationDetailController = TextEditingController(); - final _qrCodeController = TextEditingController(); - final _disposalReasonController = TextEditingController(); - final _disposalValueController = TextEditingController(); - final _usefulLifeController = TextEditingController(); - final _depreciationRateController = TextEditingController(); - final _salvageValueController = TextEditingController(); - final _remarksController = TextEditingController(); - final _frequencyController = TextEditingController(); - int? _categoryId; - int? _subcategoryId; - int? _locationId; - int? _departmentId; - int? _assignedToUserId; - int? _maintenanceInchargeUserId; - int? _vendorId; - int? _poId; - int? _grnId; - int? _grnItemId; - String? _status; - String? _condition; - String? _depreciationMethod; - DateTime? _warrantyExpiry; - DateTime? _purchaseDate; - DateTime? _commencementDate; - DateTime? _disposalDate; - List _checklistItems = []; - bool _isActive = true; - bool _isSubmitting = false; - String? _populatedSignature; - Timer? _depreciationPreviewDebounce; - AssetDepreciationPreviewModel? _depreciationPreview; - String? _depreciationPreviewError; - bool _isDepreciationPreviewLoading = false; - int _depreciationPreviewRequestId = 0; - bool _isPopulatingForm = false; - - @override - void initState() { - super.initState(); - _costController.addListener(_onDepreciationFieldChanged); - _salvageValueController.addListener(_onDepreciationFieldChanged); - _usefulLifeController.addListener(_onDepreciationFieldChanged); - _depreciationRateController.addListener(_onDepreciationFieldChanged); - } - - @override - void dispose() { - _depreciationPreviewDebounce?.cancel(); - _nameController.dispose(); - _costController.dispose(); - _serialController.dispose(); - _partNumberController.dispose(); - _brandModelController.dispose(); - _manufacturerController.dispose(); - _locationDetailController.dispose(); - _qrCodeController.dispose(); - _disposalReasonController.dispose(); - _disposalValueController.dispose(); - _usefulLifeController.dispose(); - _depreciationRateController.dispose(); - _salvageValueController.dispose(); - _remarksController.dispose(); - _frequencyController.dispose(); - super.dispose(); - } - - void _onDepreciationFieldChanged() { - _triggerPreviewRecalculation(); - } - - String _assetSignature(AssetModel asset) => - '${asset.id}:${asset.assetCategoryId}:${asset.assetSubcategoryId}:' - '${asset.locationId}:${asset.status}:${asset.assetName}'; - - int? _nullablePositiveId(int? id) { - if (id == null || id <= 0) return null; - return id; - } - - void _putOptionalId(Map payload, String key, int? value) { - final normalized = _nullablePositiveId(value); - if (normalized != null) payload[key] = normalized; - } - - void _putOptionalText( - Map payload, - String key, - String value, - ) { - final trimmed = value.trim(); - if (trimmed.isNotEmpty) payload[key] = trimmed; - } - - void _putOptionalDouble( - Map payload, - String key, - String value, - ) { - final trimmed = value.trim(); - if (trimmed.isEmpty) return; - final parsed = double.tryParse(trimmed); - if (parsed != null) payload[key] = parsed; - } - - int? _dropdownValue(int? selected, Iterable validIds) { - if (selected == null) return null; - return validIds.contains(selected) ? selected : null; - } - - bool _isAssignableUser(int? userId) { - if (userId == null || userId <= 0) return false; - final users = ref.read(assetFormLookupsProvider).valueOrNull?.users ?? const []; - return users.any((user) => int.tryParse(user.id) == userId); - } - - void _populateFromAsset(AssetModel asset) { - _isPopulatingForm = true; - setState(() { - _nameController.text = asset.assetName; - _categoryId = asset.assetCategoryId; - _subcategoryId = asset.assetSubcategoryId; - _locationId = asset.locationId; - _departmentId = _nullablePositiveId(asset.departmentId); - _assignedToUserId = _nullablePositiveId(asset.assignedToUserId); - _maintenanceInchargeUserId = - _nullablePositiveId(asset.maintenanceInchargeUserId); - _vendorId = _nullablePositiveId(asset.vendorId); - _poId = _nullablePositiveId(asset.poId); - _grnId = _nullablePositiveId(asset.grnId); - _grnItemId = _nullablePositiveId(asset.grnItemId); - _status = asset.status; - _condition = asset.condition; - _depreciationMethod = asset.depreciationMethod; - _warrantyExpiry = asset.warrantyExpiryDate; - _purchaseDate = asset.purchaseDate; - _commencementDate = asset.commencementDate; - _disposalDate = asset.disposalDate; - _checklistItems = [ - ...(asset.maintenanceChecklistJson ?? const []), - ]; - _isActive = asset.isActive; - _serialController.text = asset.serialNumber ?? ''; - _partNumberController.text = asset.partNumber ?? ''; - _brandModelController.text = asset.brandModel ?? ''; - _manufacturerController.text = asset.manufacturer ?? ''; - _locationDetailController.text = asset.locationDetail ?? ''; - _qrCodeController.text = asset.qrCodeValue ?? ''; - _disposalReasonController.text = asset.disposalReason ?? ''; - _disposalValueController.text = asset.disposalValue?.toString() ?? ''; - _usefulLifeController.text = asset.usefulLifeYears?.toString() ?? ''; - _depreciationRateController.text = asset.depreciationRate?.toString() ?? ''; - _salvageValueController.text = asset.salvageValue?.toString() ?? ''; - _remarksController.text = asset.remarks ?? ''; - _frequencyController.text = - asset.maintenanceFrequencyInDays?.toString() ?? ''; - if (asset.purchaseCost != null) { - _costController.text = asset.purchaseCost!.toStringAsFixed( - asset.purchaseCost! % 1 == 0 ? 0 : 2, - ); - } else { - _costController.clear(); - } - }); - _isPopulatingForm = false; - _triggerPreviewRecalculation(); - } - - void _triggerPreviewRecalculation() { - if (_isPopulatingForm) return; - _depreciationPreviewDebounce?.cancel(); - _depreciationPreviewDebounce = Timer( - const Duration(milliseconds: 400), - _calculateDepreciationPreview, - ); - } - - Map? _buildDepreciationPreviewPayload() { - final method = _depreciationMethod?.trim().toUpperCase(); - if (method == null || method.isEmpty) { - return null; - } - - final rate = double.tryParse(_depreciationRateController.text.trim()); - if (method == 'OTHER' && rate == null) { - return null; - } - - final payload = { - 'depreciation_method': method, - if (rate != null) 'depreciation_rate': rate, - }; - - final purchaseCost = double.tryParse(_costController.text.trim()); - if (purchaseCost != null) payload['purchase_cost'] = purchaseCost; - - final salvageValue = double.tryParse(_salvageValueController.text.trim()); - if (salvageValue != null) payload['salvage_value'] = salvageValue; - - final usefulLife = int.tryParse(_usefulLifeController.text.trim()); - if (usefulLife != null) payload['useful_life_years'] = usefulLife; - - if (_commencementDate != null) { - payload['commencement_date'] = - DateFormat('yyyy-MM-dd').format(_commencementDate!); - } - if (_purchaseDate != null) { - payload['purchase_date'] = DateFormat('yyyy-MM-dd').format(_purchaseDate!); - } - - return payload; - } - - Future _calculateDepreciationPreview() async { - final payload = _buildDepreciationPreviewPayload(); - - if (payload == null) { - if (!mounted) return; - setState(() { - _isDepreciationPreviewLoading = false; - _depreciationPreview = null; - _depreciationPreviewError = _depreciationMethod?.trim().toUpperCase() == 'OTHER' - ? 'Depreciation rate is required for OTHER' - : null; - }); - return; - } - - final requestId = ++_depreciationPreviewRequestId; - if (mounted) { - setState(() { - _isDepreciationPreviewLoading = true; - _depreciationPreviewError = null; - }); - } - - final result = - await ref.read(assetRepositoryProvider).calculateDepreciationPreview(payload); - if (!mounted || requestId != _depreciationPreviewRequestId) return; - - setState(() { - _isDepreciationPreviewLoading = false; - if (result.failure != null) { - _depreciationPreview = null; - _depreciationPreviewError = result.failure!.message; - } else { - _depreciationPreview = result.data; - _depreciationPreviewError = null; - } - }); - } - - Map _buildPayload() { - final payload = { - 'asset_name': _nameController.text.trim(), - 'item_category_id': _categoryId, - 'item_subcategory_id': _subcategoryId, - 'location_id': _locationId, - 'is_active': _isActive, - }; - - _putOptionalId(payload, 'department_id', _departmentId); - if (_isAssignableUser(_assignedToUserId)) { - _putOptionalId(payload, 'assigned_to_user_id', _assignedToUserId); - } - if (_isAssignableUser(_maintenanceInchargeUserId)) { - _putOptionalId( - payload, - 'maintenance_incharge_user_id', - _maintenanceInchargeUserId, - ); - } - _putOptionalId(payload, 'vendor_id', _vendorId); - _putOptionalId(payload, 'po_id', _poId); - _putOptionalId(payload, 'grn_id', _grnId); - _putOptionalId(payload, 'grn_item_id', _grnItemId); - - _putOptionalText(payload, 'serial_number', _serialController.text); - _putOptionalText(payload, 'part_number', _partNumberController.text); - _putOptionalText(payload, 'brand_model', _brandModelController.text); - _putOptionalText(payload, 'manufacturer', _manufacturerController.text); - _putOptionalText(payload, 'location_detail', _locationDetailController.text); - _putOptionalText(payload, 'qr_code_value', _qrCodeController.text); - _putOptionalText(payload, 'remarks', _remarksController.text); - _putOptionalText(payload, 'disposal_reason', _disposalReasonController.text); - - if (_purchaseDate != null) { - payload['purchase_date'] = DateFormat('yyyy-MM-dd').format(_purchaseDate!); - } - if (_commencementDate != null) { - payload['commencement_date'] = - DateFormat('yyyy-MM-dd').format(_commencementDate!); - } - if (_warrantyExpiry != null) { - payload['warranty_expiry_date'] = - DateFormat('yyyy-MM-dd').format(_warrantyExpiry!); - } - if (_disposalDate != null) { - payload['disposal_date'] = DateFormat('yyyy-MM-dd').format(_disposalDate!); - } - - _putOptionalDouble(payload, 'purchase_cost', _costController.text); - _putOptionalDouble(payload, 'salvage_value', _salvageValueController.text); - _putOptionalDouble(payload, 'disposal_value', _disposalValueController.text); - - final usefulLife = int.tryParse(_usefulLifeController.text.trim()); - if (usefulLife != null) payload['useful_life_years'] = usefulLife; - - final frequency = int.tryParse(_frequencyController.text.trim()); - if (frequency != null) payload['maintenance_frequency_in_days'] = frequency; - - final checklistPayload = _checklistItems - .map((item) => item.toJson()) - .where((item) { - final key = (item['key'] as String?)?.trim() ?? ''; - final label = (item['label'] as String?)?.trim() ?? ''; - return key.isNotEmpty || label.isNotEmpty; - }) - .map((item) { - final key = (item['key'] as String?)?.trim() ?? ''; - final label = (item['label'] as String?)?.trim() ?? ''; - return { - 'key': key.isNotEmpty ? key : label, - 'label': label.isNotEmpty ? label : key, - 'required': item['required'] == true, - }; - }) - .where((item) => (item['key'] as String).isNotEmpty) - .toList(); - if (checklistPayload.isNotEmpty) { - payload['maintenance_checklist_json'] = checklistPayload; - } - - if (_depreciationMethod != null) { - payload['depreciation_method'] = _depreciationMethod; - } - - final depreciationRate = - double.tryParse(_depreciationRateController.text.trim()); - if (depreciationRate != null) payload['depreciation_rate'] = depreciationRate; - - if (_condition != null) payload['condition'] = _condition; - if (_status != null) payload['status'] = _status; - - return payload; - } - - Future _pickDate(void Function(DateTime) onPicked, DateTime? current) async { - final picked = await showAppDatePopup( - context: context, - initialDate: current ?? DateTime.now(), - firstDate: DateTime(2000), - lastDate: DateTime(2100), - helpText: 'Select date', - ); - if (picked != null) { - setState(() => onPicked(picked)); - _triggerPreviewRecalculation(); - } - } - - Future _submit() async { - if (!_formKey.currentState!.validate()) return; - - final businessError = _validateBusinessRules(); - if (businessError != null) { - showSidePanelSnackBar(context, businessError); - return; - } - - setState(() => _isSubmitting = true); - try { - final notifier = ref.read(assetFormProvider(widget.assetId).notifier); - final payload = _buildPayload(); - if (widget.isEditing) { - await notifier.submitUpdate(widget.assetId!, payload); - } else { - await notifier.submitCreate(payload); - } - if (mounted) Navigator.of(context, rootNavigator: true).pop(true); - } catch (e) { - if (mounted) { - showSidePanelSnackBar(context, e.toString()); - } - } finally { - if (mounted) setState(() => _isSubmitting = false); - } - } - - String? _validateBusinessRules() { - if (_purchaseDate != null && - _warrantyExpiry != null && - _warrantyExpiry!.isBefore(_purchaseDate!)) { - return 'Warranty expiry must be on or after purchase date'; - } - - final purchaseCost = double.tryParse(_costController.text.trim()); - final salvageValue = double.tryParse(_salvageValueController.text.trim()); - if (purchaseCost != null && - salvageValue != null && - salvageValue > purchaseCost) { - return 'Salvage value cannot exceed purchase cost'; - } - - final hasDepreciationMethod = - _depreciationMethod != null && _depreciationMethod!.trim().isNotEmpty; - if (hasDepreciationMethod) { - if (_usefulLifeController.text.trim().isEmpty) { - return 'Useful life is required when depreciation method is set'; - } - final method = _depreciationMethod!.trim().toUpperCase(); - if (method == 'OTHER' && _depreciationRateController.text.trim().isEmpty) { - return 'Depreciation rate is required when depreciation method is OTHER'; - } - } - - final isDisposed = _status == 'DISPOSED' || _status == 'SCRAPPED'; - if (isDisposed) { - if (_disposalDate == null) { - return 'Disposal date is required for disposed or scrapped assets'; - } - if (_disposalReasonController.text.trim().isEmpty) { - return 'Disposal reason is required for disposed or scrapped assets'; - } - } - - final hasDisposalInput = _disposalDate != null || - _disposalReasonController.text.trim().isNotEmpty || - _disposalValueController.text.trim().isNotEmpty; - if (hasDisposalInput && !isDisposed) { - return 'Set status to Disposed or Scrapped when entering disposal details'; - } - - if (_grnId != null) { - final grnItems = - ref.read(assetGrnItemsProvider(_grnId)).valueOrNull ?? const []; - if (grnItems.isNotEmpty && _grnItemId == null) { - return 'Please select a GRN item when a GRN is linked'; - } - } - - return null; - } - - @override - Widget build(BuildContext context) { - final categoriesAsync = ref.watch(itemCategoriesFormProvider); - - if (widget.isEditing) { - ref.listen(assetFormProvider(widget.assetId), (prev, next) { - next.whenData((asset) { - if (asset == null || !mounted) return; - final signature = _assetSignature(asset); - if (_populatedSignature != signature) { - _populatedSignature = signature; - _populateFromAsset(asset); - } - }); - }); - } - - final formAsync = - widget.isEditing ? ref.watch(assetFormProvider(widget.assetId)) : null; - - return SidePanelScaffold( - title: widget.isEditing ? 'Edit Asset' : 'Add Asset', - footer: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - OutlinedButton( - onPressed: _isSubmitting - ? null - : () => Navigator.of(context, rootNavigator: true).pop(), - child: const Text('Cancel'), - ), - const SizedBox(width: 12), - AppButton( - label: widget.isEditing ? 'Edit Asset' : 'Add Asset', - expand: false, - icon: Icons.check, - isLoading: _isSubmitting, - onPressed: _isSubmitting ? null : _submit, - ), - ], - ), - child: widget.isEditing && formAsync != null - ? formAsync.when( - loading: () => const AppLoadingView(message: 'Loading asset...'), - error: (e, _) => ErrorView.fromFailure( - e is Failure ? e : Failure.unknown(message: e.toString()), - onRetry: () => ref.invalidate(assetFormProvider(widget.assetId)), - ), - data: (_) => _buildForm(categoriesAsync), - ) - : _buildForm(categoriesAsync), - ); - } - - Widget _buildForm( - AsyncValue> categoriesAsync, - ) { - final subcategoriesAsync = ref.watch(itemSubcategoriesProvider(_categoryId)); - final lookupsAsync = ref.watch(assetFormLookupsProvider); - final grnItemsAsync = ref.watch(assetGrnItemsProvider(_grnId)); - final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel(); - final depreciationMethods = options.depreciationMethods; - final conditions = options.conditions; - final statuses = options.statuses; - - // Resolve create defaults once options load from API. - if (!widget.isEditing && lookupsAsync.hasValue) { - final nextCondition = resolveAssetOptionValue( - _condition, - conditions, - preferFirstWhenEmpty: true, - ); - final nextStatus = resolveAssetOptionValue( - _status, - statuses, - preferFirstWhenEmpty: true, - ); - if (nextCondition != _condition || nextStatus != _status) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - setState(() { - _condition = nextCondition; - _status = nextStatus; - }); - }); - } - } - - return Form( - key: _formKey, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SidePanelSection( - title: 'BASIC DETAILS', - children: [ - AppTextField( - isDense: true, - controller: _nameController, - label: 'Asset Name *', - validator: (v) { - final requiredError = - Validators.required(v, fieldName: 'Asset Name'); - if (requiredError != null) return requiredError; - return Validators.minLength(v!.trim(), 2, - fieldName: 'Asset Name'); - }, - ), - const SizedBox(height: 12), - SidePanelFormRow( - left: categoriesAsync.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('Failed to load categories'), - data: (categories) => _categoryDropdown(categories), - ), - right: subcategoriesAsync.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('Failed to load subcategories'), - data: (subcategories) => _subcategoryDropdown(subcategories), - ), - ), - const SizedBox(height: 12), - SidePanelFormRow( - left: lookupsAsync.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('Failed to load locations'), - data: (lookups) => _locationDropdown(lookups.locations), - ), - right: const SizedBox.shrink(), - ), - ], - ), - SidePanelSection( - title: 'IDENTIFICATION', - children: [ - SidePanelFormRow( - left: AppTextField( - isDense: true, - controller: _serialController, - label: 'Serial Number', - validator: (v) { - if (v == null || v.trim().isEmpty) return null; - return Validators.minLength( - v.trim(), - 2, - fieldName: 'Serial Number', - ); - }, - ), - right: AppTextField( - isDense: true, - controller: _partNumberController, - label: 'Part Number', - validator: (v) { - if (v == null || v.trim().isEmpty) return null; - return Validators.minLength( - v.trim(), - 2, - fieldName: 'Part Number', - ); - }, - ), - ), - const SizedBox(height: 12), - SidePanelFormRow( - left: AppTextField( - isDense: true, - controller: _brandModelController, - label: 'Brand / Model', - validator: (v) { - if (v == null || v.trim().isEmpty) return null; - return Validators.minLength( - v.trim(), - 2, - fieldName: 'Brand / Model', - ); - }, - ), - right: AppTextField( - isDense: true, - controller: _manufacturerController, - label: 'Manufacturer', - validator: (v) { - if (v == null || v.trim().isEmpty) return null; - return Validators.minLength( - v.trim(), - 2, - fieldName: 'Manufacturer', - ); - }, - ), - ), - ], - ), - lookupsAsync.when( - loading: () => const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: LinearProgressIndicator(), - ), - error: (_, __) => const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Text('Failed to load lookup options'), - ), - data: (lookups) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SidePanelSection( - title: 'LOCATION & ASSIGNMENT', - children: [ - SidePanelFormRow( - left: _optionalLookupDropdown( - label: 'Department', - value: _departmentId, - options: lookups.departments, - masterId: 'departments', - onChanged: (v) => setState(() => _departmentId = v), - ), - right: AppTextField( - isDense: true, - controller: _locationDetailController, - label: 'Location Detail', - validator: (v) { - if (v == null || v.trim().isEmpty) return null; - return Validators.minLength( - v.trim(), - 2, - fieldName: 'Location Detail', - ); - }, - ), - ), - const SizedBox(height: 12), - SidePanelFormRow( - left: _optionalLookupDropdown( - label: 'Assigned To', - value: _assignedToUserId, - options: lookups.users, - onChanged: (v) => setState(() => _assignedToUserId = v), - emptyHint: 'Unassigned', - ), - right: const SizedBox.shrink(), - ), - ], - ), - SidePanelSection( - title: 'MAINTENANCE', - children: [ - SidePanelFormRow( - left: _optionalLookupDropdown( - label: 'Maintenance Incharge', - value: _maintenanceInchargeUserId, - options: lookups.users, - onChanged: (v) => - setState(() => _maintenanceInchargeUserId = v), - emptyHint: 'Unassigned', - ), - right: AppTextField( - isDense: true, - controller: _frequencyController, - label: 'Frequency (Days)', - keyboardType: TextInputType.number, - validator: (v) => Validators.optionalPositiveInt( - v, - fieldName: 'Frequency', - ), - ), - ), - const SizedBox(height: 12), - _buildChecklistEditor(), - ], - ), - SidePanelSection( - title: 'PROCUREMENT', - children: [ - SidePanelFormRow( - left: _optionalLookupDropdown( - label: 'Vendor', - value: _vendorId, - options: lookups.vendors, - onChanged: (v) => setState(() => _vendorId = v), - ), - right: _optionalLookupDropdown( - label: 'Purchase Order', - value: _poId, - options: lookups.purchaseOrders, - onChanged: (v) => setState(() => _poId = v), - ), - ), - const SizedBox(height: 12), - SidePanelFormRow( - left: _optionalLookupDropdown( - label: 'GRN', - value: _grnId, - options: lookups.grns, - onChanged: (v) => setState(() { - _grnId = v; - _grnItemId = null; - }), - ), - right: grnItemsAsync.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => _optionalLookupDropdown( - label: 'GRN Item', - value: _grnItemId, - options: const [], - onChanged: (v) => setState(() => _grnItemId = v), - enabled: false, - ), - data: (items) => _optionalLookupDropdown( - label: 'GRN Item', - value: _grnItemId, - options: items, - onChanged: (v) => setState(() => _grnItemId = v), - enabled: _grnId != null && items.isNotEmpty, - emptyHint: _grnId == null - ? 'Select GRN first' - : 'None', - required: _grnId != null && items.isNotEmpty, - ), - ), - ), - ], - ), - ], - ), - ), - SidePanelSection( - title: 'PURCHASE & DEPRECIATION', - children: [ - SidePanelFormRow( - left: _AssetFormDateField( - label: 'Purchase Date', - value: _purchaseDate, - onPick: () => - _pickDate((d) => _purchaseDate = d, _purchaseDate), - ), - right: _AssetFormDateField( - label: 'Commencement Date', - value: _commencementDate, - onPick: () => _pickDate( - (d) => _commencementDate = d, - _commencementDate, - ), - ), - ), - const SizedBox(height: 12), - SidePanelFormRow( - left: _AssetFormDateField( - label: 'Warranty Expiry Date', - value: _warrantyExpiry, - onPick: () => - _pickDate((d) => _warrantyExpiry = d, _warrantyExpiry), - ), - right: const SizedBox.shrink(), - ), - const SizedBox(height: 12), - SidePanelFormRow( - left: AppTextField( - isDense: true, - controller: _costController, - label: 'Purchase Cost', - keyboardType: const TextInputType.numberWithOptions(decimal: true), - validator: (v) => Validators.optionalPositiveDouble( - v, - fieldName: 'Purchase Cost', - ), - ), - right: AppTextField( - isDense: true, - controller: _usefulLifeController, - label: 'Useful Life (Years)', - keyboardType: TextInputType.number, - validator: (v) => Validators.optionalPositiveInt( - v, - fieldName: 'Useful Life', - ), - ), - ), - const SizedBox(height: 12), - SidePanelFormRow( - left: AppDropdown( - isDense: true, - label: 'Depreciation Method', - value: _depreciationMethod, - options: assetOptionDropdowns(depreciationMethods), - enabled: depreciationMethods.isNotEmpty, - hint: depreciationMethods.isEmpty - ? 'Loading depreciation methods...' - : null, - onChanged: (v) => setState(() { - _depreciationMethod = v; - _triggerPreviewRecalculation(); - }), - ), - right: AppTextField( - isDense: true, - controller: _depreciationRateController, - label: 'Depreciation Rate (%)', - keyboardType: const TextInputType.numberWithOptions(decimal: true), - validator: (v) => Validators.optionalPercentage( - v, - fieldName: 'Depreciation Rate', - ), - ), - ), - const SizedBox(height: 12), - SidePanelFormRow( - left: AppTextField( - isDense: true, - controller: _salvageValueController, - label: 'Salvage Value', - keyboardType: const TextInputType.numberWithOptions(decimal: true), - validator: (v) => Validators.optionalNonNegativeDouble( - v, - fieldName: 'Salvage Value', - ), - ), - right: const SizedBox.shrink(), - ), - const SizedBox(height: 12), - _DepreciationPreviewCard( - preview: _depreciationPreview, - isLoading: _isDepreciationPreviewLoading, - error: _depreciationPreviewError, - ), - ], - ), - SidePanelSection( - title: 'STATUS', - children: [ - SidePanelFormRow( - left: AppDropdown( - isDense: true, - label: 'Condition *', - value: _condition, - options: assetOptionDropdowns(conditions), - enabled: conditions.isNotEmpty, - onChanged: (v) => setState(() => _condition = v), - validator: (v) => v == null ? 'Condition is required' : null, - ), - right: AppDropdown( - isDense: true, - label: 'Status *', - value: _status, - options: assetOptionDropdowns(statuses), - enabled: statuses.isNotEmpty, - onChanged: (v) => setState(() => _status = v), - validator: (v) => v == null ? 'Status is required' : null, - ), - ), - const SizedBox(height: 8), - AppFormToggleField( - label: 'Active', - subtitle: 'Inactive assets are hidden from active lists', - value: _isActive, - onChanged: (value) => setState(() => _isActive = value), - ), - ], - ), - SidePanelSection( - title: 'DISPOSAL', - children: [ - SidePanelFormRow( - left: _AssetFormDateField( - label: 'Disposal Date', - value: _disposalDate, - onPick: () => - _pickDate((d) => _disposalDate = d, _disposalDate), - validator: () { - final isDisposed = - _status == 'DISPOSED' || _status == 'SCRAPPED'; - if (isDisposed && _disposalDate == null) { - return 'Disposal date is required'; - } - return null; - }, - ), - right: AppTextField( - isDense: true, - controller: _disposalValueController, - label: 'Disposal Value', - keyboardType: const TextInputType.numberWithOptions(decimal: true), - validator: (v) => Validators.optionalNonNegativeDouble( - v, - fieldName: 'Disposal Value', - ), - ), - ), - const SizedBox(height: 12), - AppTextField( - isDense: true, - controller: _disposalReasonController, - label: 'Disposal Reason', - maxLines: 2, - validator: (v) { - final isDisposed = - _status == 'DISPOSED' || _status == 'SCRAPPED'; - if (isDisposed) { - return Validators.required(v, fieldName: 'Disposal Reason'); - } - if (v == null || v.trim().isEmpty) return null; - return Validators.minLength( - v.trim(), - 3, - fieldName: 'Disposal Reason', - ); - }, - ), - ], - ), - SidePanelSection( - title: 'OTHER', - children: [ - AppTextField( - isDense: true, - controller: _qrCodeController, - label: 'QR Code Value', - validator: (v) { - if (v == null || v.trim().isEmpty) return null; - return Validators.minLength( - v.trim(), - 2, - fieldName: 'QR Code Value', - ); - }, - ), - const SizedBox(height: 12), - AppTextField( - isDense: true, - controller: _remarksController, - label: 'Remarks', - maxLines: 2, - validator: (v) { - if (v == null || v.trim().isEmpty) return null; - return Validators.minLength( - v.trim(), - 2, - fieldName: 'Remarks', - ); - }, - ), - ], - ), - ], - ), - ), - ); - } - - Widget _buildChecklistEditor() { - final theme = Theme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - 'Checklist Template', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ), - TextButton.icon( - onPressed: () { - setState(() { - _checklistItems = [ - ..._checklistItems, - const AssetMaintenanceChecklistItem( - key: '', - label: '', - ), - ]; - }); - }, - icon: const Icon(Icons.add, size: 18), - label: const Text('Add Item'), - style: TextButton.styleFrom( - foregroundColor: theme.colorScheme.primary, - ), - ), - ], - ), - if (_checklistItems.isEmpty) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - 'No checklist items. Add items for maintenance checks.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - for (var i = 0; i < _checklistItems.length; i++) ...[ - if (i > 0) const SizedBox(height: 8), - _ChecklistItemRow( - key: ValueKey('checklist-row-$i'), - item: _checklistItems[i], - onChanged: (updated) { - setState(() { - _checklistItems = [ - for (var j = 0; j < _checklistItems.length; j++) - if (j == i) updated else _checklistItems[j], - ]; - }); - }, - onRemove: () { - setState(() { - _checklistItems = [ - for (var j = 0; j < _checklistItems.length; j++) - if (j != i) _checklistItems[j], - ]; - }); - }, - ), - ], - ], - ); - } - - Widget _optionalLookupDropdown({ - required String label, - required int? value, - required List options, - required ValueChanged onChanged, - String? masterId, - String? emptyHint, - bool enabled = true, - bool required = false, - }) { - final dropdownOptions = >[ - AppDropdownOption(value: null, label: emptyHint ?? 'None'), - ...options - .map((option) { - final id = int.tryParse(option.id); - if (id == null || id <= 0) return null; - return AppDropdownOption(value: id, label: option.name); - }) - .whereType>(), - ]; - final validIds = dropdownOptions - .map((option) => option.value) - .whereType() - .toList(); - final resolvedValue = - value != null && validIds.contains(value) ? value : null; - final fieldEnabled = - enabled && (options.isNotEmpty || masterId != null); - - if (masterId == null) { - return AppSearchableDropdown( - label: required ? '$label *' : label, - value: resolvedValue, - hint: emptyHint ?? 'None', - searchHint: 'Search ${label.toLowerCase()}...', - isDense: true, - enabled: fieldEnabled, - options: dropdownOptions, - onChanged: onChanged, - validator: - required ? (v) => v == null ? '$label is required' : null : null, - ); - } - - return MasterQuickAddDropdown( - masterId: masterId, - label: required ? '$label *' : label, - value: resolvedValue, - hint: emptyHint ?? 'None', - searchHint: 'Search ${label.toLowerCase()}...', - isDense: true, - enabled: fieldEnabled, - options: dropdownOptions, - refreshLookups: () { - ref.invalidate(assetFormLookupsProvider); - }, - parseCreatedId: int.tryParse, - onChanged: onChanged, - validator: - required ? (v) => v == null ? '$label is required' : null : null, - ); - } - - Widget _subcategoryDropdown(List subcategories) { - final ids = subcategories.map((c) => int.tryParse(c.id)).whereType().toList(); - final hasCategory = _categoryId != null; - final options = subcategories - .map( - (c) => AppDropdownOption( - value: int.tryParse(c.id) ?? 0, - label: c.name, - ), - ) - .where((option) => option.value != 0) - .toList(); - return MasterQuickAddDropdown( - masterId: 'item_subcategories', - label: 'Subcategory *', - value: _dropdownValue(_subcategoryId, ids), - hint: !hasCategory - ? 'Select category first' - : options.isEmpty - ? 'No subcategories found' - : 'Select subcategory *', - searchHint: 'Search subcategory...', - isDense: true, - enabled: hasCategory, - options: options, - initialValues: {'item_category_id': _categoryId}, - refreshLookups: () { - ref.invalidate(itemSubcategoriesProvider(_categoryId)); - }, - parseCreatedId: int.tryParse, - onChanged: (v) => setState(() => _subcategoryId = v), - validator: (v) => v == null ? 'Subcategory is required' : null, - ); - } - - Widget _categoryDropdown(List categories) { - final activeCategories = - categories.where((category) => category.isActive).toList(); - final categoryIds = activeCategories - .map((c) => int.tryParse(c.id)) - .whereType() - .toList(); - return MasterQuickAddDropdown( - masterId: 'item_categories', - label: 'Category *', - value: _dropdownValue(_categoryId, categoryIds), - searchHint: 'Search category...', - isDense: true, - initialValues: const {'category_type': 'ASSET'}, - options: activeCategories - .map( - (c) => AppDropdownOption( - value: int.tryParse(c.id) ?? 0, - label: '${c.code} — ${c.name}', - ), - ) - .where((option) => option.value != 0) - .toList(), - refreshLookups: () { - ref.invalidate(itemCategoriesFormProvider); - }, - parseCreatedId: int.tryParse, - onChanged: (v) => setState(() { - _categoryId = v; - _subcategoryId = null; - final selectedCategory = - activeCategories.where((c) => int.tryParse(c.id) == v).firstOrNull; - if (selectedCategory != null) { - if (selectedCategory.defaultDepreciationMethod != null && - selectedCategory.defaultDepreciationMethod!.trim().isNotEmpty) { - _depreciationMethod = - selectedCategory.defaultDepreciationMethod!.trim().toUpperCase(); - } - if (selectedCategory.defaultUsefulLifeYears != null && - selectedCategory.defaultUsefulLifeYears! > 0) { - _usefulLifeController.text = - selectedCategory.defaultUsefulLifeYears!.toString(); - } - } - _triggerPreviewRecalculation(); - }), - validator: (v) => v == null ? 'Category is required' : null, - ); - } - - Widget _locationDropdown(List locations) { - final locationIds = locations - .map((location) => int.tryParse(location.id)) - .whereType() - .toList(); - return AppSearchableDropdown( - label: 'Location *', - value: _dropdownValue(_locationId, locationIds), - searchHint: 'Search plant or warehouse...', - isDense: true, - options: locations - .map( - (location) => AppDropdownOption( - value: int.tryParse(location.id) ?? 0, - label: location.name, - ), - ) - .where((option) => option.value != 0) - .toList(), - onChanged: (v) => setState(() => _locationId = v), - validator: (v) => v == null ? 'Location is required' : null, - ); - } -} - -class _ChecklistItemRow extends StatefulWidget { - const _ChecklistItemRow({ - super.key, - required this.item, - required this.onChanged, - required this.onRemove, - }); - - final AssetMaintenanceChecklistItem item; - final ValueChanged onChanged; - final VoidCallback onRemove; - - @override - State<_ChecklistItemRow> createState() => _ChecklistItemRowState(); -} - -class _ChecklistItemRowState extends State<_ChecklistItemRow> { - late final TextEditingController _keyController; - late final TextEditingController _labelController; - - @override - void initState() { - super.initState(); - _keyController = TextEditingController(text: widget.item.key); - _labelController = TextEditingController(text: widget.item.label); - } - - @override - void didUpdateWidget(covariant _ChecklistItemRow oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.item.key != widget.item.key && - _keyController.text != widget.item.key) { - _keyController.text = widget.item.key; - } - if (oldWidget.item.label != widget.item.label && - _labelController.text != widget.item.label) { - _labelController.text = widget.item.label; - } - } - - @override - void dispose() { - _keyController.dispose(); - _labelController.dispose(); - super.dispose(); - } - - void _emit({String? key, String? label, bool? required}) { - widget.onChanged( - AssetMaintenanceChecklistItem( - key: key ?? _keyController.text, - label: label ?? _labelController.text, - required: required ?? widget.item.required, - ), - ); - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SidePanelFormRow( - left: AppTextField( - isDense: true, - controller: _keyController, - label: 'Key', - onChanged: (value) => _emit(key: value), - ), - right: AppTextField( - isDense: true, - controller: _labelController, - label: 'Label', - onChanged: (value) => _emit(label: value), - ), - ), - const SizedBox(height: 4), - Row( - children: [ - Expanded( - child: AppFormToggleField( - label: 'Required', - value: widget.item.required, - onChanged: (value) => _emit(required: value), - ), - ), - IconButton( - tooltip: 'Remove item', - onPressed: widget.onRemove, - icon: const Icon(Icons.delete_outline, size: 20), - ), - ], - ), - ], - ); - } -} - -class _AssetFormDateField extends StatefulWidget { - const _AssetFormDateField({ - required this.label, - required this.value, - required this.onPick, - this.validator, - }); - - final String label; - final DateTime? value; - final VoidCallback onPick; - final String? Function()? validator; - - @override - State<_AssetFormDateField> createState() => _AssetFormDateFieldState(); -} - -class _DepreciationPreviewCard extends StatelessWidget { - const _DepreciationPreviewCard({ - required this.preview, - required this.isLoading, - required this.error, - }); - - final AssetDepreciationPreviewModel? preview; - final bool isLoading; - final String? error; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - if (isLoading) { - return const LinearProgressIndicator(); - } - - if (error != null && error!.trim().isNotEmpty) { - return Text( - error!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ); - } - - if (preview == null) { - return Text( - 'Enter depreciation fields to preview calculated values.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ); - } - - String money(double value) => NumberFormat.currency( - locale: 'en_IN', - symbol: '₹', - decimalDigits: 2, - ).format(value); - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: Wrap( - spacing: 16, - runSpacing: 8, - children: [ - _PreviewItem( - label: 'Resolved Rate', - value: '${preview!.depreciationRate.toStringAsFixed(2)}%', - ), - _PreviewItem( - label: 'Annual Depreciation', - value: money(preview!.annualDepreciation), - ), - _PreviewItem( - label: 'Accumulated Depreciation', - value: money(preview!.accumulatedDepreciation), - ), - _PreviewItem( - label: 'Current Book Value', - value: money(preview!.bookValue), - ), - _PreviewItem( - label: 'Years Elapsed', - value: preview!.yearsElapsed.toString(), - ), - ], - ), - ); - } -} - -class _PreviewItem extends StatelessWidget { - const _PreviewItem({ - required this.label, - required this.value, - }); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return SizedBox( - width: 220, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - Text( - value, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); - } -} - -class _AssetFormDateFieldState extends State<_AssetFormDateField> { - late final TextEditingController _controller; - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: _displayText); - } - - @override - void didUpdateWidget(covariant _AssetFormDateField oldWidget) { - super.didUpdateWidget(oldWidget); - final text = _displayText; - if (_controller.text != text) { - _controller.text = text; - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - String get _displayText => - widget.value != null ? DateFormatter.displayDate(widget.value) : ''; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(top: 8), - child: TextFormField( - readOnly: true, - onTap: widget.onPick, - controller: _controller, - validator: (_) => widget.validator?.call(), - decoration: InputDecoration( - labelText: widget.label, - hintText: 'Select date', - floatingLabelBehavior: FloatingLabelBehavior.always, - isDense: true, - suffixIcon: const Icon(Icons.calendar_today_outlined, size: 20), - ), - ), - ); - } -} - -final itemSubcategoriesProvider = - FutureProvider.family, int?>((ref, categoryId) async { - if (categoryId == null) return []; - final dataSource = ref.watch(masterRemoteDataSourceProvider); - return dataSource.listItemSubcategories(itemCategoryId: categoryId); -}); +export '../screens/asset_form_screen.dart' + show AssetFormScreen, openAssetForm, openAssetFormPanel; diff --git a/lib/modules/assets/presentation/widgets/asset_maintenance_panel.dart b/lib/modules/assets/presentation/widgets/asset_maintenance_panel.dart index fc7a6a7..fbafc6a 100644 --- a/lib/modules/assets/presentation/widgets/asset_maintenance_panel.dart +++ b/lib/modules/assets/presentation/widgets/asset_maintenance_panel.dart @@ -45,13 +45,14 @@ class SubmitMaintenanceLogPanel extends ConsumerStatefulWidget { class _ChecklistRowState { _ChecklistRowState({ - required this.keyName, required this.label, - }); + required this.required, + }) : status = required ? null : 'OK'; - final String keyName; final String label; - String status = 'OK'; + final bool required; + /// Null until the user picks a status (required items must choose explicitly). + String? status; final TextEditingController remarksController = TextEditingController(); void dispose() => remarksController.dispose(); @@ -74,10 +75,11 @@ class _SubmitMaintenanceLogPanelState super.initState(); final checklist = checklistForAsset(widget.asset); _rows = checklist + .where((item) => item.label.trim().isNotEmpty) .map( (item) => _ChecklistRowState( - keyName: item.key.isNotEmpty ? item.key : item.label, - label: item.label.isNotEmpty ? item.label : item.key, + label: item.label.trim(), + required: item.required, ), ) .toList(); @@ -133,17 +135,34 @@ class _SubmitMaintenanceLogPanelState return; } + final missingRequired = _rows.where((row) { + if (!row.required) return false; + final statusMissing = row.status == null || row.status!.trim().isEmpty; + final remarksMissing = row.remarksController.text.trim().isEmpty; + return statusMissing || remarksMissing; + }).toList(); + if (missingRequired.isNotEmpty) { + showSidePanelSnackBar( + context, + 'Complete required checklist items: ${missingRequired.map((r) => r.label).join(', ')}', + ); + return; + } + setState(() => _isSubmitting = true); try { final payload = { 'performed_date': DateFormatter.toApiDate(_performedDate), 'checklist_json': _rows .map( - (row) => { - 'key': row.keyName, - 'status': row.status, - if (row.remarksController.text.trim().isNotEmpty) - 'remarks': row.remarksController.text.trim(), + (row) { + final remarks = row.remarksController.text.trim(); + return { + 'label': row.label, + 'status': row.status, + 'remarks': remarks.isEmpty ? null : remarks, + 'required': row.required, + }; }, ) .toList(), @@ -376,27 +395,37 @@ class _ChecklistItemCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - row.label, + row.required ? '${row.label} *' : row.label, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 10), AppDropdown( - label: 'Status', + label: row.required ? 'Status *' : 'Status', isDense: true, value: row.status, + hint: 'Select status', options: const [ AppDropdownOption(value: 'OK', label: 'OK'), AppDropdownOption(value: 'NOT_OK', label: 'Not OK'), AppDropdownOption(value: 'NA', label: 'N/A'), ], onChanged: onStatusChanged, + validator: row.required + ? (v) => + (v == null || v.trim().isEmpty) ? 'Status is required' : null + : null, ), const SizedBox(height: 10), AppTextField( controller: row.remarksController, - label: 'Item remarks', + label: row.required ? 'Item remarks *' : 'Item remarks', + validator: row.required + ? (v) => (v == null || v.trim().isEmpty) + ? 'Remarks are required for this checklist item' + : null + : null, ), ], ), @@ -454,12 +483,19 @@ class _MaintenanceLogTile extends StatelessWidget { spacing: 6, runSpacing: 4, children: log.checklistJson.map((item) { - final key = item['key']?.toString() ?? 'Item'; - final status = item['status']?.toString() ?? '—'; + final label = item['label']?.toString().trim(); + final rawStatus = item['status']?.toString().trim() ?? '—'; + final status = rawStatus.replaceAll('_', ' '); + final remarks = item['remarks']?.toString().trim(); + final title = + (label != null && label.isNotEmpty) ? label : 'Item'; + final chipText = (remarks != null && remarks.isNotEmpty) + ? '$title: $status — $remarks' + : '$title: $status'; return Chip( visualDensity: VisualDensity.compact, label: Text( - '$key: $status', + chipText, style: theme.textTheme.labelSmall, ), padding: EdgeInsets.zero, diff --git a/lib/modules/assets/presentation/widgets/asset_side_panels.dart b/lib/modules/assets/presentation/widgets/asset_side_panels.dart index ede560e..1aaa45b 100644 --- a/lib/modules/assets/presentation/widgets/asset_side_panels.dart +++ b/lib/modules/assets/presentation/widgets/asset_side_panels.dart @@ -135,12 +135,20 @@ class _AddAmcPanelState extends ConsumerState { Future _pickDate({ required DateTime? current, required void Function(DateTime date) onPicked, + DateTime? firstDate, + DateTime? lastDate, }) async { + final first = firstDate ?? DateTime(2000); + final last = lastDate ?? DateTime(2100); + var initial = current ?? DateTime.now(); + if (initial.isBefore(first)) initial = first; + if (initial.isAfter(last)) initial = last; + final picked = await showAppDatePopup( context: context, - initialDate: current ?? DateTime.now(), - firstDate: DateTime(2000), - lastDate: DateTime(2100), + initialDate: initial, + firstDate: first, + lastDate: last, helpText: 'Select date', ); if (picked != null) { @@ -148,12 +156,38 @@ class _AddAmcPanelState extends ConsumerState { } } + 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 _save() async { if (!_formKey.currentState!.validate()) return; if (_startDate == null || _endDate == null) { showSidePanelSnackBar(context, 'Please select start and end dates'); return; } + if (DateTime(_endDate!.year, _endDate!.month, _endDate!.day).isBefore( + DateTime(_startDate!.year, _startDate!.month, _startDate!.day), + )) { + showSidePanelSnackBar( + context, + 'End date cannot be earlier than start date', + ); + return; + } if (_vendorId == null) { showSidePanelSnackBar(context, 'Please select a vendor'); @@ -262,7 +296,7 @@ class _AddAmcPanelState extends ConsumerState { value: _startDate, onPick: () => _pickDate( current: _startDate, - onPicked: (date) => setState(() => _startDate = date), + onPicked: _onStartDatePicked, ), ), right: _SidePanelDateField( @@ -271,7 +305,8 @@ class _AddAmcPanelState extends ConsumerState { value: _endDate, onPick: () => _pickDate( current: _endDate, - onPicked: (date) => setState(() => _endDate = date), + firstDate: _startDate ?? DateTime(2000), + onPicked: (date) => _endDate = date, ), ), ), @@ -282,7 +317,7 @@ class _AddAmcPanelState extends ConsumerState { value: _renewalDate, onPick: () => _pickDate( current: _renewalDate, - onPicked: (date) => setState(() => _renewalDate = date), + onPicked: (date) => _renewalDate = date, ), ), right: AppTextField( @@ -928,12 +963,20 @@ class _AddInsurancePanelState extends ConsumerState { Future _pickDate({ required DateTime? current, required void Function(DateTime date) onPicked, + DateTime? firstDate, + DateTime? lastDate, }) async { + final first = firstDate ?? DateTime(2000); + final last = lastDate ?? DateTime(2100); + var initial = current ?? DateTime.now(); + if (initial.isBefore(first)) initial = first; + if (initial.isAfter(last)) initial = last; + final picked = await showAppDatePopup( context: context, - initialDate: current ?? DateTime.now(), - firstDate: DateTime(2000), - lastDate: DateTime(2100), + initialDate: initial, + firstDate: first, + lastDate: last, helpText: 'Select date', ); if (picked != null) { @@ -941,6 +984,23 @@ class _AddInsurancePanelState extends ConsumerState { } } + 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 _buildPayload() { final sumInsured = double.tryParse(_sumInsuredController.text.trim()); final annualPremium = double.tryParse(_annualPremiumController.text.trim()); @@ -977,6 +1037,15 @@ class _AddInsurancePanelState extends ConsumerState { showSidePanelSnackBar(context, 'Please select start and end dates'); return; } + if (DateTime(_endDate!.year, _endDate!.month, _endDate!.day).isBefore( + DateTime(_startDate!.year, _startDate!.month, _startDate!.day), + )) { + showSidePanelSnackBar( + context, + 'End date cannot be earlier than start date', + ); + return; + } setState(() => _isSubmitting = true); try { @@ -1096,7 +1165,7 @@ class _AddInsurancePanelState extends ConsumerState { value: _startDate, onPick: () => _pickDate( current: _startDate, - onPicked: (date) => setState(() => _startDate = date), + onPicked: _onStartDatePicked, ), ), right: _SidePanelDateField( @@ -1105,7 +1174,8 @@ class _AddInsurancePanelState extends ConsumerState { value: _endDate, onPick: () => _pickDate( current: _endDate, - onPicked: (date) => setState(() => _endDate = date), + firstDate: _startDate ?? DateTime(2000), + onPicked: (date) => _endDate = date, ), ), ), @@ -1116,7 +1186,7 @@ class _AddInsurancePanelState extends ConsumerState { value: _renewalDate, onPick: () => _pickDate( current: _renewalDate, - onPicked: (date) => setState(() => _renewalDate = date), + onPicked: (date) => _renewalDate = date, ), ), right: _SidePanelDateField( @@ -1124,7 +1194,7 @@ class _AddInsurancePanelState extends ConsumerState { value: _premiumPaidDate, onPick: () => _pickDate( current: _premiumPaidDate, - onPicked: (date) => setState(() => _premiumPaidDate = date), + onPicked: (date) => _premiumPaidDate = date, ), ), ), diff --git a/lib/modules/audit/presentation/providers/audit_provider.dart b/lib/modules/audit/presentation/providers/audit_provider.dart index db6e389..0ae3b1d 100644 --- a/lib/modules/audit/presentation/providers/audit_provider.dart +++ b/lib/modules/audit/presentation/providers/audit_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/utils/column_search_paging.dart'; import '../../../../core/utils/table_search.dart'; import '../../../../shared/models/audit_log_model.dart'; @@ -82,6 +83,8 @@ final auditLogsListProvider = ); class AuditLogsListNotifier extends AsyncNotifier { + final _columnSearch = ColumnSearchPaging(); + @override Future build() async { ref.keepAlive(); @@ -148,6 +151,27 @@ class AuditLogsListNotifier extends AsyncNotifier { applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); } + Future ensureColumnSearchDataset() async { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.beginFullDataset( + currentLimit: current.query.limit, + total: current.total, + ); + if (limit == null) return; + await applyQuery( + current.query.copyWith(search: null, page: 1, limit: limit), + ); + } + + void clearColumnSearchDataset() { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.endFullDataset(); + if (limit == null) return; + applyQuery(current.query.copyWith(search: null, page: 1, limit: limit)); + } + void setPage(int page) { final current = state.valueOrNull; if (current == null) return; diff --git a/lib/modules/audit/presentation/screens/audit_logs_screen.dart b/lib/modules/audit/presentation/screens/audit_logs_screen.dart index 4de6f16..514e217 100644 --- a/lib/modules/audit/presentation/screens/audit_logs_screen.dart +++ b/lib/modules/audit/presentation/screens/audit_logs_screen.dart @@ -230,15 +230,10 @@ class _AuditLogsScreenState extends ConsumerState { : _AuditDataTable( items: state.items, onView: _viewLog, - onServerSearch: (value) { - _searchController.value = TextEditingValue( - text: value, - selection: TextSelection.collapsed( - offset: value.length, - ), - ); - notifier.setSearch(value); - }, + onEnsureFullDataset: () => + notifier.ensureColumnSearchDataset(), + onColumnSearchCleared: () => + notifier.clearColumnSearchDataset(), ), ), ), @@ -379,12 +374,14 @@ class _AuditDataTable extends StatelessWidget { const _AuditDataTable({ required this.items, required this.onView, - this.onServerSearch, + this.onEnsureFullDataset, + this.onColumnSearchCleared, }); final List items; final void Function(AuditLogEntryModel log) onView; - final ValueChanged? onServerSearch; + final Future Function()? onEnsureFullDataset; + final VoidCallback? onColumnSearchCleared; @override Widget build(BuildContext context) { @@ -392,12 +389,13 @@ class _AuditDataTable extends StatelessWidget { wrapInCard: false, rows: items, emptyMessage: 'No audit logs found', - onServerSearchChanged: onServerSearch, + onEnsureFullDataset: onEnsureFullDataset, + onColumnSearchCleared: onColumnSearchCleared, columns: [ AppDataColumn( label: 'When', flex: 2, - searchText: (row) => DateFormatter.displayDateTime(row.performedAt), + searchText: (row) => DateFormatter.searchableDate(row.performedAt), cellBuilder: (_, row) => AppTableCell.text( DateFormatter.displayDateTime(row.performedAt), ), diff --git a/lib/modules/grn/presentation/providers/grn_provider.dart b/lib/modules/grn/presentation/providers/grn_provider.dart index 9c164ce..5a017ee 100644 --- a/lib/modules/grn/presentation/providers/grn_provider.dart +++ b/lib/modules/grn/presentation/providers/grn_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/utils/column_search_paging.dart'; import '../../../../core/utils/table_search.dart'; import '../../../../shared/models/export_file_result.dart'; @@ -61,6 +62,8 @@ final grnListProvider = ); class GrnListNotifier extends AutoDisposeAsyncNotifier { + final _columnSearch = ColumnSearchPaging(); + @override Future build() async { return _load(const GrnListQuery(limit: 20)); @@ -172,6 +175,27 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier { applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); } + Future ensureColumnSearchDataset() async { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.beginFullDataset( + currentLimit: current.query.limit, + total: current.total, + ); + if (limit == null) return; + await applyQuery( + current.query.copyWith(search: null, page: 1, limit: limit), + ); + } + + void clearColumnSearchDataset() { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.endFullDataset(); + if (limit == null) return; + applyQuery(current.query.copyWith(search: null, page: 1, limit: limit)); + } + void setStatusFilter(String? status) { final current = state.valueOrNull; if (current == null) return; diff --git a/lib/modules/grn/presentation/screens/grn_detail_screen.dart b/lib/modules/grn/presentation/screens/grn_detail_screen.dart index 6ac1a22..eca753e 100644 --- a/lib/modules/grn/presentation/screens/grn_detail_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_detail_screen.dart @@ -32,6 +32,24 @@ class GrnDetailScreen extends ConsumerStatefulWidget { class _GrnDetailScreenState extends ConsumerState { bool _isWorking = false; bool _isDownloadingPdf = false; + bool _requestedFreshLoad = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_requestedFreshLoad) return; + _requestedFreshLoad = true; + // Always hit GET /grn/{id} when opening view. + ref.invalidate(grnDetailProvider(widget.grnId)); + } + + @override + void didUpdateWidget(covariant GrnDetailScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.grnId != widget.grnId) { + ref.invalidate(grnDetailProvider(widget.grnId)); + } + } @override Widget build(BuildContext context) { @@ -52,13 +70,10 @@ class _GrnDetailScreenState extends ConsumerState { data: (grn) { final lookups = lookupsAsync.asData?.value; return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 1200), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ _DetailHeader( grn: grn, isWorking: _isWorking, @@ -100,8 +115,6 @@ class _GrnDetailScreenState extends ConsumerState { const SizedBox(height: 20), _DetailFooter(grn: grn), ], - ), - ), ), ); }, diff --git a/lib/modules/grn/presentation/screens/grn_form_screen.dart b/lib/modules/grn/presentation/screens/grn_form_screen.dart index f14d680..a795fc7 100644 --- a/lib/modules/grn/presentation/screens/grn_form_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_form_screen.dart @@ -56,18 +56,29 @@ class _GrnFormScreenState extends ConsumerState { final List _lines = []; bool _isSubmitting = false; String? _populatedSignature; + bool _requestedFreshLoad = false; @override void initState() { super.initState(); if (!widget.isEditing) { _grnDate = DateTime.now(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) ref.invalidate(grnLookupsProvider); - }); } } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_requestedFreshLoad) return; + _requestedFreshLoad = true; + if (!widget.isEditing) { + ref.invalidate(grnLookupsProvider); + return; + } + // Always hit GET /grn/{id} when opening edit. + ref.invalidate(grnFormProvider(widget.grnId)); + } + @override void dispose() { _scrollController.dispose(); diff --git a/lib/modules/grn/presentation/screens/grn_list_screen.dart b/lib/modules/grn/presentation/screens/grn_list_screen.dart index 4a68f6e..b3c5a10 100644 --- a/lib/modules/grn/presentation/screens/grn_list_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_list_screen.dart @@ -158,15 +158,12 @@ class _GrnListScreenState extends ConsumerState { grns: state.grns, onView: _viewGrn, onEdit: canEdit ? _editGrn : null, - onServerSearch: (value) { - _searchController.value = TextEditingValue( - text: value, - selection: TextSelection.collapsed( - offset: value.length, - ), - ); - ref.read(grnListProvider.notifier).setSearch(value); - }, + onEnsureFullDataset: () => ref + .read(grnListProvider.notifier) + .ensureColumnSearchDataset(), + onColumnSearchCleared: () => ref + .read(grnListProvider.notifier) + .clearColumnSearchDataset(), ), ), ), @@ -297,37 +294,50 @@ class _GrnDataTable extends StatelessWidget { required this.grns, required this.onView, this.onEdit, - this.onServerSearch, + this.onEnsureFullDataset, + this.onColumnSearchCleared, }); final List grns; final ValueChanged onView; final ValueChanged? onEdit; - final ValueChanged? onServerSearch; + final Future Function()? onEnsureFullDataset; + final VoidCallback? onColumnSearchCleared; @override Widget build(BuildContext context) { return AppDataTable( wrapInCard: false, - onServerSearchChanged: onServerSearch, + onEnsureFullDataset: onEnsureFullDataset, + onColumnSearchCleared: onColumnSearchCleared, columns: [ AppDataColumn( label: 'GRN Number', flex: 2, searchText: (grn) => grn.grnNumber ?? '', - cellBuilder: (_, grn) => Text(grn.grnNumber ?? '—'), + cellBuilder: (_, grn) => AppTableCell.link( + grn.grnNumber, + onTap: () => onView(grn), + ), ), AppDataColumn( label: 'Date', flex: 1, - searchText: (grn) => DateFormatter.displayDate(grn.grnDate), + searchText: (grn) => DateFormatter.searchableDate(grn.grnDate), cellBuilder: (_, grn) => Text(DateFormatter.displayDate(grn.grnDate)), ), AppDataColumn( label: 'PO Number', flex: 2, searchText: (grn) => grn.poNumber ?? '', - cellBuilder: (_, grn) => Text(grn.poNumber ?? '—'), + cellBuilder: (_, grn) => AppTableCell.link( + grn.poNumber, + onTap: grn.poId == null + ? null + : () => context.push( + '${RouteConstants.purchaseOrders}/${grn.poId}', + ), + ), ), AppDataColumn( label: 'Vendor', @@ -404,18 +414,26 @@ class _GrnCardList extends StatelessWidget { Row( children: [ Expanded( - child: Text( + child: AppTableCell.link( grn.grnNumber ?? 'GRN #${grn.id}', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + onTap: () => onView(grn), ), ), GrnStatusChip(status: grn.status, compact: true), ], ), const SizedBox(height: 8), - Text('PO: ${grn.poNumber ?? '—'}'), + if (grn.poNumber != null && grn.poNumber!.trim().isNotEmpty) + AppTableCell.link( + 'PO: ${grn.poNumber}', + onTap: grn.poId == null + ? null + : () => context.push( + '${RouteConstants.purchaseOrders}/${grn.poId}', + ), + ) + else + const Text('PO: —'), Text('Vendor: ${grn.vendorName ?? '—'}'), Text('Location: ${grn.locationName ?? '—'}'), Text('Date: ${DateFormatter.displayDate(grn.grnDate)}'), diff --git a/lib/modules/master_data/domain/entities/master_definition.dart b/lib/modules/master_data/domain/entities/master_definition.dart index 75b4d94..52e9726 100644 --- a/lib/modules/master_data/domain/entities/master_definition.dart +++ b/lib/modules/master_data/domain/entities/master_definition.dart @@ -226,13 +226,14 @@ const masterDefinitions = [ showInList: true, showInForm: false, ), + MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true), MasterFieldDef( key: 'is_asset_item', label: 'Asset Item', type: MasterFieldType.boolean, required: true, + showInList: true, ), - MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true), MasterFieldDef( key: 'item_category_id', label: 'Category', @@ -591,6 +592,10 @@ String masterCellValue(Map row, MasterFieldDef field) { final value = row[field.key]; if (value == null || value == '') return '—'; + if (field.key == 'is_asset_item') { + return masterIsAssetItem(value) ? 'Asset' : 'Stock'; + } + if (field.key == 'tags') { if (value is List) { final tags = value @@ -670,6 +675,13 @@ String masterStatusValue(Map row) { return 'active'; } +bool masterIsAssetItem(Object? value) { + if (value == true || value == 1) return true; + if (value == false || value == 0) return false; + final text = value?.toString().trim().toLowerCase() ?? ''; + return text == 'true' || text == '1' || text == 'yes'; +} + /// Category list filter for Items form: ASSET when Asset Item is checked. String itemCategoryTypeForValues(Map values) => values['is_asset_item'] == true ? 'ASSET' : 'STOCK'; diff --git a/lib/modules/master_data/presentation/providers/master_provider.dart b/lib/modules/master_data/presentation/providers/master_provider.dart index 8271ca4..993f0f4 100644 --- a/lib/modules/master_data/presentation/providers/master_provider.dart +++ b/lib/modules/master_data/presentation/providers/master_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/app_constants.dart'; +import '../../../../core/utils/column_search_paging.dart'; import '../../../../core/utils/pagination_meta.dart'; import '../../../../core/utils/table_search.dart'; import '../../../../shared/models/export_file_result.dart'; @@ -95,6 +96,10 @@ final masterListProvider = AsyncNotifierProvider.family< MasterListNotifier, MasterListState, String>(MasterListNotifier.new); class MasterListNotifier extends FamilyAsyncNotifier { + final _columnSearch = ColumnSearchPaging( + defaultLimit: AppConstants.defaultPageSize, + ); + MasterDefinition get _definition { final def = masterDefinitionById(arg); if (def == null) throw StateError('Unknown master: $arg'); @@ -184,6 +189,25 @@ class MasterListNotifier extends FamilyAsyncNotifier { await _reload(page: 1, search: search); } + Future ensureColumnSearchDataset() async { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.beginFullDataset( + currentLimit: current.limit, + total: current.total, + ); + if (limit == null) return; + await _reload(page: 1, limit: limit, search: ''); + } + + void clearColumnSearchDataset() { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.endFullDataset(); + if (limit == null) return; + _reload(page: 1, limit: limit, search: ''); + } + /// Clears generic search and reloads the full list. Future clearSearch() async { final current = state.valueOrNull; diff --git a/lib/modules/master_data/presentation/screens/master_list_screen.dart b/lib/modules/master_data/presentation/screens/master_list_screen.dart index be3e67e..27807f9 100644 --- a/lib/modules/master_data/presentation/screens/master_list_screen.dart +++ b/lib/modules/master_data/presentation/screens/master_list_screen.dart @@ -264,15 +264,10 @@ class _MasterListScreenState extends ConsumerState { canDelete: canDelete, onEdit: (id) => _openFormPanel(recordId: id), onDelete: _deleteRecord, - onServerSearch: (value) { - _searchController.value = TextEditingValue( - text: value, - selection: TextSelection.collapsed( - offset: value.length, - ), - ); - notifier.setSearch(value); - }, + onEnsureFullDataset: () => + notifier.ensureColumnSearchDataset(), + onColumnSearchCleared: () => + notifier.clearColumnSearchDataset(), ), ), ), @@ -294,7 +289,8 @@ class _MasterListTable extends StatelessWidget { required this.canDelete, required this.onEdit, required this.onDelete, - this.onServerSearch, + this.onEnsureFullDataset, + this.onColumnSearchCleared, }); final MasterDefinition definition; @@ -304,7 +300,8 @@ class _MasterListTable extends StatelessWidget { final bool canDelete; final ValueChanged onEdit; final ValueChanged> onDelete; - final ValueChanged? onServerSearch; + final Future Function()? onEnsureFullDataset; + final VoidCallback? onColumnSearchCleared; @override Widget build(BuildContext context) { @@ -312,15 +309,28 @@ class _MasterListTable extends StatelessWidget { return AppDataTable>( wrapInCard: false, - onServerSearchChanged: onServerSearch, + onEnsureFullDataset: onEnsureFullDataset, + onColumnSearchCleared: onColumnSearchCleared, emptyMessage: 'No ${definition.title.toLowerCase()} found', columns: [ ...definition.listFields.map( (field) => AppDataColumn>( - label: field.label, + label: field.key == 'is_asset_item' ? 'Type' : field.label, flex: _columnFlex(field), searchText: (row) => masterCellValue(row, field), - cellBuilder: (_, row) => Text(masterCellValue(row, field)), + cellBuilder: (_, row) { + if (field.key == 'is_asset_item') { + final isAsset = masterIsAssetItem(row['is_asset_item']); + return TableStatusBadge( + label: isAsset ? 'Asset' : 'Stock', + color: isAsset + ? const Color(0xFF2563EB) + : const Color(0xFF64748B), + compact: true, + ); + } + return Text(masterCellValue(row, field)); + }, ), ), AppDataColumn( @@ -369,6 +379,7 @@ class _MasterListTable extends StatelessWidget { int _columnFlex(MasterFieldDef field) { return switch (field.key) { 'code' || 'item_code' || 'series_code' || 'hsn_code_id' => 1, + 'is_asset_item' => 1, 'name' || 'item_name' || 'description' || 'term_name' => 3, _ => 2, }; diff --git a/lib/modules/masters/data/datasources/master_remote_data_source.dart b/lib/modules/masters/data/datasources/master_remote_data_source.dart index 61cb7a4..456e2ef 100644 --- a/lib/modules/masters/data/datasources/master_remote_data_source.dart +++ b/lib/modules/masters/data/datasources/master_remote_data_source.dart @@ -106,6 +106,39 @@ class MasterRemoteDataSource { Future> listPaymentTerms() => _listOptions(ApiEndpoints.paymentTerms); + /// Payment terms with `credit_days` for vendor credit-period autofill. + Future< + ({ + List options, + Map creditDaysById, + })> listPaymentTermsWithCreditDays() async { + final rows = await _listAllMaps(ApiEndpoints.paymentTerms); + final options = []; + final creditDaysById = {}; + + 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> listDeliveryTerms() => _listOptions(ApiEndpoints.deliveryTerms); diff --git a/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart b/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart index 588c16f..5bf08bf 100644 --- a/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart +++ b/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart @@ -171,6 +171,25 @@ class PurchaseOrderRemoteDataSource { ); } + /// POST /notifications/trigger — send PO_SUBMIT_APPROVAL emails. + Future 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> downloadPurchaseOrderPdf(String id) async { final response = await dio.get>( ApiEndpoints.purchaseOrderPdf(id), diff --git a/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart b/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart index 493bb1e..7b08e6c 100644 --- a/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart +++ b/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart @@ -121,6 +121,11 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository { return safeApiCall(() => dataSource.cancelPurchaseOrder(id, remarks: remarks)); } + @override + Future> triggerApprovalNotification(String poId) { + return safeApiCall(() => dataSource.triggerApprovalNotification(poId)); + } + @override Future>> downloadPurchaseOrderPdf(String id) { return safeApiCall(() => dataSource.downloadPurchaseOrderPdf(id)); diff --git a/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart b/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart index 85e9f9f..7b6d2f2 100644 --- a/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart +++ b/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart @@ -34,6 +34,11 @@ abstract class PurchaseOrderRepository { Map? data, }); Future> cancelPurchaseOrder(String id, {String? remarks}); + + /// Triggers `PO_SUBMIT_APPROVAL` email for a pending-approval PO. + /// Returns the API success message when available. + Future> triggerApprovalNotification(String poId); + Future>> downloadPurchaseOrderPdf(String id); Future>> listAttachments(String poId); Future> uploadAttachment( diff --git a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart index fab384b..6117198 100644 --- a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart +++ b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart @@ -1,14 +1,25 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/network/api_handler.dart'; +import '../../../../core/utils/active_option.dart'; +import '../../../../core/utils/column_search_paging.dart'; import '../../../../core/utils/table_search.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/entity_attachment_model.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/purchase_order_model.dart'; +import '../../../../shared/models/vendor_model.dart'; import '../../../grn/presentation/providers/grn_lookups_provider.dart'; +import '../../../vendors/data/repositories/vendor_repository_impl.dart'; import '../../data/repositories/purchase_order_repository_impl.dart'; -import 'purchase_order_lookups_provider.dart'; + +/// Cached vendor options for list search enrichment only (not full PO lookups). +final _poListVendorOptionsProvider = + FutureProvider>((ref) async { + final result = await ref.watch(vendorRepositoryProvider).listVendorOptions(); + if (result.failure != null) return const []; + return result.data ?? const []; +}); class PurchaseOrdersListState { const PurchaseOrdersListState({ @@ -68,6 +79,8 @@ final pendingApprovalPurchaseOrdersListProvider = class PurchaseOrdersListNotifier extends AutoDisposeAsyncNotifier { + final _columnSearch = ColumnSearchPaging(); + @override Future build() async { return _load(const PurchaseOrderListQuery(limit: 20)); @@ -120,6 +133,28 @@ class PurchaseOrdersListNotifier applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); } + /// Column search: load all rows once (`limit = total`), then filter client-side. + Future ensureColumnSearchDataset() async { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.beginFullDataset( + currentLimit: current.query.limit, + total: current.total, + ); + if (limit == null) return; + await applyQuery( + current.query.copyWith(search: null, page: 1, limit: limit), + ); + } + + void clearColumnSearchDataset() { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.endFullDataset(); + if (limit == null) return; + applyQuery(current.query.copyWith(search: null, page: 1, limit: limit)); + } + void setStatusFilter(String? status) { final current = state.valueOrNull; if (current == null) return; @@ -181,10 +216,34 @@ class PurchaseOrdersListNotifier } return true; } + + Future approvePurchaseOrder(String id, {String? remarks}) async { + final repository = ref.read(purchaseOrderRepositoryProvider); + final result = await repository.approvePurchaseOrder(id, remarks: remarks); + if (result.failure != null) { + final current = state.valueOrNull; + if (current != null) { + state = AsyncData(current.copyWith(actionError: result.failure!.message)); + } + return false; + } + ref.invalidate(pendingApprovalPurchaseOrdersListProvider); + ref.invalidate(grnLookupsProvider); + await refresh(); + final current = state.valueOrNull; + if (current != null) { + state = AsyncData( + current.copyWith(actionSuccess: 'Purchase order approved'), + ); + } + return true; + } } class PendingApprovalPurchaseOrdersListNotifier extends AutoDisposeAsyncNotifier { + final _columnSearch = ColumnSearchPaging(); + @override Future build() async { return _load(const PurchaseOrderListQuery(limit: 20)); @@ -237,6 +296,28 @@ class PendingApprovalPurchaseOrdersListNotifier applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); } + /// Column search: load all rows once (`limit = total`), then filter client-side. + Future ensureColumnSearchDataset() async { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.beginFullDataset( + currentLimit: current.query.limit, + total: current.total, + ); + if (limit == null) return; + await applyQuery( + current.query.copyWith(search: null, page: 1, limit: limit), + ); + } + + void clearColumnSearchDataset() { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.endFullDataset(); + if (limit == null) return; + applyQuery(current.query.copyWith(search: null, page: 1, limit: limit)); + } + void setPage(int page) { final current = state.valueOrNull; if (current == null) return; @@ -346,6 +427,14 @@ class PurchaseOrderDetailNotifier return result.data!; } + /// Sends PO_SUBMIT_APPROVAL notification emails to approvers. + Future triggerApprovalNotification() async { + final repository = ref.read(purchaseOrderRepositoryProvider); + final result = await repository.triggerApprovalNotification(arg); + if (result.failure != null) throw result.failure!; + return result.data!; + } + Future> downloadPdf() async { final repository = ref.read(purchaseOrderRepositoryProvider); final result = await repository.downloadPurchaseOrderPdf(arg); @@ -455,10 +544,17 @@ Future> _enrichPurchaseOrderSearch({ var merged = List.from(items); try { - final lookups = await ref.read(purchaseOrderLookupsProvider.future); + // Vendor-only cache — do not pull full PO master lookups on every search. + final vendors = await ref.read(_poListVendorOptionsProvider.future); var vendorMatches = 0; - for (final vendor in lookups.vendors) { - if (!TableSearch.matches(search, [vendor.name])) continue; + for (final vendor in vendors) { + if (!isActiveVendorOption( + isActive: vendor.isActive, + status: vendor.status, + )) { + continue; + } + if (!TableSearch.matches(search, [vendor.vendorName])) continue; if (++vendorMatches > 5) break; final vendorId = int.tryParse(vendor.id); if (vendorId == null) continue; @@ -474,7 +570,7 @@ Future> _enrichPurchaseOrderSearch({ } } } catch (_) { - // Lookups/vendor enrichment is best-effort. + // Vendor enrichment is best-effort. } return merged; diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart index 2438833..1c0c857 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart @@ -38,6 +38,26 @@ class _PurchaseOrderDetailScreenState extends ConsumerState { bool _isWorking = false; bool _isDownloadingPdf = false; + bool _requestedFreshLoad = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_requestedFreshLoad) return; + _requestedFreshLoad = true; + // Always hit GET /purchase-orders/{id} when opening view. + ref.invalidate(purchaseOrderDetailProvider(widget.purchaseOrderId)); + ref.invalidate(purchaseOrderAttachmentsProvider(widget.purchaseOrderId)); + } + + @override + void didUpdateWidget(covariant PurchaseOrderDetailScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.purchaseOrderId != widget.purchaseOrderId) { + ref.invalidate(purchaseOrderDetailProvider(widget.purchaseOrderId)); + ref.invalidate(purchaseOrderAttachmentsProvider(widget.purchaseOrderId)); + } + } @override Widget build(BuildContext context) { @@ -82,6 +102,7 @@ class _PurchaseOrderDetailScreenState '${RouteConstants.purchaseOrders}/${order.id}/edit', ), onSubmit: () => _submit(order), + onNotify: () => _notifyApprovers(order), onApprove: () => _approve(order), onReject: () => _reject(order), onAmend: () => _amend(order), @@ -162,6 +183,37 @@ class _PurchaseOrderDetailScreenState ); } + Future _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 _approve(PurchaseOrderModel order) async { await _runWorkflow( () => ref @@ -386,6 +438,7 @@ class _DetailHeader extends StatelessWidget { required this.onPdf, required this.onEdit, required this.onSubmit, + required this.onNotify, required this.onApprove, required this.onReject, required this.onAmend, @@ -404,6 +457,7 @@ class _DetailHeader extends StatelessWidget { final VoidCallback onPdf; final VoidCallback onEdit; final VoidCallback onSubmit; + final VoidCallback onNotify; final VoidCallback onApprove; final VoidCallback onReject; final VoidCallback onAmend; @@ -446,6 +500,12 @@ class _DetailHeader extends StatelessWidget { filled: true, onPressed: isWorking ? null : onSubmit, ), + if (canEdit && order.canNotifyApprovers) + _HeaderActionButton( + label: 'Notify', + icon: Icons.notifications_outlined, + onPressed: isWorking ? null : onNotify, + ), if (canApprove && order.canApprove) _HeaderActionButton( label: 'Approve', diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart index f91b64e..e5557f0 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -62,6 +63,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState _buildPayload() { + final discount = + (double.tryParse(_discountController.text.trim()) ?? 0).clamp(0, double.infinity); + final freight = + (double.tryParse(_freightController.text.trim()) ?? 0).clamp(0, double.infinity); + final other = + (double.tryParse(_otherChargesController.text.trim()) ?? 0).clamp(0, double.infinity); return { 'po_date': DateFormatter.toApiDate(_poDate ?? DateTime.now()), 'vendor_id': _vendorId, @@ -216,12 +233,9 @@ class _PurchaseOrderFormScreenState extends ConsumerState 100) { + return 'Discount % on line ${line.lineNo} must be between 0 and 100'; + } + } + return null; + } + + String? _chargesError(PoOrderTotals totals) { + final freight = double.tryParse(_freightController.text.trim()); + if (freight == null) return 'Enter a valid freight amount'; + if (freight < 0) return 'Freight charges cannot be negative'; + + final other = double.tryParse(_otherChargesController.text.trim()); + if (other == null) return 'Enter a valid other charges amount'; + if (other < 0) return 'Other charges cannot be negative'; + + final discount = double.tryParse(_discountController.text.trim()); + if (discount == null) return 'Enter a valid discount amount'; + if (discount < 0) return 'Discount amount cannot be negative'; + if (discount > totals.maxDiscountAmount) { + return 'Discount cannot exceed ${CurrencyFormatter.format(totals.maxDiscountAmount)}'; } return null; } @@ -260,6 +296,20 @@ class _PurchaseOrderFormScreenState extends ConsumerState _isSubmitting = true); try { final payload = _buildPayload(); @@ -330,12 +388,14 @@ class _PurchaseOrderFormScreenState extends ConsumerState _pickDate({ required DateTime? current, required ValueChanged onPicked, + DateTime? firstDate, + DateTime? lastDate, }) async { final picked = await showAppDatePopup( context: context, initialDate: current ?? DateTime.now(), - firstDate: DateTime(2020), - lastDate: DateTime(2100), + firstDate: firstDate ?? DateTime(2020), + lastDate: lastDate ?? DateTime(2100), helpText: 'Select date', ); if (picked != null) onPicked(picked); @@ -398,9 +458,11 @@ class _PurchaseOrderFormScreenState extends ConsumerState _pickDate( current: _expectedDeliveryDate, + firstDate: _poDate ?? DateTime(2020), onPicked: (d) => setState( () => _expectedDeliveryDate = d, ), @@ -852,6 +915,15 @@ class _AmountSummaryCard extends StatelessWidget { final bool isEditing; final bool isInterState; + String? _validateNonNegativeAmount(String? value, String fieldName) { + final text = value?.trim() ?? ''; + if (text.isEmpty) return null; + final amount = double.tryParse(text); + if (amount == null) return 'Enter a valid amount'; + if (amount < 0) return 'Cannot be negative'; + return null; + } + String? _validateDiscount(String? value) { final text = value?.trim() ?? ''; if (text.isEmpty) return null; @@ -915,10 +987,14 @@ class _AmountSummaryCard extends StatelessWidget { _SummaryInputRow( label: 'Freight Charges', controller: freightController, + validator: (v) => _validateNonNegativeAmount(v, 'Freight'), + autovalidateMode: AutovalidateMode.onUserInteraction, ), _SummaryInputRow( label: 'Other Charges', controller: otherChargesController, + validator: (v) => _validateNonNegativeAmount(v, 'Other charges'), + autovalidateMode: AutovalidateMode.onUserInteraction, ), _SummaryInputRow( label: 'Discount Amount', @@ -1057,6 +1133,9 @@ class _SummaryInputRow extends StatelessWidget { controller: controller, keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), + ], textAlign: TextAlign.right, autovalidateMode: autovalidateMode, validator: validator, diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart index 1d96012..9396ad6 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart @@ -27,6 +27,7 @@ import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/utils/file_download_helper.dart'; +import '../../data/repositories/purchase_order_repository_impl.dart'; import '../providers/purchase_orders_provider.dart'; import '../widgets/po_status_chip.dart'; import '../../../../shared/widgets/app_toast.dart'; @@ -76,8 +77,10 @@ class _PurchaseOrderListScreenState extends ConsumerState? prev, @@ -241,6 +244,8 @@ class _PurchaseOrderListScreenState extends ConsumerState _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 _deleteOrder(PurchaseOrderModel order) async { @@ -447,7 +489,9 @@ class _PoDataTable extends StatelessWidget { this.onEdit, this.onDelete, this.onApprove, - this.onServerSearch, + this.onNotify, + this.onEnsureFullDataset, + this.onColumnSearchCleared, }); final List orders; @@ -455,25 +499,31 @@ class _PoDataTable extends StatelessWidget { final ValueChanged? onEdit; final ValueChanged? onDelete; final ValueChanged? onApprove; - final ValueChanged? onServerSearch; + final ValueChanged? onNotify; + final Future Function()? onEnsureFullDataset; + final VoidCallback? onColumnSearchCleared; @override Widget build(BuildContext context) { final theme = Theme.of(context); return AppDataTable( wrapInCard: false, - onServerSearchChanged: onServerSearch, + onEnsureFullDataset: onEnsureFullDataset, + onColumnSearchCleared: onColumnSearchCleared, columns: [ AppDataColumn( label: 'PO Number', flex: 2, searchText: (order) => order.poNo ?? '', - cellBuilder: (_, order) => Text(order.poNo ?? '—'), + cellBuilder: (_, order) => AppTableCell.link( + order.poNo, + onTap: () => onView(order), + ), ), AppDataColumn( label: 'Date', flex: 1, - searchText: (order) => DateFormatter.displayDate(order.poDate), + searchText: (order) => DateFormatter.searchableDate(order.poDate), cellBuilder: (_, order) => Text(DateFormatter.displayDate(order.poDate)), ), AppDataColumn( @@ -485,7 +535,7 @@ class _PoDataTable extends StatelessWidget { AppDataColumn( label: 'Total', flex: 1, - searchText: (order) => CurrencyFormatter.format(order.totalAmount), + searchText: (order) => CurrencyFormatter.searchable(order.totalAmount), cellBuilder: (_, order) => SizedBox( width: double.infinity, child: AppTableCell.text( @@ -506,7 +556,7 @@ class _PoDataTable extends StatelessWidget { ), AppDataColumn( label: 'Actions', - flex: onApprove != null ? 2 : 1, + width: 88, alignment: Alignment.centerRight, enableSearch: false, cellBuilder: (_, order) => AppTableActions( @@ -516,6 +566,12 @@ class _PoDataTable extends StatelessWidget { icon: Icons.visibility_outlined, onPressed: () => onView(order), ), + if (onNotify != null && order.canNotifyApprovers) + AppTableActionIcon( + tooltip: 'Notify approvers', + icon: Icons.notifications_outlined, + onPressed: () => onNotify!(order), + ), if (onApprove != null && order.canApprove) AppTableActionIcon( tooltip: 'Approve', @@ -551,6 +607,7 @@ class _PoCardList extends StatelessWidget { this.onEdit, this.onDelete, this.onApprove, + this.onNotify, }); final List orders; @@ -558,6 +615,7 @@ class _PoCardList extends StatelessWidget { final ValueChanged? onEdit; final ValueChanged? onDelete; final ValueChanged? onApprove; + final ValueChanged? onNotify; @override Widget build(BuildContext context) { @@ -568,14 +626,26 @@ class _PoCardList extends StatelessWidget { final order = orders[index]; return AppCard( child: ListTile( - title: Text(order.poNo ?? 'PO #${order.id}'), + title: AppTableCell.link( + order.poNo ?? 'PO #${order.id}', + onTap: () => onView(order), + ), subtitle: Text( '${order.vendorName ?? '—'} · ${vendorTypeLabel(order.vendorType)}', ), + onTap: () => onView(order), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ PoStatusChip(status: order.status, compact: true), + if (onNotify != null && order.canNotifyApprovers) ...[ + const SizedBox(width: 4), + IconButton( + tooltip: 'Notify approvers', + icon: const Icon(Icons.notifications_outlined), + onPressed: () => onNotify!(order), + ), + ], if (onApprove != null && order.canApprove) ...[ const SizedBox(width: 4), IconButton( @@ -589,7 +659,6 @@ class _PoCardList extends StatelessWidget { ], ], ), - onTap: () => onView(order), ), ); }, diff --git a/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart b/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart index a581ef3..07541d6 100644 --- a/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart +++ b/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/utils/formatters.dart'; @@ -41,15 +42,19 @@ class PoLineCalculation { required double discPct, required double gstPct, }) { - final baseAmount = qty * rate; - final discountAmount = baseAmount * discPct / 100; + final qtySafe = qty < 0 ? 0.0 : qty; + final rateSafe = rate < 0 ? 0.0 : rate; + final discSafe = discPct < 0 ? 0.0 : (discPct > 100 ? 100.0 : discPct); + final gstSafe = gstPct < 0 ? 0.0 : gstPct; + final baseAmount = qtySafe * rateSafe; + final discountAmount = baseAmount * discSafe / 100; final lineAmount = baseAmount - discountAmount; - final gstAmount = lineAmount * gstPct / 100; + final gstAmount = lineAmount * gstSafe / 100; return PoLineCalculation( baseAmount: baseAmount, discountAmount: discountAmount, - lineAmount: lineAmount, - gstAmount: gstAmount, + lineAmount: lineAmount < 0 ? 0 : lineAmount, + gstAmount: gstAmount < 0 ? 0 : gstAmount, ); } } @@ -72,7 +77,7 @@ class PoOrderTotals { final double taxAmount; final double grandTotal; - /// Sub Total + Tax + Freight + Other (discount cannot exceed this). + /// Sub Total + Freight + Other (discount cannot exceed this). final double maxDiscountAmount; static const zero = PoOrderTotals( @@ -99,20 +104,24 @@ class PoOrderTotals { subTotal += line.lineAmount; lineTax += line.gstAmount; } + // Keep order-level money fields non-negative. + final subSafe = subTotal < 0 ? 0.0 : subTotal; + final lineTaxSafe = lineTax < 0 ? 0.0 : lineTax; final freightSafe = freight < 0 ? 0.0 : freight; final otherSafe = otherCharges < 0 ? 0.0 : otherCharges; - final clampedDiscount = discountAmount < 0 ? 0.0 : discountAmount; - final taxableRaw = subTotal + freightSafe + otherSafe - clampedDiscount; - final taxable = taxableRaw < 0 ? 0.0 : taxableRaw; + final maxDiscount = subSafe + freightSafe + otherSafe; + final clampedDiscount = discountAmount < 0 + ? 0.0 + : (discountAmount > maxDiscount ? maxDiscount : discountAmount); + final taxable = subSafe + freightSafe + otherSafe - clampedDiscount; // Apply the blended line GST rate to Taxable Amount (not Sub Total), // so freight / other / discount are included in the tax base. - final tax = subTotal > 0 ? lineTax * (taxable / subTotal) : 0.0; - final maxDiscount = subTotal + lineTax + freightSafe + otherSafe; + final tax = subSafe > 0 ? lineTaxSafe * (taxable / subSafe) : 0.0; final grandTotal = taxable + tax; return PoOrderTotals( - subTotal: subTotal, - taxableAmount: taxable, - taxAmount: tax, + subTotal: subSafe, + taxableAmount: taxable < 0 ? 0 : taxable, + taxAmount: tax < 0 ? 0 : tax, grandTotal: grandTotal < 0 ? 0 : grandTotal, maxDiscountAmount: maxDiscount < 0 ? 0 : maxDiscount, ); @@ -122,9 +131,12 @@ class PoOrderTotals { class PoLineItemDraft { PoLineItemDraft({ this.itemId, + this.itemName, + this.itemCode, required this.lineNo, TextEditingController? qtyController, this.uomId, + this.uomName, TextEditingController? rateController, TextEditingController? discountController, this.gstRateId, @@ -135,9 +147,14 @@ class PoLineItemDraft { discountController ?? TextEditingController(text: '0'); int? itemId; + /// Kept so edit can show the label even if the item is inactive / missing + /// from the active dropdown lookups. + String? itemName; + String? itemCode; int lineNo; final TextEditingController qtyController; int? uomId; + String? uomName; final TextEditingController rateController; final TextEditingController discountController; int? gstRateId; @@ -146,10 +163,13 @@ class PoLineItemDraft { factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) { return PoLineItemDraft( itemId: item.itemId, + itemName: item.itemName, + itemCode: item.itemCode, lineNo: item.lineNo ?? 1, qtyController: TextEditingController(text: item.orderedQty?.toString() ?? ''), uomId: item.uomId, + uomName: item.uomName, rateController: TextEditingController(text: item.rate?.toString() ?? ''), discountController: TextEditingController(text: item.discountPct?.toString() ?? '0'), @@ -451,7 +471,22 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { void _onItemChanged(int? itemId) { _updateLine(() { widget.line.itemId = itemId; - if (itemId == null) return; + if (itemId == null) { + widget.line.itemName = null; + widget.line.itemCode = null; + return; + } + FilterOptionModel? selected; + for (final e in widget.items) { + if (_parseId(e.id) == itemId) { + selected = e; + break; + } + } + if (selected != null) { + widget.line.itemName = selected.name; + widget.line.itemCode = selected.slug; + } final key = itemId.toString(); final defaultUom = _itemUomById[key]; if (defaultUom != null) { @@ -471,6 +506,61 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { }); } + List> _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>() + .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> _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>() + .toList(); + + final selectedId = widget.line.uomId; + if (selectedId == null) return options; + if (options.any((o) => o.value == selectedId)) return options; + + final name = widget.line.uomName?.trim(); + return [ + AppDropdownOption( + value: selectedId, + label: (name != null && name.isNotEmpty) ? name : 'UOM #$selectedId', + ), + ...options, + ]; + } + @override void didUpdateWidget(covariant _LineItemCard oldWidget) { super.didUpdateWidget(oldWidget); @@ -500,27 +590,8 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { alpha: isDark ? 0.18 : 0.08, ); - final itemOptions = widget.items - .map((e) { - final id = _parseId(e.id); - if (id == null) return null; - final code = e.slug?.trim(); - return AppDropdownOption( - value: id, - label: e.name, - subtitle: (code == null || code.isEmpty) ? null : code, - ); - }) - .whereType>() - .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>() - .toList(); + final itemOptions = _itemOptionsWithSelected(); + final uomOptions = _uomOptionsWithSelected(); final gstOptions = [ const AppDropdownOption(value: null, label: 'Select GST Rate'), ...widget.gstRates.map((e) { @@ -559,6 +630,9 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { label: 'Qty *', hint: '0', keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), + ], validator: (v) { if (v == null || v.trim().isEmpty) return 'Required'; final qty = double.tryParse(v); @@ -589,6 +663,9 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { label: 'Rate *', hint: '0.00', keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), + ], validator: (v) { if (v == null || v.trim().isEmpty) return 'Required'; final rate = double.tryParse(v); @@ -602,6 +679,17 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { label: 'Disc %', hint: '0', keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), + ], + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + final disc = double.tryParse(v); + if (disc == null) return 'Invalid'; + if (disc < 0) return 'Cannot be negative'; + if (disc > 100) return 'Max 100%'; + return null; + }, ); final gstField = MasterQuickAddDropdown( key: ValueKey('$lineKey-gst'), @@ -662,9 +750,11 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { // Medium / narrow: wrapping grid (2–3 columns) return ResponsiveFormGrid( spacing: spacing, + xsColumns: 1, smallColumns: 1, mediumColumns: 2, largeColumns: 3, + smallBreakpoint: 520, mediumBreakpoint: 520, largeBreakpoint: 800, children: [ diff --git a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart index 95a3090..c6b4172 100644 --- a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart +++ b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart @@ -837,13 +837,12 @@ class _UsersTabState extends ConsumerState<_UsersTab> { child: UserRichDataTable( wrapInCard: false, users: usersState.users, - onServerSearchChanged: (value) { - _searchController.value = TextEditingValue( - text: value, - selection: TextSelection.collapsed(offset: value.length), - ); - ref.read(usersListProvider.notifier).setSearch(value); - }, + onEnsureFullDataset: () => ref + .read(usersListProvider.notifier) + .ensureColumnSearchDataset(), + onColumnSearchCleared: () => ref + .read(usersListProvider.notifier) + .clearColumnSearchDataset(), actionsBuilder: (_, user) => UserTableActions( user: user, canEdit: canEditUser, diff --git a/lib/modules/reports/presentation/providers/depreciation_report_provider.dart b/lib/modules/reports/presentation/providers/depreciation_report_provider.dart index 0060874..2f228e3 100644 --- a/lib/modules/reports/presentation/providers/depreciation_report_provider.dart +++ b/lib/modules/reports/presentation/providers/depreciation_report_provider.dart @@ -1,7 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/app_constants.dart'; -import '../../../../core/utils/table_search.dart'; +import '../../../../core/utils/column_search_paging.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../data/repositories/reports_repository_impl.dart'; import '../../domain/entities/depreciation_report.dart'; @@ -73,6 +73,10 @@ final depreciationReportProvider = AsyncNotifierProvider< class DepreciationReportNotifier extends AsyncNotifier { + final _columnSearch = ColumnSearchPaging( + defaultLimit: AppConstants.defaultPageSize, + ); + @override Future build() async { ref.keepAlive(); @@ -145,6 +149,39 @@ class DepreciationReportNotifier ); } + Future 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 setLocationId(String? value) async { final current = state.valueOrNull?.query ?? const DepreciationReportQuery(limit: AppConstants.defaultPageSize); diff --git a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart index 52aeb59..4e9a4eb 100644 --- a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart +++ b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart @@ -234,15 +234,10 @@ class _DepreciationReportScreenState : _MobileList(items: state.items)) : _ReportTable( items: state.items, - onServerSearch: (value) { - _searchController.value = TextEditingValue( - text: value, - selection: TextSelection.collapsed( - offset: value.length, - ), - ); - notifier.setSearch(value); - }, + onEnsureFullDataset: () => + notifier.ensureColumnSearchDataset(), + onColumnSearchCleared: () => + notifier.clearColumnSearchDataset(), ), ), ), @@ -594,24 +589,32 @@ class _FiltersBarState extends State<_FiltersBar> { class _ReportTable extends StatelessWidget { const _ReportTable({ required this.items, - this.onServerSearch, + this.onEnsureFullDataset, + this.onColumnSearchCleared, }); final List items; - final ValueChanged? onServerSearch; + final Future Function()? onEnsureFullDataset; + final VoidCallback? onColumnSearchCleared; @override Widget build(BuildContext context) { return AppDataTable( wrapInCard: false, rows: items, - onServerSearchChanged: onServerSearch, + onEnsureFullDataset: onEnsureFullDataset, + onColumnSearchCleared: onColumnSearchCleared, columns: [ AppDataColumn( label: 'Asset Code', flex: 2, searchText: (row) => row.assetCode ?? '', - cellBuilder: (_, row) => AppTableCell.text(row.assetCode), + cellBuilder: (_, row) => AppTableCell.link( + row.assetCode, + onTap: row.id.trim().isEmpty + ? null + : () => context.push('${RouteConstants.assets}/${row.id}'), + ), ), AppDataColumn( label: 'Asset Name', @@ -634,7 +637,7 @@ class _ReportTable extends StatelessWidget { AppDataColumn( label: 'Purchase Date', flex: 2, - searchText: (row) => DateFormatter.displayDate(row.purchaseDate), + searchText: (row) => DateFormatter.searchableDate(row.purchaseDate), cellBuilder: (_, row) => AppTableCell.text(DateFormatter.displayDate(row.purchaseDate)), ), @@ -642,7 +645,7 @@ class _ReportTable extends StatelessWidget { label: 'Purchase Cost', flex: 2, alignment: Alignment.centerRight, - searchText: (row) => CurrencyFormatter.format(row.purchaseCost), + searchText: (row) => CurrencyFormatter.searchable(row.purchaseCost), cellBuilder: (_, row) => AppTableCell.text( CurrencyFormatter.format(row.purchaseCost), textAlign: TextAlign.right, @@ -653,7 +656,7 @@ class _ReportTable extends StatelessWidget { flex: 2, alignment: Alignment.centerRight, searchText: (row) => - CurrencyFormatter.format(row.annualDepreciation), + CurrencyFormatter.searchable(row.annualDepreciation), cellBuilder: (_, row) => AppTableCell.text( CurrencyFormatter.format(row.annualDepreciation), textAlign: TextAlign.right, @@ -664,7 +667,7 @@ class _ReportTable extends StatelessWidget { flex: 2, alignment: Alignment.centerRight, searchText: (row) => - CurrencyFormatter.format(row.accumulatedDepreciation), + CurrencyFormatter.searchable(row.accumulatedDepreciation), cellBuilder: (_, row) => AppTableCell.text( CurrencyFormatter.format(row.accumulatedDepreciation), textAlign: TextAlign.right, @@ -674,7 +677,7 @@ class _ReportTable extends StatelessWidget { label: 'Book Value', flex: 2, alignment: Alignment.centerRight, - searchText: (row) => CurrencyFormatter.format(row.bookValue), + searchText: (row) => CurrencyFormatter.searchable(row.bookValue), cellBuilder: (_, row) => AppTableCell.text( CurrencyFormatter.format(row.bookValue), textAlign: TextAlign.right, diff --git a/lib/modules/roles/presentation/providers/roles_provider.dart b/lib/modules/roles/presentation/providers/roles_provider.dart index 3f75e16..7436439 100644 --- a/lib/modules/roles/presentation/providers/roles_provider.dart +++ b/lib/modules/roles/presentation/providers/roles_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/utils/column_search_paging.dart'; import '../../../../core/utils/table_search.dart'; import '../../../../shared/models/permission_matrix_models.dart'; @@ -86,6 +87,8 @@ final rolesListProvider = AsyncNotifierProvider(RolesListNotifier.new); class RolesListNotifier extends AsyncNotifier { + final _columnSearch = ColumnSearchPaging(defaultLimit: 10); + @override Future build() async { ref.keepAlive(); @@ -130,6 +133,36 @@ class RolesListNotifier extends AsyncNotifier { } } + Future ensureColumnSearchDataset() async { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.beginFullDataset( + currentLimit: current.limit, + total: current.total, + ); + if (limit == null) return; + state = const AsyncLoading(); + try { + final loaded = await _load(search: ''); + state = AsyncData( + loaded.copyWith( + page: 1, + limit: loaded.total > 0 ? loaded.total : limit, + ), + ); + } catch (e, st) { + state = AsyncError(e, st); + } + } + + void clearColumnSearchDataset() { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.endFullDataset(); + if (limit == null) return; + state = AsyncData(current.copyWith(page: 1, limit: limit, search: '')); + } + void setPage(int page) { final current = state.valueOrNull; if (current == null) return; @@ -175,9 +208,16 @@ class PermissionMatrixNotifier final granted = Map.from(row.granted); granted[normalizedAction] = value; - // CREATE / EDIT imply VIEW. + // CREATE / EDIT / DELETE / APPROVE / EXPORT imply VIEW. + const impliesView = { + 'create', + 'edit', + 'delete', + 'approve', + 'export', + }; if (value && - (normalizedAction == 'create' || normalizedAction == 'edit') && + impliesView.contains(normalizedAction) && isPermissionActionApplicable( row.code, 'view', diff --git a/lib/modules/roles/presentation/screens/role_list_screen.dart b/lib/modules/roles/presentation/screens/role_list_screen.dart index 16ead21..458b5c6 100644 --- a/lib/modules/roles/presentation/screens/role_list_screen.dart +++ b/lib/modules/roles/presentation/screens/role_list_screen.dart @@ -108,15 +108,10 @@ class _RoleListScreenState extends ConsumerState { : _RoleDataTable( roles: roles, onOpen: _openRole, - onServerSearch: (value) { - _searchController.value = TextEditingValue( - text: value, - selection: TextSelection.collapsed( - offset: value.length, - ), - ); - notifier.setSearch(value); - }, + onEnsureFullDataset: () => + notifier.ensureColumnSearchDataset(), + onColumnSearchCleared: () => + notifier.clearColumnSearchDataset(), ), ), ), @@ -137,18 +132,21 @@ class _RoleDataTable extends StatelessWidget { const _RoleDataTable({ required this.roles, required this.onOpen, - this.onServerSearch, + this.onEnsureFullDataset, + this.onColumnSearchCleared, }); final List roles; final void Function(RoleCardModel role) onOpen; - final ValueChanged? onServerSearch; + final Future Function()? onEnsureFullDataset; + final VoidCallback? onColumnSearchCleared; @override Widget build(BuildContext context) { return AppDataTable( wrapInCard: false, - onServerSearchChanged: onServerSearch, + onEnsureFullDataset: onEnsureFullDataset, + onColumnSearchCleared: onColumnSearchCleared, columns: [ AppDataColumn(label: 'Role Name', flex: 2, searchText: (r) => r.name, cellBuilder: (_, r) => Text(r.name)), AppDataColumn( diff --git a/lib/modules/settings/presentation/providers/settings_provider.dart b/lib/modules/settings/presentation/providers/settings_provider.dart index 28fcfc5..66e1bb1 100644 --- a/lib/modules/settings/presentation/providers/settings_provider.dart +++ b/lib/modules/settings/presentation/providers/settings_provider.dart @@ -140,6 +140,15 @@ class AppSettingsNotifier extends StateNotifier { return result.failure; } + /// Fetches Company Profile + Email Settings after login / session restore + /// and applies them app-wide (logo, favicon, company name, email config). + Future syncCompanyAndEmailFromServer() async { + await Future.wait([ + refreshCompanyProfile(), + refreshEmailSettings(), + ]); + } + Future _persist(AppSettings settings) async { state = settings; final result = await _saveSettings(settings); diff --git a/lib/modules/users/presentation/providers/users_provider.dart b/lib/modules/users/presentation/providers/users_provider.dart index 69841d2..9757f46 100644 --- a/lib/modules/users/presentation/providers/users_provider.dart +++ b/lib/modules/users/presentation/providers/users_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/utils/column_search_paging.dart'; import '../../../../core/utils/table_search.dart'; import '../../../../shared/models/export_file_result.dart'; @@ -100,6 +101,8 @@ final usersListProvider = AsyncNotifierProvider(UsersListNotifier.new); class UsersListNotifier extends AsyncNotifier { + final _columnSearch = ColumnSearchPaging(defaultLimit: 10); + @override Future build() async { ref.keepAlive(); @@ -152,6 +155,27 @@ class UsersListNotifier extends AsyncNotifier { applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); } + Future ensureColumnSearchDataset() async { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.beginFullDataset( + currentLimit: current.query.limit, + total: current.total, + ); + if (limit == null) return; + await applyQuery( + current.query.copyWith(search: null, page: 1, limit: limit), + ); + } + + void clearColumnSearchDataset() { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.endFullDataset(); + if (limit == null) return; + applyQuery(current.query.copyWith(search: null, page: 1, limit: limit)); + } + void setPage(int page) { final current = state.valueOrNull; if (current == null) return; diff --git a/lib/modules/users/presentation/screens/user_detail_screen.dart b/lib/modules/users/presentation/screens/user_detail_screen.dart index f7776be..419cb50 100644 --- a/lib/modules/users/presentation/screens/user_detail_screen.dart +++ b/lib/modules/users/presentation/screens/user_detail_screen.dart @@ -15,13 +15,38 @@ import '../../../../shared/models/user_management_models.dart'; import '../providers/users_provider.dart'; import '../../../../shared/widgets/app_toast.dart'; -class UserDetailScreen extends ConsumerWidget { +class UserDetailScreen extends ConsumerStatefulWidget { const UserDetailScreen({super.key, required this.userId}); final String userId; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _UserDetailScreenState(); +} + +class _UserDetailScreenState extends ConsumerState { + bool _requestedFreshLoad = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_requestedFreshLoad) return; + _requestedFreshLoad = true; + // Always hit GET user-by-id when opening view. + ref.invalidate(userDetailProvider(widget.userId)); + } + + @override + void didUpdateWidget(covariant UserDetailScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.userId != widget.userId) { + ref.invalidate(userDetailProvider(widget.userId)); + } + } + + @override + Widget build(BuildContext context) { + final userId = widget.userId; final userAsync = ref.watch(userDetailProvider(userId)); return Padding( @@ -48,7 +73,7 @@ class UserDetailScreen extends ConsumerWidget { AppButton( label: 'Deactivate', expand: false, - onPressed: () => _deactivate(context, ref), + onPressed: () => _deactivate(context), ), ], ), @@ -87,7 +112,7 @@ class UserDetailScreen extends ConsumerWidget { ); } - Future _deactivate(BuildContext context, WidgetRef ref) async { + Future _deactivate(BuildContext context) async { final confirmed = await showAppConfirmationDialog( context: context, title: 'Deactivate user', @@ -97,11 +122,15 @@ class UserDetailScreen extends ConsumerWidget { ); if (confirmed != true || !context.mounted) return; - final success = await ref.read(userDetailProvider(userId).notifier).deactivate(); + final success = + await ref.read(userDetailProvider(widget.userId).notifier).deactivate(); if (!context.mounted) return; - showAppToastFromSnackBar(context, - SnackBar(content: Text(success ? 'User deactivated' : 'Failed to deactivate')), + showAppToastFromSnackBar( + context, + SnackBar( + content: Text(success ? 'User deactivated' : 'Failed to deactivate'), + ), ); if (success) context.pop(); } diff --git a/lib/modules/users/presentation/screens/user_form_screen.dart b/lib/modules/users/presentation/screens/user_form_screen.dart index 1989fe6..80e1aa3 100644 --- a/lib/modules/users/presentation/screens/user_form_screen.dart +++ b/lib/modules/users/presentation/screens/user_form_screen.dart @@ -37,9 +37,19 @@ class _UserFormScreenState extends ConsumerState { String _selectedStatus = 'active'; bool _isSubmitting = false; bool _prefilled = false; + bool _requestedFreshLoad = false; bool get isEditing => widget.userId != null; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_requestedFreshLoad || !isEditing) return; + _requestedFreshLoad = true; + // Always hit GET user-by-id when opening edit. + ref.invalidate(userFormProvider(widget.userId)); + } + @override void dispose() { _employeeIdController.dispose(); diff --git a/lib/modules/users/presentation/screens/user_list_screen.dart b/lib/modules/users/presentation/screens/user_list_screen.dart index e679194..568a6cf 100644 --- a/lib/modules/users/presentation/screens/user_list_screen.dart +++ b/lib/modules/users/presentation/screens/user_list_screen.dart @@ -143,17 +143,12 @@ class _UserListScreenState extends ConsumerState { onEdit: _editUser, onToggleStatus: _toggleStatus, onDeactivate: _deactivateUser, - onServerSearch: (value) { - _searchController.value = TextEditingValue( - text: value, - selection: TextSelection.collapsed( - offset: value.length, - ), - ); - ref - .read(usersListProvider.notifier) - .setSearch(value); - }, + onEnsureFullDataset: () => ref + .read(usersListProvider.notifier) + .ensureColumnSearchDataset(), + onColumnSearchCleared: () => ref + .read(usersListProvider.notifier) + .clearColumnSearchDataset(), ), ), ), @@ -312,7 +307,8 @@ class _UserDataTable extends StatelessWidget { required this.onEdit, required this.onToggleStatus, required this.onDeactivate, - this.onServerSearch, + this.onEnsureFullDataset, + this.onColumnSearchCleared, }); final List users; @@ -323,7 +319,8 @@ class _UserDataTable extends StatelessWidget { final void Function(ManagedUserModel user) onEdit; final Future Function(ManagedUserModel user) onToggleStatus; final Future Function(ManagedUserModel user) onDeactivate; - final ValueChanged? onServerSearch; + final Future Function()? onEnsureFullDataset; + final VoidCallback? onColumnSearchCleared; @override Widget build(BuildContext context) { @@ -333,7 +330,8 @@ class _UserDataTable extends StatelessWidget { sortAscending: sortOrder == 'asc', onSort: onSort, wrapInCard: false, - onServerSearchChanged: onServerSearch, + onEnsureFullDataset: onEnsureFullDataset, + onColumnSearchCleared: onColumnSearchCleared, actionsBuilder: (_, user) => _UserActions( user: user, onView: onView, diff --git a/lib/modules/users/presentation/widgets/user_rich_data_table.dart b/lib/modules/users/presentation/widgets/user_rich_data_table.dart index b56170b..dedd59f 100644 --- a/lib/modules/users/presentation/widgets/user_rich_data_table.dart +++ b/lib/modules/users/presentation/widgets/user_rich_data_table.dart @@ -21,6 +21,8 @@ class UserRichDataTable extends StatelessWidget { this.onSort, this.wrapInCard = false, this.onServerSearchChanged, + this.onEnsureFullDataset, + this.onColumnSearchCleared, }); final List users; @@ -30,6 +32,8 @@ class UserRichDataTable extends StatelessWidget { final void Function(String column, bool ascending)? onSort; final bool wrapInCard; final ValueChanged? onServerSearchChanged; + final Future Function()? onEnsureFullDataset; + final VoidCallback? onColumnSearchCleared; @override Widget build(BuildContext context) { @@ -41,6 +45,8 @@ class UserRichDataTable extends StatelessWidget { sortAscending: sortAscending, onSort: onSort, onServerSearchChanged: onServerSearchChanged, + onEnsureFullDataset: onEnsureFullDataset, + onColumnSearchCleared: onColumnSearchCleared, columns: [ AppDataColumn( label: 'User', @@ -62,20 +68,21 @@ class UserRichDataTable extends StatelessWidget { label: 'Role', flex: 2, padding: const EdgeInsets.only(left: 8), - searchText: (user) => user.roleNames.join(' '), + searchText: (user) => + '${user.roleNames.join(' ')} ${user.roleLabel}'.trim(), cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames), ), AppDataColumn( label: 'Department', flex: 2, - searchText: (user) => user.departmentLabel, + searchText: (user) => user.departmentName ?? '', cellBuilder: (_, user) => Text(user.departmentLabel), ), AppDataColumn( label: 'Last Login', flex: 2, searchText: (user) => - DateFormatter.formatUserLastLogin(user.lastLoginAt), + DateFormatter.searchableDate(user.lastLoginAt), cellBuilder: (_, user) => Text( DateFormatter.formatUserLastLogin(user.lastLoginAt), style: theme.textTheme.bodyMedium?.copyWith( diff --git a/lib/modules/vendors/presentation/providers/vendors_provider.dart b/lib/modules/vendors/presentation/providers/vendors_provider.dart index 5fdaf5c..b62d774 100644 --- a/lib/modules/vendors/presentation/providers/vendors_provider.dart +++ b/lib/modules/vendors/presentation/providers/vendors_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/utils/column_search_paging.dart'; import '../../../../core/utils/table_search.dart'; import '../../../../shared/models/export_file_result.dart'; @@ -57,6 +58,8 @@ final vendorsListProvider = ); class VendorsListNotifier extends AutoDisposeAsyncNotifier { + final _columnSearch = ColumnSearchPaging(); + @override Future build() async { return _load(const VendorListQuery(limit: 20)); @@ -103,6 +106,27 @@ class VendorsListNotifier extends AutoDisposeAsyncNotifier { applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); } + Future ensureColumnSearchDataset() async { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.beginFullDataset( + currentLimit: current.query.limit, + total: current.total, + ); + if (limit == null) return; + await applyQuery( + current.query.copyWith(search: null, page: 1, limit: limit), + ); + } + + void clearColumnSearchDataset() { + final current = state.valueOrNull; + if (current == null) return; + final limit = _columnSearch.endFullDataset(); + if (limit == null) return; + applyQuery(current.query.copyWith(search: null, page: 1, limit: limit)); + } + void setStatusFilter(String? status) { final current = state.valueOrNull; if (current == null) return; diff --git a/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart b/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart index 44e112e..d170011 100644 --- a/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart @@ -31,6 +31,7 @@ class VendorDetailScreen extends ConsumerStatefulWidget { class _VendorDetailScreenState extends ConsumerState with SingleTickerProviderStateMixin { late final TabController _tabController; + bool _requestedFreshLoad = false; @override void initState() { @@ -38,6 +39,23 @@ class _VendorDetailScreenState extends ConsumerState _tabController = TabController(length: 4, vsync: this); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_requestedFreshLoad) return; + _requestedFreshLoad = true; + // Always hit GET /vendors/{id} when opening view. + ref.invalidate(vendorDetailProvider(widget.vendorId)); + } + + @override + void didUpdateWidget(covariant VendorDetailScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.vendorId != widget.vendorId) { + ref.invalidate(vendorDetailProvider(widget.vendorId)); + } + } + @override void dispose() { _tabController.dispose(); diff --git a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart index 3581de5..4a2c09a 100644 --- a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart @@ -164,17 +164,12 @@ class _VendorListScreenState extends ConsumerState { onView: _viewVendor, onEdit: canEdit ? _editVendor : null, onDelete: canDelete ? _deleteVendor : null, - onServerSearch: (value) { - _searchController.value = TextEditingValue( - text: value, - selection: TextSelection.collapsed( - offset: value.length, - ), - ); - ref - .read(vendorsListProvider.notifier) - .setSearch(value); - }, + onEnsureFullDataset: () => ref + .read(vendorsListProvider.notifier) + .ensureColumnSearchDataset(), + onColumnSearchCleared: () => ref + .read(vendorsListProvider.notifier) + .clearColumnSearchDataset(), ), ), ), @@ -299,26 +294,32 @@ class _VendorDataTable extends StatelessWidget { required this.onView, this.onEdit, this.onDelete, - this.onServerSearch, + this.onEnsureFullDataset, + this.onColumnSearchCleared, }); final List vendors; final ValueChanged onView; final ValueChanged? onEdit; final ValueChanged? onDelete; - final ValueChanged? onServerSearch; + final Future Function()? onEnsureFullDataset; + final VoidCallback? onColumnSearchCleared; @override Widget build(BuildContext context) { return AppDataTable( wrapInCard: false, - onServerSearchChanged: onServerSearch, + onEnsureFullDataset: onEnsureFullDataset, + onColumnSearchCleared: onColumnSearchCleared, columns: [ AppDataColumn( label: 'Code', flex: 1, searchText: (vendor) => vendor.vendorCode ?? '', - cellBuilder: (_, vendor) => Text(vendor.vendorCode ?? '—'), + cellBuilder: (_, vendor) => AppTableCell.link( + vendor.vendorCode, + onTap: () => onView(vendor), + ), ), AppDataColumn( label: 'Name', @@ -405,9 +406,13 @@ class _VendorCardList extends StatelessWidget { return AppCard( child: ListTile( title: Text(vendor.vendorName), - subtitle: Text( + subtitle: AppTableCell.link( '${vendor.vendorCode ?? '—'} · ${vendorTypeLabel(vendor.vendorType)}', + onTap: vendor.vendorCode == null || vendor.vendorCode!.isEmpty + ? null + : () => onView(vendor), ), + onTap: () => onView(vendor), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -437,7 +442,6 @@ class _VendorCardList extends StatelessWidget { ), ], ), - onTap: () => onView(vendor), ), ); }, diff --git a/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart b/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart index 886fdba..ac9a45c 100644 --- a/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart +++ b/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart @@ -235,7 +235,12 @@ class _VendorFormPanelState extends ConsumerState { } Widget _buildForm( - AsyncValue> paymentTermsAsync, + AsyncValue< + ({ + List options, + Map creditDaysById, + })> + paymentTermsAsync, AsyncValue> gstTreatmentsAsync, AsyncValue> sourceOfSupplyAsync, ) { @@ -319,7 +324,7 @@ class _VendorFormPanelState extends ConsumerState { left: paymentTermsAsync.when( loading: () => const LinearProgressIndicator(), error: (_, __) => const Text('Failed to load payment terms'), - data: (terms) => _paymentTermDropdown(terms), + data: (terms) => _paymentTermDropdown(terms.options), ), right: AppTextField( controller: _creditDaysController, @@ -346,6 +351,23 @@ class _VendorFormPanelState extends ConsumerState { ); } + void _applyPaymentTerm(int? termId) { + final creditDaysById = + ref.read(vendorPaymentTermsProvider).valueOrNull?.creditDaysById ?? + const {}; + setState(() { + _paymentTermId = termId; + if (termId == null) { + _creditDaysController.clear(); + return; + } + final days = creditDaysById[termId]; + if (days != null) { + _creditDaysController.text = days.toString(); + } + }); + } + Widget _paymentTermDropdown(List terms) { final termIds = terms.map((t) => int.tryParse(t.id)).whereType().toList(); final value = _paymentTermId != null && termIds.contains(_paymentTermId) @@ -365,17 +387,23 @@ class _VendorFormPanelState extends ConsumerState { ) .where((option) => option.value != 0) .toList(), - refreshLookups: () => ref.invalidate(vendorPaymentTermsProvider), + refreshLookups: () async { + ref.invalidate(vendorPaymentTermsProvider); + await ref.read(vendorPaymentTermsProvider.future); + }, parseCreatedId: int.tryParse, - onChanged: (v) => setState(() => _paymentTermId = v), + onChanged: _applyPaymentTerm, ); } } -final vendorPaymentTermsProvider = - FutureProvider>((ref) async { +final vendorPaymentTermsProvider = FutureProvider< + ({ + List options, + Map creditDaysById, + })>((ref) async { final dataSource = ref.watch(masterRemoteDataSourceProvider); - return dataSource.listPaymentTerms(); + return dataSource.listPaymentTermsWithCreditDays(); }); final vendorGstTreatmentsProvider = diff --git a/lib/shared/models/asset_model.dart b/lib/shared/models/asset_model.dart index 0bb7dea..2782572 100644 --- a/lib/shared/models/asset_model.dart +++ b/lib/shared/models/asset_model.dart @@ -225,25 +225,23 @@ class AssetModel with _$AssetModel { class AssetMaintenanceChecklistItem { const AssetMaintenanceChecklistItem({ - required this.key, required this.label, - this.required = false, + this.required = true, }); - final String key; final String label; final bool required; factory AssetMaintenanceChecklistItem.fromJson(Map json) { + final label = json['label']?.toString().trim() ?? ''; return AssetMaintenanceChecklistItem( - key: json['key']?.toString() ?? '', - label: json['label']?.toString() ?? '', - required: json['required'] == true, + label: label, + // API default is true when omitted. + required: json['required'] == null ? true : json['required'] == true, ); } Map toJson() => { - 'key': key, 'label': label, 'required': required, }; @@ -580,7 +578,7 @@ List? _checklistFromJson(Object? value) { Map.from(item), ), ) - .where((item) => item.key.isNotEmpty) + .where((item) => item.label.isNotEmpty) .toList(); } diff --git a/lib/shared/models/purchase_order_model.dart b/lib/shared/models/purchase_order_model.dart index c66357f..cd9fd2b 100644 --- a/lib/shared/models/purchase_order_model.dart +++ b/lib/shared/models/purchase_order_model.dart @@ -76,6 +76,14 @@ Object? _readBillingName(Map json, String key) => Object? _readShippingName(Map json, String key) => _readLocationDisplayName(json, 'shipping_name', 'shipping'); +Object? _readItemId(Map 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 json, String key) { final flat = json['item_name']; if (flat is String && flat.isNotEmpty) return flat; @@ -210,8 +218,7 @@ class PurchaseOrderModel with _$PurchaseOrderModel { factory PurchaseOrderModel.fromJson(Map json) => _$PurchaseOrderModelFromJson(json); - bool get canEdit => - status.toUpperCase() == 'DRAFT' || status.toUpperCase() == 'REJECTED'; + bool get canEdit => status.toUpperCase() == 'DRAFT'; bool get canDelete => canEdit; @@ -219,9 +226,15 @@ class PurchaseOrderModel with _$PurchaseOrderModel { bool get canApprove { final s = status.toUpperCase(); - return s == 'SUBMITTED' || s == 'PENDING_APPROVAL' || s == 'PENDING'; + return s == 'SUBMITTED' || + s == 'PENDING_APPROVAL' || + s == 'PENDING' || + s == 'REJECTED'; } + /// Notify approvers via `POST /notifications/trigger` (PO_SUBMIT_APPROVAL). + bool get canNotifyApprovers => status.toUpperCase() == 'PENDING_APPROVAL'; + bool get canReject => canApprove; bool get canAmend => status.toUpperCase() == 'APPROVED'; @@ -237,7 +250,12 @@ class PurchaseOrderItemModel with _$PurchaseOrderItemModel { const factory PurchaseOrderItemModel({ @JsonKey(fromJson: _idFromJson) required String id, @JsonKey(name: 'po_id', fromJson: _idFromJson) String? poId, - @JsonKey(name: 'item_id', fromJson: _intFromJsonNullable) int? itemId, + @JsonKey( + name: 'item_id', + readValue: _readItemId, + fromJson: _intFromJsonNullable, + ) + int? itemId, @JsonKey(name: 'item_code', readValue: _readItemCode) String? itemCode, @JsonKey(name: 'item_name', readValue: _readItemName) String? itemName, @JsonKey(name: 'line_no', fromJson: _intFromJsonNullable) int? lineNo, diff --git a/lib/shared/models/purchase_order_model.g.dart b/lib/shared/models/purchase_order_model.g.dart index 8f282b6..0aa1c97 100644 --- a/lib/shared/models/purchase_order_model.g.dart +++ b/lib/shared/models/purchase_order_model.g.dart @@ -85,7 +85,7 @@ _$PurchaseOrderItemModelImpl _$$PurchaseOrderItemModelImplFromJson( ) => _$PurchaseOrderItemModelImpl( id: _idFromJson(json['id']), poId: _idFromJson(json['po_id']), - itemId: _intFromJsonNullable(json['item_id']), + itemId: _intFromJsonNullable(_readItemId(json, 'item_id')), itemCode: _readItemCode(json, 'item_code') as String?, itemName: _readItemName(json, 'item_name') as String?, lineNo: _intFromJsonNullable(json['line_no']), diff --git a/lib/shared/providers/auth_provider.dart b/lib/shared/providers/auth_provider.dart index ce6e04c..f51df2e 100644 --- a/lib/shared/providers/auth_provider.dart +++ b/lib/shared/providers/auth_provider.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/config/dev_config.dart'; import '../../modules/auth/data/repositories/auth_repository_impl.dart'; import '../../modules/auth/domain/repositories/auth_repository.dart'; +import '../../modules/settings/presentation/providers/settings_provider.dart'; import '../models/user_model.dart'; import 'dev_user.dart'; @@ -33,15 +36,28 @@ class AuthState { } final authStateProvider = StateNotifierProvider((ref) { - return AuthNotifier(ref.watch(authRepositoryProvider)); + return AuthNotifier(ref); }); class AuthNotifier extends StateNotifier { - AuthNotifier(this._repository) : super(const AuthState()) { + AuthNotifier(this._ref) : super(const AuthState()) { checkAuth(); } - final AuthRepository _repository; + final Ref _ref; + + AuthRepository get _repository => _ref.read(authRepositoryProvider); + + /// Pull Company + Email settings once the session is authenticated. + Future _syncAppSettingsAfterAuth() async { + try { + await _ref + .read(appSettingsProvider.notifier) + .syncCompanyAndEmailFromServer(); + } catch (_) { + // Settings sync must not block login / session restore. + } + } Future checkAuth() async { state = state.copyWith(status: AuthStatus.loading); @@ -57,10 +73,13 @@ class AuthNotifier extends StateNotifier { return; } state = AuthState(status: AuthStatus.authenticated, user: result.data); + unawaited(_syncAppSettingsAfterAuth()); } void loginAsDemo() { state = const AuthState(status: AuthStatus.authenticated, user: demoUser); + // Demo has no backend session — still try sync (may no-op / fail quietly). + unawaited(_syncAppSettingsAfterAuth()); } Future login(LoginRequest request) async { @@ -93,6 +112,7 @@ class AuthNotifier extends StateNotifier { status: AuthStatus.authenticated, user: loginResponse.user, ); + unawaited(_syncAppSettingsAfterAuth()); return true; } diff --git a/lib/shared/routes/app_router.dart b/lib/shared/routes/app_router.dart index 72b4b92..1be11d6 100644 --- a/lib/shared/routes/app_router.dart +++ b/lib/shared/routes/app_router.dart @@ -7,6 +7,7 @@ import '../../core/constants/route_constants.dart'; import '../../modules/dashboard/presentation/screens/dashboard_screen.dart'; import '../../modules/assets/presentation/screens/asset_alerts_screen.dart'; import '../../modules/assets/presentation/screens/asset_detail_screen.dart'; +import '../../modules/assets/presentation/screens/asset_form_screen.dart'; import '../../modules/assets/presentation/screens/asset_list_screen.dart'; import '../../modules/assets/presentation/screens/asset_maintenance_screen.dart'; import '../../modules/auth/presentation/screens/change_password_screen.dart'; @@ -310,6 +311,16 @@ final routerProvider = Provider((ref) { path: 'maintenance', builder: (context, state) => const AssetMaintenanceScreen(), ), + GoRoute( + path: 'add', + builder: (context, state) => const AssetFormScreen(), + ), + GoRoute( + path: ':id/edit', + builder: (context, state) => AssetFormScreen( + assetId: state.pathParameters['id']!, + ), + ), GoRoute( path: ':id', builder: (context, state) => diff --git a/lib/shared/widgets/app_data_table.dart b/lib/shared/widgets/app_data_table.dart index 75691fa..402d271 100644 --- a/lib/shared/widgets/app_data_table.dart +++ b/lib/shared/widgets/app_data_table.dart @@ -18,6 +18,7 @@ class AppDataColumn { required this.cellBuilder, this.sortKey, this.flex = 1, + this.width, this.alignment = Alignment.centerLeft, this.padding = EdgeInsets.zero, this.searchText, @@ -28,6 +29,10 @@ class AppDataColumn { final Widget Function(BuildContext context, T row) cellBuilder; final String? sortKey; final int flex; + + /// When set, column uses a fixed width instead of [flex]. + final double? width; + final Alignment alignment; final EdgeInsets padding; @@ -41,6 +46,19 @@ class AppDataColumn { bool get isSearchable => enableSearch ?? searchText != null; } +/// Shared column sizing: fixed [AppDataColumn.width] or flexible [AppDataColumn.flex]. +Widget _appTableColumnSlot({ + required AppDataColumn column, + required Widget child, +}) { + final padded = Padding(padding: column.padding, child: child); + final width = column.width; + if (width != null) { + return SizedBox(width: width, child: padded); + } + return Expanded(flex: column.flex, child: padded); +} + /// Helpers for table cell content — single-line text with ellipsis and tooltip. class AppTableCell { AppTableCell._(); @@ -63,6 +81,54 @@ class AppTableCell { ); } + /// Clickable sequential number / code that navigates to a detail view. + static Widget link( + String? value, { + required VoidCallback? onTap, + TextStyle? style, + String placeholder = '—', + TextAlign? textAlign, + bool underlined = true, + }) { + final display = + (value == null || value.trim().isEmpty) ? placeholder : value.trim(); + if (onTap == null || display == placeholder) { + return text( + display, + style: style, + placeholder: placeholder, + textAlign: textAlign, + ); + } + + return Builder( + builder: (context) { + final theme = Theme.of(context); + final linkStyle = (style ?? theme.textTheme.bodyMedium)?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + decoration: underlined ? TextDecoration.underline : TextDecoration.none, + decorationColor: underlined + ? theme.colorScheme.primary.withValues(alpha: 0.45) + : null, + ); + return MouseRegion( + cursor: SystemMouseCursors.click, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(4), + child: _EllipsisTooltipText( + text: display, + style: linkStyle, + textAlign: textAlign, + showTooltip: true, + ), + ), + ); + }, + ); + } + /// Wraps non-text cell widgets (chips, actions) inside the row height budget. static Widget child(Widget widget) => widget; } @@ -80,6 +146,8 @@ class AppDataTable extends StatefulWidget { this.wrapInCard = true, this.shrinkWrap = false, this.onServerSearchChanged, + this.onEnsureFullDataset, + this.onColumnSearchCleared, }); final List> columns; @@ -94,13 +162,18 @@ class AppDataTable extends StatefulWidget { /// Set true when the table is placed inside another scrollable. final bool shrinkWrap; - /// When set, column filters are sent to the parent for API search instead of - /// filtering only the currently loaded [rows] (current page). - /// - /// The callback receives a normalized query (trimmed). Empty string means - /// clear search and reload the full paginated list. + /// Deprecated: prefer [onEnsureFullDataset] + [onColumnSearchCleared]. + /// Kept so older call sites still compile; ignored for filtering. final ValueChanged? onServerSearchChanged; + /// Called once when the first column filter becomes active. + /// Parent should load all rows (`limit = total`) with no search query. + final Future Function()? onEnsureFullDataset; + + /// Called when all column filters are cleared (or the table is disposed + /// while filters were active). Parent should restore normal page size. + final VoidCallback? onColumnSearchCleared; + @override State> createState() => _AppDataTableState(); } @@ -109,15 +182,21 @@ class _AppDataTableState extends State> { /// Column index → search query (raw, including spaces until applied). final Map _queries = {}; final Map _controllers = {}; - final SearchDebouncer _serverSearchDebouncer = SearchDebouncer(); - int? _lastEditedColumnIndex; - String _lastEmittedServerSearch = ''; + final SearchDebouncer _ensureDatasetDebouncer = SearchDebouncer( + duration: const Duration(milliseconds: 150), + ); + bool _fullDatasetActive = false; + bool _ensureInFlight = false; - bool get _serverSideSearch => widget.onServerSearchChanged != null; + bool get _usesFullDatasetMode => + widget.onEnsureFullDataset != null || widget.onColumnSearchCleared != null; @override void dispose() { - _serverSearchDebouncer.dispose(); + _ensureDatasetDebouncer.dispose(); + if (_fullDatasetActive || _ensureInFlight) { + widget.onColumnSearchCleared?.call(); + } for (final c in _controllers.values) { c.dispose(); } @@ -128,38 +207,48 @@ class _AppDataTableState extends State> { return _controllers.putIfAbsent(index, TextEditingController.new); } - String _composeServerSearch() { - if (_lastEditedColumnIndex != null) { - final latest = (_queries[_lastEditedColumnIndex!] ?? '').trim(); - if (latest.isNotEmpty) return latest; + bool get _hasActiveColumnFilters => + _queries.values.any((q) => q.trim().isNotEmpty); + + Future _syncFullDatasetMode() async { + if (!_usesFullDatasetMode) return; + + if (_hasActiveColumnFilters && !_fullDatasetActive) { + final ensure = widget.onEnsureFullDataset; + if (ensure != null && !_ensureInFlight) { + _ensureInFlight = true; + try { + await ensure(); + if (mounted && _hasActiveColumnFilters) { + setState(() => _fullDatasetActive = true); + } + } finally { + _ensureInFlight = false; + } + } + return; } - for (final entry in _queries.entries) { - final q = entry.value.trim(); - if (q.isNotEmpty) return q; + + if (!_hasActiveColumnFilters && _fullDatasetActive) { + _fullDatasetActive = false; + widget.onColumnSearchCleared?.call(); } - return ''; } void _onColumnQueryChanged(int index, String value) { setState(() { _queries[index] = value; - _lastEditedColumnIndex = index; }); - if (!_serverSideSearch) return; + if (!_usesFullDatasetMode) return; - _serverSearchDebouncer.run(value, (_) { - final composed = _composeServerSearch(); - if (composed == _lastEmittedServerSearch) return; - _lastEmittedServerSearch = composed; - widget.onServerSearchChanged!(composed); + // Debounce ensure/clear so rapid typing doesn't thrash the API. + _ensureDatasetDebouncer.run(value, (_) { + _syncFullDatasetMode(); }); } List get _filteredRows { - // Server-side mode: parent already fetched matching rows from the API. - if (_serverSideSearch) return widget.rows; - final active = {}; for (final entry in _queries.entries) { final q = entry.value.trim().toLowerCase(); @@ -180,9 +269,6 @@ class _AppDataTableState extends State> { }).toList(); } - bool get _hasActiveColumnFilters => - _queries.values.any((q) => q.trim().isNotEmpty); - @override Widget build(BuildContext context) { final showFilterRow = widget.columns.any((c) => c.isSearchable); @@ -228,7 +314,7 @@ class _AppDataTableState extends State> { child: Padding( padding: const EdgeInsets.all(32), child: Text( - _hasActiveColumnFilters || _lastEmittedServerSearch.isNotEmpty + _hasActiveColumnFilters ? widget.noMatchMessage : widget.emptyMessage, style: Theme.of(context).textTheme.bodyLarge?.copyWith( @@ -324,19 +410,16 @@ class _TableHeaderRow extends StatelessWidget { children: [ for (var i = 0; i < columns.length; i++) ...[ if (i > 0) const SizedBox(width: kAppTableColumnGap), - Expanded( - flex: columns[i].flex, - child: Padding( - padding: columns[i].padding, - child: Align( - alignment: columns[i].alignment, - child: _buildHeaderCell( - theme: theme, - col: columns[i], - sortColumn: sortColumn, - sortAscending: sortAscending, - onSort: onSort, - ), + _appTableColumnSlot( + column: columns[i], + child: Align( + alignment: columns[i].alignment, + child: _buildHeaderCell( + theme: theme, + col: columns[i], + sortColumn: sortColumn, + sortAscending: sortAscending, + onSort: onSort, ), ), ), @@ -430,18 +513,15 @@ class _TableFilterRow extends StatelessWidget { children: [ for (var i = 0; i < columns.length; i++) ...[ if (i > 0) const SizedBox(width: kAppTableColumnGap), - Expanded( - flex: columns[i].flex, - child: Padding( - padding: columns[i].padding, - child: columns[i].isSearchable - ? _ColumnSearchField( - controller: controllerFor(i), - query: queryFor(i), - onChanged: (v) => onQueryChanged(i, v), - ) - : const SizedBox.shrink(), - ), + _appTableColumnSlot( + column: columns[i], + child: columns[i].isSearchable + ? _ColumnSearchField( + controller: controllerFor(i), + query: queryFor(i), + onChanged: (v) => onQueryChanged(i, v), + ) + : const SizedBox.shrink(), ), ], ], @@ -584,21 +664,18 @@ class _TableDataRow extends StatelessWidget { ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( + child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ for (var i = 0; i < columns.length; i++) ...[ if (i > 0) const SizedBox(width: kAppTableColumnGap), - Expanded( - flex: columns[i].flex, - child: Padding( - padding: columns[i].padding, - child: Align( + _appTableColumnSlot( + column: columns[i], + child: Align( + alignment: columns[i].alignment, + child: _TableCellSlot( alignment: columns[i].alignment, - child: _TableCellSlot( - alignment: columns[i].alignment, - child: columns[i].cellBuilder(context, row), - ), + child: columns[i].cellBuilder(context, row), ), ), ), diff --git a/lib/shared/widgets/app_date_popup.dart b/lib/shared/widgets/app_date_popup.dart index 67019e7..0db35e9 100644 --- a/lib/shared/widgets/app_date_popup.dart +++ b/lib/shared/widgets/app_date_popup.dart @@ -45,14 +45,33 @@ class _AppDateDialogState extends State<_AppDateDialog> { @override void initState() { super.initState(); - _selected = DateTime( - widget.initialDate.year, - widget.initialDate.month, - widget.initialDate.day, + final clamped = _clampDate( + DateTime( + widget.initialDate.year, + widget.initialDate.month, + widget.initialDate.day, + ), ); + _selected = clamped; _displayedMonth = DateTime(_selected.year, _selected.month); } + DateTime _clampDate(DateTime d) { + final first = DateTime( + widget.firstDate.year, + widget.firstDate.month, + widget.firstDate.day, + ); + final last = DateTime( + widget.lastDate.year, + widget.lastDate.month, + widget.lastDate.day, + ); + if (d.isBefore(first)) return first; + if (d.isAfter(last)) return last; + return d; + } + void _shiftMonth(int delta) { setState(() { _displayedMonth = DateTime( @@ -106,7 +125,7 @@ class _AppDateDialogState extends State<_AppDateDialog> { firstDate: widget.firstDate, lastDate: widget.lastDate, selected: _selected, - onSelected: (day) => setState(() => _selected = day), + onSelected: (day) => setState(() => _selected = _clampDate(day)), ), const SizedBox(height: 16), Row( diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart index 2151155..346e547 100644 --- a/lib/shared/widgets/app_shell.dart +++ b/lib/shared/widgets/app_shell.dart @@ -59,10 +59,14 @@ class _AppShellState extends ConsumerState { : []; if (context.isMobile) { + final companyName = + ref.watch(appSettingsProvider).companyProfile.companyName.trim(); return Scaffold( key: _scaffoldKey, appBar: AppBar( - title: const Text(AppConstants.appName), + title: Text( + companyName.isNotEmpty ? companyName : AppConstants.appName, + ), leading: IconButton( icon: const Icon(Icons.menu), onPressed: () => _scaffoldKey.currentState?.openDrawer(), diff --git a/lib/shared/widgets/app_side_panel.dart b/lib/shared/widgets/app_side_panel.dart index ea2c070..f4fc7ac 100644 --- a/lib/shared/widgets/app_side_panel.dart +++ b/lib/shared/widgets/app_side_panel.dart @@ -452,6 +452,7 @@ class SidePanelSection extends StatelessWidget { child is FormRow || child is FormRowThree || child is FormRowFour || + child is ResponsiveFormGrid || child is QuickAddInlineHost; } @@ -570,6 +571,9 @@ class FormRowThree extends StatelessWidget { } /// Responsive row with up to four equal-width form fields. +/// +/// Columns by width: 4 (large) → 3 (medium) → 2 (small) → 1 (xs). +/// When [spans] is set, falls back to a fixed 4-col [FormRow]. class FormRowFour extends StatelessWidget { const FormRowFour({ super.key, @@ -588,37 +592,48 @@ class FormRowFour extends StatelessWidget { @override Widget build(BuildContext context) { - return FormRow( - columnCount: 4, - spans: spans, + if (spans != null) { + return FormRow( + columnCount: 4, + spans: spans, + spacing: spacing, + horizontalPadding: horizontalPadding, + stackBelowWidth: stackBelowWidth, + children: children, + ); + } + + return ResponsiveFormGrid( spacing: spacing, - horizontalPadding: horizontalPadding, - stackBelowWidth: stackBelowWidth, children: children, ); } } -/// Responsive form grid: 4 cols (medium+), 2 cols (small). +/// Responsive form grid: 4 / 3 / 2 / 1 columns by viewport width. class ResponsiveFormGrid extends StatelessWidget { const ResponsiveFormGrid({ super.key, required this.children, this.fullWidthChildren = const [], this.spacing = 12, + this.xsColumns = 1, this.smallColumns = 2, - this.mediumColumns = 4, + this.mediumColumns = 3, this.largeColumns = 4, - this.mediumBreakpoint = AppBreakpoints.tablet, - this.largeBreakpoint = AppBreakpoints.desktop, + this.smallBreakpoint = AppBreakpoints.formSmall, + this.mediumBreakpoint = AppBreakpoints.formMedium, + this.largeBreakpoint = AppBreakpoints.formLarge, }); final List children; final List fullWidthChildren; final double spacing; + final int xsColumns; final int smallColumns; final int mediumColumns; final int largeColumns; + final double smallBreakpoint; final double mediumBreakpoint; final double largeBreakpoint; @@ -634,53 +649,59 @@ class ResponsiveFormGrid extends StatelessWidget { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth.isFinite && constraints.maxWidth > 0 - ? constraints.maxWidth - : MediaQuery.sizeOf(context).width; - final columns = formGridColumnsForWidth( - width, - smallColumns: smallColumns, - mediumColumns: mediumColumns, - largeColumns: largeColumns, - mediumBreakpoint: mediumBreakpoint, - largeBreakpoint: largeBreakpoint, - ); - final rows = _chunk(children, columns); - final columnWidth = (width - (columns - 1) * spacing) / columns; + return QuickAddInlineHost( + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth.isFinite && constraints.maxWidth > 0 + ? constraints.maxWidth + : MediaQuery.sizeOf(context).width; + final columns = formGridColumnsForWidth( + width, + xsColumns: xsColumns, + smallColumns: smallColumns, + mediumColumns: mediumColumns, + largeColumns: largeColumns, + smallBreakpoint: smallBreakpoint, + mediumBreakpoint: mediumBreakpoint, + largeBreakpoint: largeBreakpoint, + ); + final rows = _chunk(children, columns); + final columnWidth = columns <= 0 + ? width + : (width - (columns - 1) * spacing) / columns; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final row in rows) - Padding( - padding: EdgeInsets.only(bottom: spacing), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var col = 0; col < columns; col++) ...[ - if (col > 0) SizedBox(width: spacing), - SizedBox( - width: columnWidth, - child: col < row.length - ? row[col] - : const SizedBox.shrink(), - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final row in rows) + Padding( + padding: EdgeInsets.only(bottom: spacing), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var col = 0; col < columns; col++) ...[ + if (col > 0) SizedBox(width: spacing), + SizedBox( + width: columnWidth, + child: col < row.length + ? QuickAddBlockable(child: row[col]) + : const SizedBox.shrink(), + ), + ], ], - ], + ), ), - ), - for (var i = 0; i < fullWidthChildren.length; i++) - Padding( - padding: EdgeInsets.only( - bottom: i == fullWidthChildren.length - 1 ? 0 : spacing, + for (var i = 0; i < fullWidthChildren.length; i++) + Padding( + padding: EdgeInsets.only( + bottom: i == fullWidthChildren.length - 1 ? 0 : spacing, + ), + child: QuickAddBlockable(child: fullWidthChildren[i]), ), - child: fullWidthChildren[i], - ), - ], - ); - }, + ], + ); + }, + ), ); } } diff --git a/lib/shared/widgets/app_table_action_icon.dart b/lib/shared/widgets/app_table_action_icon.dart index 92cd02c..c1ff65b 100644 --- a/lib/shared/widgets/app_table_action_icon.dart +++ b/lib/shared/widgets/app_table_action_icon.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; class _TableActionInkWell extends StatelessWidget { @@ -14,7 +16,7 @@ class _TableActionInkWell extends StatelessWidget { final theme = Theme.of(context); return Material( - color: Colors.transparent, + type: MaterialType.transparency, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(6), @@ -52,6 +54,8 @@ class AppTableActionIcon extends StatelessWidget { return Tooltip( message: tooltip, + waitDuration: const Duration(milliseconds: 400), + preferBelow: true, child: _TableActionInkWell( onTap: enabled ? onPressed : null, child: Padding( @@ -63,7 +67,27 @@ class AppTableActionIcon extends StatelessWidget { } } +/// Ensures only one row's action chip is expanded at a time (fast hover). +class _AppTableActionsGate { + static _AppTableActionsState? _active; + + static void claim(_AppTableActionsState next) { + final prev = _active; + if (prev != null && !identical(prev, next)) { + prev._forceClose(releaseGate: false); + } + _active = next; + } + + static void release(_AppTableActionsState state) { + if (identical(_active, state)) _active = null; + } +} + /// Collapsed three-dot trigger that expands inline action icons on hover (or tap). +/// +/// Expanded icons are shown in an [Overlay] so they stay hoverable/clickable +/// even when they paint over neighboring columns (status, etc.). class AppTableActions extends StatefulWidget { const AppTableActions({ super.key, @@ -80,86 +104,209 @@ class AppTableActions extends StatefulWidget { State createState() => _AppTableActionsState(); } -/// Room for expanded icons to grow left of the ⋮ trigger without clipping. -const double _kExpandedActionsOverflow = 108; - class _AppTableActionsState extends State { + final LayerLink _link = LayerLink(); + final OverlayPortalController _portalController = OverlayPortalController(); + bool _hovering = false; bool _pinned = false; + Timer? _closeTimer; bool get _expanded => _hovering || _pinned; + @override + void dispose() { + _closeTimer?.cancel(); + _AppTableActionsGate.release(this); + if (_portalController.isShowing) { + _portalController.hide(); + } + super.dispose(); + } + + void _forceClose({bool releaseGate = true}) { + _closeTimer?.cancel(); + _pinned = false; + _hovering = false; + if (_portalController.isShowing) { + _portalController.hide(); + } + if (releaseGate) _AppTableActionsGate.release(this); + if (mounted) setState(() {}); + } + + void _open() { + _AppTableActionsGate.claim(this); + _closeTimer?.cancel(); + if (!_hovering) { + setState(() => _hovering = true); + } + if (!_portalController.isShowing) { + _portalController.show(); + } + } + + void _onEnter() => _open(); + + void _onExit() { + if (_pinned) return; + _closeTimer?.cancel(); + // Brief delay so the pointer can move from the cell trigger into the overlay chip. + _closeTimer = Timer(const Duration(milliseconds: 80), () { + if (!mounted || _pinned) return; + // Another row may have claimed the gate already. + if (!identical(_AppTableActionsGate._active, this)) return; + setState(() => _hovering = false); + if (_portalController.isShowing) { + _portalController.hide(); + } + _AppTableActionsGate.release(this); + }); + } + + void _togglePinned() { + _closeTimer?.cancel(); + if (_pinned) { + _forceClose(); + return; + } + _AppTableActionsGate.claim(this); + setState(() { + _pinned = true; + _hovering = true; + }); + if (!_portalController.isShowing) { + _portalController.show(); + } + } + + Widget _buildChip({required bool expanded, required ColorScheme scheme}) { + final iconColor = scheme.onSurfaceVariant; + + return Material( + type: MaterialType.transparency, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + padding: EdgeInsets.symmetric( + horizontal: expanded ? 4 : 0, + vertical: expanded ? 2 : 0, + ), + decoration: BoxDecoration( + color: expanded ? scheme.surface : Colors.transparent, + borderRadius: BorderRadius.circular(8), + boxShadow: expanded + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 10, + offset: const Offset(0, 2), + ), + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ] + : const [], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: Alignment.centerRight, + clipBehavior: Clip.none, + child: expanded + ? Row( + mainAxisSize: MainAxisSize.min, + children: widget.children, + ) + : const SizedBox.shrink(), + ), + Tooltip( + message: 'Actions', + waitDuration: const Duration(milliseconds: 400), + preferBelow: true, + child: _TableActionInkWell( + onTap: _togglePinned, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(Icons.more_vert, size: 18, color: iconColor), + ), + ), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final children = widget.children; if (children.isEmpty) return const SizedBox.shrink(); - final iconColor = Theme.of(context).colorScheme.onSurfaceVariant; + final scheme = Theme.of(context).colorScheme; - final actionRow = Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - AnimatedSize( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - alignment: Alignment.centerRight, - clipBehavior: Clip.none, - child: _expanded - ? Row( - mainAxisSize: MainAxisSize.min, - children: children, - ) - : const SizedBox.shrink(), - ), - Tooltip( - message: 'Actions', - child: _TableActionInkWell( - onTap: () => setState(() => _pinned = !_pinned), - child: Padding( - padding: const EdgeInsets.all(6), - child: Icon(Icons.more_vert, size: 18, color: iconColor), + final portal = OverlayPortal( + controller: _portalController, + overlayChildBuilder: (context) { + // Overlay gives full-screen constraints; shrink-wrap to the chip only. + return CompositedTransformFollower( + link: _link, + showWhenUnlinked: false, + targetAnchor: Alignment.centerRight, + followerAnchor: Alignment.centerRight, + child: UnconstrainedBox( + alignment: Alignment.centerRight, + child: TapRegion( + onTapOutside: (_) { + if (_pinned) _forceClose(); + }, + child: MouseRegion( + onEnter: (_) => _onEnter(), + onExit: (_) => _onExit(), + child: _buildChip(expanded: true, scheme: scheme), + ), + ), + ), + ); + }, + child: CompositedTransformTarget( + link: _link, + child: MouseRegion( + onEnter: (_) => _onEnter(), + onExit: (_) => _onExit(), + // Keep a stable hit target; hide when overlay chip is showing. + child: Opacity( + opacity: _expanded ? 0 : 1, + child: IgnorePointer( + ignoring: _expanded, + child: _buildChip(expanded: false, scheme: scheme), ), ), ), - ], - ); - - final interactive = TapRegion( - onTapOutside: (_) { - if (_pinned) setState(() => _pinned = false); - }, - child: MouseRegion( - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - child: actionRow, ), ); - if (!widget.expandHitArea) return interactive; + if (!widget.expandHitArea) return portal; return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; - if (!width.isFinite || width <= 0) return interactive; + if (!width.isFinite || width <= 0) return portal; return SizedBox( width: width, - child: OverflowBox( - maxWidth: width + _kExpandedActionsOverflow, - alignment: Alignment.centerRight, - child: TapRegion( - onTapOutside: (_) { - if (_pinned) setState(() => _pinned = false); - }, - child: MouseRegion( - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - child: Align( - alignment: Alignment.centerRight, - child: actionRow, - ), - ), + child: MouseRegion( + onEnter: (_) => _onEnter(), + onExit: (_) => _onExit(), + child: Align( + alignment: Alignment.centerRight, + child: portal, ), ), );