From ad1c95d85d45f9fa5b2b7b7d843c9ac72de34ffa Mon Sep 17 00:00:00 2001 From: SurendarSuri30 Date: Thu, 9 Jul 2026 11:03:44 +0530 Subject: [PATCH] asset module type masters api integration --- lib/core/config/app_bootstrap.dart | 18 +- lib/core/config/dev_config.dart | 12 +- lib/core/config/environment.dart | 6 +- lib/core/constants/api_endpoints.dart | 10 + lib/core/network/dio_client.dart | 11 +- .../datasources/asset_remote_data_source.dart | 107 ++++-- .../repositories/asset_repository_impl.dart | 59 +++- .../domain/repositories/asset_repository.dart | 15 +- .../asset_form_lookups_provider.dart | 187 +++++++++- .../screens/asset_list_screen.dart | 44 ++- .../widgets/asset_form_panel.dart | 331 ++++++++++++++++-- .../widgets/asset_side_panels.dart | 192 ++++++---- .../providers/master_provider.dart | 15 +- .../master_remote_data_source.dart | 56 +-- lib/shared/models/asset_model.dart | 182 ++++++++-- pubspec.yaml | 3 - 16 files changed, 1012 insertions(+), 236 deletions(-) diff --git a/lib/core/config/app_bootstrap.dart b/lib/core/config/app_bootstrap.dart index 5de3623..17f3f09 100644 --- a/lib/core/config/app_bootstrap.dart +++ b/lib/core/config/app_bootstrap.dart @@ -15,7 +15,7 @@ Future startApp() async { if (kIsWeb) { usePathUrlStrategy(); } - await dotenv.load(fileName: Environment.envFileName); + await _loadEnv(); final prefs = await SharedPreferences.getInstance(); FaviconStore(prefs).apply(); @@ -28,3 +28,19 @@ Future startApp() async { ), ); } + +/// Loads flavor env file; falls back to `.env` and never blocks app startup. +/// API URL still resolves via [Environment.apiBaseUrl] flavor defaults when unset. +Future _loadEnv() async { + try { + await dotenv.load(fileName: Environment.envFileName, isOptional: true); + if (dotenv.env.isNotEmpty) return; + + await dotenv.load(fileName: '.env', isOptional: true); + } catch (_) { + // Ensure dotenv is initialized even if both asset loads fail. + if (!dotenv.isInitialized) { + dotenv.testLoad(fileInput: ''); + } + } +} diff --git a/lib/core/config/dev_config.dart b/lib/core/config/dev_config.dart index e417886..6d820a1 100644 --- a/lib/core/config/dev_config.dart +++ b/lib/core/config/dev_config.dart @@ -8,8 +8,10 @@ class DevConfig { /// Never enabled in release builds. static bool get bypassAuth { if (kReleaseMode) return false; - final env = dotenv.env['DEV_BYPASS_AUTH']; - if (env != null) return env.toLowerCase() == 'true'; + if (dotenv.isInitialized) { + final env = dotenv.env['DEV_BYPASS_AUTH']; + if (env != null) return env.toLowerCase() == 'true'; + } return kDebugMode; } @@ -18,8 +20,10 @@ class DevConfig { static bool get screenPreviewEnabled { if (kReleaseMode) return false; if (bypassAuth) return true; - final env = dotenv.env['DEV_SCREEN_PREVIEW']; - if (env != null) return env.toLowerCase() == 'true'; + if (dotenv.isInitialized) { + final env = dotenv.env['DEV_SCREEN_PREVIEW']; + if (env != null) return env.toLowerCase() == 'true'; + } return kDebugMode; } diff --git a/lib/core/config/environment.dart b/lib/core/config/environment.dart index 4c42694..8eb71a4 100644 --- a/lib/core/config/environment.dart +++ b/lib/core/config/environment.dart @@ -42,8 +42,10 @@ class Environment { const fromDefine = String.fromEnvironment('API_BASE_URL'); if (fromDefine.isNotEmpty) return fromDefine; - final fromEnvFile = dotenv.env['API_BASE_URL']; - if (fromEnvFile != null && fromEnvFile.isNotEmpty) return fromEnvFile; + if (dotenv.isInitialized) { + final fromEnvFile = dotenv.env['API_BASE_URL']; + if (fromEnvFile != null && fromEnvFile.isNotEmpty) return fromEnvFile; + } return switch (flavor) { Flavor.dev => 'https://demo.venbait.in/api/v1', diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index 7de2499..2dd6d1e 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -113,6 +113,16 @@ class ApiEndpoints { static String assetById(String id) => '/assets/$id'; static String assetTransfer(String id) => '/assets/$id/transfer'; static String assetTransferHistory(String id) => '/assets/$id/transfer-history'; + static const String assetOptions = '/assets/options'; + static const String assetContractTypes = '/assets/contract-types'; + static const String assetVisitTypes = '/assets/visit-types'; + static const String assetPolicyTypes = '/assets/policy-types'; + static const String assetConditions = '/assets/conditions'; + static const String assetStatuses = '/assets/statuses'; + static const String assetPaymentFrequencies = '/assets/payment-frequencies'; + static const String assetServiceFrequencies = '/assets/service-frequencies'; + static const String assetVisitStatuses = '/assets/visit-statuses'; + static const String assetVisitConditionsAfter = '/assets/visit-conditions-after'; static const String assetDepreciationMethods = '/assets/depreciation-methods'; static const String assetDepreciationCalculate = '/assets/depreciation/calculate'; static const String assetAlertsExpiry = '/assets/alerts/expiry'; diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index 80ebbe5..455048d 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -8,15 +8,14 @@ import 'auth_interceptor.dart'; import 'error_interceptor.dart'; final dioProvider = Provider((ref) { + final timeoutSeconds = dotenv.isInitialized + ? int.tryParse(dotenv.env['API_TIMEOUT_SECONDS'] ?? '30') ?? 30 + : 30; final dio = Dio( BaseOptions( baseUrl: Environment.apiBaseUrl, - connectTimeout: Duration( - seconds: int.tryParse(dotenv.env['API_TIMEOUT_SECONDS'] ?? '30') ?? 30, - ), - receiveTimeout: Duration( - seconds: int.tryParse(dotenv.env['API_TIMEOUT_SECONDS'] ?? '30') ?? 30, - ), + connectTimeout: Duration(seconds: timeoutSeconds), + receiveTimeout: Duration(seconds: timeoutSeconds), headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', diff --git a/lib/modules/assets/data/datasources/asset_remote_data_source.dart b/lib/modules/assets/data/datasources/asset_remote_data_source.dart index d0bf615..1b12529 100644 --- a/lib/modules/assets/data/datasources/asset_remote_data_source.dart +++ b/lib/modules/assets/data/datasources/asset_remote_data_source.dart @@ -58,9 +58,80 @@ class AssetRemoteDataSource { return _parseList(response.data, AssetCategoryModel.fromJson); } - Future> getDepreciationMethods() async { + Future getAssetOptions() async { + final response = await dio.get(ApiEndpoints.assetOptions); + final data = response.data; + if (data is Map) { + final raw = data['data']; + if (raw is Map) { + return AssetDropdownOptionsModel.fromJson( + Map.from(raw), + ); + } + } + return const AssetDropdownOptionsModel(); + } + + Future> getContractTypes() async { + final response = await dio.get(ApiEndpoints.assetContractTypes); + return _parseDropdownOptions(response.data); + } + + Future> getVisitTypes() async { + final response = await dio.get(ApiEndpoints.assetVisitTypes); + return _parseDropdownOptions(response.data); + } + + Future> getPolicyTypes() async { + final response = await dio.get(ApiEndpoints.assetPolicyTypes); + return _parseDropdownOptions(response.data); + } + + Future> getConditions() async { + final response = await dio.get(ApiEndpoints.assetConditions); + return _parseDropdownOptions(response.data); + } + + Future> getStatuses() async { + final response = await dio.get(ApiEndpoints.assetStatuses); + return _parseDropdownOptions(response.data); + } + + Future> getPaymentFrequencies() async { + final response = await dio.get(ApiEndpoints.assetPaymentFrequencies); + return _parseDropdownOptions(response.data); + } + + Future> getServiceFrequencies() async { + final response = await dio.get(ApiEndpoints.assetServiceFrequencies); + return _parseDropdownOptions(response.data); + } + + Future> getVisitStatuses() async { + final response = await dio.get(ApiEndpoints.assetVisitStatuses); + return _parseDropdownOptions(response.data); + } + + Future> getVisitConditionsAfter() async { + final response = await dio.get(ApiEndpoints.assetVisitConditionsAfter); + return _parseDropdownOptions(response.data); + } + + Future> getDepreciationMethods() async { final response = await dio.get(ApiEndpoints.assetDepreciationMethods); - return _parseStringOptions(response.data); + return _parseDropdownOptions(response.data); + } + + Future calculateDepreciationPreview( + Map data, + ) async { + final response = await dio.post( + ApiEndpoints.assetDepreciationCalculate, + data: data, + ); + return AssetDepreciationPreviewModel.fromJson( + response.data['data'] as Map, + ); } Future> getExpiryAlerts({ @@ -285,28 +356,22 @@ class AssetRemoteDataSource { ); } - List _parseStringOptions(dynamic body) { - if (body is! Map) return const []; + List _parseDropdownOptions(dynamic body) { + if (body is! Map) return const []; final raw = body['data']; - final list = raw is List - ? raw - : raw is Map - ? raw['items'] as List? ?? const [] - : const []; + final List list; + if (raw is List) { + list = raw; + } else if (raw is Map) { + final items = raw['items']; + list = items is List ? items : const []; + } else { + list = const []; + } return list - .map((item) { - if (item is String) return item.trim(); - if (item is Map) { - final value = - item['value']?.toString() ?? - item['code']?.toString() ?? - item['id']?.toString(); - return value?.trim() ?? ''; - } - return ''; - }) - .where((value) => value.isNotEmpty) + .map(AssetDropdownOption.fromDynamic) + .where((option) => option.value.isNotEmpty) .toList(); } } diff --git a/lib/modules/assets/data/repositories/asset_repository_impl.dart b/lib/modules/assets/data/repositories/asset_repository_impl.dart index 3e478e8..ac56110 100644 --- a/lib/modules/assets/data/repositories/asset_repository_impl.dart +++ b/lib/modules/assets/data/repositories/asset_repository_impl.dart @@ -61,10 +61,67 @@ class AssetRepositoryImpl implements AssetRepository { } @override - Future>> getDepreciationMethods() { + Future> getAssetOptions() { + return safeApiCall(() => dataSource.getAssetOptions()); + } + + @override + Future>> getContractTypes() { + return safeApiCall(() => dataSource.getContractTypes()); + } + + @override + Future>> getVisitTypes() { + return safeApiCall(() => dataSource.getVisitTypes()); + } + + @override + Future>> getPolicyTypes() { + return safeApiCall(() => dataSource.getPolicyTypes()); + } + + @override + Future>> getConditions() { + return safeApiCall(() => dataSource.getConditions()); + } + + @override + Future>> getStatuses() { + return safeApiCall(() => dataSource.getStatuses()); + } + + @override + Future>> getPaymentFrequencies() { + return safeApiCall(() => dataSource.getPaymentFrequencies()); + } + + @override + Future>> getServiceFrequencies() { + return safeApiCall(() => dataSource.getServiceFrequencies()); + } + + @override + Future>> getVisitStatuses() { + return safeApiCall(() => dataSource.getVisitStatuses()); + } + + @override + Future>> getVisitConditionsAfter() { + return safeApiCall(() => dataSource.getVisitConditionsAfter()); + } + + @override + Future>> getDepreciationMethods() { return safeApiCall(() => dataSource.getDepreciationMethods()); } + @override + Future> calculateDepreciationPreview( + Map data, + ) { + return safeApiCall(() => dataSource.calculateDepreciationPreview(data)); + } + @override Future>> getExpiryAlerts({ int? days, diff --git a/lib/modules/assets/domain/repositories/asset_repository.dart b/lib/modules/assets/domain/repositories/asset_repository.dart index 9890719..f64b230 100644 --- a/lib/modules/assets/domain/repositories/asset_repository.dart +++ b/lib/modules/assets/domain/repositories/asset_repository.dart @@ -11,7 +11,20 @@ abstract class AssetRepository { Future> transferAsset(String id, Map data); Future>> getTransferHistory(String assetId); Future>> getCategories(); - Future>> getDepreciationMethods(); + Future> getAssetOptions(); + Future>> getContractTypes(); + Future>> getVisitTypes(); + Future>> getPolicyTypes(); + Future>> getConditions(); + Future>> getStatuses(); + Future>> getPaymentFrequencies(); + Future>> getServiceFrequencies(); + Future>> getVisitStatuses(); + Future>> getVisitConditionsAfter(); + Future>> getDepreciationMethods(); + Future> calculateDepreciationPreview( + Map data, + ); Future>> getExpiryAlerts({ int? days, String? type, 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 3c419c3..100c0de 100644 --- a/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart +++ b/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart @@ -1,16 +1,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/app_constants.dart'; +import '../../../../core/network/api_handler.dart'; +import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/user_management_models.dart'; +import '../../../../shared/models/vendor_model.dart'; +import '../../../../shared/widgets/app_dropdown.dart'; import '../../../assets/data/repositories/asset_repository_impl.dart'; import '../../../grn/data/repositories/grn_repository_impl.dart'; import '../../../masters/data/datasources/master_remote_data_source.dart'; import '../../../purchase_orders/data/repositories/purchase_order_repository_impl.dart'; import '../../../users/presentation/providers/users_provider.dart'; import '../../../vendors/data/repositories/vendor_repository_impl.dart'; -import '../../../../shared/models/vendor_model.dart'; class AssetFormLookups { const AssetFormLookups({ @@ -21,7 +24,7 @@ class AssetFormLookups { this.users = const [], this.purchaseOrders = const [], this.grns = const [], - this.depreciationMethods = const [], + this.options = const AssetDropdownOptionsModel(), }); final List plants; @@ -31,7 +34,19 @@ class AssetFormLookups { final List users; final List purchaseOrders; final List grns; - final List depreciationMethods; + final AssetDropdownOptionsModel options; + + List get depreciationMethods => options.depreciationMethods; + List get conditions => options.conditions; + List get statuses => options.statuses; + List get contractTypes => options.contractTypes; + List get visitTypes => options.visitTypes; + List get policyTypes => options.policyTypes; + List get paymentFrequencies => options.paymentFrequencies; + List get serviceFrequencies => options.serviceFrequencies; + List get visitStatuses => options.visitStatuses; + List get visitConditionsAfter => + options.visitConditionsAfter; } final assetFormLookupsProvider = @@ -45,7 +60,7 @@ final assetFormLookupsProvider = final users = await _safeUserOptions(ref); final purchaseOrders = await _safePurchaseOrderOptions(ref); final grns = await _safeGrnOptions(ref); - final depreciationMethods = await _safeDepreciationMethods(ref); + final options = await _safeAssetOptions(ref); return AssetFormLookups( plants: plants, @@ -55,10 +70,15 @@ final assetFormLookupsProvider = users: users, purchaseOrders: purchaseOrders, grns: grns, - depreciationMethods: depreciationMethods, + options: options, ); }); +final assetDropdownOptionsProvider = + FutureProvider.autoDispose((ref) async { + return _safeAssetOptions(ref); +}); + final assetGrnItemsProvider = FutureProvider.autoDispose.family, int?>( (ref, grnId) async { @@ -187,16 +207,155 @@ Future> _safeGrnOptions(Ref ref) async { } } -Future> _safeDepreciationMethods(Ref ref) async { +Future _safeAssetOptions(Ref ref) async { try { - final result = await ref.read(assetRepositoryProvider).getDepreciationMethods(); - if (result.failure != null || result.data == null) return const []; - return result.data! - .map((method) => method.trim().toUpperCase()) - .where((method) => method.isNotEmpty) - .toSet() - .toList(); + final result = await ref.read(assetRepositoryProvider).getAssetOptions(); + if (result.failure != null || result.data == null) { + return _fallbackAssetOptions(ref); + } + + final options = result.data!; + if (!_hasAnyOption(options)) { + return _fallbackAssetOptions(ref); + } + + // Combined /assets/options currently omits depreciation_methods — fill gaps. + return _fillMissingOptionLists(ref, options); } catch (_) { - return const []; + return _fallbackAssetOptions(ref); } } + +Future _fillMissingOptionLists( + Ref ref, + AssetDropdownOptionsModel options, +) async { + final repo = ref.read(assetRepositoryProvider); + + Future> loadIfEmpty( + List current, + Future>> Function() call, + ) async { + if (current.isNotEmpty) return current; + try { + final result = await call(); + if (result.failure != null || result.data == null) return current; + return result.data!; + } catch (_) { + return current; + } + } + + final filled = await Future.wait([ + loadIfEmpty(options.contractTypes, repo.getContractTypes), + loadIfEmpty(options.visitTypes, repo.getVisitTypes), + loadIfEmpty(options.policyTypes, repo.getPolicyTypes), + loadIfEmpty(options.conditions, repo.getConditions), + loadIfEmpty(options.statuses, repo.getStatuses), + loadIfEmpty(options.paymentFrequencies, repo.getPaymentFrequencies), + loadIfEmpty(options.serviceFrequencies, repo.getServiceFrequencies), + loadIfEmpty(options.visitStatuses, repo.getVisitStatuses), + loadIfEmpty(options.visitConditionsAfter, repo.getVisitConditionsAfter), + loadIfEmpty(options.depreciationMethods, repo.getDepreciationMethods), + ]); + + return AssetDropdownOptionsModel( + contractTypes: filled[0], + visitTypes: filled[1], + policyTypes: filled[2], + conditions: filled[3], + statuses: filled[4], + paymentFrequencies: filled[5], + serviceFrequencies: filled[6], + visitStatuses: filled[7], + visitConditionsAfter: filled[8], + depreciationMethods: filled[9], + ); +} + +bool _hasAnyOption(AssetDropdownOptionsModel options) { + return options.contractTypes.isNotEmpty || + options.visitTypes.isNotEmpty || + options.policyTypes.isNotEmpty || + options.conditions.isNotEmpty || + options.statuses.isNotEmpty || + options.paymentFrequencies.isNotEmpty || + options.serviceFrequencies.isNotEmpty || + options.visitStatuses.isNotEmpty || + options.visitConditionsAfter.isNotEmpty || + options.depreciationMethods.isNotEmpty; +} + +/// Falls back to individual type endpoints when the combined call is empty/unavailable. +Future _fallbackAssetOptions(Ref ref) async { + final repo = ref.read(assetRepositoryProvider); + + Future> load( + Future>> Function() call, + ) async { + try { + final result = await call(); + if (result.failure != null || result.data == null) return const []; + return result.data!; + } catch (_) { + return const []; + } + } + + final results = await Future.wait([ + load(repo.getContractTypes), + load(repo.getVisitTypes), + load(repo.getPolicyTypes), + load(repo.getConditions), + load(repo.getStatuses), + load(repo.getPaymentFrequencies), + load(repo.getServiceFrequencies), + load(repo.getVisitStatuses), + load(repo.getVisitConditionsAfter), + load(repo.getDepreciationMethods), + ]); + + return AssetDropdownOptionsModel( + contractTypes: results[0], + visitTypes: results[1], + policyTypes: results[2], + conditions: results[3], + statuses: results[4], + paymentFrequencies: results[5], + serviceFrequencies: results[6], + visitStatuses: results[7], + visitConditionsAfter: results[8], + depreciationMethods: results[9], + ); +} + +List> assetOptionDropdowns( + List options, +) { + return options + .map( + (option) => AppDropdownOption( + value: option.value, + label: option.label, + ), + ) + .toList(); +} + +/// Keeps [current] when still valid; otherwise uses the first API option. +String? resolveAssetOptionValue( + String? current, + List options, { + bool preferFirstWhenEmpty = false, +}) { + if (options.isEmpty) return current; + if (current != null && + current.isNotEmpty && + options.any((option) => option.value == current)) { + return current; + } + if (preferFirstWhenEmpty || current == null || current.isEmpty) { + return options.first.value; + } + return current; +} diff --git a/lib/modules/assets/presentation/screens/asset_list_screen.dart b/lib/modules/assets/presentation/screens/asset_list_screen.dart index df9a36c..cec825c 100644 --- a/lib/modules/assets/presentation/screens/asset_list_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_list_screen.dart @@ -23,6 +23,7 @@ import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.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'; @@ -68,10 +69,19 @@ class _AssetListScreenState extends ConsumerState { final filteredAssets = _filterAssets(state.assets); final categories = _categoryOptions(state.assets, allCategories); final plants = _plantOptions(state.assets); - final statuses = _statusOptions(); + final statuses = _statusOptions( + ref.watch(assetFormLookupsProvider).valueOrNull?.statuses ?? const [], + ); final categoryFilter = _selectedCategory ?? 'All categories'; final plantFilter = _selectedPlant ?? 'All plants'; final statusFilter = _selectedStatus ?? 'All statuses'; + final statusLabels = { + for (final option + in (ref.watch(assetFormLookupsProvider).valueOrNull?.statuses ?? + const [])) + option.value: option.label, + 'All statuses': 'All statuses', + }; final page = state.query.page; final pageSize = state.query.limit; final total = state.total; @@ -138,6 +148,7 @@ class _AssetListScreenState extends ConsumerState { categories: categories, plants: plants, statuses: statuses, + statusLabels: statusLabels, onSearch: ref.read(assetsListProvider.notifier).setSearch, onCategoryChanged: (value) { @@ -229,8 +240,8 @@ class _AssetListScreenState extends ConsumerState { return false; } if (_selectedStatus != null) { - final label = assetStatusDisplay(asset.status).label; - if (label != _selectedStatus) return false; + final status = asset.status?.trim().toUpperCase().replaceAll(' ', '_'); + if (status != _selectedStatus) return false; } return true; }).toList(); @@ -263,15 +274,12 @@ class _AssetListScreenState extends ConsumerState { return ['All plants', ...names]; } - List _statusOptions() { - return [ - 'All statuses', - 'In Use', - 'Available', - 'Under Maintenance', - 'Disposed', - 'Inactive', - ]; + List _statusOptions(List apiStatuses) { + final labels = apiStatuses + .map((option) => option.value) + .where((value) => value.trim().isNotEmpty) + .toList(); + return ['All statuses', ...labels]; } void _viewAsset(AssetModel asset) { @@ -323,6 +331,7 @@ class _AssetsFilterBar extends StatelessWidget { required this.categories, required this.plants, required this.statuses, + required this.statusLabels, required this.onSearch, required this.onCategoryChanged, required this.onPlantChanged, @@ -336,6 +345,7 @@ class _AssetsFilterBar extends StatelessWidget { final List categories; final List plants; final List statuses; + final Map statusLabels; final ValueChanged onSearch; final ValueChanged onCategoryChanged; final ValueChanged onPlantChanged; @@ -374,6 +384,7 @@ class _AssetsFilterBar extends StatelessWidget { value: statusFilter, label: 'Status', items: statuses, + itemLabels: statusLabels, onChanged: onStatusChanged, ), ]; @@ -411,11 +422,13 @@ class _AssetFilterDropdown extends StatelessWidget { required this.label, required this.items, required this.onChanged, + this.itemLabels = const {}, }); final String value; final String label; final List items; + final Map itemLabels; final ValueChanged onChanged; @override @@ -426,7 +439,12 @@ class _AssetFilterDropdown extends StatelessWidget { isDense: true, searchHint: 'Search $label...', options: items - .map((item) => AppDropdownOption(value: item, label: item)) + .map( + (item) => AppDropdownOption( + value: item, + label: itemLabels[item] ?? item.replaceAll('_', ' '), + ), + ) .toList(), onChanged: (v) { if (v != null) onChanged(v); diff --git a/lib/modules/assets/presentation/widgets/asset_form_panel.dart b/lib/modules/assets/presentation/widgets/asset_form_panel.dart index 19163da..72ca442 100644 --- a/lib/modules/assets/presentation/widgets/asset_form_panel.dart +++ b/lib/modules/assets/presentation/widgets/asset_form_panel.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; @@ -15,6 +17,7 @@ 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 '../../data/repositories/asset_repository_impl.dart'; import '../providers/asset_categories_provider.dart'; import '../providers/asset_form_lookups_provider.dart'; import '../providers/assets_provider.dart'; @@ -90,18 +93,25 @@ class _AssetFormPanelState extends ConsumerState { 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(); - if (!widget.isEditing) { - _condition = 'NEW'; - _status = 'IN_USE'; - } + _costController.addListener(_onDepreciationFieldChanged); + _salvageValueController.addListener(_onDepreciationFieldChanged); + _usefulLifeController.addListener(_onDepreciationFieldChanged); + _depreciationRateController.addListener(_onDepreciationFieldChanged); } @override void dispose() { + _depreciationPreviewDebounce?.cancel(); _nameController.dispose(); _costController.dispose(); _serialController.dispose(); @@ -119,6 +129,10 @@ class _AssetFormPanelState extends ConsumerState { super.dispose(); } + void _onDepreciationFieldChanged() { + _triggerPreviewRecalculation(); + } + String _assetSignature(AssetModel asset) => '${asset.id}:${asset.assetCategoryId}:${asset.assetSubcategoryId}:' '${asset.plantId}:${asset.status}:${asset.assetName}'; @@ -165,6 +179,7 @@ class _AssetFormPanelState extends ConsumerState { } void _populateFromAsset(AssetModel asset) { + _isPopulatingForm = true; setState(() { _nameController.text = asset.assetName; _categoryId = asset.assetCategoryId; @@ -204,6 +219,88 @@ class _AssetFormPanelState extends ConsumerState { _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 (_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() { @@ -273,7 +370,10 @@ class _AssetFormPanelState extends ConsumerState { firstDate: DateTime(2000), lastDate: DateTime(2100), ); - if (picked != null) setState(() => onPicked(picked)); + if (picked != null) { + setState(() => onPicked(picked)); + _triggerPreviewRecalculation(); + } } Future _submit() async { @@ -325,8 +425,9 @@ class _AssetFormPanelState extends ConsumerState { if (_usefulLifeController.text.trim().isEmpty) { return 'Useful life is required when depreciation method is set'; } - if (_depreciationRateController.text.trim().isEmpty) { - return 'Depreciation rate 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'; } } @@ -420,8 +521,33 @@ class _AssetFormPanelState extends ConsumerState { final subcategoriesAsync = ref.watch(assetSubcategoriesProvider(_categoryId)); final lookupsAsync = ref.watch(assetFormLookupsProvider); final grnItemsAsync = ref.watch(assetGrnItemsProvider(_grnId)); - final depreciationMethods = - lookupsAsync.valueOrNull?.depreciationMethods ?? const []; + 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, @@ -681,14 +807,15 @@ class _AssetFormPanelState extends ConsumerState { isDense: true, label: 'Depreciation Method', value: _depreciationMethod, - options: depreciationMethods - .map((method) => AppDropdownOption( - value: method, - label: method, - )) - .toList(), + options: assetOptionDropdowns(depreciationMethods), enabled: depreciationMethods.isNotEmpty, - onChanged: (v) => setState(() => _depreciationMethod = v), + hint: depreciationMethods.isEmpty + ? 'Loading depreciation methods...' + : null, + onChanged: (v) => setState(() { + _depreciationMethod = v; + _triggerPreviewRecalculation(); + }), ), right: AppTextField( isDense: true, @@ -715,6 +842,12 @@ class _AssetFormPanelState extends ConsumerState { ), right: const SizedBox.shrink(), ), + const SizedBox(height: 12), + _DepreciationPreviewCard( + preview: _depreciationPreview, + isLoading: _isDepreciationPreviewLoading, + error: _depreciationPreviewError, + ), ], ), SidePanelSection( @@ -725,9 +858,8 @@ class _AssetFormPanelState extends ConsumerState { isDense: true, label: 'Condition *', value: _condition, - options: assetConditionOptions - .map((e) => AppDropdownOption(value: e.$1, label: e.$2)) - .toList(), + options: assetOptionDropdowns(conditions), + enabled: conditions.isNotEmpty, onChanged: (v) => setState(() => _condition = v), validator: (v) => v == null ? 'Condition is required' : null, ), @@ -735,9 +867,8 @@ class _AssetFormPanelState extends ConsumerState { isDense: true, label: 'Status *', value: _status, - options: assetStatusOptions - .map((e) => AppDropdownOption(value: e.$1, label: e.$2)) - .toList(), + options: assetOptionDropdowns(statuses), + enabled: statuses.isNotEmpty, onChanged: (v) => setState(() => _status = v), validator: (v) => v == null ? 'Status is required' : null, ), @@ -881,20 +1012,28 @@ class _AssetFormPanelState extends ConsumerState { 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 AppSearchableDropdown( label: 'Subcategory *', value: _dropdownValue(_subcategoryId, ids), + hint: !hasCategory + ? 'Select category first' + : options.isEmpty + ? 'No subcategories found' + : 'Select subcategory *', searchHint: 'Search subcategory...', isDense: true, - options: subcategories - .map( - (c) => AppDropdownOption( - value: int.tryParse(c.id) ?? 0, - label: c.name, - ), - ) - .where((option) => option.value != 0) - .toList(), + enabled: hasCategory && options.isNotEmpty, + options: options, onChanged: (v) => setState(() => _subcategoryId = v), validator: (v) => v == null ? 'Subcategory is required' : null, ); @@ -922,6 +1061,20 @@ class _AssetFormPanelState extends ConsumerState { onChanged: (v) => setState(() { _categoryId = v; _subcategoryId = null; + final selectedCategory = categories.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, ); @@ -969,6 +1122,122 @@ class _AssetFormDateField extends StatefulWidget { 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; diff --git a/lib/modules/assets/presentation/widgets/asset_side_panels.dart b/lib/modules/assets/presentation/widgets/asset_side_panels.dart index 51f1686..cd641e7 100644 --- a/lib/modules/assets/presentation/widgets/asset_side_panels.dart +++ b/lib/modules/assets/presentation/widgets/asset_side_panels.dart @@ -43,9 +43,9 @@ class _AddAmcPanelState extends ConsumerState { DateTime? _endDate; DateTime? _renewalDate; int? _vendorId; - String _contractType = 'COMPREHENSIVE'; - String? _paymentFrequency = 'MONTHLY'; - String? _serviceFrequency = 'MONTHLY'; + String? _contractType; + String? _paymentFrequency; + String? _serviceFrequency; bool _isActive = true; bool _isSubmitting = false; String? _populatedContractId; @@ -70,15 +70,15 @@ class _AddAmcPanelState extends ConsumerState { _vendorId = contract.vendorId; _contractNoController.text = contract.contractNo ?? ''; - _contractType = contract.contractType ?? 'COMPREHENSIVE'; + _contractType = contract.contractType; _startDate = contract.startDate; _endDate = contract.endDate; _renewalDate = contract.renewalDate; if (contract.annualCost != null) { _annualCostController.text = contract.annualCost.toString(); } - _paymentFrequency = contract.paymentFrequency ?? 'MONTHLY'; - _serviceFrequency = contract.serviceFrequency ?? 'MONTHLY'; + _paymentFrequency = contract.paymentFrequency; + _serviceFrequency = contract.serviceFrequency; if (contract.visitsPerYear != null) { _visitsPerYearController.text = contract.visitsPerYear.toString(); } @@ -98,7 +98,8 @@ class _AddAmcPanelState extends ConsumerState { 'vendor_id': _vendorId, if (_contractNoController.text.isNotEmpty) 'contract_no': _contractNoController.text.trim(), - 'contract_type': _contractType, + if (_contractType != null && _contractType!.trim().isNotEmpty) + 'contract_type': _contractType, 'start_date': DateFormatter.toApiDate(_startDate!), 'end_date': DateFormatter.toApiDate(_endDate!), if (_renewalDate != null) 'renewal_date': DateFormatter.toApiDate(_renewalDate!), @@ -171,14 +172,46 @@ class _AddAmcPanelState extends ConsumerState { } Widget _buildForm() { - final vendorOptionsAsync = ref.watch(assetFormLookupsProvider); + final lookupsAsync = ref.watch(assetFormLookupsProvider); + final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel(); + + if (lookupsAsync.hasValue) { + final nextContractType = resolveAssetOptionValue( + _contractType, + options.contractTypes, + preferFirstWhenEmpty: !widget.isEdit, + ); + final nextPaymentFrequency = resolveAssetOptionValue( + _paymentFrequency, + options.paymentFrequencies, + preferFirstWhenEmpty: !widget.isEdit, + ); + final nextServiceFrequency = resolveAssetOptionValue( + _serviceFrequency, + options.serviceFrequencies, + preferFirstWhenEmpty: !widget.isEdit, + ); + if (nextContractType != _contractType || + nextPaymentFrequency != _paymentFrequency || + nextServiceFrequency != _serviceFrequency) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() { + _contractType = nextContractType; + _paymentFrequency = nextPaymentFrequency; + _serviceFrequency = nextServiceFrequency; + }); + }); + } + } + return Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SidePanelFormRow( - left: vendorOptionsAsync.when( + left: lookupsAsync.when( loading: () => const LinearProgressIndicator(), error: (_, __) => const Text('Failed to load vendors'), data: (lookups) => AppSearchableDropdown( @@ -205,15 +238,13 @@ class _AddAmcPanelState extends ConsumerState { label: 'Contract Type', value: _contractType, searchHint: 'Search contract type...', - options: stringDropdownOptions(const [ - 'COMPREHENSIVE', - 'LABOUR_ONLY', - 'PARTS_ONLY', - 'PREVENTIVE_ONLY', - ]), + options: assetOptionDropdowns(options.contractTypes), + enabled: options.contractTypes.isNotEmpty, onChanged: (v) { if (v != null) setState(() => _contractType = v); }, + validator: (v) => + options.contractTypes.isEmpty ? null : (v == null ? 'Contract type is required' : null), ), const SizedBox(height: 12), SidePanelFormRow( @@ -258,28 +289,16 @@ class _AddAmcPanelState extends ConsumerState { label: 'Payment Frequency', value: _paymentFrequency, searchHint: 'Search payment frequency...', - options: stringDropdownOptions(const [ - 'DAILY', - 'WEEKLY', - 'MONTHLY', - 'QUARTERLY', - 'HALF_YEARLY', - 'YEARLY', - ]), + options: assetOptionDropdowns(options.paymentFrequencies), + enabled: options.paymentFrequencies.isNotEmpty, onChanged: (v) => setState(() => _paymentFrequency = v), ), right: AppSearchableDropdown( label: 'Service Frequency', value: _serviceFrequency, searchHint: 'Search service frequency...', - options: stringDropdownOptions(const [ - 'DAILY', - 'WEEKLY', - 'MONTHLY', - 'QUARTERLY', - 'HALF_YEARLY', - 'YEARLY', - ]), + options: assetOptionDropdowns(options.serviceFrequencies), + enabled: options.serviceFrequencies.isNotEmpty, onChanged: (v) => setState(() => _serviceFrequency = v), ), ), @@ -432,9 +451,9 @@ class _LogServiceVisitPanelState extends ConsumerState { DateTime? _nextServiceDate; int? _amcContractId; int? _vendorId; - String _visitType = 'PREVENTIVE'; - String _status = 'COMPLETED'; - String? _assetConditionAfter = 'GOOD'; + String? _visitType; + String? _status; + String? _assetConditionAfter; bool _isUnderAmc = false; bool _isSubmitting = false; String? _populatedVisitId; @@ -481,7 +500,7 @@ class _LogServiceVisitPanelState extends ConsumerState { _serviceCostController.text = visit.serviceCost.toString(); } _isUnderAmc = visit.isUnderAmc; - _assetConditionAfter = visit.assetConditionAfter ?? 'GOOD'; + _assetConditionAfter = visit.assetConditionAfter; _remarksController.text = visit.remarks ?? ''; } @@ -505,7 +524,7 @@ class _LogServiceVisitPanelState extends ConsumerState { final downtimeHours = double.tryParse(_downtimeHoursController.text.trim()); final serviceCost = double.tryParse(_serviceCostController.text.trim()); return { - 'visit_type': _visitType, + if (_visitType != null && _visitType!.trim().isNotEmpty) 'visit_type': _visitType, 'visit_date': DateFormatter.toApiDate(_visitDate!), if (_amcContractId != null) 'amc_contract_id': _amcContractId, if (visitNumber != null) 'visit_number': visitNumber, @@ -526,7 +545,7 @@ class _LogServiceVisitPanelState extends ConsumerState { 'parts_replaced': _partsReplacedController.text.trim(), if (_nextServiceDate != null) 'next_service_date': DateFormatter.toApiDate(_nextServiceDate!), - 'status': _status, + if (_status != null && _status!.trim().isNotEmpty) 'status': _status, if (downtimeHours != null) 'downtime_hours': downtimeHours, if (serviceCost != null) 'service_cost': serviceCost, 'is_under_amc': _isUnderAmc, @@ -564,7 +583,39 @@ class _LogServiceVisitPanelState extends ConsumerState { } Widget _buildForm(List amcContracts) { - final vendorOptionsAsync = ref.watch(assetFormLookupsProvider); + final lookupsAsync = ref.watch(assetFormLookupsProvider); + final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel(); + + if (lookupsAsync.hasValue) { + final nextVisitType = resolveAssetOptionValue( + _visitType, + options.visitTypes, + preferFirstWhenEmpty: !widget.isEdit, + ); + final nextStatus = resolveAssetOptionValue( + _status, + options.visitStatuses, + preferFirstWhenEmpty: !widget.isEdit, + ); + final nextConditionAfter = resolveAssetOptionValue( + _assetConditionAfter, + options.visitConditionsAfter, + preferFirstWhenEmpty: !widget.isEdit, + ); + if (nextVisitType != _visitType || + nextStatus != _status || + nextConditionAfter != _assetConditionAfter) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() { + _visitType = nextVisitType; + _status = nextStatus; + _assetConditionAfter = nextConditionAfter; + }); + }); + } + } + return Form( key: _formKey, child: Column( @@ -575,29 +626,19 @@ class _LogServiceVisitPanelState extends ConsumerState { label: 'Visit Type *', value: _visitType, searchHint: 'Search visit type...', - options: stringDropdownOptions(const [ - 'PREVENTIVE', - 'BREAKDOWN', - 'INSPECTION', - 'INSTALLATION', - 'CALIBRATION', - 'OTHER', - ]), + options: assetOptionDropdowns(options.visitTypes), + enabled: options.visitTypes.isNotEmpty, onChanged: (v) { if (v != null) setState(() => _visitType = v); }, + validator: (v) => v == null ? 'Visit type is required' : null, ), right: AppSearchableDropdown( label: 'Status', value: _status, searchHint: 'Search status...', - options: stringDropdownOptions(const [ - 'SCHEDULED', - 'IN_PROGRESS', - 'COMPLETED', - 'CANCELLED', - 'PENDING_PARTS', - ]), + options: assetOptionDropdowns(options.visitStatuses), + enabled: options.visitStatuses.isNotEmpty, onChanged: (v) { if (v != null) setState(() => _status = v); }, @@ -644,7 +685,7 @@ class _LogServiceVisitPanelState extends ConsumerState { ), const SizedBox(height: 12), SidePanelFormRow( - left: vendorOptionsAsync.when( + left: lookupsAsync.when( loading: () => const LinearProgressIndicator(), error: (_, __) => const Text('Failed to load vendors'), data: (lookups) => AppSearchableDropdown( @@ -665,13 +706,8 @@ class _LogServiceVisitPanelState extends ConsumerState { label: 'Asset Condition After', value: _assetConditionAfter, searchHint: 'Search condition...', - options: stringDropdownOptions(const [ - 'GOOD', - 'FAIR', - 'POOR', - 'DAMAGED', - 'NEEDS_REPAIR', - ]), + options: assetOptionDropdowns(options.visitConditionsAfter), + enabled: options.visitConditionsAfter.isNotEmpty, onChanged: (v) => setState(() => _assetConditionAfter = v), ), ), @@ -854,7 +890,7 @@ class _AddInsurancePanelState extends ConsumerState { DateTime? _endDate; DateTime? _renewalDate; DateTime? _premiumPaidDate; - String _policyType = 'COMPREHENSIVE'; + String? _policyType; bool _isAutoRenewal = false; bool _premiumPaid = false; bool _isActive = true; @@ -885,7 +921,7 @@ class _AddInsurancePanelState extends ConsumerState { _insurerContactController.text = policy.insurerContact ?? ''; _insurerPhoneController.text = policy.insurerPhone ?? ''; _insurerEmailController.text = policy.insurerEmail ?? ''; - _policyType = policy.policyType ?? 'COMPREHENSIVE'; + _policyType = policy.policyType; if (policy.sumInsured != null) { _sumInsuredController.text = policy.sumInsured.toString(); } @@ -931,7 +967,8 @@ class _AddInsurancePanelState extends ConsumerState { 'insurer_phone': _insurerPhoneController.text.trim(), if (_insurerEmailController.text.trim().isNotEmpty) 'insurer_email': _insurerEmailController.text.trim(), - 'policy_type': _policyType, + if (_policyType != null && _policyType!.trim().isNotEmpty) + 'policy_type': _policyType, if (sumInsured != null) 'sum_insured': sumInsured, if (annualPremium != null) 'annual_premium': annualPremium, 'policy_start_date': DateFormatter.toApiDate(_startDate!), @@ -973,6 +1010,23 @@ class _AddInsurancePanelState extends ConsumerState { } Widget _buildForm() { + final lookupsAsync = ref.watch(assetFormLookupsProvider); + final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel(); + + if (lookupsAsync.hasValue) { + final nextPolicyType = resolveAssetOptionValue( + _policyType, + options.policyTypes, + preferFirstWhenEmpty: !widget.isEdit, + ); + if (nextPolicyType != _policyType) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _policyType = nextPolicyType); + }); + } + } + return Form( key: _formKey, child: Column( @@ -1020,14 +1074,8 @@ class _AddInsurancePanelState extends ConsumerState { label: 'Policy Type', value: _policyType, searchHint: 'Search policy type...', - options: stringDropdownOptions(const [ - 'FIRE_AND_ALLIED', - 'MACHINERY_BREAKDOWN', - 'COMPREHENSIVE', - 'THIRD_PARTY', - 'VEHICLE', - 'OTHER', - ]), + options: assetOptionDropdowns(options.policyTypes), + enabled: options.policyTypes.isNotEmpty, onChanged: (v) { if (v != null) setState(() => _policyType = v); }, diff --git a/lib/modules/master_data/presentation/providers/master_provider.dart b/lib/modules/master_data/presentation/providers/master_provider.dart index a7d2680..dc90bbf 100644 --- a/lib/modules/master_data/presentation/providers/master_provider.dart +++ b/lib/modules/master_data/presentation/providers/master_provider.dart @@ -284,13 +284,14 @@ class MasterFormNotifier extends FamilyAsyncNotifier[]) - .map((method) => method.trim()) - .where((method) => method.isNotEmpty) - .toSet() - .toList(); - options[key] = methods - .map((method) => {'id': method, 'name': method}) + options[key] = (result.data ?? const []) + .where((option) => option.value.trim().isNotEmpty) + .map( + (option) => { + 'id': option.value, + 'name': option.label, + }, + ) .toList(); continue; } 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 da478ba..68d1e01 100644 --- a/lib/modules/masters/data/datasources/master_remote_data_source.dart +++ b/lib/modules/masters/data/datasources/master_remote_data_source.dart @@ -56,26 +56,13 @@ class MasterRemoteDataSource { if (assetCategoryId != null) 'asset_category_id': assetCategoryId, }, ); - final body = response.data as Map; - final raw = body['data']; - - final list = raw is List - ? raw - : raw is Map - ? raw['items'] as List? ?? const [] - : const []; - - return list - .whereType>() - .where((item) => item['is_active'] != false) - .map( - (item) => FilterOptionModel( - id: item['id']?.toString() ?? '', - name: _optionLabel(item), - ), - ) - .where((item) => item.id.isNotEmpty && item.name.isNotEmpty) - .toList(); + final categoryFilter = assetCategoryId?.toString(); + return _parseOptions( + response.data, + extraFilter: categoryFilter == null + ? null + : (item) => item['asset_category_id']?.toString() == categoryFilter, + ); } Future> _listOptions(String endpoint) async { @@ -86,18 +73,33 @@ class MasterRemoteDataSource { 'is_active': true, }, ); - final body = response.data as Map; + return _parseOptions(response.data); + } + + /// Parses masters list payloads. Uses `is List` (not `List`) so + /// Flutter web JSON arrays are not dropped as empty. + List _parseOptions( + dynamic body, { + bool Function(Map item)? extraFilter, + }) { + if (body is! Map) return const []; final raw = body['data']; - final list = raw is List - ? raw - : raw is Map - ? raw['items'] as List? ?? const [] - : const []; + final List list; + if (raw is List) { + list = raw; + } else if (raw is Map) { + final items = raw['items']; + list = items is List ? items : const []; + } else { + list = const []; + } return list - .whereType>() + .whereType() + .map((item) => Map.from(item)) .where((item) => item['is_active'] != false) + .where((item) => extraFilter == null || extraFilter(item)) .map( (item) => FilterOptionModel( id: item['id']?.toString() ?? '', diff --git a/lib/shared/models/asset_model.dart b/lib/shared/models/asset_model.dart index e4e58ce..f3db6e3 100644 --- a/lib/shared/models/asset_model.dart +++ b/lib/shared/models/asset_model.dart @@ -181,43 +181,110 @@ class AssetModel with _$AssetModel { factory AssetModel.fromJson(Map json) => _$AssetModelFromJson(json); } -const assetConditionOptions = [ - ('NEW', 'New'), - ('GOOD', 'Good'), - ('FAIR', 'Fair'), - ('POOR', 'Poor'), -]; - -const assetStatusOptions = [ - ('IN_USE', 'In Use'), - ('IDLE', 'Idle'), - ('UNDER_MAINTENANCE', 'Under Maintenance'), - ('DISPOSED', 'Disposed'), - ('SCRAPPED', 'Scrapped'), -]; - -const depreciationMethodOptions = [ - ('SLM', 'SLM'), - ('WDV', 'WDV'), - ('OTHER', 'Other'), -]; - String assetConditionLabel(String? value) { - if (value == null) return '—'; - return assetConditionOptions - .where((e) => e.$1 == value) - .map((e) => e.$2) - .firstOrNull ?? - value; + if (value == null || value.trim().isEmpty) return '—'; + return value.replaceAll('_', ' '); } String assetStatusLabel(String? value) { - if (value == null) return '—'; - return assetStatusOptions - .where((e) => e.$1 == value) - .map((e) => e.$2) - .firstOrNull ?? - value.replaceAll('_', ' '); + if (value == null || value.trim().isEmpty) return '—'; + return value.replaceAll('_', ' '); +} + +/// Dropdown option lists for the assets module (from GET /assets/options). +class AssetDropdownOptionsModel { + const AssetDropdownOptionsModel({ + this.contractTypes = const [], + this.visitTypes = const [], + this.policyTypes = const [], + this.conditions = const [], + this.statuses = const [], + this.paymentFrequencies = const [], + this.serviceFrequencies = const [], + this.visitStatuses = const [], + this.visitConditionsAfter = const [], + this.depreciationMethods = const [], + }); + + final List contractTypes; + final List visitTypes; + final List policyTypes; + final List conditions; + final List statuses; + final List paymentFrequencies; + final List serviceFrequencies; + final List visitStatuses; + final List visitConditionsAfter; + final List depreciationMethods; + + factory AssetDropdownOptionsModel.fromJson(Map json) { + List parse(String key, [String? altKey]) { + final raw = json[key] ?? (altKey == null ? null : json[altKey]); + if (raw is! List) return const []; + return raw + .map(AssetDropdownOption.fromDynamic) + .where((option) => option.value.isNotEmpty) + .toList(); + } + + return AssetDropdownOptionsModel( + contractTypes: parse('contract_types', 'contractTypes'), + visitTypes: parse('visit_types', 'visitTypes'), + policyTypes: parse('policy_types', 'policyTypes'), + // Combined endpoint uses asset_* keys; individual endpoints return list bodies. + conditions: parse('asset_conditions', 'conditions'), + statuses: parse('asset_statuses', 'statuses'), + paymentFrequencies: parse('payment_frequencies', 'paymentFrequencies'), + serviceFrequencies: parse('service_frequencies', 'serviceFrequencies'), + visitStatuses: parse('visit_statuses', 'visitStatuses'), + visitConditionsAfter: parse( + 'visit_conditions_after', + 'visitConditionsAfter', + ), + depreciationMethods: parse( + 'depreciation_methods', + 'depreciationMethods', + ), + ); + } +} + +class AssetDropdownOption { + const AssetDropdownOption({ + required this.value, + required this.label, + }); + + final String value; + final String label; + + factory AssetDropdownOption.fromDynamic(dynamic item) { + if (item is String) { + final value = item.trim(); + return AssetDropdownOption( + value: value, + label: value.replaceAll('_', ' '), + ); + } + if (item is Map) { + final map = Map.from(item); + final value = (map['value'] ?? + map['code'] ?? + map['id'] ?? + map['key'] ?? + '') + .toString() + .trim(); + final label = (map['label'] ?? map['name'] ?? map['display'] ?? value) + .toString() + .trim(); + return AssetDropdownOption( + value: value, + label: label.isEmpty ? value.replaceAll('_', ' ') : label, + ); + } + return const AssetDropdownOption(value: '', label: ''); + } } @freezed @@ -410,3 +477,52 @@ class AssetTransferHistoryModel { ); } } + +class AssetDepreciationPreviewModel { + const AssetDepreciationPreviewModel({ + required this.depreciationMethod, + required this.depreciationRate, + required this.annualDepreciation, + required this.accumulatedDepreciation, + required this.bookValue, + required this.yearsElapsed, + required this.purchaseCost, + required this.salvageValue, + required this.usefulLifeYears, + }); + + final String depreciationMethod; + final double depreciationRate; + final double annualDepreciation; + final double accumulatedDepreciation; + final double bookValue; + final int yearsElapsed; + final double purchaseCost; + final double salvageValue; + final int usefulLifeYears; + + factory AssetDepreciationPreviewModel.fromJson(Map json) { + double toDouble(dynamic value) { + if (value is num) return value.toDouble(); + return double.tryParse(value?.toString() ?? '') ?? 0; + } + + int toInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? '') ?? 0; + } + + return AssetDepreciationPreviewModel( + depreciationMethod: json['depreciation_method']?.toString() ?? '', + depreciationRate: toDouble(json['depreciation_rate']), + annualDepreciation: toDouble(json['annual_depreciation']), + accumulatedDepreciation: toDouble(json['accumulated_depreciation']), + bookValue: toDouble(json['book_value']), + yearsElapsed: toInt(json['years_elapsed']), + purchaseCost: toDouble(json['purchase_cost']), + salvageValue: toDouble(json['salvage_value']), + usefulLifeYears: toInt(json['useful_life_years']), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 1963d66..e88ca3d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,6 +67,3 @@ flutter: - .env.development - .env.uat - .env.production - - .env.development - - .env.uat - - .env.production