asset module type masters api integration

This commit is contained in:
Surendiran 2026-07-09 11:03:44 +05:30
parent 3e00b478e7
commit ad1c95d85d
16 changed files with 1012 additions and 236 deletions

View File

@ -15,7 +15,7 @@ Future<void> 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<void> 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<void> _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: '');
}
}
}

View File

@ -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;
}

View File

@ -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',

View File

@ -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';

View File

@ -8,15 +8,14 @@ import 'auth_interceptor.dart';
import 'error_interceptor.dart';
final dioProvider = Provider<Dio>((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',

View File

@ -58,9 +58,80 @@ class AssetRemoteDataSource {
return _parseList(response.data, AssetCategoryModel.fromJson);
}
Future<List<String>> getDepreciationMethods() async {
Future<AssetDropdownOptionsModel> 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<String, dynamic>.from(raw),
);
}
}
return const AssetDropdownOptionsModel();
}
Future<List<AssetDropdownOption>> getContractTypes() async {
final response = await dio.get(ApiEndpoints.assetContractTypes);
return _parseDropdownOptions(response.data);
}
Future<List<AssetDropdownOption>> getVisitTypes() async {
final response = await dio.get(ApiEndpoints.assetVisitTypes);
return _parseDropdownOptions(response.data);
}
Future<List<AssetDropdownOption>> getPolicyTypes() async {
final response = await dio.get(ApiEndpoints.assetPolicyTypes);
return _parseDropdownOptions(response.data);
}
Future<List<AssetDropdownOption>> getConditions() async {
final response = await dio.get(ApiEndpoints.assetConditions);
return _parseDropdownOptions(response.data);
}
Future<List<AssetDropdownOption>> getStatuses() async {
final response = await dio.get(ApiEndpoints.assetStatuses);
return _parseDropdownOptions(response.data);
}
Future<List<AssetDropdownOption>> getPaymentFrequencies() async {
final response = await dio.get(ApiEndpoints.assetPaymentFrequencies);
return _parseDropdownOptions(response.data);
}
Future<List<AssetDropdownOption>> getServiceFrequencies() async {
final response = await dio.get(ApiEndpoints.assetServiceFrequencies);
return _parseDropdownOptions(response.data);
}
Future<List<AssetDropdownOption>> getVisitStatuses() async {
final response = await dio.get(ApiEndpoints.assetVisitStatuses);
return _parseDropdownOptions(response.data);
}
Future<List<AssetDropdownOption>> getVisitConditionsAfter() async {
final response = await dio.get(ApiEndpoints.assetVisitConditionsAfter);
return _parseDropdownOptions(response.data);
}
Future<List<AssetDropdownOption>> getDepreciationMethods() async {
final response = await dio.get(ApiEndpoints.assetDepreciationMethods);
return _parseStringOptions(response.data);
return _parseDropdownOptions(response.data);
}
Future<AssetDepreciationPreviewModel> calculateDepreciationPreview(
Map<String, dynamic> data,
) async {
final response = await dio.post(
ApiEndpoints.assetDepreciationCalculate,
data: data,
);
return AssetDepreciationPreviewModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<PaginatedResponse<AssetAlertModel>> getExpiryAlerts({
@ -285,28 +356,22 @@ class AssetRemoteDataSource {
);
}
List<String> _parseStringOptions(dynamic body) {
if (body is! Map<String, dynamic>) return const [];
List<AssetDropdownOption> _parseDropdownOptions(dynamic body) {
if (body is! Map) return const [];
final raw = body['data'];
final list = raw is List
? raw
: raw is Map<String, dynamic>
? raw['items'] as List<dynamic>? ?? 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<String, dynamic>) {
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();
}
}

View File

@ -61,10 +61,67 @@ class AssetRepositoryImpl implements AssetRepository {
}
@override
Future<Result<List<String>>> getDepreciationMethods() {
Future<Result<AssetDropdownOptionsModel>> getAssetOptions() {
return safeApiCall(() => dataSource.getAssetOptions());
}
@override
Future<Result<List<AssetDropdownOption>>> getContractTypes() {
return safeApiCall(() => dataSource.getContractTypes());
}
@override
Future<Result<List<AssetDropdownOption>>> getVisitTypes() {
return safeApiCall(() => dataSource.getVisitTypes());
}
@override
Future<Result<List<AssetDropdownOption>>> getPolicyTypes() {
return safeApiCall(() => dataSource.getPolicyTypes());
}
@override
Future<Result<List<AssetDropdownOption>>> getConditions() {
return safeApiCall(() => dataSource.getConditions());
}
@override
Future<Result<List<AssetDropdownOption>>> getStatuses() {
return safeApiCall(() => dataSource.getStatuses());
}
@override
Future<Result<List<AssetDropdownOption>>> getPaymentFrequencies() {
return safeApiCall(() => dataSource.getPaymentFrequencies());
}
@override
Future<Result<List<AssetDropdownOption>>> getServiceFrequencies() {
return safeApiCall(() => dataSource.getServiceFrequencies());
}
@override
Future<Result<List<AssetDropdownOption>>> getVisitStatuses() {
return safeApiCall(() => dataSource.getVisitStatuses());
}
@override
Future<Result<List<AssetDropdownOption>>> getVisitConditionsAfter() {
return safeApiCall(() => dataSource.getVisitConditionsAfter());
}
@override
Future<Result<List<AssetDropdownOption>>> getDepreciationMethods() {
return safeApiCall(() => dataSource.getDepreciationMethods());
}
@override
Future<Result<AssetDepreciationPreviewModel>> calculateDepreciationPreview(
Map<String, dynamic> data,
) {
return safeApiCall(() => dataSource.calculateDepreciationPreview(data));
}
@override
Future<Result<PaginatedResponse<AssetAlertModel>>> getExpiryAlerts({
int? days,

View File

@ -11,7 +11,20 @@ abstract class AssetRepository {
Future<Result<AssetModel>> transferAsset(String id, Map<String, dynamic> data);
Future<Result<List<AssetTransferHistoryModel>>> getTransferHistory(String assetId);
Future<Result<List<AssetCategoryModel>>> getCategories();
Future<Result<List<String>>> getDepreciationMethods();
Future<Result<AssetDropdownOptionsModel>> getAssetOptions();
Future<Result<List<AssetDropdownOption>>> getContractTypes();
Future<Result<List<AssetDropdownOption>>> getVisitTypes();
Future<Result<List<AssetDropdownOption>>> getPolicyTypes();
Future<Result<List<AssetDropdownOption>>> getConditions();
Future<Result<List<AssetDropdownOption>>> getStatuses();
Future<Result<List<AssetDropdownOption>>> getPaymentFrequencies();
Future<Result<List<AssetDropdownOption>>> getServiceFrequencies();
Future<Result<List<AssetDropdownOption>>> getVisitStatuses();
Future<Result<List<AssetDropdownOption>>> getVisitConditionsAfter();
Future<Result<List<AssetDropdownOption>>> getDepreciationMethods();
Future<Result<AssetDepreciationPreviewModel>> calculateDepreciationPreview(
Map<String, dynamic> data,
);
Future<Result<PaginatedResponse<AssetAlertModel>>> getExpiryAlerts({
int? days,
String? type,

View File

@ -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<FilterOptionModel> plants;
@ -31,7 +34,19 @@ class AssetFormLookups {
final List<FilterOptionModel> users;
final List<FilterOptionModel> purchaseOrders;
final List<FilterOptionModel> grns;
final List<String> depreciationMethods;
final AssetDropdownOptionsModel options;
List<AssetDropdownOption> get depreciationMethods => options.depreciationMethods;
List<AssetDropdownOption> get conditions => options.conditions;
List<AssetDropdownOption> get statuses => options.statuses;
List<AssetDropdownOption> get contractTypes => options.contractTypes;
List<AssetDropdownOption> get visitTypes => options.visitTypes;
List<AssetDropdownOption> get policyTypes => options.policyTypes;
List<AssetDropdownOption> get paymentFrequencies => options.paymentFrequencies;
List<AssetDropdownOption> get serviceFrequencies => options.serviceFrequencies;
List<AssetDropdownOption> get visitStatuses => options.visitStatuses;
List<AssetDropdownOption> 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<AssetDropdownOptionsModel>((ref) async {
return _safeAssetOptions(ref);
});
final assetGrnItemsProvider =
FutureProvider.autoDispose.family<List<FilterOptionModel>, int?>(
(ref, grnId) async {
@ -187,16 +207,155 @@ Future<List<FilterOptionModel>> _safeGrnOptions(Ref ref) async {
}
}
Future<List<String>> _safeDepreciationMethods(Ref ref) async {
Future<AssetDropdownOptionsModel> _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<AssetDropdownOptionsModel> _fillMissingOptionLists(
Ref ref,
AssetDropdownOptionsModel options,
) async {
final repo = ref.read(assetRepositoryProvider);
Future<List<AssetDropdownOption>> loadIfEmpty(
List<AssetDropdownOption> current,
Future<Result<List<AssetDropdownOption>>> 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<AssetDropdownOptionsModel> _fallbackAssetOptions(Ref ref) async {
final repo = ref.read(assetRepositoryProvider);
Future<List<AssetDropdownOption>> load(
Future<Result<List<AssetDropdownOption>>> 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<AppDropdownOption<String>> assetOptionDropdowns(
List<AssetDropdownOption> 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<AssetDropdownOption> 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;
}

View File

@ -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<AssetListScreen> {
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 <AssetDropdownOption>[]))
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<AssetListScreen> {
categories: categories,
plants: plants,
statuses: statuses,
statusLabels: statusLabels,
onSearch:
ref.read(assetsListProvider.notifier).setSearch,
onCategoryChanged: (value) {
@ -229,8 +240,8 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
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<AssetListScreen> {
return ['All plants', ...names];
}
List<String> _statusOptions() {
return [
'All statuses',
'In Use',
'Available',
'Under Maintenance',
'Disposed',
'Inactive',
];
List<String> _statusOptions(List<AssetDropdownOption> 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<String> categories;
final List<String> plants;
final List<String> statuses;
final Map<String, String> statusLabels;
final ValueChanged<String> onSearch;
final ValueChanged<String> onCategoryChanged;
final ValueChanged<String> 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<String> items;
final Map<String, String> itemLabels;
final ValueChanged<String> 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);

View File

@ -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<AssetFormPanel> {
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<AssetFormPanel> {
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<AssetFormPanel> {
}
void _populateFromAsset(AssetModel asset) {
_isPopulatingForm = true;
setState(() {
_nameController.text = asset.assetName;
_categoryId = asset.assetCategoryId;
@ -204,6 +219,88 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
_costController.clear();
}
});
_isPopulatingForm = false;
_triggerPreviewRecalculation();
}
void _triggerPreviewRecalculation() {
if (_isPopulatingForm) return;
_depreciationPreviewDebounce?.cancel();
_depreciationPreviewDebounce = Timer(
const Duration(milliseconds: 400),
_calculateDepreciationPreview,
);
}
Map<String, dynamic>? _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 = <String, dynamic>{
'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<void> _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<String, dynamic> _buildPayload() {
@ -273,7 +370,10 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) setState(() => onPicked(picked));
if (picked != null) {
setState(() => onPicked(picked));
_triggerPreviewRecalculation();
}
}
Future<void> _submit() async {
@ -325,8 +425,9 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
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<AssetFormPanel> {
final subcategoriesAsync = ref.watch(assetSubcategoriesProvider(_categoryId));
final lookupsAsync = ref.watch(assetFormLookupsProvider);
final grnItemsAsync = ref.watch(assetGrnItemsProvider(_grnId));
final depreciationMethods =
lookupsAsync.valueOrNull?.depreciationMethods ?? const <String>[];
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<AssetFormPanel> {
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<AssetFormPanel> {
),
right: const SizedBox.shrink(),
),
const SizedBox(height: 12),
_DepreciationPreviewCard(
preview: _depreciationPreview,
isLoading: _isDepreciationPreviewLoading,
error: _depreciationPreviewError,
),
],
),
SidePanelSection(
@ -725,9 +858,8 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
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<AssetFormPanel> {
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<AssetFormPanel> {
Widget _subcategoryDropdown(List<FilterOptionModel> subcategories) {
final ids = subcategories.map((c) => int.tryParse(c.id)).whereType<int>().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<int>(
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<AssetFormPanel> {
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;

View File

@ -43,9 +43,9 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
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<AddAmcPanel> {
_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<AddAmcPanel> {
'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<AddAmcPanel> {
}
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<int>(
@ -205,15 +238,13 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
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<AddAmcPanel> {
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<String>(
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<LogServiceVisitPanel> {
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<LogServiceVisitPanel> {
_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<LogServiceVisitPanel> {
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<LogServiceVisitPanel> {
'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<LogServiceVisitPanel> {
}
Widget _buildForm(List<AmcContractModel> 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<LogServiceVisitPanel> {
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<String>(
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<LogServiceVisitPanel> {
),
const SizedBox(height: 12),
SidePanelFormRow(
left: vendorOptionsAsync.when(
left: lookupsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('Failed to load vendors'),
data: (lookups) => AppSearchableDropdown<int>(
@ -665,13 +706,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
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<AddInsurancePanel> {
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<AddInsurancePanel> {
_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<AddInsurancePanel> {
'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<AddInsurancePanel> {
}
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<AddInsurancePanel> {
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);
},

View File

@ -284,13 +284,14 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
if (key == 'asset_depreciation_methods') {
final result = await ref.read(assetRepositoryProvider).getDepreciationMethods();
if (result.failure != null) continue;
final methods = (result.data ?? const <String>[])
.map((method) => method.trim())
.where((method) => method.isNotEmpty)
.toSet()
.toList();
options[key] = methods
.map((method) => <String, dynamic>{'id': method, 'name': method})
options[key] = (result.data ?? const [])
.where((option) => option.value.trim().isNotEmpty)
.map(
(option) => <String, dynamic>{
'id': option.value,
'name': option.label,
},
)
.toList();
continue;
}

View File

@ -56,26 +56,13 @@ class MasterRemoteDataSource {
if (assetCategoryId != null) 'asset_category_id': assetCategoryId,
},
);
final body = response.data as Map<String, dynamic>;
final raw = body['data'];
final list = raw is List<dynamic>
? raw
: raw is Map<String, dynamic>
? raw['items'] as List<dynamic>? ?? const []
: const [];
return list
.whereType<Map<String, dynamic>>()
.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<List<FilterOptionModel>> _listOptions(String endpoint) async {
@ -86,18 +73,33 @@ class MasterRemoteDataSource {
'is_active': true,
},
);
final body = response.data as Map<String, dynamic>;
return _parseOptions(response.data);
}
/// Parses masters list payloads. Uses `is List` (not `List<dynamic>`) so
/// Flutter web JSON arrays are not dropped as empty.
List<FilterOptionModel> _parseOptions(
dynamic body, {
bool Function(Map<String, dynamic> item)? extraFilter,
}) {
if (body is! Map) return const [];
final raw = body['data'];
final list = raw is List<dynamic>
? raw
: raw is Map<String, dynamic>
? raw['items'] as List<dynamic>? ?? 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<Map<String, dynamic>>()
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.where((item) => item['is_active'] != false)
.where((item) => extraFilter == null || extraFilter(item))
.map(
(item) => FilterOptionModel(
id: item['id']?.toString() ?? '',

View File

@ -181,43 +181,110 @@ class AssetModel with _$AssetModel {
factory AssetModel.fromJson(Map<String, dynamic> 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<AssetDropdownOption> contractTypes;
final List<AssetDropdownOption> visitTypes;
final List<AssetDropdownOption> policyTypes;
final List<AssetDropdownOption> conditions;
final List<AssetDropdownOption> statuses;
final List<AssetDropdownOption> paymentFrequencies;
final List<AssetDropdownOption> serviceFrequencies;
final List<AssetDropdownOption> visitStatuses;
final List<AssetDropdownOption> visitConditionsAfter;
final List<AssetDropdownOption> depreciationMethods;
factory AssetDropdownOptionsModel.fromJson(Map<String, dynamic> json) {
List<AssetDropdownOption> 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<String, dynamic>.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<String, dynamic> 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']),
);
}
}

View File

@ -67,6 +67,3 @@ flutter:
- .env.development
- .env.uat
- .env.production
- .env.development
- .env.uat
- .env.production