Assets, Vendor, User

This commit is contained in:
Surendiran 2026-07-08 15:50:17 +05:30
parent b23a74442f
commit 3e00b478e7
41 changed files with 5970 additions and 652 deletions

View File

@ -40,6 +40,8 @@ class ApiEndpoints {
// Asset Categories
static const String assetCategories = '/masters/asset-categories';
static String assetCategoryById(String id) => '/masters/asset-categories/$id';
static const String assetSubcategories = '/masters/asset-subcategories';
static String assetSubcategoryById(String id) => '/masters/asset-subcategories/$id';
// Masters
static const String departments = '/masters/departments';
@ -72,6 +74,8 @@ class ApiEndpoints {
// Vendors
static const String vendors = '/vendors';
static const String vendorGstTreatments = '/vendors/gst-treatments';
static const String vendorSourceOfSupply = '/vendors/source-of-supply';
static String vendorById(String id) => '/vendors/$id';
static String vendorStatus(String id) => '/vendors/$id/status';
static String vendorAddresses(String vendorId) => '/vendors/$vendorId/addresses';
@ -84,6 +88,9 @@ class ApiEndpoints {
'/vendors/$vendorId/bank-details';
static String vendorBankDetailById(String vendorId, String bankDetailId) =>
'/vendors/$vendorId/bank-details/$bankDetailId';
static String vendorItems(String vendorId) => '/vendors/$vendorId/items';
static String vendorItemById(String vendorId, String mappingId) =>
'/vendors/$vendorId/items/$mappingId';
// Purchase Orders
static const String purchaseOrders = '/purchase-orders';
@ -105,13 +112,22 @@ class ApiEndpoints {
static const String assets = '/assets';
static String assetById(String id) => '/assets/$id';
static String assetTransfer(String id) => '/assets/$id/transfer';
static String assetTransferHistory(String id) => '/assets/$id/transfer-history';
static const String assetDepreciationMethods = '/assets/depreciation-methods';
static const String assetDepreciationCalculate = '/assets/depreciation/calculate';
static const String assetAlertsExpiry = '/assets/alerts/expiry';
static const String assetAlertsService = '/assets/alerts/service';
static String assetAmc(String assetId) => '/assets/$assetId/amc';
static String assetAmcById(String assetId, String contractId) =>
'/assets/$assetId/amc/$contractId';
static String assetAmcRenew(String assetId, String contractId) =>
'/assets/$assetId/amc/$contractId/renew';
static String assetServiceVisits(String assetId) => '/assets/$assetId/service-visits';
static String assetServiceVisitById(String assetId, String visitId) =>
'/assets/$assetId/service-visits/$visitId';
static String assetInsurance(String assetId) => '/assets/$assetId/insurance';
static String assetInsuranceById(String assetId, String policyId) =>
'/assets/$assetId/insurance/$policyId';
static String assetInsuranceRenew(String assetId, String policyId) =>
'/assets/$assetId/insurance/$policyId/renew';
@ -130,7 +146,8 @@ class ApiEndpoints {
static const String settings = '/settings';
static const String settingsBranding = '/settings/branding';
static const String settingsGeneral = '/settings/general';
static const String settingsCompany = '/settings/company-profile';
static const String settingsCompany = '/settings/company';
static String settingsCompanyLogo = '/settings/company/logo';
static const String settingsAsset = '/settings/asset';
static const String settingsNotifications = '/settings/notifications';
static const String settingsEmail = '/settings/email';

View File

@ -206,6 +206,56 @@ class Validators {
return null;
}
/// Validates a positive decimal when a value is entered.
static String? optionalPositiveDouble(
String? value, {
String fieldName = 'Value',
}) {
if (value == null || value.trim().isEmpty) return null;
final parsed = double.tryParse(value.trim());
if (parsed == null) return 'Enter a valid number';
if (parsed <= 0) return '$fieldName must be greater than 0';
return null;
}
/// Validates a non-negative decimal when a value is entered.
static String? optionalNonNegativeDouble(
String? value, {
String fieldName = 'Value',
}) {
if (value == null || value.trim().isEmpty) return null;
final parsed = double.tryParse(value.trim());
if (parsed == null) return 'Enter a valid number';
if (parsed < 0) return '$fieldName cannot be negative';
return null;
}
/// Validates a positive whole number when a value is entered.
static String? optionalPositiveInt(
String? value, {
String fieldName = 'Value',
}) {
if (value == null || value.trim().isEmpty) return null;
final parsed = int.tryParse(value.trim());
if (parsed == null) return 'Enter a valid whole number';
if (parsed <= 0) return '$fieldName must be greater than 0';
return null;
}
/// Validates a percentage between 0 and 100 when a value is entered.
static String? optionalPercentage(
String? value, {
String fieldName = 'Percentage',
}) {
if (value == null || value.trim().isEmpty) return null;
final parsed = double.tryParse(value.trim());
if (parsed == null) return 'Enter a valid percentage';
if (parsed < 0 || parsed > 100) {
return '$fieldName must be between 0 and 100';
}
return null;
}
static final RegExp _roleNamePattern = RegExp(r'^[a-zA-Z0-9 ]+$');
static final RegExp _masterNamePattern =
RegExp(r'^[A-Za-z0-9 _/&.()\-]+$');

View File

@ -45,6 +45,11 @@ class AssetRemoteDataSource {
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<List<AssetTransferHistoryModel>> getTransferHistory(String assetId) async {
final response = await dio.get(ApiEndpoints.assetTransferHistory(assetId));
return _parseList(response.data, AssetTransferHistoryModel.fromJson);
}
Future<List<AssetCategoryModel>> getCategories() async {
final response = await dio.get(
ApiEndpoints.assetCategories,
@ -53,6 +58,11 @@ class AssetRemoteDataSource {
return _parseList(response.data, AssetCategoryModel.fromJson);
}
Future<List<String>> getDepreciationMethods() async {
final response = await dio.get(ApiEndpoints.assetDepreciationMethods);
return _parseStringOptions(response.data);
}
Future<PaginatedResponse<AssetAlertModel>> getExpiryAlerts({
int? days,
String? type,
@ -92,6 +102,26 @@ class AssetRemoteDataSource {
return AmcContractModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<AmcContractModel> getAmcContractById(
String assetId,
String contractId,
) async {
final response = await dio.get(ApiEndpoints.assetAmcById(assetId, contractId));
return AmcContractModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<AmcContractModel> updateAmcContract(
String assetId,
String contractId,
Map<String, dynamic> data,
) async {
final response = await dio.put(
ApiEndpoints.assetAmcById(assetId, contractId),
data: data,
);
return AmcContractModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<AmcContractModel> renewAmcContract(
String assetId,
String contractId,
@ -117,6 +147,26 @@ class AssetRemoteDataSource {
return ServiceVisitModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<ServiceVisitModel> getServiceVisitById(
String assetId,
String visitId,
) async {
final response = await dio.get(ApiEndpoints.assetServiceVisitById(assetId, visitId));
return ServiceVisitModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<ServiceVisitModel> updateServiceVisit(
String assetId,
String visitId,
Map<String, dynamic> data,
) async {
final response = await dio.put(
ApiEndpoints.assetServiceVisitById(assetId, visitId),
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);
@ -132,6 +182,30 @@ class AssetRemoteDataSource {
);
}
Future<InsurancePolicyModel> getInsurancePolicyById(
String assetId,
String policyId,
) async {
final response = await dio.get(ApiEndpoints.assetInsuranceById(assetId, policyId));
return InsurancePolicyModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<InsurancePolicyModel> updateInsurancePolicy(
String assetId,
String policyId,
Map<String, dynamic> data,
) async {
final response = await dio.put(
ApiEndpoints.assetInsuranceById(assetId, policyId),
data: data,
);
return InsurancePolicyModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<InsurancePolicyModel> renewInsurancePolicy(
String assetId,
String policyId,
@ -210,4 +284,29 @@ class AssetRemoteDataSource {
totalPages: 1,
);
}
List<String> _parseStringOptions(dynamic body) {
if (body is! Map<String, dynamic>) return const [];
final raw = body['data'];
final list = raw is List
? raw
: raw is Map<String, dynamic>
? raw['items'] as List<dynamic>? ?? const []
: const [];
return list
.map((item) {
if (item is String) return item.trim();
if (item is Map<String, dynamic>) {
final value =
item['value']?.toString() ??
item['code']?.toString() ??
item['id']?.toString();
return value?.trim() ?? '';
}
return '';
})
.where((value) => value.isNotEmpty)
.toList();
}
}

View File

@ -50,11 +50,21 @@ class AssetRepositoryImpl implements AssetRepository {
return safeApiCall(() => dataSource.transferAsset(id, data));
}
@override
Future<Result<List<AssetTransferHistoryModel>>> getTransferHistory(String assetId) {
return safeApiCall(() => dataSource.getTransferHistory(assetId));
}
@override
Future<Result<List<AssetCategoryModel>>> getCategories() {
return safeApiCall(() => dataSource.getCategories());
}
@override
Future<Result<List<String>>> getDepreciationMethods() {
return safeApiCall(() => dataSource.getDepreciationMethods());
}
@override
Future<Result<PaginatedResponse<AssetAlertModel>>> getExpiryAlerts({
int? days,
@ -85,6 +95,25 @@ class AssetRepositoryImpl implements AssetRepository {
return safeApiCall(() => dataSource.createAmcContract(assetId, data));
}
@override
Future<Result<AmcContractModel>> getAmcContractById(
String assetId,
String contractId,
) {
return safeApiCall(() => dataSource.getAmcContractById(assetId, contractId));
}
@override
Future<Result<AmcContractModel>> updateAmcContract(
String assetId,
String contractId,
Map<String, dynamic> data,
) {
return safeApiCall(
() => dataSource.updateAmcContract(assetId, contractId, data),
);
}
@override
Future<Result<AmcContractModel>> renewAmcContract(
String assetId,
@ -107,6 +136,25 @@ class AssetRepositoryImpl implements AssetRepository {
return safeApiCall(() => dataSource.logServiceVisit(assetId, data));
}
@override
Future<Result<ServiceVisitModel>> getServiceVisitById(
String assetId,
String visitId,
) {
return safeApiCall(() => dataSource.getServiceVisitById(assetId, visitId));
}
@override
Future<Result<ServiceVisitModel>> updateServiceVisit(
String assetId,
String visitId,
Map<String, dynamic> data,
) {
return safeApiCall(
() => dataSource.updateServiceVisit(assetId, visitId, data),
);
}
@override
Future<Result<List<InsurancePolicyModel>>> getInsurancePolicies(String assetId) {
return safeApiCall(() => dataSource.getInsurancePolicies(assetId));
@ -120,6 +168,25 @@ class AssetRepositoryImpl implements AssetRepository {
return safeApiCall(() => dataSource.createInsurancePolicy(assetId, data));
}
@override
Future<Result<InsurancePolicyModel>> getInsurancePolicyById(
String assetId,
String policyId,
) {
return safeApiCall(() => dataSource.getInsurancePolicyById(assetId, policyId));
}
@override
Future<Result<InsurancePolicyModel>> updateInsurancePolicy(
String assetId,
String policyId,
Map<String, dynamic> data,
) {
return safeApiCall(
() => dataSource.updateInsurancePolicy(assetId, policyId, data),
);
}
@override
Future<Result<InsurancePolicyModel>> renewInsurancePolicy(
String assetId,

View File

@ -9,7 +9,9 @@ abstract class AssetRepository {
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<AssetTransferHistoryModel>>> getTransferHistory(String assetId);
Future<Result<List<AssetCategoryModel>>> getCategories();
Future<Result<List<String>>> getDepreciationMethods();
Future<Result<PaginatedResponse<AssetAlertModel>>> getExpiryAlerts({
int? days,
String? type,
@ -22,6 +24,15 @@ abstract class AssetRepository {
String assetId,
Map<String, dynamic> data,
);
Future<Result<AmcContractModel>> getAmcContractById(
String assetId,
String contractId,
);
Future<Result<AmcContractModel>> updateAmcContract(
String assetId,
String contractId,
Map<String, dynamic> data,
);
Future<Result<AmcContractModel>> renewAmcContract(
String assetId,
String contractId,
@ -32,11 +43,29 @@ abstract class AssetRepository {
String assetId,
Map<String, dynamic> data,
);
Future<Result<ServiceVisitModel>> getServiceVisitById(
String assetId,
String visitId,
);
Future<Result<ServiceVisitModel>> updateServiceVisit(
String assetId,
String visitId,
Map<String, dynamic> data,
);
Future<Result<List<InsurancePolicyModel>>> getInsurancePolicies(String assetId);
Future<Result<InsurancePolicyModel>> createInsurancePolicy(
String assetId,
Map<String, dynamic> data,
);
Future<Result<InsurancePolicyModel>> getInsurancePolicyById(
String assetId,
String policyId,
);
Future<Result<InsurancePolicyModel>> updateInsurancePolicy(
String assetId,
String policyId,
Map<String, dynamic> data,
);
Future<Result<InsurancePolicyModel>> renewInsurancePolicy(
String assetId,
String policyId,

View File

@ -0,0 +1,202 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../assets/data/repositories/asset_repository_impl.dart';
import '../../../grn/data/repositories/grn_repository_impl.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart';
import '../../../purchase_orders/data/repositories/purchase_order_repository_impl.dart';
import '../../../users/presentation/providers/users_provider.dart';
import '../../../vendors/data/repositories/vendor_repository_impl.dart';
import '../../../../shared/models/vendor_model.dart';
class AssetFormLookups {
const AssetFormLookups({
this.plants = const [],
this.departments = const [],
this.warehouses = const [],
this.vendors = const [],
this.users = const [],
this.purchaseOrders = const [],
this.grns = const [],
this.depreciationMethods = const [],
});
final List<FilterOptionModel> plants;
final List<FilterOptionModel> departments;
final List<FilterOptionModel> warehouses;
final List<FilterOptionModel> vendors;
final List<FilterOptionModel> users;
final List<FilterOptionModel> purchaseOrders;
final List<FilterOptionModel> grns;
final List<String> depreciationMethods;
}
final assetFormLookupsProvider =
FutureProvider.autoDispose<AssetFormLookups>((ref) async {
final master = ref.watch(masterRemoteDataSourceProvider);
final plants = await _safeOptions(master.listPlants);
final departments = await _safeOptions(master.listDepartments);
final warehouses = await _safeOptions(master.listWarehouses);
final vendors = await _safeVendorOptions(ref);
final users = await _safeUserOptions(ref);
final purchaseOrders = await _safePurchaseOrderOptions(ref);
final grns = await _safeGrnOptions(ref);
final depreciationMethods = await _safeDepreciationMethods(ref);
return AssetFormLookups(
plants: plants,
departments: departments,
warehouses: warehouses,
vendors: vendors,
users: users,
purchaseOrders: purchaseOrders,
grns: grns,
depreciationMethods: depreciationMethods,
);
});
final assetGrnItemsProvider =
FutureProvider.autoDispose.family<List<FilterOptionModel>, int?>(
(ref, grnId) async {
if (grnId == null || grnId <= 0) return const [];
final result =
await ref.read(grnRepositoryProvider).getGrnById(grnId.toString());
if (result.failure != null || result.data == null) return const [];
return result.data!.items.map((item) {
final code = item.itemCode;
final name = item.itemName;
final line = item.lineNo;
final label = [
if (code != null && code.isNotEmpty) code,
if (name != null && name.isNotEmpty) name,
if (line != null) 'Line $line',
].join(' · ');
return FilterOptionModel(
id: item.id,
name: label.isEmpty ? 'Item #${item.id}' : label,
);
}).toList();
},
);
Future<List<FilterOptionModel>> _safeOptions(
Future<List<FilterOptionModel>> Function() load,
) async {
try {
return await load();
} catch (_) {
return const [];
}
}
Future<List<FilterOptionModel>> _safeVendorOptions(Ref ref) async {
try {
final result = await ref.read(vendorRepositoryProvider).getVendors(
const VendorListQuery(
page: 1,
limit: AppConstants.maxPageSize,
status: 'active',
),
);
if (result.failure != null || result.data == null) return const [];
return result.data!.items
.map(
(vendor) => FilterOptionModel(
id: vendor.id,
name: vendor.vendorName,
),
)
.toList();
} catch (_) {
return const [];
}
}
Future<List<FilterOptionModel>> _safeUserOptions(Ref ref) async {
try {
final result = await ref.read(getUsersUseCaseProvider)(
const UserListQuery(
page: 1,
limit: AppConstants.maxPageSize,
status: 'active',
isActive: true,
),
);
if (result.failure != null || result.data == null) return const [];
return result.data!.items
.where((user) {
final status = user.status.trim().toLowerCase();
return status == 'active' && user.isActive;
})
.map(
(user) => FilterOptionModel(
id: user.id,
name: user.fullName,
),
)
.toList();
} catch (_) {
return const [];
}
}
Future<List<FilterOptionModel>> _safePurchaseOrderOptions(Ref ref) async {
try {
final result =
await ref.read(purchaseOrderRepositoryProvider).getPurchaseOrders(
const PurchaseOrderListQuery(
page: 1,
limit: AppConstants.maxPageSize,
),
);
if (result.failure != null || result.data == null) return const [];
return result.data!.items
.map(
(po) => FilterOptionModel(
id: po.id,
name: po.poNo ?? 'PO #${po.id}',
),
)
.toList();
} catch (_) {
return const [];
}
}
Future<List<FilterOptionModel>> _safeGrnOptions(Ref ref) async {
try {
final result = await ref.read(grnRepositoryProvider).getGrns(
const GrnListQuery(page: 1, limit: AppConstants.maxPageSize),
);
if (result.failure != null || result.data == null) return const [];
return result.data!.items
.map(
(grn) => FilterOptionModel(
id: grn.id,
name: grn.grnNumber ?? 'GRN #${grn.id}',
),
)
.toList();
} catch (_) {
return const [];
}
}
Future<List<String>> _safeDepreciationMethods(Ref ref) async {
try {
final result = await ref.read(assetRepositoryProvider).getDepreciationMethods();
if (result.failure != null || result.data == null) return const [];
return result.data!
.map((method) => method.trim().toUpperCase())
.where((method) => method.isNotEmpty)
.toSet()
.toList();
} catch (_) {
return const [];
}
}

View File

@ -223,6 +223,17 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
return result.data;
}
Future<AmcContractModel?> updateAmc(
String contractId,
Map<String, dynamic> data,
) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.updateAmcContract(arg, contractId, 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);
@ -231,6 +242,17 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
return result.data;
}
Future<ServiceVisitModel?> updateVisit(
String visitId,
Map<String, dynamic> data,
) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.updateServiceVisit(arg, visitId, 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);
@ -238,8 +260,66 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
await reload();
return result.data;
}
Future<InsurancePolicyModel?> updateInsurance(
String policyId,
Map<String, dynamic> data,
) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.updateInsurancePolicy(arg, policyId, data);
if (result.failure != null) throw result.failure!;
await reload();
return result.data;
}
}
typedef AmcContractFormParams = ({String assetId, String contractId});
final amcContractFormProvider = FutureProvider.autoDispose
.family<AmcContractModel, AmcContractFormParams>((ref, params) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.getAmcContractById(
params.assetId,
params.contractId,
);
if (result.failure != null) throw result.failure!;
return result.data!;
});
typedef ServiceVisitFormParams = ({String assetId, String visitId});
final serviceVisitFormProvider = FutureProvider.autoDispose
.family<ServiceVisitModel, ServiceVisitFormParams>((ref, params) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.getServiceVisitById(
params.assetId,
params.visitId,
);
if (result.failure != null) throw result.failure!;
return result.data!;
});
typedef InsurancePolicyFormParams = ({String assetId, String policyId});
final insurancePolicyFormProvider = FutureProvider.autoDispose
.family<InsurancePolicyModel, InsurancePolicyFormParams>((ref, params) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.getInsurancePolicyById(
params.assetId,
params.policyId,
);
if (result.failure != null) throw result.failure!;
return result.data!;
});
final transferHistoryProvider = FutureProvider.autoDispose
.family<List<AssetTransferHistoryModel>, String>((ref, assetId) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.getTransferHistory(assetId);
if (result.failure != null) throw result.failure!;
return result.data ?? const [];
});
final assetFormProvider =
AsyncNotifierProvider.family.autoDispose<AssetFormNotifier, AssetModel?, String?>(
AssetFormNotifier.new,

View File

@ -15,11 +15,31 @@ import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/assets_provider.dart';
class AssetAlertsScreen extends ConsumerWidget {
class AssetAlertsScreen extends ConsumerStatefulWidget {
const AssetAlertsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<AssetAlertsScreen> createState() => _AssetAlertsScreenState();
}
class _AssetAlertsScreenState extends ConsumerState<AssetAlertsScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final alertsAsync = ref.watch(assetAlertsProvider);
return Padding(
@ -30,41 +50,40 @@ class AssetAlertsScreen extends ConsumerWidget {
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(
clipBehavior: Clip.none,
children: [
_ExpiryAlertsTab(state: state),
_ServiceAlertsTab(state: state),
],
data: (state) => 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),
TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Expiry Alerts'),
Tab(text: 'Service Alerts'),
],
),
const SizedBox(height: 16),
Expanded(
child: TabBarView(
controller: _tabController,
clipBehavior: Clip.none,
children: [
_ExpiryAlertsTab(state: state),
_ServiceAlertsTab(state: state),
],
),
],
),
),
],
),
),
);
@ -87,7 +106,7 @@ class _ExpiryAlertsTab extends ConsumerWidget {
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.start,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SizedBox(
width: 160,
@ -118,10 +137,13 @@ class _ExpiryAlertsTab extends ConsumerWidget {
onChanged: notifier.setExpiryType,
),
),
IconButton(
onPressed: () => notifier.refresh(),
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
Padding(
padding: const EdgeInsets.only(top: 8),
child: IconButton(
onPressed: () => notifier.refresh(),
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
),
],
),
@ -179,6 +201,7 @@ class _ServiceAlertsTab extends ConsumerWidget {
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SizedBox(
width: 200,
@ -196,10 +219,13 @@ class _ServiceAlertsTab extends ConsumerWidget {
onChanged: notifier.setServiceStatus,
),
),
IconButton(
onPressed: () => notifier.refresh(),
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
Padding(
padding: const EdgeInsets.only(top: 8),
child: IconButton(
onPressed: () => notifier.refresh(),
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
),
],
),

View File

@ -77,7 +77,7 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
if (canEdit) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => _showTransferDialog(state.asset),
onPressed: _showTransferDialog,
icon: const Icon(Icons.swap_horiz),
label: const Text('Transfer'),
),
@ -107,9 +107,16 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
child: TabBarView(
controller: _tabController,
children: [
_OverviewTab(asset: state.asset),
_OverviewTab(
asset: state.asset,
onOpenTransferHistory: _openTransferHistoryPanel,
),
_AmcTab(assetId: widget.assetId, contracts: state.amcContracts),
_ServiceVisitsTab(assetId: widget.assetId, visits: state.serviceVisits),
_ServiceVisitsTab(
assetId: widget.assetId,
visits: state.serviceVisits,
amcContracts: state.amcContracts,
),
_InsuranceTab(assetId: widget.assetId, policies: state.insurancePolicies),
],
),
@ -134,52 +141,36 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
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')),
],
),
Future<void> _showTransferDialog() async {
final transferred = await showSidePanel<bool>(
context,
TransferAssetPanel(assetId: widget.assetId),
width: 520,
);
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())));
}
if (transferred == true && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Asset transferred')),
);
}
}
Future<void> _openTransferHistoryPanel() async {
await showSidePanel<void>(
context,
TransferHistoryPanel(assetId: widget.assetId),
width: 520,
);
}
}
class _OverviewTab extends StatelessWidget {
const _OverviewTab({required this.asset});
const _OverviewTab({
required this.asset,
required this.onOpenTransferHistory,
});
final AssetModel asset;
final VoidCallback onOpenTransferHistory;
@override
Widget build(BuildContext context) {
@ -196,12 +187,22 @@ class _OverviewTab extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Asset Details',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
Row(
children: [
Text(
'Asset Details',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
TextButton.icon(
onPressed: onOpenTransferHistory,
icon: const Icon(Icons.history, size: 18),
label: const Text('Transfer History'),
),
],
),
const SizedBox(height: 12),
_AssetInfoGrid(
@ -209,7 +210,17 @@ class _OverviewTab extends StatelessWidget {
_AssetInfo('Asset Name', asset.assetName),
_AssetInfo('Asset Code', asset.assetCode ?? ''),
_AssetInfo('Category', asset.assetCategoryName ?? ''),
_AssetInfo('Subcategory', asset.assetSubcategoryName ?? ''),
_AssetInfo('Plant', asset.plantName ?? ''),
_AssetInfo('Serial Number', asset.serialNumber ?? ''),
_AssetInfo('Brand / Model', asset.brandModel ?? ''),
_AssetInfo('Manufacturer', asset.manufacturer ?? ''),
_AssetInfo(
'Purchase Date',
asset.purchaseDate != null
? dateFormat.format(asset.purchaseDate!)
: '',
),
_AssetInfo(
'Warranty Expiry',
asset.warrantyExpiryDate != null
@ -220,16 +231,36 @@ class _OverviewTab extends StatelessWidget {
'Purchase Cost',
asset.purchaseCost != null ? '${asset.purchaseCost}' : '',
),
_AssetInfo.status(
'Status',
AppStatusChip(status: asset.status ?? 'active'),
_AssetInfo(
'Useful Life',
asset.usefulLifeYears != null
? '${asset.usefulLifeYears} years'
: '',
),
_AssetInfo(
'Active',
asset.isActive ? 'Yes' : 'No',
'Depreciation',
asset.depreciationMethod != null
? '${asset.depreciationMethod}'
'${asset.depreciationRate != null ? ' (${asset.depreciationRate}%)' : ''}'
: '',
),
_AssetInfo('Condition', assetConditionLabel(asset.condition)),
_AssetInfo.widget(
'Status',
AppStatusChip(status: asset.status ?? 'IN_USE'),
),
_AssetInfo('Active', asset.isActive ? 'Yes' : 'No'),
],
),
if (asset.remarks?.trim().isNotEmpty == true) ...[
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Divider(height: 1),
),
_AssetInfoGrid(
items: [_AssetInfo('Remarks', asset.remarks!)],
),
],
],
),
),
@ -315,7 +346,7 @@ class _AssetDetailTile extends StatelessWidget {
class _AssetInfo {
const _AssetInfo(this.label, this.value) : valueWidget = null;
const _AssetInfo.status(this.label, this.valueWidget) : value = null;
const _AssetInfo.widget(this.label, this.valueWidget) : value = null;
final String label;
final String? value;
@ -363,7 +394,14 @@ class _AmcTab extends ConsumerWidget {
),
itemCount: contracts.length,
itemBuilder: (context, index) {
return _AssetAmcCard(contract: contracts[index]);
return _AssetAmcCard(
contract: contracts[index],
onEdit: () => _openEditAmcPanel(
context,
ref,
contracts[index].id,
),
);
},
),
),
@ -383,13 +421,35 @@ class _AmcTab extends ConsumerWidget {
);
}
}
Future<void> _openEditAmcPanel(
BuildContext context,
WidgetRef ref,
String contractId,
) async {
final saved = await showSidePanel<bool>(
context,
AddAmcPanel(assetId: assetId, contractId: contractId),
width: 520,
);
if (saved == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('AMC contract updated')),
);
}
}
}
class _ServiceVisitsTab extends ConsumerWidget {
const _ServiceVisitsTab({required this.assetId, required this.visits});
const _ServiceVisitsTab({
required this.assetId,
required this.visits,
required this.amcContracts,
});
final String assetId;
final List<ServiceVisitModel> visits;
final List<AmcContractModel> amcContracts;
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -426,7 +486,14 @@ class _ServiceVisitsTab extends ConsumerWidget {
),
itemCount: visits.length,
itemBuilder: (context, index) {
return _AssetServiceVisitCard(visit: visits[index]);
return _AssetServiceVisitCard(
visit: visits[index],
onEdit: () => _openEditVisitPanel(
context,
ref,
visits[index].id,
),
);
},
),
),
@ -437,8 +504,11 @@ class _ServiceVisitsTab extends ConsumerWidget {
Future<void> _openLogVisitPanel(BuildContext context, WidgetRef ref) async {
final saved = await showSidePanel<bool>(
context,
LogServiceVisitPanel(assetId: assetId),
width: 520,
LogServiceVisitPanel(
assetId: assetId,
amcContracts: amcContracts,
),
width: 560,
);
if (saved == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@ -446,6 +516,27 @@ class _ServiceVisitsTab extends ConsumerWidget {
);
}
}
Future<void> _openEditVisitPanel(
BuildContext context,
WidgetRef ref,
String visitId,
) async {
final saved = await showSidePanel<bool>(
context,
LogServiceVisitPanel(
assetId: assetId,
visitId: visitId,
amcContracts: amcContracts,
),
width: 560,
);
if (saved == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Service visit updated')),
);
}
}
}
class _InsuranceTab extends ConsumerWidget {
@ -489,7 +580,14 @@ class _InsuranceTab extends ConsumerWidget {
),
itemCount: policies.length,
itemBuilder: (context, index) {
return _AssetInsuranceCard(policy: policies[index]);
return _AssetInsuranceCard(
policy: policies[index],
onEdit: () => _openEditInsurancePanel(
context,
ref,
policies[index].id,
),
);
},
),
),
@ -509,12 +607,33 @@ class _InsuranceTab extends ConsumerWidget {
);
}
}
Future<void> _openEditInsurancePanel(
BuildContext context,
WidgetRef ref,
String policyId,
) async {
final saved = await showSidePanel<bool>(
context,
AddInsurancePanel(assetId: assetId, policyId: policyId),
width: 520,
);
if (saved == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Insurance policy updated')),
);
}
}
}
class _AssetAmcCard extends StatelessWidget {
const _AssetAmcCard({required this.contract});
const _AssetAmcCard({
required this.contract,
this.onEdit,
});
final AmcContractModel contract;
final VoidCallback? onEdit;
@override
Widget build(BuildContext context) {
@ -535,18 +654,40 @@ class _AssetAmcCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFFCA8A04).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.handyman_outlined,
color: Color(0xFFCA8A04),
size: 20,
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFFCA8A04).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.handyman_outlined,
color: Color(0xFFCA8A04),
size: 20,
),
),
const Spacer(),
if (onEdit != null)
CanPermission(
module: 'assets',
action: PermissionAction.update,
child: IconButton(
icon: const Icon(Icons.edit_outlined, size: 18),
tooltip: 'Edit',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: onEdit,
),
),
],
),
const SizedBox(height: 8),
Text(
@ -628,9 +769,13 @@ class _AssetAmcCard extends StatelessWidget {
}
class _AssetServiceVisitCard extends StatelessWidget {
const _AssetServiceVisitCard({required this.visit});
const _AssetServiceVisitCard({
required this.visit,
this.onEdit,
});
final ServiceVisitModel visit;
final VoidCallback? onEdit;
@override
Widget build(BuildContext context) {
@ -646,18 +791,40 @@ class _AssetServiceVisitCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFF2563EB).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.build_outlined,
color: Color(0xFF2563EB),
size: 20,
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFF2563EB).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.build_outlined,
color: Color(0xFF2563EB),
size: 20,
),
),
const Spacer(),
if (onEdit != null)
CanPermission(
module: 'assets',
action: PermissionAction.update,
child: IconButton(
icon: const Icon(Icons.edit_outlined, size: 18),
tooltip: 'Edit',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: onEdit,
),
),
],
),
const SizedBox(height: 8),
Text(
@ -748,9 +915,13 @@ class _AssetServiceVisitCard extends StatelessWidget {
}
class _AssetInsuranceCard extends StatelessWidget {
const _AssetInsuranceCard({required this.policy});
const _AssetInsuranceCard({
required this.policy,
this.onEdit,
});
final InsurancePolicyModel policy;
final VoidCallback? onEdit;
@override
Widget build(BuildContext context) {
@ -766,18 +937,40 @@ class _AssetInsuranceCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFF16A34A).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.shield_outlined,
color: Color(0xFF16A34A),
size: 20,
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFF16A34A).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.shield_outlined,
color: Color(0xFF16A34A),
size: 20,
),
),
const Spacer(),
if (onEdit != null)
CanPermission(
module: 'assets',
action: PermissionAction.update,
child: IconButton(
icon: const Icon(Icons.edit_outlined, size: 18),
tooltip: 'Edit',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: onEdit,
),
),
],
),
const SizedBox(height: 8),
Text(

View File

@ -16,6 +16,7 @@ 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/app_pagination.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/can_permission.dart';
@ -74,8 +75,6 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
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,
@ -194,67 +193,18 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
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'),
),
],
child: AppPagination(
currentPage: page,
totalPages: state.totalPages,
totalItems: total,
pageSize: pageSize,
itemLabel: 'assets',
onPageChanged: ref
.read(assetsListProvider.notifier)
.setPage,
onPageSizeChanged: ref
.read(assetsListProvider.notifier)
.setPageSize,
),
),
],
@ -393,13 +343,18 @@ class _AssetsFilterBar extends StatelessWidget {
@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,
final searchField = Padding(
padding: const EdgeInsets.only(top: 8),
child: TextField(
decoration: const InputDecoration(
labelText: 'Search',
hintText: 'Search by name, code, category...',
prefixIcon: Icon(Icons.search, size: 20),
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
),
onChanged: onSearch,
),
onChanged: onSearch,
);
final filters = [
@ -429,12 +384,14 @@ class _AssetsFilterBar extends StatelessWidget {
children: [
searchField,
const SizedBox(height: 12),
Wrap(spacing: 12, runSpacing: 12, children: filters),
...filters.expand((f) => [f, const SizedBox(height: 12)]).toList()
..removeLast(),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
@ -503,7 +460,11 @@ class _AssetDataTable extends StatelessWidget {
AppDataColumn(
label: 'Asset Code',
flex: 1,
cellBuilder: (_, asset) => Text(asset.assetCode ?? ''),
cellBuilder: (_, asset) {
final code = asset.assetCode;
if (code == null || code.isEmpty) return const Text('');
return _AssetCodeBadge(code: code);
},
),
AppDataColumn(
label: 'Asset Name',
@ -621,7 +582,10 @@ class _AssetMobileList extends StatelessWidget {
],
),
const SizedBox(height: 4),
Text(asset.assetCode ?? ''),
if (asset.assetCode != null && asset.assetCode!.isNotEmpty)
_AssetCodeBadge(code: asset.assetCode!)
else
const Text(''),
Text('${asset.assetCategoryName ?? ''} · ${asset.plantName ?? ''}'),
const SizedBox(height: 8),
Row(
@ -654,3 +618,28 @@ class _AssetMobileList extends StatelessWidget {
);
}
}
class _AssetCodeBadge extends StatelessWidget {
const _AssetCodeBadge({required this.code});
final String code;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(20),
),
child: Text(
code,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
}

View File

@ -16,6 +16,7 @@ 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/asset_form_lookups_provider.dart';
import '../providers/assets_provider.dart';
Future<void> openAssetFormPanel(
@ -24,10 +25,11 @@ Future<void> openAssetFormPanel(
String? assetId,
}) async {
ref.invalidate(assetFormProvider(assetId));
final panelWidth = MediaQuery.sizeOf(context).width * 0.4;
final saved = await showSidePanel<bool>(
context,
AssetFormPanel(assetId: assetId),
width: 520,
width: panelWidth,
);
if (saved == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@ -57,35 +59,143 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _costController = TextEditingController();
final _serialController = TextEditingController();
final _partNumberController = TextEditingController();
final _brandModelController = TextEditingController();
final _manufacturerController = TextEditingController();
final _locationDetailController = TextEditingController();
final _qrCodeController = TextEditingController();
final _disposalReasonController = TextEditingController();
final _disposalValueController = TextEditingController();
final _usefulLifeController = TextEditingController();
final _depreciationRateController = TextEditingController();
final _salvageValueController = TextEditingController();
final _remarksController = TextEditingController();
int? _categoryId;
int? _subcategoryId;
int? _plantId;
int? _departmentId;
int? _warehouseId;
int? _assignedToUserId;
int? _vendorId;
int? _poId;
int? _grnId;
int? _grnItemId;
String? _status;
String? _condition;
String? _depreciationMethod;
DateTime? _warrantyExpiry;
DateTime? _purchaseDate;
DateTime? _disposalDate;
bool _isActive = true;
bool _isSubmitting = false;
String? _populatedSignature;
@override
void initState() {
super.initState();
if (!widget.isEditing) {
_condition = 'NEW';
_status = 'IN_USE';
}
}
@override
void dispose() {
_nameController.dispose();
_costController.dispose();
_serialController.dispose();
_partNumberController.dispose();
_brandModelController.dispose();
_manufacturerController.dispose();
_locationDetailController.dispose();
_qrCodeController.dispose();
_disposalReasonController.dispose();
_disposalValueController.dispose();
_usefulLifeController.dispose();
_depreciationRateController.dispose();
_salvageValueController.dispose();
_remarksController.dispose();
super.dispose();
}
String _assetSignature(AssetModel asset) =>
'${asset.id}:${asset.assetCategoryId}:${asset.plantId}:'
'${asset.warrantyExpiryDate?.toIso8601String()}:'
'${asset.purchaseCost}:${asset.assetName}';
'${asset.id}:${asset.assetCategoryId}:${asset.assetSubcategoryId}:'
'${asset.plantId}:${asset.status}:${asset.assetName}';
int? _nullablePositiveId(int? id) {
if (id == null || id <= 0) return null;
return id;
}
void _putOptionalId(Map<String, dynamic> payload, String key, int? value) {
final normalized = _nullablePositiveId(value);
if (normalized != null) payload[key] = normalized;
}
void _putOptionalText(
Map<String, dynamic> payload,
String key,
String value,
) {
final trimmed = value.trim();
if (trimmed.isNotEmpty) payload[key] = trimmed;
}
void _putOptionalDouble(
Map<String, dynamic> payload,
String key,
String value,
) {
final trimmed = value.trim();
if (trimmed.isEmpty) return;
final parsed = double.tryParse(trimmed);
if (parsed != null) payload[key] = parsed;
}
int? _dropdownValue(int? selected, Iterable<int> validIds) {
if (selected == null) return null;
return validIds.contains(selected) ? selected : null;
}
bool _isAssignableUser(int? userId) {
if (userId == null || userId <= 0) return false;
final users = ref.read(assetFormLookupsProvider).valueOrNull?.users ?? const [];
return users.any((user) => int.tryParse(user.id) == userId);
}
void _populateFromAsset(AssetModel asset) {
setState(() {
_nameController.text = asset.assetName;
_categoryId = asset.assetCategoryId;
_subcategoryId = asset.assetSubcategoryId;
_plantId = asset.plantId;
_departmentId = _nullablePositiveId(asset.departmentId);
_warehouseId = _nullablePositiveId(asset.warehouseId);
_assignedToUserId = _nullablePositiveId(asset.assignedToUserId);
_vendorId = _nullablePositiveId(asset.vendorId);
_poId = _nullablePositiveId(asset.poId);
_grnId = _nullablePositiveId(asset.grnId);
_grnItemId = _nullablePositiveId(asset.grnItemId);
_status = asset.status;
_condition = asset.condition;
_depreciationMethod = asset.depreciationMethod;
_warrantyExpiry = asset.warrantyExpiryDate;
_purchaseDate = asset.purchaseDate;
_disposalDate = asset.disposalDate;
_isActive = asset.isActive;
_serialController.text = asset.serialNumber ?? '';
_partNumberController.text = asset.partNumber ?? '';
_brandModelController.text = asset.brandModel ?? '';
_manufacturerController.text = asset.manufacturer ?? '';
_locationDetailController.text = asset.locationDetail ?? '';
_qrCodeController.text = asset.qrCodeValue ?? '';
_disposalReasonController.text = asset.disposalReason ?? '';
_disposalValueController.text = asset.disposalValue?.toString() ?? '';
_usefulLifeController.text = asset.usefulLifeYears?.toString() ?? '';
_depreciationRateController.text = asset.depreciationRate?.toString() ?? '';
_salvageValueController.text = asset.salvageValue?.toString() ?? '';
_remarksController.text = asset.remarks ?? '';
if (asset.purchaseCost != null) {
_costController.text = asset.purchaseCost!.toStringAsFixed(
asset.purchaseCost! % 1 == 0 ? 0 : 2,
@ -97,33 +207,81 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
}
Map<String, dynamic> _buildPayload() {
return {
final payload = <String, dynamic>{
'asset_name': _nameController.text.trim(),
'asset_category_id': _categoryId,
'asset_subcategory_id': _subcategoryId,
'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()),
'is_active': _isActive,
};
_putOptionalId(payload, 'department_id', _departmentId);
_putOptionalId(payload, 'warehouse_id', _warehouseId);
if (_isAssignableUser(_assignedToUserId)) {
_putOptionalId(payload, 'assigned_to_user_id', _assignedToUserId);
}
_putOptionalId(payload, 'vendor_id', _vendorId);
_putOptionalId(payload, 'po_id', _poId);
_putOptionalId(payload, 'grn_id', _grnId);
_putOptionalId(payload, 'grn_item_id', _grnItemId);
_putOptionalText(payload, 'serial_number', _serialController.text);
_putOptionalText(payload, 'part_number', _partNumberController.text);
_putOptionalText(payload, 'brand_model', _brandModelController.text);
_putOptionalText(payload, 'manufacturer', _manufacturerController.text);
_putOptionalText(payload, 'location_detail', _locationDetailController.text);
_putOptionalText(payload, 'qr_code_value', _qrCodeController.text);
_putOptionalText(payload, 'remarks', _remarksController.text);
_putOptionalText(payload, 'disposal_reason', _disposalReasonController.text);
if (_purchaseDate != null) {
payload['purchase_date'] = DateFormat('yyyy-MM-dd').format(_purchaseDate!);
}
if (_warrantyExpiry != null) {
payload['warranty_expiry_date'] =
DateFormat('yyyy-MM-dd').format(_warrantyExpiry!);
}
if (_disposalDate != null) {
payload['disposal_date'] = DateFormat('yyyy-MM-dd').format(_disposalDate!);
}
_putOptionalDouble(payload, 'purchase_cost', _costController.text);
_putOptionalDouble(payload, 'salvage_value', _salvageValueController.text);
_putOptionalDouble(payload, 'disposal_value', _disposalValueController.text);
final usefulLife = int.tryParse(_usefulLifeController.text.trim());
if (usefulLife != null) payload['useful_life_years'] = usefulLife;
if (_depreciationMethod != null) {
payload['depreciation_method'] = _depreciationMethod;
}
final depreciationRate =
double.tryParse(_depreciationRateController.text.trim());
if (depreciationRate != null) payload['depreciation_rate'] = depreciationRate;
if (_condition != null) payload['condition'] = _condition;
if (_status != null) payload['status'] = _status;
return payload;
}
Future<void> _pickWarrantyDate() async {
Future<void> _pickDate(void Function(DateTime) onPicked, DateTime? current) async {
final picked = await showDatePicker(
context: context,
initialDate: _warrantyExpiry ?? DateTime.now(),
initialDate: current ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) setState(() => _warrantyExpiry = picked);
if (picked != null) setState(() => onPicked(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')),
);
final businessError = _validateBusinessRules();
if (businessError != null) {
showSidePanelSnackBar(context, businessError);
return;
}
@ -139,15 +297,67 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
showSidePanelSnackBar(context, e.toString());
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
String? _validateBusinessRules() {
if (_purchaseDate != null &&
_warrantyExpiry != null &&
_warrantyExpiry!.isBefore(_purchaseDate!)) {
return 'Warranty expiry must be on or after purchase date';
}
final purchaseCost = double.tryParse(_costController.text.trim());
final salvageValue = double.tryParse(_salvageValueController.text.trim());
if (purchaseCost != null &&
salvageValue != null &&
salvageValue > purchaseCost) {
return 'Salvage value cannot exceed purchase cost';
}
final hasDepreciationMethod =
_depreciationMethod != null && _depreciationMethod!.trim().isNotEmpty;
if (hasDepreciationMethod) {
if (_usefulLifeController.text.trim().isEmpty) {
return 'Useful life is required when depreciation method is set';
}
if (_depreciationRateController.text.trim().isEmpty) {
return 'Depreciation rate is required when depreciation method is set';
}
}
final isDisposed = _status == 'DISPOSED' || _status == 'SCRAPPED';
if (isDisposed) {
if (_disposalDate == null) {
return 'Disposal date is required for disposed or scrapped assets';
}
if (_disposalReasonController.text.trim().isEmpty) {
return 'Disposal reason is required for disposed or scrapped assets';
}
}
final hasDisposalInput = _disposalDate != null ||
_disposalReasonController.text.trim().isNotEmpty ||
_disposalValueController.text.trim().isNotEmpty;
if (hasDisposalInput && !isDisposed) {
return 'Set status to Disposed or Scrapped when entering disposal details';
}
if (_grnId != null) {
final grnItems =
ref.read(assetGrnItemsProvider(_grnId)).valueOrNull ?? const [];
if (grnItems.isNotEmpty && _grnItemId == null) {
return 'Please select a GRN item when a GRN is linked';
}
}
return null;
}
@override
Widget build(BuildContext context) {
final categoriesAsync = ref.watch(assetCategoriesProvider);
@ -207,46 +417,489 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
AsyncValue<List<AssetCategoryModel>> categoriesAsync,
AsyncValue<List<FilterOptionModel>> plantsAsync,
) {
final subcategoriesAsync = ref.watch(assetSubcategoriesProvider(_categoryId));
final lookupsAsync = ref.watch(assetFormLookupsProvider);
final grnItemsAsync = ref.watch(assetGrnItemsProvider(_grnId));
final depreciationMethods =
lookupsAsync.valueOrNull?.depreciationMethods ?? const <String>[];
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),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SidePanelSection(
title: 'BASIC DETAILS',
children: [
AppTextField(
isDense: true,
controller: _nameController,
label: 'Asset Name *',
validator: (v) {
final requiredError =
Validators.required(v, fieldName: 'Asset name');
if (requiredError != null) return requiredError;
return Validators.minLength(v!.trim(), 2,
fieldName: 'Asset name');
},
),
const SizedBox(height: 12),
SidePanelFormRow(
left: categoriesAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('Failed to load categories'),
data: (categories) => _categoryDropdown(categories),
),
right: subcategoriesAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('Failed to load subcategories'),
data: (subcategories) => _subcategoryDropdown(subcategories),
),
),
const SizedBox(height: 12),
plantsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('Failed to load plants'),
data: (plants) => _plantDropdown(plants),
),
],
),
right: plantsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('Failed to load plants'),
data: (plants) => _plantDropdown(plants),
SidePanelSection(
title: 'IDENTIFICATION',
children: [
SidePanelFormRow(
left: AppTextField(
isDense: true,
controller: _serialController,
label: 'Serial Number',
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
return Validators.minLength(
v.trim(),
2,
fieldName: 'Serial number',
);
},
),
right: AppTextField(
isDense: true,
controller: _partNumberController,
label: 'Part Number',
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
return Validators.minLength(
v.trim(),
2,
fieldName: 'Part number',
);
},
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppTextField(
isDense: true,
controller: _brandModelController,
label: 'Brand / Model',
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
return Validators.minLength(
v.trim(),
2,
fieldName: 'Brand / model',
);
},
),
right: AppTextField(
isDense: true,
controller: _manufacturerController,
label: 'Manufacturer',
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
return Validators.minLength(
v.trim(),
2,
fieldName: 'Manufacturer',
);
},
),
),
],
),
),
SidePanelFormRow(
left: _AssetFormDateField(
label: 'Warranty Expiry Date',
value: _warrantyExpiry,
onPick: _pickWarrantyDate,
lookupsAsync.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: LinearProgressIndicator(),
),
error: (_, __) => const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('Failed to load lookup options'),
),
data: (lookups) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SidePanelSection(
title: 'LOCATION & ASSIGNMENT',
children: [
SidePanelFormRow(
left: _optionalLookupDropdown(
label: 'Department',
value: _departmentId,
options: lookups.departments,
onChanged: (v) => setState(() => _departmentId = v),
),
right: _optionalLookupDropdown(
label: 'Warehouse',
value: _warehouseId,
options: lookups.warehouses,
onChanged: (v) => setState(() => _warehouseId = v),
),
),
const SizedBox(height: 12),
AppTextField(
isDense: true,
controller: _locationDetailController,
label: 'Location Detail',
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
return Validators.minLength(
v.trim(),
2,
fieldName: 'Location detail',
);
},
),
const SizedBox(height: 12),
_optionalLookupDropdown(
label: 'Assigned To',
value: _assignedToUserId,
options: lookups.users,
onChanged: (v) => setState(() => _assignedToUserId = v),
emptyHint: 'Unassigned',
),
],
),
SidePanelSection(
title: 'PROCUREMENT',
children: [
SidePanelFormRow(
left: _optionalLookupDropdown(
label: 'Vendor',
value: _vendorId,
options: lookups.vendors,
onChanged: (v) => setState(() => _vendorId = v),
),
right: _optionalLookupDropdown(
label: 'Purchase Order',
value: _poId,
options: lookups.purchaseOrders,
onChanged: (v) => setState(() => _poId = v),
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: _optionalLookupDropdown(
label: 'GRN',
value: _grnId,
options: lookups.grns,
onChanged: (v) => setState(() {
_grnId = v;
_grnItemId = null;
}),
),
right: grnItemsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => _optionalLookupDropdown(
label: 'GRN Item',
value: _grnItemId,
options: const [],
onChanged: (v) => setState(() => _grnItemId = v),
enabled: false,
),
data: (items) => _optionalLookupDropdown(
label: 'GRN Item',
value: _grnItemId,
options: items,
onChanged: (v) => setState(() => _grnItemId = v),
enabled: _grnId != null && items.isNotEmpty,
emptyHint: _grnId == null
? 'Select GRN first'
: 'None',
required: _grnId != null && items.isNotEmpty,
),
),
),
],
),
],
),
),
right: AppTextField(
controller: _costController,
label: 'Purchase Cost',
keyboardType: TextInputType.number,
SidePanelSection(
title: 'PURCHASE & DEPRECIATION',
children: [
SidePanelFormRow(
left: _AssetFormDateField(
label: 'Purchase Date',
value: _purchaseDate,
onPick: () =>
_pickDate((d) => _purchaseDate = d, _purchaseDate),
),
right: _AssetFormDateField(
label: 'Warranty Expiry Date',
value: _warrantyExpiry,
onPick: () =>
_pickDate((d) => _warrantyExpiry = d, _warrantyExpiry),
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppTextField(
isDense: true,
controller: _costController,
label: 'Purchase Cost',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => Validators.optionalPositiveDouble(
v,
fieldName: 'Purchase cost',
),
),
right: AppTextField(
isDense: true,
controller: _usefulLifeController,
label: 'Useful Life (Years)',
keyboardType: TextInputType.number,
validator: (v) => Validators.optionalPositiveInt(
v,
fieldName: 'Useful life',
),
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppDropdown<String>(
isDense: true,
label: 'Depreciation Method',
value: _depreciationMethod,
options: depreciationMethods
.map((method) => AppDropdownOption(
value: method,
label: method,
))
.toList(),
enabled: depreciationMethods.isNotEmpty,
onChanged: (v) => setState(() => _depreciationMethod = v),
),
right: AppTextField(
isDense: true,
controller: _depreciationRateController,
label: 'Depreciation Rate (%)',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => Validators.optionalPercentage(
v,
fieldName: 'Depreciation rate',
),
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppTextField(
isDense: true,
controller: _salvageValueController,
label: 'Salvage Value',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => Validators.optionalNonNegativeDouble(
v,
fieldName: 'Salvage value',
),
),
right: const SizedBox.shrink(),
),
],
),
),
],
SidePanelSection(
title: 'STATUS',
children: [
SidePanelFormRow(
left: AppDropdown<String>(
isDense: true,
label: 'Condition *',
value: _condition,
options: assetConditionOptions
.map((e) => AppDropdownOption(value: e.$1, label: e.$2))
.toList(),
onChanged: (v) => setState(() => _condition = v),
validator: (v) => v == null ? 'Condition is required' : null,
),
right: AppDropdown<String>(
isDense: true,
label: 'Status *',
value: _status,
options: assetStatusOptions
.map((e) => AppDropdownOption(value: e.$1, label: e.$2))
.toList(),
onChanged: (v) => setState(() => _status = v),
validator: (v) => v == null ? 'Status is required' : null,
),
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Active'),
subtitle: const Text('Inactive assets are hidden from active lists'),
value: _isActive,
onChanged: (value) => setState(() => _isActive = value),
),
],
),
SidePanelSection(
title: 'DISPOSAL',
children: [
SidePanelFormRow(
left: _AssetFormDateField(
label: 'Disposal Date',
value: _disposalDate,
onPick: () =>
_pickDate((d) => _disposalDate = d, _disposalDate),
validator: () {
final isDisposed =
_status == 'DISPOSED' || _status == 'SCRAPPED';
if (isDisposed && _disposalDate == null) {
return 'Disposal date is required';
}
return null;
},
),
right: AppTextField(
isDense: true,
controller: _disposalValueController,
label: 'Disposal Value',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => Validators.optionalNonNegativeDouble(
v,
fieldName: 'Disposal value',
),
),
),
const SizedBox(height: 12),
AppTextField(
isDense: true,
controller: _disposalReasonController,
label: 'Disposal Reason',
maxLines: 2,
validator: (v) {
final isDisposed =
_status == 'DISPOSED' || _status == 'SCRAPPED';
if (isDisposed) {
return Validators.required(v, fieldName: 'Disposal reason');
}
if (v == null || v.trim().isEmpty) return null;
return Validators.minLength(
v.trim(),
3,
fieldName: 'Disposal reason',
);
},
),
],
),
SidePanelSection(
title: 'OTHER',
children: [
AppTextField(
isDense: true,
controller: _qrCodeController,
label: 'QR Code Value',
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
return Validators.minLength(
v.trim(),
2,
fieldName: 'QR code value',
);
},
),
const SizedBox(height: 12),
AppTextField(
isDense: true,
controller: _remarksController,
label: 'Remarks',
maxLines: 2,
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
return Validators.minLength(
v.trim(),
2,
fieldName: 'Remarks',
);
},
),
],
),
],
),
),
);
}
Widget _optionalLookupDropdown({
required String label,
required int? value,
required List<FilterOptionModel> options,
required ValueChanged<int?> onChanged,
String? emptyHint,
bool enabled = true,
bool required = false,
}) {
final dropdownOptions = <AppDropdownOption<int?>>[
AppDropdownOption<int?>(value: null, label: emptyHint ?? 'None'),
...options
.map((option) {
final id = int.tryParse(option.id);
if (id == null || id <= 0) return null;
return AppDropdownOption<int?>(value: id, label: option.name);
})
.whereType<AppDropdownOption<int?>>(),
];
final validIds = dropdownOptions
.map((option) => option.value)
.whereType<int>()
.toList();
return AppSearchableDropdown<int?>(
label: required ? '$label *' : label,
value: value != null && validIds.contains(value) ? value : null,
hint: emptyHint ?? 'None',
searchHint: 'Search ${label.toLowerCase()}...',
isDense: true,
enabled: enabled && options.isNotEmpty,
options: dropdownOptions,
onChanged: onChanged,
validator: required ? (v) => v == null ? '$label is required' : null : null,
);
}
Widget _subcategoryDropdown(List<FilterOptionModel> subcategories) {
final ids = subcategories.map((c) => int.tryParse(c.id)).whereType<int>().toList();
return AppSearchableDropdown<int>(
label: 'Subcategory *',
value: _dropdownValue(_subcategoryId, ids),
searchHint: 'Search subcategory...',
isDense: true,
options: subcategories
.map(
(c) => AppDropdownOption(
value: int.tryParse(c.id) ?? 0,
label: c.name,
),
)
.where((option) => option.value != 0)
.toList(),
onChanged: (v) => setState(() => _subcategoryId = v),
validator: (v) => v == null ? 'Subcategory is required' : null,
);
}
Widget _categoryDropdown(List<AssetCategoryModel> categories) {
final categoryIds = categories
.map((c) => int.tryParse(c.id))
@ -256,6 +909,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
label: 'Category *',
value: _dropdownValue(_categoryId, categoryIds),
searchHint: 'Search category...',
isDense: true,
options: categories
.map(
(c) => AppDropdownOption(
@ -265,7 +919,10 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
)
.where((option) => option.value != 0)
.toList(),
onChanged: (v) => setState(() => _categoryId = v),
onChanged: (v) => setState(() {
_categoryId = v;
_subcategoryId = null;
}),
validator: (v) => v == null ? 'Category is required' : null,
);
}
@ -279,6 +936,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
label: 'Plant *',
value: _dropdownValue(_plantId, plantIds),
searchHint: 'Search plant...',
isDense: true,
options: plants
.map(
(p) => AppDropdownOption(
@ -294,30 +952,67 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
}
}
class _AssetFormDateField extends StatelessWidget {
class _AssetFormDateField extends StatefulWidget {
const _AssetFormDateField({
required this.label,
required this.value,
required this.onPick,
this.validator,
});
final String label;
final DateTime? value;
final VoidCallback onPick;
final String? Function()? validator;
@override
State<_AssetFormDateField> createState() => _AssetFormDateFieldState();
}
class _AssetFormDateFieldState extends State<_AssetFormDateField> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: _displayText);
}
@override
void didUpdateWidget(covariant _AssetFormDateField oldWidget) {
super.didUpdateWidget(oldWidget);
final text = _displayText;
if (_controller.text != text) {
_controller.text = text;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
String get _displayText =>
widget.value != null ? DateFormatter.displayDate(widget.value) : '';
@override
Widget build(BuildContext context) {
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),
return Padding(
padding: const EdgeInsets.only(top: 8),
child: TextFormField(
readOnly: true,
onTap: widget.onPick,
controller: _controller,
validator: (_) => widget.validator?.call(),
decoration: InputDecoration(
labelText: widget.label,
hintText: 'Select date',
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 20),
),
),
controller: TextEditingController(text: displayText),
);
}
}
@ -326,3 +1021,10 @@ final assetPlantsProvider = FutureProvider<List<FilterOptionModel>>((ref) async
final dataSource = ref.watch(masterRemoteDataSourceProvider);
return dataSource.listPlants();
});
final assetSubcategoriesProvider =
FutureProvider.family<List<FilterOptionModel>, int?>((ref, categoryId) async {
if (categoryId == null) return [];
final dataSource = ref.watch(masterRemoteDataSourceProvider);
return dataSource.listAssetSubcategories(assetCategoryId: categoryId);
});

