asset form popup fix
This commit is contained in:
parent
fa310dd87c
commit
f496be0c7f
@ -73,25 +73,16 @@ class ApiEndpoints {
|
||||
// Assets
|
||||
static const String assets = '/assets';
|
||||
static String assetById(String id) => '/assets/$id';
|
||||
static String assetQrCode(String id) => '/assets/$id/qr-code';
|
||||
static const String assetSearch = '/assets/search';
|
||||
|
||||
// Asset Allocations
|
||||
static const String assetAllocations = '/asset-allocations';
|
||||
static String assetAllocationById(String id) => '/asset-allocations/$id';
|
||||
static String returnAsset(String id) => '/asset-allocations/$id/return';
|
||||
static String reassignAsset(String id) => '/asset-allocations/$id/reassign';
|
||||
static String transferAsset(String id) => '/asset-allocations/$id/transfer';
|
||||
static const String allocationHistory = '/asset-allocations/history';
|
||||
|
||||
// Asset Maintenance
|
||||
static const String assetMaintenance = '/asset-maintenance';
|
||||
static String maintenanceById(String id) => '/asset-maintenance/$id';
|
||||
|
||||
// Asset Disposal
|
||||
static const String assetDisposal = '/asset-disposal';
|
||||
static String disposalById(String id) => '/asset-disposal/$id';
|
||||
static String approveDisposal(String id) => '/asset-disposal/$id/approve';
|
||||
static String assetTransfer(String id) => '/assets/$id/transfer';
|
||||
static const String assetAlertsExpiry = '/assets/alerts/expiry';
|
||||
static const String assetAlertsService = '/assets/alerts/service';
|
||||
static String assetAmc(String assetId) => '/assets/$assetId/amc';
|
||||
static String assetAmcRenew(String assetId, String contractId) =>
|
||||
'/assets/$assetId/amc/$contractId/renew';
|
||||
static String assetServiceVisits(String assetId) => '/assets/$assetId/service-visits';
|
||||
static String assetInsurance(String assetId) => '/assets/$assetId/insurance';
|
||||
static String assetInsuranceRenew(String assetId, String policyId) =>
|
||||
'/assets/$assetId/insurance/$policyId/renew';
|
||||
|
||||
// Dashboard
|
||||
static const String dashboardKpis = '/dashboard/kpis';
|
||||
|
||||
@ -45,11 +45,7 @@ class RouteConstants {
|
||||
static const String assetEdit = '/assets/:id/edit';
|
||||
static const String assetDetail = '/assets/:id';
|
||||
static const String assetCategories = '/assets/categories';
|
||||
static const String assetAllocations = '/assets/allocations';
|
||||
static const String assetMaintenance = '/assets/maintenance';
|
||||
static const String assetDisposal = '/assets/disposal';
|
||||
static const String assetQrScan = '/assets/qr-scan';
|
||||
static const String assetQrGenerate = '/assets/qr-generate';
|
||||
static const String assetAlerts = '/assets/alerts';
|
||||
|
||||
// Master Data
|
||||
static const String masterData = '/master-data';
|
||||
|
||||
@ -0,0 +1,213 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
|
||||
class AssetRemoteDataSource {
|
||||
AssetRemoteDataSource({required this.dio});
|
||||
|
||||
final Dio dio;
|
||||
|
||||
Future<PaginatedResponse<AssetModel>> getAssets(PaginationParams params) async {
|
||||
final response = await dio.get(
|
||||
ApiEndpoints.assets,
|
||||
queryParameters: {
|
||||
'page': params.page,
|
||||
'limit': params.limit,
|
||||
if (params.search != null && params.search!.isNotEmpty) 'search': params.search,
|
||||
},
|
||||
);
|
||||
return _parsePaginated(response.data, AssetModel.fromJson);
|
||||
}
|
||||
|
||||
Future<AssetModel> getAssetById(String id) async {
|
||||
final response = await dio.get(ApiEndpoints.assetById(id));
|
||||
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<AssetModel> createAsset(Map<String, dynamic> data) async {
|
||||
final response = await dio.post(ApiEndpoints.assets, data: data);
|
||||
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<AssetModel> updateAsset(String id, Map<String, dynamic> data) async {
|
||||
final response = await dio.put(ApiEndpoints.assetById(id), data: data);
|
||||
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> deleteAsset(String id) async {
|
||||
await dio.delete(ApiEndpoints.assetById(id));
|
||||
}
|
||||
|
||||
Future<AssetModel> transferAsset(String id, Map<String, dynamic> data) async {
|
||||
final response = await dio.post(ApiEndpoints.assetTransfer(id), data: data);
|
||||
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<List<AssetCategoryModel>> getCategories() async {
|
||||
final response = await dio.get(
|
||||
ApiEndpoints.assetCategories,
|
||||
queryParameters: const {'limit': 100, 'is_active': true},
|
||||
);
|
||||
return _parseList(response.data, AssetCategoryModel.fromJson);
|
||||
}
|
||||
|
||||
Future<PaginatedResponse<AssetAlertModel>> getExpiryAlerts({
|
||||
int? days,
|
||||
String? type,
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await dio.get(
|
||||
ApiEndpoints.assetAlertsExpiry,
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
if (days != null) 'days': days,
|
||||
if (type != null) 'type': type,
|
||||
},
|
||||
);
|
||||
return _parsePaginated(response.data, AssetAlertModel.fromJson);
|
||||
}
|
||||
|
||||
Future<List<AssetAlertModel>> getServiceAlerts({String? status}) async {
|
||||
final response = await dio.get(
|
||||
ApiEndpoints.assetAlertsService,
|
||||
queryParameters: {if (status != null) 'status': status},
|
||||
);
|
||||
return _parseList(response.data, AssetAlertModel.fromJson);
|
||||
}
|
||||
|
||||
Future<List<AmcContractModel>> getAmcContracts(String assetId) async {
|
||||
final response = await dio.get(ApiEndpoints.assetAmc(assetId));
|
||||
return _parseList(response.data, AmcContractModel.fromJson);
|
||||
}
|
||||
|
||||
Future<AmcContractModel> createAmcContract(
|
||||
String assetId,
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final response = await dio.post(ApiEndpoints.assetAmc(assetId), data: data);
|
||||
return AmcContractModel.fromJson(response.data['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<AmcContractModel> renewAmcContract(
|
||||
String assetId,
|
||||
String contractId,
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final response = await dio.patch(
|
||||
ApiEndpoints.assetAmcRenew(assetId, contractId),
|
||||
data: data,
|
||||
);
|
||||
return AmcContractModel.fromJson(response.data['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<List<ServiceVisitModel>> getServiceVisits(String assetId) async {
|
||||
final response = await dio.get(ApiEndpoints.assetServiceVisits(assetId));
|
||||
return _parseList(response.data, ServiceVisitModel.fromJson);
|
||||
}
|
||||
|
||||
Future<ServiceVisitModel> logServiceVisit(
|
||||
String assetId,
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final response = await dio.post(ApiEndpoints.assetServiceVisits(assetId), data: data);
|
||||
return ServiceVisitModel.fromJson(response.data['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<List<InsurancePolicyModel>> getInsurancePolicies(String assetId) async {
|
||||
final response = await dio.get(ApiEndpoints.assetInsurance(assetId));
|
||||
return _parseList(response.data, InsurancePolicyModel.fromJson);
|
||||
}
|
||||
|
||||
Future<InsurancePolicyModel> createInsurancePolicy(
|
||||
String assetId,
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final response = await dio.post(ApiEndpoints.assetInsurance(assetId), data: data);
|
||||
return InsurancePolicyModel.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<InsurancePolicyModel> renewInsurancePolicy(
|
||||
String assetId,
|
||||
String policyId,
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final response = await dio.patch(
|
||||
ApiEndpoints.assetInsuranceRenew(assetId, policyId),
|
||||
data: data,
|
||||
);
|
||||
return InsurancePolicyModel.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
List<T> _parseList<T>(
|
||||
dynamic body,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
if (body is! Map<String, dynamic>) return [];
|
||||
final raw = body['data'];
|
||||
if (raw is List) {
|
||||
return raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(fromJson)
|
||||
.toList();
|
||||
}
|
||||
if (raw is Map<String, dynamic>) {
|
||||
final items = raw['items'];
|
||||
if (items is List) {
|
||||
return items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
PaginatedResponse<T> _parsePaginated<T>(
|
||||
dynamic body,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
if (body is! Map<String, dynamic>) {
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
);
|
||||
}
|
||||
|
||||
final raw = body['data'];
|
||||
final meta = body['meta'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
if (raw is List) {
|
||||
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList();
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: (meta['page'] as num?)?.toInt() ?? 1,
|
||||
limit: (meta['limit'] as num?)?.toInt() ?? items.length,
|
||||
total: (meta['total'] as num?)?.toInt() ?? items.length,
|
||||
totalPages: (meta['totalPages'] as num?)?.toInt() ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
if (raw is Map<String, dynamic>) {
|
||||
return PaginatedResponse.fromJson(raw, (json) => fromJson(json! as Map<String, dynamic>));
|
||||
}
|
||||
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,129 +1,131 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../domain/repositories/asset_repository.dart';
|
||||
import '../datasources/asset_remote_data_source.dart';
|
||||
|
||||
final assetRemoteDataSourceProvider = Provider<AssetRemoteDataSource>((ref) {
|
||||
return AssetRemoteDataSource(dio: ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final assetRepositoryProvider = Provider<AssetRepository>((ref) {
|
||||
return AssetRepositoryImpl(dio: ref.watch(dioProvider));
|
||||
return AssetRepositoryImpl(dataSource: ref.watch(assetRemoteDataSourceProvider));
|
||||
});
|
||||
|
||||
class AssetRepositoryImpl implements AssetRepository {
|
||||
AssetRepositoryImpl({required this.dio});
|
||||
AssetRepositoryImpl({required this.dataSource});
|
||||
|
||||
final Dio dio;
|
||||
final AssetRemoteDataSource dataSource;
|
||||
|
||||
@override
|
||||
Future<Result<PaginatedResponse<AssetModel>>> getAssets(PaginationParams params) async {
|
||||
return safeApiCall(() async {
|
||||
final response = await dio.get(ApiEndpoints.assets, queryParameters: {
|
||||
'page': params.page,
|
||||
'limit': params.limit,
|
||||
if (params.search != null) 'search': params.search,
|
||||
});
|
||||
return PaginatedResponse<AssetModel>.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>,
|
||||
(json) => AssetModel.fromJson(json! as Map<String, dynamic>),
|
||||
);
|
||||
});
|
||||
Future<Result<PaginatedResponse<AssetModel>>> getAssets(PaginationParams params) {
|
||||
return safeApiCall(() => dataSource.getAssets(params));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<AssetModel>> getAssetById(String id) async {
|
||||
return safeApiCall(() async {
|
||||
final response = await dio.get(ApiEndpoints.assetById(id));
|
||||
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
|
||||
});
|
||||
Future<Result<AssetModel>> getAssetById(String id) {
|
||||
return safeApiCall(() => dataSource.getAssetById(id));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<AssetModel>> createAsset(Map<String, dynamic> data) async {
|
||||
return safeApiCall(() async {
|
||||
final response = await dio.post(ApiEndpoints.assets, data: data);
|
||||
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
|
||||
});
|
||||
Future<Result<AssetModel>> createAsset(Map<String, dynamic> data) {
|
||||
return safeApiCall(() => dataSource.createAsset(data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<AssetModel>> updateAsset(String id, Map<String, dynamic> data) async {
|
||||
return safeApiCall(() async {
|
||||
final response = await dio.put(ApiEndpoints.assetById(id), data: data);
|
||||
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
|
||||
});
|
||||
Future<Result<AssetModel>> updateAsset(String id, Map<String, dynamic> data) {
|
||||
return safeApiCall(() => dataSource.updateAsset(id, data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<List<AssetCategoryModel>>> getCategories() async {
|
||||
return safeApiCall(() async {
|
||||
final response = await dio.get(ApiEndpoints.assetCategories);
|
||||
final rawData = response.data['data'];
|
||||
final list = rawData is List<dynamic>
|
||||
? rawData
|
||||
: (rawData is Map<String, dynamic> ? rawData['items'] as List<dynamic>? : null) ??
|
||||
<dynamic>[];
|
||||
return list
|
||||
.map((e) => AssetCategoryModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
Future<Result<void>> deleteAsset(String id) {
|
||||
return safeApiCall(() => dataSource.deleteAsset(id));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<PaginatedResponse<AssetAllocationModel>>> getAllocations(
|
||||
PaginationParams params,
|
||||
) async {
|
||||
return safeApiCall(() async {
|
||||
final response = await dio.get(ApiEndpoints.assetAllocations, queryParameters: {
|
||||
'page': params.page,
|
||||
'limit': params.limit,
|
||||
});
|
||||
return PaginatedResponse<AssetAllocationModel>.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>,
|
||||
(json) => AssetAllocationModel.fromJson(json! as Map<String, dynamic>),
|
||||
);
|
||||
});
|
||||
Future<Result<AssetModel>> transferAsset(String id, Map<String, dynamic> data) {
|
||||
return safeApiCall(() => dataSource.transferAsset(id, data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<PaginatedResponse<AssetMaintenanceModel>>> getMaintenance(
|
||||
PaginationParams params,
|
||||
) async {
|
||||
return safeApiCall(() async {
|
||||
final response = await dio.get(ApiEndpoints.assetMaintenance, queryParameters: {
|
||||
'page': params.page,
|
||||
'limit': params.limit,
|
||||
});
|
||||
return PaginatedResponse<AssetMaintenanceModel>.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>,
|
||||
(json) => AssetMaintenanceModel.fromJson(json! as Map<String, dynamic>),
|
||||
);
|
||||
});
|
||||
Future<Result<List<AssetCategoryModel>>> getCategories() {
|
||||
return safeApiCall(() => dataSource.getCategories());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<PaginatedResponse<AssetDisposalModel>>> getDisposals(
|
||||
PaginationParams params,
|
||||
) async {
|
||||
return safeApiCall(() async {
|
||||
final response = await dio.get(ApiEndpoints.assetDisposal, queryParameters: {
|
||||
'page': params.page,
|
||||
'limit': params.limit,
|
||||
});
|
||||
return PaginatedResponse<AssetDisposalModel>.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>,
|
||||
(json) => AssetDisposalModel.fromJson(json! as Map<String, dynamic>),
|
||||
);
|
||||
});
|
||||
Future<Result<PaginatedResponse<AssetAlertModel>>> getExpiryAlerts({
|
||||
int? days,
|
||||
String? type,
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) {
|
||||
return safeApiCall(
|
||||
() => dataSource.getExpiryAlerts(days: days, type: type, page: page, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<AssetModel>> searchByQrCode(String code) async {
|
||||
return safeApiCall(() async {
|
||||
final response = await dio.get(ApiEndpoints.assetSearch, queryParameters: {'q': code});
|
||||
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
|
||||
});
|
||||
Future<Result<List<AssetAlertModel>>> getServiceAlerts({String? status}) {
|
||||
return safeApiCall(() => dataSource.getServiceAlerts(status: status));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<List<AmcContractModel>>> getAmcContracts(String assetId) {
|
||||
return safeApiCall(() => dataSource.getAmcContracts(assetId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<AmcContractModel>> createAmcContract(
|
||||
String assetId,
|
||||
Map<String, dynamic> data,
|
||||
) {
|
||||
return safeApiCall(() => dataSource.createAmcContract(assetId, data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<AmcContractModel>> renewAmcContract(
|
||||
String assetId,
|
||||
String contractId,
|
||||
Map<String, dynamic> data,
|
||||
) {
|
||||
return safeApiCall(() => dataSource.renewAmcContract(assetId, contractId, data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<List<ServiceVisitModel>>> getServiceVisits(String assetId) {
|
||||
return safeApiCall(() => dataSource.getServiceVisits(assetId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<ServiceVisitModel>> logServiceVisit(
|
||||
String assetId,
|
||||
Map<String, dynamic> data,
|
||||
) {
|
||||
return safeApiCall(() => dataSource.logServiceVisit(assetId, data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<List<InsurancePolicyModel>>> getInsurancePolicies(String assetId) {
|
||||
return safeApiCall(() => dataSource.getInsurancePolicies(assetId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<InsurancePolicyModel>> createInsurancePolicy(
|
||||
String assetId,
|
||||
Map<String, dynamic> data,
|
||||
) {
|
||||
return safeApiCall(() => dataSource.createInsurancePolicy(assetId, data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<InsurancePolicyModel>> renewInsurancePolicy(
|
||||
String assetId,
|
||||
String policyId,
|
||||
Map<String, dynamic> data,
|
||||
) {
|
||||
return safeApiCall(() => dataSource.renewInsurancePolicy(assetId, policyId, data));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
@ -8,9 +7,39 @@ abstract class AssetRepository {
|
||||
Future<Result<AssetModel>> getAssetById(String id);
|
||||
Future<Result<AssetModel>> createAsset(Map<String, dynamic> data);
|
||||
Future<Result<AssetModel>> updateAsset(String id, Map<String, dynamic> data);
|
||||
Future<Result<void>> deleteAsset(String id);
|
||||
Future<Result<AssetModel>> transferAsset(String id, Map<String, dynamic> data);
|
||||
Future<Result<List<AssetCategoryModel>>> getCategories();
|
||||
Future<Result<PaginatedResponse<AssetAllocationModel>>> getAllocations(PaginationParams params);
|
||||
Future<Result<PaginatedResponse<AssetMaintenanceModel>>> getMaintenance(PaginationParams params);
|
||||
Future<Result<PaginatedResponse<AssetDisposalModel>>> getDisposals(PaginationParams params);
|
||||
Future<Result<AssetModel>> searchByQrCode(String code);
|
||||
Future<Result<PaginatedResponse<AssetAlertModel>>> getExpiryAlerts({
|
||||
int? days,
|
||||
String? type,
|
||||
int page,
|
||||
int limit,
|
||||
});
|
||||
Future<Result<List<AssetAlertModel>>> getServiceAlerts({String? status});
|
||||
Future<Result<List<AmcContractModel>>> getAmcContracts(String assetId);
|
||||
Future<Result<AmcContractModel>> createAmcContract(
|
||||
String assetId,
|
||||
Map<String, dynamic> data,
|
||||
);
|
||||
Future<Result<AmcContractModel>> renewAmcContract(
|
||||
String assetId,
|
||||
String contractId,
|
||||
Map<String, dynamic> data,
|
||||
);
|
||||
Future<Result<List<ServiceVisitModel>>> getServiceVisits(String assetId);
|
||||
Future<Result<ServiceVisitModel>> logServiceVisit(
|
||||
String assetId,
|
||||
Map<String, dynamic> data,
|
||||
);
|
||||
Future<Result<List<InsurancePolicyModel>>> getInsurancePolicies(String assetId);
|
||||
Future<Result<InsurancePolicyModel>> createInsurancePolicy(
|
||||
String assetId,
|
||||
Map<String, dynamic> data,
|
||||
);
|
||||
Future<Result<InsurancePolicyModel>> renewInsurancePolicy(
|
||||
String assetId,
|
||||
String policyId,
|
||||
Map<String, dynamic> data,
|
||||
);
|
||||
}
|
||||
|
||||
408
lib/modules/assets/presentation/providers/assets_provider.dart
Normal file
408
lib/modules/assets/presentation/providers/assets_provider.dart
Normal file
@ -0,0 +1,408 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../data/repositories/asset_repository_impl.dart';
|
||||
|
||||
class AssetsListState {
|
||||
const AssetsListState({
|
||||
this.assets = const [],
|
||||
this.query = const PaginationParams(limit: 20),
|
||||
this.total = 0,
|
||||
this.totalPages = 1,
|
||||
this.isRefreshing = false,
|
||||
this.actionError,
|
||||
this.actionSuccess,
|
||||
});
|
||||
|
||||
final List<AssetModel> assets;
|
||||
final PaginationParams query;
|
||||
final int total;
|
||||
final int totalPages;
|
||||
final bool isRefreshing;
|
||||
final String? actionError;
|
||||
final String? actionSuccess;
|
||||
|
||||
AssetsListState copyWith({
|
||||
List<AssetModel>? assets,
|
||||
PaginationParams? query,
|
||||
int? total,
|
||||
int? totalPages,
|
||||
bool? isRefreshing,
|
||||
String? actionError,
|
||||
String? actionSuccess,
|
||||
bool clearMessages = false,
|
||||
}) {
|
||||
return AssetsListState(
|
||||
assets: assets ?? this.assets,
|
||||
query: query ?? this.query,
|
||||
total: total ?? this.total,
|
||||
totalPages: totalPages ?? this.totalPages,
|
||||
isRefreshing: isRefreshing ?? this.isRefreshing,
|
||||
actionError: clearMessages ? null : actionError ?? this.actionError,
|
||||
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final assetsListProvider =
|
||||
AsyncNotifierProvider.autoDispose<AssetsListNotifier, AssetsListState>(
|
||||
AssetsListNotifier.new,
|
||||
);
|
||||
|
||||
class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
||||
@override
|
||||
Future<AssetsListState> build() async {
|
||||
return _load(const PaginationParams(limit: 20));
|
||||
}
|
||||
|
||||
Future<AssetsListState> _load(PaginationParams query) async {
|
||||
final repository = ref.read(assetRepositoryProvider);
|
||||
final result = await repository.getAssets(query);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final page = result.data!;
|
||||
return AssetsListState(
|
||||
assets: page.items,
|
||||
query: query,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final current = state.valueOrNull ?? const AssetsListState();
|
||||
state = AsyncData(current.copyWith(isRefreshing: true, clearMessages: true));
|
||||
try {
|
||||
state = AsyncData(await _load(current.query));
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> applyQuery(PaginationParams query) async {
|
||||
final previous = state.valueOrNull;
|
||||
if (previous == null) {
|
||||
state = const AsyncLoading();
|
||||
}
|
||||
try {
|
||||
state = AsyncData(await _load(query));
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
void setSearch(String search) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
applyQuery(current.query.copyWith(search: search, page: 1));
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
applyQuery(current.query.copyWith(page: page));
|
||||
}
|
||||
|
||||
void setPageSize(int limit) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
applyQuery(current.query.copyWith(limit: limit, page: 1));
|
||||
}
|
||||
|
||||
Future<bool> deleteAsset(String id) async {
|
||||
final repository = ref.read(assetRepositoryProvider);
|
||||
final result = await repository.deleteAsset(id);
|
||||
if (result.failure != null) {
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncData(current.copyWith(actionError: result.failure!.message));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
await refresh();
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncData(current.copyWith(actionSuccess: 'Asset deleted'));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
final assetDetailProvider =
|
||||
AsyncNotifierProvider.family<AssetDetailNotifier, AssetDetailState, String>(
|
||||
AssetDetailNotifier.new,
|
||||
);
|
||||
|
||||
class AssetDetailState {
|
||||
const AssetDetailState({
|
||||
required this.asset,
|
||||
this.amcContracts = const [],
|
||||
this.serviceVisits = const [],
|
||||
this.insurancePolicies = const [],
|
||||
});
|
||||
|
||||
final AssetModel asset;
|
||||
final List<AmcContractModel> amcContracts;
|
||||
final List<ServiceVisitModel> serviceVisits;
|
||||
final List<InsurancePolicyModel> insurancePolicies;
|
||||
|
||||
AssetDetailState copyWith({
|
||||
AssetModel? asset,
|
||||
List<AmcContractModel>? amcContracts,
|
||||
List<ServiceVisitModel>? serviceVisits,
|
||||
List<InsurancePolicyModel>? insurancePolicies,
|
||||
}) {
|
||||
return AssetDetailState(
|
||||
asset: asset ?? this.asset,
|
||||
amcContracts: amcContracts ?? this.amcContracts,
|
||||
serviceVisits: serviceVisits ?? this.serviceVisits,
|
||||
insurancePolicies: insurancePolicies ?? this.insurancePolicies,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String> {
|
||||
@override
|
||||
Future<AssetDetailState> build(String arg) async {
|
||||
return _loadAll(arg);
|
||||
}
|
||||
|
||||
Future<AssetDetailState> _loadAll(String assetId) async {
|
||||
final repository = ref.read(assetRepositoryProvider);
|
||||
final assetResult = await repository.getAssetById(assetId);
|
||||
if (assetResult.failure != null) throw assetResult.failure!;
|
||||
|
||||
final amcResult = await repository.getAmcContracts(assetId);
|
||||
final visitsResult = await repository.getServiceVisits(assetId);
|
||||
final insuranceResult = await repository.getInsurancePolicies(assetId);
|
||||
|
||||
return AssetDetailState(
|
||||
asset: assetResult.data!,
|
||||
amcContracts: amcResult.data ?? [],
|
||||
serviceVisits: visitsResult.data ?? [],
|
||||
insurancePolicies: insuranceResult.data ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
state = const AsyncLoading();
|
||||
state = AsyncData(await _loadAll(arg));
|
||||
}
|
||||
|
||||
Future<AssetModel?> updateAsset(Map<String, dynamic> data) async {
|
||||
final repository = ref.read(assetRepositoryProvider);
|
||||
final result = await repository.updateAsset(arg, data);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
await reload();
|
||||
ref.invalidate(assetsListProvider);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
Future<bool> deleteAsset() async {
|
||||
final repository = ref.read(assetRepositoryProvider);
|
||||
final result = await repository.deleteAsset(arg);
|
||||
if (result.failure != null) return false;
|
||||
ref.invalidate(assetsListProvider);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<AssetModel?> transferAsset(Map<String, dynamic> data) async {
|
||||
final repository = ref.read(assetRepositoryProvider);
|
||||
final result = await repository.transferAsset(arg, data);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
await reload();
|
||||
ref.invalidate(assetsListProvider);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
Future<AmcContractModel?> createAmc(Map<String, dynamic> data) async {
|
||||
final repository = ref.read(assetRepositoryProvider);
|
||||
final result = await repository.createAmcContract(arg, data);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
await reload();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
Future<ServiceVisitModel?> logVisit(Map<String, dynamic> data) async {
|
||||
final repository = ref.read(assetRepositoryProvider);
|
||||
final result = await repository.logServiceVisit(arg, data);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
await reload();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
Future<InsurancePolicyModel?> createInsurance(Map<String, dynamic> data) async {
|
||||
final repository = ref.read(assetRepositoryProvider);
|
||||
final result = await repository.createInsurancePolicy(arg, data);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
await reload();
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
|
||||
final assetFormProvider =
|
||||
AsyncNotifierProvider.family.autoDispose<AssetFormNotifier, AssetModel?, String?>(
|
||||
AssetFormNotifier.new,
|
||||
);
|
||||
|
||||
class AssetFormNotifier extends AutoDisposeFamilyAsyncNotifier<AssetModel?, String?> {
|
||||
@override
|
||||
Future<AssetModel?> build(String? arg) async {
|
||||
if (arg == null) return null;
|
||||
final repository = ref.read(assetRepositoryProvider);
|
||||
final result = await repository.getAssetById(arg);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
return result.data;
|
||||
}
|
||||
|
||||
Future<AssetModel?> submitCreate(Map<String, dynamic> data) async {
|
||||
final repository = ref.read(assetRepositoryProvider);
|
||||
final result = await repository.createAsset(data);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
ref.invalidate(assetsListProvider);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
Future<AssetModel?> submitUpdate(String id, Map<String, dynamic> data) async {
|
||||
final repository = ref.read(assetRepositoryProvider);
|
||||
final result = await repository.updateAsset(id, data);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
ref.invalidate(assetsListProvider);
|
||||
ref.invalidate(assetDetailProvider(id));
|
||||
|
||||
final fresh = await repository.getAssetById(id);
|
||||
final asset = fresh.data ?? result.data;
|
||||
if (asset != null) {
|
||||
state = AsyncData(asset);
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
class AssetAlertsState {
|
||||
const AssetAlertsState({
|
||||
this.expiryAlerts = const [],
|
||||
this.serviceAlerts = const [],
|
||||
this.expiryDays = 30,
|
||||
this.expiryType,
|
||||
this.serviceStatus,
|
||||
this.expiryPage = 1,
|
||||
this.expiryTotalPages = 1,
|
||||
this.isRefreshing = false,
|
||||
});
|
||||
|
||||
final List<AssetAlertModel> expiryAlerts;
|
||||
final List<AssetAlertModel> serviceAlerts;
|
||||
final int expiryDays;
|
||||
final String? expiryType;
|
||||
final String? serviceStatus;
|
||||
final int expiryPage;
|
||||
final int expiryTotalPages;
|
||||
final bool isRefreshing;
|
||||
|
||||
AssetAlertsState copyWith({
|
||||
List<AssetAlertModel>? expiryAlerts,
|
||||
List<AssetAlertModel>? serviceAlerts,
|
||||
int? expiryDays,
|
||||
String? expiryType,
|
||||
String? serviceStatus,
|
||||
int? expiryPage,
|
||||
int? expiryTotalPages,
|
||||
bool? isRefreshing,
|
||||
bool clearExpiryType = false,
|
||||
bool clearServiceStatus = false,
|
||||
}) {
|
||||
return AssetAlertsState(
|
||||
expiryAlerts: expiryAlerts ?? this.expiryAlerts,
|
||||
serviceAlerts: serviceAlerts ?? this.serviceAlerts,
|
||||
expiryDays: expiryDays ?? this.expiryDays,
|
||||
expiryType: clearExpiryType ? null : expiryType ?? this.expiryType,
|
||||
serviceStatus: clearServiceStatus ? null : serviceStatus ?? this.serviceStatus,
|
||||
expiryPage: expiryPage ?? this.expiryPage,
|
||||
expiryTotalPages: expiryTotalPages ?? this.expiryTotalPages,
|
||||
isRefreshing: isRefreshing ?? this.isRefreshing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final assetAlertsProvider =
|
||||
AsyncNotifierProvider<AssetAlertsNotifier, AssetAlertsState>(AssetAlertsNotifier.new);
|
||||
|
||||
class AssetAlertsNotifier extends AsyncNotifier<AssetAlertsState> {
|
||||
@override
|
||||
Future<AssetAlertsState> build() async {
|
||||
return _load(const AssetAlertsState());
|
||||
}
|
||||
|
||||
Future<AssetAlertsState> _load(AssetAlertsState filters) async {
|
||||
final repository = ref.read(assetRepositoryProvider);
|
||||
final expiryResult = await repository.getExpiryAlerts(
|
||||
days: filters.expiryDays,
|
||||
type: filters.expiryType,
|
||||
page: filters.expiryPage,
|
||||
);
|
||||
final serviceResult = await repository.getServiceAlerts(status: filters.serviceStatus);
|
||||
|
||||
if (expiryResult.failure != null) throw expiryResult.failure!;
|
||||
|
||||
final expiryPage = expiryResult.data!;
|
||||
return AssetAlertsState(
|
||||
expiryAlerts: expiryPage.items,
|
||||
serviceAlerts: serviceResult.data ?? [],
|
||||
expiryDays: filters.expiryDays,
|
||||
expiryType: filters.expiryType,
|
||||
serviceStatus: filters.serviceStatus,
|
||||
expiryPage: expiryPage.page,
|
||||
expiryTotalPages: expiryPage.totalPages,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final current = state.valueOrNull ?? const AssetAlertsState();
|
||||
state = AsyncData(current.copyWith(isRefreshing: true));
|
||||
try {
|
||||
state = AsyncData(await _load(current));
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setExpiryDays(int days) async {
|
||||
final current = state.valueOrNull ?? const AssetAlertsState();
|
||||
state = const AsyncLoading();
|
||||
state = AsyncData(await _load(current.copyWith(expiryDays: days, expiryPage: 1)));
|
||||
}
|
||||
|
||||
Future<void> setExpiryType(String? type) async {
|
||||
final current = state.valueOrNull ?? const AssetAlertsState();
|
||||
state = const AsyncLoading();
|
||||
state = AsyncData(
|
||||
await _load(
|
||||
current.copyWith(
|
||||
expiryType: type,
|
||||
expiryPage: 1,
|
||||
clearExpiryType: type == null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setServiceStatus(String? status) async {
|
||||
final current = state.valueOrNull ?? const AssetAlertsState();
|
||||
state = const AsyncLoading();
|
||||
state = AsyncData(
|
||||
await _load(
|
||||
current.copyWith(
|
||||
serviceStatus: status,
|
||||
clearServiceStatus: status == null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setExpiryPage(int page) async {
|
||||
final current = state.valueOrNull ?? const AssetAlertsState();
|
||||
state = const AsyncLoading();
|
||||
state = AsyncData(await _load(current.copyWith(expiryPage: page)));
|
||||
}
|
||||
}
|
||||
271
lib/modules/assets/presentation/screens/asset_alerts_screen.dart
Normal file
271
lib/modules/assets/presentation/screens/asset_alerts_screen.dart
Normal file
@ -0,0 +1,271 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_pagination.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../providers/assets_provider.dart';
|
||||
|
||||
class AssetAlertsScreen extends ConsumerWidget {
|
||||
const AssetAlertsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final alertsAsync = ref.watch(assetAlertsProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: alertsAsync.when(
|
||||
loading: () => const AppLoadingView(message: 'Loading alerts...'),
|
||||
error: (e, _) => ErrorView.fromFailure(
|
||||
e is Failure ? e : Failure.unknown(message: e.toString()),
|
||||
onRetry: () => ref.invalidate(assetAlertsProvider),
|
||||
),
|
||||
data: (state) => DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PageHeader(
|
||||
title: 'Asset Alerts',
|
||||
subtitle: 'Expiry and service due notifications',
|
||||
actions: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go(RouteConstants.assets),
|
||||
icon: const Icon(Icons.inventory_2_outlined),
|
||||
label: const Text('Asset Master'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'Expiry Alerts'),
|
||||
Tab(text: 'Service Alerts'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
_ExpiryAlertsTab(state: state),
|
||||
_ServiceAlertsTab(state: state),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExpiryAlertsTab extends ConsumerWidget {
|
||||
const _ExpiryAlertsTab({required this.state});
|
||||
|
||||
final AssetAlertsState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final notifier = ref.read(assetAlertsProvider.notifier);
|
||||
final dateFormat = DateFormat('dd MMM yyyy');
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: AppDropdown<int>(
|
||||
label: 'Days ahead',
|
||||
value: state.expiryDays,
|
||||
options: const [7, 15, 30, 60, 90]
|
||||
.map((d) => AppDropdownOption(value: d, label: '$d days'))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) notifier.setExpiryDays(v);
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: AppDropdown<String?>(
|
||||
label: 'Type',
|
||||
value: state.expiryType,
|
||||
options: const [
|
||||
AppDropdownOption(value: null, label: 'All'),
|
||||
AppDropdownOption(value: 'AMC', label: 'AMC'),
|
||||
AppDropdownOption(value: 'INSURANCE', label: 'Insurance'),
|
||||
AppDropdownOption(value: 'WARRANTY', label: 'Warranty'),
|
||||
],
|
||||
onChanged: notifier.setExpiryType,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => notifier.refresh(),
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: state.expiryAlerts.isEmpty
|
||||
? const AppEmptyState(
|
||||
title: 'No expiry alerts',
|
||||
description: 'No AMC, insurance or warranty expiries in this window.',
|
||||
icon: Icons.event_available_outlined,
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: state.expiryAlerts.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final alert = state.expiryAlerts[index];
|
||||
return _AlertCard(
|
||||
alert: alert,
|
||||
dateLabel: alert.expiryDate != null
|
||||
? dateFormat.format(alert.expiryDate!)
|
||||
: null,
|
||||
onTap: alert.assetId != null
|
||||
? () => context.push('${RouteConstants.assets}/${alert.assetId}')
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
AppPagination(
|
||||
currentPage: state.expiryPage,
|
||||
totalPages: state.expiryTotalPages,
|
||||
totalItems: state.expiryAlerts.length,
|
||||
pageSize: 20,
|
||||
onPageChanged: notifier.setExpiryPage,
|
||||
onPageSizeChanged: (_) {},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceAlertsTab extends ConsumerWidget {
|
||||
const _ServiceAlertsTab({required this.state});
|
||||
|
||||
final AssetAlertsState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final notifier = ref.read(assetAlertsProvider.notifier);
|
||||
final dateFormat = DateFormat('dd MMM yyyy');
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: AppDropdown<String?>(
|
||||
label: 'Status',
|
||||
value: state.serviceStatus,
|
||||
options: const [
|
||||
AppDropdownOption(value: null, label: 'All'),
|
||||
AppDropdownOption(value: 'OVERDUE', label: 'Overdue'),
|
||||
AppDropdownOption(value: 'DUE_THIS_WEEK', label: 'Due This Week'),
|
||||
AppDropdownOption(value: 'DUE_THIS_MONTH', label: 'Due This Month'),
|
||||
AppDropdownOption(value: 'UPCOMING', label: 'Upcoming'),
|
||||
],
|
||||
onChanged: notifier.setServiceStatus,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => notifier.refresh(),
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: state.serviceAlerts.isEmpty
|
||||
? const AppEmptyState(
|
||||
title: 'No service alerts',
|
||||
description: 'All services are up to date.',
|
||||
icon: Icons.build_circle_outlined,
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: state.serviceAlerts.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final alert = state.serviceAlerts[index];
|
||||
return _AlertCard(
|
||||
alert: alert,
|
||||
dateLabel: alert.dueDate != null
|
||||
? dateFormat.format(alert.dueDate!)
|
||||
: null,
|
||||
onTap: alert.assetId != null
|
||||
? () => context.push('${RouteConstants.assets}/${alert.assetId}')
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlertCard extends StatelessWidget {
|
||||
const _AlertCard({
|
||||
required this.alert,
|
||||
this.dateLabel,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final AssetAlertModel alert;
|
||||
final String? dateLabel;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppCard(
|
||||
child: ListTile(
|
||||
onTap: onTap,
|
||||
leading: Icon(
|
||||
alert.type?.toUpperCase() == 'AMC'
|
||||
? Icons.handshake_outlined
|
||||
: alert.type?.toUpperCase() == 'INSURANCE'
|
||||
? Icons.shield_outlined
|
||||
: Icons.notifications_outlined,
|
||||
),
|
||||
title: Text(alert.assetName ?? alert.title ?? 'Asset ${alert.assetId ?? ''}'),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (alert.assetCode != null) alert.assetCode,
|
||||
if (alert.type != null) alert.type,
|
||||
if (alert.plantName != null) alert.plantName,
|
||||
if (dateLabel != null) 'Due: $dateLabel',
|
||||
if (alert.daysRemaining != null) '${alert.daysRemaining} days',
|
||||
].whereType<String>().join(' · '),
|
||||
),
|
||||
trailing: alert.status != null
|
||||
? Chip(label: Text(alert.status!, style: const TextStyle(fontSize: 11)))
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -36,7 +36,10 @@ class AssetCategoriesScreen extends ConsumerWidget {
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.category_outlined),
|
||||
title: Text(category.name),
|
||||
subtitle: Text(category.slug),
|
||||
subtitle: Text(
|
||||
'${category.code}'
|
||||
'${category.defaultDepreciationMethod != null ? ' · ${category.defaultDepreciationMethod}' : ''}',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -1,77 +1,422 @@
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../core/constants/enums.dart';
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/providers/permissions_provider.dart';
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
import '../../../../shared/widgets/can_permission.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../providers/assets_provider.dart';
|
||||
import '../widgets/asset_form_panel.dart';
|
||||
import '../widgets/asset_side_panels.dart';
|
||||
|
||||
class AssetDetailScreen extends StatelessWidget {
|
||||
class AssetDetailScreen extends ConsumerStatefulWidget {
|
||||
const AssetDetailScreen({super.key, required this.assetId});
|
||||
|
||||
final String assetId;
|
||||
|
||||
@override
|
||||
ConsumerState<AssetDetailScreen> createState() => _AssetDetailScreenState();
|
||||
}
|
||||
|
||||
class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PageHeader(
|
||||
title: 'Asset Details',
|
||||
subtitle: 'ID: $assetId',
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: AppCard(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_DetailRow(label: 'Asset Name', value: '—'),
|
||||
_DetailRow(label: 'Asset Code', value: '—'),
|
||||
_DetailRow(label: 'Category', value: '—'),
|
||||
_DetailRow(label: 'Serial Number', value: '—'),
|
||||
_DetailRow(label: 'Status', value: '—'),
|
||||
_DetailRow(label: 'Branch', value: '—'),
|
||||
],
|
||||
),
|
||||
final detailAsync = ref.watch(assetDetailProvider(widget.assetId));
|
||||
final canEdit = ref.can('assets', PermissionAction.update);
|
||||
final canDelete = ref.can('assets', PermissionAction.delete);
|
||||
|
||||
return detailAsync.when(
|
||||
loading: () => const AppLoadingView(message: 'Loading asset details...'),
|
||||
error: (e, _) => ErrorView.fromFailure(
|
||||
e is Failure ? e : Failure.unknown(message: e.toString()),
|
||||
onRetry: () => ref.invalidate(assetDetailProvider(widget.assetId)),
|
||||
),
|
||||
data: (state) => Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PageHeader(
|
||||
title: state.asset.assetName,
|
||||
subtitle: state.asset.assetCode ?? 'Asset ID: ${state.asset.id}',
|
||||
actions: [
|
||||
if (canEdit)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
openAssetFormPanel(context, ref, assetId: widget.assetId),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
),
|
||||
if (canEdit) ...[
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _showTransferDialog(state.asset),
|
||||
icon: const Icon(Icons.swap_horiz),
|
||||
label: const Text('Transfer'),
|
||||
),
|
||||
],
|
||||
if (canDelete) ...[
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _deleteAsset,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: 'Overview'),
|
||||
Tab(text: 'AMC'),
|
||||
Tab(text: 'Service Visits'),
|
||||
Tab(text: 'Insurance'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_OverviewTab(asset: state.asset),
|
||||
_AmcTab(assetId: widget.assetId, contracts: state.amcContracts),
|
||||
_ServiceVisitsTab(assetId: widget.assetId, visits: state.serviceVisits),
|
||||
_InsuranceTab(assetId: widget.assetId, policies: state.insurancePolicies),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
AppCard(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text('QR Code'),
|
||||
const SizedBox(height: 16),
|
||||
QrImageView(
|
||||
data: assetId,
|
||||
version: QrVersions.auto,
|
||||
size: 160,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteAsset() async {
|
||||
final confirmed = await showAppConfirmationDialog(
|
||||
context: context,
|
||||
title: 'Delete Asset',
|
||||
message: 'Soft delete this asset?',
|
||||
confirmLabel: 'Delete',
|
||||
isDestructive: true,
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
final deleted = await ref.read(assetDetailProvider(widget.assetId).notifier).deleteAsset();
|
||||
if (deleted && mounted) context.go(RouteConstants.assets);
|
||||
}
|
||||
|
||||
Future<void> _showTransferDialog(AssetModel asset) async {
|
||||
final plantIdController = TextEditingController(text: asset.plantId?.toString() ?? '');
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Transfer Asset'),
|
||||
content: TextField(
|
||||
controller: plantIdController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Target Plant ID',
|
||||
helperText: 'Enter the plant ID to transfer this asset to',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Transfer')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
final plantId = int.tryParse(plantIdController.text.trim());
|
||||
if (plantId == null) return;
|
||||
|
||||
try {
|
||||
await ref.read(assetDetailProvider(widget.assetId).notifier).transferAsset({
|
||||
'plant_id': plantId,
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Asset transferred')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _OverviewTab extends StatelessWidget {
|
||||
const _OverviewTab({required this.asset});
|
||||
|
||||
final AssetModel asset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateFormat = DateFormat('dd MMM yyyy');
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: AppCard(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
_DetailRow(label: 'Asset Name', value: asset.assetName),
|
||||
_DetailRow(label: 'Asset Code', value: asset.assetCode ?? '—'),
|
||||
_DetailRow(label: 'Category', value: asset.assetCategoryName ?? '—'),
|
||||
_DetailRow(label: 'Plant', value: asset.plantName ?? '—'),
|
||||
_DetailRow(
|
||||
label: 'Warranty Expiry',
|
||||
value: asset.warrantyExpiryDate != null
|
||||
? dateFormat.format(asset.warrantyExpiryDate!)
|
||||
: '—',
|
||||
),
|
||||
_DetailRow(
|
||||
label: 'Purchase Cost',
|
||||
value: asset.purchaseCost != null ? '₹${asset.purchaseCost}' : '—',
|
||||
),
|
||||
_DetailRow(
|
||||
label: 'Status',
|
||||
valueWidget: AppStatusChip(status: asset.status ?? 'active'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AmcTab extends ConsumerWidget {
|
||||
const _AmcTab({required this.assetId, required this.contracts});
|
||||
|
||||
final String assetId;
|
||||
final List<AmcContractModel> contracts;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CanPermission(
|
||||
module: 'assets',
|
||||
action: PermissionAction.create,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _openAddAmcPanel(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add AMC'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: contracts.isEmpty
|
||||
? const Center(child: Text('No AMC contracts'))
|
||||
: ListView.separated(
|
||||
itemCount: contracts.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final c = contracts[index];
|
||||
return AppCard(
|
||||
child: ListTile(
|
||||
title: Text(c.contractNo ?? 'AMC #${c.id}'),
|
||||
subtitle: Text(
|
||||
'${c.vendorName ?? 'Vendor ${c.vendorId ?? '—'}'} · '
|
||||
'${DateFormat('dd MMM yyyy').format(c.startDate)} – '
|
||||
'${DateFormat('dd MMM yyyy').format(c.endDate)}',
|
||||
),
|
||||
trailing: c.annualCost != null ? Text('₹${c.annualCost}') : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openAddAmcPanel(BuildContext context, WidgetRef ref) async {
|
||||
final saved = await showSidePanel<bool>(
|
||||
context,
|
||||
AddAmcPanel(assetId: assetId),
|
||||
width: 520,
|
||||
);
|
||||
if (saved == true && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('AMC contract created')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceVisitsTab extends ConsumerWidget {
|
||||
const _ServiceVisitsTab({required this.assetId, required this.visits});
|
||||
|
||||
final String assetId;
|
||||
final List<ServiceVisitModel> visits;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CanPermission(
|
||||
module: 'assets',
|
||||
action: PermissionAction.create,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _openLogVisitPanel(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Log Visit'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: visits.isEmpty
|
||||
? const Center(child: Text('No service visits'))
|
||||
: ListView.separated(
|
||||
itemCount: visits.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final v = visits[index];
|
||||
return AppCard(
|
||||
child: ListTile(
|
||||
title: Text(v.visitType),
|
||||
subtitle: Text(
|
||||
'${DateFormat('dd MMM yyyy').format(v.visitDate)}'
|
||||
'${v.workDone != null ? ' · ${v.workDone}' : ''}',
|
||||
),
|
||||
trailing: AppStatusChip(status: v.status, compact: true),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openLogVisitPanel(BuildContext context, WidgetRef ref) async {
|
||||
final saved = await showSidePanel<bool>(
|
||||
context,
|
||||
LogServiceVisitPanel(assetId: assetId),
|
||||
width: 520,
|
||||
);
|
||||
if (saved == true && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Service visit logged')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _InsuranceTab extends ConsumerWidget {
|
||||
const _InsuranceTab({required this.assetId, required this.policies});
|
||||
|
||||
final String assetId;
|
||||
final List<InsurancePolicyModel> policies;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CanPermission(
|
||||
module: 'assets',
|
||||
action: PermissionAction.create,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _openAddInsurancePanel(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Policy'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: policies.isEmpty
|
||||
? const Center(child: Text('No insurance policies'))
|
||||
: ListView.separated(
|
||||
itemCount: policies.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final p = policies[index];
|
||||
return AppCard(
|
||||
child: ListTile(
|
||||
title: Text(p.policyNo),
|
||||
subtitle: Text(
|
||||
'${p.insurerName} · '
|
||||
'${DateFormat('dd MMM yyyy').format(p.policyStartDate)} – '
|
||||
'${DateFormat('dd MMM yyyy').format(p.policyEndDate)}',
|
||||
),
|
||||
trailing: p.sumInsured != null ? Text('₹${p.sumInsured}') : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openAddInsurancePanel(BuildContext context, WidgetRef ref) async {
|
||||
final saved = await showSidePanel<bool>(
|
||||
context,
|
||||
AddInsurancePanel(assetId: assetId),
|
||||
width: 520,
|
||||
);
|
||||
if (saved == true && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Insurance policy created')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailRow extends StatelessWidget {
|
||||
const _DetailRow({required this.label, required this.value});
|
||||
const _DetailRow({
|
||||
required this.label,
|
||||
this.value,
|
||||
this.valueWidget,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final String? value;
|
||||
final Widget? valueWidget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -80,7 +425,7 @@ class _DetailRow extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 140,
|
||||
width: 160,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
@ -88,7 +433,9 @@ class _DetailRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(value, style: Theme.of(context).textTheme.bodyLarge)),
|
||||
Expanded(
|
||||
child: valueWidget ?? Text(value ?? '—', style: Theme.of(context).textTheme.bodyLarge),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/enums.dart';
|
||||
import '../../../../core/utils/validators.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
|
||||
class AssetFormScreen extends StatefulWidget {
|
||||
const AssetFormScreen({super.key, this.assetId});
|
||||
|
||||
final String? assetId;
|
||||
|
||||
@override
|
||||
State<AssetFormScreen> createState() => _AssetFormScreenState();
|
||||
}
|
||||
|
||||
class _AssetFormScreenState extends State<AssetFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _codeController = TextEditingController();
|
||||
final _brandController = TextEditingController();
|
||||
final _modelController = TextEditingController();
|
||||
final _serialController = TextEditingController();
|
||||
final _vendorController = TextEditingController();
|
||||
final _costController = TextEditingController();
|
||||
AssetCategoryType _category = AssetCategoryType.laptop;
|
||||
AssetStatus _status = AssetStatus.available;
|
||||
|
||||
bool get isEditing => widget.assetId != null;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_codeController.dispose();
|
||||
_brandController.dispose();
|
||||
_modelController.dispose();
|
||||
_serialController.dispose();
|
||||
_vendorController.dispose();
|
||||
_costController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(isEditing ? 'Edit Asset' : 'Add Asset')),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
AppTextField(
|
||||
controller: _nameController,
|
||||
label: 'Asset Name',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Asset name'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _codeController,
|
||||
label: 'Asset Code',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Asset code'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<AssetCategoryType>(
|
||||
value: _category,
|
||||
decoration: const InputDecoration(labelText: 'Category'),
|
||||
items: AssetCategoryType.values
|
||||
.map((c) => DropdownMenuItem(value: c, child: Text(c.label)))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _category = v!),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(controller: _brandController, label: 'Brand'),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(controller: _modelController, label: 'Model'),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(controller: _serialController, label: 'Serial Number'),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _costController,
|
||||
label: 'Purchase Cost',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(controller: _vendorController, label: 'Vendor'),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<AssetStatus>(
|
||||
value: _status,
|
||||
decoration: const InputDecoration(labelText: 'Status'),
|
||||
items: AssetStatus.values
|
||||
.map((s) => DropdownMenuItem(value: s, child: Text(s.label)))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _status = v!),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
AppButton(
|
||||
label: isEditing ? 'Update Asset' : 'Create Asset',
|
||||
onPressed: () {
|
||||
if (_formKey.currentState!.validate()) Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,46 +1,802 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/constants/enums.dart';
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../core/utils/responsive_utils.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/providers/permissions_provider.dart';
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/can_permission.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../../rbac/presentation/widgets/rbac_widgets.dart';
|
||||
import '../providers/asset_categories_provider.dart';
|
||||
import '../providers/assets_provider.dart';
|
||||
import '../widgets/asset_form_panel.dart';
|
||||
|
||||
class AssetListScreen extends StatelessWidget {
|
||||
class AssetListScreen extends ConsumerStatefulWidget {
|
||||
const AssetListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AssetListScreen> createState() => _AssetListScreenState();
|
||||
}
|
||||
|
||||
class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
static const _tableMinWidth = 1040.0;
|
||||
|
||||
String? _selectedCategory;
|
||||
String? _selectedPlant;
|
||||
String? _selectedStatus;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final assetsAsync = ref.watch(assetsListProvider);
|
||||
final canEdit = ref.can('assets', PermissionAction.update);
|
||||
final canDelete = ref.can('assets', PermissionAction.delete);
|
||||
|
||||
ref.listen(assetsListProvider, (prev, next) {
|
||||
final error = next.valueOrNull?.actionError;
|
||||
final success = next.valueOrNull?.actionSuccess;
|
||||
if (error != null && error != prev?.valueOrNull?.actionError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
if (success != null && success != prev?.valueOrNull?.actionSuccess) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(success)));
|
||||
}
|
||||
});
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PageHeader(
|
||||
title: 'Asset Master',
|
||||
subtitle: 'Manage all company assets',
|
||||
actions: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go(RouteConstants.assetQrScan),
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
label: const Text('QR Scan'),
|
||||
child: assetsAsync.when(
|
||||
loading: () => const AppLoadingView(message: 'Loading assets...'),
|
||||
error: (error, _) => ErrorView.fromFailure(
|
||||
error is Failure ? error : Failure.unknown(message: error.toString()),
|
||||
onRetry: () => ref.invalidate(assetsListProvider),
|
||||
),
|
||||
data: (state) {
|
||||
final allCategories = ref.watch(assetCategoriesProvider).valueOrNull ?? [];
|
||||
final filteredAssets = _filterAssets(state.assets);
|
||||
final categories = _categoryOptions(state.assets, allCategories);
|
||||
final plants = _plantOptions(state.assets);
|
||||
final statuses = _statusOptions();
|
||||
final categoryFilter = _selectedCategory ?? 'All categories';
|
||||
final plantFilter = _selectedPlant ?? 'All plants';
|
||||
final statusFilter = _selectedStatus ?? 'All statuses';
|
||||
final page = state.query.page;
|
||||
final pageSize = state.query.limit;
|
||||
final total = state.total;
|
||||
final start = total == 0 ? 0 : ((page - 1) * pageSize) + 1;
|
||||
final end = (page * pageSize).clamp(0, total);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PageHeader(
|
||||
title: 'Asset Master',
|
||||
subtitle: 'Manage plant assets, AMC, service & insurance',
|
||||
actions: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go(RouteConstants.assetAlerts),
|
||||
icon: const Icon(Icons.notifications_outlined),
|
||||
label: const Text('Alerts'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
CanPermission(
|
||||
module: 'assets',
|
||||
action: PermissionAction.create,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => openAssetFormPanel(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Asset'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.push('${RouteConstants.assets}/add'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Asset'),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => ref.read(assetsListProvider.notifier).refresh(),
|
||||
child: context.isMobile
|
||||
? _AssetMobileList(
|
||||
assets: filteredAssets,
|
||||
onView: _viewAsset,
|
||||
onEdit: canEdit ? _editAsset : null,
|
||||
onDelete: canDelete ? _deleteAsset : null,
|
||||
)
|
||||
: AppCard(
|
||||
enableHover: false,
|
||||
clipBehavior: Clip.none,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outline
|
||||
.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return _AssetsFilterBar(
|
||||
wrapped: constraints.maxWidth < 1000,
|
||||
categoryFilter: categoryFilter,
|
||||
plantFilter: plantFilter,
|
||||
statusFilter: statusFilter,
|
||||
categories: categories,
|
||||
plants: plants,
|
||||
statuses: statuses,
|
||||
onSearch:
|
||||
ref.read(assetsListProvider.notifier).setSearch,
|
||||
onCategoryChanged: (value) {
|
||||
setState(() {
|
||||
_selectedCategory =
|
||||
value == 'All categories' ? null : value;
|
||||
});
|
||||
},
|
||||
onPlantChanged: (value) {
|
||||
setState(() {
|
||||
_selectedPlant =
|
||||
value == 'All plants' ? null : value;
|
||||
});
|
||||
},
|
||||
onStatusChanged: (value) {
|
||||
setState(() {
|
||||
_selectedStatus =
|
||||
value == 'All statuses' ? null : value;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
if (filteredAssets.isEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No assets found',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final tableWidth =
|
||||
constraints.maxWidth < _tableMinWidth
|
||||
? _tableMinWidth
|
||||
: constraints.maxWidth;
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: ConstrainedBox(
|
||||
constraints:
|
||||
BoxConstraints(minWidth: tableWidth),
|
||||
child: DataTable(
|
||||
horizontalMargin: 20,
|
||||
columnSpacing: 16,
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.4),
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('ASSET')),
|
||||
DataColumn(label: Text('CATEGORY')),
|
||||
DataColumn(label: Text('PLANT')),
|
||||
DataColumn(label: Text('WARRANTY')),
|
||||
DataColumn(label: Text('STATUS')),
|
||||
DataColumn(
|
||||
label: _AssetTableActionsHeader(),
|
||||
),
|
||||
],
|
||||
rows: filteredAssets.map((asset) {
|
||||
final status =
|
||||
assetStatusDisplay(asset.status);
|
||||
return DataRow(
|
||||
cells: [
|
||||
DataCell(
|
||||
Row(
|
||||
children: [
|
||||
UserAvatarChip(
|
||||
name: asset.assetName,
|
||||
initials:
|
||||
assetInitials(asset),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
asset.assetName,
|
||||
style: const TextStyle(
|
||||
fontWeight:
|
||||
FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
asset.assetCode ?? '—',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
RoleBadge(
|
||||
label: asset.assetCategoryName ??
|
||||
'—',
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(asset.plantName ?? '—'),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
DateFormatter.displayDate(
|
||||
asset.warrantyExpiryDate,
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
StatusBadge(
|
||||
label: status.label,
|
||||
color: status.color,
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
_AssetTableActionsCell(
|
||||
child: _AssetTableActions(
|
||||
canView: true,
|
||||
canEdit: canEdit,
|
||||
canDelete: canDelete,
|
||||
onView: () => _viewAsset(asset),
|
||||
onEdit: () => _editAsset(asset),
|
||||
onDelete: () =>
|
||||
_deleteAsset(asset),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Showing $start–$end of $total assets',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: page > 1
|
||||
? () => ref
|
||||
.read(assetsListProvider.notifier)
|
||||
.setPage(page - 1)
|
||||
: null,
|
||||
child: const Text('Previous'),
|
||||
),
|
||||
...List.generate(state.totalPages.clamp(0, 4), (i) {
|
||||
final pageIndex = i + 1;
|
||||
final selected = page == pageIndex;
|
||||
return Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Material(
|
||||
color: selected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () => ref
|
||||
.read(assetsListProvider.notifier)
|
||||
.setPage(pageIndex),
|
||||
child: SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$pageIndex',
|
||||
style: TextStyle(
|
||||
color: selected
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimary
|
||||
: null,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
TextButton(
|
||||
onPressed: page < state.totalPages
|
||||
? () => ref
|
||||
.read(assetsListProvider.notifier)
|
||||
.setPage(page + 1)
|
||||
: null,
|
||||
child: const Text('Next'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Expanded(
|
||||
child: EmptyStateView(
|
||||
title: 'No assets yet',
|
||||
description: 'Add assets to start tracking laptops, devices, furniture, and more.',
|
||||
icon: Icons.inventory_2_outlined,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<AssetModel> _filterAssets(List<AssetModel> assets) {
|
||||
return assets.where((asset) {
|
||||
if (_selectedCategory != null &&
|
||||
asset.assetCategoryName != _selectedCategory) {
|
||||
return false;
|
||||
}
|
||||
if (_selectedPlant != null && asset.plantName != _selectedPlant) {
|
||||
return false;
|
||||
}
|
||||
if (_selectedStatus != null) {
|
||||
final label = assetStatusDisplay(asset.status).label;
|
||||
if (label != _selectedStatus) return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<String> _categoryOptions(
|
||||
List<AssetModel> assets,
|
||||
List<AssetCategoryModel> allCategories,
|
||||
) {
|
||||
final names = <String>{};
|
||||
for (final asset in assets) {
|
||||
final name = asset.assetCategoryName;
|
||||
if (name != null && name.isNotEmpty) names.add(name);
|
||||
}
|
||||
for (final category in allCategories) {
|
||||
names.add(category.name);
|
||||
}
|
||||
final sorted = names.toList()..sort();
|
||||
return ['All categories', ...sorted];
|
||||
}
|
||||
|
||||
List<String> _plantOptions(List<AssetModel> assets) {
|
||||
final names = assets
|
||||
.map((a) => a.plantName)
|
||||
.whereType<String>()
|
||||
.where((n) => n.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
return ['All plants', ...names];
|
||||
}
|
||||
|
||||
List<String> _statusOptions() {
|
||||
return [
|
||||
'All statuses',
|
||||
'In Use',
|
||||
'Available',
|
||||
'Under Maintenance',
|
||||
'Disposed',
|
||||
'Inactive',
|
||||
];
|
||||
}
|
||||
|
||||
void _viewAsset(AssetModel asset) {
|
||||
context.push('${RouteConstants.assets}/${asset.id}');
|
||||
}
|
||||
|
||||
void _editAsset(AssetModel asset) {
|
||||
openAssetFormPanel(context, ref, assetId: asset.id);
|
||||
}
|
||||
|
||||
Future<void> _deleteAsset(AssetModel asset) async {
|
||||
final confirmed = await showAppConfirmationDialog(
|
||||
context: context,
|
||||
title: 'Delete Asset',
|
||||
message: 'Soft delete "${asset.assetName}"?',
|
||||
confirmLabel: 'Delete',
|
||||
isDestructive: true,
|
||||
);
|
||||
if (confirmed == true && mounted) {
|
||||
await ref.read(assetsListProvider.notifier).deleteAsset(asset.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String assetInitials(AssetModel asset) {
|
||||
final code = asset.assetCode;
|
||||
if (code != null && code.isNotEmpty) {
|
||||
return code.length >= 2 ? code.substring(0, 2).toUpperCase() : code.toUpperCase();
|
||||
}
|
||||
if (asset.assetName.isNotEmpty) {
|
||||
final parts = asset.assetName.trim().split(RegExp(r'\s+'));
|
||||
if (parts.length >= 2) {
|
||||
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
|
||||
}
|
||||
return asset.assetName[0].toUpperCase();
|
||||
}
|
||||
return 'A';
|
||||
}
|
||||
|
||||
({String label, Color color}) assetStatusDisplay(String? status) {
|
||||
return switch (status?.toUpperCase().replaceAll(' ', '_')) {
|
||||
'IN_USE' => (label: 'In Use', color: const Color(0xFF16A34A)),
|
||||
'AVAILABLE' => (label: 'Available', color: const Color(0xFF2563EB)),
|
||||
'UNDER_MAINTENANCE' => (
|
||||
label: 'Under Maintenance',
|
||||
color: const Color(0xFFCA8A04),
|
||||
),
|
||||
'DISPOSED' => (label: 'Disposed', color: const Color(0xFF6B7280)),
|
||||
'INACTIVE' => (label: 'Inactive', color: const Color(0xFFDC2626)),
|
||||
_ when status != null && status.isNotEmpty => (
|
||||
label: status.replaceAll('_', ' '),
|
||||
color: const Color(0xFF16A34A),
|
||||
),
|
||||
_ => (label: 'Active', color: const Color(0xFF16A34A)),
|
||||
};
|
||||
}
|
||||
|
||||
class _AssetsFilterBar extends StatelessWidget {
|
||||
const _AssetsFilterBar({
|
||||
required this.wrapped,
|
||||
required this.categoryFilter,
|
||||
required this.plantFilter,
|
||||
required this.statusFilter,
|
||||
required this.categories,
|
||||
required this.plants,
|
||||
required this.statuses,
|
||||
required this.onSearch,
|
||||
required this.onCategoryChanged,
|
||||
required this.onPlantChanged,
|
||||
required this.onStatusChanged,
|
||||
});
|
||||
|
||||
final bool wrapped;
|
||||
final String categoryFilter;
|
||||
final String plantFilter;
|
||||
final String statusFilter;
|
||||
final List<String> categories;
|
||||
final List<String> plants;
|
||||
final List<String> statuses;
|
||||
final ValueChanged<String> onSearch;
|
||||
final ValueChanged<String> onCategoryChanged;
|
||||
final ValueChanged<String> onPlantChanged;
|
||||
final ValueChanged<String> onStatusChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final searchField = TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search by name, code, category...',
|
||||
prefixIcon: Icon(Icons.search, size: 20),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: onSearch,
|
||||
);
|
||||
|
||||
final filters = [
|
||||
_AssetFilterDropdown(
|
||||
value: categoryFilter,
|
||||
label: 'Category',
|
||||
items: categories,
|
||||
onChanged: onCategoryChanged,
|
||||
),
|
||||
_AssetFilterDropdown(
|
||||
value: plantFilter,
|
||||
label: 'Plant',
|
||||
items: plants,
|
||||
onChanged: onPlantChanged,
|
||||
),
|
||||
_AssetFilterDropdown(
|
||||
value: statusFilter,
|
||||
label: 'Status',
|
||||
items: statuses,
|
||||
onChanged: onStatusChanged,
|
||||
),
|
||||
];
|
||||
|
||||
if (wrapped) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
searchField,
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 12, runSpacing: 12, children: filters),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(flex: 3, child: searchField),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(flex: 2, child: filters[0]),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(flex: 2, child: filters[1]),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: filters[2]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetFilterDropdown extends StatelessWidget {
|
||||
const _AssetFilterDropdown({
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.items,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String value;
|
||||
final String label;
|
||||
final List<String> items;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppSearchableDropdown<String>(
|
||||
label: label,
|
||||
value: value,
|
||||
isDense: true,
|
||||
searchHint: 'Search $label...',
|
||||
options: items
|
||||
.map((item) => AppDropdownOption(value: item, label: item))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetTableActions extends StatelessWidget {
|
||||
const _AssetTableActions({
|
||||
required this.onView,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
this.canView = true,
|
||||
this.canEdit = true,
|
||||
this.canDelete = true,
|
||||
});
|
||||
|
||||
static const columnWidth = 108.0;
|
||||
|
||||
final VoidCallback onView;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
final bool canView;
|
||||
final bool canEdit;
|
||||
final bool canDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (canView)
|
||||
_AssetActionIcon(
|
||||
tooltip: 'View asset',
|
||||
icon: Icons.visibility_outlined,
|
||||
color: muted,
|
||||
onPressed: onView,
|
||||
),
|
||||
if (canEdit)
|
||||
_AssetActionIcon(
|
||||
tooltip: 'Edit asset',
|
||||
icon: Icons.edit_outlined,
|
||||
color: muted,
|
||||
onPressed: onEdit,
|
||||
),
|
||||
if (canDelete)
|
||||
_AssetActionIcon(
|
||||
tooltip: 'Delete asset',
|
||||
icon: Icons.delete_outline,
|
||||
color: muted,
|
||||
onPressed: onDelete,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetTableActionsHeader extends StatelessWidget {
|
||||
const _AssetTableActionsHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SizedBox(
|
||||
width: _AssetTableActions.columnWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text('ACTIONS'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetTableActionsCell extends StatelessWidget {
|
||||
const _AssetTableActionsCell({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: _AssetTableActions.columnWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetActionIcon extends StatelessWidget {
|
||||
const _AssetActionIcon({
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(icon, size: 18, color: color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetMobileList extends StatelessWidget {
|
||||
const _AssetMobileList({
|
||||
required this.assets,
|
||||
required this.onView,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
final List<AssetModel> assets;
|
||||
final void Function(AssetModel) onView;
|
||||
final void Function(AssetModel)? onEdit;
|
||||
final void Function(AssetModel)? onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (assets.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
AppEmptyState(
|
||||
title: 'No assets found',
|
||||
description: 'Add assets or adjust your search.',
|
||||
icon: Icons.inventory_2_outlined,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: assets.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final asset = assets[index];
|
||||
final status = assetStatusDisplay(asset.status);
|
||||
|
||||
return AppCard(
|
||||
child: ListTile(
|
||||
onTap: () => onView(asset),
|
||||
leading: UserAvatarChip(
|
||||
name: asset.assetName,
|
||||
initials: assetInitials(asset),
|
||||
),
|
||||
title: Text(
|
||||
asset.assetName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(asset.assetCode ?? '—'),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: RoleBadge(
|
||||
label: asset.assetCategoryName ?? '—',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
StatusBadge(label: status.label, color: status.color),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (onEdit != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => onEdit!(asset),
|
||||
),
|
||||
if (onDelete != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => onDelete!(asset),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
326
lib/modules/assets/presentation/widgets/asset_form_panel.dart
Normal file
326
lib/modules/assets/presentation/widgets/asset_form_panel.dart
Normal file
@ -0,0 +1,326 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../core/utils/validators.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart' show FilterOptionModel;
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../masters/data/datasources/master_remote_data_source.dart';
|
||||
import '../providers/asset_categories_provider.dart';
|
||||
import '../providers/assets_provider.dart';
|
||||
|
||||
Future<void> openAssetFormPanel(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
String? assetId,
|
||||
}) async {
|
||||
ref.invalidate(assetFormProvider(assetId));
|
||||
final saved = await showSidePanel<bool>(
|
||||
context,
|
||||
AssetFormPanel(assetId: assetId),
|
||||
width: 520,
|
||||
);
|
||||
if (saved == true && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
assetId == null
|
||||
? 'Asset created successfully'
|
||||
: 'Asset updated successfully',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AssetFormPanel extends ConsumerStatefulWidget {
|
||||
const AssetFormPanel({super.key, this.assetId});
|
||||
|
||||
final String? assetId;
|
||||
|
||||
bool get isEditing => assetId != null;
|
||||
|
||||
@override
|
||||
ConsumerState<AssetFormPanel> createState() => _AssetFormPanelState();
|
||||
}
|
||||
|
||||
class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _costController = TextEditingController();
|
||||
int? _categoryId;
|
||||
int? _plantId;
|
||||
DateTime? _warrantyExpiry;
|
||||
bool _isSubmitting = false;
|
||||
String? _populatedSignature;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_costController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _assetSignature(AssetModel asset) =>
|
||||
'${asset.id}:${asset.assetCategoryId}:${asset.plantId}:'
|
||||
'${asset.warrantyExpiryDate?.toIso8601String()}:'
|
||||
'${asset.purchaseCost}:${asset.assetName}';
|
||||
|
||||
int? _dropdownValue(int? selected, Iterable<int> validIds) {
|
||||
if (selected == null) return null;
|
||||
return validIds.contains(selected) ? selected : null;
|
||||
}
|
||||
|
||||
void _populateFromAsset(AssetModel asset) {
|
||||
setState(() {
|
||||
_nameController.text = asset.assetName;
|
||||
_categoryId = asset.assetCategoryId;
|
||||
_plantId = asset.plantId;
|
||||
_warrantyExpiry = asset.warrantyExpiryDate;
|
||||
if (asset.purchaseCost != null) {
|
||||
_costController.text = asset.purchaseCost!.toStringAsFixed(
|
||||
asset.purchaseCost! % 1 == 0 ? 0 : 2,
|
||||
);
|
||||
} else {
|
||||
_costController.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPayload() {
|
||||
return {
|
||||
'asset_name': _nameController.text.trim(),
|
||||
'asset_category_id': _categoryId,
|
||||
'plant_id': _plantId,
|
||||
if (_warrantyExpiry != null)
|
||||
'warranty_expiry_date': DateFormat('yyyy-MM-dd').format(_warrantyExpiry!),
|
||||
if (_costController.text.trim().isNotEmpty)
|
||||
'purchase_cost': double.tryParse(_costController.text.trim()),
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _pickWarrantyDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _warrantyExpiry ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (picked != null) setState(() => _warrantyExpiry = picked);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_categoryId == null || _plantId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please select category and plant')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final notifier = ref.read(assetFormProvider(widget.assetId).notifier);
|
||||
final payload = _buildPayload();
|
||||
if (widget.isEditing) {
|
||||
await notifier.submitUpdate(widget.assetId!, payload);
|
||||
} else {
|
||||
await notifier.submitCreate(payload);
|
||||
}
|
||||
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final categoriesAsync = ref.watch(assetCategoriesProvider);
|
||||
final plantsAsync = ref.watch(assetPlantsProvider);
|
||||
|
||||
if (widget.isEditing) {
|
||||
ref.listen(assetFormProvider(widget.assetId), (prev, next) {
|
||||
next.whenData((asset) {
|
||||
if (asset == null || !mounted) return;
|
||||
final signature = _assetSignature(asset);
|
||||
if (_populatedSignature != signature) {
|
||||
_populatedSignature = signature;
|
||||
_populateFromAsset(asset);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
final formAsync =
|
||||
widget.isEditing ? ref.watch(assetFormProvider(widget.assetId)) : null;
|
||||
|
||||
return SidePanelScaffold(
|
||||
title: widget.isEditing ? 'Edit asset' : 'Add asset',
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () => Navigator.of(context, rootNavigator: true).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AppButton(
|
||||
label: widget.isEditing ? 'Update asset' : 'Save asset',
|
||||
expand: false,
|
||||
icon: Icons.check,
|
||||
isLoading: _isSubmitting,
|
||||
onPressed: _isSubmitting ? null : _submit,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: widget.isEditing && formAsync != null
|
||||
? formAsync.when(
|
||||
loading: () => const AppLoadingView(message: 'Loading asset...'),
|
||||
error: (e, _) => ErrorView.fromFailure(
|
||||
e is Failure ? e : Failure.unknown(message: e.toString()),
|
||||
onRetry: () => ref.invalidate(assetFormProvider(widget.assetId)),
|
||||
),
|
||||
data: (_) => _buildForm(categoriesAsync, plantsAsync),
|
||||
)
|
||||
: _buildForm(categoriesAsync, plantsAsync),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm(
|
||||
AsyncValue<List<AssetCategoryModel>> categoriesAsync,
|
||||
AsyncValue<List<FilterOptionModel>> plantsAsync,
|
||||
) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AppTextField(
|
||||
controller: _nameController,
|
||||
label: 'Asset Name *',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Asset name'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SidePanelFormRow(
|
||||
left: categoriesAsync.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('Failed to load categories'),
|
||||
data: (categories) => _categoryDropdown(categories),
|
||||
),
|
||||
right: plantsAsync.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('Failed to load plants'),
|
||||
data: (plants) => _plantDropdown(plants),
|
||||
),
|
||||
),
|
||||
SidePanelFormRow(
|
||||
left: _AssetFormDateField(
|
||||
label: 'Warranty Expiry Date',
|
||||
value: _warrantyExpiry,
|
||||
onPick: _pickWarrantyDate,
|
||||
),
|
||||
right: AppTextField(
|
||||
controller: _costController,
|
||||
label: 'Purchase Cost',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _categoryDropdown(List<AssetCategoryModel> categories) {
|
||||
final categoryIds = categories
|
||||
.map((c) => int.tryParse(c.id))
|
||||
.whereType<int>()
|
||||
.toList();
|
||||
return DropdownButtonFormField<int>(
|
||||
value: _dropdownValue(_categoryId, categoryIds),
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Category *'),
|
||||
items: categories
|
||||
.map(
|
||||
(c) => DropdownMenuItem(
|
||||
value: int.tryParse(c.id),
|
||||
child: Text('${c.code} — ${c.name}'),
|
||||
),
|
||||
)
|
||||
.where((item) => item.value != null)
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _categoryId = v),
|
||||
validator: (v) => v == null ? 'Category is required' : null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _plantDropdown(List<FilterOptionModel> plants) {
|
||||
final plantIds = plants
|
||||
.map((p) => int.tryParse(p.id))
|
||||
.whereType<int>()
|
||||
.toList();
|
||||
return DropdownButtonFormField<int>(
|
||||
value: _dropdownValue(_plantId, plantIds),
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Plant *'),
|
||||
items: plants
|
||||
.map(
|
||||
(p) => DropdownMenuItem(
|
||||
value: int.tryParse(p.id),
|
||||
child: Text(p.name),
|
||||
),
|
||||
)
|
||||
.where((item) => item.value != null)
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _plantId = v),
|
||||
validator: (v) => v == null ? 'Plant is required' : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetFormDateField extends StatelessWidget {
|
||||
const _AssetFormDateField({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onPick,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final DateTime? value;
|
||||
final VoidCallback onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final displayText = value != null ? DateFormatter.displayDate(value) : '';
|
||||
|
||||
return TextFormField(
|
||||
readOnly: true,
|
||||
onTap: onPick,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: 'Select date',
|
||||
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 20),
|
||||
),
|
||||
controller: TextEditingController(text: displayText),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final assetPlantsProvider = FutureProvider<List<FilterOptionModel>>((ref) async {
|
||||
final dataSource = ref.watch(masterRemoteDataSourceProvider);
|
||||
return dataSource.listPlants();
|
||||
});
|
||||
500
lib/modules/assets/presentation/widgets/asset_side_panels.dart
Normal file
500
lib/modules/assets/presentation/widgets/asset_side_panels.dart
Normal file
@ -0,0 +1,500 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../core/utils/validators.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../providers/assets_provider.dart';
|
||||
|
||||
class AddAmcPanel extends ConsumerStatefulWidget {
|
||||
const AddAmcPanel({super.key, required this.assetId});
|
||||
|
||||
final String assetId;
|
||||
|
||||
@override
|
||||
ConsumerState<AddAmcPanel> createState() => _AddAmcPanelState();
|
||||
}
|
||||
|
||||
class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _vendorIdController = TextEditingController();
|
||||
final _contractNoController = TextEditingController();
|
||||
final _annualCostController = TextEditingController();
|
||||
DateTime? _startDate;
|
||||
DateTime? _endDate;
|
||||
String _contractType = 'COMPREHENSIVE';
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_vendorIdController.dispose();
|
||||
_contractNoController.dispose();
|
||||
_annualCostController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickDate({required bool isStart}) async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: (isStart ? _startDate : _endDate) ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
if (isStart) {
|
||||
_startDate = picked;
|
||||
} else {
|
||||
_endDate = picked;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_startDate == null || _endDate == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please select start and end dates')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final vendorId = int.tryParse(_vendorIdController.text.trim());
|
||||
if (vendorId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please enter a valid vendor ID')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
await ref.read(assetDetailProvider(widget.assetId).notifier).createAmc({
|
||||
'vendor_id': vendorId,
|
||||
if (_contractNoController.text.isNotEmpty)
|
||||
'contract_no': _contractNoController.text.trim(),
|
||||
'contract_type': _contractType,
|
||||
'start_date': DateFormatter.toApiDate(_startDate!),
|
||||
'end_date': DateFormatter.toApiDate(_endDate!),
|
||||
if (_annualCostController.text.isNotEmpty)
|
||||
'annual_cost': double.tryParse(_annualCostController.text),
|
||||
});
|
||||
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SidePanelScaffold(
|
||||
title: 'Add AMC Contract',
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
saveLabel: 'Save contract',
|
||||
onSave: _save,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SidePanelFormRow(
|
||||
left: AppTextField(
|
||||
controller: _vendorIdController,
|
||||
label: 'Vendor ID *',
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) => Validators.required(v, fieldName: 'Vendor ID'),
|
||||
),
|
||||
right: AppTextField(
|
||||
controller: _contractNoController,
|
||||
label: 'Contract No',
|
||||
),
|
||||
),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _contractType,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Contract Type'),
|
||||
items: const [
|
||||
'COMPREHENSIVE',
|
||||
'LABOUR_ONLY',
|
||||
'PARTS_ONLY',
|
||||
'PREVENTIVE_ONLY',
|
||||
]
|
||||
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) setState(() => _contractType = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SidePanelFormRow(
|
||||
left: _SidePanelDateField(
|
||||
label: 'Start Date',
|
||||
isRequired: true,
|
||||
value: _startDate,
|
||||
onPick: () => _pickDate(isStart: true),
|
||||
),
|
||||
right: _SidePanelDateField(
|
||||
label: 'End Date',
|
||||
isRequired: true,
|
||||
value: _endDate,
|
||||
onPick: () => _pickDate(isStart: false),
|
||||
),
|
||||
),
|
||||
AppTextField(
|
||||
controller: _annualCostController,
|
||||
label: 'Annual Cost',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LogServiceVisitPanel extends ConsumerStatefulWidget {
|
||||
const LogServiceVisitPanel({super.key, required this.assetId});
|
||||
|
||||
final String assetId;
|
||||
|
||||
@override
|
||||
ConsumerState<LogServiceVisitPanel> createState() => _LogServiceVisitPanelState();
|
||||
}
|
||||
|
||||
class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _workDoneController = TextEditingController();
|
||||
DateTime? _visitDate = DateTime.now();
|
||||
String _visitType = 'PREVENTIVE';
|
||||
String _status = 'COMPLETED';
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_workDoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickVisitDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _visitDate ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (picked != null) setState(() => _visitDate = picked);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_visitDate == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please select visit date')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
await ref.read(assetDetailProvider(widget.assetId).notifier).logVisit({
|
||||
'visit_type': _visitType,
|
||||
'visit_date': DateFormatter.toApiDate(_visitDate!),
|
||||
if (_workDoneController.text.isNotEmpty)
|
||||
'work_done': _workDoneController.text.trim(),
|
||||
'status': _status,
|
||||
});
|
||||
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SidePanelScaffold(
|
||||
title: 'Log Service Visit',
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
saveLabel: 'Save visit',
|
||||
onSave: _save,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SidePanelFormRow(
|
||||
left: DropdownButtonFormField<String>(
|
||||
value: _visitType,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Visit Type *'),
|
||||
items: const [
|
||||
'PREVENTIVE',
|
||||
'BREAKDOWN',
|
||||
'INSPECTION',
|
||||
'INSTALLATION',
|
||||
'CALIBRATION',
|
||||
'OTHER',
|
||||
]
|
||||
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) setState(() => _visitType = v);
|
||||
},
|
||||
),
|
||||
right: DropdownButtonFormField<String>(
|
||||
value: _status,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Status'),
|
||||
items: const [
|
||||
'SCHEDULED',
|
||||
'IN_PROGRESS',
|
||||
'COMPLETED',
|
||||
'CANCELLED',
|
||||
'PENDING_PARTS',
|
||||
]
|
||||
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) setState(() => _status = v);
|
||||
},
|
||||
),
|
||||
),
|
||||
_SidePanelDateField(
|
||||
label: 'Visit Date',
|
||||
isRequired: true,
|
||||
value: _visitDate,
|
||||
onPick: _pickVisitDate,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppTextField(
|
||||
controller: _workDoneController,
|
||||
label: 'Work Done',
|
||||
maxLines: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AddInsurancePanel extends ConsumerStatefulWidget {
|
||||
const AddInsurancePanel({super.key, required this.assetId});
|
||||
|
||||
final String assetId;
|
||||
|
||||
@override
|
||||
ConsumerState<AddInsurancePanel> createState() => _AddInsurancePanelState();
|
||||
}
|
||||
|
||||
class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _policyNoController = TextEditingController();
|
||||
final _insurerNameController = TextEditingController();
|
||||
final _sumInsuredController = TextEditingController();
|
||||
DateTime? _startDate;
|
||||
DateTime? _endDate;
|
||||
String _policyType = 'COMPREHENSIVE';
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_policyNoController.dispose();
|
||||
_insurerNameController.dispose();
|
||||
_sumInsuredController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickDate({required bool isStart}) async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: (isStart ? _startDate : _endDate) ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
if (isStart) {
|
||||
_startDate = picked;
|
||||
} else {
|
||||
_endDate = picked;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_startDate == null || _endDate == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please select start and end dates')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
await ref.read(assetDetailProvider(widget.assetId).notifier).createInsurance({
|
||||
'policy_no': _policyNoController.text.trim(),
|
||||
'insurer_name': _insurerNameController.text.trim(),
|
||||
'policy_type': _policyType,
|
||||
if (_sumInsuredController.text.isNotEmpty)
|
||||
'sum_insured': double.tryParse(_sumInsuredController.text),
|
||||
'policy_start_date': DateFormatter.toApiDate(_startDate!),
|
||||
'policy_end_date': DateFormatter.toApiDate(_endDate!),
|
||||
});
|
||||
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SidePanelScaffold(
|
||||
title: 'Add Insurance Policy',
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
saveLabel: 'Save policy',
|
||||
onSave: _save,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SidePanelFormRow(
|
||||
left: AppTextField(
|
||||
controller: _policyNoController,
|
||||
label: 'Policy No *',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Policy no'),
|
||||
),
|
||||
right: AppTextField(
|
||||
controller: _insurerNameController,
|
||||
label: 'Insurer Name *',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Insurer name'),
|
||||
),
|
||||
),
|
||||
SidePanelFormRow(
|
||||
left: DropdownButtonFormField<String>(
|
||||
value: _policyType,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Policy Type'),
|
||||
items: const [
|
||||
'FIRE_AND_ALLIED',
|
||||
'MACHINERY_BREAKDOWN',
|
||||
'COMPREHENSIVE',
|
||||
'THIRD_PARTY',
|
||||
'VEHICLE',
|
||||
'OTHER',
|
||||
]
|
||||
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) setState(() => _policyType = v);
|
||||
},
|
||||
),
|
||||
right: AppTextField(
|
||||
controller: _sumInsuredController,
|
||||
label: 'Sum Insured',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
SidePanelFormRow(
|
||||
left: _SidePanelDateField(
|
||||
label: 'Start Date',
|
||||
isRequired: true,
|
||||
value: _startDate,
|
||||
onPick: () => _pickDate(isStart: true),
|
||||
),
|
||||
right: _SidePanelDateField(
|
||||
label: 'End Date',
|
||||
isRequired: true,
|
||||
value: _endDate,
|
||||
onPick: () => _pickDate(isStart: false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _panelFooter(
|
||||
BuildContext context, {
|
||||
required bool isSubmitting,
|
||||
required String saveLabel,
|
||||
required VoidCallback onSave,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: isSubmitting
|
||||
? null
|
||||
: () => Navigator.of(context, rootNavigator: true).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AppButton(
|
||||
label: saveLabel,
|
||||
expand: false,
|
||||
icon: Icons.check,
|
||||
isLoading: isSubmitting,
|
||||
onPressed: isSubmitting ? null : onSave,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _SidePanelDateField extends StatelessWidget {
|
||||
const _SidePanelDateField({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onPick,
|
||||
this.isRequired = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final DateTime? value;
|
||||
final VoidCallback onPick;
|
||||
final bool isRequired;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labelText = isRequired ? '$label *' : label;
|
||||
final displayText = value != null ? DateFormatter.displayDate(value) : '';
|
||||
|
||||
return TextFormField(
|
||||
readOnly: true,
|
||||
onTap: onPick,
|
||||
decoration: InputDecoration(
|
||||
labelText: labelText,
|
||||
hintText: 'Select date',
|
||||
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 20),
|
||||
),
|
||||
controller: TextEditingController(text: displayText),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -77,15 +77,10 @@ final _entries = [
|
||||
),
|
||||
// Assets
|
||||
_GalleryEntry(title: 'Asset List', route: RouteConstants.assets, group: 'Assets'),
|
||||
_GalleryEntry(title: 'Add Asset', route: RouteConstants.assetAdd, group: 'Assets'),
|
||||
_GalleryEntry(title: 'Edit Asset', route: '/assets/demo-asset/edit', group: 'Assets'),
|
||||
_GalleryEntry(title: 'Asset Detail', route: '/assets/demo-asset', group: 'Assets'),
|
||||
_GalleryEntry(title: 'Asset Categories', route: RouteConstants.assetCategories, group: 'Assets'),
|
||||
_GalleryEntry(title: 'Allocations', route: RouteConstants.assetAllocations, group: 'Assets'),
|
||||
_GalleryEntry(title: 'Maintenance', route: RouteConstants.assetMaintenance, group: 'Assets'),
|
||||
_GalleryEntry(title: 'Disposal', route: RouteConstants.assetDisposal, group: 'Assets'),
|
||||
_GalleryEntry(title: 'QR Scan', route: RouteConstants.assetQrScan, group: 'Assets'),
|
||||
_GalleryEntry(title: 'QR Generate', route: RouteConstants.assetQrGenerate, group: 'Assets'),
|
||||
_GalleryEntry(title: 'Categories', route: RouteConstants.assetCategories, group: 'Assets'),
|
||||
_GalleryEntry(title: 'Alerts', route: RouteConstants.assetAlerts, group: 'Assets'),
|
||||
// Master data
|
||||
_GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'),
|
||||
...masterDefinitions.map(
|
||||
|
||||
@ -3,15 +3,80 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'asset_model.freezed.dart';
|
||||
part 'asset_model.g.dart';
|
||||
|
||||
String _idFromJson(Object? value) => value?.toString() ?? '';
|
||||
|
||||
int? _intFromJsonNullable(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value.toString());
|
||||
}
|
||||
|
||||
double? _doubleFromJsonNullable(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is double) return value;
|
||||
if (value is num) return value.toDouble();
|
||||
return double.tryParse(value.toString());
|
||||
}
|
||||
|
||||
DateTime _dateFromJson(Object? value) {
|
||||
if (value is DateTime) return value;
|
||||
return DateTime.parse(value.toString());
|
||||
}
|
||||
|
||||
DateTime? _dateFromJsonNullable(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is DateTime) return value;
|
||||
return DateTime.tryParse(value.toString());
|
||||
}
|
||||
|
||||
Object? _readNestedName(Map<dynamic, dynamic> json, String nestedKey) {
|
||||
final nested = json[nestedKey];
|
||||
if (nested is Map) return nested['name'];
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _readAssetCategoryName(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['asset_category_name'];
|
||||
if (flat is String && flat.isNotEmpty) return flat;
|
||||
return _readNestedName(json, 'asset_category');
|
||||
}
|
||||
|
||||
Object? _readPlantName(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['plant_name'];
|
||||
if (flat is String && flat.isNotEmpty) return flat;
|
||||
return _readNestedName(json, 'plant');
|
||||
}
|
||||
|
||||
Object? _readAssetCategoryId(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['asset_category_id'];
|
||||
if (flat != null) return flat;
|
||||
final nested = json['asset_category'];
|
||||
if (nested is Map) return nested['id'];
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _readPlantId(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['plant_id'];
|
||||
if (flat != null) return flat;
|
||||
final nested = json['plant'];
|
||||
if (nested is Map) return nested['id'];
|
||||
return null;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class AssetCategoryModel with _$AssetCategoryModel {
|
||||
const factory AssetCategoryModel({
|
||||
required String id,
|
||||
@JsonKey(fromJson: _idFromJson) required String id,
|
||||
required String code,
|
||||
required String name,
|
||||
required String slug,
|
||||
String? description,
|
||||
@JsonKey(name: 'company_id') String? companyId,
|
||||
@JsonKey(name: 'code_prefix') String? codePrefix,
|
||||
@JsonKey(name: 'default_useful_life_years', fromJson: _intFromJsonNullable)
|
||||
int? defaultUsefulLifeYears,
|
||||
@JsonKey(name: 'default_depreciation_method') String? defaultDepreciationMethod,
|
||||
@JsonKey(name: 'is_active') @Default(true) bool isActive,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) = _AssetCategoryModel;
|
||||
|
||||
factory AssetCategoryModel.fromJson(Map<String, dynamic> json) =>
|
||||
@ -21,85 +86,108 @@ class AssetCategoryModel with _$AssetCategoryModel {
|
||||
@freezed
|
||||
class AssetModel with _$AssetModel {
|
||||
const factory AssetModel({
|
||||
required String id,
|
||||
required String name,
|
||||
@JsonKey(name: 'asset_code') required String assetCode,
|
||||
@JsonKey(name: 'category_id') required String categoryId,
|
||||
@JsonKey(name: 'category_name') String? categoryName,
|
||||
String? brand,
|
||||
String? model,
|
||||
@JsonKey(name: 'serial_number') String? serialNumber,
|
||||
@JsonKey(name: 'purchase_date') DateTime? purchaseDate,
|
||||
@JsonKey(name: 'purchase_cost') double? purchaseCost,
|
||||
String? vendor,
|
||||
@JsonKey(name: 'warranty_start') DateTime? warrantyStart,
|
||||
@JsonKey(name: 'warranty_end') DateTime? warrantyEnd,
|
||||
@Default('available') String status,
|
||||
@JsonKey(name: 'branch_id') String? branchId,
|
||||
@JsonKey(name: 'branch_name') String? branchName,
|
||||
@JsonKey(name: 'company_id') String? companyId,
|
||||
@JsonKey(name: 'qr_code_url') String? qrCodeUrl,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
@JsonKey(fromJson: _idFromJson) required String id,
|
||||
@JsonKey(name: 'asset_name') required String assetName,
|
||||
@JsonKey(name: 'asset_code') String? assetCode,
|
||||
@JsonKey(
|
||||
name: 'asset_category_id',
|
||||
readValue: _readAssetCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? assetCategoryId,
|
||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
||||
String? assetCategoryName,
|
||||
@JsonKey(name: 'plant_id', readValue: _readPlantId, fromJson: _intFromJsonNullable)
|
||||
int? plantId,
|
||||
@JsonKey(name: 'plant_name', readValue: _readPlantName) String? plantName,
|
||||
@JsonKey(name: 'warranty_expiry_date', fromJson: _dateFromJsonNullable)
|
||||
DateTime? warrantyExpiryDate,
|
||||
@JsonKey(name: 'purchase_cost', fromJson: _doubleFromJsonNullable) double? purchaseCost,
|
||||
String? status,
|
||||
@JsonKey(name: 'is_active') @Default(true) bool isActive,
|
||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
|
||||
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt,
|
||||
}) = _AssetModel;
|
||||
|
||||
factory AssetModel.fromJson(Map<String, dynamic> json) => _$AssetModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class AssetAllocationModel with _$AssetAllocationModel {
|
||||
const factory AssetAllocationModel({
|
||||
required String id,
|
||||
@JsonKey(name: 'asset_id') required String assetId,
|
||||
@JsonKey(name: 'asset_name') String? assetName,
|
||||
@JsonKey(name: 'employee_id') required String employeeId,
|
||||
@JsonKey(name: 'employee_name') String? employeeName,
|
||||
@JsonKey(name: 'assigned_date') required DateTime assignedDate,
|
||||
@JsonKey(name: 'returned_date') DateTime? returnedDate,
|
||||
String? remarks,
|
||||
class AmcContractModel with _$AmcContractModel {
|
||||
const factory AmcContractModel({
|
||||
@JsonKey(fromJson: _idFromJson) required String id,
|
||||
@JsonKey(name: 'asset_id', fromJson: _idFromJson) String? assetId,
|
||||
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId,
|
||||
@JsonKey(name: 'vendor_name') String? vendorName,
|
||||
@JsonKey(name: 'contract_no') String? contractNo,
|
||||
@JsonKey(name: 'contract_type') String? contractType,
|
||||
@JsonKey(name: 'start_date', fromJson: _dateFromJson) required DateTime startDate,
|
||||
@JsonKey(name: 'end_date', fromJson: _dateFromJson) required DateTime endDate,
|
||||
@JsonKey(name: 'annual_cost', fromJson: _doubleFromJsonNullable) double? annualCost,
|
||||
@Default('active') String status,
|
||||
DateTime? createdAt,
|
||||
}) = _AssetAllocationModel;
|
||||
}) = _AmcContractModel;
|
||||
|
||||
factory AssetAllocationModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$AssetAllocationModelFromJson(json);
|
||||
factory AmcContractModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$AmcContractModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class AssetMaintenanceModel with _$AssetMaintenanceModel {
|
||||
const factory AssetMaintenanceModel({
|
||||
required String id,
|
||||
@JsonKey(name: 'asset_id') required String assetId,
|
||||
@JsonKey(name: 'asset_name') String? assetName,
|
||||
required String description,
|
||||
@JsonKey(name: 'service_vendor') String? serviceVendor,
|
||||
double? cost,
|
||||
@Default('open') String status,
|
||||
@JsonKey(name: 'requested_by') String? requestedBy,
|
||||
@JsonKey(name: 'completed_date') DateTime? completedDate,
|
||||
class ServiceVisitModel with _$ServiceVisitModel {
|
||||
const factory ServiceVisitModel({
|
||||
@JsonKey(fromJson: _idFromJson) required String id,
|
||||
@JsonKey(name: 'asset_id', fromJson: _idFromJson) String? assetId,
|
||||
@JsonKey(name: 'visit_type') required String visitType,
|
||||
@JsonKey(name: 'visit_date', fromJson: _dateFromJson) required DateTime visitDate,
|
||||
@JsonKey(name: 'complaint_no') String? complaintNo,
|
||||
@JsonKey(name: 'work_done') String? workDone,
|
||||
@JsonKey(name: 'next_service_date', fromJson: _dateFromJsonNullable)
|
||||
DateTime? nextServiceDate,
|
||||
@Default('COMPLETED') String status,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) = _AssetMaintenanceModel;
|
||||
}) = _ServiceVisitModel;
|
||||
|
||||
factory AssetMaintenanceModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$AssetMaintenanceModelFromJson(json);
|
||||
factory ServiceVisitModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ServiceVisitModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class AssetDisposalModel with _$AssetDisposalModel {
|
||||
const factory AssetDisposalModel({
|
||||
required String id,
|
||||
@JsonKey(name: 'asset_id') required String assetId,
|
||||
@JsonKey(name: 'asset_name') String? assetName,
|
||||
@JsonKey(name: 'disposal_reason') required String disposalReason,
|
||||
@JsonKey(name: 'disposal_date') DateTime? disposalDate,
|
||||
@Default('pending') String status,
|
||||
@JsonKey(name: 'requested_by') String? requestedBy,
|
||||
@JsonKey(name: 'approved_by') String? approvedBy,
|
||||
class InsurancePolicyModel with _$InsurancePolicyModel {
|
||||
const factory InsurancePolicyModel({
|
||||
@JsonKey(fromJson: _idFromJson) required String id,
|
||||
@JsonKey(name: 'asset_id', fromJson: _idFromJson) String? assetId,
|
||||
@JsonKey(name: 'policy_no') required String policyNo,
|
||||
@JsonKey(name: 'insurer_name') required String insurerName,
|
||||
@JsonKey(name: 'policy_type') String? policyType,
|
||||
@JsonKey(name: 'sum_insured', fromJson: _doubleFromJsonNullable) double? sumInsured,
|
||||
@JsonKey(name: 'policy_start_date', fromJson: _dateFromJson)
|
||||
required DateTime policyStartDate,
|
||||
@JsonKey(name: 'policy_end_date', fromJson: _dateFromJson) required DateTime policyEndDate,
|
||||
@Default('active') String status,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) = _AssetDisposalModel;
|
||||
}) = _InsurancePolicyModel;
|
||||
|
||||
factory AssetDisposalModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$AssetDisposalModelFromJson(json);
|
||||
factory InsurancePolicyModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$InsurancePolicyModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class AssetAlertModel with _$AssetAlertModel {
|
||||
const factory AssetAlertModel({
|
||||
@JsonKey(fromJson: _idFromJson) required String id,
|
||||
@JsonKey(name: 'asset_id', fromJson: _idFromJson) String? assetId,
|
||||
@JsonKey(name: 'asset_name') String? assetName,
|
||||
@JsonKey(name: 'asset_code') String? assetCode,
|
||||
String? type,
|
||||
String? title,
|
||||
String? message,
|
||||
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable) DateTime? expiryDate,
|
||||
@JsonKey(name: 'due_date', fromJson: _dateFromJsonNullable) DateTime? dueDate,
|
||||
@JsonKey(name: 'days_remaining', fromJson: _intFromJsonNullable) int? daysRemaining,
|
||||
String? status,
|
||||
@JsonKey(name: 'plant_name') String? plantName,
|
||||
}) = _AssetAlertModel;
|
||||
|
||||
factory AssetAlertModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$AssetAlertModelFromJson(json);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -9,189 +9,200 @@ part of 'asset_model.dart';
|
||||
_$AssetCategoryModelImpl _$$AssetCategoryModelImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$AssetCategoryModelImpl(
|
||||
id: json['id'] as String,
|
||||
id: _idFromJson(json['id']),
|
||||
code: json['code'] as String,
|
||||
name: json['name'] as String,
|
||||
slug: json['slug'] as String,
|
||||
description: json['description'] as String?,
|
||||
companyId: json['company_id'] as String?,
|
||||
codePrefix: json['code_prefix'] as String?,
|
||||
defaultUsefulLifeYears: _intFromJsonNullable(
|
||||
json['default_useful_life_years'],
|
||||
),
|
||||
defaultDepreciationMethod: json['default_depreciation_method'] as String?,
|
||||
isActive: json['is_active'] as bool? ?? true,
|
||||
createdAt: json['createdAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: json['updatedAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['updatedAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$AssetCategoryModelImplToJson(
|
||||
_$AssetCategoryModelImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'code': instance.code,
|
||||
'name': instance.name,
|
||||
'slug': instance.slug,
|
||||
'description': instance.description,
|
||||
'company_id': instance.companyId,
|
||||
'code_prefix': instance.codePrefix,
|
||||
'default_useful_life_years': instance.defaultUsefulLifeYears,
|
||||
'default_depreciation_method': instance.defaultDepreciationMethod,
|
||||
'is_active': instance.isActive,
|
||||
'createdAt': instance.createdAt?.toIso8601String(),
|
||||
'updatedAt': instance.updatedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_$AssetModelImpl _$$AssetModelImplFromJson(Map<String, dynamic> json) =>
|
||||
_$AssetModelImpl(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
assetCode: json['asset_code'] as String,
|
||||
categoryId: json['category_id'] as String,
|
||||
categoryName: json['category_name'] as String?,
|
||||
brand: json['brand'] as String?,
|
||||
model: json['model'] as String?,
|
||||
serialNumber: json['serial_number'] as String?,
|
||||
purchaseDate: json['purchase_date'] == null
|
||||
? null
|
||||
: DateTime.parse(json['purchase_date'] as String),
|
||||
purchaseCost: (json['purchase_cost'] as num?)?.toDouble(),
|
||||
vendor: json['vendor'] as String?,
|
||||
warrantyStart: json['warranty_start'] == null
|
||||
? null
|
||||
: DateTime.parse(json['warranty_start'] as String),
|
||||
warrantyEnd: json['warranty_end'] == null
|
||||
? null
|
||||
: DateTime.parse(json['warranty_end'] as String),
|
||||
status: json['status'] as String? ?? 'available',
|
||||
branchId: json['branch_id'] as String?,
|
||||
branchName: json['branch_name'] as String?,
|
||||
companyId: json['company_id'] as String?,
|
||||
qrCodeUrl: json['qr_code_url'] as String?,
|
||||
createdAt: json['createdAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: json['updatedAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['updatedAt'] as String),
|
||||
id: _idFromJson(json['id']),
|
||||
assetName: json['asset_name'] as String,
|
||||
assetCode: json['asset_code'] as String?,
|
||||
assetCategoryId: _intFromJsonNullable(
|
||||
_readAssetCategoryId(json, 'asset_category_id'),
|
||||
),
|
||||
assetCategoryName:
|
||||
_readAssetCategoryName(json, 'asset_category_name') as String?,
|
||||
plantId: _intFromJsonNullable(_readPlantId(json, 'plant_id')),
|
||||
plantName: _readPlantName(json, 'plant_name') as String?,
|
||||
warrantyExpiryDate: _dateFromJsonNullable(json['warranty_expiry_date']),
|
||||
purchaseCost: _doubleFromJsonNullable(json['purchase_cost']),
|
||||
status: json['status'] as String?,
|
||||
isActive: json['is_active'] as bool? ?? true,
|
||||
createdAt: _dateFromJsonNullable(json['created_at']),
|
||||
updatedAt: _dateFromJsonNullable(json['updated_at']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$AssetModelImplToJson(_$AssetModelImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'asset_name': instance.assetName,
|
||||
'asset_code': instance.assetCode,
|
||||
'category_id': instance.categoryId,
|
||||
'category_name': instance.categoryName,
|
||||
'brand': instance.brand,
|
||||
'model': instance.model,
|
||||
'serial_number': instance.serialNumber,
|
||||
'purchase_date': instance.purchaseDate?.toIso8601String(),
|
||||
'asset_category_id': instance.assetCategoryId,
|
||||
'asset_category_name': instance.assetCategoryName,
|
||||
'plant_id': instance.plantId,
|
||||
'plant_name': instance.plantName,
|
||||
'warranty_expiry_date': instance.warrantyExpiryDate?.toIso8601String(),
|
||||
'purchase_cost': instance.purchaseCost,
|
||||
'vendor': instance.vendor,
|
||||
'warranty_start': instance.warrantyStart?.toIso8601String(),
|
||||
'warranty_end': instance.warrantyEnd?.toIso8601String(),
|
||||
'status': instance.status,
|
||||
'branch_id': instance.branchId,
|
||||
'branch_name': instance.branchName,
|
||||
'company_id': instance.companyId,
|
||||
'qr_code_url': instance.qrCodeUrl,
|
||||
'createdAt': instance.createdAt?.toIso8601String(),
|
||||
'updatedAt': instance.updatedAt?.toIso8601String(),
|
||||
'is_active': instance.isActive,
|
||||
'created_at': instance.createdAt?.toIso8601String(),
|
||||
'updated_at': instance.updatedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_$AssetAllocationModelImpl _$$AssetAllocationModelImplFromJson(
|
||||
_$AmcContractModelImpl _$$AmcContractModelImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$AssetAllocationModelImpl(
|
||||
id: json['id'] as String,
|
||||
assetId: json['asset_id'] as String,
|
||||
assetName: json['asset_name'] as String?,
|
||||
employeeId: json['employee_id'] as String,
|
||||
employeeName: json['employee_name'] as String?,
|
||||
assignedDate: DateTime.parse(json['assigned_date'] as String),
|
||||
returnedDate: json['returned_date'] == null
|
||||
? null
|
||||
: DateTime.parse(json['returned_date'] as String),
|
||||
remarks: json['remarks'] as String?,
|
||||
) => _$AmcContractModelImpl(
|
||||
id: _idFromJson(json['id']),
|
||||
assetId: _idFromJson(json['asset_id']),
|
||||
vendorId: _intFromJsonNullable(json['vendor_id']),
|
||||
vendorName: json['vendor_name'] as String?,
|
||||
contractNo: json['contract_no'] as String?,
|
||||
contractType: json['contract_type'] as String?,
|
||||
startDate: _dateFromJson(json['start_date']),
|
||||
endDate: _dateFromJson(json['end_date']),
|
||||
annualCost: _doubleFromJsonNullable(json['annual_cost']),
|
||||
status: json['status'] as String? ?? 'active',
|
||||
createdAt: json['createdAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$AssetAllocationModelImplToJson(
|
||||
_$AssetAllocationModelImpl instance,
|
||||
Map<String, dynamic> _$$AmcContractModelImplToJson(
|
||||
_$AmcContractModelImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'asset_id': instance.assetId,
|
||||
'asset_name': instance.assetName,
|
||||
'employee_id': instance.employeeId,
|
||||
'employee_name': instance.employeeName,
|
||||
'assigned_date': instance.assignedDate.toIso8601String(),
|
||||
'returned_date': instance.returnedDate?.toIso8601String(),
|
||||
'remarks': instance.remarks,
|
||||
'vendor_id': instance.vendorId,
|
||||
'vendor_name': instance.vendorName,
|
||||
'contract_no': instance.contractNo,
|
||||
'contract_type': instance.contractType,
|
||||
'start_date': instance.startDate.toIso8601String(),
|
||||
'end_date': instance.endDate.toIso8601String(),
|
||||
'annual_cost': instance.annualCost,
|
||||
'status': instance.status,
|
||||
'createdAt': instance.createdAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_$AssetMaintenanceModelImpl _$$AssetMaintenanceModelImplFromJson(
|
||||
_$ServiceVisitModelImpl _$$ServiceVisitModelImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$AssetMaintenanceModelImpl(
|
||||
id: json['id'] as String,
|
||||
assetId: json['asset_id'] as String,
|
||||
assetName: json['asset_name'] as String?,
|
||||
description: json['description'] as String,
|
||||
serviceVendor: json['service_vendor'] as String?,
|
||||
cost: (json['cost'] as num?)?.toDouble(),
|
||||
status: json['status'] as String? ?? 'open',
|
||||
requestedBy: json['requested_by'] as String?,
|
||||
completedDate: json['completed_date'] == null
|
||||
? null
|
||||
: DateTime.parse(json['completed_date'] as String),
|
||||
) => _$ServiceVisitModelImpl(
|
||||
id: _idFromJson(json['id']),
|
||||
assetId: _idFromJson(json['asset_id']),
|
||||
visitType: json['visit_type'] as String,
|
||||
visitDate: _dateFromJson(json['visit_date']),
|
||||
complaintNo: json['complaint_no'] as String?,
|
||||
workDone: json['work_done'] as String?,
|
||||
nextServiceDate: _dateFromJsonNullable(json['next_service_date']),
|
||||
status: json['status'] as String? ?? 'COMPLETED',
|
||||
createdAt: json['createdAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: json['updatedAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['updatedAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$AssetMaintenanceModelImplToJson(
|
||||
_$AssetMaintenanceModelImpl instance,
|
||||
Map<String, dynamic> _$$ServiceVisitModelImplToJson(
|
||||
_$ServiceVisitModelImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'asset_id': instance.assetId,
|
||||
'asset_name': instance.assetName,
|
||||
'description': instance.description,
|
||||
'service_vendor': instance.serviceVendor,
|
||||
'cost': instance.cost,
|
||||
'visit_type': instance.visitType,
|
||||
'visit_date': instance.visitDate.toIso8601String(),
|
||||
'complaint_no': instance.complaintNo,
|
||||
'work_done': instance.workDone,
|
||||
'next_service_date': instance.nextServiceDate?.toIso8601String(),
|
||||
'status': instance.status,
|
||||
'requested_by': instance.requestedBy,
|
||||
'completed_date': instance.completedDate?.toIso8601String(),
|
||||
'createdAt': instance.createdAt?.toIso8601String(),
|
||||
'updatedAt': instance.updatedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_$AssetDisposalModelImpl _$$AssetDisposalModelImplFromJson(
|
||||
_$InsurancePolicyModelImpl _$$InsurancePolicyModelImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$AssetDisposalModelImpl(
|
||||
id: json['id'] as String,
|
||||
assetId: json['asset_id'] as String,
|
||||
assetName: json['asset_name'] as String?,
|
||||
disposalReason: json['disposal_reason'] as String,
|
||||
disposalDate: json['disposal_date'] == null
|
||||
? null
|
||||
: DateTime.parse(json['disposal_date'] as String),
|
||||
status: json['status'] as String? ?? 'pending',
|
||||
requestedBy: json['requested_by'] as String?,
|
||||
approvedBy: json['approved_by'] as String?,
|
||||
) => _$InsurancePolicyModelImpl(
|
||||
id: _idFromJson(json['id']),
|
||||
assetId: _idFromJson(json['asset_id']),
|
||||
policyNo: json['policy_no'] as String,
|
||||
insurerName: json['insurer_name'] as String,
|
||||
policyType: json['policy_type'] as String?,
|
||||
sumInsured: _doubleFromJsonNullable(json['sum_insured']),
|
||||
policyStartDate: _dateFromJson(json['policy_start_date']),
|
||||
policyEndDate: _dateFromJson(json['policy_end_date']),
|
||||
status: json['status'] as String? ?? 'active',
|
||||
createdAt: json['createdAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: json['updatedAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['updatedAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$AssetDisposalModelImplToJson(
|
||||
_$AssetDisposalModelImpl instance,
|
||||
Map<String, dynamic> _$$InsurancePolicyModelImplToJson(
|
||||
_$InsurancePolicyModelImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'asset_id': instance.assetId,
|
||||
'policy_no': instance.policyNo,
|
||||
'insurer_name': instance.insurerName,
|
||||
'policy_type': instance.policyType,
|
||||
'sum_insured': instance.sumInsured,
|
||||
'policy_start_date': instance.policyStartDate.toIso8601String(),
|
||||
'policy_end_date': instance.policyEndDate.toIso8601String(),
|
||||
'status': instance.status,
|
||||
'createdAt': instance.createdAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_$AssetAlertModelImpl _$$AssetAlertModelImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$AssetAlertModelImpl(
|
||||
id: _idFromJson(json['id']),
|
||||
assetId: _idFromJson(json['asset_id']),
|
||||
assetName: json['asset_name'] as String?,
|
||||
assetCode: json['asset_code'] as String?,
|
||||
type: json['type'] as String?,
|
||||
title: json['title'] as String?,
|
||||
message: json['message'] as String?,
|
||||
expiryDate: _dateFromJsonNullable(json['expiry_date']),
|
||||
dueDate: _dateFromJsonNullable(json['due_date']),
|
||||
daysRemaining: _intFromJsonNullable(json['days_remaining']),
|
||||
status: json['status'] as String?,
|
||||
plantName: json['plant_name'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$AssetAlertModelImplToJson(
|
||||
_$AssetAlertModelImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'asset_id': instance.assetId,
|
||||
'asset_name': instance.assetName,
|
||||
'disposal_reason': instance.disposalReason,
|
||||
'disposal_date': instance.disposalDate?.toIso8601String(),
|
||||
'asset_code': instance.assetCode,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'message': instance.message,
|
||||
'expiry_date': instance.expiryDate?.toIso8601String(),
|
||||
'due_date': instance.dueDate?.toIso8601String(),
|
||||
'days_remaining': instance.daysRemaining,
|
||||
'status': instance.status,
|
||||
'requested_by': instance.requestedBy,
|
||||
'approved_by': instance.approvedBy,
|
||||
'createdAt': instance.createdAt?.toIso8601String(),
|
||||
'updatedAt': instance.updatedAt?.toIso8601String(),
|
||||
'plant_name': instance.plantName,
|
||||
};
|
||||
|
||||
@ -5,15 +5,10 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../core/config/dev_config.dart';
|
||||
import '../../core/constants/route_constants.dart';
|
||||
import '../../modules/dashboard/presentation/screens/dashboard_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_allocations_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_alerts_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_categories_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_detail_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_disposal_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_form_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_list_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_maintenance_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_qr_generate_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_qr_scan_screen.dart';
|
||||
import '../../modules/auth/presentation/screens/change_password_screen.dart';
|
||||
import '../../modules/auth/presentation/screens/forgot_password_screen.dart';
|
||||
import '../../modules/auth/presentation/screens/login_screen.dart';
|
||||
@ -210,44 +205,19 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const AssetListScreen()),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'add',
|
||||
builder: (context, state) => const AssetFormScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'categories',
|
||||
builder: (context, state) => const AssetCategoriesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'allocations',
|
||||
builder: (context, state) => const AssetAllocationsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'maintenance',
|
||||
builder: (context, state) => const AssetMaintenanceScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'disposal',
|
||||
builder: (context, state) => const AssetDisposalScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'qr-scan',
|
||||
builder: (context, state) => const AssetQrScanScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'qr-generate',
|
||||
builder: (context, state) => const AssetQrGenerateScreen(),
|
||||
path: 'alerts',
|
||||
builder: (context, state) => const AssetAlertsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
builder: (context, state) =>
|
||||
AssetDetailScreen(assetId: state.pathParameters['id']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':id/edit',
|
||||
builder: (context, state) =>
|
||||
AssetFormScreen(assetId: state.pathParameters['id']),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
|
||||
@ -67,33 +67,9 @@ const List<MenuItem> appMenuItems = [
|
||||
module: 'asset_categories',
|
||||
),
|
||||
MenuItem(
|
||||
label: 'Allocations',
|
||||
icon: Icons.assignment_ind_outlined,
|
||||
route: RouteConstants.assetAllocations,
|
||||
module: 'asset_allocations',
|
||||
),
|
||||
MenuItem(
|
||||
label: 'Maintenance',
|
||||
icon: Icons.build_outlined,
|
||||
route: RouteConstants.assetMaintenance,
|
||||
module: 'asset_maintenance',
|
||||
),
|
||||
MenuItem(
|
||||
label: 'Disposal',
|
||||
icon: Icons.delete_outline,
|
||||
route: RouteConstants.assetDisposal,
|
||||
module: 'asset_disposal',
|
||||
),
|
||||
MenuItem(
|
||||
label: 'QR Scan',
|
||||
icon: Icons.qr_code_scanner_outlined,
|
||||
route: RouteConstants.assetQrScan,
|
||||
module: 'assets',
|
||||
),
|
||||
MenuItem(
|
||||
label: 'QR Generate',
|
||||
icon: Icons.qr_code_2_outlined,
|
||||
route: RouteConstants.assetQrGenerate,
|
||||
label: 'Alerts',
|
||||
icon: Icons.notifications_outlined,
|
||||
route: RouteConstants.assetAlerts,
|
||||
module: 'assets',
|
||||
),
|
||||
],
|
||||
|
||||
@ -49,26 +49,46 @@ class AppSidebar extends ConsumerStatefulWidget {
|
||||
|
||||
class _AppSidebarState extends ConsumerState<AppSidebar> {
|
||||
final Set<String> _expandedMenus = <String>{};
|
||||
final Set<String> _manuallyCollapsedMenus = <String>{};
|
||||
|
||||
List<menu.MenuItem> get _mainMenuItems =>
|
||||
widget.menuItems.where((item) => item.route != RouteConstants.settings).toList();
|
||||
|
||||
bool _isSelected(String route) {
|
||||
if (route == '/') return widget.currentRoute == route;
|
||||
return widget.currentRoute.startsWith(route);
|
||||
bool _routeMatches(String route) {
|
||||
final current = widget.currentRoute;
|
||||
if (route == '/') return current == route;
|
||||
if (current == route) return true;
|
||||
return current.startsWith('$route/');
|
||||
}
|
||||
|
||||
bool _isGroupActive(menu.MenuItem item) =>
|
||||
item.children.any((child) => _isSelected(child.route));
|
||||
bool _isSelected(String route) => _routeMatches(route);
|
||||
|
||||
bool _isGroupExpanded(menu.MenuItem item) =>
|
||||
_expandedMenus.contains(item.route) || _isGroupActive(item);
|
||||
bool _isSelectedAmongSiblings(String route, List<menu.MenuItem> siblings) {
|
||||
if (!_routeMatches(route)) return false;
|
||||
for (final sibling in siblings) {
|
||||
if (sibling.route == route) continue;
|
||||
if (sibling.route.length > route.length && _routeMatches(sibling.route)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _isGroupActive(menu.MenuItem item) => item.children
|
||||
.any((child) => _isSelectedAmongSiblings(child.route, item.children));
|
||||
|
||||
bool _isGroupExpanded(menu.MenuItem item) {
|
||||
if (_manuallyCollapsedMenus.contains(item.route)) return false;
|
||||
return _expandedMenus.contains(item.route) || _isGroupActive(item);
|
||||
}
|
||||
|
||||
void _toggleGroup(menu.MenuItem item) {
|
||||
setState(() {
|
||||
if (_expandedMenus.contains(item.route)) {
|
||||
if (_isGroupExpanded(item)) {
|
||||
_expandedMenus.remove(item.route);
|
||||
_manuallyCollapsedMenus.add(item.route);
|
||||
} else {
|
||||
_manuallyCollapsedMenus.remove(item.route);
|
||||
_expandedMenus.add(item.route);
|
||||
}
|
||||
});
|
||||
@ -81,6 +101,7 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
||||
for (final item in _mainMenuItems) {
|
||||
if (item.children.isNotEmpty && _isGroupActive(item)) {
|
||||
_expandedMenus.add(item.route);
|
||||
_manuallyCollapsedMenus.remove(item.route);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -148,7 +169,8 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
||||
return _CollapsedFlyoutNavItem(
|
||||
item: item,
|
||||
selected: _isGroupActive(item),
|
||||
isChildSelected: _isSelected,
|
||||
isChildSelected: (route) =>
|
||||
_isSelectedAmongSiblings(route, item.children),
|
||||
onChildTap: widget.onItemTap,
|
||||
);
|
||||
}
|
||||
@ -173,7 +195,10 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
||||
(child) => _SidebarNavItem(
|
||||
icon: child.icon,
|
||||
label: child.label,
|
||||
selected: _isSelected(child.route),
|
||||
selected: _isSelectedAmongSiblings(
|
||||
child.route,
|
||||
item.children,
|
||||
),
|
||||
collapsed: false,
|
||||
indent: _sidebarChildIndent,
|
||||
onTap: () => widget.onItemTap(child.route),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user