asset module type masters api integration
This commit is contained in:
parent
3e00b478e7
commit
ad1c95d85d
@ -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: '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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() ?? '',
|
||||
|
||||
@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,6 +67,3 @@ flutter:
|
||||
- .env.development
|
||||
- .env.uat
|
||||
- .env.production
|
||||
- .env.development
|
||||
- .env.uat
|
||||
- .env.production
|
||||
|
||||
Loading…
Reference in New Issue
Block a user