File diff suppressed because it is too large Load Diff

View File

@ -1,40 +1,123 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../settings/presentation/providers/settings_provider.dart';
class CompanyListScreen extends StatelessWidget {
class CompanyListScreen extends ConsumerStatefulWidget {
const CompanyListScreen({super.key});
@override
ConsumerState<CompanyListScreen> createState() => _CompanyListScreenState();
}
class _CompanyListScreenState extends ConsumerState<CompanyListScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(appSettingsProvider.notifier).refreshCompanyProfile();
});
}
@override
Widget build(BuildContext context) {
final profile = ref.watch(appSettingsProvider).companyProfile;
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: 'Companies',
subtitle: 'Manage organizations',
title: 'Company',
subtitle: 'Organisation profile from server settings',
actions: [
ElevatedButton.icon(
onPressed: () => context.push('${RouteConstants.companies}/add'),
icon: const Icon(Icons.add),
label: const Text('Add Company'),
AppButton(
label: 'Edit Company',
expand: false,
icon: Icons.edit_outlined,
onPressed: () => context.push(RouteConstants.settingsCompanyProfile),
),
],
),
const Expanded(
child: EmptyStateView(
title: 'No companies yet',
description: 'Connect to the API to load companies, or add your first company.',
icon: Icons.business_outlined,
),
const SizedBox(height: 16),
Expanded(
child: profile.companyName.isEmpty
? const AppEmptyState(
title: 'No company profile',
description: 'Configure your organisation details in settings.',
icon: Icons.business_outlined,
)
: AppCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profile.companyName,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
_InfoRow(label: 'Mobile', value: profile.phone),
_InfoRow(label: 'Email', value: profile.email),
_InfoRow(label: 'Website', value: profile.website),
_InfoRow(label: 'Address', value: profile.address),
_InfoRow(
label: 'City / State / Pincode',
value: [
profile.city,
profile.state,
profile.pincode,
].where((e) => e.isNotEmpty).join(', '),
),
],
),
),
),
),
],
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
if (value.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 160,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Expanded(child: Text(value)),
],
),
);
}
}

View File

@ -352,12 +352,37 @@ const masterDefinitions = <MasterDefinition>[
MasterFieldDef(
key: 'default_depreciation_method',
label: 'Depreciation Method',
type: MasterFieldType.dropdown,
required: true,
showInList: true,
optionsMasterKey: 'asset_depreciation_methods',
),
_activeField,
],
),
MasterDefinition(
id: 'asset_subcategories',
title: 'Asset Subcategories',
subtitle: 'Sub-classification under asset categories',
category: 'Assets',
routeKey: 'asset-subcategories',
apiPath: '/masters/asset-subcategories',
module: 'asset_subcategories',
icon: Icons.category_outlined,
fields: [
MasterFieldDef(
key: 'asset_category_id',
label: 'Asset Category',
type: MasterFieldType.dropdown,
required: true,
showInList: true,
optionsMasterKey: 'asset_categories',
),
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
_activeField,
],
),
MasterDefinition(
id: 'delivery_terms',
title: 'Delivery Terms',

View File

@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../../assets/data/repositories/asset_repository_impl.dart';
import '../../data/repositories/master_repository_impl.dart';
import '../../domain/entities/master_definition.dart';
@ -280,6 +281,20 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
.toSet();
for (final key in keys) {
if (key == 'asset_depreciation_methods') {
final result = await ref.read(assetRepositoryProvider).getDepreciationMethods();
if (result.failure != null) continue;
final methods = (result.data ?? const <String>[])
.map((method) => method.trim())
.where((method) => method.isNotEmpty)
.toSet()
.toList();
options[key] = methods
.map((method) => <String, dynamic>{'id': method, 'name': method})
.toList();
continue;
}
final def = masterDefinitionById(key);
if (def == null) continue;
final result = await ref.read(masterRepositoryProvider).listOptions(def);

View File

@ -47,6 +47,37 @@ class MasterRemoteDataSource {
Future<List<FilterOptionModel>> listAssetCategories() =>
_listOptions(ApiEndpoints.assetCategories);
Future<List<FilterOptionModel>> listAssetSubcategories({int? assetCategoryId}) async {
final response = await dio.get(
ApiEndpoints.assetSubcategories,
queryParameters: {
'limit': AppConstants.maxPageSize,
'is_active': true,
if (assetCategoryId != null) 'asset_category_id': assetCategoryId,
},
);
final body = response.data as Map<String, dynamic>;
final raw = body['data'];
final list = raw is List<dynamic>
? raw
: raw is Map<String, dynamic>
? raw['items'] as List<dynamic>? ?? const []
: const [];
return list
.whereType<Map<String, dynamic>>()
.where((item) => item['is_active'] != false)
.map(
(item) => FilterOptionModel(
id: item['id']?.toString() ?? '',
name: _optionLabel(item),
),
)
.where((item) => item.id.isNotEmpty && item.name.isNotEmpty)
.toList();
}
Future<List<FilterOptionModel>> _listOptions(String endpoint) async {
final response = await dio.get(
endpoint,

View File

@ -792,6 +792,8 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
onPageChanged:
ref.read(usersListProvider.notifier).setPage,
onPageSizeChanged:
ref.read(usersListProvider.notifier).setPageSize,
),
],
),
@ -838,14 +840,19 @@ class _UsersFilterBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final searchField = TextField(
controller: searchController,
decoration: const InputDecoration(
hintText: 'Search by name, email, employee code...',
prefixIcon: Icon(Icons.search, size: 20),
isDense: true,
final searchField = Padding(
padding: const EdgeInsets.only(top: 8),
child: TextField(
controller: searchController,
decoration: const InputDecoration(
labelText: 'Search',
hintText: 'Search by name, email, employee code...',
prefixIcon: Icon(Icons.search, size: 20),
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
),
onChanged: onSearch,
),
onChanged: onSearch,
);
final filters = [
@ -867,8 +874,16 @@ class _UsersFilterBar extends StatelessWidget {
items: statuses,
onChanged: onStatusChanged,
),
OutlinedButton.icon(
];
final exportButton = Padding(
padding: const EdgeInsets.only(top: 8),
child: OutlinedButton.icon(
onPressed: isExporting ? null : onExport,
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
icon: isExporting
? const SizedBox(
width: 18,
@ -878,12 +893,7 @@ class _UsersFilterBar extends StatelessWidget {
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
),
];
final visibleFilters = [
...filters.take(3),
if (showExport) filters[3],
];
);
if (wrapped) {
return Column(
@ -891,12 +901,18 @@ class _UsersFilterBar extends StatelessWidget {
children: [
searchField,
const SizedBox(height: 12),
Wrap(spacing: 12, runSpacing: 12, children: visibleFilters),
...filters.expand((f) => [f, const SizedBox(height: 12)]).toList()
..removeLast(),
if (showExport) ...[
const SizedBox(height: 12),
exportButton,
],
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
@ -907,7 +923,7 @@ class _UsersFilterBar extends StatelessWidget {
Expanded(child: filters[2]),
if (showExport) ...[
const SizedBox(width: 12),
filters[3],
exportButton,
],
],
);

View File

@ -82,7 +82,16 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
_selectedDesignationId = user.designationId;
_selectedPlantId = user.plantId;
_selectedReportingToId = user.reportingTo;
_selectedStatus = _statusFromApi(user.status);
_selectedStatus = _statusLabelFromUser(user);
}
String _statusLabelFromUser(ManagedUserModel user) {
final normalized = user.status.trim().toLowerCase();
if (normalized.isNotEmpty && normalized != 'active') {
return _statusFromApi(user.status);
}
if (!user.isActive) return 'Inactive';
return _statusFromApi(user.status);
}
List<AppDropdownOption<String>> _toOptions(List<FilterOptionModel> items) {
@ -224,12 +233,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
),
data: (formState) {
if (formState.editingUser != null) {
if (!_prefilled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _prefilled) return;
setState(() => _prefillFromUser(formState.editingUser!));
});
}
_prefillFromUser(formState.editingUser!);
}
return Form(

View File

@ -167,10 +167,42 @@ class UserRolesCell extends StatelessWidget {
return const Text('');
}
return Wrap(
spacing: 6,
runSpacing: 6,
children: roles.map((role) => RoleBadge(label: role)).toList(),
final theme = Theme.of(context);
final extraCount = roles.length - 1;
final tooltipMessage = roles.join('\n');
return Tooltip(
message: tooltipMessage,
preferBelow: true,
waitDuration: const Duration(milliseconds: 250),
child: SizedBox(
height: 28,
child: Row(
children: [
Flexible(
child: RoleBadge(label: roles.first),
),
if (extraCount > 0) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'+$extraCount',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
),
);
}
}

View File

@ -23,4 +23,69 @@ class SettingsRemoteDataSource {
}
return settings;
}
Future<CompanyProfileSettings?> fetchCompany() async {
final response = await _dio.get(ApiEndpoints.settingsCompany);
final data = response.data['data'];
if (data is! Map<String, dynamic>) return null;
return CompanyProfileSettings.fromApiJson(data);
}
Future<CompanyProfileSettings> saveCompany(CompanyProfileSettings profile) async {
final response = await _dio.put(
ApiEndpoints.settingsCompany,
data: profile.toApiJson(),
);
final data = response.data['data'];
if (data is Map<String, dynamic>) {
return CompanyProfileSettings.fromApiJson(data).copyWith(
companyCode: profile.companyCode,
registrationNumber: profile.registrationNumber,
gstNumber: profile.gstNumber,
faviconUrl: profile.faviconUrl,
);
}
return profile;
}
Future<String?> uploadCompanyLogo(List<int> bytes, String filename) async {
final formData = FormData.fromMap({
'logo': MultipartFile.fromBytes(bytes, filename: filename),
});
final response = await _dio.post(
ApiEndpoints.settingsCompanyLogo,
data: formData,
);
final data = response.data['data'];
if (data is Map<String, dynamic>) {
return data['logo_url'] as String? ?? data['logo'] as String?;
}
return null;
}
Future<EmailConfigurationSettings?> fetchEmail() async {
final response = await _dio.get(ApiEndpoints.settingsEmail);
final data = response.data['data'];
if (data is! Map<String, dynamic>) return null;
return EmailConfigurationSettings.fromApiJson(data);
}
Future<EmailConfigurationSettings> saveEmail(
EmailConfigurationSettings email,
) async {
final response = await _dio.put(
ApiEndpoints.settingsEmail,
data: email.toApiJson(),
);
final data = response.data['data'];
if (data is Map<String, dynamic>) {
return EmailConfigurationSettings.fromApiJson(data).copyWith(
allocationTemplate: email.allocationTemplate,
returnTemplate: email.returnTemplate,
maintenanceTemplate: email.maintenanceTemplate,
warrantyTemplate: email.warrantyTemplate,
);
}
return email;
}
}

View File

@ -16,17 +16,147 @@ class SettingsRepositoryImpl implements SettingsRepository {
@override
Future<Result<AppSettings>> getSettings() async {
return safeApiCall(() async {
// Local-first until settings API is available.
return await local.read() ?? const AppSettings();
final localSettings = await local.read() ?? const AppSettings();
try {
final company = await remote.fetchCompany();
final email = await remote.fetchEmail();
return localSettings.copyWith(
companyProfile: company ?? localSettings.companyProfile,
email: email != null
? localSettings.email.copyWith(
smtpHost: email.smtpHost,
smtpPort: email.smtpPort,
smtpUsername: email.smtpUsername,
smtpPassword: email.smtpPassword.isNotEmpty
? email.smtpPassword
: localSettings.email.smtpPassword,
senderEmail: email.senderEmail,
senderName: email.senderName,
)
: localSettings.email,
);
} catch (_) {
return localSettings;
}
});
}
@override
Future<Result<AppSettings>> saveSettings(AppSettings settings) async {
return safeApiCall(() async {
// Always persist locally; remote sync can be wired when API is ready.
await local.write(settings);
return settings;
});
}
@override
Future<Result<CompanyProfileSettings>> fetchCompanyProfile() async {
return safeApiCall(() async {
final remoteProfile = await remote.fetchCompany();
if (remoteProfile != null) {
final current = await local.read();
final merged = (current?.companyProfile ?? const CompanyProfileSettings())
.copyWith(
companyName: remoteProfile.companyName,
address: remoteProfile.address,
city: remoteProfile.city,
state: remoteProfile.state,
pincode: remoteProfile.pincode,
email: remoteProfile.email,
phone: remoteProfile.phone,
website: remoteProfile.website,
logoUrl: remoteProfile.logoUrl.isNotEmpty
? remoteProfile.logoUrl
: current?.companyProfile.logoUrl ?? '',
);
await local.write((current ?? const AppSettings()).copyWith(
companyProfile: merged,
));
return merged;
}
return (await local.read())?.companyProfile ??
const CompanyProfileSettings();
});
}
@override
Future<Result<CompanyProfileSettings>> saveCompanyProfile(
CompanyProfileSettings profile,
) async {
return safeApiCall(() async {
final saved = await remote.saveCompany(profile);
final current = await local.read() ?? const AppSettings();
final merged = profile.copyWith(
companyName: saved.companyName.isNotEmpty
? saved.companyName
: profile.companyName,
address: saved.address.isNotEmpty ? saved.address : profile.address,
city: saved.city.isNotEmpty ? saved.city : profile.city,
state: saved.state.isNotEmpty ? saved.state : profile.state,
pincode: saved.pincode.isNotEmpty ? saved.pincode : profile.pincode,
email: saved.email.isNotEmpty ? saved.email : profile.email,
phone: saved.phone.isNotEmpty ? saved.phone : profile.phone,
website: saved.website.isNotEmpty ? saved.website : profile.website,
logoUrl: saved.logoUrl.isNotEmpty ? saved.logoUrl : profile.logoUrl,
);
await local.write(current.copyWith(companyProfile: merged));
return merged;
});
}
@override
Future<Result<String?>> uploadCompanyLogo(
List<int> bytes,
String filename,
) async {
return safeApiCall<String?>(() => remote.uploadCompanyLogo(bytes, filename));
}
@override
Future<Result<EmailConfigurationSettings>> fetchEmailSettings() async {
return safeApiCall(() async {
final remoteEmail = await remote.fetchEmail();
if (remoteEmail != null) {
final current = await local.read() ?? const AppSettings();
final merged = current.email.copyWith(
smtpHost: remoteEmail.smtpHost,
smtpPort: remoteEmail.smtpPort,
smtpUsername: remoteEmail.smtpUsername,
smtpPassword: remoteEmail.smtpPassword.isNotEmpty
? remoteEmail.smtpPassword
: current.email.smtpPassword,
senderEmail: remoteEmail.senderEmail,
senderName: remoteEmail.senderName,
);
await local.write(current.copyWith(email: merged));
return merged;
}
return (await local.read())?.email ?? const EmailConfigurationSettings();
});
}
@override
Future<Result<EmailConfigurationSettings>> saveEmailSettings(
EmailConfigurationSettings email,
) async {
return safeApiCall(() async {
final saved = await remote.saveEmail(email);
final current = await local.read() ?? const AppSettings();
final merged = email.copyWith(
smtpHost: saved.smtpHost.isNotEmpty ? saved.smtpHost : email.smtpHost,
smtpPort: saved.smtpPort,
smtpUsername:
saved.smtpUsername.isNotEmpty ? saved.smtpUsername : email.smtpUsername,
smtpPassword: saved.smtpPassword.isNotEmpty
? saved.smtpPassword
: email.smtpPassword,
senderEmail:
saved.senderEmail.isNotEmpty ? saved.senderEmail : email.senderEmail,
senderName:
saved.senderName.isNotEmpty ? saved.senderName : email.senderName,
);
await local.write(current.copyWith(email: merged));
return merged;
});
}
}

View File

@ -73,6 +73,9 @@ class CompanyProfileSettings {
this.registrationNumber = '',
this.gstNumber = '',
this.address = '',
this.city = '',
this.state = '',
this.pincode = '',
this.email = '',
this.phone = '',
this.website = '',
@ -85,6 +88,9 @@ class CompanyProfileSettings {
final String registrationNumber;
final String gstNumber;
final String address;
final String city;
final String state;
final String pincode;
final String email;
final String phone;
final String website;
@ -97,6 +103,9 @@ class CompanyProfileSettings {
String? registrationNumber,
String? gstNumber,
String? address,
String? city,
String? state,
String? pincode,
String? email,
String? phone,
String? website,
@ -109,6 +118,9 @@ class CompanyProfileSettings {
registrationNumber: registrationNumber ?? this.registrationNumber,
gstNumber: gstNumber ?? this.gstNumber,
address: address ?? this.address,
city: city ?? this.city,
state: state ?? this.state,
pincode: pincode ?? this.pincode,
email: email ?? this.email,
phone: phone ?? this.phone,
website: website ?? this.website,
@ -123,6 +135,9 @@ class CompanyProfileSettings {
'registrationNumber': registrationNumber,
'gstNumber': gstNumber,
'address': address,
'city': city,
'state': state,
'pincode': pincode,
'email': email,
'phone': phone,
'website': website,
@ -130,17 +145,41 @@ class CompanyProfileSettings {
'faviconUrl': faviconUrl,
};
Map<String, dynamic> toApiJson() {
final payload = <String, dynamic>{};
if (companyName.isNotEmpty) payload['org_name'] = companyName;
if (phone.isNotEmpty) payload['mobile'] = phone;
if (email.isNotEmpty) payload['email'] = email;
if (website.isNotEmpty) payload['website'] = website;
if (address.isNotEmpty) payload['address'] = address;
if (city.isNotEmpty) payload['city'] = city;
if (state.isNotEmpty) payload['state'] = state;
if (pincode.isNotEmpty) payload['pincode'] = pincode;
return payload;
}
factory CompanyProfileSettings.fromJson(Map<String, dynamic> json) =>
CompanyProfileSettings.fromApiJson(json);
factory CompanyProfileSettings.fromApiJson(Map<String, dynamic> json) =>
CompanyProfileSettings(
companyName: json['companyName'] as String? ?? '',
companyName: json['org_name'] as String? ??
json['companyName'] as String? ??
'',
companyCode: json['companyCode'] as String? ?? '',
registrationNumber: json['registrationNumber'] as String? ?? '',
gstNumber: json['gstNumber'] as String? ?? '',
address: json['address'] as String? ?? '',
city: json['city'] as String? ?? '',
state: json['state'] as String? ?? '',
pincode: json['pincode'] as String? ?? '',
email: json['email'] as String? ?? '',
phone: json['phone'] as String? ?? '',
phone: json['mobile'] as String? ?? json['phone'] as String? ?? '',
website: json['website'] as String? ?? '',
logoUrl: json['logoUrl'] as String? ?? '',
logoUrl: json['logo_url'] as String? ??
json['logoUrl'] as String? ??
json['logo'] as String? ??
'',
faviconUrl: json['faviconUrl'] as String? ?? '',
);
}
@ -411,14 +450,34 @@ class EmailConfigurationSettings {
'warrantyTemplate': warrantyTemplate,
};
Map<String, dynamic> toApiJson() {
final payload = <String, dynamic>{};
if (smtpHost.isNotEmpty) payload['smtp_host'] = smtpHost;
if (smtpPort > 0) payload['smtp_port'] = smtpPort;
if (smtpUsername.isNotEmpty) payload['smtp_username'] = smtpUsername;
if (smtpPassword.isNotEmpty) payload['smtp_password'] = smtpPassword;
if (senderEmail.isNotEmpty) payload['sender_email'] = senderEmail;
if (senderName.isNotEmpty) payload['sender_name'] = senderName;
return payload;
}
factory EmailConfigurationSettings.fromJson(Map<String, dynamic> json) =>
EmailConfigurationSettings.fromApiJson(json);
factory EmailConfigurationSettings.fromApiJson(Map<String, dynamic> json) =>
EmailConfigurationSettings(
smtpHost: json['smtpHost'] as String? ?? '',
smtpPort: json['smtpPort'] as int? ?? 587,
smtpUsername: json['smtpUsername'] as String? ?? '',
smtpPassword: json['smtpPassword'] as String? ?? '',
senderEmail: json['senderEmail'] as String? ?? '',
senderName: json['senderName'] as String? ?? '',
smtpHost: json['smtp_host'] as String? ?? json['smtpHost'] as String? ?? '',
smtpPort: (json['smtp_port'] as num?)?.toInt() ??
(json['smtpPort'] as num?)?.toInt() ??
587,
smtpUsername:
json['smtp_username'] as String? ?? json['smtpUsername'] as String? ?? '',
smtpPassword:
json['smtp_password'] as String? ?? json['smtpPassword'] as String? ?? '',
senderEmail:
json['sender_email'] as String? ?? json['senderEmail'] as String? ?? '',
senderName:
json['sender_name'] as String? ?? json['senderName'] as String? ?? '',
allocationTemplate: json['allocationTemplate'] as String? ??
'Your asset {{asset_name}} has been allocated.',
returnTemplate: json['returnTemplate'] as String? ??

View File

@ -4,4 +4,16 @@ import '../entities/app_settings.dart';
abstract class SettingsRepository {
Future<Result<AppSettings>> getSettings();
Future<Result<AppSettings>> saveSettings(AppSettings settings);
Future<Result<CompanyProfileSettings>> fetchCompanyProfile();
Future<Result<CompanyProfileSettings>> saveCompanyProfile(
CompanyProfileSettings profile,
);
Future<Result<String?>> uploadCompanyLogo(
List<int> bytes,
String filename,
);
Future<Result<EmailConfigurationSettings>> fetchEmailSettings();
Future<Result<EmailConfigurationSettings>> saveEmailSettings(
EmailConfigurationSettings email,
);
}

View File

@ -40,6 +40,7 @@ final appSettingsProvider =
return AppSettingsNotifier(
getSettings: ref.watch(getSettingsUseCaseProvider),
saveSettings: ref.watch(saveSettingsUseCaseProvider),
repository: ref.watch(settingsRepositoryProvider),
faviconStore: FaviconStore(ref.watch(sharedPreferencesProvider)),
);
});
@ -48,9 +49,11 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
AppSettingsNotifier({
required GetSettingsUseCase getSettings,
required SaveSettingsUseCase saveSettings,
required SettingsRepository repository,
required FaviconStore faviconStore,
}) : _getSettings = getSettings,
_saveSettings = saveSettings,
_repository = repository,
_faviconStore = faviconStore,
super(const AppSettings()) {
_load();
@ -58,6 +61,7 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
final GetSettingsUseCase _getSettings;
final SaveSettingsUseCase _saveSettings;
final SettingsRepository _repository;
final FaviconStore _faviconStore;
Future<void> _load() async {
@ -66,6 +70,20 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
_faviconStore.apply();
}
Future<void> refreshCompanyProfile() async {
final result = await _repository.fetchCompanyProfile();
if (result.failure == null && result.data != null) {
state = state.copyWith(companyProfile: result.data!);
}
}
Future<void> refreshEmailSettings() async {
final result = await _repository.fetchEmailSettings();
if (result.failure == null && result.data != null) {
state = state.copyWith(email: result.data!);
}
}
Future<void> _persist(AppSettings settings) async {
state = settings;
final result = await _saveSettings(settings);
@ -77,9 +95,28 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
}
Future<void> updateCompanyProfile(CompanyProfileSettings profile) async {
final result = await _repository.saveCompanyProfile(profile);
if (result.failure == null && result.data != null) {
state = state.copyWith(companyProfile: result.data!);
return;
}
await _persist(state.copyWith(companyProfile: profile));
}
Future<String?> uploadCompanyLogo(List<int> bytes, String filename) async {
final result = await _repository.uploadCompanyLogo(bytes, filename);
if (result.failure == null && result.data != null) {
final logoUrl = result.data!;
await _persist(
state.copyWith(
companyProfile: state.companyProfile.copyWith(logoUrl: logoUrl),
),
);
return logoUrl;
}
return null;
}
Future<void> updateUiPreferences(UiPreferencesSettings prefs) async {
await _persist(state.copyWith(uiPreferences: prefs));
}
@ -93,6 +130,12 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
}
Future<void> updateEmail(EmailConfigurationSettings email) async {
final result = await _repository.saveEmailSettings(email);
if (result.failure == null && result.data != null) {
state = state.copyWith(email: result.data!);
await _saveSettings(state);
return;
}
await _persist(state.copyWith(email: email));
}

View File

@ -9,6 +9,7 @@ import '../../../../core/utils/favicon_store.dart';
import '../../../../core/utils/favicon_updater.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 '../../../../shared/widgets/sidebar_logo.dart';
import '../../domain/entities/app_settings.dart';
@ -31,6 +32,9 @@ class _CompanyProfileSettingsScreenState
late final TextEditingController _registrationController;
late final TextEditingController _gstController;
late final TextEditingController _addressController;
late final TextEditingController _cityController;
late final TextEditingController _stateController;
late final TextEditingController _pincodeController;
late final TextEditingController _emailController;
late final TextEditingController _phoneController;
late final TextEditingController _websiteController;
@ -43,12 +47,18 @@ class _CompanyProfileSettingsScreenState
final profile = ref.read(appSettingsProvider).companyProfile;
final faviconFromPrefs =
FaviconStore(ref.read(sharedPreferencesProvider)).read();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(appSettingsProvider.notifier).refreshCompanyProfile();
});
_nameController = TextEditingController(text: profile.companyName);
_codeController = TextEditingController(text: profile.companyCode);
_registrationController =
TextEditingController(text: profile.registrationNumber);
_gstController = TextEditingController(text: profile.gstNumber);
_addressController = TextEditingController(text: profile.address);
_cityController = TextEditingController(text: profile.city);
_stateController = TextEditingController(text: profile.state);
_pincodeController = TextEditingController(text: profile.pincode);
_emailController = TextEditingController(text: profile.email);
_phoneController = TextEditingController(text: profile.phone);
_websiteController = TextEditingController(text: profile.website);
@ -67,6 +77,9 @@ class _CompanyProfileSettingsScreenState
_registrationController.dispose();
_gstController.dispose();
_addressController.dispose();
_cityController.dispose();
_stateController.dispose();
_pincodeController.dispose();
_emailController.dispose();
_phoneController.dispose();
_websiteController.dispose();
@ -89,6 +102,9 @@ class _CompanyProfileSettingsScreenState
registrationNumber: _registrationController.text.trim(),
gstNumber: _gstController.text.trim(),
address: _addressController.text.trim(),
city: _cityController.text.trim(),
state: _stateController.text.trim(),
pincode: _pincodeController.text.trim(),
email: _emailController.text.trim(),
phone: _phoneController.text.trim(),
website: _websiteController.text.trim(),
@ -137,9 +153,38 @@ class _CompanyProfileSettingsScreenState
});
}
Future<void> _pickLogo() => _pickImage((dataUri) {
_logoUrlController.text = dataUri;
});
Future<void> _pickLogo() async {
final result = await FilePicker.pickFiles(
type: FileType.image,
withData: true,
);
if (result == null || result.files.isEmpty) return;
final file = result.files.single;
final bytes = file.bytes;
if (bytes == null) return;
final uploadedUrl = await ref.read(appSettingsProvider.notifier).uploadCompanyLogo(
bytes,
file.name,
);
if (!mounted) return;
if (uploadedUrl != null && uploadedUrl.isNotEmpty) {
setState(() => _logoUrlController.text = uploadedUrl);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Logo uploaded')),
);
return;
}
final ext = (file.extension ?? 'png').toLowerCase();
final mime = ext == 'jpg' ? 'jpeg' : ext;
final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}';
setState(() => _logoUrlController.text = dataUri);
}
Future<void> _pickFavicon() => _pickImage((dataUri) {
_faviconUrlController.text = dataUri;
@ -184,7 +229,24 @@ class _CompanyProfileSettingsScreenState
AppTextField(
controller: _addressController,
label: 'Address',
maxLines: 3,
maxLines: 2,
),
const SizedBox(height: 16),
SidePanelFormRow(
left: AppTextField(
controller: _cityController,
label: 'City',
),
right: AppTextField(
controller: _stateController,
label: 'State',
),
),
const SizedBox(height: 16),
AppTextField(
controller: _pincodeController,
label: 'Pincode',
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
AppTextField(

View File

@ -34,6 +34,9 @@ class _EmailConfigurationScreenState
void initState() {
super.initState();
final email = ref.read(appSettingsProvider).email;
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(appSettingsProvider.notifier).refreshEmailSettings();
});
_hostController = TextEditingController(text: email.smtpHost);
_portController = TextEditingController(text: '${email.smtpPort}');
_usernameController = TextEditingController(text: email.smtpUsername);

View File

@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/models/vendor_model.dart';
class VendorRemoteDataSource {
@ -152,6 +153,39 @@ class VendorRemoteDataSource {
await dio.delete(ApiEndpoints.vendorBankDetailById(vendorId, bankDetailId));
}
Future<List<FilterOptionModel>> getGstTreatments() async {
final response = await dio.get(ApiEndpoints.vendorGstTreatments);
return _parseDropdownOptions(response.data);
}
Future<List<FilterOptionModel>> getSourceOfSupply() async {
final response = await dio.get(ApiEndpoints.vendorSourceOfSupply);
return _parseDropdownOptions(response.data);
}
List<FilterOptionModel> _parseDropdownOptions(dynamic body) {
if (body is! Map<String, dynamic>) return [];
final raw = body['data'];
final list = raw is List<dynamic>
? raw
: raw is Map<String, dynamic>
? raw['items'] as List<dynamic>? ?? const []
: const [];
return list.map((item) {
if (item is String) {
return FilterOptionModel(id: item, name: item);
}
if (item is Map<String, dynamic>) {
final value = item['value']?.toString() ?? item['id']?.toString() ?? '';
final label = item['label']?.toString() ??
item['name']?.toString() ??
value;
return FilterOptionModel(id: value, name: label);
}
return const FilterOptionModel(id: '', name: '');
}).where((item) => item.id.isNotEmpty).toList();
}
Map<String, dynamic> _queryToMap(VendorListQuery query) {
return {
'page': query.page,

View File

@ -231,6 +231,14 @@ class _OverviewTab extends StatelessWidget {
_VendorInfo('Vendor Code', vendor.vendorCode ?? ''),
_VendorInfo('Vendor Name', vendor.vendorName),
_VendorInfo('Type', vendorTypeLabel(vendor.vendorType)),
_VendorInfo(
'GST Treatment',
gstTreatmentLabel(vendor.gstTreatment),
),
_VendorInfo(
'Source of Supply',
vendor.sourceOfSupply ?? '',
),
_VendorInfo('GSTIN', vendor.gstin ?? ''),
_VendorInfo('PAN', vendor.pan ?? ''),
_VendorInfo('Payment Term', vendor.paymentTermName ?? ''),

View File

@ -13,6 +13,7 @@ import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart';
import '../../data/repositories/vendor_repository_impl.dart';
import '../providers/vendors_provider.dart';
Future<void> openVendorFormPanel(
@ -58,6 +59,8 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
final _creditDaysController = TextEditingController();
final _remarksController = TextEditingController();
String? _vendorType;
String? _gstTreatment;
String? _sourceOfSupply;
int? _paymentTermId;
bool _isActive = true;
bool _isSubmitting = false;
@ -74,13 +77,16 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
}
String _vendorSignature(VendorModel vendor) =>
'${vendor.id}:${vendor.vendorType}:${vendor.paymentTermId}:'
'${vendor.id}:${vendor.vendorType}:${vendor.gstTreatment}:'
'${vendor.sourceOfSupply}:${vendor.paymentTermId}:'
'${vendor.creditPeriodDays}:${vendor.isActive}:${vendor.vendorName}';
void _populateFromVendor(VendorModel vendor) {
setState(() {
_nameController.text = vendor.vendorName;
_vendorType = vendor.vendorType;
_gstTreatment = vendor.gstTreatment;
_sourceOfSupply = vendor.sourceOfSupply;
_gstinController.text = vendor.gstin ?? '';
_panController.text = vendor.pan ?? '';
_paymentTermId = vendor.paymentTermId;
@ -95,6 +101,9 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
return {
'vendor_name': _nameController.text.trim(),
'vendor_type': _vendorType,
if (_gstTreatment != null) 'gst_treatment': _gstTreatment,
if (_sourceOfSupply != null && _sourceOfSupply!.isNotEmpty)
'source_of_supply': _sourceOfSupply,
if (_gstinController.text.trim().isNotEmpty)
'gstin': _gstinController.text.trim(),
if (_panController.text.trim().isNotEmpty) 'pan': _panController.text.trim(),
@ -140,6 +149,8 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
@override
Widget build(BuildContext context) {
final paymentTermsAsync = ref.watch(vendorPaymentTermsProvider);
final gstTreatmentsAsync = ref.watch(vendorGstTreatmentsProvider);
final sourceOfSupplyAsync = ref.watch(vendorSourceOfSupplyProvider);
if (widget.isEditing) {
ref.listen(vendorFormProvider(widget.vendorId), (prev, next) {
@ -185,13 +196,25 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(vendorFormProvider(widget.vendorId)),
),
data: (_) => _buildForm(paymentTermsAsync),
data: (_) => _buildForm(
paymentTermsAsync,
gstTreatmentsAsync,
sourceOfSupplyAsync,
),
)
: _buildForm(paymentTermsAsync),
: _buildForm(
paymentTermsAsync,
gstTreatmentsAsync,
sourceOfSupplyAsync,
),
);
}
Widget _buildForm(AsyncValue<List<FilterOptionModel>> paymentTermsAsync) {
Widget _buildForm(
AsyncValue<List<FilterOptionModel>> paymentTermsAsync,
AsyncValue<List<FilterOptionModel>> gstTreatmentsAsync,
AsyncValue<List<FilterOptionModel>> sourceOfSupplyAsync,
) {
return Form(
key: _formKey,
child: Column(
@ -213,6 +236,46 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
validator: (v) => v == null ? 'Vendor type is required' : null,
),
const SizedBox(height: 12),
SidePanelFormRow(
left: gstTreatmentsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => AppDropdown<String>(
label: 'GST Treatment',
value: _gstTreatment,
options: gstTreatmentOptions
.map((e) => AppDropdownOption(value: e.$1, label: e.$2))
.toList(),
onChanged: (v) => setState(() => _gstTreatment = v),
),
data: (options) => AppSearchableDropdown<String>(
label: 'GST Treatment',
value: _gstTreatment,
searchHint: 'Search GST treatment...',
options: options
.map((o) => AppDropdownOption(value: o.id, label: o.name))
.toList(),
onChanged: (v) => setState(() => _gstTreatment = v),
),
),
right: sourceOfSupplyAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => AppTextField(
controller: TextEditingController(text: _sourceOfSupply ?? ''),
label: 'Source of Supply',
onChanged: (v) => _sourceOfSupply = v,
),
data: (options) => AppSearchableDropdown<String>(
label: 'Source of Supply',
value: _sourceOfSupply,
searchHint: 'Search state...',
options: options
.map((o) => AppDropdownOption(value: o.id, label: o.name))
.toList(),
onChanged: (v) => setState(() => _sourceOfSupply = v),
),
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppTextField(
controller: _gstinController,
@ -286,3 +349,15 @@ final vendorPaymentTermsProvider =
final dataSource = ref.watch(masterRemoteDataSourceProvider);
return dataSource.listPaymentTerms();
});
final vendorGstTreatmentsProvider =
FutureProvider<List<FilterOptionModel>>((ref) async {
final dataSource = ref.watch(vendorRemoteDataSourceProvider);
return dataSource.getGstTreatments();
});
final vendorSourceOfSupplyProvider =
FutureProvider<List<FilterOptionModel>>((ref) async {
final dataSource = ref.watch(vendorRemoteDataSourceProvider);
return dataSource.getSourceOfSupply();
});

View File

@ -64,6 +64,38 @@ Object? _readPlantId(Map<dynamic, dynamic> json, String key) {
return null;
}
Object? _readAssetSubcategoryName(Map<dynamic, dynamic> json, String key) {
final flat = json['asset_subcategory_name'];
if (flat is String && flat.isNotEmpty) return flat;
return _readNestedName(json, 'asset_subcategory');
}
Object? _readAssetSubcategoryId(Map<dynamic, dynamic> json, String key) {
final flat = json['asset_subcategory_id'];
if (flat != null) return flat;
final nested = json['asset_subcategory'];
if (nested is Map) return nested['id'];
return null;
}
Object? _readDepartmentName(Map<dynamic, dynamic> json, String key) {
final flat = json['department_name'];
if (flat is String && flat.isNotEmpty) return flat;
return _readNestedName(json, 'department');
}
Object? _readWarehouseName(Map<dynamic, dynamic> json, String key) {
final flat = json['warehouse_name'];
if (flat is String && flat.isNotEmpty) return flat;
return _readNestedName(json, 'warehouse');
}
Object? _readVendorNameFromNested(Map<dynamic, dynamic> json, String key) {
final flat = json['vendor_name'];
if (flat is String && flat.isNotEmpty) return flat;
return _readNestedName(json, 'vendor');
}
@freezed
class AssetCategoryModel with _$AssetCategoryModel {
const factory AssetCategoryModel({
@ -97,13 +129,50 @@ class AssetModel with _$AssetModel {
int? assetCategoryId,
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
String? assetCategoryName,
@JsonKey(
name: 'asset_subcategory_id',
readValue: _readAssetSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? assetSubcategoryId,
@JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName)
String? assetSubcategoryName,
@JsonKey(name: 'plant_id', readValue: _readPlantId, fromJson: _intFromJsonNullable)
int? plantId,
@JsonKey(name: 'plant_name', readValue: _readPlantName) String? plantName,
@JsonKey(name: 'brand_model') String? brandModel,
String? manufacturer,
@JsonKey(name: 'serial_number') String? serialNumber,
@JsonKey(name: 'part_number') String? partNumber,
@JsonKey(name: 'department_id', fromJson: _intFromJsonNullable) int? departmentId,
@JsonKey(name: 'department_name', readValue: _readDepartmentName)
String? departmentName,
@JsonKey(name: 'warehouse_id', fromJson: _intFromJsonNullable) int? warehouseId,
@JsonKey(name: 'warehouse_name', readValue: _readWarehouseName) String? warehouseName,
@JsonKey(name: 'location_detail') String? locationDetail,
@JsonKey(name: 'assigned_to_user_id', fromJson: _intFromJsonNullable)
int? assignedToUserId,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId,
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested) String? vendorName,
@JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) int? poId,
@JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable) int? grnId,
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable) int? grnItemId,
@JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable) DateTime? purchaseDate,
@JsonKey(name: 'purchase_cost', fromJson: _doubleFromJsonNullable) double? purchaseCost,
@JsonKey(name: 'useful_life_years', fromJson: _intFromJsonNullable) int? usefulLifeYears,
@JsonKey(name: 'depreciation_method') String? depreciationMethod,
@JsonKey(name: 'depreciation_rate', fromJson: _doubleFromJsonNullable)
double? depreciationRate,
@JsonKey(name: 'salvage_value', fromJson: _doubleFromJsonNullable) double? salvageValue,
@JsonKey(name: 'warranty_expiry_date', fromJson: _dateFromJsonNullable)
DateTime? warrantyExpiryDate,
@JsonKey(name: 'purchase_cost', fromJson: _doubleFromJsonNullable) double? purchaseCost,
String? condition,
String? status,
@JsonKey(name: 'qr_code_value') String? qrCodeValue,
@JsonKey(name: 'disposal_date', fromJson: _dateFromJsonNullable) DateTime? disposalDate,
@JsonKey(name: 'disposal_reason') String? disposalReason,
@JsonKey(name: 'disposal_value', fromJson: _doubleFromJsonNullable) double? disposalValue,
String? remarks,
@JsonKey(name: 'is_active') @Default(true) bool isActive,
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt,
@ -112,6 +181,45 @@ class AssetModel with _$AssetModel {
factory AssetModel.fromJson(Map<String, dynamic> json) => _$AssetModelFromJson(json);
}
const assetConditionOptions = [
('NEW', 'New'),
('GOOD', 'Good'),
('FAIR', 'Fair'),
('POOR', 'Poor'),
];
const assetStatusOptions = [
('IN_USE', 'In Use'),
('IDLE', 'Idle'),
('UNDER_MAINTENANCE', 'Under Maintenance'),
('DISPOSED', 'Disposed'),
('SCRAPPED', 'Scrapped'),
];
const depreciationMethodOptions = [
('SLM', 'SLM'),
('WDV', 'WDV'),
('OTHER', 'Other'),
];
String assetConditionLabel(String? value) {
if (value == null) return '';
return assetConditionOptions
.where((e) => e.$1 == value)
.map((e) => e.$2)
.firstOrNull ??
value;
}
String assetStatusLabel(String? value) {
if (value == null) return '';
return assetStatusOptions
.where((e) => e.$1 == value)
.map((e) => e.$2)
.firstOrNull ??
value.replaceAll('_', ' ');
}
@freezed
class AmcContractModel with _$AmcContractModel {
const factory AmcContractModel({
@ -123,9 +231,21 @@ class AmcContractModel with _$AmcContractModel {
@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: 'renewal_date', fromJson: _dateFromJsonNullable)
DateTime? renewalDate,
@JsonKey(name: 'annual_cost', fromJson: _doubleFromJsonNullable) double? annualCost,
@JsonKey(name: 'payment_frequency') String? paymentFrequency,
@JsonKey(name: 'service_frequency') String? serviceFrequency,
@JsonKey(name: 'visits_per_year', fromJson: _intFromJsonNullable) int? visitsPerYear,
@JsonKey(name: 'contact_person') String? contactPerson,
@JsonKey(name: 'contact_phone') String? contactPhone,
@JsonKey(name: 'contact_email') String? contactEmail,
@JsonKey(name: 'scope_of_work') String? scopeOfWork,
String? exclusions,
String? remarks,
@JsonKey(name: 'is_active') @Default(true) bool isActive,
@Default('active') String status,
DateTime? createdAt,
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
}) = _AmcContractModel;
factory AmcContractModel.fromJson(Map<String, dynamic> json) =>
@ -139,12 +259,26 @@ class ServiceVisitModel with _$ServiceVisitModel {
@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: 'amc_contract_id', fromJson: _intFromJsonNullable) int? amcContractId,
@JsonKey(name: 'visit_number', fromJson: _intFromJsonNullable) int? visitNumber,
@JsonKey(name: 'complaint_no') String? complaintNo,
@JsonKey(name: 'complaint_date', fromJson: _dateFromJsonNullable) DateTime? complaintDate,
@JsonKey(name: 'complaint_desc') String? complaintDesc,
@JsonKey(name: 'engineer_name') String? engineerName,
@JsonKey(name: 'engineer_phone') String? engineerPhone,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId,
@JsonKey(name: 'vendor_name') String? vendorName,
@JsonKey(name: 'work_done') String? workDone,
@JsonKey(name: 'parts_replaced') String? partsReplaced,
@JsonKey(name: 'next_service_date', fromJson: _dateFromJsonNullable)
DateTime? nextServiceDate,
@Default('COMPLETED') String status,
DateTime? createdAt,
@JsonKey(name: 'downtime_hours', fromJson: _doubleFromJsonNullable) double? downtimeHours,
@JsonKey(name: 'service_cost', fromJson: _doubleFromJsonNullable) double? serviceCost,
@JsonKey(name: 'is_under_amc') @Default(false) bool isUnderAmc,
@JsonKey(name: 'asset_condition_after') String? assetConditionAfter,
String? remarks,
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
}) = _ServiceVisitModel;
factory ServiceVisitModel.fromJson(Map<String, dynamic> json) =>
@ -158,13 +292,25 @@ class InsurancePolicyModel with _$InsurancePolicyModel {
@JsonKey(name: 'asset_id', fromJson: _idFromJson) String? assetId,
@JsonKey(name: 'policy_no') required String policyNo,
@JsonKey(name: 'insurer_name') required String insurerName,
@JsonKey(name: 'insurer_branch') String? insurerBranch,
@JsonKey(name: 'insurer_contact') String? insurerContact,
@JsonKey(name: 'insurer_phone') String? insurerPhone,
@JsonKey(name: 'insurer_email') String? insurerEmail,
@JsonKey(name: 'policy_type') String? policyType,
@JsonKey(name: 'sum_insured', fromJson: _doubleFromJsonNullable) double? sumInsured,
@JsonKey(name: 'annual_premium', fromJson: _doubleFromJsonNullable) double? annualPremium,
@JsonKey(name: 'policy_start_date', fromJson: _dateFromJson)
required DateTime policyStartDate,
@JsonKey(name: 'policy_end_date', fromJson: _dateFromJson) required DateTime policyEndDate,
@JsonKey(name: 'renewal_date', fromJson: _dateFromJsonNullable) DateTime? renewalDate,
@JsonKey(name: 'is_auto_renewal') @Default(false) bool isAutoRenewal,
@JsonKey(name: 'premium_paid') @Default(false) bool premiumPaid,
@JsonKey(name: 'premium_paid_date', fromJson: _dateFromJsonNullable)
DateTime? premiumPaidDate,
String? remarks,
@JsonKey(name: 'is_active') @Default(true) bool isActive,
@Default('active') String status,
DateTime? createdAt,
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
}) = _InsurancePolicyModel;
factory InsurancePolicyModel.fromJson(Map<String, dynamic> json) =>
@ -191,3 +337,76 @@ class AssetAlertModel with _$AssetAlertModel {
factory AssetAlertModel.fromJson(Map<String, dynamic> json) =>
_$AssetAlertModelFromJson(json);
}
class AssetTransferHistoryModel {
const AssetTransferHistoryModel({
required this.id,
this.transferDate,
this.reason,
this.fromPlantName,
this.toPlantName,
this.fromDepartmentName,
this.toDepartmentName,
this.fromWarehouseName,
this.toWarehouseName,
this.fromUserName,
this.toUserName,
this.createdAt,
this.createdByName,
});
final String id;
final DateTime? transferDate;
final String? reason;
final String? fromPlantName;
final String? toPlantName;
final String? fromDepartmentName;
final String? toDepartmentName;
final String? fromWarehouseName;
final String? toWarehouseName;
final String? fromUserName;
final String? toUserName;
final DateTime? createdAt;
final String? createdByName;
factory AssetTransferHistoryModel.fromJson(Map<String, dynamic> json) {
String? readName(String key) {
final value = json[key];
if (value is String && value.trim().isNotEmpty) return value.trim();
return null;
}
String? readNestedName(String key) {
final nested = json[key];
if (nested is Map<String, dynamic>) {
final name = nested['name'];
if (name is String && name.trim().isNotEmpty) return name.trim();
}
return null;
}
DateTime? parseDate(dynamic value) {
if (value == null) return null;
if (value is DateTime) return value;
return DateTime.tryParse(value.toString());
}
return AssetTransferHistoryModel(
id: _idFromJson(json['id']),
transferDate: parseDate(json['transfer_date']),
reason: readName('reason'),
fromPlantName: readName('from_plant_name') ?? readNestedName('from_plant'),
toPlantName: readName('to_plant_name') ?? readNestedName('to_plant'),
fromDepartmentName:
readName('from_department_name') ?? readNestedName('from_department'),
toDepartmentName: readName('to_department_name') ?? readNestedName('to_department'),
fromWarehouseName:
readName('from_warehouse_name') ?? readNestedName('from_warehouse'),
toWarehouseName: readName('to_warehouse_name') ?? readNestedName('to_warehouse'),
fromUserName: readName('from_user_name') ?? readNestedName('from_user'),
toUserName: readName('to_user_name') ?? readNestedName('to_user'),
createdAt: parseDate(json['created_at']),
createdByName: readName('created_by_name') ?? readNestedName('created_by'),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -50,11 +50,42 @@ _$AssetModelImpl _$$AssetModelImplFromJson(Map<String, dynamic> json) =>
),
assetCategoryName:
_readAssetCategoryName(json, 'asset_category_name') as String?,
assetSubcategoryId: _intFromJsonNullable(
_readAssetSubcategoryId(json, 'asset_subcategory_id'),
),
assetSubcategoryName:
_readAssetSubcategoryName(json, 'asset_subcategory_name') as String?,
plantId: _intFromJsonNullable(_readPlantId(json, 'plant_id')),
plantName: _readPlantName(json, 'plant_name') as String?,
warrantyExpiryDate: _dateFromJsonNullable(json['warranty_expiry_date']),
brandModel: json['brand_model'] as String?,
manufacturer: json['manufacturer'] as String?,
serialNumber: json['serial_number'] as String?,
partNumber: json['part_number'] as String?,
departmentId: _intFromJsonNullable(json['department_id']),
departmentName: _readDepartmentName(json, 'department_name') as String?,
warehouseId: _intFromJsonNullable(json['warehouse_id']),
warehouseName: _readWarehouseName(json, 'warehouse_name') as String?,
locationDetail: json['location_detail'] as String?,
assignedToUserId: _intFromJsonNullable(json['assigned_to_user_id']),
vendorId: _intFromJsonNullable(json['vendor_id']),
vendorName: _readVendorNameFromNested(json, 'vendor_name') as String?,
poId: _intFromJsonNullable(json['po_id']),
grnId: _intFromJsonNullable(json['grn_id']),
grnItemId: _intFromJsonNullable(json['grn_item_id']),
purchaseDate: _dateFromJsonNullable(json['purchase_date']),
purchaseCost: _doubleFromJsonNullable(json['purchase_cost']),
usefulLifeYears: _intFromJsonNullable(json['useful_life_years']),
depreciationMethod: json['depreciation_method'] as String?,
depreciationRate: _doubleFromJsonNullable(json['depreciation_rate']),
salvageValue: _doubleFromJsonNullable(json['salvage_value']),
warrantyExpiryDate: _dateFromJsonNullable(json['warranty_expiry_date']),
condition: json['condition'] as String?,
status: json['status'] as String?,
qrCodeValue: json['qr_code_value'] as String?,
disposalDate: _dateFromJsonNullable(json['disposal_date']),
disposalReason: json['disposal_reason'] as String?,
disposalValue: _doubleFromJsonNullable(json['disposal_value']),
remarks: json['remarks'] as String?,
isActive: json['is_active'] as bool? ?? true,
createdAt: _dateFromJsonNullable(json['created_at']),
updatedAt: _dateFromJsonNullable(json['updated_at']),
@ -67,11 +98,39 @@ Map<String, dynamic> _$$AssetModelImplToJson(_$AssetModelImpl instance) =>
'asset_code': instance.assetCode,
'asset_category_id': instance.assetCategoryId,
'asset_category_name': instance.assetCategoryName,
'asset_subcategory_id': instance.assetSubcategoryId,
'asset_subcategory_name': instance.assetSubcategoryName,
'plant_id': instance.plantId,
'plant_name': instance.plantName,
'warranty_expiry_date': instance.warrantyExpiryDate?.toIso8601String(),
'brand_model': instance.brandModel,
'manufacturer': instance.manufacturer,
'serial_number': instance.serialNumber,
'part_number': instance.partNumber,
'department_id': instance.departmentId,
'department_name': instance.departmentName,
'warehouse_id': instance.warehouseId,
'warehouse_name': instance.warehouseName,
'location_detail': instance.locationDetail,
'assigned_to_user_id': instance.assignedToUserId,
'vendor_id': instance.vendorId,
'vendor_name': instance.vendorName,
'po_id': instance.poId,
'grn_id': instance.grnId,
'grn_item_id': instance.grnItemId,
'purchase_date': instance.purchaseDate?.toIso8601String(),
'purchase_cost': instance.purchaseCost,
'useful_life_years': instance.usefulLifeYears,
'depreciation_method': instance.depreciationMethod,
'depreciation_rate': instance.depreciationRate,
'salvage_value': instance.salvageValue,
'warranty_expiry_date': instance.warrantyExpiryDate?.toIso8601String(),
'condition': instance.condition,
'status': instance.status,
'qr_code_value': instance.qrCodeValue,
'disposal_date': instance.disposalDate?.toIso8601String(),
'disposal_reason': instance.disposalReason,
'disposal_value': instance.disposalValue,
'remarks': instance.remarks,
'is_active': instance.isActive,
'created_at': instance.createdAt?.toIso8601String(),
'updated_at': instance.updatedAt?.toIso8601String(),
@ -88,11 +147,20 @@ _$AmcContractModelImpl _$$AmcContractModelImplFromJson(
contractType: json['contract_type'] as String?,
startDate: _dateFromJson(json['start_date']),
endDate: _dateFromJson(json['end_date']),
renewalDate: _dateFromJsonNullable(json['renewal_date']),
annualCost: _doubleFromJsonNullable(json['annual_cost']),
paymentFrequency: json['payment_frequency'] as String?,
serviceFrequency: json['service_frequency'] as String?,
visitsPerYear: _intFromJsonNullable(json['visits_per_year']),
contactPerson: json['contact_person'] as String?,
contactPhone: json['contact_phone'] as String?,
contactEmail: json['contact_email'] as String?,
scopeOfWork: json['scope_of_work'] as String?,
exclusions: json['exclusions'] as String?,
remarks: json['remarks'] as String?,
isActive: json['is_active'] as bool? ?? true,
status: json['status'] as String? ?? 'active',
createdAt: json['createdAt'] == null
? null
: DateTime.parse(json['createdAt'] as String),
createdAt: _dateFromJsonNullable(json['created_at']),
);
Map<String, dynamic> _$$AmcContractModelImplToJson(
@ -106,9 +174,20 @@ Map<String, dynamic> _$$AmcContractModelImplToJson(
'contract_type': instance.contractType,
'start_date': instance.startDate.toIso8601String(),
'end_date': instance.endDate.toIso8601String(),
'renewal_date': instance.renewalDate?.toIso8601String(),
'annual_cost': instance.annualCost,
'payment_frequency': instance.paymentFrequency,
'service_frequency': instance.serviceFrequency,
'visits_per_year': instance.visitsPerYear,
'contact_person': instance.contactPerson,
'contact_phone': instance.contactPhone,
'contact_email': instance.contactEmail,
'scope_of_work': instance.scopeOfWork,
'exclusions': instance.exclusions,
'remarks': instance.remarks,
'is_active': instance.isActive,
'status': instance.status,
'createdAt': instance.createdAt?.toIso8601String(),
'created_at': instance.createdAt?.toIso8601String(),
};
_$ServiceVisitModelImpl _$$ServiceVisitModelImplFromJson(
@ -118,13 +197,25 @@ _$ServiceVisitModelImpl _$$ServiceVisitModelImplFromJson(
assetId: _idFromJson(json['asset_id']),
visitType: json['visit_type'] as String,
visitDate: _dateFromJson(json['visit_date']),
amcContractId: _intFromJsonNullable(json['amc_contract_id']),
visitNumber: _intFromJsonNullable(json['visit_number']),
complaintNo: json['complaint_no'] as String?,
complaintDate: _dateFromJsonNullable(json['complaint_date']),
complaintDesc: json['complaint_desc'] as String?,
engineerName: json['engineer_name'] as String?,
engineerPhone: json['engineer_phone'] as String?,
vendorId: _intFromJsonNullable(json['vendor_id']),
vendorName: json['vendor_name'] as String?,
workDone: json['work_done'] as String?,
partsReplaced: json['parts_replaced'] 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),
downtimeHours: _doubleFromJsonNullable(json['downtime_hours']),
serviceCost: _doubleFromJsonNullable(json['service_cost']),
isUnderAmc: json['is_under_amc'] as bool? ?? false,
assetConditionAfter: json['asset_condition_after'] as String?,
remarks: json['remarks'] as String?,
createdAt: _dateFromJsonNullable(json['created_at']),
);
Map<String, dynamic> _$$ServiceVisitModelImplToJson(
@ -134,11 +225,25 @@ Map<String, dynamic> _$$ServiceVisitModelImplToJson(
'asset_id': instance.assetId,
'visit_type': instance.visitType,
'visit_date': instance.visitDate.toIso8601String(),
'amc_contract_id': instance.amcContractId,
'visit_number': instance.visitNumber,
'complaint_no': instance.complaintNo,
'complaint_date': instance.complaintDate?.toIso8601String(),
'complaint_desc': instance.complaintDesc,
'engineer_name': instance.engineerName,
'engineer_phone': instance.engineerPhone,
'vendor_id': instance.vendorId,
'vendor_name': instance.vendorName,
'work_done': instance.workDone,
'parts_replaced': instance.partsReplaced,
'next_service_date': instance.nextServiceDate?.toIso8601String(),
'status': instance.status,
'createdAt': instance.createdAt?.toIso8601String(),
'downtime_hours': instance.downtimeHours,
'service_cost': instance.serviceCost,
'is_under_amc': instance.isUnderAmc,
'asset_condition_after': instance.assetConditionAfter,
'remarks': instance.remarks,
'created_at': instance.createdAt?.toIso8601String(),
};
_$InsurancePolicyModelImpl _$$InsurancePolicyModelImplFromJson(
@ -148,14 +253,23 @@ _$InsurancePolicyModelImpl _$$InsurancePolicyModelImplFromJson(
assetId: _idFromJson(json['asset_id']),
policyNo: json['policy_no'] as String,
insurerName: json['insurer_name'] as String,
insurerBranch: json['insurer_branch'] as String?,
insurerContact: json['insurer_contact'] as String?,
insurerPhone: json['insurer_phone'] as String?,
insurerEmail: json['insurer_email'] as String?,
policyType: json['policy_type'] as String?,
sumInsured: _doubleFromJsonNullable(json['sum_insured']),
annualPremium: _doubleFromJsonNullable(json['annual_premium']),
policyStartDate: _dateFromJson(json['policy_start_date']),
policyEndDate: _dateFromJson(json['policy_end_date']),
renewalDate: _dateFromJsonNullable(json['renewal_date']),
isAutoRenewal: json['is_auto_renewal'] as bool? ?? false,
premiumPaid: json['premium_paid'] as bool? ?? false,
premiumPaidDate: _dateFromJsonNullable(json['premium_paid_date']),
remarks: json['remarks'] as String?,
isActive: json['is_active'] as bool? ?? true,
status: json['status'] as String? ?? 'active',
createdAt: json['createdAt'] == null
? null
: DateTime.parse(json['createdAt'] as String),
createdAt: _dateFromJsonNullable(json['created_at']),
);
Map<String, dynamic> _$$InsurancePolicyModelImplToJson(
@ -165,12 +279,23 @@ Map<String, dynamic> _$$InsurancePolicyModelImplToJson(
'asset_id': instance.assetId,
'policy_no': instance.policyNo,
'insurer_name': instance.insurerName,
'insurer_branch': instance.insurerBranch,
'insurer_contact': instance.insurerContact,
'insurer_phone': instance.insurerPhone,
'insurer_email': instance.insurerEmail,
'policy_type': instance.policyType,
'sum_insured': instance.sumInsured,
'annual_premium': instance.annualPremium,
'policy_start_date': instance.policyStartDate.toIso8601String(),
'policy_end_date': instance.policyEndDate.toIso8601String(),
'renewal_date': instance.renewalDate?.toIso8601String(),
'is_auto_renewal': instance.isAutoRenewal,
'premium_paid': instance.premiumPaid,
'premium_paid_date': instance.premiumPaidDate?.toIso8601String(),
'remarks': instance.remarks,
'is_active': instance.isActive,
'status': instance.status,
'createdAt': instance.createdAt?.toIso8601String(),
'created_at': instance.createdAt?.toIso8601String(),
};
_$AssetAlertModelImpl _$$AssetAlertModelImplFromJson(

View File

@ -36,6 +36,8 @@ class VendorModel with _$VendorModel {
@JsonKey(name: 'vendor_code') String? vendorCode,
@JsonKey(name: 'vendor_name') required String vendorName,
@JsonKey(name: 'vendor_type') String? vendorType,
@JsonKey(name: 'gst_treatment') String? gstTreatment,
@JsonKey(name: 'source_of_supply') String? sourceOfSupply,
String? gstin,
String? pan,
@JsonKey(name: 'payment_term_id', fromJson: _intFromJsonNullable)
@ -136,6 +138,17 @@ const vendorTypeOptions = [
('GENERAL', 'General'),
];
const gstTreatmentOptions = [
('REGULAR', 'Regular'),
('COMPOSITION', 'Composition'),
('UNREGISTERED', 'Unregistered'),
('CONSUMER', 'Consumer'),
('SEZ', 'SEZ'),
('OVERSEAS', 'Overseas'),
('DEEMED_EXPORT', 'Deemed Export'),
('OTHER', 'Other'),
];
const vendorStatusOptions = [
('active', 'Active'),
('inactive', 'Inactive'),
@ -163,6 +176,15 @@ String vendorTypeLabel(String? value) {
value;
}
String gstTreatmentLabel(String? value) {
if (value == null) return '';
return gstTreatmentOptions
.where((e) => e.$1 == value)
.map((e) => e.$2)
.firstOrNull ??
value.replaceAll('_', ' ');
}
String vendorStatusLabel(String? value) {
if (value == null) return '';
return vendorStatusOptions

View File

@ -29,6 +29,10 @@ mixin _$VendorModel {
String get vendorName => throw _privateConstructorUsedError;
@JsonKey(name: 'vendor_type')
String? get vendorType => throw _privateConstructorUsedError;
@JsonKey(name: 'gst_treatment')
String? get gstTreatment => throw _privateConstructorUsedError;
@JsonKey(name: 'source_of_supply')
String? get sourceOfSupply => throw _privateConstructorUsedError;
String? get gstin => throw _privateConstructorUsedError;
String? get pan => throw _privateConstructorUsedError;
@JsonKey(name: 'payment_term_id', fromJson: _intFromJsonNullable)
@ -73,6 +77,8 @@ abstract class $VendorModelCopyWith<$Res> {
@JsonKey(name: 'vendor_code') String? vendorCode,
@JsonKey(name: 'vendor_name') String vendorName,
@JsonKey(name: 'vendor_type') String? vendorType,
@JsonKey(name: 'gst_treatment') String? gstTreatment,
@JsonKey(name: 'source_of_supply') String? sourceOfSupply,
String? gstin,
String? pan,
@JsonKey(name: 'payment_term_id', fromJson: _intFromJsonNullable)
@ -113,6 +119,8 @@ class _$VendorModelCopyWithImpl<$Res, $Val extends VendorModel>
Object? vendorCode = freezed,
Object? vendorName = null,
Object? vendorType = freezed,
Object? gstTreatment = freezed,
Object? sourceOfSupply = freezed,
Object? gstin = freezed,
Object? pan = freezed,
Object? paymentTermId = freezed,
@ -145,6 +153,14 @@ class _$VendorModelCopyWithImpl<$Res, $Val extends VendorModel>
? _value.vendorType
: vendorType // ignore: cast_nullable_to_non_nullable
as String?,
gstTreatment: freezed == gstTreatment
? _value.gstTreatment
: gstTreatment // ignore: cast_nullable_to_non_nullable
as String?,
sourceOfSupply: freezed == sourceOfSupply
? _value.sourceOfSupply
: sourceOfSupply // ignore: cast_nullable_to_non_nullable
as String?,
gstin: freezed == gstin
? _value.gstin
: gstin // ignore: cast_nullable_to_non_nullable
@ -217,6 +233,8 @@ abstract class _$$VendorModelImplCopyWith<$Res>
@JsonKey(name: 'vendor_code') String? vendorCode,
@JsonKey(name: 'vendor_name') String vendorName,
@JsonKey(name: 'vendor_type') String? vendorType,
@JsonKey(name: 'gst_treatment') String? gstTreatment,
@JsonKey(name: 'source_of_supply') String? sourceOfSupply,
String? gstin,
String? pan,
@JsonKey(name: 'payment_term_id', fromJson: _intFromJsonNullable)
@ -256,6 +274,8 @@ class __$$VendorModelImplCopyWithImpl<$Res>
Object? vendorCode = freezed,
Object? vendorName = null,
Object? vendorType = freezed,
Object? gstTreatment = freezed,
Object? sourceOfSupply = freezed,
Object? gstin = freezed,
Object? pan = freezed,
Object? paymentTermId = freezed,
@ -288,6 +308,14 @@ class __$$VendorModelImplCopyWithImpl<$Res>
? _value.vendorType
: vendorType // ignore: cast_nullable_to_non_nullable
as String?,
gstTreatment: freezed == gstTreatment
? _value.gstTreatment
: gstTreatment // ignore: cast_nullable_to_non_nullable
as String?,
sourceOfSupply: freezed == sourceOfSupply
? _value.sourceOfSupply
: sourceOfSupply // ignore: cast_nullable_to_non_nullable
as String?,
gstin: freezed == gstin
? _value.gstin
: gstin // ignore: cast_nullable_to_non_nullable
@ -353,6 +381,8 @@ class _$VendorModelImpl implements _VendorModel {
@JsonKey(name: 'vendor_code') this.vendorCode,
@JsonKey(name: 'vendor_name') required this.vendorName,
@JsonKey(name: 'vendor_type') this.vendorType,
@JsonKey(name: 'gst_treatment') this.gstTreatment,
@JsonKey(name: 'source_of_supply') this.sourceOfSupply,
this.gstin,
this.pan,
@JsonKey(name: 'payment_term_id', fromJson: _intFromJsonNullable)
@ -392,6 +422,12 @@ class _$VendorModelImpl implements _VendorModel {
@JsonKey(name: 'vendor_type')
final String? vendorType;
@override
@JsonKey(name: 'gst_treatment')
final String? gstTreatment;
@override
@JsonKey(name: 'source_of_supply')
final String? sourceOfSupply;
@override
final String? gstin;
@override
final String? pan;
@ -446,7 +482,7 @@ class _$VendorModelImpl implements _VendorModel {
@override
String toString() {
return 'VendorModel(id: $id, vendorCode: $vendorCode, vendorName: $vendorName, vendorType: $vendorType, gstin: $gstin, pan: $pan, paymentTermId: $paymentTermId, paymentTermName: $paymentTermName, creditPeriodDays: $creditPeriodDays, remarks: $remarks, status: $status, isActive: $isActive, createdAt: $createdAt, updatedAt: $updatedAt, addresses: $addresses, contacts: $contacts, bankDetails: $bankDetails)';
return 'VendorModel(id: $id, vendorCode: $vendorCode, vendorName: $vendorName, vendorType: $vendorType, gstTreatment: $gstTreatment, sourceOfSupply: $sourceOfSupply, gstin: $gstin, pan: $pan, paymentTermId: $paymentTermId, paymentTermName: $paymentTermName, creditPeriodDays: $creditPeriodDays, remarks: $remarks, status: $status, isActive: $isActive, createdAt: $createdAt, updatedAt: $updatedAt, addresses: $addresses, contacts: $contacts, bankDetails: $bankDetails)';
}
@override
@ -461,6 +497,10 @@ class _$VendorModelImpl implements _VendorModel {
other.vendorName == vendorName) &&
(identical(other.vendorType, vendorType) ||
other.vendorType == vendorType) &&
(identical(other.gstTreatment, gstTreatment) ||
other.gstTreatment == gstTreatment) &&
(identical(other.sourceOfSupply, sourceOfSupply) ||
other.sourceOfSupply == sourceOfSupply) &&
(identical(other.gstin, gstin) || other.gstin == gstin) &&
(identical(other.pan, pan) || other.pan == pan) &&
(identical(other.paymentTermId, paymentTermId) ||
@ -490,12 +530,14 @@ class _$VendorModelImpl implements _VendorModel {
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
int get hashCode => Object.hashAll([
runtimeType,
id,
vendorCode,
vendorName,
vendorType,
gstTreatment,
sourceOfSupply,
gstin,
pan,
paymentTermId,
@ -509,7 +551,7 @@ class _$VendorModelImpl implements _VendorModel {
const DeepCollectionEquality().hash(_addresses),
const DeepCollectionEquality().hash(_contacts),
const DeepCollectionEquality().hash(_bankDetails),
);
]);
/// Create a copy of VendorModel
/// with the given fields replaced by the non-null parameter values.
@ -531,6 +573,8 @@ abstract class _VendorModel implements VendorModel {
@JsonKey(name: 'vendor_code') final String? vendorCode,
@JsonKey(name: 'vendor_name') required final String vendorName,
@JsonKey(name: 'vendor_type') final String? vendorType,
@JsonKey(name: 'gst_treatment') final String? gstTreatment,
@JsonKey(name: 'source_of_supply') final String? sourceOfSupply,
final String? gstin,
final String? pan,
@JsonKey(name: 'payment_term_id', fromJson: _intFromJsonNullable)
@ -568,6 +612,12 @@ abstract class _VendorModel implements VendorModel {
@JsonKey(name: 'vendor_type')
String? get vendorType;
@override
@JsonKey(name: 'gst_treatment')
String? get gstTreatment;
@override
@JsonKey(name: 'source_of_supply')
String? get sourceOfSupply;
@override
String? get gstin;
@override
String? get pan;

View File

@ -13,6 +13,8 @@ _$VendorModelImpl _$$VendorModelImplFromJson(
vendorCode: json['vendor_code'] as String?,
vendorName: json['vendor_name'] as String,
vendorType: json['vendor_type'] as String?,
gstTreatment: json['gst_treatment'] as String?,
sourceOfSupply: json['source_of_supply'] as String?,
gstin: json['gstin'] as String?,
pan: json['pan'] as String?,
paymentTermId: _intFromJsonNullable(json['payment_term_id']),
@ -48,6 +50,8 @@ Map<String, dynamic> _$$VendorModelImplToJson(_$VendorModelImpl instance) =>
'vendor_code': instance.vendorCode,
'vendor_name': instance.vendorName,
'vendor_type': instance.vendorType,
'gst_treatment': instance.gstTreatment,
'source_of_supply': instance.sourceOfSupply,
'gstin': instance.gstin,
'pan': instance.pan,
'payment_term_id': instance.paymentTermId,

View File

@ -60,12 +60,6 @@ const List<MenuItem> appMenuItems = [
route: RouteConstants.assets,
module: 'assets',
),
MenuItem(
label: 'Categories',
icon: Icons.category_outlined,
route: RouteConstants.assetCategories,
module: 'asset_categories',
),
MenuItem(
label: 'Alerts',
icon: Icons.notifications_outlined,

View File

@ -166,6 +166,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
final theme = Theme.of(context);
return FormField<T>(
key: ValueKey<T?>(widget.value),
initialValue: widget.value,
validator: widget.validator,
builder: (field) {

View File

@ -44,8 +44,7 @@ class _AppSearchableMultiSelectDropdownState<T>
super.dispose();
}
String _displayText() {
if (widget.values.isEmpty) return '';
List<String> _selectedLabels() {
final labels = <String>[];
for (final value in widget.values) {
for (final option in widget.options) {
@ -55,7 +54,7 @@ class _AppSearchableMultiSelectDropdownState<T>
}
}
}
return labels.join(', ');
return labels;
}
void _removeOverlay() {
@ -161,9 +160,9 @@ class _AppSearchableMultiSelectDropdownState<T>
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final displayText = _displayText();
return FormField<List<T>>(
key: ValueKey<String>(widget.values.join('\u0000')),
initialValue: widget.values,
validator: widget.validator,
builder: (field) {
@ -171,6 +170,10 @@ class _AppSearchableMultiSelectDropdownState<T>
widget.hint ?? 'Select ${widget.label.toLowerCase()}';
final canOpen = widget.enabled && widget.options.isNotEmpty;
final colors = theme.colorScheme;
final labels = _selectedLabels();
final displayText = labels.join(', ');
final tooltipMessage =
labels.length > 1 ? labels.join('\n') : displayText;
return Padding(
padding: const EdgeInsets.only(top: 8),
@ -202,12 +205,17 @@ class _AppSearchableMultiSelectDropdownState<T>
),
child: displayText.isEmpty
? const SizedBox.shrink()
: Text(
displayText,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color: colors.onSurface,
: Tooltip(
message: tooltipMessage,
preferBelow: true,
waitDuration: const Duration(milliseconds: 250),
child: Text(
displayText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color: colors.onSurface,
),
),
),
),

View File

@ -30,7 +30,12 @@ Future<T?> showSidePanel<T>(
child: SizedBox(
width: panelWidth,
height: MediaQuery.sizeOf(context).height,
child: panel,
child: ScaffoldMessenger(
child: Scaffold(
backgroundColor: theme.colorScheme.surface,
body: panel,
),
),
),
),
);
@ -47,6 +52,21 @@ Future<T?> showSidePanel<T>(
);
}
/// Shows a snackbar above the side panel overlay (not behind it).
void showSidePanelSnackBar(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 4),
}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: duration,
),
);
}
class SidePanelScaffold extends StatelessWidget {
const SidePanelScaffold({
super.key,
@ -180,7 +200,7 @@ class SidePanelFormRow extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: left),
SizedBox(width: spacing),

View File

@ -18,6 +18,7 @@ class AppTextField extends StatelessWidget {
this.inputFormatters,
this.enabled = true,
this.autofillHints,
this.isDense = false,
});
final TextEditingController controller;
@ -34,6 +35,7 @@ class AppTextField extends StatelessWidget {
final List<TextInputFormatter>? inputFormatters;
final bool enabled;
final Iterable<String>? autofillHints;
final bool isDense;
@override
Widget build(BuildContext context) {
@ -55,6 +57,7 @@ class AppTextField extends StatelessWidget {
hintText: hint,
prefixIcon: prefixIcon,
suffixIcon: suffixIcon,
isDense: isDense,
floatingLabelBehavior:
label != null ? FloatingLabelBehavior.always : null,
),

View File

@ -133,10 +133,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
@ -596,26 +596,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.18.0"
mime:
dependency: transitive
description:
@ -1009,10 +1009,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.11"
timing:
dependency: transitive
description:
@ -1142,5 +1142,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.9.2 <4.0.0"
dart: ">=3.10.0-0 <4.0.0"
flutter: ">=3.35.6"