diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index 2dd6d1e..2e32d50 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -37,12 +37,6 @@ class ApiEndpoints { static String rolePermissionMatrix(String roleId) => '/roles/$roleId/permission-matrix'; - // 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'; static String departmentById(String id) => '/masters/departments/$id'; @@ -69,6 +63,8 @@ class ApiEndpoints { static String paymentTermById(String id) => '/masters/payment-terms/$id'; static const String gstRates = '/masters/gst-rates'; static String gstRateById(String id) => '/masters/gst-rates/$id'; + static const String hsnCodes = '/masters/hsn-codes'; + static String hsnCodeById(String id) => '/masters/hsn-codes/$id'; static const String warehouses = '/masters/warehouses'; static String warehouseById(String id) => '/masters/warehouses/$id'; @@ -107,6 +103,11 @@ class ApiEndpoints { static String grnById(String id) => '/grn/$id'; static String grnCancel(String id) => '/grn/$id/cancel'; static String grnPdf(String id) => '/grn/$id/pdf'; + static String grnAttachments(String grnId) => '/grn/$grnId/attachments'; + static String grnAttachmentById(String grnId, String attachmentId) => + '/grn/$grnId/attachments/$attachmentId'; + static String grnAttachmentDownload(String grnId, String attachmentId) => + '/grn/$grnId/attachments/$attachmentId/download'; // Assets static const String assets = '/assets'; @@ -165,6 +166,9 @@ class ApiEndpoints { // Audit static const String auditLogs = '/audit-logs'; + static const String auditLogsFilters = '/audit-logs/filters'; + static const String auditLogsExport = '/audit-logs/export'; + static String auditLogById(String id) => '/audit-logs/$id'; // Notifications static const String notifications = '/notifications'; diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 1dea5a0..e57f62e 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -10,4 +10,7 @@ class AppConstants { static const Duration animationDuration = Duration(milliseconds: 300); static const Duration snackBarDuration = Duration(seconds: 3); + + /// Sidebar / top-bar Notifications entry. Kept in code; set true to show again. + static const bool showNotificationsMenu = false; } diff --git a/lib/core/constants/route_constants.dart b/lib/core/constants/route_constants.dart index ea6ede9..5d53966 100644 --- a/lib/core/constants/route_constants.dart +++ b/lib/core/constants/route_constants.dart @@ -62,7 +62,6 @@ class RouteConstants { static const String assetAdd = '/assets/add'; static const String assetEdit = '/assets/:id/edit'; static const String assetDetail = '/assets/:id'; - static const String assetCategories = '/assets/categories'; static const String assetAlerts = '/assets/alerts'; // Master Data diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index d6ac62b..ae04dd6 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../shared/widgets/app_data_table.dart'; import 'app_colors.dart'; import 'app_typography.dart'; import 'branding_config.dart'; @@ -202,6 +203,9 @@ class AppTheme { headingRowColor: WidgetStateProperty.all( colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), ), + headingRowHeight: kAppTableRowHeight, + dataRowMinHeight: kAppTableRowHeight, + dataRowMaxHeight: kAppTableRowHeight, headingTextStyle: textTheme.labelLarge, dataTextStyle: textTheme.bodyMedium, ), diff --git a/lib/core/utils/media_url.dart b/lib/core/utils/media_url.dart new file mode 100644 index 0000000..425bd07 --- /dev/null +++ b/lib/core/utils/media_url.dart @@ -0,0 +1,20 @@ +import '../config/environment.dart'; + +/// Turns API-relative media paths into absolute URLs the UI can load. +/// +/// Leaves `http(s)://` and `data:` URIs unchanged. +String? resolveMediaUrl(String? path) { + if (path == null) return null; + final trimmed = path.trim(); + if (trimmed.isEmpty) return null; + if (trimmed.startsWith('data:') || + trimmed.startsWith('http://') || + trimmed.startsWith('https://') || + trimmed.startsWith('blob:')) { + return trimmed; + } + + final origin = Uri.parse(Environment.apiBaseUrl).origin; + if (trimmed.startsWith('/')) return '$origin$trimmed'; + return '$origin/$trimmed'; +} diff --git a/lib/core/utils/validators.dart b/lib/core/utils/validators.dart index 2ed0f7c..ca1b4d9 100644 --- a/lib/core/utils/validators.dart +++ b/lib/core/utils/validators.dart @@ -321,6 +321,49 @@ class Validators { return normalizedKey == 'code' || normalizedKey == 'item_code'; } + /// HSN/SAC codes are 4–8 digits. + static final RegExp _hsnCodePattern = RegExp(r'^\d{4,8}$'); + + static String? hsnCode(String? value, {String fieldName = 'HSN/SAC Code'}) { + final requiredError = required(value, fieldName: fieldName); + if (requiredError != null) return requiredError; + + final trimmed = value!.trim(); + if (!_hsnCodePattern.hasMatch(trimmed)) { + return '$fieldName must be 4–8 digits'; + } + return null; + } + + static String? uniqueHsnCode( + String? value, { + required Iterable> existingRecords, + String? currentRecordId, + String fieldName = 'HSN/SAC Code', + }) { + final formatError = hsnCode(value, fieldName: fieldName); + if (formatError != null) return formatError; + + final normalized = value!.trim(); + for (final record in existingRecords) { + final recordId = record['id']?.toString(); + if (currentRecordId != null && recordId == currentRecordId) continue; + + final existingCode = record['code']?.toString().trim(); + if (existingCode != null && + existingCode.isNotEmpty && + existingCode == normalized) { + return '$fieldName must be unique'; + } + } + return null; + } + + static List get hsnCodeInput => [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(8), + ]; + /// Required master code — allowed: A-Z a-z 0-9 - _ / static String? masterCode(String? value, {String fieldName = 'Code'}) { final requiredError = required(value, fieldName: fieldName); diff --git a/lib/modules/assets/data/datasources/asset_remote_data_source.dart b/lib/modules/assets/data/datasources/asset_remote_data_source.dart index 5afb122..e031e93 100644 --- a/lib/modules/assets/data/datasources/asset_remote_data_source.dart +++ b/lib/modules/assets/data/datasources/asset_remote_data_source.dart @@ -51,7 +51,7 @@ class AssetRemoteDataSource { Future> getCategories() async { final response = await dio.get( - ApiEndpoints.assetCategories, + ApiEndpoints.itemCategories, queryParameters: const {'limit': 100, 'is_active': true}, ); return _parseList(response.data, AssetCategoryModel.fromJson); diff --git a/lib/modules/assets/presentation/providers/asset_categories_provider.dart b/lib/modules/assets/presentation/providers/asset_categories_provider.dart index a203253..e770323 100644 --- a/lib/modules/assets/presentation/providers/asset_categories_provider.dart +++ b/lib/modules/assets/presentation/providers/asset_categories_provider.dart @@ -3,9 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../data/repositories/asset_repository_impl.dart'; import '../../../../shared/models/asset_model.dart'; -final assetCategoriesProvider = FutureProvider>((ref) async { +/// Item categories from `/masters/item-categories` (shared for items and assets). +final itemCategoriesProvider = FutureProvider>((ref) async { final repository = ref.watch(assetRepositoryProvider); final result = await repository.getCategories(); if (result.failure != null) throw result.failure!; return result.data ?? []; }); + +@Deprecated('Use itemCategoriesProvider') +final assetCategoriesProvider = itemCategoriesProvider; diff --git a/lib/modules/assets/presentation/screens/asset_categories_screen.dart b/lib/modules/assets/presentation/screens/asset_categories_screen.dart deleted file mode 100644 index 222d20b..0000000 --- a/lib/modules/assets/presentation/screens/asset_categories_screen.dart +++ /dev/null @@ -1,58 +0,0 @@ -import '../../../../shared/widgets/app_card.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../providers/asset_categories_provider.dart'; -import '../../../../shared/widgets/page_header.dart'; - -class AssetCategoriesScreen extends ConsumerWidget { - const AssetCategoriesScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final categories = ref.watch(assetCategoriesProvider); - - return Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const PageHeader( - title: 'Asset Categories', - subtitle: 'Predefined and custom categories', - ), - Expanded( - child: categories.when( - data: (items) { - if (items.isEmpty) { - return const Center(child: Text('No categories found')); - } - return ListView.separated( - itemCount: items.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final category = items[index]; - return AppCard( - child: ListTile( - leading: const Icon(Icons.category_outlined), - title: Text(category.name), - subtitle: Text( - '${category.code}' - '${category.defaultDepreciationMethod != null ? ' · ${category.defaultDepreciationMethod}' : ''}', - ), - ), - ); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => const Center( - child: Text('Failed to load categories from API'), - ), - ), - ) - ], - ), - ); - } -} diff --git a/lib/modules/assets/presentation/screens/asset_list_screen.dart b/lib/modules/assets/presentation/screens/asset_list_screen.dart index edfcc82..e6ab7e6 100644 --- a/lib/modules/assets/presentation/screens/asset_list_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_list_screen.dart @@ -65,7 +65,7 @@ class _AssetListScreenState extends ConsumerState { onRetry: () => ref.invalidate(assetsListProvider), ), data: (state) { - final allCategories = ref.watch(assetCategoriesProvider).valueOrNull ?? []; + final allCategories = ref.watch(itemCategoriesProvider).valueOrNull ?? []; final filteredAssets = _filterAssets(state.assets); final categories = _categoryOptions(state.assets, allCategories); final plants = _plantOptions(state.assets); @@ -651,7 +651,7 @@ class _AssetCodeBadge extends StatelessWidget { color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(20), ), - child: Text( + child: AppTableCell.text( code, style: theme.textTheme.labelSmall?.copyWith( fontWeight: FontWeight.w600, diff --git a/lib/modules/assets/presentation/widgets/asset_form_panel.dart b/lib/modules/assets/presentation/widgets/asset_form_panel.dart index 72ca442..f81a176 100644 --- a/lib/modules/assets/presentation/widgets/asset_form_panel.dart +++ b/lib/modules/assets/presentation/widgets/asset_form_panel.dart @@ -306,8 +306,8 @@ class _AssetFormPanelState extends ConsumerState { Map _buildPayload() { final payload = { 'asset_name': _nameController.text.trim(), - 'asset_category_id': _categoryId, - 'asset_subcategory_id': _subcategoryId, + 'item_category_id': _categoryId, + 'item_subcategory_id': _subcategoryId, 'plant_id': _plantId, 'is_active': _isActive, }; @@ -461,7 +461,7 @@ class _AssetFormPanelState extends ConsumerState { @override Widget build(BuildContext context) { - final categoriesAsync = ref.watch(assetCategoriesProvider); + final categoriesAsync = ref.watch(itemCategoriesProvider); final plantsAsync = ref.watch(assetPlantsProvider); if (widget.isEditing) { @@ -518,7 +518,7 @@ class _AssetFormPanelState extends ConsumerState { AsyncValue> categoriesAsync, AsyncValue> plantsAsync, ) { - final subcategoriesAsync = ref.watch(assetSubcategoriesProvider(_categoryId)); + final subcategoriesAsync = ref.watch(itemSubcategoriesProvider(_categoryId)); final lookupsAsync = ref.watch(assetFormLookupsProvider); final grnItemsAsync = ref.watch(assetGrnItemsProvider(_grnId)); final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel(); @@ -1291,9 +1291,9 @@ final assetPlantsProvider = FutureProvider>((ref) async return dataSource.listPlants(); }); -final assetSubcategoriesProvider = +final itemSubcategoriesProvider = FutureProvider.family, int?>((ref, categoryId) async { if (categoryId == null) return []; final dataSource = ref.watch(masterRemoteDataSourceProvider); - return dataSource.listAssetSubcategories(assetCategoryId: categoryId); + return dataSource.listItemSubcategories(itemCategoryId: categoryId); }); diff --git a/lib/modules/audit/data/datasources/audit_remote_data_source.dart b/lib/modules/audit/data/datasources/audit_remote_data_source.dart new file mode 100644 index 0000000..9eab1af --- /dev/null +++ b/lib/modules/audit/data/datasources/audit_remote_data_source.dart @@ -0,0 +1,118 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/constants/api_endpoints.dart'; +import '../../../../shared/models/audit_log_model.dart'; +import '../../../../shared/models/export_file_result.dart'; + +class AuditRemoteDataSource { + AuditRemoteDataSource({required this.dio}); + + final Dio dio; + + Future getFilters() async { + final response = await dio.get(ApiEndpoints.auditLogsFilters); + final data = response.data['data'] as Map? ?? {}; + return AuditLogFilterOptions.fromJson(data); + } + + Future getAuditLogs(AuditLogListQuery query) async { + final response = await dio.get( + ApiEndpoints.auditLogs, + queryParameters: _queryToMap(query), + ); + final body = response.data as Map; + final rawItems = body['data']; + final items = rawItems is List + ? rawItems + .map( + (item) => + AuditLogEntryModel.fromJson(item as Map), + ) + .toList() + : []; + + final meta = body['meta'] as Map? ?? {}; + final page = (meta['page'] as num?)?.toInt() ?? query.page; + final limit = (meta['limit'] as num?)?.toInt() ?? query.limit; + final total = (meta['total'] as num?)?.toInt() ?? items.length; + final totalPages = limit > 0 + ? ((total + limit - 1) ~/ limit).clamp(1, 999999) + : 1; + final filtersRequired = meta['filters_required'] == true; + + return AuditLogListResult( + items: items, + page: page, + limit: limit, + total: total, + totalPages: totalPages, + filtersRequired: filtersRequired, + ); + } + + Future getAuditLogById(String id) async { + final response = await dio.get(ApiEndpoints.auditLogById(id)); + return AuditLogDetailModel.fromJson( + response.data['data'] as Map, + ); + } + + Future exportAuditLogs(AuditLogListQuery query) async { + final response = await dio.get>( + ApiEndpoints.auditLogsExport, + queryParameters: _exportQueryToMap(query), + options: Options(responseType: ResponseType.bytes), + ); + final bytes = response.data ?? []; + return ExportFileResult( + bytes: bytes, + fileName: _fileNameFromResponse(response), + ); + } + + Map _queryToMap(AuditLogListQuery query) { + return { + 'page': query.page, + 'limit': query.limit, + ..._exportQueryToMap(query), + }; + } + + Map _exportQueryToMap(AuditLogListQuery query) { + return { + if (query.tableName != null && query.tableName!.isNotEmpty) + 'table_name': query.tableName, + if (query.recordId != null) 'record_id': query.recordId, + if (query.action != null && query.action!.isNotEmpty) 'action': query.action, + if (query.performedBy != null) 'performed_by': query.performedBy, + if (query.requestId != null && query.requestId!.isNotEmpty) + 'request_id': query.requestId, + if (query.dateFrom != null) 'date_from': query.dateFrom!.toUtc().toIso8601String(), + if (query.dateTo != null) 'date_to': query.dateTo!.toUtc().toIso8601String(), + if (query.search != null && query.search!.isNotEmpty) 'search': query.search, + }; + } + + String _fileNameFromResponse(Response> response) { + final disposition = response.headers.value('content-disposition'); + if (disposition != null) { + final utf8Match = RegExp( + r"filename\*=UTF-8''([^;\n]+)", + caseSensitive: false, + ).firstMatch(disposition); + if (utf8Match != null) { + return Uri.decodeComponent(utf8Match.group(1)!); + } + + final match = RegExp(r'filename="?([^";\n]+)"?').firstMatch(disposition); + if (match != null) { + return match.group(1)!.trim(); + } + } + + final contentType = + response.headers.value('content-type')?.toLowerCase() ?? ''; + if (contentType.contains('csv')) return 'audit_logs_export.csv'; + return 'audit_logs_export.csv'; + } +} diff --git a/lib/modules/audit/data/repositories/audit_repository_impl.dart b/lib/modules/audit/data/repositories/audit_repository_impl.dart new file mode 100644 index 0000000..b4842b8 --- /dev/null +++ b/lib/modules/audit/data/repositories/audit_repository_impl.dart @@ -0,0 +1,38 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/network/api_handler.dart'; +import '../../../../core/network/dio_client.dart'; +import '../../../../shared/models/audit_log_model.dart'; +import '../../../../shared/models/export_file_result.dart'; +import '../../domain/repositories/audit_repository.dart'; +import '../datasources/audit_remote_data_source.dart'; + +final auditRemoteDataSourceProvider = Provider((ref) { + return AuditRemoteDataSource(dio: ref.watch(dioProvider)); +}); + +final auditRepositoryProvider = Provider((ref) { + return AuditRepositoryImpl(remote: ref.watch(auditRemoteDataSourceProvider)); +}); + +class AuditRepositoryImpl implements AuditRepository { + AuditRepositoryImpl({required this.remote}); + + final AuditRemoteDataSource remote; + + @override + Future> getFilters() => + safeApiCall(remote.getFilters); + + @override + Future> getAuditLogs(AuditLogListQuery query) => + safeApiCall(() => remote.getAuditLogs(query)); + + @override + Future> getAuditLogById(String id) => + safeApiCall(() => remote.getAuditLogById(id)); + + @override + Future> exportAuditLogs(AuditLogListQuery query) => + safeApiCall(() => remote.exportAuditLogs(query)); +} diff --git a/lib/modules/audit/domain/repositories/audit_repository.dart b/lib/modules/audit/domain/repositories/audit_repository.dart new file mode 100644 index 0000000..3a51692 --- /dev/null +++ b/lib/modules/audit/domain/repositories/audit_repository.dart @@ -0,0 +1,10 @@ +import '../../../../core/network/api_handler.dart'; +import '../../../../shared/models/audit_log_model.dart'; +import '../../../../shared/models/export_file_result.dart'; + +abstract class AuditRepository { + Future> getFilters(); + Future> getAuditLogs(AuditLogListQuery query); + Future> getAuditLogById(String id); + Future> exportAuditLogs(AuditLogListQuery query); +} diff --git a/lib/modules/audit/domain/usecases/audit_usecases.dart b/lib/modules/audit/domain/usecases/audit_usecases.dart new file mode 100644 index 0000000..5ae5800 --- /dev/null +++ b/lib/modules/audit/domain/usecases/audit_usecases.dart @@ -0,0 +1,39 @@ +import '../../../../core/network/api_handler.dart'; +import '../../../../shared/models/audit_log_model.dart'; +import '../../../../shared/models/export_file_result.dart'; +import '../repositories/audit_repository.dart'; + +class GetAuditFiltersUseCase { + GetAuditFiltersUseCase(this._repository); + + final AuditRepository _repository; + + Future> call() => _repository.getFilters(); +} + +class GetAuditLogsUseCase { + GetAuditLogsUseCase(this._repository); + + final AuditRepository _repository; + + Future> call(AuditLogListQuery query) => + _repository.getAuditLogs(query); +} + +class GetAuditLogByIdUseCase { + GetAuditLogByIdUseCase(this._repository); + + final AuditRepository _repository; + + Future> call(String id) => + _repository.getAuditLogById(id); +} + +class ExportAuditLogsUseCase { + ExportAuditLogsUseCase(this._repository); + + final AuditRepository _repository; + + Future> call(AuditLogListQuery query) => + _repository.exportAuditLogs(query); +} diff --git a/lib/modules/audit/presentation/providers/audit_provider.dart b/lib/modules/audit/presentation/providers/audit_provider.dart new file mode 100644 index 0000000..a8553d5 --- /dev/null +++ b/lib/modules/audit/presentation/providers/audit_provider.dart @@ -0,0 +1,239 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../shared/models/audit_log_model.dart'; +import '../../../../shared/models/export_file_result.dart'; +import '../../data/repositories/audit_repository_impl.dart'; +import '../../domain/usecases/audit_usecases.dart'; + +class AuditLogsListState { + const AuditLogsListState({ + this.items = const [], + this.filters = const AuditLogFilterOptions(), + this.query = const AuditLogListQuery(), + this.total = 0, + this.totalPages = 1, + this.filtersRequired = true, + this.isRefreshing = false, + this.isExporting = false, + this.actionError, + this.actionSuccess, + }); + + final List items; + final AuditLogFilterOptions filters; + final AuditLogListQuery query; + final int total; + final int totalPages; + final bool filtersRequired; + final bool isRefreshing; + final bool isExporting; + final String? actionError; + final String? actionSuccess; + + AuditLogsListState copyWith({ + List? items, + AuditLogFilterOptions? filters, + AuditLogListQuery? query, + int? total, + int? totalPages, + bool? filtersRequired, + bool? isRefreshing, + bool? isExporting, + String? actionError, + String? actionSuccess, + bool clearMessages = false, + }) { + return AuditLogsListState( + items: items ?? this.items, + filters: filters ?? this.filters, + query: query ?? this.query, + total: total ?? this.total, + totalPages: totalPages ?? this.totalPages, + filtersRequired: filtersRequired ?? this.filtersRequired, + isRefreshing: isRefreshing ?? this.isRefreshing, + isExporting: isExporting ?? this.isExporting, + actionError: clearMessages ? null : actionError ?? this.actionError, + actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess, + ); + } +} + +final getAuditFiltersUseCaseProvider = Provider((ref) { + return GetAuditFiltersUseCase(ref.watch(auditRepositoryProvider)); +}); + +final getAuditLogsUseCaseProvider = Provider((ref) { + return GetAuditLogsUseCase(ref.watch(auditRepositoryProvider)); +}); + +final getAuditLogByIdUseCaseProvider = Provider((ref) { + return GetAuditLogByIdUseCase(ref.watch(auditRepositoryProvider)); +}); + +final exportAuditLogsUseCaseProvider = Provider((ref) { + return ExportAuditLogsUseCase(ref.watch(auditRepositoryProvider)); +}); + +final auditLogsListProvider = + AsyncNotifierProvider( + AuditLogsListNotifier.new, +); + +class AuditLogsListNotifier extends AsyncNotifier { + @override + Future build() async { + ref.keepAlive(); + return _loadAll(const AuditLogListQuery(limit: 20)); + } + + Future _loadAll(AuditLogListQuery query) async { + final filtersResult = await ref.read(getAuditFiltersUseCaseProvider)(); + final listResult = await ref.read(getAuditLogsUseCaseProvider)(query); + + if (listResult.failure != null) throw listResult.failure!; + + final page = listResult.data!; + return AuditLogsListState( + items: page.items, + filters: filtersResult.data ?? const AuditLogFilterOptions(), + query: query, + total: page.total, + totalPages: page.totalPages, + filtersRequired: page.filtersRequired || !query.hasActiveFilter, + ); + } + + Future refresh() async { + final current = state.valueOrNull ?? const AuditLogsListState(); + state = AsyncData(current.copyWith(isRefreshing: true, clearMessages: true)); + try { + state = AsyncData(await _loadAll(current.query)); + } catch (e, st) { + state = AsyncError(e, st); + } + } + + Future applyQuery(AuditLogListQuery query) async { + final previous = state.valueOrNull; + if (previous == null) { + state = const AsyncLoading(); + } else { + state = AsyncData(previous.copyWith(query: query, clearMessages: true)); + } + try { + final filters = previous?.filters ?? const AuditLogFilterOptions(); + final listResult = await ref.read(getAuditLogsUseCaseProvider)(query); + if (listResult.failure != null) throw listResult.failure!; + final page = listResult.data!; + state = AsyncData( + AuditLogsListState( + items: page.items, + filters: filters, + query: query, + total: page.total, + totalPages: page.totalPages, + filtersRequired: page.filtersRequired || !query.hasActiveFilter, + ), + ); + } catch (e, st) { + state = AsyncError(e, st); + } + } + + void setSearch(String search) { + final current = state.valueOrNull; + if (current == null) return; + applyQuery(current.query.copyWith(search: search, page: 1)); + } + + void setPage(int page) { + final current = state.valueOrNull; + if (current == null) return; + applyQuery(current.query.copyWith(page: page)); + } + + void setPageSize(int limit) { + final current = state.valueOrNull; + if (current == null) return; + applyQuery(current.query.copyWith(limit: limit, page: 1)); + } + + void setTableName(String? tableName) { + final current = state.valueOrNull; + if (current == null) return; + applyQuery(current.query.copyWith(tableName: tableName, page: 1)); + } + + void setAction(String? action) { + final current = state.valueOrNull; + if (current == null) return; + applyQuery(current.query.copyWith(action: action, page: 1)); + } + + void setPerformedBy(int? performedBy) { + final current = state.valueOrNull; + if (current == null) return; + applyQuery(current.query.copyWith(performedBy: performedBy, page: 1)); + } + + void setDateRange(DateTime? dateFrom, DateTime? dateTo) { + final current = state.valueOrNull; + if (current == null) return; + applyQuery( + current.query.copyWith(dateFrom: dateFrom, dateTo: dateTo, page: 1), + ); + } + + Future resetFilters() async { + final current = state.valueOrNull; + if (current == null) return; + await applyQuery(AuditLogListQuery(limit: current.query.limit)); + } + + Future exportLogs() async { + final current = state.valueOrNull; + if (current == null) return null; + + if (!current.query.hasActiveFilter) { + state = AsyncData( + current.copyWith( + actionError: 'Apply at least one filter before exporting.', + ), + ); + return null; + } + + state = AsyncData(current.copyWith(isExporting: true, clearMessages: true)); + + final result = await ref.read(exportAuditLogsUseCaseProvider)(current.query); + final latest = state.valueOrNull ?? current; + + if (result.failure != null) { + state = AsyncData( + latest.copyWith( + isExporting: false, + actionError: result.failure!.message, + ), + ); + return null; + } + + state = AsyncData(latest.copyWith(isExporting: false)); + return result.data; + } +} + +final auditLogDetailProvider = AsyncNotifierProvider.family< + AuditLogDetailNotifier, AuditLogDetailModel, String>( + AuditLogDetailNotifier.new, +); + +class AuditLogDetailNotifier + extends FamilyAsyncNotifier { + @override + Future build(String arg) async { + final result = await ref.read(getAuditLogByIdUseCaseProvider)(arg); + if (result.failure != null) throw result.failure!; + return result.data!; + } +} diff --git a/lib/modules/audit/presentation/screens/audit_logs_screen.dart b/lib/modules/audit/presentation/screens/audit_logs_screen.dart index 238b05e..cd24a94 100644 --- a/lib/modules/audit/presentation/screens/audit_logs_screen.dart +++ b/lib/modules/audit/presentation/screens/audit_logs_screen.dart @@ -1,6 +1,525 @@ -import '../../../../shared/widgets/placeholder_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -class AuditLogsScreen extends PlaceholderScreen { - const AuditLogsScreen({super.key}) - : super(title: 'Audit Logs', description: 'System activity and audit trail'); +import '../../../../core/constants/enums.dart'; +import '../../../../core/errors/failure.dart'; +import '../../../../core/utils/formatters.dart'; +import '../../../../core/utils/responsive_utils.dart'; +import '../../../../shared/models/audit_log_model.dart'; +import '../../../../shared/providers/permissions_provider.dart'; +import '../../../../shared/utils/file_download_helper.dart'; +import '../../../../shared/widgets/app_card.dart'; +import '../../../../shared/widgets/app_data_table.dart'; +import '../../../../shared/widgets/app_dropdown.dart'; +import '../../../../shared/widgets/app_empty_state.dart'; +import '../../../../shared/widgets/app_loading_view.dart'; +import '../../../../shared/widgets/app_pagination.dart'; +import '../../../../shared/widgets/app_search_field.dart'; +import '../../../../shared/widgets/app_searchable_dropdown.dart'; +import '../../../../shared/widgets/app_side_panel.dart'; +import '../../../../shared/widgets/app_status_chip.dart'; +import '../../../../shared/widgets/app_table_action_icon.dart'; +import '../../../../shared/widgets/app_table_shell.dart'; +import '../../../../shared/widgets/error_view.dart'; +import '../../../../shared/widgets/page_header.dart'; +import '../providers/audit_provider.dart'; +import '../widgets/audit_log_detail_panel.dart'; + +class AuditLogsScreen extends ConsumerStatefulWidget { + const AuditLogsScreen({super.key}); + + @override + ConsumerState createState() => _AuditLogsScreenState(); +} + +class _AuditLogsScreenState extends ConsumerState { + final _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _exportLogs() async { + final file = await ref.read(auditLogsListProvider.notifier).exportLogs(); + if (!mounted) return; + + if (file == null) { + final error = ref.read(auditLogsListProvider).valueOrNull?.actionError; + if (error != null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error))); + } + return; + } + + final saved = await downloadFile( + bytes: file.bytes, + fileName: file.fileName, + ); + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(saved ? 'Downloaded ${file.fileName}' : 'Export cancelled'), + ), + ); + } + + Future _viewLog(AuditLogEntryModel log) async { + ref.invalidate(auditLogDetailProvider(log.id)); + await showSidePanel( + context, + AuditLogDetailPanel(logId: log.id), + width: 560, + ); + } + + Future _pickDateRange(AuditLogListQuery query) async { + final now = DateTime.now(); + final initial = (query.dateFrom != null && query.dateTo != null) + ? DateTimeRange(start: query.dateFrom!, end: query.dateTo!) + : null; + + final picked = await showDateRangePicker( + context: context, + firstDate: DateTime(now.year - 5), + lastDate: DateTime(now.year + 1), + initialDateRange: initial, + helpText: 'Filter by date range', + ); + if (picked == null) return; + + final start = DateTime(picked.start.year, picked.start.month, picked.start.day); + final end = DateTime( + picked.end.year, + picked.end.month, + picked.end.day, + 23, + 59, + 59, + ); + ref.read(auditLogsListProvider.notifier).setDateRange(start, end); + } + + @override + Widget build(BuildContext context) { + final logsAsync = ref.watch(auditLogsListProvider); + final canExport = ref.can('audit_logs', PermissionAction.export); + + ref.listen(auditLogsListProvider, (prev, next) { + final error = next.valueOrNull?.actionError; + if (error != null && error != prev?.valueOrNull?.actionError) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error))); + } + }); + + return Padding( + padding: const EdgeInsets.all(24), + child: logsAsync.when( + loading: () => const AppLoadingView(message: 'Loading audit logs...'), + error: (error, _) => ErrorView.fromFailure( + error is Failure ? error : Failure.unknown(message: error.toString()), + onRetry: () => ref.invalidate(auditLogsListProvider), + ), + data: (state) { + final notifier = ref.read(auditLogsListProvider.notifier); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PageHeader( + title: 'Audit Logs', + subtitle: 'System activity and change history', + actions: [ + if (canExport) + OutlinedButton.icon( + onPressed: state.isExporting || !state.query.hasActiveFilter + ? null + : _exportLogs, + icon: state.isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined), + label: Text(state.isExporting ? 'Exporting...' : 'Export'), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: AppTableShell( + toolbar: LayoutBuilder( + builder: (context, constraints) { + return _FiltersBar( + searchController: _searchController, + filters: state.filters, + query: state.query, + wrapped: constraints.maxWidth < 1100, + onSearch: notifier.setSearch, + onTableChanged: notifier.setTableName, + onActionChanged: notifier.setAction, + onPerformerChanged: notifier.setPerformedBy, + onPickDateRange: () => _pickDateRange(state.query), + onClearDateRange: () => notifier.setDateRange(null, null), + onReset: () { + _searchController.clear(); + notifier.resetFilters(); + }, + ); + }, + ), + footer: AppPagination( + currentPage: state.query.page, + totalPages: state.totalPages, + totalItems: state.total, + pageSize: state.query.limit, + itemLabel: 'audit logs', + onPageChanged: notifier.setPage, + onPageSizeChanged: notifier.setPageSize, + ), + child: RefreshIndicator( + onRefresh: notifier.refresh, + child: state.filtersRequired && state.items.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox( + height: 260, + child: AppEmptyState( + title: 'Apply a filter to view logs', + description: + 'Select a table, action, user, or date range to load audit history.', + icon: Icons.filter_alt_outlined, + ), + ), + ], + ) + : state.items.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox( + height: 260, + child: AppEmptyState( + title: 'No audit logs found', + description: + 'Try adjusting filters or expanding the date range.', + icon: Icons.history_outlined, + ), + ), + ], + ) + : context.isMobile + ? _AuditCardList( + items: state.items, + onView: _viewLog, + ) + : _AuditDataTable( + items: state.items, + onView: _viewLog, + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +class _FiltersBar extends StatelessWidget { + const _FiltersBar({ + required this.searchController, + required this.filters, + required this.query, + required this.wrapped, + required this.onSearch, + required this.onTableChanged, + required this.onActionChanged, + required this.onPerformerChanged, + required this.onPickDateRange, + required this.onClearDateRange, + required this.onReset, + }); + + final TextEditingController searchController; + final AuditLogFilterOptions filters; + final AuditLogListQuery query; + final bool wrapped; + final ValueChanged onSearch; + final ValueChanged onTableChanged; + final ValueChanged onActionChanged; + final ValueChanged onPerformerChanged; + final VoidCallback onPickDateRange; + final VoidCallback onClearDateRange; + final VoidCallback onReset; + + String get _dateLabel { + if (query.dateFrom == null && query.dateTo == null) return 'Date range'; + final from = DateFormatter.displayDate(query.dateFrom); + final to = DateFormatter.displayDate(query.dateTo); + return '$from – $to'; + } + + @override + Widget build(BuildContext context) { + final searchField = AppSearchField( + controller: searchController, + hint: 'Search table, action, request ID...', + onChanged: onSearch, + ); + + final tableDropdown = AppSearchableDropdown( + label: 'Table', + value: query.tableName, + searchHint: 'Search table...', + isDense: true, + options: [ + const AppDropdownOption(value: null, label: 'All tables'), + ...filters.tableNames.map( + (name) => AppDropdownOption(value: name, label: name), + ), + ], + onChanged: onTableChanged, + ); + + final actionDropdown = AppSearchableDropdown( + label: 'Action', + value: query.action, + searchHint: 'Search action...', + isDense: true, + options: [ + const AppDropdownOption(value: null, label: 'All actions'), + ...filters.actions.map( + (action) => AppDropdownOption(value: action, label: action), + ), + ], + onChanged: onActionChanged, + ); + + final performerDropdown = AppSearchableDropdown( + label: 'Performed by', + value: query.performedBy, + searchHint: 'Search user...', + isDense: true, + options: [ + const AppDropdownOption(value: null, label: 'All users'), + ...filters.performers.map( + (user) => AppDropdownOption( + value: int.tryParse(user.id), + label: user.label, + ), + ), + ], + onChanged: onPerformerChanged, + ); + + final dateButton = OutlinedButton.icon( + onPressed: onPickDateRange, + icon: const Icon(Icons.date_range_outlined, size: 18), + label: Text(_dateLabel, overflow: TextOverflow.ellipsis), + ); + + final clearDate = query.dateFrom != null || query.dateTo != null + ? IconButton( + tooltip: 'Clear date range', + onPressed: onClearDateRange, + icon: const Icon(Icons.clear, size: 18), + ) + : null; + + final resetButton = TextButton( + onPressed: query.hasActiveFilter ? onReset : null, + child: const Text('Reset'), + ); + + if (wrapped) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + searchField, + const SizedBox(height: 12), + tableDropdown, + const SizedBox(height: 12), + actionDropdown, + const SizedBox(height: 12), + performerDropdown, + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: dateButton), + if (clearDate != null) clearDate, + resetButton, + ], + ), + ], + ); + } + + return Column( + children: [ + Row( + children: [ + Expanded(flex: 3, child: searchField), + const SizedBox(width: 12), + Expanded(flex: 2, child: tableDropdown), + const SizedBox(width: 12), + Expanded(flex: 2, child: actionDropdown), + const SizedBox(width: 12), + Expanded(flex: 2, child: performerDropdown), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Flexible(child: dateButton), + if (clearDate != null) clearDate, + const Spacer(), + resetButton, + ], + ), + ], + ); + } +} + +class _AuditDataTable extends StatelessWidget { + const _AuditDataTable({ + required this.items, + required this.onView, + }); + + final List items; + final void Function(AuditLogEntryModel log) onView; + + @override + Widget build(BuildContext context) { + return AppDataTable( + wrapInCard: false, + rows: items, + emptyMessage: 'No audit logs found', + columns: [ + AppDataColumn( + label: 'When', + flex: 2, + cellBuilder: (_, row) => AppTableCell.text( + DateFormatter.displayDateTime(row.performedAt), + ), + ), + AppDataColumn( + label: 'Action', + flex: 1, + cellBuilder: (_, row) => AppTableCell.child( + AppStatusChip(status: row.action, compact: true), + ), + ), + AppDataColumn( + label: 'Table', + flex: 2, + cellBuilder: (_, row) => AppTableCell.text(row.tableName), + ), + AppDataColumn( + label: 'Record', + flex: 1, + cellBuilder: (_, row) => AppTableCell.text(row.recordId), + ), + AppDataColumn( + label: 'Performed by', + flex: 2, + cellBuilder: (_, row) => AppTableCell.text(row.performerLabel), + ), + AppDataColumn( + label: 'Request ID', + flex: 2, + cellBuilder: (_, row) => AppTableCell.text(row.requestId), + ), + AppDataColumn( + label: 'Changes', + flex: 1, + cellBuilder: (_, row) { + final parts = []; + if (row.hasOldValue) parts.add('old'); + if (row.hasNewValue) parts.add('new'); + return AppTableCell.text( + parts.isEmpty ? '—' : parts.join(' / '), + ); + }, + ), + AppDataColumn( + label: 'Actions', + flex: 1, + alignment: Alignment.centerRight, + cellBuilder: (_, row) => AppTableActions( + children: [ + AppTableActionIcon( + tooltip: 'View', + icon: Icons.visibility_outlined, + onPressed: () => onView(row), + ), + ], + ), + ), + ], + ); + } +} + +class _AuditCardList extends StatelessWidget { + const _AuditCardList({ + required this.items, + required this.onView, + }); + + final List items; + final void Function(AuditLogEntryModel log) onView; + + @override + Widget build(BuildContext context) { + return ListView.separated( + itemCount: items.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final log = items[index]; + return AppCard( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + log.tableName, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + AppStatusChip(status: log.action, compact: true), + ], + ), + const SizedBox(height: 4), + Text(DateFormatter.displayDateTime(log.performedAt)), + Text('Record: ${log.recordId ?? '—'}'), + Text(log.performerLabel), + if (log.requestId != null) Text('Request: ${log.requestId}'), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () => onView(log), + icon: const Icon(Icons.visibility_outlined, size: 18), + label: const Text('View'), + ), + ), + ], + ), + ), + ); + }, + ); + } } diff --git a/lib/modules/audit/presentation/widgets/audit_log_detail_panel.dart b/lib/modules/audit/presentation/widgets/audit_log_detail_panel.dart new file mode 100644 index 0000000..55870e3 --- /dev/null +++ b/lib/modules/audit/presentation/widgets/audit_log_detail_panel.dart @@ -0,0 +1,476 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../../core/errors/failure.dart'; +import '../../../../core/utils/formatters.dart'; +import '../../../../shared/models/audit_log_model.dart'; +import '../../../../shared/widgets/app_loading_view.dart'; +import '../../../../shared/widgets/app_side_panel.dart'; +import '../../../../shared/widgets/app_status_chip.dart'; +import '../../../../shared/widgets/error_view.dart'; +import '../providers/audit_provider.dart'; + +class AuditLogDetailPanel extends ConsumerWidget { + const AuditLogDetailPanel({super.key, required this.logId}); + + final String logId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final detailAsync = ref.watch(auditLogDetailProvider(logId)); + + return SidePanelScaffold( + title: 'Audit Log Detail', + child: detailAsync.when( + loading: () => const SizedBox( + height: 240, + child: AppLoadingView(message: 'Loading audit log...'), + ), + error: (error, _) => ErrorView.fromFailure( + error is Failure ? error : Failure.unknown(message: error.toString()), + onRetry: () => ref.invalidate(auditLogDetailProvider(logId)), + ), + data: (detail) => _DetailBody(detail: detail), + ), + ); + } +} + +class _DetailBody extends StatelessWidget { + const _DetailBody({required this.detail}); + + final AuditLogDetailModel detail; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SidePanelSection( + title: 'SUMMARY', + children: [ + _DetailRow( + label: 'Action', + child: AppStatusChip(status: detail.action, compact: true), + ), + _DetailRow(label: 'Table', value: _humanizeKey(detail.tableName)), + _DetailRow(label: 'Record ID', value: detail.recordId ?? '—'), + _DetailRow( + label: 'Performed At', + value: DateFormatter.displayDateTime(detail.performedAt), + ), + _DetailRow(label: 'Performed By', value: detail.performerLabel), + if (detail.performedByUser?.email != null) + _DetailRow( + label: 'Email', + value: detail.performedByUser!.email!, + ), + _DetailRow(label: 'Request ID', value: detail.requestId ?? '—'), + ], + ), + if (detail.oldValue != null || detail.hasOldValue) + _ReadableValueSection( + title: 'OLD VALUE', + value: detail.oldValue, + emptyLabel: 'No previous value recorded', + ), + if (detail.newValue != null || detail.hasNewValue) + _ReadableValueSection( + title: 'NEW VALUE', + value: detail.newValue, + emptyLabel: 'No new value recorded', + ), + if ((detail.oldValue != null || detail.hasOldValue) && + (detail.newValue != null || detail.hasNewValue)) + _ChangedFieldsSection( + oldValue: detail.oldValue, + newValue: detail.newValue, + ), + ], + ); + } +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({ + required this.label, + this.value, + this.child, + }); + + final String label; + final String? value; + final Widget? child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 140, + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: child ?? + SelectableText( + value ?? '—', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} + +class _ReadableValueSection extends StatelessWidget { + const _ReadableValueSection({ + required this.title, + required this.value, + required this.emptyLabel, + }); + + final String title; + final Map? value; + final String emptyLabel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final rows = value == null ? const <_FieldRow>[] : _flattenFields(value!); + + return SidePanelSection( + title: title, + children: [ + if (rows.isEmpty) + Text( + emptyLabel, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ) + else + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(14, 14, 14, 2), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.12), + ), + ), + child: Column( + children: [ + for (final row in rows) + _DetailRow(label: row.label, value: row.value), + ], + ), + ), + ], + ); + } +} + +class _ChangedFieldsSection extends StatelessWidget { + const _ChangedFieldsSection({ + required this.oldValue, + required this.newValue, + }); + + final Map? oldValue; + final Map? newValue; + + @override + Widget build(BuildContext context) { + final oldRows = { + for (final row in _flattenFields(oldValue ?? const {})) row.label: row.value, + }; + final newRows = { + for (final row in _flattenFields(newValue ?? const {})) row.label: row.value, + }; + final labels = {...oldRows.keys, ...newRows.keys}.toList()..sort(); + final changes = labels + .where((label) => (oldRows[label] ?? '—') != (newRows[label] ?? '—')) + .toList(); + + if (changes.isEmpty) return const SizedBox.shrink(); + + final theme = Theme.of(context); + + return SidePanelSection( + title: 'CHANGED FIELDS', + children: [ + ...changes.map( + (label) => Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _ChangeValue( + label: 'Before', + value: oldRows[label] ?? '—', + tone: theme.colorScheme.error, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 18), + child: Icon( + Icons.arrow_forward, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Expanded( + child: _ChangeValue( + label: 'After', + value: newRows[label] ?? '—', + tone: theme.colorScheme.primary, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } +} + +class _ChangeValue extends StatelessWidget { + const _ChangeValue({ + required this.label, + required this.value, + required this.tone, + }); + + final String label; + final String value; + final Color tone; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: tone.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: tone.withValues(alpha: 0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: tone, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + SelectableText( + value, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} + +class _FieldRow { + const _FieldRow({required this.label, required this.value}); + + final String label; + final String value; +} + +List<_FieldRow> _flattenFields( + Map map, { + String? prefix, +}) { + final rows = <_FieldRow>[]; + final entries = map.entries.toList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + + for (final entry in entries) { + final key = entry.key.toString(); + final label = prefix == null + ? _humanizeKey(key) + : '${_humanizeKey(prefix)} › ${_humanizeKey(key)}'; + final value = entry.value; + + if (value is Map) { + final nested = Map.from(value); + final summary = _nestedSummary(nested); + if (summary != null) { + rows.add(_FieldRow(label: _humanizeKey(key), value: summary)); + } else { + rows.addAll( + _flattenFields( + nested, + prefix: prefix == null ? key : '$prefix.$key', + ), + ); + } + continue; + } + + if (value is List) { + rows.add(_FieldRow(label: label, value: _formatList(value))); + continue; + } + + rows.add(_FieldRow(label: label, value: _formatValue(key, value))); + } + + return rows; +} + +String? _nestedSummary(Map nested) { + const preferredKeys = [ + 'vendor_name', + 'full_name', + 'name', + 'label', + 'title', + 'code', + 'employee_code', + 'contract_no', + 'email', + ]; + + for (final key in preferredKeys) { + final value = nested[key]; + if (value == null) continue; + final text = value.toString().trim(); + if (text.isEmpty) continue; + + final id = nested['id']?.toString().trim(); + if (id != null && id.isNotEmpty && key != 'id') { + return '$text (ID: $id)'; + } + return text; + } + + if (nested.length <= 2) { + return nested.entries + .map((e) => '${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}') + .join(', '); + } + + return null; +} + +String _formatList(List values) { + if (values.isEmpty) return '—'; + return values.map((item) { + if (item is Map) { + final summary = _nestedSummary(Map.from(item)); + return summary ?? item.toString(); + } + return _formatValue('', item); + }).join(', '); +} + +String _formatValue(String key, Object? value) { + if (value == null) return '—'; + + if (value is bool) return value ? 'Yes' : 'No'; + + if (value is num) { + final lower = key.toLowerCase(); + if (lower.contains('amount') || + lower.contains('cost') || + lower.contains('price') || + lower.contains('rate') || + lower.contains('premium')) { + return CurrencyFormatter.format(value.toDouble()); + } + if (value is double || value.toString().contains('.')) { + return NumberFormat('#,##0.##').format(value); + } + return NumberFormat('#,##0').format(value); + } + + final text = value.toString().trim(); + if (text.isEmpty) return '—'; + + final lower = key.toLowerCase(); + final looksLikeDate = lower.contains('date') || + lower.contains('_at') || + lower.endsWith('at') || + RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(text); + + if (looksLikeDate) { + final parsed = DateTime.tryParse(text); + if (parsed != null) { + if (text.contains('T') || text.contains(':')) { + return DateFormatter.displayDateTime(parsed); + } + return DateFormatter.displayDate(parsed); + } + } + + if (text == 'true') return 'Yes'; + if (text == 'false') return 'No'; + + return text; +} + +String _humanizeKey(String key) { + final cleaned = key.trim().replaceAll(RegExp(r'[_\-.]+'), ' '); + if (cleaned.isEmpty) return key; + + const acronyms = { + 'id': 'ID', + 'amc': 'AMC', + 'gst': 'GST', + 'hsn': 'HSN', + 'uom': 'UOM', + 'po': 'PO', + 'grn': 'GRN', + 'url': 'URL', + 'api': 'API', + }; + + return cleaned + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .map((part) { + final lower = part.toLowerCase(); + if (acronyms.containsKey(lower)) return acronyms[lower]!; + return '${lower[0].toUpperCase()}${lower.substring(1)}'; + }) + .join(' '); +} diff --git a/lib/modules/dev/presentation/screens/screen_gallery_screen.dart b/lib/modules/dev/presentation/screens/screen_gallery_screen.dart index 4ec922f..bff0c50 100644 --- a/lib/modules/dev/presentation/screens/screen_gallery_screen.dart +++ b/lib/modules/dev/presentation/screens/screen_gallery_screen.dart @@ -78,8 +78,6 @@ final _entries = [ // Assets _GalleryEntry(title: 'Asset List', route: RouteConstants.assets, group: 'Assets'), _GalleryEntry(title: 'Asset Detail', route: '/assets/demo-asset', group: 'Assets'), - _GalleryEntry(title: 'Asset Categories', route: RouteConstants.assetCategories, group: 'Assets'), - _GalleryEntry(title: 'Categories', route: RouteConstants.assetCategories, group: 'Assets'), _GalleryEntry(title: 'Alerts', route: RouteConstants.assetAlerts, group: 'Assets'), // Master data _GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'), diff --git a/lib/modules/grn/data/datasources/grn_remote_data_source.dart b/lib/modules/grn/data/datasources/grn_remote_data_source.dart index efb6126..9aa1abb 100644 --- a/lib/modules/grn/data/datasources/grn_remote_data_source.dart +++ b/lib/modules/grn/data/datasources/grn_remote_data_source.dart @@ -48,6 +48,60 @@ class GrnRemoteDataSource { return response.data ?? []; } + Future> listAttachments(String grnId) async { + final response = await dio.get(ApiEndpoints.grnAttachments(grnId)); + final data = response.data['data']; + if (data is! List) return const []; + return data + .whereType>() + .map(GrnAttachmentModel.fromJson) + .toList(); + } + + Future uploadAttachment( + String grnId, { + required List bytes, + required String filename, + }) async { + final formData = FormData.fromMap({ + 'file': MultipartFile.fromBytes(bytes, filename: filename), + }); + final response = await dio.post( + ApiEndpoints.grnAttachments(grnId), + data: formData, + ); + return GrnAttachmentModel.fromJson( + response.data['data'] as Map, + ); + } + + Future getAttachment( + String grnId, + String attachmentId, + ) async { + final response = await dio.get( + ApiEndpoints.grnAttachmentById(grnId, attachmentId), + ); + return GrnAttachmentModel.fromJson( + response.data['data'] as Map, + ); + } + + Future> downloadAttachment( + String grnId, + String attachmentId, + ) async { + final response = await dio.get>( + ApiEndpoints.grnAttachmentDownload(grnId, attachmentId), + options: Options(responseType: ResponseType.bytes), + ); + return response.data ?? []; + } + + Future deleteAttachment(String grnId, String attachmentId) async { + await dio.delete(ApiEndpoints.grnAttachmentById(grnId, attachmentId)); + } + Map _queryToMap(GrnListQuery query) { return { 'page': query.page, diff --git a/lib/modules/grn/data/repositories/grn_repository_impl.dart b/lib/modules/grn/data/repositories/grn_repository_impl.dart index eeb0d60..483c6d3 100644 --- a/lib/modules/grn/data/repositories/grn_repository_impl.dart +++ b/lib/modules/grn/data/repositories/grn_repository_impl.dart @@ -54,4 +54,41 @@ class GrnRepositoryImpl implements GrnRepository { Future>> downloadGrnPdf(String id) { return safeApiCall(() => dataSource.downloadGrnPdf(id)); } + + @override + Future>> listAttachments(String grnId) { + return safeApiCall(() => dataSource.listAttachments(grnId)); + } + + @override + Future> uploadAttachment( + String grnId, { + required List bytes, + required String filename, + }) { + return safeApiCall( + () => dataSource.uploadAttachment( + grnId, + bytes: bytes, + filename: filename, + ), + ); + } + + @override + Future>> downloadAttachment( + String grnId, + String attachmentId, + ) { + return safeApiCall( + () => dataSource.downloadAttachment(grnId, attachmentId), + ); + } + + @override + Future> deleteAttachment(String grnId, String attachmentId) { + return safeApiCall( + () => dataSource.deleteAttachment(grnId, attachmentId), + ); + } } diff --git a/lib/modules/grn/domain/repositories/grn_repository.dart b/lib/modules/grn/domain/repositories/grn_repository.dart index 9f61ce6..16cc484 100644 --- a/lib/modules/grn/domain/repositories/grn_repository.dart +++ b/lib/modules/grn/domain/repositories/grn_repository.dart @@ -9,4 +9,16 @@ abstract class GrnRepository { Future> updateGrn(String id, Map data); Future> cancelGrn(String id, {required String cancellationReason}); Future>> downloadGrnPdf(String id); + + Future>> listAttachments(String grnId); + Future> uploadAttachment( + String grnId, { + required List bytes, + required String filename, + }); + Future>> downloadAttachment( + String grnId, + String attachmentId, + ); + Future> deleteAttachment(String grnId, String attachmentId); } diff --git a/lib/modules/grn/presentation/providers/grn_lookups_provider.dart b/lib/modules/grn/presentation/providers/grn_lookups_provider.dart index 272f5ef..45dfe9f 100644 --- a/lib/modules/grn/presentation/providers/grn_lookups_provider.dart +++ b/lib/modules/grn/presentation/providers/grn_lookups_provider.dart @@ -11,13 +11,11 @@ class GrnLookups { const GrnLookups({ this.warehouses = const [], this.receivablePurchaseOrders = const [], - this.assetCategories = const [], this.users = const [], }); final List warehouses; final List receivablePurchaseOrders; - final List assetCategories; final List users; } @@ -26,7 +24,6 @@ final grnLookupsProvider = FutureProvider.autoDispose((ref) async { final poRepo = ref.watch(purchaseOrderRepositoryProvider); final warehouses = await _safeOptions(master.listWarehouses); - final assetCategories = await _safeOptions(master.listAssetCategories); final users = await _safeUserOptions(ref); final receivablePos = []; @@ -46,7 +43,6 @@ final grnLookupsProvider = FutureProvider.autoDispose((ref) async { return GrnLookups( warehouses: warehouses, receivablePurchaseOrders: receivablePos, - assetCategories: assetCategories, users: users, ); }); @@ -89,17 +85,6 @@ Future> _safeUserOptions(Ref ref) async { } } -final grnAssetSubcategoriesProvider = - FutureProvider.autoDispose.family, int?>( - (ref, categoryId) async { - if (categoryId == null) return const []; - final master = ref.watch(masterRemoteDataSourceProvider); - return _safeOptions( - () => master.listAssetSubcategories(assetCategoryId: categoryId), - ); - }, -); - final grnPurchaseOrderProvider = FutureProvider.autoDispose.family((ref, poId) async { final result = diff --git a/lib/modules/grn/presentation/providers/grn_provider.dart b/lib/modules/grn/presentation/providers/grn_provider.dart index c762028..ec5bd29 100644 --- a/lib/modules/grn/presentation/providers/grn_provider.dart +++ b/lib/modules/grn/presentation/providers/grn_provider.dart @@ -152,6 +152,55 @@ class GrnDetailNotifier extends FamilyAsyncNotifier { if (result.failure != null) throw result.failure!; return result.data ?? []; } + + Future uploadAttachment({ + required List bytes, + required String filename, + }) async { + final repository = ref.read(grnRepositoryProvider); + final result = await repository.uploadAttachment( + arg, + bytes: bytes, + filename: filename, + ); + if (result.failure != null) throw result.failure!; + final current = state.valueOrNull; + if (current != null) { + state = AsyncData( + current.copyWith( + attachments: [...current.attachments, result.data!], + ), + ); + } else { + await reload(); + } + return result.data!; + } + + Future> downloadAttachment(String attachmentId) async { + final repository = ref.read(grnRepositoryProvider); + final result = await repository.downloadAttachment(arg, attachmentId); + if (result.failure != null) throw result.failure!; + return result.data ?? []; + } + + Future deleteAttachment(String attachmentId) async { + final repository = ref.read(grnRepositoryProvider); + final result = await repository.deleteAttachment(arg, attachmentId); + if (result.failure != null) throw result.failure!; + final current = state.valueOrNull; + if (current != null) { + state = AsyncData( + current.copyWith( + attachments: current.attachments + .where((a) => a.id != attachmentId) + .toList(), + ), + ); + } else { + await reload(); + } + } } final grnFormProvider = diff --git a/lib/modules/grn/presentation/screens/grn_detail_screen.dart b/lib/modules/grn/presentation/screens/grn_detail_screen.dart index 73f163d..d0a1a1e 100644 --- a/lib/modules/grn/presentation/screens/grn_detail_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_detail_screen.dart @@ -10,13 +10,12 @@ import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/utils/file_download_helper.dart'; -import '../../../../shared/widgets/app_card.dart'; -import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_text_field.dart'; +import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/error_view.dart'; -import '../../../../shared/widgets/page_header.dart'; import '../providers/grn_lookups_provider.dart'; import '../providers/grn_provider.dart'; +import '../widgets/grn_attachments_card.dart'; import '../widgets/grn_line_items_editor.dart'; import '../widgets/grn_status_chip.dart'; @@ -35,120 +34,93 @@ class _GrnDetailScreenState extends ConsumerState { @override Widget build(BuildContext context) { final detailAsync = ref.watch(grnDetailProvider(widget.grnId)); + final lookupsAsync = ref.watch(grnLookupsProvider); final canEdit = ref.can('grn', PermissionAction.update); + final canDelete = ref.can('grn', PermissionAction.delete); final canExport = ref.can('grn', PermissionAction.export); return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.go(RouteConstants.grn), - ), - title: detailAsync.maybeWhen( - data: (grn) => Text(grn.grnNumber ?? 'GRN #${grn.id}'), - orElse: () => const Text('GRN'), - ), - ), body: detailAsync.when( loading: () => const AppLoadingView(message: 'Loading GRN...'), error: (e, _) => ErrorView.fromFailure( e is Failure ? e : Failure.unknown(message: e.toString()), onRetry: () => ref.invalidate(grnDetailProvider(widget.grnId)), ), - data: (grn) => SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 1200), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PageHeader( - title: grn.grnNumber ?? 'GRN #${grn.id}', - subtitle: - 'PO ${grn.poNumber ?? '—'} · ${grn.vendorName ?? '—'}', - actions: [ - if (canExport) - OutlinedButton.icon( - onPressed: _isWorking ? null : () => _downloadPdf(grn), - icon: const Icon(Icons.picture_as_pdf_outlined), - label: const Text('PDF'), - ), - if (canEdit && grn.canEdit) ...[ - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: _isWorking - ? null - : () => context.push( - '${RouteConstants.grn}/${grn.id}/edit', - ), - icon: const Icon(Icons.edit_outlined), - label: const Text('Edit'), - ), - ], - if (canEdit && grn.canCancel) ...[ - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: _isWorking ? null : () => _cancel(grn), - icon: const Icon(Icons.block_outlined), - label: const Text('Cancel'), - ), - ], - ], - ), - const SizedBox(height: 16), - GrnStatusChip(status: grn.status), - if (grn.cancellationReason != null && - grn.cancellationReason!.isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - 'Cancellation reason: ${grn.cancellationReason}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ], - const SizedBox(height: 16), - _OverviewCard(grn: grn), - const SizedBox(height: 16), - AppCard( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Line Items', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 16), - GrnItemsTable(items: grn.items), - ], + data: (grn) { + final lookups = lookupsAsync.asData?.value; + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DetailHeader( + grn: grn, + isWorking: _isWorking, + canEdit: canEdit, + canExport: canExport, + onBack: () => context.go(RouteConstants.grn), + onPdf: () => _downloadPdf(grn), + onEdit: () => context.push( + '${RouteConstants.grn}/${grn.id}/edit', ), + onCancel: () => _cancel(grn), ), - ), - ], + if (grn.cancellationReason != null && + grn.cancellationReason!.isNotEmpty) ...[ + const SizedBox(height: 12), + _CancellationBanner(reason: grn.cancellationReason!), + ], + const SizedBox(height: 16), + _ReceiptDetailsCard(grn: grn, lookups: lookups), + const SizedBox(height: 16), + _LineItemsCard(grn: grn), + const SizedBox(height: 16), + GrnAttachmentsCard( + grn: grn, + canUpload: canEdit, + canDelete: canDelete, + ), + if (grn.remarks?.trim().isNotEmpty == true) ...[ + const SizedBox(height: 16), + _SectionCard( + title: 'REMARKS', + child: Text( + grn.remarks!.trim(), + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + const SizedBox(height: 20), + _DetailFooter(grn: grn), + ], + ), ), ), - ), - ), + ); + }, ), ); } - Future _runWorkflow(Future Function() action, String success) async { + Future _runWorkflow( + Future Function() action, + String success, + ) async { setState(() => _isWorking = true); try { await action(); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(success))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(success))); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); } } finally { if (mounted) setState(() => _isWorking = false); @@ -164,7 +136,9 @@ class _GrnDetailScreenState extends ConsumerState { content: Column( mainAxisSize: MainAxisSize.min, children: [ - Text('Cancel ${grn.grnNumber ?? grn.id}? This will reverse PO receipts.'), + Text( + 'Cancel ${grn.grnNumber ?? grn.id}? This will reverse PO receipts.', + ), const SizedBox(height: 16), AppTextField( label: 'Cancellation reason *', @@ -201,8 +175,9 @@ class _GrnDetailScreenState extends ConsumerState { Future _downloadPdf(GrnModel grn) async { await _runWorkflow(() async { - final bytes = - await ref.read(grnDetailProvider(widget.grnId).notifier).downloadPdf(); + final bytes = await ref + .read(grnDetailProvider(widget.grnId).notifier) + .downloadPdf(); await downloadFile( bytes: bytes, fileName: '${grn.grnNumber ?? 'GRN-${grn.id}'}.pdf', @@ -211,122 +186,381 @@ class _GrnDetailScreenState extends ConsumerState { } } -class _OverviewCard extends ConsumerWidget { - const _OverviewCard({required this.grn}); - - final GrnModel grn; - - String _userLabel(List users, int? userId) { - if (userId == null) return '—'; - final match = users.where((u) => int.tryParse(u.id) == userId); - if (match.isNotEmpty) return match.first.name; - return 'User #$userId'; - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final users = ref.watch(grnLookupsProvider).maybeWhen( - data: (lookups) => lookups.users, - orElse: () => const [], - ); - - return AppCard( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Overview', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 12), - _GrnInfoGrid( - columns: 4, - items: [ - _GrnInfo('GRN Date', DateFormatter.displayDate(grn.grnDate)), - _GrnInfo('PO Number', grn.poNumber ?? '—'), - _GrnInfo('Vendor', grn.vendorName ?? '—'), - _GrnInfo('Warehouse', grn.warehouseName ?? '—'), - _GrnInfo('Vendor Invoice No', grn.vendorInvoiceNo ?? '—'), - _GrnInfo( - 'Vendor Invoice Date', - DateFormatter.displayDate(grn.vendorInvoiceDate), - ), - _GrnInfo( - 'Vendor Invoice Amount', - grn.vendorInvoiceAmount != null - ? CurrencyFormatter.format(grn.vendorInvoiceAmount!) - : '—', - ), - _GrnInfo('Vehicle No', grn.vehicleNo ?? '—'), - _GrnInfo('LR No', grn.lrNo ?? '—'), - _GrnInfo('LR Date', DateFormatter.displayDate(grn.lrDate)), - _GrnInfo('Received By', _userLabel(users, grn.receivedBy)), - _GrnInfo( - 'Quality Checked By', - _userLabel(users, grn.qualityCheckedBy), - ), - _GrnInfo('Remarks', grn.remarks?.trim().isNotEmpty == true - ? grn.remarks! - : '—'), - ], - ), - ], - ), - ), - ); +String _userLabel(List? users, int? userId) { + if (userId == null || users == null) return '—'; + for (final user in users) { + if (int.tryParse(user.id) == userId) return user.name; } + return 'User #$userId'; } -class _GrnInfoGrid extends StatelessWidget { - const _GrnInfoGrid({ - required this.items, - this.columns = 4, +String _displayOrDash(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) return '—'; + return trimmed; +} + +class _DetailHeader extends StatelessWidget { + const _DetailHeader({ + required this.grn, + required this.isWorking, + required this.canEdit, + required this.canExport, + required this.onBack, + required this.onPdf, + required this.onEdit, + required this.onCancel, }); - final List<_GrnInfo> items; - final int columns; + final GrnModel grn; + final bool isWorking; + final bool canEdit; + final bool canExport; + final VoidCallback onBack; + final VoidCallback onPdf; + final VoidCallback onEdit; + final VoidCallback onCancel; @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final subtitleParts = [ + if (grn.poNumber?.trim().isNotEmpty == true) 'PO ${grn.poNumber!.trim()}', + if (grn.vendorName?.trim().isNotEmpty == true) grn.vendorName!.trim(), + if (grn.warehouseName?.trim().isNotEmpty == true) + grn.warehouseName!.trim(), + ]; + + final actions = Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.end, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (canExport) + _HeaderActionButton( + label: 'PDF', + icon: Icons.description_outlined, + onPressed: isWorking ? null : onPdf, + ), + if (canEdit && grn.canEdit) + _HeaderActionButton( + label: 'Edit', + icon: Icons.edit_outlined, + onPressed: isWorking ? null : onEdit, + ), + if (canEdit && grn.canCancel) + _HeaderActionButton( + label: 'Cancel', + icon: Icons.block_outlined, + destructive: true, + onPressed: isWorking ? null : onCancel, + ), + ], + ); + + final titleBlock = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + grn.grnNumber ?? 'GRN #${grn.id}', + style: theme.textTheme.headlineSmall, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 10), + GrnStatusChip(status: grn.status, compact: true), + ], + ), + if (subtitleParts.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitleParts.join(' · '), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ); + return LayoutBuilder( builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - final cols = maxWidth < 600 - ? 1 - : maxWidth < 900 - ? 2 - : columns; - const spacing = 16.0; - final colWidth = (maxWidth - spacing * (cols - 1)) / cols; - - return Wrap( - spacing: spacing, - runSpacing: 16, - children: items - .map( - (item) => SizedBox( - width: colWidth, - child: _GrnDetailTile( - label: item.label, - value: item.value, + final stack = constraints.maxWidth < 800; + if (stack) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + tooltip: 'Back', + onPressed: onBack, + icon: const Icon(Icons.arrow_back), ), - ), - ) - .toList(), + const SizedBox(width: 4), + Expanded(child: titleBlock), + ], + ), + const SizedBox(height: 12), + actions, + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + tooltip: 'Back', + onPressed: onBack, + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: 4), + Expanded(child: titleBlock), + const SizedBox(width: 12), + actions, + ], ); }, ); } } -class _GrnDetailTile extends StatelessWidget { - const _GrnDetailTile({ +class _HeaderActionButton extends StatelessWidget { + const _HeaderActionButton({ + required this.label, + required this.icon, + required this.onPressed, + this.destructive = false, + }); + + final String label; + final IconData icon; + final VoidCallback? onPressed; + final bool destructive; + + static const double _height = 40; + static const double _radius = 8; + static const EdgeInsets _padding = + EdgeInsets.symmetric(horizontal: 14, vertical: 0); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final error = theme.colorScheme.error; + + final style = ButtonStyle( + minimumSize: const WidgetStatePropertyAll(Size(0, _height)), + fixedSize: const WidgetStatePropertyAll(Size.fromHeight(_height)), + padding: const WidgetStatePropertyAll(_padding), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(_radius)), + ), + visualDensity: VisualDensity.standard, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + + final child = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18), + const SizedBox(width: 8), + Text(label), + ], + ); + + if (destructive) { + return OutlinedButton( + onPressed: onPressed, + style: style.copyWith( + foregroundColor: WidgetStatePropertyAll(error), + side: WidgetStatePropertyAll(BorderSide(color: error)), + ), + child: child, + ); + } + + return OutlinedButton( + onPressed: onPressed, + style: style, + child: child, + ); + } +} + +class _CancellationBanner extends StatelessWidget { + const _CancellationBanner({required this.reason}); + + final String reason; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.35), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: theme.colorScheme.error, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Cancellation reason: $reason', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + ], + ), + ); + } +} + +class _SectionCard extends StatelessWidget { + const _SectionCard({ + required this.title, + required this.child, + }); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 16), + child, + ], + ), + ); + } +} + +class _ReceiptDetailsCard extends StatelessWidget { + const _ReceiptDetailsCard({ + required this.grn, + required this.lookups, + }); + + final GrnModel grn; + final GrnLookups? lookups; + + @override + Widget build(BuildContext context) { + return _SectionCard( + title: 'RECEIPT DETAILS', + child: LayoutBuilder( + builder: (context, constraints) { + final cols = constraints.maxWidth < 600 + ? 1 + : constraints.maxWidth < 900 + ? 2 + : 4; + const spacing = 20.0; + final width = (constraints.maxWidth - spacing * (cols - 1)) / cols; + final items = [ + _DetailField( + label: 'GRN date', + value: DateFormatter.displayDate(grn.grnDate), + ), + _DetailField( + label: 'PO number', + value: _displayOrDash(grn.poNumber), + ), + _DetailField( + label: 'Vendor', + value: _displayOrDash(grn.vendorName), + ), + _DetailField( + label: 'Warehouse', + value: _displayOrDash(grn.warehouseName), + ), + _DetailField( + label: 'Vendor invoice no', + value: _displayOrDash(grn.vendorInvoiceNo), + ), + _DetailField( + label: 'Vendor invoice date', + value: DateFormatter.displayDate(grn.vendorInvoiceDate), + ), + _DetailField( + label: 'Vendor invoice amount', + value: grn.vendorInvoiceAmount != null + ? CurrencyFormatter.format(grn.vendorInvoiceAmount!) + : '—', + ), + _DetailField( + label: 'Vehicle no', + value: _displayOrDash(grn.vehicleNo), + ), + _DetailField( + label: 'LR no', + value: _displayOrDash(grn.lrNo), + ), + _DetailField( + label: 'LR date', + value: DateFormatter.displayDate(grn.lrDate), + ), + _DetailField( + label: 'Received by', + value: _userLabel(lookups?.users, grn.receivedBy), + ), + _DetailField( + label: 'Quality checked by', + value: _userLabel(lookups?.users, grn.qualityCheckedBy), + ), + ]; + + return Wrap( + spacing: spacing, + runSpacing: 16, + children: items + .map((item) => SizedBox(width: width, child: item)) + .toList(), + ); + }, + ), + ); + } +} + +class _DetailField extends StatelessWidget { + const _DetailField({ required this.label, required this.value, }); @@ -337,7 +571,6 @@ class _GrnDetailTile extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -349,15 +582,53 @@ class _GrnDetailTile extends StatelessWidget { ), ), const SizedBox(height: 4), - Text(value, style: theme.textTheme.bodyLarge), + Text( + value, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), ], ); } } -class _GrnInfo { - const _GrnInfo(this.label, this.value); +class _LineItemsCard extends StatelessWidget { + const _LineItemsCard({required this.grn}); - final String label; - final String value; + final GrnModel grn; + + @override + Widget build(BuildContext context) { + return _SectionCard( + title: 'LINE ITEMS · ${grn.items.length}', + child: GrnItemsTable(items: grn.items), + ); + } +} + +class _DetailFooter extends StatelessWidget { + const _DetailFooter({required this.grn}); + + final GrnModel grn; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final parts = [ + if (grn.createdAt != null) + 'Created ${DateFormatter.displayDateTime(grn.createdAt)}', + if (grn.updatedAt != null) + 'Updated ${DateFormatter.displayDateTime(grn.updatedAt)}', + ]; + + if (parts.isEmpty) return const SizedBox.shrink(); + + return Text( + parts.join(' · '), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ); + } } diff --git a/lib/modules/grn/presentation/screens/grn_form_screen.dart b/lib/modules/grn/presentation/screens/grn_form_screen.dart index 570d01a..975fadc 100644 --- a/lib/modules/grn/presentation/screens/grn_form_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_form_screen.dart @@ -14,13 +14,13 @@ import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; +import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/error_view.dart'; -import '../../../../shared/widgets/app_side_panel.dart'; -import '../../../../shared/widgets/page_header.dart'; import '../providers/grn_lookups_provider.dart'; import '../providers/grn_provider.dart'; import '../widgets/grn_line_items_editor.dart'; +import '../widgets/grn_status_chip.dart'; class GrnFormScreen extends ConsumerStatefulWidget { const GrnFormScreen({super.key, this.grnId}); @@ -58,6 +58,9 @@ class _GrnFormScreenState extends ConsumerState { super.initState(); if (!widget.isEditing) { _grnDate = DateTime.now(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) ref.invalidate(grnLookupsProvider); + }); } } @@ -90,8 +93,8 @@ class _GrnFormScreenState extends ConsumerState { _vehicleNoController.text = grn.vehicleNo ?? ''; _lrNoController.text = grn.lrNo ?? ''; _lrDate = grn.lrDate; - _receivedById = grn.receivedBy; - _qualityCheckedById = grn.qualityCheckedBy; + _receivedById = _normalizeUserId(grn.receivedBy); + _qualityCheckedById = _normalizeUserId(grn.qualityCheckedBy); _remarksController.text = grn.remarks ?? ''; }); } @@ -112,11 +115,18 @@ class _GrnFormScreenState extends ConsumerState { int? _parseId(String value) => int.tryParse(value.trim()); + int? _normalizeUserId(int? id) => id != null && id > 0 ? id : null; + int? _dropdownValue(int? selected, Iterable validIds) { if (selected == null) return null; return validIds.contains(selected) ? selected : null; } + void _putOptionalUserId(Map payload, String key, int? id) { + final normalized = _normalizeUserId(id); + if (normalized != null) payload[key] = normalized; + } + List> _intOptions(List options) { return options .map((e) { @@ -130,7 +140,7 @@ class _GrnFormScreenState extends ConsumerState { Map _buildCreatePayload() { final poId = int.tryParse(_selectedPoId ?? ''); - return { + final payload = { 'grn_date': DateFormatter.toApiDate(_grnDate ?? DateTime.now()), 'po_id': poId, 'warehouse_id': _warehouseId, @@ -143,17 +153,19 @@ class _GrnFormScreenState extends ConsumerState { double.tryParse(_vendorInvoiceAmountController.text.trim()), if (_vehicleNoController.text.trim().isNotEmpty) 'vehicle_no': _vehicleNoController.text.trim(), - if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(), + if (_lrNoController.text.trim().isNotEmpty) + 'lr_no': _lrNoController.text.trim(), if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!), - if (_receivedById != null) 'received_by': _receivedById, - if (_qualityCheckedById != null) 'quality_checked_by': _qualityCheckedById, - 'remarks': _remarksController.text.trim(), - 'items': _lines.map((line) => line.toPayload()).toList(), }; + _putOptionalUserId(payload, 'received_by', _receivedById); + _putOptionalUserId(payload, 'quality_checked_by', _qualityCheckedById); + payload['remarks'] = _remarksController.text.trim(); + payload['items'] = _lines.map((line) => line.toPayload()).toList(); + return payload; } Map _buildUpdatePayload() { - return { + final payload = { if (_vendorInvoiceNoController.text.trim().isNotEmpty) 'vendor_invoice_no': _vendorInvoiceNoController.text.trim(), if (_vendorInvoiceDate != null) @@ -163,12 +175,14 @@ class _GrnFormScreenState extends ConsumerState { double.tryParse(_vendorInvoiceAmountController.text.trim()), if (_vehicleNoController.text.trim().isNotEmpty) 'vehicle_no': _vehicleNoController.text.trim(), - if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(), + if (_lrNoController.text.trim().isNotEmpty) + 'lr_no': _lrNoController.text.trim(), if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!), - if (_receivedById != null) 'received_by': _receivedById, - if (_qualityCheckedById != null) 'quality_checked_by': _qualityCheckedById, - 'remarks': _remarksController.text.trim(), }; + _putOptionalUserId(payload, 'received_by', _receivedById); + _putOptionalUserId(payload, 'quality_checked_by', _qualityCheckedById); + payload['remarks'] = _remarksController.text.trim(); + return payload; } String? _lineItemsError() { @@ -180,6 +194,10 @@ class _GrnFormScreenState extends ConsumerState { if (line.acceptedQty < 0) { return 'Accepted quantity must be zero or more for line ${line.lineNo}'; } + if (line.rejectedQty > 0 && + line.rejectionReasonController.text.trim().isEmpty) { + return 'Rejection reason is required for line ${line.lineNo}'; + } } return null; } @@ -206,7 +224,8 @@ class _GrnFormScreenState extends ConsumerState { } final lineError = _lineItemsError(); if (lineError != null) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(lineError))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(lineError))); return; } } @@ -241,7 +260,8 @@ class _GrnFormScreenState extends ConsumerState { if (!mounted) return; final message = e is Failure ? validationErrorMessage(e) : e.toString(); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); } finally { if (mounted) setState(() => _isSubmitting = false); } @@ -260,6 +280,17 @@ class _GrnFormScreenState extends ConsumerState { if (picked != null) onPicked(picked); } + void _goBack(GrnModel? existing) { + if (_isSubmitting) return; + if (widget.isEditing && existing != null) { + context.go('${RouteConstants.grn}/${existing.id}'); + } else if (widget.isEditing) { + context.go('${RouteConstants.grn}/${widget.grnId}'); + } else { + context.go(RouteConstants.grn); + } + } + @override Widget build(BuildContext context) { final lookupsAsync = ref.watch(grnLookupsProvider); @@ -268,33 +299,33 @@ class _GrnFormScreenState extends ConsumerState { : const AsyncData(null); return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.go( - widget.isEditing - ? '${RouteConstants.grn}/${widget.grnId}' - : RouteConstants.grn, - ), - ), - title: Text(widget.isEditing ? 'Edit GRN' : 'Create GRN'), - ), + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: lookupsAsync.when( - loading: () => const AppLoadingView(message: 'Loading form...'), + skipLoadingOnReload: true, + loading: () => lookupsAsync.hasValue + ? _buildFormBody( + lookups: lookupsAsync.value!, + existing: existingAsync.valueOrNull, + ) + : const AppLoadingView(message: 'Loading form...'), error: (e, _) => ErrorView.fromFailure( e is Failure ? e : Failure.unknown(message: e.toString()), onRetry: () => ref.invalidate(grnLookupsProvider), ), data: (lookups) => existingAsync.when( - loading: () => const AppLoadingView(message: 'Loading GRN...'), + skipLoadingOnReload: true, + loading: () => existingAsync.hasValue + ? _buildFormBody( + lookups: lookups, + existing: existingAsync.valueOrNull, + ) + : const AppLoadingView(message: 'Loading GRN...'), error: (e, _) => ErrorView.fromFailure( e is Failure ? e : Failure.unknown(message: e.toString()), onRetry: () => ref.invalidate(grnFormProvider(widget.grnId)), ), - data: (existing) => _buildFormBody(lookups: lookups, existing: existing), + data: (existing) => + _buildFormBody(lookups: lookups, existing: existing), ), ), ); @@ -323,6 +354,8 @@ class _GrnFormScreenState extends ConsumerState { final warehouseIds = lookups.warehouses.map((e) => _parseId(e.id)).whereType(); + final userIds = + lookups.users.map((e) => _parseId(e.id)).whereType(); final poOptions = lookups.receivablePurchaseOrders .map( (po) => AppDropdownOption( @@ -331,220 +364,401 @@ class _GrnFormScreenState extends ConsumerState { ), ) .toList(); + final theme = Theme.of(context); return SingleChildScrollView( controller: _scrollController, - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), child: Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1200), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!widget.isEditing) - const PageHeader( - title: 'Create Goods Received Note', - subtitle: 'Receive items against an approved purchase order', - ) - else if (existing?.grnNumber != null) - Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Text( - existing!.grnNumber!, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ResponsiveFormGrid( - children: [ - _DateField( - label: 'GRN Date *', - value: _grnDate, - enabled: !widget.isEditing, - onTap: widget.isEditing - ? null - : () => _pickDate( - current: _grnDate, - onPicked: (d) => setState(() => _grnDate = d), - ), - ), - if (!widget.isEditing) - AppSearchableDropdown( - label: 'Purchase Order *', - value: _selectedPoId, - searchHint: 'Search PO...', - isDense: true, - options: poOptions, - onChanged: (v) async { - setState(() => _selectedPoId = v); - if (v == null) { - for (final line in _lines) { - line.dispose(); + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(existing), + const SizedBox(height: 16), + _SectionCard( + title: 'RECEIPT DETAILS', + child: Column( + children: [ + FormRowFour( + children: [ + _DateField( + label: 'GRN date *', + value: _grnDate, + enabled: !widget.isEditing, + onTap: widget.isEditing + ? null + : () => _pickDate( + current: _grnDate, + onPicked: (d) => + setState(() => _grnDate = d), + ), + ), + if (!widget.isEditing) + AppSearchableDropdown( + label: 'Purchase order *', + value: _selectedPoId, + hint: 'Select PO', + searchHint: 'Search PO...', + options: poOptions, + onChanged: (v) async { + setState(() => _selectedPoId = v); + if (v == null) { + for (final line in _lines) { + line.dispose(); + } + setState(() => _lines.clear()); + return; } - setState(() => _lines.clear()); - return; - } - try { - final po = await ref.read( - grnPurchaseOrderProvider(v).future, - ); - if (mounted && po != null) _loadLinesFromPo(po); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString())), - ); - } - }, - validator: (v) => - v == null ? 'Purchase order is required' : null, - ) - else - Padding( - padding: const EdgeInsets.only(top: 8), - child: InputDecorator( - decoration: const InputDecoration( - labelText: 'Purchase Order', - floatingLabelBehavior: FloatingLabelBehavior.always, - isDense: true, - ), - child: Text(existing?.poNumber ?? '—'), + try { + final po = await ref.read( + grnPurchaseOrderProvider(v).future, + ); + if (mounted && po != null) { + _loadLinesFromPo(po); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString())), + ); + } + }, + validator: (v) => v == null + ? 'Purchase order is required' + : null, + ) + else + _ReadOnlyField( + label: 'Purchase order', + value: existing?.poNumber ?? '—', ), + AppSearchableDropdown( + label: 'Warehouse *', + value: _dropdownValue(_warehouseId, warehouseIds), + hint: 'Select warehouse', + searchHint: 'Search warehouse...', + options: _intOptions(lookups.warehouses), + onChanged: widget.isEditing + ? (_) {} + : (v) => setState(() => _warehouseId = v), + validator: widget.isEditing + ? null + : (v) => + v == null ? 'Warehouse is required' : null, + enabled: !widget.isEditing, ), - AppSearchableDropdown( - label: 'Warehouse *', - value: _dropdownValue(_warehouseId, warehouseIds), - searchHint: 'Search warehouse...', - isDense: true, - options: _intOptions(lookups.warehouses), - onChanged: widget.isEditing - ? (_) {} - : (v) => setState(() => _warehouseId = v), - validator: widget.isEditing - ? null - : (v) => v == null ? 'Warehouse is required' : null, - enabled: !widget.isEditing, - ), - AppTextField( - label: 'Vendor Invoice No', - controller: _vendorInvoiceNoController, - isDense: true, - ), - _DateField( - label: 'Vendor Invoice Date', - value: _vendorInvoiceDate, - onTap: () => _pickDate( - current: _vendorInvoiceDate, - onPicked: (d) => - setState(() => _vendorInvoiceDate = d), + AppTextField( + label: 'Vendor invoice no', + controller: _vendorInvoiceNoController, ), - ), - AppTextField( - label: 'Vendor Invoice Amount', - controller: _vendorInvoiceAmountController, - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - isDense: true, - ), - AppTextField( - label: 'Vehicle No', - controller: _vehicleNoController, - isDense: true, - ), - AppTextField( - label: 'LR No', - controller: _lrNoController, - isDense: true, - ), - _DateField( - label: 'LR Date', - value: _lrDate, - onTap: () => _pickDate( - current: _lrDate, - onPicked: (d) => setState(() => _lrDate = d), - ), - ), - AppSearchableDropdown( - label: 'Received By', - value: _dropdownValue( - _receivedById, - lookups.users.map((e) => _parseId(e.id)).whereType(), - ), - searchHint: 'Search user...', - isDense: true, - options: _intOptions(lookups.users), - onChanged: (v) => setState(() => _receivedById = v), - ), - AppSearchableDropdown( - label: 'Quality Checked By', - value: _dropdownValue( - _qualityCheckedById, - lookups.users.map((e) => _parseId(e.id)).whereType(), - ), - searchHint: 'Search user...', - isDense: true, - options: _intOptions(lookups.users), - onChanged: (v) => - setState(() => _qualityCheckedById = v), - ), - AppTextField( - label: 'Remarks', - controller: _remarksController, - maxLines: 3, - isDense: true, - ), - ], - ), - if (!widget.isEditing) ...[ - const SizedBox(height: 24), - Text( - 'Line Items', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + ], ), - const SizedBox(height: 12), - GrnLineItemsEditor( - items: _lines, - onChanged: () => setState(() {}), - ), - ] else ...[ - const SizedBox(height: 16), - Text( - 'Line items cannot be changed after posting.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + FormRowFour( + children: [ + _DateField( + label: 'Vendor invoice date', + value: _vendorInvoiceDate, + onTap: () => _pickDate( + current: _vendorInvoiceDate, + onPicked: (d) => + setState(() => _vendorInvoiceDate = d), ), + ), + AppTextField( + label: 'Vendor invoice amount', + controller: _vendorInvoiceAmountController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + ), + AppTextField( + label: 'Vehicle no', + controller: _vehicleNoController, + ), + AppTextField( + label: 'LR no', + controller: _lrNoController, + ), + ], + ), + FormRowFour( + children: [ + _DateField( + label: 'LR date', + value: _lrDate, + onTap: () => _pickDate( + current: _lrDate, + onPicked: (d) => setState(() => _lrDate = d), + ), + ), + AppSearchableDropdown( + label: 'Received by', + value: _dropdownValue( + _normalizeUserId(_receivedById), + userIds, + ), + hint: 'Select user', + searchHint: 'Search user...', + options: _intOptions(lookups.users), + onChanged: (v) => + setState(() => _receivedById = v), + ), + AppSearchableDropdown( + label: 'Quality checked by', + value: _dropdownValue( + _normalizeUserId(_qualityCheckedById), + userIds, + ), + hint: 'Select user', + searchHint: 'Search user...', + options: _intOptions(lookups.users), + onChanged: (v) => + setState(() => _qualityCheckedById = v), + ), + const SizedBox.shrink(), + ], ), ], - const SizedBox(height: 24), - Row( + ), + ), + const SizedBox(height: 16), + if (!widget.isEditing) + GrnLineItemsEditor( + items: _lines, + onChanged: () => setState(() {}), + ) + else ...[ + _SectionCard( + title: 'LINE ITEMS · ${existing?.items.length ?? 0}', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( - child: AppButton( - label: widget.isEditing ? 'Save Changes' : 'Create GRN', - onPressed: _isSubmitting ? null : _submit, - isLoading: _isSubmitting, + Text( + 'Line items cannot be changed after posting.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), ), + if (existing?.items.isNotEmpty == true) ...[ + const SizedBox(height: 12), + GrnItemsTable(items: existing!.items), + ], ], ), + ), + ], + const SizedBox(height: 16), + _SectionCard( + title: 'ADDITIONAL DETAILS', + child: AppTextField( + controller: _remarksController, + label: 'Remarks', + hint: 'Any additional notes for this receipt.', + maxLines: 4, + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Text( + 'Fields marked * are required', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + Text( + widget.isEditing + ? '${existing?.items.length ?? 0} line item${(existing?.items.length ?? 0) == 1 ? '' : 's'} · editing header only' + : '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), ], ), - ), - ], + ], + ), ), ), ), ); } + + Widget _buildHeader(GrnModel? existing) { + final theme = Theme.of(context); + final title = widget.isEditing + ? 'Edit ${existing?.grnNumber ?? 'GRN'}' + : 'Create goods received note'; + final subtitle = widget.isEditing + ? null + : 'Select an approved purchase order, enter receipt details, then confirm quantities.'; + + final actions = Row( + mainAxisSize: MainAxisSize.min, + children: [ + OutlinedButton( + onPressed: _isSubmitting ? null : () => _goBack(existing), + child: const Text('Cancel'), + ), + const SizedBox(width: 12), + AppButton( + label: widget.isEditing ? 'Update GRN' : 'Save GRN', + icon: Icons.check, + expand: false, + isLoading: _isSubmitting, + onPressed: _isSubmitting ? null : _submit, + ), + ], + ); + + final titleBlock = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + Text(title, style: theme.textTheme.headlineSmall), + if (widget.isEditing && existing != null) + GrnStatusChip(status: existing.status, compact: true), + ], + ), + if (subtitle != null) ...[ + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: LayoutBuilder( + builder: (context, constraints) { + final stack = constraints.maxWidth < 720; + if (stack) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + tooltip: 'Back', + onPressed: + _isSubmitting ? null : () => _goBack(existing), + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: 4), + Expanded(child: titleBlock), + ], + ), + const SizedBox(height: 12), + Align(alignment: Alignment.centerRight, child: actions), + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + tooltip: 'Back', + onPressed: _isSubmitting ? null : () => _goBack(existing), + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: 4), + Expanded(child: titleBlock), + const SizedBox(width: 12), + actions, + ], + ); + }, + ), + ); + } +} + +class _SectionCard extends StatelessWidget { + const _SectionCard({ + required this.title, + required this.child, + }); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final cardColor = isDark + ? theme.colorScheme.surfaceContainerHighest + : theme.colorScheme.surface; + final borderColor = theme.colorScheme.outline.withValues( + alpha: isDark ? 0.35 : 0.2, + ); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 12), + child, + ], + ), + ); + } +} + +class _ReadOnlyField extends StatelessWidget { + const _ReadOnlyField({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + child: Text(value), + ), + ); + } } class _DateField extends StatelessWidget { @@ -571,16 +785,15 @@ class _DateField extends StatelessWidget { decoration: InputDecoration( labelText: label, floatingLabelBehavior: FloatingLabelBehavior.always, - isDense: true, suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18), enabled: enabled, ), child: Text( value != null ? DateFormatter.displayDate(value) : 'Select date', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: value != null - ? null - : Theme.of(context).hintColor, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: value == null + ? Theme.of(context).colorScheme.onSurfaceVariant + : null, ), ), ), diff --git a/lib/modules/grn/presentation/widgets/grn_attachments_card.dart b/lib/modules/grn/presentation/widgets/grn_attachments_card.dart new file mode 100644 index 0000000..a7724ca --- /dev/null +++ b/lib/modules/grn/presentation/widgets/grn_attachments_card.dart @@ -0,0 +1,371 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/errors/failure.dart'; +import '../../../../core/network/api_handler.dart'; +import '../../../../core/utils/formatters.dart'; +import '../../../../shared/models/grn_model.dart'; +import '../../../../shared/utils/file_download_helper.dart'; +import '../../../../shared/widgets/app_confirmation_dialog.dart'; +import '../providers/grn_provider.dart'; + +const _allowedExtensions = ['pdf', 'jpg', 'jpeg', 'png', 'webp']; + +String _formatFileSize(int? bytes) { + if (bytes == null || bytes <= 0) return '—'; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; +} + +IconData _fileIcon(GrnAttachmentModel attachment) { + if (attachment.isPdf) return Icons.picture_as_pdf_outlined; + if (attachment.isImage) return Icons.image_outlined; + return Icons.insert_drive_file_outlined; +} + +/// Attachments section for GRN detail — list / upload / download / delete. +class GrnAttachmentsCard extends ConsumerStatefulWidget { + const GrnAttachmentsCard({ + super.key, + required this.grn, + required this.canUpload, + required this.canDelete, + }); + + final GrnModel grn; + final bool canUpload; + final bool canDelete; + + @override + ConsumerState createState() => _GrnAttachmentsCardState(); +} + +class _GrnAttachmentsCardState extends ConsumerState { + bool _isUploading = false; + String? _busyAttachmentId; + + bool get _canManage => + widget.grn.canManageAttachments && (widget.canUpload || widget.canDelete); + + Future _upload() async { + if (!widget.canUpload || !widget.grn.canManageAttachments) return; + + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: _allowedExtensions, + withData: true, + ); + if (result == null || result.files.isEmpty) return; + + final file = result.files.single; + final bytes = file.bytes; + if (bytes == null || bytes.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not read the selected file')), + ); + return; + } + + final ext = (file.extension ?? '').toLowerCase(); + if (!_allowedExtensions.contains(ext)) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Only PDF, JPEG, PNG, and WebP files are allowed'), + ), + ); + return; + } + + setState(() => _isUploading = true); + try { + await ref.read(grnDetailProvider(widget.grn.id).notifier).uploadAttachment( + bytes: bytes, + filename: file.name, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${file.name} uploaded')), + ); + } catch (e) { + if (!mounted) return; + final message = + e is Failure ? validationErrorMessage(e) : e.toString(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } finally { + if (mounted) setState(() => _isUploading = false); + } + } + + Future _download(GrnAttachmentModel attachment) async { + setState(() => _busyAttachmentId = attachment.id); + try { + final bytes = await ref + .read(grnDetailProvider(widget.grn.id).notifier) + .downloadAttachment(attachment.id); + if (bytes.isEmpty) throw Exception('Empty file response'); + await downloadFile( + bytes: bytes, + fileName: attachment.fileName ?? 'grn-attachment-${attachment.id}', + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString())), + ); + } finally { + if (mounted) setState(() => _busyAttachmentId = null); + } + } + + Future _delete(GrnAttachmentModel attachment) async { + if (!widget.canDelete || !widget.grn.canManageAttachments) return; + + final confirmed = await showAppConfirmationDialog( + context: context, + title: 'Delete attachment', + message: + 'Delete ${attachment.fileName ?? 'this file'}? This cannot be undone.', + confirmLabel: 'Delete', + isDestructive: true, + ); + if (confirmed != true || !mounted) return; + + setState(() => _busyAttachmentId = attachment.id); + try { + await ref + .read(grnDetailProvider(widget.grn.id).notifier) + .deleteAttachment(attachment.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Attachment deleted')), + ); + } catch (e) { + if (!mounted) return; + final message = + e is Failure ? validationErrorMessage(e) : e.toString(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } finally { + if (mounted) setState(() => _busyAttachmentId = null); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final attachments = widget.grn.attachments; + final showUpload = + widget.canUpload && widget.grn.canManageAttachments; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text( + 'ATTACHMENTS · ${attachments.length}', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + const Spacer(), + if (showUpload) + TextButton.icon( + onPressed: _isUploading ? null : _upload, + icon: _isUploading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.upload_file_outlined, size: 18), + label: Text(_isUploading ? 'Uploading…' : 'Upload'), + ), + ], + ), + const SizedBox(height: 4), + Text( + showUpload + ? 'PDF, JPEG, PNG, or WebP · upload/delete only while GRN is Posted' + : 'Supporting documents for this GRN', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + if (attachments.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.15), + ), + ), + child: Text( + showUpload + ? 'No attachments yet. Upload a vendor invoice, LR copy, or receipt photo.' + : 'No attachments', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + else + ...attachments.asMap().entries.map((entry) { + final index = entry.key; + final attachment = entry.value; + return Padding( + padding: EdgeInsets.only( + bottom: index == attachments.length - 1 ? 0 : 8, + ), + child: _AttachmentRow( + attachment: attachment, + isBusy: _busyAttachmentId == attachment.id, + canDelete: + widget.canDelete && widget.grn.canManageAttachments, + onDownload: () => _download(attachment), + onDelete: () => _delete(attachment), + ), + ); + }), + if (!_canManage && + !widget.grn.canManageAttachments && + attachments.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'This GRN is cancelled — attachments are view/download only.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ); + } +} + +class _AttachmentRow extends StatelessWidget { + const _AttachmentRow({ + required this.attachment, + required this.isBusy, + required this.canDelete, + required this.onDownload, + required this.onDelete, + }); + + final GrnAttachmentModel attachment; + final bool isBusy; + final bool canDelete; + final VoidCallback onDownload; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final borderColor = theme.colorScheme.outline.withValues(alpha: 0.15); + final metaParts = [ + _formatFileSize(attachment.fileSize), + if (attachment.uploadedByName?.trim().isNotEmpty == true) + attachment.uploadedByName!.trim(), + if (attachment.createdAt != null) + DateFormatter.displayDateTime(attachment.createdAt), + ]; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + ), + child: Row( + children: [ + Icon( + _fileIcon(attachment), + size: 22, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + attachment.fileName ?? 'Attachment #${attachment.id}', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (metaParts.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + metaParts.join(' · '), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + if (isBusy) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else ...[ + IconButton( + tooltip: 'Download', + onPressed: onDownload, + icon: const Icon(Icons.download_outlined, size: 20), + visualDensity: VisualDensity.compact, + ), + if (canDelete) + IconButton( + tooltip: 'Delete', + onPressed: onDelete, + icon: Icon( + Icons.delete_outline, + size: 20, + color: theme.colorScheme.error, + ), + visualDensity: VisualDensity.compact, + ), + ], + ], + ), + ); + } +} diff --git a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart index bb9b7e5..0afc379 100644 --- a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart +++ b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart @@ -1,20 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/theme/app_colors.dart'; import '../../../../core/utils/formatters.dart'; -import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/purchase_order_model.dart'; -import '../../../../shared/models/user_management_models.dart'; -import '../../../../shared/widgets/app_data_table.dart'; -import '../../../../shared/widgets/app_dropdown.dart'; -import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_text_field.dart'; -import '../../../assets/presentation/providers/asset_categories_provider.dart'; -import '../providers/grn_lookups_provider.dart'; String _formatQty(double value) { if (value % 1 == 0) return value.toInt().toString(); @@ -38,8 +29,6 @@ class GrnLineItemDraft { TextEditingController? remarksController, this.mfgDate, this.expiryDate, - this.assetCategoryId, - this.assetSubcategoryId, }) : acceptedQtyController = acceptedQtyController ?? TextEditingController( text: remainingQty > 0 ? _formatQty(remainingQty) : '', @@ -68,11 +57,11 @@ class GrnLineItemDraft { final TextEditingController remarksController; DateTime? mfgDate; DateTime? expiryDate; - int? assetCategoryId; - int? assetSubcategoryId; - double get acceptedQty => double.tryParse(acceptedQtyController.text.trim()) ?? 0; - double get rejectedQty => double.tryParse(rejectedQtyController.text.trim()) ?? 0; + double get acceptedQty => + double.tryParse(acceptedQtyController.text.trim()) ?? 0; + double get rejectedQty => + double.tryParse(rejectedQtyController.text.trim()) ?? 0; double get currentQty => acceptedQty + rejectedQty; void dispose() { @@ -92,8 +81,8 @@ class GrnLineItemDraft { 'line_no': lineNo, 'current_qty': currentQty, 'accepted_qty': acceptedQty, - if (rejectedQty > 0) 'rejected_qty': rejectedQty, - if (rejectionReasonController.text.trim().isNotEmpty) + 'rejected_qty': rejectedQty, + if (rejectedQty > 0 && rejectionReasonController.text.trim().isNotEmpty) 'rejection_reason': rejectionReasonController.text.trim(), if (rateController.text.trim().isNotEmpty) 'rate': double.tryParse(rateController.text.trim()), @@ -103,8 +92,6 @@ class GrnLineItemDraft { if (expiryDate != null) 'expiry_date': DateFormatter.toApiDate(expiryDate!), if (storageLocationController.text.trim().isNotEmpty) 'storage_location': storageLocationController.text.trim(), - if (assetCategoryId != null) 'asset_category_id': assetCategoryId, - if (assetSubcategoryId != null) 'asset_subcategory_id': assetSubcategoryId, if (remarksController.text.trim().isNotEmpty) 'remarks': remarksController.text.trim(), }; @@ -145,44 +132,78 @@ class GrnLineItemsEditor extends StatelessWidget { @override Widget build(BuildContext context) { - if (items.isEmpty) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: AppColors.lightSurface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.textSecondary.withValues(alpha: 0.2)), - ), - child: Text( - readOnly - ? 'No line items.' - : 'Select a purchase order to load receivable line items.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppColors.textSecondary, - ), - ), - ); - } + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final cardColor = isDark + ? theme.colorScheme.surfaceContainerHighest + : theme.colorScheme.surface; + final borderColor = theme.colorScheme.outline.withValues( + alpha: isDark ? 0.35 : 0.2, + ); - return Column( - children: [ - for (var i = 0; i < items.length; i++) - Padding( - padding: EdgeInsets.only(bottom: i == items.length - 1 ? 0 : 12), - child: _GrnLineItemCard( - item: items[i], - readOnly: readOnly, - onChanged: onChanged, + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'LINE ITEMS', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, ), ), - ], + const SizedBox(height: 8), + if (items.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + readOnly + ? 'No line items.' + : 'Select a purchase order to load receivable line items.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ) + else + ...items.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + return Padding( + padding: EdgeInsets.only( + bottom: index == items.length - 1 ? 0 : 12, + ), + child: _GrnLineItemCard( + key: ObjectKey(item), + item: item, + readOnly: readOnly, + onChanged: onChanged, + ), + ); + }), + ], + ), ); } } -class _GrnLineItemCard extends ConsumerWidget { +class _GrnLineItemCard extends StatefulWidget { const _GrnLineItemCard({ + super.key, required this.item, required this.onChanged, required this.readOnly, @@ -192,41 +213,35 @@ class _GrnLineItemCard extends ConsumerWidget { final VoidCallback onChanged; final bool readOnly; + @override + State<_GrnLineItemCard> createState() => _GrnLineItemCardState(); +} + +class _GrnLineItemCardState extends State<_GrnLineItemCard> { static final _qtyFormatters = [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,4}')), ]; - int? _dropdownValue(int? selected, Iterable validIds) { - if (selected == null) return null; - return validIds.contains(selected) ? selected : null; + @override + void initState() { + super.initState(); + widget.item.acceptedQtyController.addListener(_onFieldChanged); + widget.item.rejectedQtyController.addListener(_onFieldChanged); } - List> _intOptions(List options) { - return options - .map((e) { - final id = int.tryParse(e.id); - if (id == null) return null; - return AppDropdownOption(value: id, label: e.name); - }) - .whereType>() - .toList(); + @override + void dispose() { + widget.item.acceptedQtyController.removeListener(_onFieldChanged); + widget.item.rejectedQtyController.removeListener(_onFieldChanged); + super.dispose(); } - List> _categoryOptions( - List categories, - ) { - return categories - .map((c) { - final id = int.tryParse(c.id); - if (id == null) return null; - return AppDropdownOption(value: id, label: c.name); - }) - .whereType>() - .toList(); + void _onFieldChanged() { + widget.onChanged(); + setState(() {}); } - Future _pickDate( - BuildContext context, { + Future _pickDate({ required DateTime? current, required ValueChanged onPicked, }) async { @@ -240,201 +255,279 @@ class _GrnLineItemCard extends ConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { - final categoryId = item.assetCategoryId; - final categoriesAsync = ref.watch(assetCategoriesProvider); - final categoryOptions = categoriesAsync.maybeWhen( - data: _categoryOptions, - orElse: () => const >[], + Widget build(BuildContext context) { + final theme = Theme.of(context); + final item = widget.item; + final lineKey = 'grn-line-${item.lineNo}'; + final isDark = theme.brightness == Brightness.dark; + final borderColor = theme.colorScheme.outline.withValues( + alpha: isDark ? 0.35 : 0.18, ); - final categoryIds = categoryOptions.map((e) => e.value); - final subcategoriesAsync = ref.watch(grnAssetSubcategoriesProvider(categoryId)); - final subcategoryOptions = subcategoriesAsync.maybeWhen( - data: _intOptions, - orElse: () => const >[], + final currentBg = theme.colorScheme.primary.withValues( + alpha: isDark ? 0.18 : 0.08, ); - final subcategoryIds = subcategoryOptions.map((e) => e.value); - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.lightSurface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.textSecondary.withValues(alpha: 0.2)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'Line ${item.lineNo}', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - item.itemName, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - 'Ordered: ${_formatQty(item.orderedQty)} · ' - 'Already received: ${_formatQty(item.receivedQty)} · ' - 'Remaining: ${_formatQty(item.remainingQty)}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppColors.textSecondary, - ), - ), - if (!readOnly) ...[ - ResponsiveFormGrid( - children: _buildLineItemFields( - context: context, - categoryOptions: categoryOptions, - categoryIds: categoryIds, - subcategoryOptions: subcategoryOptions, - subcategoryIds: subcategoryIds, - ), + if (widget.readOnly) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _LineItemTitle( + lineNo: item.lineNo, + itemName: item.itemName, + orderedQty: item.orderedQty, + receivedQty: item.receivedQty, + remainingQty: item.remainingQty, ), - ] else ...[ const SizedBox(height: 8), Text( 'Accepted: ${_formatQty(item.acceptedQty)} · ' 'Rejected: ${_formatQty(item.rejectedQty)} · ' 'Current: ${_formatQty(item.currentQty)}', - style: Theme.of(context).textTheme.bodySmall, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), ], + ), + ); + } + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _LineItemTitle( + lineNo: item.lineNo, + itemName: item.itemName, + orderedQty: item.orderedQty, + receivedQty: item.receivedQty, + remainingQty: item.remainingQty, + ), + const SizedBox(height: 8), + FormRow( + columnCount: 12, + spans: const [1, 1, 1, 2, 3, 4], + spacing: 8, + stackBelowWidth: 1100, + children: [ + AppTextField( + key: ValueKey('$lineKey-accepted'), + controller: item.acceptedQtyController, + label: 'Accepted *', + hint: '0', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + inputFormatters: _qtyFormatters, + isDense: true, + ), + AppTextField( + key: ValueKey('$lineKey-rejected'), + controller: item.rejectedQtyController, + label: 'Rejected', + hint: '0', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + inputFormatters: _qtyFormatters, + isDense: true, + ), + _GrnLineReadOnlyField( + key: ValueKey('$lineKey-current'), + label: 'Current', + value: _formatQty(item.currentQty), + backgroundColor: currentBg, + ), + AppTextField( + key: ValueKey('$lineKey-rate'), + controller: item.rateController, + label: 'Rate', + hint: '0.00', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + inputFormatters: _qtyFormatters, + isDense: true, + onChanged: (_) => widget.onChanged(), + ), + AppTextField( + key: ValueKey('$lineKey-batch'), + controller: item.batchNoController, + label: 'Batch No', + isDense: true, + onChanged: (_) => widget.onChanged(), + ), + AppTextField( + key: ValueKey('$lineKey-storage'), + controller: item.storageLocationController, + label: 'Storage', + isDense: true, + onChanged: (_) => widget.onChanged(), + ), + ], + ), + Theme( + data: theme.copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + title: Text( + 'More details', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + children: [ + FormRow( + columnCount: 12, + spans: const [3, 3, 3, 3], + spacing: 8, + stackBelowWidth: 1100, + children: [ + _GrnLineDateField( + key: ValueKey('$lineKey-mfg'), + label: 'Mfg Date', + value: item.mfgDate, + onTap: () => _pickDate( + current: item.mfgDate, + onPicked: (date) { + item.mfgDate = date; + widget.onChanged(); + setState(() {}); + }, + ), + ), + _GrnLineDateField( + key: ValueKey('$lineKey-expiry'), + label: 'Expiry Date', + value: item.expiryDate, + onTap: () => _pickDate( + current: item.expiryDate, + onPicked: (date) { + item.expiryDate = date; + widget.onChanged(); + setState(() {}); + }, + ), + ), + AppTextField( + key: ValueKey('$lineKey-rejection'), + controller: item.rejectionReasonController, + label: 'Rejection Reason', + isDense: true, + onChanged: (_) => widget.onChanged(), + ), + AppTextField( + key: ValueKey('$lineKey-remarks'), + controller: item.remarksController, + label: 'Remarks', + isDense: true, + onChanged: (_) => widget.onChanged(), + ), + ], + ), + ], + ), + ), ], ), ); } +} - List _buildLineItemFields({ - required BuildContext context, - required List> categoryOptions, - required Iterable categoryIds, - required List> subcategoryOptions, - required Iterable subcategoryIds, - }) { - final hasCategory = item.assetCategoryId != null; - return [ - AppTextField( - label: 'Accepted Qty *', - controller: item.acceptedQtyController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: _qtyFormatters, - isDense: true, - onChanged: (_) => onChanged(), - ), - AppTextField( - label: 'Rejected Qty', - controller: item.rejectedQtyController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: _qtyFormatters, - isDense: true, - onChanged: (_) => onChanged(), - ), - AppTextField( - label: 'Rate', - controller: item.rateController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: _qtyFormatters, - isDense: true, - onChanged: (_) => onChanged(), - ), - AppTextField( - label: 'Batch No', - controller: item.batchNoController, - isDense: true, - onChanged: (_) => onChanged(), - ), - _GrnLineDateField( - label: 'Mfg Date', - value: item.mfgDate, - onTap: () => _pickDate( - context, - current: item.mfgDate, - onPicked: (date) { - item.mfgDate = date; - onChanged(); - }, +/// Title for a line item — name + ordered/received/remaining, not a form field. +class _LineItemTitle extends StatelessWidget { + const _LineItemTitle({ + required this.lineNo, + required this.itemName, + required this.orderedQty, + required this.receivedQty, + required this.remainingQty, + }); + + final int lineNo; + final String itemName; + final double orderedQty; + final double receivedQty; + final double remainingQty; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Line $lineNo · $itemName', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + 'Ordered ${_formatQty(orderedQty)} · ' + 'Received ${_formatQty(receivedQty)} · ' + 'Remaining ${_formatQty(remainingQty)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } +} + +class _GrnLineReadOnlyField extends StatelessWidget { + const _GrnLineReadOnlyField({ + super.key, + required this.label, + required this.value, + this.backgroundColor, + }); + + final String label; + final String value; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + floatingLabelBehavior: FloatingLabelBehavior.always, + isDense: true, + filled: backgroundColor != null, + fillColor: backgroundColor, + enabled: false, + ), + child: Text( + value, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), ), ), - _GrnLineDateField( - label: 'Expiry Date', - value: item.expiryDate, - onTap: () => _pickDate( - context, - current: item.expiryDate, - onPicked: (date) { - item.expiryDate = date; - onChanged(); - }, - ), - ), - AppTextField( - label: 'Storage Location', - controller: item.storageLocationController, - isDense: true, - onChanged: (_) => onChanged(), - ), - AppTextField( - label: 'Rejection Reason', - controller: item.rejectionReasonController, - isDense: true, - onChanged: (_) => onChanged(), - ), - AppSearchableDropdown( - key: ValueKey('line_${item.lineNo}_asset_category'), - label: 'Asset Category', - value: _dropdownValue(item.assetCategoryId, categoryIds), - searchHint: 'Search asset category...', - isDense: true, - enabled: categoryOptions.isNotEmpty, - options: categoryOptions, - onChanged: (v) { - item.assetCategoryId = v; - item.assetSubcategoryId = null; - onChanged(); - }, - ), - AppSearchableDropdown( - key: ValueKey('line_${item.lineNo}_asset_subcategory'), - label: 'Asset Subcategory', - value: _dropdownValue(item.assetSubcategoryId, subcategoryIds), - searchHint: 'Search subcategory...', - isDense: true, - enabled: hasCategory && subcategoryOptions.isNotEmpty, - hint: !hasCategory - ? 'Select category first' - : subcategoryOptions.isEmpty - ? 'No subcategories found' - : null, - options: subcategoryOptions, - onChanged: (v) { - item.assetSubcategoryId = v; - onChanged(); - }, - ), - AppTextField( - label: 'Remarks', - controller: item.remarksController, - isDense: true, - maxLines: 2, - onChanged: (_) => onChanged(), - ), - ]; + ); } } class _GrnLineDateField extends StatelessWidget { const _GrnLineDateField({ + super.key, required this.label, required this.value, required this.onTap, @@ -460,8 +553,10 @@ class _GrnLineDateField extends StatelessWidget { ), child: Text( value != null ? DateFormatter.displayDate(value) : 'Select date', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: value != null ? null : Theme.of(context).hintColor, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: value != null + ? null + : Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), @@ -470,6 +565,7 @@ class _GrnLineDateField extends StatelessWidget { } } +/// Read-only single-row line items table for GRN detail / edit view. class GrnItemsTable extends StatelessWidget { const GrnItemsTable({super.key, required this.items}); @@ -477,70 +573,211 @@ class GrnItemsTable extends StatelessWidget { @override Widget build(BuildContext context) { - return AppDataTable( - wrapInCard: false, - shrinkWrap: true, - emptyMessage: 'No line items', - columns: [ - AppDataColumn( - label: '#', - flex: 1, - cellBuilder: (_, item) => Text('${item.lineNo ?? '—'}'), + final theme = Theme.of(context); + final borderColor = theme.colorScheme.outline.withValues(alpha: 0.15); + + if (items.isEmpty) { + return Text( + 'No line items', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - AppDataColumn( - label: 'Item', - flex: 3, - cellBuilder: (_, item) => Text(item.itemName ?? item.itemCode ?? '—'), - ), - AppDataColumn( - label: 'Accepted', - flex: 1, - cellBuilder: (_, item) => Text(_formatQty(item.acceptedQty ?? 0)), - ), - AppDataColumn( - label: 'Rejected', - flex: 1, - cellBuilder: (_, item) => Text(_formatQty(item.rejectedQty ?? 0)), - ), - AppDataColumn( - label: 'Rate', - flex: 1, - cellBuilder: (_, item) => Text( - item.rate != null ? _formatQty(item.rate!) : '—', + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final tableWidth = + constraints.maxWidth < 1100 ? 1100.0 : constraints.maxWidth; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: tableWidth, + child: Column( + children: [ + _GrnItemsHeader(borderColor: borderColor), + ...items.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + return _GrnItemRow( + item: item, + showDivider: index < items.length - 1, + borderColor: borderColor, + ); + }), + ], + ), ), - ), - AppDataColumn( - label: 'Batch', - flex: 1, - cellBuilder: (_, item) => Text(item.batchNo ?? '—'), - ), - AppDataColumn( - label: 'Mfg Date', - flex: 1, - cellBuilder: (_, item) => Text(DateFormatter.displayDate(item.mfgDate)), - ), - AppDataColumn( - label: 'Expiry', - flex: 1, - cellBuilder: (_, item) => Text(DateFormatter.displayDate(item.expiryDate)), - ), - AppDataColumn( - label: 'Storage', - flex: 1, - cellBuilder: (_, item) => Text(item.storageLocation ?? '—'), - ), - AppDataColumn( - label: 'Rejection Reason', - flex: 2, - cellBuilder: (_, item) => Text(item.rejectionReason ?? '—'), - ), - AppDataColumn( - label: 'Remarks', - flex: 2, - cellBuilder: (_, item) => Text(item.remarks ?? '—'), - ), - ], - rows: items, + ); + }, + ); + } +} + +class _GrnItemsHeader extends StatelessWidget { + const _GrnItemsHeader({required this.borderColor}); + + final Color borderColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final style = theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ); + + return Container( + padding: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: borderColor)), + ), + child: Row( + children: [ + SizedBox(width: 40, child: Text('#', style: style)), + Expanded(flex: 3, child: Text('ITEM', style: style)), + const SizedBox(width: 12), + SizedBox( + width: 72, + child: Text('ACCEPTED', style: style, textAlign: TextAlign.right), + ), + const SizedBox(width: 12), + SizedBox( + width: 72, + child: Text('REJECTED', style: style, textAlign: TextAlign.right), + ), + const SizedBox(width: 12), + SizedBox( + width: 72, + child: Text('CURRENT', style: style, textAlign: TextAlign.right), + ), + const SizedBox(width: 12), + SizedBox( + width: 80, + child: Text('RATE', style: style, textAlign: TextAlign.right), + ), + const SizedBox(width: 12), + SizedBox(width: 90, child: Text('BATCH', style: style)), + const SizedBox(width: 12), + SizedBox(width: 90, child: Text('MFG', style: style)), + const SizedBox(width: 12), + SizedBox(width: 90, child: Text('EXPIRY', style: style)), + const SizedBox(width: 12), + Expanded(flex: 2, child: Text('STORAGE', style: style)), + ], + ), + ); + } +} + +class _GrnItemRow extends StatelessWidget { + const _GrnItemRow({ + required this.item, + required this.showDivider, + required this.borderColor, + }); + + final GrnItemModel item; + final bool showDivider; + final Color borderColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final body = theme.textTheme.bodyMedium; + final strong = body?.copyWith(fontWeight: FontWeight.w600); + + return Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: showDivider + ? BoxDecoration( + border: Border(bottom: BorderSide(color: borderColor)), + ) + : null, + child: Row( + children: [ + SizedBox( + width: 40, + child: Text('${item.lineNo ?? '—'}', style: body), + ), + Expanded( + flex: 3, + child: Text( + item.itemName ?? item.itemCode ?? '—', + style: strong, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 72, + child: Text( + _formatQty(item.acceptedQty ?? 0), + style: body, + textAlign: TextAlign.right, + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 72, + child: Text( + _formatQty(item.rejectedQty ?? 0), + style: body, + textAlign: TextAlign.right, + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 72, + child: Text( + _formatQty(item.currentQty ?? 0), + style: strong, + textAlign: TextAlign.right, + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 80, + child: Text( + item.rate != null ? _formatQty(item.rate!) : '—', + style: body, + textAlign: TextAlign.right, + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 90, + child: Text( + item.batchNo ?? '—', + style: body, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 90, + child: Text(DateFormatter.displayDate(item.mfgDate), style: body), + ), + const SizedBox(width: 12), + SizedBox( + width: 90, + child: Text(DateFormatter.displayDate(item.expiryDate), style: body), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: Text( + item.storageLocation ?? '—', + style: body, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), ); } } diff --git a/lib/modules/master_data/data/datasources/master_crud_remote_data_source.dart b/lib/modules/master_data/data/datasources/master_crud_remote_data_source.dart index 908f3b4..3502954 100644 --- a/lib/modules/master_data/data/datasources/master_crud_remote_data_source.dart +++ b/lib/modules/master_data/data/datasources/master_crud_remote_data_source.dart @@ -84,7 +84,7 @@ class MasterCrudRemoteDataSource { final result = await list( definition, page: page, - limit: AppConstants.maxPageSize, + limit: AppConstants.defaultPageSize, ); allItems.addAll( result.items.where((item) => item['is_active'] != false), diff --git a/lib/modules/master_data/domain/entities/master_definition.dart b/lib/modules/master_data/domain/entities/master_definition.dart index a229d81..d6e2aa9 100644 --- a/lib/modules/master_data/domain/entities/master_definition.dart +++ b/lib/modules/master_data/domain/entities/master_definition.dart @@ -11,9 +11,12 @@ class MasterFieldDef { this.type = MasterFieldType.text, this.required = false, this.showInList = false, + this.showInForm = true, this.optionsMasterKey, this.staticOptions, this.multiline = false, + this.filterByFieldKey, + this.filterByOptionKey, }); final String key; @@ -21,11 +24,17 @@ class MasterFieldDef { final MasterFieldType type; final bool required; final bool showInList; + /// When false, field is list/display-only and excluded from create/update payloads. + final bool showInForm; /// Master key used to populate dropdown options (e.g. `plants` for plant_id). final String? optionsMasterKey; /// Fixed dropdown choices (e.g. brand type) — no API lookup. final List? staticOptions; final bool multiline; + /// Form field whose value filters this dropdown (e.g. `item_category_id`). + final String? filterByFieldKey; + /// Option-row key matched against [filterByFieldKey] (defaults to same key). + final String? filterByOptionKey; } class MasterDefinition { @@ -54,7 +63,8 @@ class MasterDefinition { List get listFields => fields.where((field) => field.showInList).toList(); - List get formFields => fields; + List get formFields => + fields.where((field) => field.showInForm).toList(); String listRoute(String base) => '$base/$routeKey'; String addRoute(String base) => '$base/$routeKey/add'; @@ -109,6 +119,19 @@ const masterDefinitions = [ fields: [ MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true), MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true), + MasterFieldDef(key: 'code_prefix', label: 'Code Prefix', showInList: true), + MasterFieldDef( + key: 'default_useful_life_years', + label: 'Useful Life (Years)', + type: MasterFieldType.number, + ), + MasterFieldDef( + key: 'default_depreciation_method', + label: 'Depreciation Method', + type: MasterFieldType.dropdown, + showInList: true, + optionsMasterKey: 'asset_depreciation_methods', + ), _activeField, ], ), @@ -145,7 +168,13 @@ const masterDefinitions = [ module: 'items', icon: Icons.inventory_outlined, fields: [ - MasterFieldDef(key: 'item_code', label: 'Item Code', required: true, showInList: true), + // Auto-generated by backend — list only; never sent on create/update. + MasterFieldDef( + key: 'item_code', + label: 'Item Code', + showInList: true, + showInForm: false, + ), MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true), MasterFieldDef( key: 'item_category_id', @@ -159,6 +188,7 @@ const masterDefinitions = [ label: 'Sub Category', type: MasterFieldType.dropdown, optionsMasterKey: 'item_subcategories', + filterByFieldKey: 'item_category_id', ), MasterFieldDef( key: 'uom_id', @@ -167,6 +197,13 @@ const masterDefinitions = [ required: true, optionsMasterKey: 'uom', ), + MasterFieldDef( + key: 'hsn_code_id', + label: 'HSN Code', + type: MasterFieldType.dropdown, + showInList: true, + optionsMasterKey: 'hsn_codes', + ), MasterFieldDef( key: 'gst_rate_id', label: 'GST Rate', @@ -202,6 +239,27 @@ const masterDefinitions = [ _activeField, ], ), + MasterDefinition( + id: 'hsn_codes', + title: 'HSN Codes', + subtitle: 'HSN/SAC tax classification codes', + category: 'Finance & Terms', + routeKey: 'hsn-codes', + apiPath: '/masters/hsn-codes', + module: 'hsn_codes', + icon: Icons.qr_code_outlined, + fields: [ + MasterFieldDef(key: 'code', label: 'HSN/SAC Code', required: true, showInList: true), + MasterFieldDef( + key: 'description', + label: 'Description', + required: true, + showInList: true, + multiline: true, + ), + _activeField, + ], + ), MasterDefinition( id: 'brands', title: 'Brands', @@ -330,59 +388,6 @@ const masterDefinitions = [ _activeField, ], ), - MasterDefinition( - id: 'asset_categories', - title: 'Asset Categories', - subtitle: 'Fixed asset classification', - category: 'Assets', - routeKey: 'asset-categories', - apiPath: '/masters/asset-categories', - module: 'asset_categories', - icon: Icons.precision_manufacturing_outlined, - fields: [ - MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true), - MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true), - MasterFieldDef(key: 'code_prefix', label: 'Code Prefix', required: true, showInList: true), - MasterFieldDef( - key: 'default_useful_life_years', - label: 'Useful Life (Years)', - type: MasterFieldType.number, - required: true, - ), - 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', @@ -455,16 +460,35 @@ String masterRecordLabel(Map row) { ? ratePct.toDouble() : double.tryParse(ratePct.toString()); if (rate != null) { - return rate % 1 == 0 ? '${rate.toInt()}%' : '$rate%'; + final rateLabel = rate % 1 == 0 ? '${rate.toInt()}%' : '$rate%'; + final desc = row['description']; + if (desc != null && desc.toString().trim().isNotEmpty) { + return '$rateLabel — ${desc.toString().trim()}'; + } + return rateLabel; } } - for (final key in ['name', 'item_name', 'code', 'item_code', 'description']) { + for (final key in ['name', 'item_name', 'item_code']) { final value = row[key]; if (value != null && value.toString().trim().isNotEmpty) { return value.toString(); } } + + final code = row['code']; + if (code != null && code.toString().trim().isNotEmpty) { + final desc = row['description']; + if (desc != null && desc.toString().trim().isNotEmpty) { + return '${code.toString().trim()} — ${desc.toString().trim()}'; + } + return code.toString().trim(); + } + + final description = row['description']; + if (description != null && description.toString().trim().isNotEmpty) { + return description.toString().trim(); + } return row['id']?.toString() ?? 'Record'; } @@ -480,8 +504,8 @@ String masterCellValue(Map row, MasterFieldDef field) { final label = row['${field.key}_label']; if (label != null && label.toString().isNotEmpty) return label.toString(); - // Prefer explicit "_name" or nested ".name" from API payloads - // (e.g. asset_category_id -> asset_category_name / asset_category.name) + // Prefer explicit "_name" or nested ".name" / flat code from API + // (e.g. asset_category_id -> asset_category_name; hsn_code_id -> hsn_code) if (field.key.endsWith('_id')) { final baseKey = field.key.substring(0, field.key.length - 3); final explicitName = row['${baseKey}_name']; @@ -490,10 +514,16 @@ String masterCellValue(Map row, MasterFieldDef field) { } final nested = row[baseKey]; if (nested is Map) { - final nestedName = nested['name']; - if (nestedName != null && nestedName.toString().trim().isNotEmpty) { - return nestedName.toString().trim(); + for (final nestedKey in ['code', 'name', 'description']) { + final nestedValue = nested[nestedKey]; + if (nestedValue != null && + nestedValue.toString().trim().isNotEmpty) { + return nestedValue.toString().trim(); + } } + } else if (nested != null && nested.toString().trim().isNotEmpty) { + // Flat denormalized value (e.g. items.hsn_code string) + return nested.toString().trim(); } } diff --git a/lib/modules/master_data/presentation/providers/master_provider.dart b/lib/modules/master_data/presentation/providers/master_provider.dart index dc90bbf..e9b3998 100644 --- a/lib/modules/master_data/presentation/providers/master_provider.dart +++ b/lib/modules/master_data/presentation/providers/master_provider.dart @@ -259,7 +259,7 @@ class MasterFormNotifier extends FamilyAsyncNotifier.from(current.values); values[key] = value; + + // Clear dependent dropdowns when their parent filter value changes + // (e.g. item_category_id → item_subcategory_id). + for (final field in _definition.formFields) { + if (field.filterByFieldKey != key) continue; + final dependentValue = values[field.key]; + if (dependentValue == null || dependentValue == '') continue; + + final optionKey = field.filterByOptionKey ?? field.filterByFieldKey!; + final options = + current.dropdownOptions[field.optionsMasterKey] ?? const []; + final stillValid = options.any( + (item) => + item['id']?.toString() == dependentValue.toString() && + item[optionKey]?.toString() == value?.toString(), + ); + if (!stillValid) { + values[field.key] = null; + } + } + state = AsyncData(current.copyWith(values: values)); } diff --git a/lib/modules/master_data/presentation/screens/master_list_screen.dart b/lib/modules/master_data/presentation/screens/master_list_screen.dart index b46dbf2..c5a8eb2 100644 --- a/lib/modules/master_data/presentation/screens/master_list_screen.dart +++ b/lib/modules/master_data/presentation/screens/master_list_screen.dart @@ -386,7 +386,7 @@ class _MasterListTable extends StatelessWidget { int _columnFlex(MasterFieldDef field) { return switch (field.key) { - 'code' || 'item_code' || 'series_code' => 1, + 'code' || 'item_code' || 'series_code' || 'hsn_code_id' => 1, 'name' || 'item_name' || 'description' || 'term_name' => 3, _ => 2, }; diff --git a/lib/modules/master_data/presentation/widgets/master_form_panel.dart b/lib/modules/master_data/presentation/widgets/master_form_panel.dart index 55e7667..fa9f3f4 100644 --- a/lib/modules/master_data/presentation/widgets/master_form_panel.dart +++ b/lib/modules/master_data/presentation/widgets/master_form_panel.dart @@ -99,8 +99,20 @@ class _MasterFormPanelState extends ConsumerState { if (field.staticOptions != null) { dropdownOptions = stringDropdownOptions(field.staticOptions!); } else { - final options = formState.dropdownOptions[field.optionsMasterKey] ?? + var options = formState.dropdownOptions[field.optionsMasterKey] ?? const >[]; + final filterField = field.filterByFieldKey; + if (filterField != null) { + final parentId = formState.values[filterField]?.toString(); + final optionKey = field.filterByOptionKey ?? filterField; + if (parentId == null || parentId.isEmpty) { + options = const []; + } else { + options = options + .where((item) => item[optionKey]?.toString() == parentId) + .toList(); + } + } dropdownOptions = >[]; for (final item in options) { final id = item['id']?.toString(); @@ -111,15 +123,37 @@ class _MasterFormPanelState extends ConsumerState { } } + final filterField = field.filterByFieldKey; + final parentSelected = filterField == null || + (formState.values[filterField] != null && + formState.values[filterField].toString().isNotEmpty); + final enabled = field.staticOptions != null + ? true + : parentSelected && dropdownOptions.isNotEmpty; + String parentLabel = 'parent'; + if (filterField != null) { + for (final f in _definition.formFields) { + if (f.key == filterField) { + parentLabel = f.label.toLowerCase(); + break; + } + } + } + return AppSearchableDropdown( + key: ValueKey( + '${field.key}-${filterField == null ? '' : formState.values[filterField]}', + ), label: _fieldLabel(field), value: value?.toString(), options: dropdownOptions, - hint: dropdownOptions.isEmpty - ? 'No options available' - : 'Select ${field.label.toLowerCase()}', + hint: !parentSelected + ? 'Select $parentLabel first' + : dropdownOptions.isEmpty + ? 'No options available' + : 'Select ${field.label.toLowerCase()}', searchHint: 'Search ${field.label.toLowerCase()}...', - enabled: dropdownOptions.isNotEmpty, + enabled: enabled, onChanged: (selected) => notifier.updateValue(field.key, selected), validator: field.required ? (v) => v == null ? '${field.label} is required' : null @@ -139,15 +173,29 @@ class _MasterFormPanelState extends ConsumerState { ); case MasterFieldType.text: - final formatters = Validators.inputFormattersForFieldKey(field.key); + final isHsnCodeField = + widget.masterId == 'hsn_codes' && field.key == 'code'; + final formatters = isHsnCodeField + ? Validators.hsnCodeInput + : Validators.inputFormattersForFieldKey(field.key); return TextFormField( key: ValueKey(field.key), initialValue: value?.toString(), maxLines: field.multiline ? 3 : 1, - keyboardType: _keyboardTypeForFieldKey(field.key), + keyboardType: isHsnCodeField + ? TextInputType.number + : _keyboardTypeForFieldKey(field.key), inputFormatters: formatters.isEmpty ? null : formatters, decoration: InputDecoration(labelText: _fieldLabel(field)), validator: (v) { + if (isHsnCodeField) { + return Validators.uniqueHsnCode( + v, + existingRecords: formState.existingRecords, + currentRecordId: widget.recordId, + fieldName: field.label, + ); + } if (Validators.isMasterNameFieldKey(field.key)) { return Validators.uniqueMasterName( v, diff --git a/lib/modules/masters/data/datasources/master_remote_data_source.dart b/lib/modules/masters/data/datasources/master_remote_data_source.dart index 68d1e01..15c0c17 100644 --- a/lib/modules/masters/data/datasources/master_remote_data_source.dart +++ b/lib/modules/masters/data/datasources/master_remote_data_source.dart @@ -44,62 +44,96 @@ class MasterRemoteDataSource { Future> listGstRates() => _listOptions(ApiEndpoints.gstRates); - Future> listAssetCategories() => - _listOptions(ApiEndpoints.assetCategories); + Future> listHsnCodes() => + _listOptions(ApiEndpoints.hsnCodes); - Future> 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, - }, + /// Active items with default HSN / UOM / GST for PO line autofill. + Future< + ({ + List options, + Map hsnByItemId, + Map uomByItemId, + Map gstRateByItemId, + })> listItemsWithHsn() async { + final rows = await _listAllMaps( + ApiEndpoints.items, + queryParameters: {'is_active': true}, ); - final categoryFilter = assetCategoryId?.toString(); - return _parseOptions( - response.data, - extraFilter: categoryFilter == null - ? null - : (item) => item['asset_category_id']?.toString() == categoryFilter, - ); - } + final hsnByItemId = {}; + final uomByItemId = {}; + final gstRateByItemId = {}; + final options = []; - Future> _listOptions(String endpoint) async { - final response = await dio.get( - endpoint, - queryParameters: { - 'limit': AppConstants.maxPageSize, - 'is_active': true, - }, - ); - return _parseOptions(response.data); - } + for (final item in rows) { + if (item['is_active'] == false) continue; + final id = item['id']?.toString() ?? ''; + if (id.isEmpty) continue; - /// Parses masters list payloads. Uses `is List` (not `List`) so - /// Flutter web JSON arrays are not dropped as empty. - List _parseOptions( - dynamic body, { - bool Function(Map item)? extraFilter, - }) { - if (body is! Map) return const []; - final raw = body['data']; + hsnByItemId[id] = _asInt(item['hsn_code_id']); + uomByItemId[id] = _asInt(item['uom_id']); + gstRateByItemId[id] = _asInt(item['gst_rate_id']); - final List list; - if (raw is List) { - list = raw; - } else if (raw is Map) { - final items = raw['items']; - list = items is List ? items : const []; - } else { - list = const []; + final name = _optionLabel(item); + if (name.isEmpty) continue; + options.add(FilterOptionModel(id: id, name: name)); } - return list - .whereType() - .map((item) => Map.from(item)) + return ( + options: options, + hsnByItemId: hsnByItemId, + uomByItemId: uomByItemId, + gstRateByItemId: gstRateByItemId, + ); + } + + /// GST rate options with numeric `rate_pct` for tax calculations. + Future<({List options, Map pctById})> + listGstRatesWithPct() async { + final rows = await _listAllMaps( + ApiEndpoints.gstRates, + queryParameters: {'is_active': true}, + ); + final options = []; + final pctById = {}; + + for (final item in rows) { + if (item['is_active'] == false) continue; + final id = item['id']?.toString() ?? ''; + if (id.isEmpty) continue; + + final pctRaw = item['rate_pct']; + final pct = pctRaw is num + ? pctRaw.toDouble() + : double.tryParse(pctRaw?.toString() ?? ''); + if (pct != null) pctById[id] = pct; + + final name = _optionLabel(item); + if (name.isEmpty) continue; + options.add(FilterOptionModel(id: id, name: name)); + } + + return (options: options, pctById: pctById); + } + + Future> listItemCategories() => + _listOptions(ApiEndpoints.itemCategories); + + Future> listItemSubcategories({int? itemCategoryId}) async { + final rows = await _listAllMaps( + ApiEndpoints.itemSubcategories, + queryParameters: { + 'is_active': true, + if (itemCategoryId != null) 'item_category_id': itemCategoryId, + }, + ); + final categoryFilter = itemCategoryId?.toString(); + return rows .where((item) => item['is_active'] != false) - .where((item) => extraFilter == null || extraFilter(item)) + .where( + (item) => + categoryFilter == null || + item['item_category_id']?.toString() == categoryFilter, + ) .map( (item) => FilterOptionModel( id: item['id']?.toString() ?? '', @@ -110,6 +144,101 @@ class MasterRemoteDataSource { .toList(); } + Future> _listOptions(String endpoint) async { + final rows = await _listAllMaps( + endpoint, + queryParameters: {'is_active': true}, + ); + return rows + .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(); + } + + /// Loads every page of a masters list (API default page size = 20). + Future>> _listAllMaps( + String endpoint, { + Map? queryParameters, + }) async { + final all = >[]; + var page = 1; + var totalPages = 1; + final pageSize = AppConstants.defaultPageSize; + + while (page <= totalPages) { + final response = await dio.get( + endpoint, + queryParameters: { + 'page': page, + 'limit': pageSize, + ...?queryParameters, + }, + ); + final parsed = _parsePage(response.data, fallbackLimit: pageSize); + all.addAll(parsed.items); + totalPages = parsed.totalPages; + if (parsed.items.isEmpty) break; + page++; + } + + return all; + } + + ({List> items, int totalPages}) _parsePage( + dynamic body, { + required int fallbackLimit, + }) { + if (body is! Map) { + return (items: const >[], totalPages: 1); + } + + final raw = body['data']; + final meta = body['meta'] is Map + ? Map.from(body['meta'] as Map) + : {}; + + List list; + Map pageMeta = meta; + + if (raw is List) { + list = raw; + } else if (raw is Map) { + final map = Map.from(raw); + final items = map['items']; + list = items is List ? items : const []; + pageMeta = {...meta, ...map}; + } else { + list = const []; + } + + final items = list + .whereType() + .map((item) => Map.from(item)) + .toList(); + + final total = _asInt(pageMeta['total']) ?? items.length; + final limit = _asInt(pageMeta['limit']) ?? fallbackLimit; + final explicitTotalPages = _asInt(pageMeta['totalPages']) ?? + _asInt(pageMeta['total_pages']); + final totalPages = explicitTotalPages ?? + (limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1); + + return (items: items, totalPages: totalPages < 1 ? 1 : totalPages); + } + + int? _asInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } + String _optionLabel(Map item) { final ratePct = item['rate_pct']; if (ratePct != null) { @@ -132,14 +261,26 @@ class MasterRemoteDataSource { 'item_name', 'term_name', 'vendor_name', - 'code', - 'description', ]) { final value = item[key]; if (value is String && value.trim().isNotEmpty) { return value.trim(); } } + + final code = item['code']; + if (code is String && code.trim().isNotEmpty) { + final desc = item['description']; + if (desc is String && desc.trim().isNotEmpty) { + return '${code.trim()} — ${desc.trim()}'; + } + return code.trim(); + } + + final description = item['description']; + if (description is String && description.trim().isNotEmpty) { + return description.trim(); + } return ''; } } diff --git a/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart b/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart index 599b8a6..098a7b3 100644 --- a/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart +++ b/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart @@ -21,9 +21,16 @@ class PurchaseOrderRemoteDataSource { Future getPurchaseOrderById(String id) async { final response = await dio.get(ApiEndpoints.purchaseOrderById(id)); - return PurchaseOrderModel.fromJson( - response.data['data'] as Map, - ); + final raw = response.data['data']; + if (raw is! Map) { + throw StateError('Invalid purchase order detail response'); + } + final map = Map.from(raw); + // Support alternate line-item key from API payloads. + if (map['items'] == null && map['line_items'] is List) { + map['items'] = map['line_items']; + } + return PurchaseOrderModel.fromJson(map); } Future createPurchaseOrder(Map data) async { diff --git a/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart b/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart index 5b26520..a175fcc 100644 --- a/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart +++ b/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart @@ -16,8 +16,13 @@ class PurchaseOrderLookups { this.paymentTerms = const [], this.deliveryTerms = const [], this.items = const [], + this.itemHsnById = const {}, + this.itemUomById = const {}, + this.itemGstRateById = const {}, this.uom = const [], this.gstRates = const [], + this.gstRatePctById = const {}, + this.hsnCodes = const [], }); final List vendors; @@ -27,8 +32,17 @@ class PurchaseOrderLookups { final List paymentTerms; final List deliveryTerms; final List items; + /// Item id → default `hsn_code_id` from item master. + final Map itemHsnById; + /// Item id → default `uom_id` from item master. + final Map itemUomById; + /// Item id → default `gst_rate_id` from item master. + final Map itemGstRateById; final List uom; final List gstRates; + /// GST rate id → `rate_pct` for tax calculations. + final Map gstRatePctById; + final List hsnCodes; } final purchaseOrderLookupsProvider = @@ -38,15 +52,17 @@ final purchaseOrderLookupsProvider = final vendors = await _safeOptions(() => _fetchActiveVendors(vendorRepo)); + final itemsWithDefaults = await _safeItemsWithDefaults(master.listItemsWithHsn); + final gstWithPct = await _safeGstRatesWithPct(master.listGstRatesWithPct); + final results = await Future.wait([ _safeOptions(master.listPlants), _safeOptions(master.listWarehouses), _safeOptions(master.listBrands), _safeOptions(master.listPaymentTerms), _safeOptions(master.listDeliveryTerms), - _safeOptions(master.listItems), _safeOptions(master.listUom), - _safeOptions(master.listGstRates), + _safeOptions(master.listHsnCodes), ]); return PurchaseOrderLookups( @@ -56,9 +72,14 @@ final purchaseOrderLookupsProvider = brands: results[2], paymentTerms: results[3], deliveryTerms: results[4], - items: results[5], - uom: results[6], - gstRates: results[7], + items: itemsWithDefaults.options, + itemHsnById: itemsWithDefaults.hsnByItemId, + itemUomById: itemsWithDefaults.uomByItemId, + itemGstRateById: itemsWithDefaults.gstRateByItemId, + uom: results[5], + gstRates: gstWithPct.options, + gstRatePctById: gstWithPct.pctById, + hsnCodes: results[6], ); }); @@ -72,6 +93,48 @@ Future> _safeOptions( } } +Future< + ({ + List options, + Map hsnByItemId, + Map uomByItemId, + Map gstRateByItemId, + })> _safeItemsWithDefaults( + Future< + ({ + List options, + Map hsnByItemId, + Map uomByItemId, + Map gstRateByItemId, + })> + Function() + load, +) async { + try { + return await load(); + } catch (_) { + return ( + options: [], + hsnByItemId: {}, + uomByItemId: {}, + gstRateByItemId: {}, + ); + } +} + +Future<({List options, Map pctById})> + _safeGstRatesWithPct( + Future<({List options, Map pctById})> + Function() + load, +) async { + try { + return await load(); + } catch (_) { + return (options: [], pctById: {}); + } +} + Future> _fetchActiveVendors( VendorRepository vendorRepo, ) async { diff --git a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart index 5c0cef4..f0d53bb 100644 --- a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart +++ b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../shared/models/purchase_order_model.dart'; +import '../../../grn/presentation/providers/grn_lookups_provider.dart'; import '../../data/repositories/purchase_order_repository_impl.dart'; class PurchaseOrdersListState { @@ -131,6 +132,7 @@ class PurchaseOrdersListNotifier } return false; } + ref.invalidate(grnLookupsProvider); await refresh(); final current = state.valueOrNull; if (current != null) { @@ -166,6 +168,7 @@ class PurchaseOrderDetailNotifier if (result.failure != null) throw result.failure!; state = AsyncData(result.data!); ref.invalidate(purchaseOrdersListProvider); + ref.invalidate(grnLookupsProvider); return result.data!; } @@ -175,6 +178,7 @@ class PurchaseOrderDetailNotifier if (result.failure != null) throw result.failure!; state = AsyncData(result.data!); ref.invalidate(purchaseOrdersListProvider); + ref.invalidate(grnLookupsProvider); return result.data!; } @@ -184,6 +188,7 @@ class PurchaseOrderDetailNotifier if (result.failure != null) throw result.failure!; state = AsyncData(result.data!); ref.invalidate(purchaseOrdersListProvider); + ref.invalidate(grnLookupsProvider); return result.data!; } @@ -192,6 +197,7 @@ class PurchaseOrderDetailNotifier final result = await repository.amendPurchaseOrder(arg); if (result.failure != null) throw result.failure!; ref.invalidate(purchaseOrdersListProvider); + ref.invalidate(grnLookupsProvider); return result.data!; } @@ -201,6 +207,7 @@ class PurchaseOrderDetailNotifier if (result.failure != null) throw result.failure!; state = AsyncData(result.data!); ref.invalidate(purchaseOrdersListProvider); + ref.invalidate(grnLookupsProvider); return result.data!; } @@ -232,6 +239,7 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier context.go(RouteConstants.purchaseOrders), - ), - title: detailAsync.maybeWhen( - data: (order) => Text(order.poNo ?? 'Purchase Order #${order.id}'), - orElse: () => const Text('Purchase Order'), - ), - ), body: detailAsync.when( - loading: () => const AppLoadingView(message: 'Loading purchase order...'), + loading: () => + const AppLoadingView(message: 'Loading purchase order...'), error: (e, _) => ErrorView.fromFailure( e is Failure ? e : Failure.unknown(message: e.toString()), - onRetry: () => - ref.invalidate(purchaseOrderDetailProvider(widget.purchaseOrderId)), - ), - data: (order) => SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 1200), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PageHeader( - title: order.poNo ?? 'Purchase Order #${order.id}', - subtitle: - '${poTypeLabel(order.poType)} · ${order.vendorName ?? '—'}', - actions: [ - if (canExport) - OutlinedButton.icon( - onPressed: _isWorking ? null : () => _downloadPdf(order), - icon: const Icon(Icons.picture_as_pdf_outlined), - label: const Text('PDF'), - ), - if (canEdit && order.canEdit) ...[ - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: _isWorking - ? null - : () => context.push( - '${RouteConstants.purchaseOrders}/${order.id}/edit', - ), - icon: const Icon(Icons.edit_outlined), - label: const Text('Edit'), - ), - ], - if (canEdit && order.canSubmit) ...[ - const SizedBox(width: 8), - FilledButton.icon( - onPressed: _isWorking ? null : () => _submit(order), - icon: const Icon(Icons.send_outlined), - label: const Text('Submit'), - ), - ], - if (canApprove && order.canApprove) ...[ - const SizedBox(width: 8), - FilledButton.icon( - onPressed: _isWorking ? null : () => _approve(order), - icon: const Icon(Icons.check_circle_outline), - label: const Text('Approve'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: _isWorking ? null : () => _reject(order), - icon: const Icon(Icons.cancel_outlined), - label: const Text('Reject'), - ), - ], - if (canEdit && order.canAmend) ...[ - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: _isWorking ? null : () => _amend(order), - icon: const Icon(Icons.history_edu_outlined), - label: const Text('Amend'), - ), - ], - if (canEdit && order.canCancel) ...[ - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: _isWorking ? null : () => _cancel(order), - icon: const Icon(Icons.block_outlined), - label: const Text('Cancel'), - ), - ], - if (canDelete && order.canDelete) ...[ - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: _isWorking ? null : _delete, - icon: const Icon(Icons.delete_outline), - label: const Text('Delete'), - ), - ], - ], - ), - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - PoStatusChip(status: order.status), - if (order.revisionNo != null && order.revisionNo! > 0) - PoRevisionChip(revisionNo: order.revisionNo!), - ], - ), - const SizedBox(height: 16), - _OverviewCard(order: order), - const SizedBox(height: 16), - _LineItemsCard(items: order.items), - ], - ), - ), + onRetry: () => ref.invalidate( + purchaseOrderDetailProvider(widget.purchaseOrderId), ), ), + data: (order) { + final lookups = lookupsAsync.asData?.value; + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DetailHeader( + order: order, + isWorking: _isWorking, + canEdit: canEdit, + canDelete: canDelete, + canApprove: canApprove, + canExport: canExport, + onBack: () => context.go(RouteConstants.purchaseOrders), + onPdf: () => _downloadPdf(order), + onEdit: () => context.push( + '${RouteConstants.purchaseOrders}/${order.id}/edit', + ), + onSubmit: () => _submit(order), + onApprove: () => _approve(order), + onReject: () => _reject(order), + onAmend: () => _amend(order), + onCancel: () => _cancel(order), + onDelete: _delete, + ), + const SizedBox(height: 16), + _OrderDetailsCard(order: order, lookups: lookups), + const SizedBox(height: 16), + _LineItemsCard(order: order, lookups: lookups), + const SizedBox(height: 16), + LayoutBuilder( + builder: (context, constraints) { + final stack = constraints.maxWidth < 900; + final terms = _TermsRemarksColumn(order: order); + final summary = _AmountSummaryCard(order: order); + if (stack) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + terms, + const SizedBox(height: 16), + summary, + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 3, child: terms), + const SizedBox(width: 16), + Expanded(flex: 2, child: summary), + ], + ); + }, + ), + const SizedBox(height: 20), + _DetailFooter(order: order), + ], + ), + ), + ), + ); + }, ), ); } - Future _runWorkflow(Future Function() action, String success) async { + Future _runWorkflow( + Future Function() action, + String success, + ) async { setState(() => _isWorking = true); try { await action(); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(success))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(success))); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); } } finally { if (mounted) setState(() => _isWorking = false); @@ -243,8 +211,7 @@ class _PurchaseOrderDetailScreenState final confirmed = await showAppConfirmationDialog( context: context, title: 'Amend Purchase Order', - message: - 'This will create a new draft revision. Continue?', + message: 'This will create a new draft revision. Continue?', confirmLabel: 'Amend', ); if (confirmed != true || !mounted) return; @@ -262,7 +229,8 @@ class _PurchaseOrderDetailScreenState } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); } } finally { if (mounted) setState(() => _isWorking = false); @@ -299,7 +267,8 @@ class _PurchaseOrderDetailScreenState setState(() => _isWorking = true); try { final repository = ref.read(purchaseOrderRepositoryProvider); - final result = await repository.deletePurchaseOrder(widget.purchaseOrderId); + final result = + await repository.deletePurchaseOrder(widget.purchaseOrderId); if (result.failure != null) throw result.failure!; ref.invalidate(purchaseOrdersListProvider); if (mounted) { @@ -310,7 +279,8 @@ class _PurchaseOrderDetailScreenState } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); } } finally { if (mounted) setState(() => _isWorking = false); @@ -330,7 +300,8 @@ class _PurchaseOrderDetailScreenState ); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); } } finally { if (mounted) setState(() => _isWorking = false); @@ -338,161 +309,449 @@ class _PurchaseOrderDetailScreenState } } -class _OverviewCard extends StatelessWidget { - const _OverviewCard({required this.order}); +String _lookupName(List? options, int? id) { + if (id == null || options == null) return '—'; + for (final option in options) { + if (option.id == id.toString()) return option.name; + } + return '—'; +} + +String _displayOrDash(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) return '—'; + return trimmed; +} + +double _lineAmount(PurchaseOrderItemModel item) { + if (item.lineAmount != null) return item.lineAmount!; + final qty = item.orderedQty ?? 0; + final rate = item.rate ?? 0; + final discPct = item.discountPct ?? 0; + return PoLineCalculation.compute( + qty: qty, + rate: rate, + discPct: discPct, + gstPct: 0, + ).lineAmount; +} + +String _gstLabel( + PurchaseOrderItemModel item, + PurchaseOrderLookups? lookups, +) { + if (item.gstRateId != null && lookups != null) { + final pct = lookups.gstRatePctById[item.gstRateId.toString()]; + if (pct != null) { + return pct % 1 == 0 ? '${pct.toInt()}%' : '$pct%'; + } + for (final option in lookups.gstRates) { + if (option.id == item.gstRateId.toString()) return option.name; + } + } + return '—'; +} + +String _itemLabel( + PurchaseOrderItemModel item, + PurchaseOrderLookups? lookups, +) { + final name = item.itemName?.trim(); + if (name != null && name.isNotEmpty) return name; + final code = item.itemCode?.trim(); + if (code != null && code.isNotEmpty) return code; + return _lookupName(lookups?.items, item.itemId); +} + +String _hsnLabel( + PurchaseOrderItemModel item, + PurchaseOrderLookups? lookups, +) { + final fromApi = item.hsnCodeName?.trim(); + if (fromApi != null && fromApi.isNotEmpty) return fromApi; + return _lookupName(lookups?.hsnCodes, item.hsnCodeId); +} + +class _DetailHeader extends StatelessWidget { + const _DetailHeader({ + required this.order, + required this.isWorking, + required this.canEdit, + required this.canDelete, + required this.canApprove, + required this.canExport, + required this.onBack, + required this.onPdf, + required this.onEdit, + required this.onSubmit, + required this.onApprove, + required this.onReject, + required this.onAmend, + required this.onCancel, + required this.onDelete, + }); final PurchaseOrderModel order; + final bool isWorking; + final bool canEdit; + final bool canDelete; + final bool canApprove; + final bool canExport; + final VoidCallback onBack; + final VoidCallback onPdf; + final VoidCallback onEdit; + final VoidCallback onSubmit; + final VoidCallback onApprove; + final VoidCallback onReject; + final VoidCallback onAmend; + final VoidCallback onCancel; + final VoidCallback onDelete; @override Widget build(BuildContext context) { final theme = Theme.of(context); - final hasTerms = order.termsAndConditions?.trim().isNotEmpty == true; - final hasRemarks = order.remarks?.trim().isNotEmpty == true; + final subtitleParts = [ + poTypeLabel(order.poType), + if (order.vendorName?.trim().isNotEmpty == true) order.vendorName!.trim(), + if (order.plantName?.trim().isNotEmpty == true) order.plantName!.trim(), + ]; - return AppCard( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + final actions = Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.end, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (canExport) + _HeaderActionButton( + label: 'PDF', + icon: Icons.description_outlined, + onPressed: isWorking ? null : onPdf, + ), + if (canEdit && order.canEdit) + _HeaderActionButton( + label: 'Edit', + icon: Icons.edit_outlined, + onPressed: isWorking ? null : onEdit, + ), + if (canEdit && order.canSubmit) + _HeaderActionButton( + label: 'Submit', + icon: Icons.send_outlined, + filled: true, + onPressed: isWorking ? null : onSubmit, + ), + if (canApprove && order.canApprove) + _HeaderActionButton( + label: 'Approve', + icon: Icons.check, + filled: true, + onPressed: isWorking ? null : onApprove, + ), + if (canApprove && order.canReject) + _HeaderActionButton( + label: 'Reject', + icon: Icons.close, + destructive: true, + onPressed: isWorking ? null : onReject, + ), + if (canEdit && order.canAmend) + _HeaderActionButton( + label: 'Amend', + icon: Icons.history_edu_outlined, + onPressed: isWorking ? null : onAmend, + ), + if (canEdit && order.canCancel) + _HeaderActionButton( + label: 'Cancel', + icon: Icons.block_outlined, + onPressed: isWorking ? null : onCancel, + ), + if (canDelete && order.canDelete) + _HeaderActionButton( + label: 'Delete', + icon: Icons.delete_outline, + destructive: true, + onPressed: isWorking ? null : onDelete, + ), + ], + ); + + final titleBlock = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Text( - 'Overview', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, + Flexible( + child: Text( + order.poNo ?? 'Purchase Order #${order.id}', + style: theme.textTheme.headlineSmall, + overflow: TextOverflow.ellipsis, ), ), - const SizedBox(height: 20), - Text( - 'Order Details', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 12), - _InfoGrid( - columns: 4, - items: [ - _Info('PO Date', DateFormatter.displayDate(order.poDate)), - _Info( - 'Expected Delivery', - DateFormatter.displayDate(order.expectedDeliveryDate), - ), - _Info('Vendor', order.vendorName ?? '—'), - _Info('Type', poTypeLabel(order.poType)), - _Info('Plant', order.plantName ?? '—'), - _Info('Warehouse', order.warehouseName ?? '—'), - ], - ), - const Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Divider(height: 1), - ), - Text( - 'Amount Summary', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 12), - _InfoGrid( - columns: 6, - items: [ - _Info('Taxable', CurrencyFormatter.format(order.taxableAmount)), - _Info('Tax', CurrencyFormatter.format(order.taxAmount)), - _Info('Freight', CurrencyFormatter.format(order.freightCharges)), - _Info( - 'Other Charges', - CurrencyFormatter.format(order.otherCharges), - ), - _Info('Discount', CurrencyFormatter.format(order.discountAmount)), - _Info( - 'Total', - CurrencyFormatter.format(order.totalAmount), - emphasize: true, - ), - ], - ), - if (hasTerms || hasRemarks) ...[ - const Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Divider(height: 1), - ), - _InfoGrid( - columns: 2, - items: [ - if (hasTerms) - _Info('Terms & Conditions', order.termsAndConditions!), - if (hasRemarks) _Info('Remarks', order.remarks!), - ], - ), + const SizedBox(width: 10), + PoStatusChip(status: order.status, compact: true), + if (order.revisionNo != null && order.revisionNo! > 0) ...[ + const SizedBox(width: 8), + PoRevisionChip(revisionNo: order.revisionNo!, compact: true), ], ], ), - ), + const SizedBox(height: 4), + Text( + subtitleParts.join(' · '), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], ); - } -} -class _InfoGrid extends StatelessWidget { - const _InfoGrid({ - required this.items, - this.columns = 4, - }); - - final List<_Info> items; - final int columns; - - @override - Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - final cols = maxWidth < 600 - ? 1 - : maxWidth < 900 - ? 2 - : columns; - const spacing = 16.0; - final colWidth = (maxWidth - spacing * (cols - 1)) / cols; - - return Wrap( - spacing: spacing, - runSpacing: 16, - children: items - .map( - (item) => SizedBox( - width: colWidth, - child: _DetailTile( - label: item.label, - value: item.value, - emphasize: item.emphasize, + final stack = constraints.maxWidth < 800; + if (stack) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + tooltip: 'Back', + onPressed: onBack, + icon: const Icon(Icons.arrow_back), ), - ), - ) - .toList(), + const SizedBox(width: 4), + Expanded(child: titleBlock), + ], + ), + const SizedBox(height: 12), + actions, + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + tooltip: 'Back', + onPressed: onBack, + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: 4), + Expanded(child: titleBlock), + const SizedBox(width: 12), + actions, + ], ); }, ); } } -class _DetailTile extends StatelessWidget { - const _DetailTile({ +/// Shared header action — same height, radius, and padding for every button. +class _HeaderActionButton extends StatelessWidget { + const _HeaderActionButton({ required this.label, - required this.value, - this.emphasize = false, + required this.icon, + required this.onPressed, + this.filled = false, + this.destructive = false, }); final String label; - final String value; - final bool emphasize; + final IconData icon; + final VoidCallback? onPressed; + final bool filled; + final bool destructive; + + static const double _height = 40; + static const double _radius = 8; + static const EdgeInsets _padding = + EdgeInsets.symmetric(horizontal: 14, vertical: 0); @override Widget build(BuildContext context) { final theme = Theme.of(context); + final error = theme.colorScheme.error; + final primary = theme.colorScheme.primary; + final style = ButtonStyle( + minimumSize: const WidgetStatePropertyAll(Size(0, _height)), + fixedSize: const WidgetStatePropertyAll(Size.fromHeight(_height)), + padding: const WidgetStatePropertyAll(_padding), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(_radius)), + ), + visualDensity: VisualDensity.standard, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + + final child = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18), + const SizedBox(width: 8), + Text(label), + ], + ); + + if (filled) { + return FilledButton( + onPressed: onPressed, + style: style.copyWith( + backgroundColor: WidgetStatePropertyAll(primary), + foregroundColor: WidgetStatePropertyAll(theme.colorScheme.onPrimary), + ), + child: child, + ); + } + + if (destructive) { + return OutlinedButton( + onPressed: onPressed, + style: style.copyWith( + foregroundColor: WidgetStatePropertyAll(error), + side: WidgetStatePropertyAll(BorderSide(color: error)), + ), + child: child, + ); + } + + return OutlinedButton( + onPressed: onPressed, + style: style, + child: child, + ); + } +} + +class _SectionCard extends StatelessWidget { + const _SectionCard({ + required this.title, + required this.child, + }); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 16), + child, + ], + ), + ); + } +} + +class _OrderDetailsCard extends StatelessWidget { + const _OrderDetailsCard({ + required this.order, + required this.lookups, + }); + + final PurchaseOrderModel order; + final PurchaseOrderLookups? lookups; + + @override + Widget build(BuildContext context) { + final brand = _lookupName(lookups?.brands, order.brandId); + final paymentTerm = + _lookupName(lookups?.paymentTerms, order.paymentTermId); + final deliveryTerm = + _lookupName(lookups?.deliveryTerms, order.deliveryTermId); + + return _SectionCard( + title: 'ORDER DETAILS', + child: LayoutBuilder( + builder: (context, constraints) { + final cols = constraints.maxWidth < 600 + ? 1 + : constraints.maxWidth < 900 + ? 2 + : 4; + const spacing = 20.0; + final width = (constraints.maxWidth - spacing * (cols - 1)) / cols; + final items = [ + _DetailField( + label: 'PO date', + value: DateFormatter.displayDate(order.poDate), + ), + _DetailField( + label: 'Expected delivery', + value: DateFormatter.displayDate(order.expectedDeliveryDate), + ), + _DetailField( + label: 'Vendor', + value: _displayOrDash(order.vendorName), + ), + _DetailField( + label: 'PO type', + value: poTypeLabel(order.poType), + ), + _DetailField( + label: 'Plant', + value: _displayOrDash(order.plantName), + ), + _DetailField( + label: 'Warehouse', + value: _displayOrDash(order.warehouseName), + ), + _DetailField(label: 'Brand', value: brand), + _DetailField(label: 'Payment term', value: paymentTerm), + _DetailField(label: 'Delivery term', value: deliveryTerm), + ]; + + return Wrap( + spacing: spacing, + runSpacing: 16, + children: items + .map((item) => SizedBox(width: width, child: item)) + .toList(), + ); + }, + ), + ); + } +} + +class _DetailField extends StatelessWidget { + const _DetailField({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -506,92 +765,437 @@ class _DetailTile extends StatelessWidget { const SizedBox(height: 4), Text( value, - style: emphasize - ? theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - color: theme.colorScheme.primary, - ) - : theme.textTheme.bodyLarge, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), ), ], ); } } -class _Info { - const _Info(this.label, this.value, {this.emphasize = false}); - final String label; - final String value; - final bool emphasize; -} - class _LineItemsCard extends StatelessWidget { - const _LineItemsCard({required this.items}); + const _LineItemsCard({ + required this.order, + required this.lookups, + }); - final List items; + final PurchaseOrderModel order; + final PurchaseOrderLookups? lookups; @override Widget build(BuildContext context) { - return AppCard( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Line Items', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 16), - if (items.isEmpty) - Text( - 'No line items', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ) - else - AppDataTable( - wrapInCard: false, - shrinkWrap: true, - columns: [ - AppDataColumn( - label: '#', - flex: 1, - cellBuilder: (_, item) => Text('${item.lineNo ?? '—'}'), - ), - AppDataColumn( - label: 'Item', - flex: 3, - cellBuilder: (_, item) => Text( - item.itemName ?? item.itemCode ?? '—', - ), - ), - AppDataColumn( - label: 'Qty', - flex: 1, - cellBuilder: (_, item) => Text('${item.orderedQty ?? '—'}'), - ), - AppDataColumn( - label: 'UOM', - flex: 1, - cellBuilder: (_, item) => Text(item.uomName ?? '—'), - ), - AppDataColumn( - label: 'Rate', - flex: 1, - cellBuilder: (_, item) => - Text(CurrencyFormatter.format(item.rate)), - ), - AppDataColumn( - label: 'Amount', - flex: 1, - cellBuilder: (_, item) => - Text(CurrencyFormatter.format(item.lineAmount)), - ), - ], - rows: items, + final theme = Theme.of(context); + final items = order.items; + final borderColor = theme.colorScheme.outline.withValues(alpha: 0.15); + + return _SectionCard( + title: 'LINE ITEMS · ${items.length}', + child: items.isEmpty + ? Text( + 'No line items', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - ], + ) + : LayoutBuilder( + builder: (context, constraints) { + // Horizontal scroll gives unbounded width; Expanded rows need a + // finite width or the View PO screen crashes (blank content). + final tableWidth = + constraints.maxWidth < 1100 ? 1100.0 : constraints.maxWidth; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: tableWidth, + child: Column( + children: [ + _LineItemsHeader(borderColor: borderColor), + ...items.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + return _LineItemRow( + item: item, + lookups: lookups, + showDivider: index < items.length - 1, + borderColor: borderColor, + ); + }), + ], + ), + ), + ); + }, + ), + ); + } +} + +class _LineItemsHeader extends StatelessWidget { + const _LineItemsHeader({required this.borderColor}); + + final Color borderColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final style = theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ); + + return Container( + padding: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: borderColor)), + ), + child: Row( + children: [ + SizedBox(width: 40, child: Text('#', style: style)), + Expanded(flex: 4, child: Text('ITEM', style: style)), + const SizedBox(width: 12), + SizedBox(width: 100, child: Text('HSN', style: style)), + const SizedBox(width: 16), + SizedBox( + width: 72, + child: Text('QTY', style: style, textAlign: TextAlign.right), + ), + const SizedBox(width: 16), + SizedBox(width: 100, child: Text('UOM', style: style)), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: Text('RATE', style: style, textAlign: TextAlign.right), + ), + const SizedBox(width: 12), + SizedBox( + width: 72, + child: Text('DISC %', style: style, textAlign: TextAlign.right), + ), + const SizedBox(width: 12), + SizedBox( + width: 72, + child: Text('GST', style: style, textAlign: TextAlign.right), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: Text('AMOUNT', style: style, textAlign: TextAlign.right), + ), + ], + ), + ); + } +} + +class _LineItemRow extends StatelessWidget { + const _LineItemRow({ + required this.item, + required this.lookups, + required this.showDivider, + required this.borderColor, + }); + + final PurchaseOrderItemModel item; + final PurchaseOrderLookups? lookups; + final bool showDivider; + final Color borderColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final amount = _lineAmount(item); + final discPct = item.discountPct ?? 0; + final discLabel = + discPct % 1 == 0 ? '${discPct.toInt()}%' : '$discPct%'; + + return Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: showDivider + ? BoxDecoration( + border: Border(bottom: BorderSide(color: borderColor)), + ) + : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 40, + child: Text( + '${item.lineNo ?? '—'}', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + flex: 4, + child: Text( + _itemLabel(item, lookups), + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 100, + child: Text( + _hsnLabel(item, lookups), + style: theme.textTheme.bodyMedium, + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 72, + child: Text( + item.orderedQty?.toString() ?? '—', + textAlign: TextAlign.right, + style: theme.textTheme.bodyMedium, + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 100, + child: Text( + item.uomName ?? '—', + style: theme.textTheme.bodyMedium, + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: Text( + CurrencyFormatter.format(item.rate), + textAlign: TextAlign.right, + style: theme.textTheme.bodyMedium, + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 72, + child: Text( + discLabel, + textAlign: TextAlign.right, + style: theme.textTheme.bodyMedium, + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 72, + child: Text( + _gstLabel(item, lookups), + textAlign: TextAlign.right, + style: theme.textTheme.bodyMedium, + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: Text( + CurrencyFormatter.format(amount), + textAlign: TextAlign.right, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } +} + +class _TermsRemarksColumn extends StatelessWidget { + const _TermsRemarksColumn({required this.order}); + + final PurchaseOrderModel order; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _TextBlockCard( + title: 'TERMS AND CONDITIONS', + body: order.termsAndConditions, + ), + const SizedBox(height: 16), + _TextBlockCard( + title: 'REMARKS', + body: order.remarks, + ), + ], + ); + } +} + +class _TextBlockCard extends StatelessWidget { + const _TextBlockCard({ + required this.title, + required this.body, + }); + + final String title; + final String? body; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final text = body?.trim(); + return _SectionCard( + title: title, + child: Text( + text == null || text.isEmpty ? '—' : text, + style: theme.textTheme.bodyMedium?.copyWith( + color: text == null || text.isEmpty + ? theme.colorScheme.onSurfaceVariant + : theme.colorScheme.onSurface, + height: 1.5, ), ), ); } } + +class _AmountSummaryCard extends StatelessWidget { + const _AmountSummaryCard({required this.order}); + + final PurchaseOrderModel order; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final discount = order.discountAmount ?? 0; + final primaryTint = theme.colorScheme.primary.withValues(alpha: 0.1); + + return _SectionCard( + title: 'AMOUNT SUMMARY', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SummaryRow( + label: 'Taxable amount', + value: CurrencyFormatter.format(order.taxableAmount), + ), + const SizedBox(height: 12), + _SummaryRow( + label: 'Tax (GST)', + value: CurrencyFormatter.format(order.taxAmount), + ), + const SizedBox(height: 12), + _SummaryRow( + label: 'Freight charges', + value: CurrencyFormatter.format(order.freightCharges ?? 0), + ), + const SizedBox(height: 12), + _SummaryRow( + label: 'Other charges', + value: CurrencyFormatter.format(order.otherCharges ?? 0), + ), + const SizedBox(height: 12), + _SummaryRow( + label: 'Discount', + value: discount > 0 + ? '-${CurrencyFormatter.format(discount)}' + : CurrencyFormatter.format(0), + valueColor: discount > 0 ? AppColors.error : null, + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: primaryTint, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Text( + 'Grand total', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.primary, + ), + ), + const Spacer(), + Text( + CurrencyFormatter.format(order.totalAmount), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + color: theme.colorScheme.primary, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _SummaryRow extends StatelessWidget { + const _SummaryRow({ + required this.label, + required this.value, + this.valueColor, + }); + + final String label; + final String value; + final Color? valueColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Expanded( + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Text( + value, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: valueColor, + ), + ), + ], + ); + } +} + +class _DetailFooter extends StatelessWidget { + const _DetailFooter({required this.order}); + + final PurchaseOrderModel order; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final style = theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: Row( + children: [ + Text( + '${poTypeLabel(order.poType)} · ${poStatusLabel(order.status)}', + style: style, + ), + const Spacer(), + if (order.revisionNo != null && order.revisionNo! > 0) + Text('Revision ${order.revisionNo}', style: style), + ], + ), + ); + } +} diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart index 8af0b68..e24ba94 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../../../core/constants/route_constants.dart'; import '../../../../core/errors/failure.dart'; import '../../../../core/network/api_handler.dart'; +import '../../../../core/theme/app_colors.dart'; import '../../../../core/utils/formatters.dart'; import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/user_management_models.dart'; @@ -16,10 +17,11 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/app_side_panel.dart'; -import '../../../../shared/widgets/page_header.dart'; import '../../data/repositories/purchase_order_repository_impl.dart'; +import '../../../grn/presentation/providers/grn_lookups_provider.dart'; import '../providers/purchase_order_lookups_provider.dart'; import '../providers/purchase_orders_provider.dart'; +import '../widgets/po_status_chip.dart'; import '../widgets/purchase_order_line_items_editor.dart'; class PurchaseOrderFormScreen extends ConsumerStatefulWidget { @@ -37,9 +39,9 @@ class PurchaseOrderFormScreen extends ConsumerStatefulWidget { class _PurchaseOrderFormScreenState extends ConsumerState { final _formKey = GlobalKey(); final _scrollController = ScrollController(); - final _discountController = TextEditingController(); - final _freightController = TextEditingController(); - final _otherChargesController = TextEditingController(); + final _discountController = TextEditingController(text: '0.00'); + final _freightController = TextEditingController(text: '0.00'); + final _otherChargesController = TextEditingController(text: '0.00'); final _termsController = TextEditingController(); final _remarksController = TextEditingController(); @@ -63,10 +65,16 @@ class _PurchaseOrderFormScreenState extends ConsumerState setState(() {}); + String _orderSignature(PurchaseOrderModel order) => '${order.id}:${order.updatedAt?.toIso8601String()}:${order.items.length}'; @@ -93,9 +103,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState> _nullableIntOptions(List options) { + List> _nullableIntOptions( + List options, + ) { return [ - const AppDropdownOption(value: null, label: 'None'), + const AppDropdownOption(value: null, label: '—'), ...options.map( (e) { final id = _parseId(e.id); @@ -158,6 +173,17 @@ class _PurchaseOrderFormScreenState extends ConsumerState>().toList(); } + PoOrderTotals _computeTotals(Map gstRatePctById) { + final lineCalcs = + _lines.map((line) => line.calculate(gstRatePctById)).toList(); + return PoOrderTotals.compute( + lines: lineCalcs, + freight: double.tryParse(_freightController.text.trim()) ?? 0, + otherCharges: double.tryParse(_otherChargesController.text.trim()) ?? 0, + discountAmount: double.tryParse(_discountController.text.trim()) ?? 0, + ); + } + Map _buildPayload() { return { 'po_date': DateFormatter.toApiDate(_poDate ?? DateTime.now()), @@ -171,12 +197,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState _parseId(e.id)).whereType(); final plantIds = lookups.plants.map((e) => _parseId(e.id)).whereType(); + final totals = _computeTotals(lookups.gstRatePctById); + final theme = Theme.of(context); return SingleChildScrollView( controller: _scrollController, - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), child: Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1200), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!widget.isEditing) - PageHeader( - title: 'Create Purchase Order', - subtitle: 'Fill header details and add line items', - ) - else if (existing?.poNo != null) - Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Text( - existing!.poNo!, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(existing), + if (_showReapprovalWarning(existing)) ...[ + const SizedBox(height: 8), + _ReapprovalBanner(), + ], + const SizedBox(height: 16), + _SectionCard( + title: 'ORDER DETAILS', + child: Column( + children: [ + FormRowFour( + children: [ + _DateField( + label: 'PO date *', + value: _poDate, + onTap: () => _pickDate( + current: _poDate, + onPicked: (d) => setState(() => _poDate = d), + ), + ), + AppSearchableDropdown( + label: 'PO type *', + value: _poType, + hint: 'Select PO type', + searchHint: 'Search type...', + options: poTypeOptions + .map( + (e) => AppDropdownOption( + value: e.$1, + label: e.$2, + ), + ) + .toList(), + onChanged: (v) => setState(() => _poType = v), + validator: (v) => + v == null ? 'PO type is required' : null, + ), + AppSearchableDropdown( + label: 'Vendor *', + value: _dropdownValue(_vendorId, vendorIds), + hint: 'Select vendor', + searchHint: 'Search vendor...', + options: _intOptions(lookups.vendors), + onChanged: (v) => setState(() => _vendorId = v), + validator: (v) => + v == null ? 'Vendor is required' : null, + ), + AppSearchableDropdown( + label: 'Plant *', + value: _dropdownValue(_plantId, plantIds), + hint: 'Select plant', + searchHint: 'Search plant...', + options: _intOptions(lookups.plants), + onChanged: (v) => setState(() => _plantId = v), + validator: (v) => + v == null ? 'Plant is required' : null, + ), + ], + ), + FormRowFour( + children: [ + AppSearchableDropdown( + label: 'Warehouse', + value: _warehouseId, + hint: 'Select warehouse', + searchHint: 'Search warehouse...', + options: _nullableIntOptions(lookups.warehouses), + onChanged: (v) => + setState(() => _warehouseId = v), + ), + AppSearchableDropdown( + label: 'Brand', + value: _brandId, + hint: 'Select brand', + searchHint: 'Search brand...', + options: _nullableIntOptions(lookups.brands), + onChanged: (v) => setState(() => _brandId = v), + ), + AppSearchableDropdown( + label: 'Payment term', + value: _paymentTermId, + hint: 'Select payment term', + searchHint: 'Search payment term...', + options: + _nullableIntOptions(lookups.paymentTerms), + onChanged: (v) => + setState(() => _paymentTermId = v), + ), + AppSearchableDropdown( + label: 'Delivery term', + value: _deliveryTermId, + hint: 'Select delivery term', + searchHint: 'Search delivery term...', + options: + _nullableIntOptions(lookups.deliveryTerms), + onChanged: (v) => + setState(() => _deliveryTermId = v), + ), + ], + ), + FormRow( + columnCount: 4, + children: [ + _DateField( + label: 'Expected delivery', + value: _expectedDeliveryDate, + onTap: () => _pickDate( + current: _expectedDeliveryDate, + onPicked: (d) => setState( + () => _expectedDeliveryDate = d, + ), + ), + ), + ], + ), + ], ), ), - Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + const SizedBox(height: 16), + PurchaseOrderLineItemsEditor( + lines: _lines, + items: lookups.items, + itemHsnById: lookups.itemHsnById, + itemUomById: lookups.itemUomById, + itemGstRateById: lookups.itemGstRateById, + uom: lookups.uom, + gstRates: lookups.gstRates, + gstRatePctById: lookups.gstRatePctById, + onAddLine: _addLine, + onRemoveLine: _removeLine, + onChanged: () => setState(() {}), + ), + const SizedBox(height: 16), + LayoutBuilder( + builder: (context, constraints) { + final stack = constraints.maxWidth < 900; + final additional = _SectionCard( + title: 'ADDITIONAL DETAILS', + child: Column( + children: [ + AppTextField( + controller: _termsController, + label: 'Terms & conditions', + hint: 'Payment terms, inspection conditions, etc.', + maxLines: 5, + ), + AppTextField( + controller: _remarksController, + label: 'Remarks', + hint: 'Any additional notes for this order.', + maxLines: 4, + ), + ], + ), + ); + final summary = _AmountSummaryCard( + totals: totals, + freightController: _freightController, + otherChargesController: _otherChargesController, + discountController: _discountController, + isEditing: widget.isEditing, + ); + + if (stack) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + additional, + const SizedBox(height: 16), + summary, + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 3, child: additional), + const SizedBox(width: 16), + Expanded(flex: 2, child: summary), + ], + ); + }, + ), + const SizedBox(height: 20), + Row( children: [ - FormRowFour( - children: [ - _DateField( - label: 'PO Date *', - value: _poDate, - onTap: () => _pickDate( - current: _poDate, - onPicked: (d) => setState(() => _poDate = d), - ), - ), - AppSearchableDropdown( - label: 'PO Type *', - value: _poType, - searchHint: 'Search type...', - options: poTypeOptions - .map( - (e) => - AppDropdownOption(value: e.$1, label: e.$2), - ) - .toList(), - onChanged: (v) => setState(() => _poType = v), - validator: (v) => - v == null ? 'PO type is required' : null, - ), - AppSearchableDropdown( - label: 'Vendor *', - value: _dropdownValue(_vendorId, vendorIds), - searchHint: 'Search vendor...', - options: _intOptions(lookups.vendors), - onChanged: (v) => setState(() => _vendorId = v), - validator: (v) => - v == null ? 'Vendor is required' : null, - ), - AppSearchableDropdown( - label: 'Plant *', - value: _dropdownValue(_plantId, plantIds), - searchHint: 'Search plant...', - options: _intOptions(lookups.plants), - onChanged: (v) => setState(() => _plantId = v), - validator: (v) => - v == null ? 'Plant is required' : null, - ), - ], + Text( + 'Fields marked * are required', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), - FormRowFour( - children: [ - AppSearchableDropdown( - label: 'Warehouse', - value: _warehouseId, - searchHint: 'Search warehouse...', - options: _nullableIntOptions(lookups.warehouses), - onChanged: (v) => setState(() => _warehouseId = v), - ), - AppSearchableDropdown( - label: 'Brand', - value: _brandId, - searchHint: 'Search brand...', - options: _nullableIntOptions(lookups.brands), - onChanged: (v) => setState(() => _brandId = v), - ), - AppSearchableDropdown( - label: 'Payment Term', - value: _paymentTermId, - searchHint: 'Search payment term...', - options: _nullableIntOptions(lookups.paymentTerms), - onChanged: (v) => setState(() => _paymentTermId = v), - ), - AppSearchableDropdown( - label: 'Delivery Term', - value: _deliveryTermId, - searchHint: 'Search delivery term...', - options: _nullableIntOptions(lookups.deliveryTerms), - onChanged: (v) => setState(() => _deliveryTermId = v), - ), - ], - ), - FormRowFour( - children: [ - _DateField( - label: 'Expected Delivery', - value: _expectedDeliveryDate, - onTap: () => _pickDate( - current: _expectedDeliveryDate, - onPicked: (d) => - setState(() => _expectedDeliveryDate = d), - ), - ), - AppTextField( - controller: _discountController, - label: 'Discount Amount', - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - ), - AppTextField( - controller: _freightController, - label: 'Freight Charges', - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - ), - AppTextField( - controller: _otherChargesController, - label: 'Other Charges', - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - ), - ], - ), - FormRowFour( - spans: const [2, 2], - children: [ - AppTextField( - controller: _termsController, - label: 'Terms & Conditions', - maxLines: 3, - ), - AppTextField( - controller: _remarksController, - label: 'Remarks', - maxLines: 3, - ), - ], - ), - const SizedBox(height: 24), - PurchaseOrderLineItemsEditor( - lines: _lines, - items: lookups.items, - uom: lookups.uom, - gstRates: lookups.gstRates, - onAddLine: _addLine, - onRemoveLine: _removeLine, - ), - const SizedBox(height: 24), - Row( - children: [ - OutlinedButton( - onPressed: _isSubmitting - ? null - : () => context.pop(), - child: const Text('Cancel'), - ), - const SizedBox(width: 12), - Expanded( - child: AppButton( - label: widget.isEditing ? 'Update PO' : 'Create PO', - expand: false, - isLoading: _isSubmitting, - onPressed: _isSubmitting ? null : _submit, - ), - ), - ], + const Spacer(), + Text( + widget.isEditing + ? '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · editing existing draft' + : '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), ], ), - ), - ], + ], + ), ), ), ), ); } + Widget _buildHeader(PurchaseOrderModel? existing) { + final theme = Theme.of(context); + final title = widget.isEditing + ? 'Edit ${existing?.poNo ?? 'purchase order'}' + : 'Create purchase order'; + final subtitle = widget.isEditing + ? null + : 'Fill in order details, add line items, then review the totals before saving.'; + + final actions = Row( + mainAxisSize: MainAxisSize.min, + children: [ + OutlinedButton( + onPressed: _isSubmitting ? null : () => context.pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 12), + AppButton( + label: widget.isEditing + ? 'Update purchase order' + : 'Save purchase order', + icon: Icons.check, + expand: false, + isLoading: _isSubmitting, + onPressed: _isSubmitting ? null : _submit, + ), + ], + ); + + final titleBlock = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + Text(title, style: theme.textTheme.headlineSmall), + if (widget.isEditing && existing != null) ...[ + PoStatusChip(status: existing.status, compact: true), + if (existing.revisionNo != null && existing.revisionNo! > 0) + PoRevisionChip( + revisionNo: existing.revisionNo!, + compact: true, + ), + ], + ], + ), + if (subtitle != null) ...[ + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: LayoutBuilder( + builder: (context, constraints) { + final stack = constraints.maxWidth < 720; + if (stack) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + tooltip: 'Back', + onPressed: _isSubmitting ? null : () => context.pop(), + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: 4), + Expanded(child: titleBlock), + ], + ), + const SizedBox(height: 12), + Align(alignment: Alignment.centerRight, child: actions), + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + tooltip: 'Back', + onPressed: _isSubmitting ? null : () => context.pop(), + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: 4), + Expanded(child: titleBlock), + const SizedBox(width: 12), + actions, + ], + ); + }, + ), + ); + } + @override Widget build(BuildContext context) { final lookupsAsync = ref.watch(purchaseOrderLookupsProvider); return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.surface, - surfaceTintColor: Colors.transparent, - title: Text( - widget.isEditing ? 'Edit Purchase Order' : 'Create Purchase Order', - ), - ), + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: lookupsAsync.when( loading: () => const AppLoadingView(message: 'Loading form options...'), error: (e, _) => ErrorView.fromFailure( @@ -562,6 +726,264 @@ class _PurchaseOrderFormScreenState extends ConsumerState 0 ? AppColors.error : null, + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: primaryTint, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Text( + 'Grand total', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + Text( + CurrencyFormatter.format(totals.grandTotal), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + color: theme.colorScheme.primary, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + Text( + isEditing + ? 'Editing charges or discount recalculates the grand total immediately — matches what will print on the PDF.' + : 'Taxable amount and tax are calculated automatically from line items. Grand total updates as you edit freight, other charges or discount.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + ], + ), + ); + } +} + +class _SummaryReadOnlyRow extends StatelessWidget { + const _SummaryReadOnlyRow({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Expanded( + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Text( + value, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } +} + +class _SummaryInputRow extends StatelessWidget { + const _SummaryInputRow({ + required this.label, + required this.controller, + this.valueColor, + }); + + final String label; + final TextEditingController controller; + final Color? valueColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + SizedBox( + width: 120, + child: TextFormField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + textAlign: TextAlign.right, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: valueColor, + ), + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _ReapprovalBanner extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFFFFF4E5), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFFFCC80)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.orange.shade800), + const SizedBox(width: 10), + Expanded( + child: Text( + 'This order is already Pending approval. Saving changes will reset it to Draft and require re-approval.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.orange.shade900, + ), + ), + ), + ], + ), + ); + } +} + class _DateField extends StatelessWidget { const _DateField({ required this.label, @@ -584,10 +1006,15 @@ class _DateField extends StatelessWidget { decoration: InputDecoration( labelText: label, floatingLabelBehavior: FloatingLabelBehavior.always, - suffixIcon: const Icon(Icons.calendar_today_outlined), + suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18), ), child: Text( value != null ? DateFormatter.displayDate(value) : 'Select date', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: value == null + ? Theme.of(context).colorScheme.onSurfaceVariant + : null, + ), ), ), ), diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart index 6ce5a61..e2a3099 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart @@ -169,6 +169,8 @@ class _PurchaseOrderListScreenState extends ConsumerState lines, + required double freight, + required double otherCharges, + required double discountAmount, + }) { + var taxable = 0.0; + var tax = 0.0; + for (final line in lines) { + taxable += line.lineAmount; + tax += line.gstAmount; + } + final grandTotal = taxable + tax + freight + otherCharges - discountAmount; + return PoOrderTotals( + taxableAmount: taxable, + taxAmount: tax, + grandTotal: grandTotal, + ); + } +} + class PoLineItemDraft { PoLineItemDraft({ this.itemId, @@ -16,11 +103,11 @@ class PoLineItemDraft { TextEditingController? rateController, TextEditingController? discountController, this.gstRateId, - TextEditingController? remarksController, + this.hsnCodeId, }) : qtyController = qtyController ?? TextEditingController(), rateController = rateController ?? TextEditingController(), - discountController = discountController ?? TextEditingController(text: '0'), - remarksController = remarksController ?? TextEditingController(); + discountController = + discountController ?? TextEditingController(text: '0'); int? itemId; int lineNo; @@ -29,19 +116,20 @@ class PoLineItemDraft { final TextEditingController rateController; final TextEditingController discountController; int? gstRateId; - final TextEditingController remarksController; + int? hsnCodeId; factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) { return PoLineItemDraft( itemId: item.itemId, lineNo: item.lineNo ?? 1, - qtyController: TextEditingController(text: item.orderedQty?.toString() ?? ''), + qtyController: + TextEditingController(text: item.orderedQty?.toString() ?? ''), uomId: item.uomId, rateController: TextEditingController(text: item.rate?.toString() ?? ''), discountController: TextEditingController(text: item.discountPct?.toString() ?? '0'), gstRateId: item.gstRateId, - remarksController: TextEditingController(text: item.remarks ?? ''), + hsnCodeId: item.hsnCodeId, ); } @@ -49,7 +137,21 @@ class PoLineItemDraft { qtyController.dispose(); rateController.dispose(); discountController.dispose(); - remarksController.dispose(); + } + + PoLineCalculation calculate(Map gstRatePctById) { + final qty = double.tryParse(qtyController.text.trim()) ?? 0; + final rate = double.tryParse(rateController.text.trim()) ?? 0; + final discPct = double.tryParse(discountController.text.trim()) ?? 0; + final gstPct = gstRateId == null + ? 0.0 + : (gstRatePctById[gstRateId.toString()] ?? 0.0); + return PoLineCalculation.compute( + qty: qty, + rate: rate, + discPct: discPct, + gstPct: gstPct, + ); } Map toPayload() { @@ -66,8 +168,7 @@ class PoLineItemDraft { 'rate': rate, 'discount_pct': double.tryParse(discountController.text.trim()) ?? 0, if (gstRateId != null) 'gst_rate_id': gstRateId, - if (remarksController.text.trim().isNotEmpty) - 'remarks': remarksController.text.trim(), + if (hsnCodeId != null) 'hsn_code_id': hsnCodeId, }; } } @@ -77,79 +178,134 @@ class PurchaseOrderLineItemsEditor extends StatefulWidget { super.key, required this.lines, required this.items, + required this.itemHsnById, + required this.itemUomById, + required this.itemGstRateById, required this.uom, required this.gstRates, + required this.gstRatePctById, required this.onAddLine, required this.onRemoveLine, + this.onChanged, }); final List lines; final List items; + final Map itemHsnById; + final Map itemUomById; + final Map itemGstRateById; final List uom; final List gstRates; + final Map gstRatePctById; final VoidCallback onAddLine; final ValueChanged onRemoveLine; + final VoidCallback? onChanged; @override State createState() => _PurchaseOrderLineItemsEditorState(); } -class _PurchaseOrderLineItemsEditorState extends State { +class _PurchaseOrderLineItemsEditorState + extends State { + void _notifyChanged() { + widget.onChanged?.call(); + setState(() {}); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final cardColor = isDark + ? theme.colorScheme.surfaceContainerHighest + : theme.colorScheme.surface; + final borderColor = theme.colorScheme.outline.withValues( + alpha: isDark ? 0.35 : 0.2, + ); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text('Line Items', style: theme.textTheme.titleMedium), - const Spacer(), - TextButton.icon( - onPressed: widget.onAddLine, - icon: const Icon(Icons.add), - label: const Text('Add line'), - ), - ], - ), - const SizedBox(height: 8), - if (widget.lines.isEmpty) - Container( - width: double.infinity, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - 'Add at least one line item', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text( + 'LINE ITEMS', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), ), - textAlign: TextAlign.center, - ), - ) - else - ...widget.lines.asMap().entries.map((entry) { - final index = entry.key; - final line = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _LineItemCard( - key: ObjectKey(line), - line: line, - items: widget.items, - uom: widget.uom, - gstRates: widget.gstRates, - onRemove: widget.lines.length > 1 - ? () => widget.onRemoveLine(index) - : null, + const Spacer(), + TextButton.icon( + onPressed: () { + widget.onAddLine(); + widget.onChanged?.call(); + }, + icon: const Icon(Icons.add, size: 18), + label: const Text('Add line'), + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.primary, + ), ), - ); - }), - ], + ], + ), + const SizedBox(height: 8), + if (widget.lines.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Add at least one line item', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ) + else + ...widget.lines.asMap().entries.map((entry) { + final index = entry.key; + final line = entry.value; + return Padding( + padding: EdgeInsets.only( + bottom: index == widget.lines.length - 1 ? 0 : 12, + ), + child: _LineItemCard( + key: ObjectKey(line), + line: line, + items: widget.items, + itemHsnById: widget.itemHsnById, + itemUomById: widget.itemUomById, + itemGstRateById: widget.itemGstRateById, + uom: widget.uom, + gstRates: widget.gstRates, + gstRatePctById: widget.gstRatePctById, + onChanged: _notifyChanged, + onRemove: widget.lines.length > 1 + ? () { + widget.onRemoveLine(index); + widget.onChanged?.call(); + } + : null, + ), + ); + }), + ], + ), ); } } @@ -159,15 +315,25 @@ class _LineItemCard extends StatefulWidget { super.key, required this.line, required this.items, + required this.itemHsnById, + required this.itemUomById, + required this.itemGstRateById, required this.uom, required this.gstRates, + required this.gstRatePctById, + required this.onChanged, this.onRemove, }); final PoLineItemDraft line; final List items; + final Map itemHsnById; + final Map itemUomById; + final Map itemGstRateById; final List uom; final List gstRates; + final Map gstRatePctById; + final VoidCallback onChanged; final VoidCallback? onRemove; @override @@ -175,18 +341,69 @@ class _LineItemCard extends StatefulWidget { } class _LineItemCardState extends State<_LineItemCard> { + @override + void initState() { + super.initState(); + widget.line.qtyController.addListener(_onFieldChanged); + widget.line.rateController.addListener(_onFieldChanged); + widget.line.discountController.addListener(_onFieldChanged); + } + + @override + void dispose() { + widget.line.qtyController.removeListener(_onFieldChanged); + widget.line.rateController.removeListener(_onFieldChanged); + widget.line.discountController.removeListener(_onFieldChanged); + super.dispose(); + } + + void _onFieldChanged() { + widget.onChanged(); + setState(() {}); + } + int? _parseId(String value) => int.tryParse(value.trim()); void _updateLine(void Function() mutate) { mutate(); + widget.onChanged(); setState(() {}); } + void _onItemChanged(int? itemId) { + _updateLine(() { + widget.line.itemId = itemId; + if (itemId == null) return; + final key = itemId.toString(); + final defaultUom = widget.itemUomById[key]; + if (defaultUom != null) { + widget.line.uomId = defaultUom; + } + final defaultGst = widget.itemGstRateById[key]; + if (defaultGst != null) { + widget.line.gstRateId = defaultGst; + } + final defaultHsn = widget.itemHsnById[key]; + if (defaultHsn != null) { + widget.line.hsnCodeId = defaultHsn; + } + }); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final line = widget.line; final lineKey = 'po-line-${line.lineNo}'; + final calc = line.calculate(widget.gstRatePctById); + final isDark = theme.brightness == Brightness.dark; + final borderColor = theme.colorScheme.outline.withValues( + alpha: isDark ? 0.35 : 0.18, + ); + final amountBg = theme.colorScheme.primary.withValues( + alpha: isDark ? 0.18 : 0.08, + ); + final itemOptions = widget.items .map((e) { final id = _parseId(e.id); @@ -204,7 +421,7 @@ class _LineItemCardState extends State<_LineItemCard> { .whereType>() .toList(); final gstOptions = [ - const AppDropdownOption(value: null, label: 'No GST'), + const AppDropdownOption(value: null, label: 'Select GST rate'), ...widget.gstRates.map((e) { final id = _parseId(e.id); if (id == null) return null; @@ -213,116 +430,179 @@ class _LineItemCardState extends State<_LineItemCard> { ].whereType>().toList(); return Container( - padding: const EdgeInsets.symmetric(vertical: 16), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(10), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: FormRow( + columnCount: 12, + spans: const [3, 1, 2, 1, 1, 2, 2], + spacing: 8, + stackBelowWidth: 1100, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Text('Line ${line.lineNo}', style: theme.textTheme.titleSmall), - const Spacer(), - if (widget.onRemove != null) - IconButton( - tooltip: 'Remove line', - icon: const Icon(Icons.delete_outline), - onPressed: widget.onRemove, - ), - ], - ), + AppSearchableDropdown( + key: ValueKey('$lineKey-item'), + label: 'Item *', + value: line.itemId, + hint: 'Select item', + searchHint: 'Search item...', + options: itemOptions, + onChanged: _onItemChanged, + validator: (v) => v == null ? 'Item is required' : null, ), - const SizedBox(height: 12), - FormRow( - columnCount: 6, - horizontalPadding: 16, - spacing: 8, - stackBelowWidth: 992, - children: [ - AppSearchableDropdown( - key: ValueKey('$lineKey-item'), - label: 'Item *', - value: line.itemId, - searchHint: 'Search item...', - options: itemOptions, - onChanged: (v) => _updateLine(() => line.itemId = v), - validator: (v) => v == null ? 'Item is required' : null, - ), - AppTextField( - key: ValueKey('$lineKey-qty'), - controller: line.qtyController, - label: 'Quantity *', - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - validator: (v) { - if (v == null || v.trim().isEmpty) { - return 'Quantity is required'; - } - final qty = double.tryParse(v); - if (qty == null || qty <= 0) return 'Enter a valid quantity'; - return null; - }, - ), - AppSearchableDropdown( - key: ValueKey('$lineKey-uom'), - label: 'UOM *', - value: line.uomId, - searchHint: 'Search UOM...', - options: uomOptions, - onChanged: (v) => _updateLine(() => line.uomId = v), - validator: (v) => v == null ? 'UOM is required' : null, - ), - AppTextField( - key: ValueKey('$lineKey-rate'), - controller: line.rateController, - label: 'Rate *', - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - validator: (v) { - if (v == null || v.trim().isEmpty) return 'Rate is required'; - final rate = double.tryParse(v); - if (rate == null || rate < 0) return 'Enter a valid rate'; - return null; - }, - ), - AppTextField( - key: ValueKey('$lineKey-discount'), - controller: line.discountController, - label: 'Discount %', - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - ), - AppSearchableDropdown( - key: ValueKey('$lineKey-gst'), - label: 'GST Rate', - value: line.gstRateId, - searchHint: 'Search GST rate...', - options: gstOptions, - onChanged: (v) => _updateLine(() => line.gstRateId = v), - ), - ], + AppTextField( + key: ValueKey('$lineKey-qty'), + controller: line.qtyController, + label: 'Qty *', + hint: '0', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'Required'; + } + final qty = double.tryParse(v); + if (qty == null || qty <= 0) return 'Invalid'; + return null; + }, ), - FormRow( - columnCount: 6, - spans: const [6], - horizontalPadding: 16, - spacing: 8, - stackBelowWidth: 992, - children: [ - AppTextField( - key: ValueKey('$lineKey-remarks'), - controller: line.remarksController, - label: 'Remarks', - maxLines: 2, - ), - ], + AppSearchableDropdown( + key: ValueKey('$lineKey-uom'), + label: 'UOM *', + value: line.uomId, + hint: 'Select UOM', + searchHint: 'Search UOM...', + options: uomOptions, + onChanged: (v) => _updateLine(() => line.uomId = v), + validator: (v) => v == null ? 'Required' : null, + ), + AppTextField( + key: ValueKey('$lineKey-rate'), + controller: line.rateController, + label: 'Rate *', + hint: '0.00', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Required'; + final rate = double.tryParse(v); + if (rate == null || rate < 0) return 'Invalid'; + return null; + }, + ), + AppTextField( + key: ValueKey('$lineKey-discount'), + controller: line.discountController, + label: 'Disc %', + hint: '0', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + ), + AppSearchableDropdown( + key: ValueKey('$lineKey-gst'), + label: 'GST rate', + value: line.gstRateId, + hint: 'Select GST rate', + searchHint: 'Search GST rate...', + options: gstOptions, + onChanged: (v) => _updateLine(() => line.gstRateId = v), + ), + _AmountWithRemove( + amount: CurrencyFormatter.format(calc.lineAmount), + backgroundColor: amountBg, + onRemove: widget.onRemove, ), ], ), ); } } + +class _AmountWithRemove extends StatelessWidget { + const _AmountWithRemove({ + required this.amount, + required this.backgroundColor, + this.onRemove, + }); + + final String amount; + final Color backgroundColor; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _AmountDisplay( + label: 'Amount', + value: amount, + backgroundColor: backgroundColor, + ), + ), + if (onRemove != null) ...[ + const SizedBox(width: 4), + Padding( + padding: const EdgeInsets.only(top: 20), + child: IconButton( + tooltip: 'Remove line', + onPressed: onRemove, + icon: const Icon(Icons.delete_outline, size: 20), + color: theme.colorScheme.error, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + padding: EdgeInsets.zero, + ), + ), + ], + ], + ); + } +} + +class _AmountDisplay extends StatelessWidget { + const _AmountDisplay({ + required this.label, + required this.value, + required this.backgroundColor, + }); + + final String label; + final String value; + final Color backgroundColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + floatingLabelBehavior: FloatingLabelBehavior.always, + filled: true, + fillColor: backgroundColor, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: theme.colorScheme.primary.withValues(alpha: 0.2), + ), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + value, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), + ), + ), + ); + } +} diff --git a/lib/modules/rbac/presentation/widgets/rbac_widgets.dart b/lib/modules/rbac/presentation/widgets/rbac_widgets.dart index 364f4fc..81d6401 100644 --- a/lib/modules/rbac/presentation/widgets/rbac_widgets.dart +++ b/lib/modules/rbac/presentation/widgets/rbac_widgets.dart @@ -1,6 +1,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import '../../../../shared/widgets/app_data_table.dart'; + import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/widgets/app_card.dart'; @@ -221,12 +223,13 @@ class EmployeeCodeBadge extends StatelessWidget { color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(20), ), - child: Text( + child: AppTableCell.text( code, style: theme.textTheme.labelSmall?.copyWith( fontWeight: FontWeight.w600, color: theme.colorScheme.onSurfaceVariant, ), + showTooltip: true, ), ); } @@ -246,24 +249,24 @@ class UserTableUserCell extends StatelessWidget { UserAvatarChip( name: user.fullName, initials: user.initialsDisplay, + radius: 16, ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ - Text( + AppTableCell.text( user.fullName, style: const TextStyle(fontWeight: FontWeight.w600), - overflow: TextOverflow.ellipsis, ), - Text( + AppTableCell.text( user.email, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), - overflow: TextOverflow.ellipsis, ), ], ), @@ -274,10 +277,16 @@ class UserTableUserCell extends StatelessWidget { } class UserAvatarChip extends StatelessWidget { - const UserAvatarChip({super.key, required this.name, this.initials}); + const UserAvatarChip({ + super.key, + required this.name, + this.initials, + this.radius = 20, + }); final String name; final String? initials; + final double radius; @override Widget build(BuildContext context) { @@ -293,14 +302,14 @@ class UserAvatarChip extends StatelessWidget { (name.isNotEmpty ? name.trim()[0].toUpperCase() : 'U'); return CircleAvatar( - radius: 20, + radius: radius, backgroundColor: color.withValues(alpha: 0.12), child: Text( display.length > 2 ? display.substring(0, 2) : display, style: TextStyle( color: color, fontWeight: FontWeight.w700, - fontSize: 12, + fontSize: radius * 0.6, ), ), ); diff --git a/lib/modules/roles/presentation/screens/permission_matrix_screen.dart b/lib/modules/roles/presentation/screens/permission_matrix_screen.dart index a86e079..9779856 100644 --- a/lib/modules/roles/presentation/screens/permission_matrix_screen.dart +++ b/lib/modules/roles/presentation/screens/permission_matrix_screen.dart @@ -1,4 +1,5 @@ import '../../../../shared/widgets/app_card.dart'; +import '../../../../shared/widgets/app_data_table.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -98,7 +99,7 @@ class _MatrixGrid extends ConsumerWidget { .map( (row) => DataRow( cells: [ - DataCell(Text(row.name)), + DataCell(AppTableCell.text(row.name)), ...matrix.actionColumns.map( (action) => DataCell( _actionToggle( diff --git a/lib/modules/settings/data/datasources/settings_remote_data_source.dart b/lib/modules/settings/data/datasources/settings_remote_data_source.dart index 28c1d89..c953c51 100644 --- a/lib/modules/settings/data/datasources/settings_remote_data_source.dart +++ b/lib/modules/settings/data/datasources/settings_remote_data_source.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/utils/media_url.dart'; import '../../domain/entities/app_settings.dart'; class SettingsRemoteDataSource { @@ -8,36 +9,54 @@ class SettingsRemoteDataSource { final Dio _dio; + Map? _asDataMap(dynamic responseData) { + if (responseData is! Map) return null; + final root = Map.from(responseData); + final data = root['data']; + if (data is Map) return data; + if (data is Map) return Map.from(data); + // Some responses return the entity at the root alongside success/message. + if (root.containsKey('org_name') || + root.containsKey('smtp_host') || + root.containsKey('logo_url') || + root.containsKey('logo')) { + return root; + } + return null; + } + Future fetch() async { final response = await _dio.get(ApiEndpoints.settings); - final data = response.data['data']; - if (data is! Map) return null; + final data = _asDataMap(response.data); + if (data == null) return null; return AppSettings.fromJson(data); } Future save(AppSettings settings) async { final response = await _dio.put(ApiEndpoints.settings, data: settings.toJson()); - final data = response.data['data']; - if (data is Map) { + final data = _asDataMap(response.data); + if (data != null) { return AppSettings.fromJson(data); } return settings; } + /// GET `/settings/company` Future fetchCompany() async { final response = await _dio.get(ApiEndpoints.settingsCompany); - final data = response.data['data']; - if (data is! Map) return null; + final data = _asDataMap(response.data); + if (data == null) return null; return CompanyProfileSettings.fromApiJson(data); } + /// PUT `/settings/company` Future saveCompany(CompanyProfileSettings profile) async { final response = await _dio.put( ApiEndpoints.settingsCompany, data: profile.toApiJson(), ); - final data = response.data['data']; - if (data is Map) { + final data = _asDataMap(response.data); + if (data != null) { return CompanyProfileSettings.fromApiJson(data).copyWith( companyCode: profile.companyCode, registrationNumber: profile.registrationNumber, @@ -48,6 +67,7 @@ class SettingsRemoteDataSource { return profile; } + /// POST `/settings/company/logo` (multipart field `logo`) Future uploadCompanyLogo(List bytes, String filename) async { final formData = FormData.fromMap({ 'logo': MultipartFile.fromBytes(bytes, filename: filename), @@ -56,20 +76,26 @@ class SettingsRemoteDataSource { ApiEndpoints.settingsCompanyLogo, data: formData, ); - final data = response.data['data']; - if (data is Map) { - return data['logo_url'] as String? ?? data['logo'] as String?; + final data = _asDataMap(response.data); + if (data != null) { + return resolveMediaUrl( + data['logo_url'] as String? ?? + data['logoUrl'] as String? ?? + data['logo'] as String?, + ); } return null; } + /// GET `/settings/email` Future fetchEmail() async { final response = await _dio.get(ApiEndpoints.settingsEmail); - final data = response.data['data']; - if (data is! Map) return null; + final data = _asDataMap(response.data); + if (data == null) return null; return EmailConfigurationSettings.fromApiJson(data); } + /// PUT `/settings/email` Future saveEmail( EmailConfigurationSettings email, ) async { @@ -77,8 +103,8 @@ class SettingsRemoteDataSource { ApiEndpoints.settingsEmail, data: email.toApiJson(), ); - final data = response.data['data']; - if (data is Map) { + final data = _asDataMap(response.data); + if (data != null) { return EmailConfigurationSettings.fromApiJson(data).copyWith( allocationTemplate: email.allocationTemplate, returnTemplate: email.returnTemplate, diff --git a/lib/modules/settings/domain/entities/app_settings.dart b/lib/modules/settings/domain/entities/app_settings.dart index 91faf9b..2b5beda 100644 --- a/lib/modules/settings/domain/entities/app_settings.dart +++ b/lib/modules/settings/domain/entities/app_settings.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../../../core/utils/media_url.dart'; + class GeneralSettings { const GeneralSettings({ this.defaultBranch = '', @@ -145,18 +147,17 @@ class CompanyProfileSettings { 'faviconUrl': faviconUrl, }; - Map toApiJson() { - final payload = {}; - 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; - } + /// Payload for `PUT /settings/company` ([CompanySettingsBody]). + Map toApiJson() => { + 'org_name': companyName, + 'mobile': phone, + 'email': email, + 'website': website, + 'address': address, + 'city': city, + 'state': state, + 'pincode': pincode, + }; factory CompanyProfileSettings.fromJson(Map json) => CompanyProfileSettings.fromApiJson(json); @@ -176,11 +177,13 @@ class CompanyProfileSettings { email: json['email'] as String? ?? '', phone: json['mobile'] as String? ?? json['phone'] as String? ?? '', website: json['website'] as String? ?? '', - logoUrl: json['logo_url'] as String? ?? - json['logoUrl'] as String? ?? - json['logo'] as String? ?? + logoUrl: resolveMediaUrl( + json['logo_url'] as String? ?? + json['logoUrl'] as String? ?? + json['logo'] as String?, + ) ?? '', - faviconUrl: json['faviconUrl'] as String? ?? '', + faviconUrl: resolveMediaUrl(json['faviconUrl'] as String?) ?? '', ); } @@ -450,14 +453,19 @@ class EmailConfigurationSettings { 'warrantyTemplate': warrantyTemplate, }; + /// Payload for `PUT /settings/email` ([EmailSettingsBody]). + /// Omits blank password so an existing SMTP password is not cleared. Map toApiJson() { - final payload = {}; - 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; + final payload = { + 'smtp_host': smtpHost, + 'smtp_port': smtpPort, + 'smtp_username': smtpUsername, + 'sender_email': senderEmail, + 'sender_name': senderName, + }; + if (smtpPassword.isNotEmpty) { + payload['smtp_password'] = smtpPassword; + } return payload; } @@ -659,6 +667,7 @@ class SettingsSection { required this.icon, required this.route, this.phase = 1, + this.hidden = false, }); final String id; @@ -667,6 +676,8 @@ class SettingsSection { final IconData icon; final String route; final int phase; + /// When true, the card stays defined but is not shown on the Settings hub. + final bool hidden; } const phase1SettingsSections = [ @@ -696,7 +707,7 @@ const phase1SettingsSections = [ title: 'Roles & Permissions', subtitle: 'Manage roles, permissions, and menu access', icon: Icons.security_outlined, - route: '/settings/roles', + route: '/users-roles?tab=permissions', ), SettingsSection( id: 'asset', @@ -704,6 +715,7 @@ const phase1SettingsSections = [ subtitle: 'Asset codes, statuses, warranty, and QR', icon: Icons.inventory_2_outlined, route: '/settings/asset', + hidden: true, ), SettingsSection( id: 'notifications', @@ -711,11 +723,12 @@ const phase1SettingsSections = [ subtitle: 'Email, SMS, push, and in-app alerts', icon: Icons.notifications_outlined, route: '/settings/notifications', + hidden: true, ), SettingsSection( id: 'email', title: 'Email Configuration', - subtitle: 'SMTP server and email templates', + subtitle: 'SMTP server settings', icon: Icons.email_outlined, route: '/settings/email', ), @@ -725,9 +738,11 @@ const phase1SettingsSections = [ subtitle: 'Authentication, session, and audit policies', icon: Icons.lock_outline, route: '/settings/security', + hidden: true, ), ]; +/// Phase 2 cards are kept for later; currently hidden on the Settings hub. const phase2SettingsSections = [ SettingsSection( id: 'workflow', @@ -736,6 +751,7 @@ const phase2SettingsSections = [ icon: Icons.account_tree_outlined, route: '/settings/workflow', phase: 2, + hidden: true, ), SettingsSection( id: 'dashboard', @@ -744,6 +760,7 @@ const phase2SettingsSections = [ icon: Icons.dashboard_outlined, route: '/settings/dashboard', phase: 2, + hidden: true, ), SettingsSection( id: 'reports', @@ -752,6 +769,7 @@ const phase2SettingsSections = [ icon: Icons.assessment_outlined, route: '/settings/reports', phase: 2, + hidden: true, ), SettingsSection( id: 'storage', @@ -760,6 +778,7 @@ const phase2SettingsSections = [ icon: Icons.cloud_upload_outlined, route: '/settings/storage', phase: 2, + hidden: true, ), SettingsSection( id: 'audit', @@ -768,6 +787,7 @@ const phase2SettingsSections = [ icon: Icons.history_outlined, route: '/settings/audit', phase: 2, + hidden: true, ), SettingsSection( id: 'mobile', @@ -776,6 +796,7 @@ const phase2SettingsSections = [ icon: Icons.phone_android_outlined, route: '/settings/mobile', phase: 2, + hidden: true, ), SettingsSection( id: 'integrations', @@ -784,5 +805,14 @@ const phase2SettingsSections = [ icon: Icons.extension_outlined, route: '/settings/integrations', phase: 2, + hidden: true, ), ]; + +/// Visible Phase 1 cards for the Settings hub. +List get visiblePhase1SettingsSections => + phase1SettingsSections.where((s) => !s.hidden).toList(); + +/// Visible Phase 2 cards for the Settings hub (empty while all are hidden). +List get visiblePhase2SettingsSections => + phase2SettingsSections.where((s) => !s.hidden).toList(); diff --git a/lib/modules/settings/presentation/providers/settings_provider.dart b/lib/modules/settings/presentation/providers/settings_provider.dart index 20cb235..280dd68 100644 --- a/lib/modules/settings/presentation/providers/settings_provider.dart +++ b/lib/modules/settings/presentation/providers/settings_provider.dart @@ -1,8 +1,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/errors/failure.dart'; +import '../../../../core/network/api_handler.dart'; import '../../../../core/network/dio_client.dart'; +import '../../../../core/theme/branding_config.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../../core/utils/favicon_store.dart'; +import '../../../../core/utils/media_url.dart'; import '../../data/datasources/settings_local_data_source.dart'; import '../../data/datasources/settings_remote_data_source.dart'; import '../../data/repositories/settings_repository_impl.dart'; @@ -42,6 +46,17 @@ final appSettingsProvider = saveSettings: ref.watch(saveSettingsUseCaseProvider), repository: ref.watch(settingsRepositoryProvider), faviconStore: FaviconStore(ref.watch(sharedPreferencesProvider)), + syncAppLogo: (logoUrl, companyName) async { + final branding = ref.read(brandingProvider); + await ref.read(brandingProvider.notifier).updateBranding( + BrandingConfig( + logoUrl: logoUrl, + primaryColorValue: branding.primaryColorValue, + secondaryColorValue: branding.secondaryColorValue, + companyName: companyName ?? branding.companyName, + ), + ); + }, ); }); @@ -51,10 +66,13 @@ class AppSettingsNotifier extends StateNotifier { required SaveSettingsUseCase saveSettings, required SettingsRepository repository, required FaviconStore faviconStore, + required Future Function(String? logoUrl, String? companyName) + syncAppLogo, }) : _getSettings = getSettings, _saveSettings = saveSettings, _repository = repository, _faviconStore = faviconStore, + _syncAppLogo = syncAppLogo, super(const AppSettings()) { _load(); } @@ -63,25 +81,38 @@ class AppSettingsNotifier extends StateNotifier { final SaveSettingsUseCase _saveSettings; final SettingsRepository _repository; final FaviconStore _faviconStore; + final Future Function(String? logoUrl, String? companyName) _syncAppLogo; + + Future _syncMainLogo(CompanyProfileSettings profile) async { + final logo = resolveMediaUrl(profile.logoUrl); + await _syncAppLogo( + (logo == null || logo.isEmpty) ? null : logo, + profile.companyName.isEmpty ? null : profile.companyName, + ); + } Future _load() async { final result = await _getSettings(); state = result.data ?? const AppSettings(); _faviconStore.apply(); + await _syncMainLogo(state.companyProfile); } - Future refreshCompanyProfile() async { + Future refreshCompanyProfile() async { final result = await _repository.fetchCompanyProfile(); if (result.failure == null && result.data != null) { state = state.copyWith(companyProfile: result.data!); + await _syncMainLogo(result.data!); } + return result.failure; } - Future refreshEmailSettings() async { + Future refreshEmailSettings() async { final result = await _repository.fetchEmailSettings(); if (result.failure == null && result.data != null) { state = state.copyWith(email: result.data!); } + return result.failure; } Future _persist(AppSettings settings) async { @@ -94,29 +125,44 @@ class AppSettingsNotifier extends StateNotifier { await _persist(state.copyWith(general: general)); } - Future updateCompanyProfile(CompanyProfileSettings profile) async { + /// PUT `/settings/company` — returns failure when the API call fails. + Future updateCompanyProfile(CompanyProfileSettings profile) async { final result = await _repository.saveCompanyProfile(profile); - if (result.failure == null && result.data != null) { + if (result.failure != null) return result.failure; + if (result.data != null) { state = state.copyWith(companyProfile: result.data!); - return; - } - await _persist(state.copyWith(companyProfile: profile)); - } - - Future uploadCompanyLogo(List 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; + await _saveSettings(state); + await _syncMainLogo(result.data!); } return null; } + /// POST `/settings/company/logo` — also updates the app main logo. + Future> uploadCompanyLogo( + List bytes, + String filename, + ) async { + final result = await _repository.uploadCompanyLogo(bytes, filename); + if (result.failure == null && result.data != null && result.data!.isNotEmpty) { + final logoUrl = resolveMediaUrl(result.data!) ?? result.data!; + final profile = state.companyProfile.copyWith(logoUrl: logoUrl); + state = state.copyWith(companyProfile: profile); + await _saveSettings(state); + await _syncMainLogo(profile); + return (data: logoUrl, failure: null); + } + return result; + } + + /// Applies a local/preview logo to company profile + main app branding. + Future applyLocalCompanyLogo(String logoUrl) async { + final resolved = resolveMediaUrl(logoUrl) ?? logoUrl; + final profile = state.companyProfile.copyWith(logoUrl: resolved); + state = state.copyWith(companyProfile: profile); + await _saveSettings(state); + await _syncMainLogo(profile); + } + Future updateUiPreferences(UiPreferencesSettings prefs) async { await _persist(state.copyWith(uiPreferences: prefs)); } @@ -129,14 +175,15 @@ class AppSettingsNotifier extends StateNotifier { await _persist(state.copyWith(notifications: notifications)); } - Future updateEmail(EmailConfigurationSettings email) async { + /// PUT `/settings/email` — returns failure when the API call fails. + Future updateEmail(EmailConfigurationSettings email) async { final result = await _repository.saveEmailSettings(email); - if (result.failure == null && result.data != null) { + if (result.failure != null) return result.failure; + if (result.data != null) { state = state.copyWith(email: result.data!); await _saveSettings(state); - return; } - await _persist(state.copyWith(email: email)); + return null; } Future updateSecurity(SecuritySettingsConfig security) async { @@ -145,5 +192,6 @@ class AppSettingsNotifier extends StateNotifier { Future resetToDefaults() async { await _persist(const AppSettings()); + await _syncAppLogo(null, null); } } diff --git a/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart b/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart index d213fcc..1bef63e 100644 --- a/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart +++ b/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart @@ -4,6 +4,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/network/api_handler.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../../core/utils/favicon_store.dart'; import '../../../../core/utils/favicon_updater.dart'; @@ -41,15 +42,16 @@ class _CompanyProfileSettingsScreenState late final TextEditingController _logoUrlController; late final TextEditingController _faviconUrlController; + bool _loading = true; + bool _saving = false; + bool _uploadingLogo = false; + @override void initState() { super.initState(); 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 = @@ -68,6 +70,43 @@ class _CompanyProfileSettingsScreenState ? profile.faviconUrl : faviconFromPrefs, ); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadFromApi()); + } + + Future _loadFromApi() async { + setState(() => _loading = true); + final failure = + await ref.read(appSettingsProvider.notifier).refreshCompanyProfile(); + if (!mounted) return; + _applyProfile(ref.read(appSettingsProvider).companyProfile); + setState(() => _loading = false); + if (failure != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(failure.message), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + + void _applyProfile(CompanyProfileSettings profile) { + _nameController.text = profile.companyName; + _codeController.text = profile.companyCode; + _registrationController.text = profile.registrationNumber; + _gstController.text = profile.gstNumber; + _addressController.text = profile.address; + _cityController.text = profile.city; + _stateController.text = profile.state; + _pincodeController.text = profile.pincode; + _emailController.text = profile.email; + _phoneController.text = profile.phone; + _websiteController.text = profile.website; + _logoUrlController.text = profile.logoUrl; + if (profile.faviconUrl.isNotEmpty) { + _faviconUrlController.text = profile.faviconUrl; + } + setState(() {}); } @override @@ -95,23 +134,37 @@ class _CompanyProfileSettingsScreenState final faviconUrl = _faviconUrlController.text.trim(); final companyName = _nameController.text.trim(); - await ref.read(appSettingsProvider.notifier).updateCompanyProfile( - CompanyProfileSettings( - companyName: companyName, - companyCode: _codeController.text.trim(), - 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(), - logoUrl: logoUrl, - faviconUrl: faviconUrl, - ), - ); + setState(() => _saving = true); + final failure = + await ref.read(appSettingsProvider.notifier).updateCompanyProfile( + CompanyProfileSettings( + companyName: companyName, + companyCode: _codeController.text.trim(), + 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(), + logoUrl: logoUrl, + faviconUrl: faviconUrl, + ), + ); + if (!mounted) return; + setState(() => _saving = false); + + if (failure != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(validationErrorMessage(failure)), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + return; + } await ref.read(brandingProvider.notifier).updateBranding( ref.read(brandingProvider).copyWith( @@ -165,14 +218,19 @@ class _CompanyProfileSettingsScreenState final bytes = file.bytes; if (bytes == null) return; - final uploadedUrl = await ref.read(appSettingsProvider.notifier).uploadCompanyLogo( - bytes, - file.name, - ); - + setState(() => _uploadingLogo = true); + final uploadResult = + await ref.read(appSettingsProvider.notifier).uploadCompanyLogo( + bytes, + file.name, + ); if (!mounted) return; + setState(() => _uploadingLogo = false); - if (uploadedUrl != null && uploadedUrl.isNotEmpty) { + final uploadedUrl = uploadResult.data; + if (uploadResult.failure == null && + uploadedUrl != null && + uploadedUrl.isNotEmpty) { setState(() => _logoUrlController.text = uploadedUrl); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Logo uploaded')), @@ -180,9 +238,21 @@ class _CompanyProfileSettingsScreenState return; } + if (uploadResult.failure != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(uploadResult.failure!.message), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + + // Local preview fallback when upload is unavailable — still apply as main logo. final ext = (file.extension ?? 'png').toLowerCase(); final mime = ext == 'jpg' ? 'jpeg' : ext; final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}'; + await ref.read(appSettingsProvider.notifier).applyLocalCompanyLogo(dataUri); + if (!mounted) return; setState(() => _logoUrlController.text = dataUri); } @@ -195,147 +265,165 @@ class _CompanyProfileSettingsScreenState return SettingsPageLayout( title: 'Company Profile', subtitle: 'Company information and branding assets', - child: Form( - key: _formKey, - child: Column( - children: [ - SettingsFormCard( - title: 'Company Information', - children: [ - AppTextField( - controller: _nameController, - label: 'Company Name', - validator: (v) => Validators.required(v, fieldName: 'Company name'), - ), - const SizedBox(height: 16), - AppTextField( - controller: _codeController, - label: 'Company Code', - validator: (v) => Validators.required(v, fieldName: 'Company code'), - ), - const SizedBox(height: 16), - AppTextField( - controller: _registrationController, - label: 'Registration Number', - ), - const SizedBox(height: 16), - AppTextField( - controller: _gstController, - label: 'GST/VAT Number', - validator: Validators.optionalGstin, - inputFormatters: Validators.gstinInput, - ), - const SizedBox(height: 16), - AppTextField( - controller: _addressController, - label: 'Address', - maxLines: 2, - ), - const SizedBox(height: 16), - SidePanelFormRow( - left: AppTextField( - controller: _cityController, - label: 'City', + child: _loading + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 48), + child: Center(child: CircularProgressIndicator()), + ) + : Form( + key: _formKey, + child: Column( + children: [ + SettingsFormCard( + title: 'Company Information', + children: [ + AppTextField( + controller: _nameController, + label: 'Company Name', + validator: (v) => + Validators.required(v, fieldName: 'Company name'), + ), + const SizedBox(height: 16), + AppTextField( + controller: _codeController, + label: 'Company Code', + ), + const SizedBox(height: 16), + AppTextField( + controller: _registrationController, + label: 'Registration Number', + ), + const SizedBox(height: 16), + AppTextField( + controller: _gstController, + label: 'GST/VAT Number', + validator: Validators.optionalGstin, + inputFormatters: Validators.gstinInput, + ), + const SizedBox(height: 16), + AppTextField( + controller: _addressController, + label: 'Address', + 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( + controller: _emailController, + label: 'Email', + keyboardType: TextInputType.emailAddress, + validator: Validators.optionalEmail, + ), + const SizedBox(height: 16), + AppTextField( + controller: _phoneController, + label: 'Phone', + keyboardType: TextInputType.phone, + validator: Validators.optionalMobile, + inputFormatters: Validators.mobileInput, + ), + const SizedBox(height: 16), + AppTextField( + controller: _websiteController, + label: 'Website', + keyboardType: TextInputType.url, + ), + ], ), - right: AppTextField( - controller: _stateController, - label: 'State', + const SizedBox(height: 16), + SettingsFormCard( + title: 'Logo Upload', + subtitle: 'Upload an image or provide a logo URL', + children: [ + Center( + child: SidebarLogo( + logoUrl: _logoUrlController.text.trim().isEmpty + ? null + : _logoUrlController.text.trim(), + width: 240, + height: 80, + fit: BoxFit.contain, + ), + ), + const SizedBox(height: 16), + AppTextField( + controller: _logoUrlController, + label: 'Logo URL', + hint: 'https://example.com/logo.png', + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _uploadingLogo ? null : _pickLogo, + icon: _uploadingLogo + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.upload_file), + label: Text( + _uploadingLogo ? 'Uploading…' : 'Upload Logo', + ), + ), + ], ), - ), - const SizedBox(height: 16), - AppTextField( - controller: _pincodeController, - label: 'Pincode', - keyboardType: TextInputType.number, - ), - const SizedBox(height: 16), - AppTextField( - controller: _emailController, - label: 'Email', - keyboardType: TextInputType.emailAddress, - validator: Validators.email, - ), - const SizedBox(height: 16), - AppTextField( - controller: _phoneController, - label: 'Phone', - keyboardType: TextInputType.phone, - validator: Validators.optionalMobile, - inputFormatters: Validators.mobileInput, - ), - const SizedBox(height: 16), - AppTextField( - controller: _websiteController, - label: 'Website', - keyboardType: TextInputType.url, - ), - ], + const SizedBox(height: 16), + SettingsFormCard( + title: 'Favicon Upload', + subtitle: + 'Upload an image or provide a favicon URL for the browser tab', + children: [ + Center( + child: SidebarLogo( + logoUrl: _faviconUrlController.text.trim().isEmpty + ? null + : _faviconUrlController.text.trim(), + width: 64, + height: 64, + fit: BoxFit.contain, + ), + ), + const SizedBox(height: 16), + AppTextField( + controller: _faviconUrlController, + label: 'Favicon URL', + hint: 'https://example.com/favicon.ico', + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _pickFavicon, + icon: const Icon(Icons.upload_file), + label: const Text('Upload Favicon'), + ), + ], + ), + const SizedBox(height: 24), + AppButton( + label: 'Save Changes', + onPressed: _save, + isLoading: _saving, + ), + ], + ), ), - const SizedBox(height: 16), - SettingsFormCard( - title: 'Logo Upload', - subtitle: 'Upload an image or provide a logo URL', - children: [ - Center( - child: SidebarLogo( - logoUrl: _logoUrlController.text.trim().isEmpty - ? null - : _logoUrlController.text.trim(), - width: 240, - height: 80, - fit: BoxFit.contain, - ), - ), - const SizedBox(height: 16), - AppTextField( - controller: _logoUrlController, - label: 'Logo URL', - hint: 'https://example.com/logo.png', - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _pickLogo, - icon: const Icon(Icons.upload_file), - label: const Text('Upload Logo'), - ), - ], - ), - const SizedBox(height: 16), - SettingsFormCard( - title: 'Favicon Upload', - subtitle: 'Upload an image or provide a favicon URL for the browser tab', - children: [ - Center( - child: SidebarLogo( - logoUrl: _faviconUrlController.text.trim().isEmpty - ? null - : _faviconUrlController.text.trim(), - width: 64, - height: 64, - fit: BoxFit.contain, - ), - ), - const SizedBox(height: 16), - AppTextField( - controller: _faviconUrlController, - label: 'Favicon URL', - hint: 'https://example.com/favicon.ico', - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _pickFavicon, - icon: const Icon(Icons.upload_file), - label: const Text('Upload Favicon'), - ), - ], - ), - const SizedBox(height: 24), - AppButton(label: 'Save Changes', onPressed: _save), - ], - ), - ), ); } } diff --git a/lib/modules/settings/presentation/screens/email_configuration_screen.dart b/lib/modules/settings/presentation/screens/email_configuration_screen.dart index 968f514..ad9ede1 100644 --- a/lib/modules/settings/presentation/screens/email_configuration_screen.dart +++ b/lib/modules/settings/presentation/screens/email_configuration_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/network/api_handler.dart'; import '../../../../core/utils/validators.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_text_field.dart'; @@ -25,31 +26,49 @@ class _EmailConfigurationScreenState late final TextEditingController _passwordController; late final TextEditingController _senderEmailController; late final TextEditingController _senderNameController; - late final TextEditingController _allocationTemplateController; - late final TextEditingController _returnTemplateController; - late final TextEditingController _maintenanceTemplateController; - late final TextEditingController _warrantyTemplateController; + + bool _loading = true; + bool _saving = false; @override 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); _passwordController = TextEditingController(text: email.smtpPassword); _senderEmailController = TextEditingController(text: email.senderEmail); _senderNameController = TextEditingController(text: email.senderName); - _allocationTemplateController = - TextEditingController(text: email.allocationTemplate); - _returnTemplateController = TextEditingController(text: email.returnTemplate); - _maintenanceTemplateController = - TextEditingController(text: email.maintenanceTemplate); - _warrantyTemplateController = - TextEditingController(text: email.warrantyTemplate); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadFromApi()); + } + + Future _loadFromApi() async { + setState(() => _loading = true); + final failure = + await ref.read(appSettingsProvider.notifier).refreshEmailSettings(); + if (!mounted) return; + _applyEmail(ref.read(appSettingsProvider).email); + setState(() => _loading = false); + if (failure != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(failure.message), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + + void _applyEmail(EmailConfigurationSettings email) { + _hostController.text = email.smtpHost; + _portController.text = '${email.smtpPort}'; + _usernameController.text = email.smtpUsername; + if (email.smtpPassword.isNotEmpty) { + _passwordController.text = email.smtpPassword; + } + _senderEmailController.text = email.senderEmail; + _senderNameController.text = email.senderName; } @override @@ -60,121 +79,117 @@ class _EmailConfigurationScreenState _passwordController.dispose(); _senderEmailController.dispose(); _senderNameController.dispose(); - _allocationTemplateController.dispose(); - _returnTemplateController.dispose(); - _maintenanceTemplateController.dispose(); - _warrantyTemplateController.dispose(); super.dispose(); } Future _save() async { if (!_formKey.currentState!.validate()) return; - await ref.read(appSettingsProvider.notifier).updateEmail( - EmailConfigurationSettings( + final port = int.tryParse(_portController.text.trim()); + if (port == null || port <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enter a valid SMTP port')), + ); + return; + } + + final current = ref.read(appSettingsProvider).email; + setState(() => _saving = true); + final failure = await ref.read(appSettingsProvider.notifier).updateEmail( + current.copyWith( smtpHost: _hostController.text.trim(), - smtpPort: int.parse(_portController.text.trim()), + smtpPort: port, smtpUsername: _usernameController.text.trim(), smtpPassword: _passwordController.text.trim(), senderEmail: _senderEmailController.text.trim(), senderName: _senderNameController.text.trim(), - allocationTemplate: _allocationTemplateController.text.trim(), - returnTemplate: _returnTemplateController.text.trim(), - maintenanceTemplate: _maintenanceTemplateController.text.trim(), - warrantyTemplate: _warrantyTemplateController.text.trim(), ), ); + if (!mounted) return; + setState(() => _saving = false); - if (mounted) { + if (failure != null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Email configuration saved')), + SnackBar( + content: Text(validationErrorMessage(failure)), + backgroundColor: Theme.of(context).colorScheme.error, + ), ); + return; } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Email configuration saved')), + ); } @override Widget build(BuildContext context) { return SettingsPageLayout( title: 'Email Configuration', - subtitle: 'SMTP server settings and email templates', - child: Form( - key: _formKey, - child: Column( - children: [ - SettingsFormCard( - title: 'SMTP Settings', - children: [ - AppTextField( - controller: _hostController, - label: 'SMTP Host', - hint: 'smtp.gmail.com', - ), - const SizedBox(height: 16), - AppTextField( - controller: _portController, - label: 'SMTP Port', - keyboardType: TextInputType.number, - ), - const SizedBox(height: 16), - AppTextField( - controller: _usernameController, - label: 'Username', - ), - const SizedBox(height: 16), - AppTextField( - controller: _passwordController, - label: 'Password', - obscureText: true, - ), - const SizedBox(height: 16), - AppTextField( - controller: _senderEmailController, - label: 'Sender Email', - keyboardType: TextInputType.emailAddress, - validator: Validators.email, - ), - const SizedBox(height: 16), - AppTextField( - controller: _senderNameController, - label: 'Sender Name', - ), - ], + subtitle: 'SMTP server settings', + child: _loading + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 48), + child: Center(child: CircularProgressIndicator()), + ) + : Form( + key: _formKey, + child: Column( + children: [ + SettingsFormCard( + title: 'SMTP Settings', + children: [ + AppTextField( + controller: _hostController, + label: 'SMTP Host', + hint: 'smtp.gmail.com', + validator: (v) => + Validators.required(v, fieldName: 'SMTP host'), + ), + const SizedBox(height: 16), + AppTextField( + controller: _portController, + label: 'SMTP Port', + keyboardType: TextInputType.number, + validator: (v) => + Validators.required(v, fieldName: 'SMTP port'), + ), + const SizedBox(height: 16), + AppTextField( + controller: _usernameController, + label: 'Username', + ), + const SizedBox(height: 16), + AppTextField( + controller: _passwordController, + label: 'Password', + obscureText: true, + hint: 'Leave blank to keep existing password', + ), + const SizedBox(height: 16), + AppTextField( + controller: _senderEmailController, + label: 'Sender Email', + keyboardType: TextInputType.emailAddress, + validator: Validators.email, + ), + const SizedBox(height: 16), + AppTextField( + controller: _senderNameController, + label: 'Sender Name', + ), + ], + ), + const SizedBox(height: 24), + AppButton( + label: 'Save Changes', + onPressed: _save, + isLoading: _saving, + ), + ], + ), ), - const SizedBox(height: 16), - SettingsFormCard( - title: 'Email Templates', - subtitle: 'Use {{asset_name}} and {{date}} as placeholders', - children: [ - AppTextField( - controller: _allocationTemplateController, - label: 'Asset Allocation Email', - maxLines: 2, - ), - const SizedBox(height: 16), - AppTextField( - controller: _returnTemplateController, - label: 'Asset Return Email', - maxLines: 2, - ), - const SizedBox(height: 16), - AppTextField( - controller: _maintenanceTemplateController, - label: 'Maintenance Email', - maxLines: 2, - ), - const SizedBox(height: 16), - AppTextField( - controller: _warrantyTemplateController, - label: 'Warranty Expiry Email', - maxLines: 2, - ), - ], - ), - const SizedBox(height: 24), - AppButton(label: 'Save Changes', onPressed: _save), - ], - ), - ), ); } } diff --git a/lib/modules/settings/presentation/screens/settings_screen.dart b/lib/modules/settings/presentation/screens/settings_screen.dart index 766bd39..a0a8e8b 100644 --- a/lib/modules/settings/presentation/screens/settings_screen.dart +++ b/lib/modules/settings/presentation/screens/settings_screen.dart @@ -11,6 +11,9 @@ class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final visiblePhase1 = visiblePhase1SettingsSections; + final visiblePhase2 = visiblePhase2SettingsSections; + return SingleChildScrollView( padding: const EdgeInsets.all(24), child: Center( @@ -21,35 +24,31 @@ class SettingsScreen extends StatelessWidget { children: [ const PageHeader( title: 'Settings', - subtitle: 'Configure company, security, assets, and system behavior', - ), - Text( - 'Phase 1 — Asset Management MVP', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 16), - SettingsSectionGrid( - sections: phase1SettingsSections, - onSectionTap: (section) => context.go(section.route), - ), - const SizedBox(height: 24), - Text( - 'Coming in Phase 2', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 16), - SettingsSectionGrid( - sections: phase2SettingsSections, - enabled: false, - badge: 'Phase 2', - onSectionTap: (_) {}, + subtitle: + 'Configure company, security, assets, and system behavior', ), + if (visiblePhase1.isNotEmpty) + SettingsSectionGrid( + sections: visiblePhase1, + onSectionTap: (section) => context.go(section.route), + ), + if (visiblePhase2.isNotEmpty) ...[ + const SizedBox(height: 24), + Text( + 'Coming in Phase 2', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + SettingsSectionGrid( + sections: visiblePhase2, + enabled: false, + badge: 'Phase 2', + onSectionTap: (_) {}, + ), + ], ], ), ), diff --git a/lib/shared/models/asset_model.dart b/lib/shared/models/asset_model.dart index a80ee9a..2c6a2ec 100644 --- a/lib/shared/models/asset_model.dart +++ b/lib/shared/models/asset_model.dart @@ -36,10 +36,13 @@ Object? _readNestedName(Map json, String nestedKey) { return null; } -Object? _readAssetCategoryName(Map json, String key) { - final flat = json['asset_category_name']; - if (flat is String && flat.isNotEmpty) return flat; - return _readNestedName(json, 'asset_category'); +Object? _readItemCategoryName(Map json, String key) { + for (final flatKey in ['item_category_name', 'asset_category_name']) { + final flat = json[flatKey]; + if (flat is String && flat.isNotEmpty) return flat; + } + return _readNestedName(json, 'item_category') ?? + _readNestedName(json, 'asset_category'); } Object? _readPlantName(Map json, String key) { @@ -48,10 +51,12 @@ Object? _readPlantName(Map json, String key) { return _readNestedName(json, 'plant'); } -Object? _readAssetCategoryId(Map json, String key) { - final flat = json['asset_category_id']; - if (flat != null) return flat; - final nested = json['asset_category']; +Object? _readItemCategoryId(Map json, String key) { + for (final flatKey in ['item_category_id', 'asset_category_id']) { + final flat = json[flatKey]; + if (flat != null) return flat; + } + final nested = json['item_category'] ?? json['asset_category']; if (nested is Map) return nested['id']; return null; } @@ -64,16 +69,21 @@ Object? _readPlantId(Map json, String key) { return null; } -Object? _readAssetSubcategoryName(Map json, String key) { - final flat = json['asset_subcategory_name']; - if (flat is String && flat.isNotEmpty) return flat; - return _readNestedName(json, 'asset_subcategory'); +Object? _readItemSubcategoryName(Map json, String key) { + for (final flatKey in ['item_subcategory_name', 'asset_subcategory_name']) { + final flat = json[flatKey]; + if (flat is String && flat.isNotEmpty) return flat; + } + return _readNestedName(json, 'item_subcategory') ?? + _readNestedName(json, 'asset_subcategory'); } -Object? _readAssetSubcategoryId(Map json, String key) { - final flat = json['asset_subcategory_id']; - if (flat != null) return flat; - final nested = json['asset_subcategory']; +Object? _readItemSubcategoryId(Map json, String key) { + for (final flatKey in ['item_subcategory_id', 'asset_subcategory_id']) { + final flat = json[flatKey]; + if (flat != null) return flat; + } + final nested = json['item_subcategory'] ?? json['asset_subcategory']; if (nested is Map) return nested['id']; return null; } @@ -127,20 +137,20 @@ class AssetModel with _$AssetModel { @JsonKey(name: 'asset_name') required String assetName, @JsonKey(name: 'asset_code') String? assetCode, @JsonKey( - name: 'asset_category_id', - readValue: _readAssetCategoryId, + name: 'item_category_id', + readValue: _readItemCategoryId, fromJson: _intFromJsonNullable, ) int? assetCategoryId, - @JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName) + @JsonKey(name: 'item_category_name', readValue: _readItemCategoryName) String? assetCategoryName, @JsonKey( - name: 'asset_subcategory_id', - readValue: _readAssetSubcategoryId, + name: 'item_subcategory_id', + readValue: _readItemSubcategoryId, fromJson: _intFromJsonNullable, ) int? assetSubcategoryId, - @JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName) + @JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName) String? assetSubcategoryName, @JsonKey(name: 'plant_id', readValue: _readPlantId, fromJson: _intFromJsonNullable) int? plantId, diff --git a/lib/shared/models/asset_model.freezed.dart b/lib/shared/models/asset_model.freezed.dart index 0bc15ed..32991d2 100644 --- a/lib/shared/models/asset_model.freezed.dart +++ b/lib/shared/models/asset_model.freezed.dart @@ -397,20 +397,20 @@ mixin _$AssetModel { @JsonKey(name: 'asset_code') String? get assetCode => throw _privateConstructorUsedError; @JsonKey( - name: 'asset_category_id', - readValue: _readAssetCategoryId, + name: 'item_category_id', + readValue: _readItemCategoryId, fromJson: _intFromJsonNullable, ) int? get assetCategoryId => throw _privateConstructorUsedError; - @JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName) + @JsonKey(name: 'item_category_name', readValue: _readItemCategoryName) String? get assetCategoryName => throw _privateConstructorUsedError; @JsonKey( - name: 'asset_subcategory_id', - readValue: _readAssetSubcategoryId, + name: 'item_subcategory_id', + readValue: _readItemSubcategoryId, fromJson: _intFromJsonNullable, ) int? get assetSubcategoryId => throw _privateConstructorUsedError; - @JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName) + @JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName) String? get assetSubcategoryName => throw _privateConstructorUsedError; @JsonKey( name: 'plant_id', @@ -503,23 +503,20 @@ abstract class $AssetModelCopyWith<$Res> { @JsonKey(name: 'asset_name') String assetName, @JsonKey(name: 'asset_code') String? assetCode, @JsonKey( - name: 'asset_category_id', - readValue: _readAssetCategoryId, + name: 'item_category_id', + readValue: _readItemCategoryId, fromJson: _intFromJsonNullable, ) int? assetCategoryId, - @JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName) + @JsonKey(name: 'item_category_name', readValue: _readItemCategoryName) String? assetCategoryName, @JsonKey( - name: 'asset_subcategory_id', - readValue: _readAssetSubcategoryId, + name: 'item_subcategory_id', + readValue: _readItemSubcategoryId, fromJson: _intFromJsonNullable, ) int? assetSubcategoryId, - @JsonKey( - name: 'asset_subcategory_name', - readValue: _readAssetSubcategoryName, - ) + @JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName) String? assetSubcategoryName, @JsonKey( name: 'plant_id', @@ -823,23 +820,20 @@ abstract class _$$AssetModelImplCopyWith<$Res> @JsonKey(name: 'asset_name') String assetName, @JsonKey(name: 'asset_code') String? assetCode, @JsonKey( - name: 'asset_category_id', - readValue: _readAssetCategoryId, + name: 'item_category_id', + readValue: _readItemCategoryId, fromJson: _intFromJsonNullable, ) int? assetCategoryId, - @JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName) + @JsonKey(name: 'item_category_name', readValue: _readItemCategoryName) String? assetCategoryName, @JsonKey( - name: 'asset_subcategory_id', - readValue: _readAssetSubcategoryId, + name: 'item_subcategory_id', + readValue: _readItemSubcategoryId, fromJson: _intFromJsonNullable, ) int? assetSubcategoryId, - @JsonKey( - name: 'asset_subcategory_name', - readValue: _readAssetSubcategoryName, - ) + @JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName) String? assetSubcategoryName, @JsonKey( name: 'plant_id', @@ -1135,23 +1129,20 @@ class _$AssetModelImpl implements _AssetModel { @JsonKey(name: 'asset_name') required this.assetName, @JsonKey(name: 'asset_code') this.assetCode, @JsonKey( - name: 'asset_category_id', - readValue: _readAssetCategoryId, + name: 'item_category_id', + readValue: _readItemCategoryId, fromJson: _intFromJsonNullable, ) this.assetCategoryId, - @JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName) + @JsonKey(name: 'item_category_name', readValue: _readItemCategoryName) this.assetCategoryName, @JsonKey( - name: 'asset_subcategory_id', - readValue: _readAssetSubcategoryId, + name: 'item_subcategory_id', + readValue: _readItemSubcategoryId, fromJson: _intFromJsonNullable, ) this.assetSubcategoryId, - @JsonKey( - name: 'asset_subcategory_name', - readValue: _readAssetSubcategoryName, - ) + @JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName) this.assetSubcategoryName, @JsonKey( name: 'plant_id', @@ -1225,23 +1216,23 @@ class _$AssetModelImpl implements _AssetModel { final String? assetCode; @override @JsonKey( - name: 'asset_category_id', - readValue: _readAssetCategoryId, + name: 'item_category_id', + readValue: _readItemCategoryId, fromJson: _intFromJsonNullable, ) final int? assetCategoryId; @override - @JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName) + @JsonKey(name: 'item_category_name', readValue: _readItemCategoryName) final String? assetCategoryName; @override @JsonKey( - name: 'asset_subcategory_id', - readValue: _readAssetSubcategoryId, + name: 'item_subcategory_id', + readValue: _readItemSubcategoryId, fromJson: _intFromJsonNullable, ) final int? assetSubcategoryId; @override - @JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName) + @JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName) final String? assetSubcategoryName; @override @JsonKey( @@ -1501,23 +1492,20 @@ abstract class _AssetModel implements AssetModel { @JsonKey(name: 'asset_name') required final String assetName, @JsonKey(name: 'asset_code') final String? assetCode, @JsonKey( - name: 'asset_category_id', - readValue: _readAssetCategoryId, + name: 'item_category_id', + readValue: _readItemCategoryId, fromJson: _intFromJsonNullable, ) final int? assetCategoryId, - @JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName) + @JsonKey(name: 'item_category_name', readValue: _readItemCategoryName) final String? assetCategoryName, @JsonKey( - name: 'asset_subcategory_id', - readValue: _readAssetSubcategoryId, + name: 'item_subcategory_id', + readValue: _readItemSubcategoryId, fromJson: _intFromJsonNullable, ) final int? assetSubcategoryId, - @JsonKey( - name: 'asset_subcategory_name', - readValue: _readAssetSubcategoryName, - ) + @JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName) final String? assetSubcategoryName, @JsonKey( name: 'plant_id', @@ -1593,23 +1581,23 @@ abstract class _AssetModel implements AssetModel { String? get assetCode; @override @JsonKey( - name: 'asset_category_id', - readValue: _readAssetCategoryId, + name: 'item_category_id', + readValue: _readItemCategoryId, fromJson: _intFromJsonNullable, ) int? get assetCategoryId; @override - @JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName) + @JsonKey(name: 'item_category_name', readValue: _readItemCategoryName) String? get assetCategoryName; @override @JsonKey( - name: 'asset_subcategory_id', - readValue: _readAssetSubcategoryId, + name: 'item_subcategory_id', + readValue: _readItemSubcategoryId, fromJson: _intFromJsonNullable, ) int? get assetSubcategoryId; @override - @JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName) + @JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName) String? get assetSubcategoryName; @override @JsonKey( diff --git a/lib/shared/models/asset_model.g.dart b/lib/shared/models/asset_model.g.dart index 2649095..9a6b34d 100644 --- a/lib/shared/models/asset_model.g.dart +++ b/lib/shared/models/asset_model.g.dart @@ -46,15 +46,15 @@ _$AssetModelImpl _$$AssetModelImplFromJson(Map json) => assetName: json['asset_name'] as String, assetCode: json['asset_code'] as String?, assetCategoryId: _intFromJsonNullable( - _readAssetCategoryId(json, 'asset_category_id'), + _readItemCategoryId(json, 'item_category_id'), ), assetCategoryName: - _readAssetCategoryName(json, 'asset_category_name') as String?, + _readItemCategoryName(json, 'item_category_name') as String?, assetSubcategoryId: _intFromJsonNullable( - _readAssetSubcategoryId(json, 'asset_subcategory_id'), + _readItemSubcategoryId(json, 'item_subcategory_id'), ), assetSubcategoryName: - _readAssetSubcategoryName(json, 'asset_subcategory_name') as String?, + _readItemSubcategoryName(json, 'item_subcategory_name') as String?, plantId: _intFromJsonNullable(_readPlantId(json, 'plant_id')), plantName: _readPlantName(json, 'plant_name') as String?, brandModel: json['brand_model'] as String?, @@ -96,10 +96,10 @@ Map _$$AssetModelImplToJson(_$AssetModelImpl instance) => 'id': instance.id, 'asset_name': instance.assetName, 'asset_code': instance.assetCode, - 'asset_category_id': instance.assetCategoryId, - 'asset_category_name': instance.assetCategoryName, - 'asset_subcategory_id': instance.assetSubcategoryId, - 'asset_subcategory_name': instance.assetSubcategoryName, + 'item_category_id': instance.assetCategoryId, + 'item_category_name': instance.assetCategoryName, + 'item_subcategory_id': instance.assetSubcategoryId, + 'item_subcategory_name': instance.assetSubcategoryName, 'plant_id': instance.plantId, 'plant_name': instance.plantName, 'brand_model': instance.brandModel, diff --git a/lib/shared/models/audit_log_model.dart b/lib/shared/models/audit_log_model.dart new file mode 100644 index 0000000..abab1ca --- /dev/null +++ b/lib/shared/models/audit_log_model.dart @@ -0,0 +1,193 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +// ignore_for_file: invalid_annotation_target + +part 'audit_log_model.freezed.dart'; +part 'audit_log_model.g.dart'; + +String _idFromJson(Object? value) => value?.toString() ?? ''; + +String? _idFromJsonNullable(Object? value) { + if (value == null) return null; + final text = value.toString().trim(); + return text.isEmpty ? null : text; +} + +DateTime? _dateFromJsonNullable(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + return DateTime.tryParse(value.toString()); +} + +Map? _mapFromJsonNullable(Object? value) { + if (value == null) return null; + if (value is Map) return value; + if (value is Map) { + return value.map((key, val) => MapEntry(key.toString(), val)); + } + return null; +} + +@freezed +class AuditLogUserModel with _$AuditLogUserModel { + const factory AuditLogUserModel({ + @JsonKey(fromJson: _idFromJson) required String id, + @JsonKey(name: 'full_name') String? fullName, + @JsonKey(name: 'employee_code') String? employeeCode, + String? email, + }) = _AuditLogUserModel; + + factory AuditLogUserModel.fromJson(Map json) => + _$AuditLogUserModelFromJson(json); +} + +@freezed +class AuditLogPerformerOption with _$AuditLogPerformerOption { + const factory AuditLogPerformerOption({ + @JsonKey(fromJson: _idFromJson) required String id, + @JsonKey(name: 'full_name') String? fullName, + @JsonKey(name: 'employee_code') String? employeeCode, + }) = _AuditLogPerformerOption; + + factory AuditLogPerformerOption.fromJson(Map json) => + _$AuditLogPerformerOptionFromJson(json); + + const AuditLogPerformerOption._(); + + String get label { + final name = fullName?.trim(); + final code = employeeCode?.trim(); + if (name != null && name.isNotEmpty && code != null && code.isNotEmpty) { + return '$name ($code)'; + } + if (name != null && name.isNotEmpty) return name; + if (code != null && code.isNotEmpty) return code; + return id; + } +} + +@freezed +class AuditLogFilterOptions with _$AuditLogFilterOptions { + const factory AuditLogFilterOptions({ + @JsonKey(name: 'table_names') @Default([]) List tableNames, + @Default([]) List actions, + @Default([]) List performers, + }) = _AuditLogFilterOptions; + + factory AuditLogFilterOptions.fromJson(Map json) => + _$AuditLogFilterOptionsFromJson(json); +} + +@freezed +class AuditLogEntryModel with _$AuditLogEntryModel { + const factory AuditLogEntryModel({ + @JsonKey(fromJson: _idFromJson) required String id, + @JsonKey(name: 'table_name') required String tableName, + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) String? recordId, + required String action, + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + DateTime? performedAt, + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + String? performedBy, + @JsonKey(name: 'request_id') String? requestId, + @JsonKey(name: 'performed_by_user') AuditLogUserModel? performedByUser, + @JsonKey(name: 'has_old_value') @Default(false) bool hasOldValue, + @JsonKey(name: 'has_new_value') @Default(false) bool hasNewValue, + }) = _AuditLogEntryModel; + + factory AuditLogEntryModel.fromJson(Map json) => + _$AuditLogEntryModelFromJson(json); + + const AuditLogEntryModel._(); + + String get performerLabel { + final user = performedByUser; + if (user == null) return performedBy ?? '—'; + final name = user.fullName?.trim(); + final code = user.employeeCode?.trim(); + if (name != null && name.isNotEmpty && code != null && code.isNotEmpty) { + return '$name ($code)'; + } + if (name != null && name.isNotEmpty) return name; + return performedBy ?? '—'; + } +} + +@freezed +class AuditLogDetailModel with _$AuditLogDetailModel { + const factory AuditLogDetailModel({ + @JsonKey(fromJson: _idFromJson) required String id, + @JsonKey(name: 'table_name') required String tableName, + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) String? recordId, + required String action, + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + DateTime? performedAt, + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + String? performedBy, + @JsonKey(name: 'request_id') String? requestId, + @JsonKey(name: 'performed_by_user') AuditLogUserModel? performedByUser, + @JsonKey(name: 'has_old_value') @Default(false) bool hasOldValue, + @JsonKey(name: 'has_new_value') @Default(false) bool hasNewValue, + @JsonKey(name: 'old_value', fromJson: _mapFromJsonNullable) + Map? oldValue, + @JsonKey(name: 'new_value', fromJson: _mapFromJsonNullable) + Map? newValue, + }) = _AuditLogDetailModel; + + factory AuditLogDetailModel.fromJson(Map json) => + _$AuditLogDetailModelFromJson(json); + + const AuditLogDetailModel._(); + + String get performerLabel { + final user = performedByUser; + if (user == null) return performedBy ?? '—'; + final name = user.fullName?.trim(); + final code = user.employeeCode?.trim(); + if (name != null && name.isNotEmpty && code != null && code.isNotEmpty) { + return '$name ($code)'; + } + if (name != null && name.isNotEmpty) return name; + return performedBy ?? '—'; + } +} + +@freezed +class AuditLogListQuery with _$AuditLogListQuery { + const factory AuditLogListQuery({ + @Default(1) int page, + @Default(20) int limit, + @JsonKey(name: 'table_name') String? tableName, + @JsonKey(name: 'record_id') int? recordId, + String? action, + @JsonKey(name: 'performed_by') int? performedBy, + @JsonKey(name: 'request_id') String? requestId, + @JsonKey(name: 'date_from') DateTime? dateFrom, + @JsonKey(name: 'date_to') DateTime? dateTo, + String? search, + }) = _AuditLogListQuery; + + const AuditLogListQuery._(); + + bool get hasActiveFilter => + (tableName?.isNotEmpty ?? false) || + recordId != null || + (action?.isNotEmpty ?? false) || + performedBy != null || + (requestId?.isNotEmpty ?? false) || + dateFrom != null || + dateTo != null || + (search?.isNotEmpty ?? false); +} + +@freezed +class AuditLogListResult with _$AuditLogListResult { + const factory AuditLogListResult({ + @Default([]) List items, + @Default(1) int page, + @Default(20) int limit, + @Default(0) int total, + @Default(1) int totalPages, + @Default(false) bool filtersRequired, + }) = _AuditLogListResult; +} diff --git a/lib/shared/models/audit_log_model.freezed.dart b/lib/shared/models/audit_log_model.freezed.dart new file mode 100644 index 0000000..ace3e4f --- /dev/null +++ b/lib/shared/models/audit_log_model.freezed.dart @@ -0,0 +1,2283 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'audit_log_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +AuditLogUserModel _$AuditLogUserModelFromJson(Map json) { + return _AuditLogUserModel.fromJson(json); +} + +/// @nodoc +mixin _$AuditLogUserModel { + @JsonKey(fromJson: _idFromJson) + String get id => throw _privateConstructorUsedError; + @JsonKey(name: 'full_name') + String? get fullName => throw _privateConstructorUsedError; + @JsonKey(name: 'employee_code') + String? get employeeCode => throw _privateConstructorUsedError; + String? get email => throw _privateConstructorUsedError; + + /// Serializes this AuditLogUserModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of AuditLogUserModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AuditLogUserModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AuditLogUserModelCopyWith<$Res> { + factory $AuditLogUserModelCopyWith( + AuditLogUserModel value, + $Res Function(AuditLogUserModel) then, + ) = _$AuditLogUserModelCopyWithImpl<$Res, AuditLogUserModel>; + @useResult + $Res call({ + @JsonKey(fromJson: _idFromJson) String id, + @JsonKey(name: 'full_name') String? fullName, + @JsonKey(name: 'employee_code') String? employeeCode, + String? email, + }); +} + +/// @nodoc +class _$AuditLogUserModelCopyWithImpl<$Res, $Val extends AuditLogUserModel> + implements $AuditLogUserModelCopyWith<$Res> { + _$AuditLogUserModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AuditLogUserModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? fullName = freezed, + Object? employeeCode = freezed, + Object? email = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + fullName: freezed == fullName + ? _value.fullName + : fullName // ignore: cast_nullable_to_non_nullable + as String?, + employeeCode: freezed == employeeCode + ? _value.employeeCode + : employeeCode // ignore: cast_nullable_to_non_nullable + as String?, + email: freezed == email + ? _value.email + : email // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$AuditLogUserModelImplCopyWith<$Res> + implements $AuditLogUserModelCopyWith<$Res> { + factory _$$AuditLogUserModelImplCopyWith( + _$AuditLogUserModelImpl value, + $Res Function(_$AuditLogUserModelImpl) then, + ) = __$$AuditLogUserModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + @JsonKey(fromJson: _idFromJson) String id, + @JsonKey(name: 'full_name') String? fullName, + @JsonKey(name: 'employee_code') String? employeeCode, + String? email, + }); +} + +/// @nodoc +class __$$AuditLogUserModelImplCopyWithImpl<$Res> + extends _$AuditLogUserModelCopyWithImpl<$Res, _$AuditLogUserModelImpl> + implements _$$AuditLogUserModelImplCopyWith<$Res> { + __$$AuditLogUserModelImplCopyWithImpl( + _$AuditLogUserModelImpl _value, + $Res Function(_$AuditLogUserModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of AuditLogUserModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? fullName = freezed, + Object? employeeCode = freezed, + Object? email = freezed, + }) { + return _then( + _$AuditLogUserModelImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + fullName: freezed == fullName + ? _value.fullName + : fullName // ignore: cast_nullable_to_non_nullable + as String?, + employeeCode: freezed == employeeCode + ? _value.employeeCode + : employeeCode // ignore: cast_nullable_to_non_nullable + as String?, + email: freezed == email + ? _value.email + : email // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$AuditLogUserModelImpl implements _AuditLogUserModel { + const _$AuditLogUserModelImpl({ + @JsonKey(fromJson: _idFromJson) required this.id, + @JsonKey(name: 'full_name') this.fullName, + @JsonKey(name: 'employee_code') this.employeeCode, + this.email, + }); + + factory _$AuditLogUserModelImpl.fromJson(Map json) => + _$$AuditLogUserModelImplFromJson(json); + + @override + @JsonKey(fromJson: _idFromJson) + final String id; + @override + @JsonKey(name: 'full_name') + final String? fullName; + @override + @JsonKey(name: 'employee_code') + final String? employeeCode; + @override + final String? email; + + @override + String toString() { + return 'AuditLogUserModel(id: $id, fullName: $fullName, employeeCode: $employeeCode, email: $email)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AuditLogUserModelImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.fullName, fullName) || + other.fullName == fullName) && + (identical(other.employeeCode, employeeCode) || + other.employeeCode == employeeCode) && + (identical(other.email, email) || other.email == email)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, id, fullName, employeeCode, email); + + /// Create a copy of AuditLogUserModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AuditLogUserModelImplCopyWith<_$AuditLogUserModelImpl> get copyWith => + __$$AuditLogUserModelImplCopyWithImpl<_$AuditLogUserModelImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$AuditLogUserModelImplToJson(this); + } +} + +abstract class _AuditLogUserModel implements AuditLogUserModel { + const factory _AuditLogUserModel({ + @JsonKey(fromJson: _idFromJson) required final String id, + @JsonKey(name: 'full_name') final String? fullName, + @JsonKey(name: 'employee_code') final String? employeeCode, + final String? email, + }) = _$AuditLogUserModelImpl; + + factory _AuditLogUserModel.fromJson(Map json) = + _$AuditLogUserModelImpl.fromJson; + + @override + @JsonKey(fromJson: _idFromJson) + String get id; + @override + @JsonKey(name: 'full_name') + String? get fullName; + @override + @JsonKey(name: 'employee_code') + String? get employeeCode; + @override + String? get email; + + /// Create a copy of AuditLogUserModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AuditLogUserModelImplCopyWith<_$AuditLogUserModelImpl> get copyWith => + throw _privateConstructorUsedError; +} + +AuditLogPerformerOption _$AuditLogPerformerOptionFromJson( + Map json, +) { + return _AuditLogPerformerOption.fromJson(json); +} + +/// @nodoc +mixin _$AuditLogPerformerOption { + @JsonKey(fromJson: _idFromJson) + String get id => throw _privateConstructorUsedError; + @JsonKey(name: 'full_name') + String? get fullName => throw _privateConstructorUsedError; + @JsonKey(name: 'employee_code') + String? get employeeCode => throw _privateConstructorUsedError; + + /// Serializes this AuditLogPerformerOption to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of AuditLogPerformerOption + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AuditLogPerformerOptionCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AuditLogPerformerOptionCopyWith<$Res> { + factory $AuditLogPerformerOptionCopyWith( + AuditLogPerformerOption value, + $Res Function(AuditLogPerformerOption) then, + ) = _$AuditLogPerformerOptionCopyWithImpl<$Res, AuditLogPerformerOption>; + @useResult + $Res call({ + @JsonKey(fromJson: _idFromJson) String id, + @JsonKey(name: 'full_name') String? fullName, + @JsonKey(name: 'employee_code') String? employeeCode, + }); +} + +/// @nodoc +class _$AuditLogPerformerOptionCopyWithImpl< + $Res, + $Val extends AuditLogPerformerOption +> + implements $AuditLogPerformerOptionCopyWith<$Res> { + _$AuditLogPerformerOptionCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AuditLogPerformerOption + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? fullName = freezed, + Object? employeeCode = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + fullName: freezed == fullName + ? _value.fullName + : fullName // ignore: cast_nullable_to_non_nullable + as String?, + employeeCode: freezed == employeeCode + ? _value.employeeCode + : employeeCode // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$AuditLogPerformerOptionImplCopyWith<$Res> + implements $AuditLogPerformerOptionCopyWith<$Res> { + factory _$$AuditLogPerformerOptionImplCopyWith( + _$AuditLogPerformerOptionImpl value, + $Res Function(_$AuditLogPerformerOptionImpl) then, + ) = __$$AuditLogPerformerOptionImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + @JsonKey(fromJson: _idFromJson) String id, + @JsonKey(name: 'full_name') String? fullName, + @JsonKey(name: 'employee_code') String? employeeCode, + }); +} + +/// @nodoc +class __$$AuditLogPerformerOptionImplCopyWithImpl<$Res> + extends + _$AuditLogPerformerOptionCopyWithImpl< + $Res, + _$AuditLogPerformerOptionImpl + > + implements _$$AuditLogPerformerOptionImplCopyWith<$Res> { + __$$AuditLogPerformerOptionImplCopyWithImpl( + _$AuditLogPerformerOptionImpl _value, + $Res Function(_$AuditLogPerformerOptionImpl) _then, + ) : super(_value, _then); + + /// Create a copy of AuditLogPerformerOption + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? fullName = freezed, + Object? employeeCode = freezed, + }) { + return _then( + _$AuditLogPerformerOptionImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + fullName: freezed == fullName + ? _value.fullName + : fullName // ignore: cast_nullable_to_non_nullable + as String?, + employeeCode: freezed == employeeCode + ? _value.employeeCode + : employeeCode // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$AuditLogPerformerOptionImpl extends _AuditLogPerformerOption { + const _$AuditLogPerformerOptionImpl({ + @JsonKey(fromJson: _idFromJson) required this.id, + @JsonKey(name: 'full_name') this.fullName, + @JsonKey(name: 'employee_code') this.employeeCode, + }) : super._(); + + factory _$AuditLogPerformerOptionImpl.fromJson(Map json) => + _$$AuditLogPerformerOptionImplFromJson(json); + + @override + @JsonKey(fromJson: _idFromJson) + final String id; + @override + @JsonKey(name: 'full_name') + final String? fullName; + @override + @JsonKey(name: 'employee_code') + final String? employeeCode; + + @override + String toString() { + return 'AuditLogPerformerOption(id: $id, fullName: $fullName, employeeCode: $employeeCode)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AuditLogPerformerOptionImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.fullName, fullName) || + other.fullName == fullName) && + (identical(other.employeeCode, employeeCode) || + other.employeeCode == employeeCode)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, id, fullName, employeeCode); + + /// Create a copy of AuditLogPerformerOption + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AuditLogPerformerOptionImplCopyWith<_$AuditLogPerformerOptionImpl> + get copyWith => + __$$AuditLogPerformerOptionImplCopyWithImpl< + _$AuditLogPerformerOptionImpl + >(this, _$identity); + + @override + Map toJson() { + return _$$AuditLogPerformerOptionImplToJson(this); + } +} + +abstract class _AuditLogPerformerOption extends AuditLogPerformerOption { + const factory _AuditLogPerformerOption({ + @JsonKey(fromJson: _idFromJson) required final String id, + @JsonKey(name: 'full_name') final String? fullName, + @JsonKey(name: 'employee_code') final String? employeeCode, + }) = _$AuditLogPerformerOptionImpl; + const _AuditLogPerformerOption._() : super._(); + + factory _AuditLogPerformerOption.fromJson(Map json) = + _$AuditLogPerformerOptionImpl.fromJson; + + @override + @JsonKey(fromJson: _idFromJson) + String get id; + @override + @JsonKey(name: 'full_name') + String? get fullName; + @override + @JsonKey(name: 'employee_code') + String? get employeeCode; + + /// Create a copy of AuditLogPerformerOption + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AuditLogPerformerOptionImplCopyWith<_$AuditLogPerformerOptionImpl> + get copyWith => throw _privateConstructorUsedError; +} + +AuditLogFilterOptions _$AuditLogFilterOptionsFromJson( + Map json, +) { + return _AuditLogFilterOptions.fromJson(json); +} + +/// @nodoc +mixin _$AuditLogFilterOptions { + @JsonKey(name: 'table_names') + List get tableNames => throw _privateConstructorUsedError; + List get actions => throw _privateConstructorUsedError; + List get performers => + throw _privateConstructorUsedError; + + /// Serializes this AuditLogFilterOptions to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of AuditLogFilterOptions + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AuditLogFilterOptionsCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AuditLogFilterOptionsCopyWith<$Res> { + factory $AuditLogFilterOptionsCopyWith( + AuditLogFilterOptions value, + $Res Function(AuditLogFilterOptions) then, + ) = _$AuditLogFilterOptionsCopyWithImpl<$Res, AuditLogFilterOptions>; + @useResult + $Res call({ + @JsonKey(name: 'table_names') List tableNames, + List actions, + List performers, + }); +} + +/// @nodoc +class _$AuditLogFilterOptionsCopyWithImpl< + $Res, + $Val extends AuditLogFilterOptions +> + implements $AuditLogFilterOptionsCopyWith<$Res> { + _$AuditLogFilterOptionsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AuditLogFilterOptions + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? tableNames = null, + Object? actions = null, + Object? performers = null, + }) { + return _then( + _value.copyWith( + tableNames: null == tableNames + ? _value.tableNames + : tableNames // ignore: cast_nullable_to_non_nullable + as List, + actions: null == actions + ? _value.actions + : actions // ignore: cast_nullable_to_non_nullable + as List, + performers: null == performers + ? _value.performers + : performers // ignore: cast_nullable_to_non_nullable + as List, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$AuditLogFilterOptionsImplCopyWith<$Res> + implements $AuditLogFilterOptionsCopyWith<$Res> { + factory _$$AuditLogFilterOptionsImplCopyWith( + _$AuditLogFilterOptionsImpl value, + $Res Function(_$AuditLogFilterOptionsImpl) then, + ) = __$$AuditLogFilterOptionsImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + @JsonKey(name: 'table_names') List tableNames, + List actions, + List performers, + }); +} + +/// @nodoc +class __$$AuditLogFilterOptionsImplCopyWithImpl<$Res> + extends + _$AuditLogFilterOptionsCopyWithImpl<$Res, _$AuditLogFilterOptionsImpl> + implements _$$AuditLogFilterOptionsImplCopyWith<$Res> { + __$$AuditLogFilterOptionsImplCopyWithImpl( + _$AuditLogFilterOptionsImpl _value, + $Res Function(_$AuditLogFilterOptionsImpl) _then, + ) : super(_value, _then); + + /// Create a copy of AuditLogFilterOptions + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? tableNames = null, + Object? actions = null, + Object? performers = null, + }) { + return _then( + _$AuditLogFilterOptionsImpl( + tableNames: null == tableNames + ? _value._tableNames + : tableNames // ignore: cast_nullable_to_non_nullable + as List, + actions: null == actions + ? _value._actions + : actions // ignore: cast_nullable_to_non_nullable + as List, + performers: null == performers + ? _value._performers + : performers // ignore: cast_nullable_to_non_nullable + as List, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$AuditLogFilterOptionsImpl implements _AuditLogFilterOptions { + const _$AuditLogFilterOptionsImpl({ + @JsonKey(name: 'table_names') final List tableNames = const [], + final List actions = const [], + final List performers = const [], + }) : _tableNames = tableNames, + _actions = actions, + _performers = performers; + + factory _$AuditLogFilterOptionsImpl.fromJson(Map json) => + _$$AuditLogFilterOptionsImplFromJson(json); + + final List _tableNames; + @override + @JsonKey(name: 'table_names') + List get tableNames { + if (_tableNames is EqualUnmodifiableListView) return _tableNames; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tableNames); + } + + final List _actions; + @override + @JsonKey() + List get actions { + if (_actions is EqualUnmodifiableListView) return _actions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_actions); + } + + final List _performers; + @override + @JsonKey() + List get performers { + if (_performers is EqualUnmodifiableListView) return _performers; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_performers); + } + + @override + String toString() { + return 'AuditLogFilterOptions(tableNames: $tableNames, actions: $actions, performers: $performers)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AuditLogFilterOptionsImpl && + const DeepCollectionEquality().equals( + other._tableNames, + _tableNames, + ) && + const DeepCollectionEquality().equals(other._actions, _actions) && + const DeepCollectionEquality().equals( + other._performers, + _performers, + )); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(_tableNames), + const DeepCollectionEquality().hash(_actions), + const DeepCollectionEquality().hash(_performers), + ); + + /// Create a copy of AuditLogFilterOptions + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AuditLogFilterOptionsImplCopyWith<_$AuditLogFilterOptionsImpl> + get copyWith => + __$$AuditLogFilterOptionsImplCopyWithImpl<_$AuditLogFilterOptionsImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$AuditLogFilterOptionsImplToJson(this); + } +} + +abstract class _AuditLogFilterOptions implements AuditLogFilterOptions { + const factory _AuditLogFilterOptions({ + @JsonKey(name: 'table_names') final List tableNames, + final List actions, + final List performers, + }) = _$AuditLogFilterOptionsImpl; + + factory _AuditLogFilterOptions.fromJson(Map json) = + _$AuditLogFilterOptionsImpl.fromJson; + + @override + @JsonKey(name: 'table_names') + List get tableNames; + @override + List get actions; + @override + List get performers; + + /// Create a copy of AuditLogFilterOptions + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AuditLogFilterOptionsImplCopyWith<_$AuditLogFilterOptionsImpl> + get copyWith => throw _privateConstructorUsedError; +} + +AuditLogEntryModel _$AuditLogEntryModelFromJson(Map json) { + return _AuditLogEntryModel.fromJson(json); +} + +/// @nodoc +mixin _$AuditLogEntryModel { + @JsonKey(fromJson: _idFromJson) + String get id => throw _privateConstructorUsedError; + @JsonKey(name: 'table_name') + String get tableName => throw _privateConstructorUsedError; + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) + String? get recordId => throw _privateConstructorUsedError; + String get action => throw _privateConstructorUsedError; + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + DateTime? get performedAt => throw _privateConstructorUsedError; + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + String? get performedBy => throw _privateConstructorUsedError; + @JsonKey(name: 'request_id') + String? get requestId => throw _privateConstructorUsedError; + @JsonKey(name: 'performed_by_user') + AuditLogUserModel? get performedByUser => throw _privateConstructorUsedError; + @JsonKey(name: 'has_old_value') + bool get hasOldValue => throw _privateConstructorUsedError; + @JsonKey(name: 'has_new_value') + bool get hasNewValue => throw _privateConstructorUsedError; + + /// Serializes this AuditLogEntryModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of AuditLogEntryModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AuditLogEntryModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AuditLogEntryModelCopyWith<$Res> { + factory $AuditLogEntryModelCopyWith( + AuditLogEntryModel value, + $Res Function(AuditLogEntryModel) then, + ) = _$AuditLogEntryModelCopyWithImpl<$Res, AuditLogEntryModel>; + @useResult + $Res call({ + @JsonKey(fromJson: _idFromJson) String id, + @JsonKey(name: 'table_name') String tableName, + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) String? recordId, + String action, + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + DateTime? performedAt, + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + String? performedBy, + @JsonKey(name: 'request_id') String? requestId, + @JsonKey(name: 'performed_by_user') AuditLogUserModel? performedByUser, + @JsonKey(name: 'has_old_value') bool hasOldValue, + @JsonKey(name: 'has_new_value') bool hasNewValue, + }); + + $AuditLogUserModelCopyWith<$Res>? get performedByUser; +} + +/// @nodoc +class _$AuditLogEntryModelCopyWithImpl<$Res, $Val extends AuditLogEntryModel> + implements $AuditLogEntryModelCopyWith<$Res> { + _$AuditLogEntryModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AuditLogEntryModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? tableName = null, + Object? recordId = freezed, + Object? action = null, + Object? performedAt = freezed, + Object? performedBy = freezed, + Object? requestId = freezed, + Object? performedByUser = freezed, + Object? hasOldValue = null, + Object? hasNewValue = null, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + tableName: null == tableName + ? _value.tableName + : tableName // ignore: cast_nullable_to_non_nullable + as String, + recordId: freezed == recordId + ? _value.recordId + : recordId // ignore: cast_nullable_to_non_nullable + as String?, + action: null == action + ? _value.action + : action // ignore: cast_nullable_to_non_nullable + as String, + performedAt: freezed == performedAt + ? _value.performedAt + : performedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + performedBy: freezed == performedBy + ? _value.performedBy + : performedBy // ignore: cast_nullable_to_non_nullable + as String?, + requestId: freezed == requestId + ? _value.requestId + : requestId // ignore: cast_nullable_to_non_nullable + as String?, + performedByUser: freezed == performedByUser + ? _value.performedByUser + : performedByUser // ignore: cast_nullable_to_non_nullable + as AuditLogUserModel?, + hasOldValue: null == hasOldValue + ? _value.hasOldValue + : hasOldValue // ignore: cast_nullable_to_non_nullable + as bool, + hasNewValue: null == hasNewValue + ? _value.hasNewValue + : hasNewValue // ignore: cast_nullable_to_non_nullable + as bool, + ) + as $Val, + ); + } + + /// Create a copy of AuditLogEntryModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AuditLogUserModelCopyWith<$Res>? get performedByUser { + if (_value.performedByUser == null) { + return null; + } + + return $AuditLogUserModelCopyWith<$Res>(_value.performedByUser!, (value) { + return _then(_value.copyWith(performedByUser: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$AuditLogEntryModelImplCopyWith<$Res> + implements $AuditLogEntryModelCopyWith<$Res> { + factory _$$AuditLogEntryModelImplCopyWith( + _$AuditLogEntryModelImpl value, + $Res Function(_$AuditLogEntryModelImpl) then, + ) = __$$AuditLogEntryModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + @JsonKey(fromJson: _idFromJson) String id, + @JsonKey(name: 'table_name') String tableName, + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) String? recordId, + String action, + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + DateTime? performedAt, + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + String? performedBy, + @JsonKey(name: 'request_id') String? requestId, + @JsonKey(name: 'performed_by_user') AuditLogUserModel? performedByUser, + @JsonKey(name: 'has_old_value') bool hasOldValue, + @JsonKey(name: 'has_new_value') bool hasNewValue, + }); + + @override + $AuditLogUserModelCopyWith<$Res>? get performedByUser; +} + +/// @nodoc +class __$$AuditLogEntryModelImplCopyWithImpl<$Res> + extends _$AuditLogEntryModelCopyWithImpl<$Res, _$AuditLogEntryModelImpl> + implements _$$AuditLogEntryModelImplCopyWith<$Res> { + __$$AuditLogEntryModelImplCopyWithImpl( + _$AuditLogEntryModelImpl _value, + $Res Function(_$AuditLogEntryModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of AuditLogEntryModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? tableName = null, + Object? recordId = freezed, + Object? action = null, + Object? performedAt = freezed, + Object? performedBy = freezed, + Object? requestId = freezed, + Object? performedByUser = freezed, + Object? hasOldValue = null, + Object? hasNewValue = null, + }) { + return _then( + _$AuditLogEntryModelImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + tableName: null == tableName + ? _value.tableName + : tableName // ignore: cast_nullable_to_non_nullable + as String, + recordId: freezed == recordId + ? _value.recordId + : recordId // ignore: cast_nullable_to_non_nullable + as String?, + action: null == action + ? _value.action + : action // ignore: cast_nullable_to_non_nullable + as String, + performedAt: freezed == performedAt + ? _value.performedAt + : performedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + performedBy: freezed == performedBy + ? _value.performedBy + : performedBy // ignore: cast_nullable_to_non_nullable + as String?, + requestId: freezed == requestId + ? _value.requestId + : requestId // ignore: cast_nullable_to_non_nullable + as String?, + performedByUser: freezed == performedByUser + ? _value.performedByUser + : performedByUser // ignore: cast_nullable_to_non_nullable + as AuditLogUserModel?, + hasOldValue: null == hasOldValue + ? _value.hasOldValue + : hasOldValue // ignore: cast_nullable_to_non_nullable + as bool, + hasNewValue: null == hasNewValue + ? _value.hasNewValue + : hasNewValue // ignore: cast_nullable_to_non_nullable + as bool, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$AuditLogEntryModelImpl extends _AuditLogEntryModel { + const _$AuditLogEntryModelImpl({ + @JsonKey(fromJson: _idFromJson) required this.id, + @JsonKey(name: 'table_name') required this.tableName, + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) this.recordId, + required this.action, + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + this.performedAt, + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + this.performedBy, + @JsonKey(name: 'request_id') this.requestId, + @JsonKey(name: 'performed_by_user') this.performedByUser, + @JsonKey(name: 'has_old_value') this.hasOldValue = false, + @JsonKey(name: 'has_new_value') this.hasNewValue = false, + }) : super._(); + + factory _$AuditLogEntryModelImpl.fromJson(Map json) => + _$$AuditLogEntryModelImplFromJson(json); + + @override + @JsonKey(fromJson: _idFromJson) + final String id; + @override + @JsonKey(name: 'table_name') + final String tableName; + @override + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) + final String? recordId; + @override + final String action; + @override + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + final DateTime? performedAt; + @override + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + final String? performedBy; + @override + @JsonKey(name: 'request_id') + final String? requestId; + @override + @JsonKey(name: 'performed_by_user') + final AuditLogUserModel? performedByUser; + @override + @JsonKey(name: 'has_old_value') + final bool hasOldValue; + @override + @JsonKey(name: 'has_new_value') + final bool hasNewValue; + + @override + String toString() { + return 'AuditLogEntryModel(id: $id, tableName: $tableName, recordId: $recordId, action: $action, performedAt: $performedAt, performedBy: $performedBy, requestId: $requestId, performedByUser: $performedByUser, hasOldValue: $hasOldValue, hasNewValue: $hasNewValue)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AuditLogEntryModelImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.tableName, tableName) || + other.tableName == tableName) && + (identical(other.recordId, recordId) || + other.recordId == recordId) && + (identical(other.action, action) || other.action == action) && + (identical(other.performedAt, performedAt) || + other.performedAt == performedAt) && + (identical(other.performedBy, performedBy) || + other.performedBy == performedBy) && + (identical(other.requestId, requestId) || + other.requestId == requestId) && + (identical(other.performedByUser, performedByUser) || + other.performedByUser == performedByUser) && + (identical(other.hasOldValue, hasOldValue) || + other.hasOldValue == hasOldValue) && + (identical(other.hasNewValue, hasNewValue) || + other.hasNewValue == hasNewValue)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + tableName, + recordId, + action, + performedAt, + performedBy, + requestId, + performedByUser, + hasOldValue, + hasNewValue, + ); + + /// Create a copy of AuditLogEntryModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AuditLogEntryModelImplCopyWith<_$AuditLogEntryModelImpl> get copyWith => + __$$AuditLogEntryModelImplCopyWithImpl<_$AuditLogEntryModelImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$AuditLogEntryModelImplToJson(this); + } +} + +abstract class _AuditLogEntryModel extends AuditLogEntryModel { + const factory _AuditLogEntryModel({ + @JsonKey(fromJson: _idFromJson) required final String id, + @JsonKey(name: 'table_name') required final String tableName, + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) + final String? recordId, + required final String action, + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + final DateTime? performedAt, + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + final String? performedBy, + @JsonKey(name: 'request_id') final String? requestId, + @JsonKey(name: 'performed_by_user') + final AuditLogUserModel? performedByUser, + @JsonKey(name: 'has_old_value') final bool hasOldValue, + @JsonKey(name: 'has_new_value') final bool hasNewValue, + }) = _$AuditLogEntryModelImpl; + const _AuditLogEntryModel._() : super._(); + + factory _AuditLogEntryModel.fromJson(Map json) = + _$AuditLogEntryModelImpl.fromJson; + + @override + @JsonKey(fromJson: _idFromJson) + String get id; + @override + @JsonKey(name: 'table_name') + String get tableName; + @override + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) + String? get recordId; + @override + String get action; + @override + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + DateTime? get performedAt; + @override + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + String? get performedBy; + @override + @JsonKey(name: 'request_id') + String? get requestId; + @override + @JsonKey(name: 'performed_by_user') + AuditLogUserModel? get performedByUser; + @override + @JsonKey(name: 'has_old_value') + bool get hasOldValue; + @override + @JsonKey(name: 'has_new_value') + bool get hasNewValue; + + /// Create a copy of AuditLogEntryModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AuditLogEntryModelImplCopyWith<_$AuditLogEntryModelImpl> get copyWith => + throw _privateConstructorUsedError; +} + +AuditLogDetailModel _$AuditLogDetailModelFromJson(Map json) { + return _AuditLogDetailModel.fromJson(json); +} + +/// @nodoc +mixin _$AuditLogDetailModel { + @JsonKey(fromJson: _idFromJson) + String get id => throw _privateConstructorUsedError; + @JsonKey(name: 'table_name') + String get tableName => throw _privateConstructorUsedError; + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) + String? get recordId => throw _privateConstructorUsedError; + String get action => throw _privateConstructorUsedError; + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + DateTime? get performedAt => throw _privateConstructorUsedError; + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + String? get performedBy => throw _privateConstructorUsedError; + @JsonKey(name: 'request_id') + String? get requestId => throw _privateConstructorUsedError; + @JsonKey(name: 'performed_by_user') + AuditLogUserModel? get performedByUser => throw _privateConstructorUsedError; + @JsonKey(name: 'has_old_value') + bool get hasOldValue => throw _privateConstructorUsedError; + @JsonKey(name: 'has_new_value') + bool get hasNewValue => throw _privateConstructorUsedError; + @JsonKey(name: 'old_value', fromJson: _mapFromJsonNullable) + Map? get oldValue => throw _privateConstructorUsedError; + @JsonKey(name: 'new_value', fromJson: _mapFromJsonNullable) + Map? get newValue => throw _privateConstructorUsedError; + + /// Serializes this AuditLogDetailModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of AuditLogDetailModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AuditLogDetailModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AuditLogDetailModelCopyWith<$Res> { + factory $AuditLogDetailModelCopyWith( + AuditLogDetailModel value, + $Res Function(AuditLogDetailModel) then, + ) = _$AuditLogDetailModelCopyWithImpl<$Res, AuditLogDetailModel>; + @useResult + $Res call({ + @JsonKey(fromJson: _idFromJson) String id, + @JsonKey(name: 'table_name') String tableName, + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) String? recordId, + String action, + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + DateTime? performedAt, + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + String? performedBy, + @JsonKey(name: 'request_id') String? requestId, + @JsonKey(name: 'performed_by_user') AuditLogUserModel? performedByUser, + @JsonKey(name: 'has_old_value') bool hasOldValue, + @JsonKey(name: 'has_new_value') bool hasNewValue, + @JsonKey(name: 'old_value', fromJson: _mapFromJsonNullable) + Map? oldValue, + @JsonKey(name: 'new_value', fromJson: _mapFromJsonNullable) + Map? newValue, + }); + + $AuditLogUserModelCopyWith<$Res>? get performedByUser; +} + +/// @nodoc +class _$AuditLogDetailModelCopyWithImpl<$Res, $Val extends AuditLogDetailModel> + implements $AuditLogDetailModelCopyWith<$Res> { + _$AuditLogDetailModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AuditLogDetailModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? tableName = null, + Object? recordId = freezed, + Object? action = null, + Object? performedAt = freezed, + Object? performedBy = freezed, + Object? requestId = freezed, + Object? performedByUser = freezed, + Object? hasOldValue = null, + Object? hasNewValue = null, + Object? oldValue = freezed, + Object? newValue = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + tableName: null == tableName + ? _value.tableName + : tableName // ignore: cast_nullable_to_non_nullable + as String, + recordId: freezed == recordId + ? _value.recordId + : recordId // ignore: cast_nullable_to_non_nullable + as String?, + action: null == action + ? _value.action + : action // ignore: cast_nullable_to_non_nullable + as String, + performedAt: freezed == performedAt + ? _value.performedAt + : performedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + performedBy: freezed == performedBy + ? _value.performedBy + : performedBy // ignore: cast_nullable_to_non_nullable + as String?, + requestId: freezed == requestId + ? _value.requestId + : requestId // ignore: cast_nullable_to_non_nullable + as String?, + performedByUser: freezed == performedByUser + ? _value.performedByUser + : performedByUser // ignore: cast_nullable_to_non_nullable + as AuditLogUserModel?, + hasOldValue: null == hasOldValue + ? _value.hasOldValue + : hasOldValue // ignore: cast_nullable_to_non_nullable + as bool, + hasNewValue: null == hasNewValue + ? _value.hasNewValue + : hasNewValue // ignore: cast_nullable_to_non_nullable + as bool, + oldValue: freezed == oldValue + ? _value.oldValue + : oldValue // ignore: cast_nullable_to_non_nullable + as Map?, + newValue: freezed == newValue + ? _value.newValue + : newValue // ignore: cast_nullable_to_non_nullable + as Map?, + ) + as $Val, + ); + } + + /// Create a copy of AuditLogDetailModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AuditLogUserModelCopyWith<$Res>? get performedByUser { + if (_value.performedByUser == null) { + return null; + } + + return $AuditLogUserModelCopyWith<$Res>(_value.performedByUser!, (value) { + return _then(_value.copyWith(performedByUser: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$AuditLogDetailModelImplCopyWith<$Res> + implements $AuditLogDetailModelCopyWith<$Res> { + factory _$$AuditLogDetailModelImplCopyWith( + _$AuditLogDetailModelImpl value, + $Res Function(_$AuditLogDetailModelImpl) then, + ) = __$$AuditLogDetailModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + @JsonKey(fromJson: _idFromJson) String id, + @JsonKey(name: 'table_name') String tableName, + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) String? recordId, + String action, + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + DateTime? performedAt, + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + String? performedBy, + @JsonKey(name: 'request_id') String? requestId, + @JsonKey(name: 'performed_by_user') AuditLogUserModel? performedByUser, + @JsonKey(name: 'has_old_value') bool hasOldValue, + @JsonKey(name: 'has_new_value') bool hasNewValue, + @JsonKey(name: 'old_value', fromJson: _mapFromJsonNullable) + Map? oldValue, + @JsonKey(name: 'new_value', fromJson: _mapFromJsonNullable) + Map? newValue, + }); + + @override + $AuditLogUserModelCopyWith<$Res>? get performedByUser; +} + +/// @nodoc +class __$$AuditLogDetailModelImplCopyWithImpl<$Res> + extends _$AuditLogDetailModelCopyWithImpl<$Res, _$AuditLogDetailModelImpl> + implements _$$AuditLogDetailModelImplCopyWith<$Res> { + __$$AuditLogDetailModelImplCopyWithImpl( + _$AuditLogDetailModelImpl _value, + $Res Function(_$AuditLogDetailModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of AuditLogDetailModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? tableName = null, + Object? recordId = freezed, + Object? action = null, + Object? performedAt = freezed, + Object? performedBy = freezed, + Object? requestId = freezed, + Object? performedByUser = freezed, + Object? hasOldValue = null, + Object? hasNewValue = null, + Object? oldValue = freezed, + Object? newValue = freezed, + }) { + return _then( + _$AuditLogDetailModelImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + tableName: null == tableName + ? _value.tableName + : tableName // ignore: cast_nullable_to_non_nullable + as String, + recordId: freezed == recordId + ? _value.recordId + : recordId // ignore: cast_nullable_to_non_nullable + as String?, + action: null == action + ? _value.action + : action // ignore: cast_nullable_to_non_nullable + as String, + performedAt: freezed == performedAt + ? _value.performedAt + : performedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + performedBy: freezed == performedBy + ? _value.performedBy + : performedBy // ignore: cast_nullable_to_non_nullable + as String?, + requestId: freezed == requestId + ? _value.requestId + : requestId // ignore: cast_nullable_to_non_nullable + as String?, + performedByUser: freezed == performedByUser + ? _value.performedByUser + : performedByUser // ignore: cast_nullable_to_non_nullable + as AuditLogUserModel?, + hasOldValue: null == hasOldValue + ? _value.hasOldValue + : hasOldValue // ignore: cast_nullable_to_non_nullable + as bool, + hasNewValue: null == hasNewValue + ? _value.hasNewValue + : hasNewValue // ignore: cast_nullable_to_non_nullable + as bool, + oldValue: freezed == oldValue + ? _value._oldValue + : oldValue // ignore: cast_nullable_to_non_nullable + as Map?, + newValue: freezed == newValue + ? _value._newValue + : newValue // ignore: cast_nullable_to_non_nullable + as Map?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$AuditLogDetailModelImpl extends _AuditLogDetailModel { + const _$AuditLogDetailModelImpl({ + @JsonKey(fromJson: _idFromJson) required this.id, + @JsonKey(name: 'table_name') required this.tableName, + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) this.recordId, + required this.action, + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + this.performedAt, + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + this.performedBy, + @JsonKey(name: 'request_id') this.requestId, + @JsonKey(name: 'performed_by_user') this.performedByUser, + @JsonKey(name: 'has_old_value') this.hasOldValue = false, + @JsonKey(name: 'has_new_value') this.hasNewValue = false, + @JsonKey(name: 'old_value', fromJson: _mapFromJsonNullable) + final Map? oldValue, + @JsonKey(name: 'new_value', fromJson: _mapFromJsonNullable) + final Map? newValue, + }) : _oldValue = oldValue, + _newValue = newValue, + super._(); + + factory _$AuditLogDetailModelImpl.fromJson(Map json) => + _$$AuditLogDetailModelImplFromJson(json); + + @override + @JsonKey(fromJson: _idFromJson) + final String id; + @override + @JsonKey(name: 'table_name') + final String tableName; + @override + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) + final String? recordId; + @override + final String action; + @override + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + final DateTime? performedAt; + @override + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + final String? performedBy; + @override + @JsonKey(name: 'request_id') + final String? requestId; + @override + @JsonKey(name: 'performed_by_user') + final AuditLogUserModel? performedByUser; + @override + @JsonKey(name: 'has_old_value') + final bool hasOldValue; + @override + @JsonKey(name: 'has_new_value') + final bool hasNewValue; + final Map? _oldValue; + @override + @JsonKey(name: 'old_value', fromJson: _mapFromJsonNullable) + Map? get oldValue { + final value = _oldValue; + if (value == null) return null; + if (_oldValue is EqualUnmodifiableMapView) return _oldValue; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + + final Map? _newValue; + @override + @JsonKey(name: 'new_value', fromJson: _mapFromJsonNullable) + Map? get newValue { + final value = _newValue; + if (value == null) return null; + if (_newValue is EqualUnmodifiableMapView) return _newValue; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + + @override + String toString() { + return 'AuditLogDetailModel(id: $id, tableName: $tableName, recordId: $recordId, action: $action, performedAt: $performedAt, performedBy: $performedBy, requestId: $requestId, performedByUser: $performedByUser, hasOldValue: $hasOldValue, hasNewValue: $hasNewValue, oldValue: $oldValue, newValue: $newValue)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AuditLogDetailModelImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.tableName, tableName) || + other.tableName == tableName) && + (identical(other.recordId, recordId) || + other.recordId == recordId) && + (identical(other.action, action) || other.action == action) && + (identical(other.performedAt, performedAt) || + other.performedAt == performedAt) && + (identical(other.performedBy, performedBy) || + other.performedBy == performedBy) && + (identical(other.requestId, requestId) || + other.requestId == requestId) && + (identical(other.performedByUser, performedByUser) || + other.performedByUser == performedByUser) && + (identical(other.hasOldValue, hasOldValue) || + other.hasOldValue == hasOldValue) && + (identical(other.hasNewValue, hasNewValue) || + other.hasNewValue == hasNewValue) && + const DeepCollectionEquality().equals(other._oldValue, _oldValue) && + const DeepCollectionEquality().equals(other._newValue, _newValue)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + tableName, + recordId, + action, + performedAt, + performedBy, + requestId, + performedByUser, + hasOldValue, + hasNewValue, + const DeepCollectionEquality().hash(_oldValue), + const DeepCollectionEquality().hash(_newValue), + ); + + /// Create a copy of AuditLogDetailModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AuditLogDetailModelImplCopyWith<_$AuditLogDetailModelImpl> get copyWith => + __$$AuditLogDetailModelImplCopyWithImpl<_$AuditLogDetailModelImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$AuditLogDetailModelImplToJson(this); + } +} + +abstract class _AuditLogDetailModel extends AuditLogDetailModel { + const factory _AuditLogDetailModel({ + @JsonKey(fromJson: _idFromJson) required final String id, + @JsonKey(name: 'table_name') required final String tableName, + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) + final String? recordId, + required final String action, + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + final DateTime? performedAt, + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + final String? performedBy, + @JsonKey(name: 'request_id') final String? requestId, + @JsonKey(name: 'performed_by_user') + final AuditLogUserModel? performedByUser, + @JsonKey(name: 'has_old_value') final bool hasOldValue, + @JsonKey(name: 'has_new_value') final bool hasNewValue, + @JsonKey(name: 'old_value', fromJson: _mapFromJsonNullable) + final Map? oldValue, + @JsonKey(name: 'new_value', fromJson: _mapFromJsonNullable) + final Map? newValue, + }) = _$AuditLogDetailModelImpl; + const _AuditLogDetailModel._() : super._(); + + factory _AuditLogDetailModel.fromJson(Map json) = + _$AuditLogDetailModelImpl.fromJson; + + @override + @JsonKey(fromJson: _idFromJson) + String get id; + @override + @JsonKey(name: 'table_name') + String get tableName; + @override + @JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) + String? get recordId; + @override + String get action; + @override + @JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable) + DateTime? get performedAt; + @override + @JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable) + String? get performedBy; + @override + @JsonKey(name: 'request_id') + String? get requestId; + @override + @JsonKey(name: 'performed_by_user') + AuditLogUserModel? get performedByUser; + @override + @JsonKey(name: 'has_old_value') + bool get hasOldValue; + @override + @JsonKey(name: 'has_new_value') + bool get hasNewValue; + @override + @JsonKey(name: 'old_value', fromJson: _mapFromJsonNullable) + Map? get oldValue; + @override + @JsonKey(name: 'new_value', fromJson: _mapFromJsonNullable) + Map? get newValue; + + /// Create a copy of AuditLogDetailModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AuditLogDetailModelImplCopyWith<_$AuditLogDetailModelImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$AuditLogListQuery { + int get page => throw _privateConstructorUsedError; + int get limit => throw _privateConstructorUsedError; + @JsonKey(name: 'table_name') + String? get tableName => throw _privateConstructorUsedError; + @JsonKey(name: 'record_id') + int? get recordId => throw _privateConstructorUsedError; + String? get action => throw _privateConstructorUsedError; + @JsonKey(name: 'performed_by') + int? get performedBy => throw _privateConstructorUsedError; + @JsonKey(name: 'request_id') + String? get requestId => throw _privateConstructorUsedError; + @JsonKey(name: 'date_from') + DateTime? get dateFrom => throw _privateConstructorUsedError; + @JsonKey(name: 'date_to') + DateTime? get dateTo => throw _privateConstructorUsedError; + String? get search => throw _privateConstructorUsedError; + + /// Create a copy of AuditLogListQuery + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AuditLogListQueryCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AuditLogListQueryCopyWith<$Res> { + factory $AuditLogListQueryCopyWith( + AuditLogListQuery value, + $Res Function(AuditLogListQuery) then, + ) = _$AuditLogListQueryCopyWithImpl<$Res, AuditLogListQuery>; + @useResult + $Res call({ + int page, + int limit, + @JsonKey(name: 'table_name') String? tableName, + @JsonKey(name: 'record_id') int? recordId, + String? action, + @JsonKey(name: 'performed_by') int? performedBy, + @JsonKey(name: 'request_id') String? requestId, + @JsonKey(name: 'date_from') DateTime? dateFrom, + @JsonKey(name: 'date_to') DateTime? dateTo, + String? search, + }); +} + +/// @nodoc +class _$AuditLogListQueryCopyWithImpl<$Res, $Val extends AuditLogListQuery> + implements $AuditLogListQueryCopyWith<$Res> { + _$AuditLogListQueryCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AuditLogListQuery + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? page = null, + Object? limit = null, + Object? tableName = freezed, + Object? recordId = freezed, + Object? action = freezed, + Object? performedBy = freezed, + Object? requestId = freezed, + Object? dateFrom = freezed, + Object? dateTo = freezed, + Object? search = freezed, + }) { + return _then( + _value.copyWith( + page: null == page + ? _value.page + : page // ignore: cast_nullable_to_non_nullable + as int, + limit: null == limit + ? _value.limit + : limit // ignore: cast_nullable_to_non_nullable + as int, + tableName: freezed == tableName + ? _value.tableName + : tableName // ignore: cast_nullable_to_non_nullable + as String?, + recordId: freezed == recordId + ? _value.recordId + : recordId // ignore: cast_nullable_to_non_nullable + as int?, + action: freezed == action + ? _value.action + : action // ignore: cast_nullable_to_non_nullable + as String?, + performedBy: freezed == performedBy + ? _value.performedBy + : performedBy // ignore: cast_nullable_to_non_nullable + as int?, + requestId: freezed == requestId + ? _value.requestId + : requestId // ignore: cast_nullable_to_non_nullable + as String?, + dateFrom: freezed == dateFrom + ? _value.dateFrom + : dateFrom // ignore: cast_nullable_to_non_nullable + as DateTime?, + dateTo: freezed == dateTo + ? _value.dateTo + : dateTo // ignore: cast_nullable_to_non_nullable + as DateTime?, + search: freezed == search + ? _value.search + : search // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$AuditLogListQueryImplCopyWith<$Res> + implements $AuditLogListQueryCopyWith<$Res> { + factory _$$AuditLogListQueryImplCopyWith( + _$AuditLogListQueryImpl value, + $Res Function(_$AuditLogListQueryImpl) then, + ) = __$$AuditLogListQueryImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + int page, + int limit, + @JsonKey(name: 'table_name') String? tableName, + @JsonKey(name: 'record_id') int? recordId, + String? action, + @JsonKey(name: 'performed_by') int? performedBy, + @JsonKey(name: 'request_id') String? requestId, + @JsonKey(name: 'date_from') DateTime? dateFrom, + @JsonKey(name: 'date_to') DateTime? dateTo, + String? search, + }); +} + +/// @nodoc +class __$$AuditLogListQueryImplCopyWithImpl<$Res> + extends _$AuditLogListQueryCopyWithImpl<$Res, _$AuditLogListQueryImpl> + implements _$$AuditLogListQueryImplCopyWith<$Res> { + __$$AuditLogListQueryImplCopyWithImpl( + _$AuditLogListQueryImpl _value, + $Res Function(_$AuditLogListQueryImpl) _then, + ) : super(_value, _then); + + /// Create a copy of AuditLogListQuery + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? page = null, + Object? limit = null, + Object? tableName = freezed, + Object? recordId = freezed, + Object? action = freezed, + Object? performedBy = freezed, + Object? requestId = freezed, + Object? dateFrom = freezed, + Object? dateTo = freezed, + Object? search = freezed, + }) { + return _then( + _$AuditLogListQueryImpl( + page: null == page + ? _value.page + : page // ignore: cast_nullable_to_non_nullable + as int, + limit: null == limit + ? _value.limit + : limit // ignore: cast_nullable_to_non_nullable + as int, + tableName: freezed == tableName + ? _value.tableName + : tableName // ignore: cast_nullable_to_non_nullable + as String?, + recordId: freezed == recordId + ? _value.recordId + : recordId // ignore: cast_nullable_to_non_nullable + as int?, + action: freezed == action + ? _value.action + : action // ignore: cast_nullable_to_non_nullable + as String?, + performedBy: freezed == performedBy + ? _value.performedBy + : performedBy // ignore: cast_nullable_to_non_nullable + as int?, + requestId: freezed == requestId + ? _value.requestId + : requestId // ignore: cast_nullable_to_non_nullable + as String?, + dateFrom: freezed == dateFrom + ? _value.dateFrom + : dateFrom // ignore: cast_nullable_to_non_nullable + as DateTime?, + dateTo: freezed == dateTo + ? _value.dateTo + : dateTo // ignore: cast_nullable_to_non_nullable + as DateTime?, + search: freezed == search + ? _value.search + : search // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc + +class _$AuditLogListQueryImpl extends _AuditLogListQuery { + const _$AuditLogListQueryImpl({ + this.page = 1, + this.limit = 20, + @JsonKey(name: 'table_name') this.tableName, + @JsonKey(name: 'record_id') this.recordId, + this.action, + @JsonKey(name: 'performed_by') this.performedBy, + @JsonKey(name: 'request_id') this.requestId, + @JsonKey(name: 'date_from') this.dateFrom, + @JsonKey(name: 'date_to') this.dateTo, + this.search, + }) : super._(); + + @override + @JsonKey() + final int page; + @override + @JsonKey() + final int limit; + @override + @JsonKey(name: 'table_name') + final String? tableName; + @override + @JsonKey(name: 'record_id') + final int? recordId; + @override + final String? action; + @override + @JsonKey(name: 'performed_by') + final int? performedBy; + @override + @JsonKey(name: 'request_id') + final String? requestId; + @override + @JsonKey(name: 'date_from') + final DateTime? dateFrom; + @override + @JsonKey(name: 'date_to') + final DateTime? dateTo; + @override + final String? search; + + @override + String toString() { + return 'AuditLogListQuery(page: $page, limit: $limit, tableName: $tableName, recordId: $recordId, action: $action, performedBy: $performedBy, requestId: $requestId, dateFrom: $dateFrom, dateTo: $dateTo, search: $search)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AuditLogListQueryImpl && + (identical(other.page, page) || other.page == page) && + (identical(other.limit, limit) || other.limit == limit) && + (identical(other.tableName, tableName) || + other.tableName == tableName) && + (identical(other.recordId, recordId) || + other.recordId == recordId) && + (identical(other.action, action) || other.action == action) && + (identical(other.performedBy, performedBy) || + other.performedBy == performedBy) && + (identical(other.requestId, requestId) || + other.requestId == requestId) && + (identical(other.dateFrom, dateFrom) || + other.dateFrom == dateFrom) && + (identical(other.dateTo, dateTo) || other.dateTo == dateTo) && + (identical(other.search, search) || other.search == search)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + page, + limit, + tableName, + recordId, + action, + performedBy, + requestId, + dateFrom, + dateTo, + search, + ); + + /// Create a copy of AuditLogListQuery + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AuditLogListQueryImplCopyWith<_$AuditLogListQueryImpl> get copyWith => + __$$AuditLogListQueryImplCopyWithImpl<_$AuditLogListQueryImpl>( + this, + _$identity, + ); +} + +abstract class _AuditLogListQuery extends AuditLogListQuery { + const factory _AuditLogListQuery({ + final int page, + final int limit, + @JsonKey(name: 'table_name') final String? tableName, + @JsonKey(name: 'record_id') final int? recordId, + final String? action, + @JsonKey(name: 'performed_by') final int? performedBy, + @JsonKey(name: 'request_id') final String? requestId, + @JsonKey(name: 'date_from') final DateTime? dateFrom, + @JsonKey(name: 'date_to') final DateTime? dateTo, + final String? search, + }) = _$AuditLogListQueryImpl; + const _AuditLogListQuery._() : super._(); + + @override + int get page; + @override + int get limit; + @override + @JsonKey(name: 'table_name') + String? get tableName; + @override + @JsonKey(name: 'record_id') + int? get recordId; + @override + String? get action; + @override + @JsonKey(name: 'performed_by') + int? get performedBy; + @override + @JsonKey(name: 'request_id') + String? get requestId; + @override + @JsonKey(name: 'date_from') + DateTime? get dateFrom; + @override + @JsonKey(name: 'date_to') + DateTime? get dateTo; + @override + String? get search; + + /// Create a copy of AuditLogListQuery + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AuditLogListQueryImplCopyWith<_$AuditLogListQueryImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$AuditLogListResult { + List get items => throw _privateConstructorUsedError; + int get page => throw _privateConstructorUsedError; + int get limit => throw _privateConstructorUsedError; + int get total => throw _privateConstructorUsedError; + int get totalPages => throw _privateConstructorUsedError; + bool get filtersRequired => throw _privateConstructorUsedError; + + /// Create a copy of AuditLogListResult + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AuditLogListResultCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AuditLogListResultCopyWith<$Res> { + factory $AuditLogListResultCopyWith( + AuditLogListResult value, + $Res Function(AuditLogListResult) then, + ) = _$AuditLogListResultCopyWithImpl<$Res, AuditLogListResult>; + @useResult + $Res call({ + List items, + int page, + int limit, + int total, + int totalPages, + bool filtersRequired, + }); +} + +/// @nodoc +class _$AuditLogListResultCopyWithImpl<$Res, $Val extends AuditLogListResult> + implements $AuditLogListResultCopyWith<$Res> { + _$AuditLogListResultCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AuditLogListResult + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? items = null, + Object? page = null, + Object? limit = null, + Object? total = null, + Object? totalPages = null, + Object? filtersRequired = null, + }) { + return _then( + _value.copyWith( + items: null == items + ? _value.items + : items // ignore: cast_nullable_to_non_nullable + as List, + page: null == page + ? _value.page + : page // ignore: cast_nullable_to_non_nullable + as int, + limit: null == limit + ? _value.limit + : limit // ignore: cast_nullable_to_non_nullable + as int, + total: null == total + ? _value.total + : total // ignore: cast_nullable_to_non_nullable + as int, + totalPages: null == totalPages + ? _value.totalPages + : totalPages // ignore: cast_nullable_to_non_nullable + as int, + filtersRequired: null == filtersRequired + ? _value.filtersRequired + : filtersRequired // ignore: cast_nullable_to_non_nullable + as bool, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$AuditLogListResultImplCopyWith<$Res> + implements $AuditLogListResultCopyWith<$Res> { + factory _$$AuditLogListResultImplCopyWith( + _$AuditLogListResultImpl value, + $Res Function(_$AuditLogListResultImpl) then, + ) = __$$AuditLogListResultImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + List items, + int page, + int limit, + int total, + int totalPages, + bool filtersRequired, + }); +} + +/// @nodoc +class __$$AuditLogListResultImplCopyWithImpl<$Res> + extends _$AuditLogListResultCopyWithImpl<$Res, _$AuditLogListResultImpl> + implements _$$AuditLogListResultImplCopyWith<$Res> { + __$$AuditLogListResultImplCopyWithImpl( + _$AuditLogListResultImpl _value, + $Res Function(_$AuditLogListResultImpl) _then, + ) : super(_value, _then); + + /// Create a copy of AuditLogListResult + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? items = null, + Object? page = null, + Object? limit = null, + Object? total = null, + Object? totalPages = null, + Object? filtersRequired = null, + }) { + return _then( + _$AuditLogListResultImpl( + items: null == items + ? _value._items + : items // ignore: cast_nullable_to_non_nullable + as List, + page: null == page + ? _value.page + : page // ignore: cast_nullable_to_non_nullable + as int, + limit: null == limit + ? _value.limit + : limit // ignore: cast_nullable_to_non_nullable + as int, + total: null == total + ? _value.total + : total // ignore: cast_nullable_to_non_nullable + as int, + totalPages: null == totalPages + ? _value.totalPages + : totalPages // ignore: cast_nullable_to_non_nullable + as int, + filtersRequired: null == filtersRequired + ? _value.filtersRequired + : filtersRequired // ignore: cast_nullable_to_non_nullable + as bool, + ), + ); + } +} + +/// @nodoc + +class _$AuditLogListResultImpl implements _AuditLogListResult { + const _$AuditLogListResultImpl({ + final List items = const [], + this.page = 1, + this.limit = 20, + this.total = 0, + this.totalPages = 1, + this.filtersRequired = false, + }) : _items = items; + + final List _items; + @override + @JsonKey() + List get items { + if (_items is EqualUnmodifiableListView) return _items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_items); + } + + @override + @JsonKey() + final int page; + @override + @JsonKey() + final int limit; + @override + @JsonKey() + final int total; + @override + @JsonKey() + final int totalPages; + @override + @JsonKey() + final bool filtersRequired; + + @override + String toString() { + return 'AuditLogListResult(items: $items, page: $page, limit: $limit, total: $total, totalPages: $totalPages, filtersRequired: $filtersRequired)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AuditLogListResultImpl && + const DeepCollectionEquality().equals(other._items, _items) && + (identical(other.page, page) || other.page == page) && + (identical(other.limit, limit) || other.limit == limit) && + (identical(other.total, total) || other.total == total) && + (identical(other.totalPages, totalPages) || + other.totalPages == totalPages) && + (identical(other.filtersRequired, filtersRequired) || + other.filtersRequired == filtersRequired)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(_items), + page, + limit, + total, + totalPages, + filtersRequired, + ); + + /// Create a copy of AuditLogListResult + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AuditLogListResultImplCopyWith<_$AuditLogListResultImpl> get copyWith => + __$$AuditLogListResultImplCopyWithImpl<_$AuditLogListResultImpl>( + this, + _$identity, + ); +} + +abstract class _AuditLogListResult implements AuditLogListResult { + const factory _AuditLogListResult({ + final List items, + final int page, + final int limit, + final int total, + final int totalPages, + final bool filtersRequired, + }) = _$AuditLogListResultImpl; + + @override + List get items; + @override + int get page; + @override + int get limit; + @override + int get total; + @override + int get totalPages; + @override + bool get filtersRequired; + + /// Create a copy of AuditLogListResult + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AuditLogListResultImplCopyWith<_$AuditLogListResultImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/shared/models/audit_log_model.g.dart b/lib/shared/models/audit_log_model.g.dart new file mode 100644 index 0000000..d26186c --- /dev/null +++ b/lib/shared/models/audit_log_model.g.dart @@ -0,0 +1,141 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'audit_log_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$AuditLogUserModelImpl _$$AuditLogUserModelImplFromJson( + Map json, +) => _$AuditLogUserModelImpl( + id: _idFromJson(json['id']), + fullName: json['full_name'] as String?, + employeeCode: json['employee_code'] as String?, + email: json['email'] as String?, +); + +Map _$$AuditLogUserModelImplToJson( + _$AuditLogUserModelImpl instance, +) => { + 'id': instance.id, + 'full_name': instance.fullName, + 'employee_code': instance.employeeCode, + 'email': instance.email, +}; + +_$AuditLogPerformerOptionImpl _$$AuditLogPerformerOptionImplFromJson( + Map json, +) => _$AuditLogPerformerOptionImpl( + id: _idFromJson(json['id']), + fullName: json['full_name'] as String?, + employeeCode: json['employee_code'] as String?, +); + +Map _$$AuditLogPerformerOptionImplToJson( + _$AuditLogPerformerOptionImpl instance, +) => { + 'id': instance.id, + 'full_name': instance.fullName, + 'employee_code': instance.employeeCode, +}; + +_$AuditLogFilterOptionsImpl _$$AuditLogFilterOptionsImplFromJson( + Map json, +) => _$AuditLogFilterOptionsImpl( + tableNames: + (json['table_names'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + actions: + (json['actions'] as List?)?.map((e) => e as String).toList() ?? + const [], + performers: + (json['performers'] as List?) + ?.map( + (e) => AuditLogPerformerOption.fromJson(e as Map), + ) + .toList() ?? + const [], +); + +Map _$$AuditLogFilterOptionsImplToJson( + _$AuditLogFilterOptionsImpl instance, +) => { + 'table_names': instance.tableNames, + 'actions': instance.actions, + 'performers': instance.performers, +}; + +_$AuditLogEntryModelImpl _$$AuditLogEntryModelImplFromJson( + Map json, +) => _$AuditLogEntryModelImpl( + id: _idFromJson(json['id']), + tableName: json['table_name'] as String, + recordId: _idFromJsonNullable(json['record_id']), + action: json['action'] as String, + performedAt: _dateFromJsonNullable(json['performed_at']), + performedBy: _idFromJsonNullable(json['performed_by']), + requestId: json['request_id'] as String?, + performedByUser: json['performed_by_user'] == null + ? null + : AuditLogUserModel.fromJson( + json['performed_by_user'] as Map, + ), + hasOldValue: json['has_old_value'] as bool? ?? false, + hasNewValue: json['has_new_value'] as bool? ?? false, +); + +Map _$$AuditLogEntryModelImplToJson( + _$AuditLogEntryModelImpl instance, +) => { + 'id': instance.id, + 'table_name': instance.tableName, + 'record_id': instance.recordId, + 'action': instance.action, + 'performed_at': instance.performedAt?.toIso8601String(), + 'performed_by': instance.performedBy, + 'request_id': instance.requestId, + 'performed_by_user': instance.performedByUser, + 'has_old_value': instance.hasOldValue, + 'has_new_value': instance.hasNewValue, +}; + +_$AuditLogDetailModelImpl _$$AuditLogDetailModelImplFromJson( + Map json, +) => _$AuditLogDetailModelImpl( + id: _idFromJson(json['id']), + tableName: json['table_name'] as String, + recordId: _idFromJsonNullable(json['record_id']), + action: json['action'] as String, + performedAt: _dateFromJsonNullable(json['performed_at']), + performedBy: _idFromJsonNullable(json['performed_by']), + requestId: json['request_id'] as String?, + performedByUser: json['performed_by_user'] == null + ? null + : AuditLogUserModel.fromJson( + json['performed_by_user'] as Map, + ), + hasOldValue: json['has_old_value'] as bool? ?? false, + hasNewValue: json['has_new_value'] as bool? ?? false, + oldValue: _mapFromJsonNullable(json['old_value']), + newValue: _mapFromJsonNullable(json['new_value']), +); + +Map _$$AuditLogDetailModelImplToJson( + _$AuditLogDetailModelImpl instance, +) => { + 'id': instance.id, + 'table_name': instance.tableName, + 'record_id': instance.recordId, + 'action': instance.action, + 'performed_at': instance.performedAt?.toIso8601String(), + 'performed_by': instance.performedBy, + 'request_id': instance.requestId, + 'performed_by_user': instance.performedByUser, + 'has_old_value': instance.hasOldValue, + 'has_new_value': instance.hasNewValue, + 'old_value': instance.oldValue, + 'new_value': instance.newValue, +}; diff --git a/lib/shared/models/grn_model.dart b/lib/shared/models/grn_model.dart index b5eb13b..5df1034 100644 --- a/lib/shared/models/grn_model.dart +++ b/lib/shared/models/grn_model.dart @@ -87,6 +87,36 @@ Object? _readUomName(Map json, String key) { return null; } +Object? _readGrnItemCategoryId(Map json, String key) { + for (final flatKey in ['item_category_id', 'asset_category_id']) { + final flat = json[flatKey]; + if (flat != null) return flat; + } + final nested = json['item_category'] ?? json['asset_category']; + if (nested is Map) return nested['id']; + return null; +} + +Object? _readGrnItemSubcategoryId(Map json, String key) { + for (final flatKey in ['item_subcategory_id', 'asset_subcategory_id']) { + final flat = json[flatKey]; + if (flat != null) return flat; + } + final nested = json['item_subcategory'] ?? json['asset_subcategory']; + if (nested is Map) return nested['id']; + return null; +} + +Object? _readUploadedByName(Map json, String key) { + final flat = json['uploaded_by_name']; + if (flat is String && flat.trim().isNotEmpty) return flat; + final nested = json['uploaded_by_user'] ?? json['uploaded_by']; + if (nested is Map) { + return nested['full_name'] ?? nested['name']; + } + return null; +} + @freezed class GrnModel with _$GrnModel { const GrnModel._(); @@ -118,6 +148,7 @@ class GrnModel with _$GrnModel { @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt, @JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt, @Default([]) List items, + @Default([]) List attachments, }) = _GrnModel; factory GrnModel.fromJson(Map json) => _$GrnModelFromJson(json); @@ -125,6 +156,42 @@ class GrnModel with _$GrnModel { bool get canEdit => status.toUpperCase() == 'POSTED'; bool get canCancel => status.toUpperCase() == 'POSTED'; + + /// Upload/delete attachments only while GRN is POSTED. + bool get canManageAttachments => status.toUpperCase() == 'POSTED'; +} + +@freezed +class GrnAttachmentModel with _$GrnAttachmentModel { + const GrnAttachmentModel._(); + + const factory GrnAttachmentModel({ + @JsonKey(fromJson: _idFromJson) required String id, + @JsonKey(name: 'grn_id', fromJson: _idFromJson) String? grnId, + @JsonKey(name: 'file_name') String? fileName, + @JsonKey(name: 'file_type') String? fileType, + @JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) int? fileSize, + @JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName) + String? uploadedByName, + @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt, + }) = _GrnAttachmentModel; + + factory GrnAttachmentModel.fromJson(Map json) => + _$GrnAttachmentModelFromJson(json); + + bool get isPdf => + (fileType ?? '').toLowerCase().contains('pdf') || + (fileName ?? '').toLowerCase().endsWith('.pdf'); + + bool get isImage { + final type = (fileType ?? '').toLowerCase(); + final name = (fileName ?? '').toLowerCase(); + return type.startsWith('image/') || + name.endsWith('.jpg') || + name.endsWith('.jpeg') || + name.endsWith('.png') || + name.endsWith('.webp'); + } } @freezed @@ -149,10 +216,18 @@ class GrnItemModel with _$GrnItemModel { @JsonKey(name: 'mfg_date', fromJson: _dateFromJsonNullable) DateTime? mfgDate, @JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable) DateTime? expiryDate, @JsonKey(name: 'storage_location') String? storageLocation, - @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) - int? assetCategoryId, - @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) - int? assetSubcategoryId, + @JsonKey( + name: 'item_category_id', + readValue: _readGrnItemCategoryId, + fromJson: _intFromJsonNullable, + ) + int? itemCategoryId, + @JsonKey( + name: 'item_subcategory_id', + readValue: _readGrnItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + int? itemSubcategoryId, @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId, @JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName, String? remarks, diff --git a/lib/shared/models/grn_model.freezed.dart b/lib/shared/models/grn_model.freezed.dart index 68cc3c7..444442d 100644 --- a/lib/shared/models/grn_model.freezed.dart +++ b/lib/shared/models/grn_model.freezed.dart @@ -64,6 +64,8 @@ mixin _$GrnModel { @JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? get updatedAt => throw _privateConstructorUsedError; List get items => throw _privateConstructorUsedError; + List get attachments => + throw _privateConstructorUsedError; /// Serializes this GrnModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -114,6 +116,7 @@ abstract class $GrnModelCopyWith<$Res> { @JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt, List items, + List attachments, }); } @@ -155,6 +158,7 @@ class _$GrnModelCopyWithImpl<$Res, $Val extends GrnModel> Object? createdAt = freezed, Object? updatedAt = freezed, Object? items = null, + Object? attachments = null, }) { return _then( _value.copyWith( @@ -250,6 +254,10 @@ class _$GrnModelCopyWithImpl<$Res, $Val extends GrnModel> ? _value.items : items // ignore: cast_nullable_to_non_nullable as List, + attachments: null == attachments + ? _value.attachments + : attachments // ignore: cast_nullable_to_non_nullable + as List, ) as $Val, ); @@ -299,6 +307,7 @@ abstract class _$$GrnModelImplCopyWith<$Res> @JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt, List items, + List attachments, }); } @@ -339,6 +348,7 @@ class __$$GrnModelImplCopyWithImpl<$Res> Object? createdAt = freezed, Object? updatedAt = freezed, Object? items = null, + Object? attachments = null, }) { return _then( _$GrnModelImpl( @@ -434,6 +444,10 @@ class __$$GrnModelImplCopyWithImpl<$Res> ? _value._items : items // ignore: cast_nullable_to_non_nullable as List, + attachments: null == attachments + ? _value._attachments + : attachments // ignore: cast_nullable_to_non_nullable + as List, ), ); } @@ -474,7 +488,9 @@ class _$GrnModelImpl extends _GrnModel { @JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) this.updatedAt, final List items = const [], + final List attachments = const [], }) : _items = items, + _attachments = attachments, super._(); factory _$GrnModelImpl.fromJson(Map json) => @@ -554,9 +570,18 @@ class _$GrnModelImpl extends _GrnModel { return EqualUnmodifiableListView(_items); } + final List _attachments; + @override + @JsonKey() + List get attachments { + if (_attachments is EqualUnmodifiableListView) return _attachments; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_attachments); + } + @override String toString() { - return 'GrnModel(id: $id, grnNumber: $grnNumber, grnDate: $grnDate, status: $status, poId: $poId, poNumber: $poNumber, vendorId: $vendorId, vendorName: $vendorName, warehouseId: $warehouseId, warehouseName: $warehouseName, vendorInvoiceNo: $vendorInvoiceNo, vendorInvoiceDate: $vendorInvoiceDate, vendorInvoiceAmount: $vendorInvoiceAmount, vehicleNo: $vehicleNo, lrNo: $lrNo, lrDate: $lrDate, receivedBy: $receivedBy, qualityCheckedBy: $qualityCheckedBy, remarks: $remarks, cancellationReason: $cancellationReason, createdAt: $createdAt, updatedAt: $updatedAt, items: $items)'; + return 'GrnModel(id: $id, grnNumber: $grnNumber, grnDate: $grnDate, status: $status, poId: $poId, poNumber: $poNumber, vendorId: $vendorId, vendorName: $vendorName, warehouseId: $warehouseId, warehouseName: $warehouseName, vendorInvoiceNo: $vendorInvoiceNo, vendorInvoiceDate: $vendorInvoiceDate, vendorInvoiceAmount: $vendorInvoiceAmount, vehicleNo: $vehicleNo, lrNo: $lrNo, lrDate: $lrDate, receivedBy: $receivedBy, qualityCheckedBy: $qualityCheckedBy, remarks: $remarks, cancellationReason: $cancellationReason, createdAt: $createdAt, updatedAt: $updatedAt, items: $items, attachments: $attachments)'; } @override @@ -601,7 +626,11 @@ class _$GrnModelImpl extends _GrnModel { other.createdAt == createdAt) && (identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt) && - const DeepCollectionEquality().equals(other._items, _items)); + const DeepCollectionEquality().equals(other._items, _items) && + const DeepCollectionEquality().equals( + other._attachments, + _attachments, + )); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -631,6 +660,7 @@ class _$GrnModelImpl extends _GrnModel { createdAt, updatedAt, const DeepCollectionEquality().hash(_items), + const DeepCollectionEquality().hash(_attachments), ]); /// Create a copy of GrnModel @@ -686,6 +716,7 @@ abstract class _GrnModel extends GrnModel { @JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) final DateTime? updatedAt, final List items, + final List attachments, }) = _$GrnModelImpl; const _GrnModel._() : super._(); @@ -758,6 +789,8 @@ abstract class _GrnModel extends GrnModel { DateTime? get updatedAt; @override List get items; + @override + List get attachments; /// Create a copy of GrnModel /// with the given fields replaced by the non-null parameter values. @@ -767,6 +800,337 @@ abstract class _GrnModel extends GrnModel { throw _privateConstructorUsedError; } +GrnAttachmentModel _$GrnAttachmentModelFromJson(Map json) { + return _GrnAttachmentModel.fromJson(json); +} + +/// @nodoc +mixin _$GrnAttachmentModel { + @JsonKey(fromJson: _idFromJson) + String get id => throw _privateConstructorUsedError; + @JsonKey(name: 'grn_id', fromJson: _idFromJson) + String? get grnId => throw _privateConstructorUsedError; + @JsonKey(name: 'file_name') + String? get fileName => throw _privateConstructorUsedError; + @JsonKey(name: 'file_type') + String? get fileType => throw _privateConstructorUsedError; + @JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) + int? get fileSize => throw _privateConstructorUsedError; + @JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName) + String? get uploadedByName => throw _privateConstructorUsedError; + @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) + DateTime? get createdAt => throw _privateConstructorUsedError; + + /// Serializes this GrnAttachmentModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of GrnAttachmentModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $GrnAttachmentModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $GrnAttachmentModelCopyWith<$Res> { + factory $GrnAttachmentModelCopyWith( + GrnAttachmentModel value, + $Res Function(GrnAttachmentModel) then, + ) = _$GrnAttachmentModelCopyWithImpl<$Res, GrnAttachmentModel>; + @useResult + $Res call({ + @JsonKey(fromJson: _idFromJson) String id, + @JsonKey(name: 'grn_id', fromJson: _idFromJson) String? grnId, + @JsonKey(name: 'file_name') String? fileName, + @JsonKey(name: 'file_type') String? fileType, + @JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) int? fileSize, + @JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName) + String? uploadedByName, + @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) + DateTime? createdAt, + }); +} + +/// @nodoc +class _$GrnAttachmentModelCopyWithImpl<$Res, $Val extends GrnAttachmentModel> + implements $GrnAttachmentModelCopyWith<$Res> { + _$GrnAttachmentModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of GrnAttachmentModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? grnId = freezed, + Object? fileName = freezed, + Object? fileType = freezed, + Object? fileSize = freezed, + Object? uploadedByName = freezed, + Object? createdAt = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + grnId: freezed == grnId + ? _value.grnId + : grnId // ignore: cast_nullable_to_non_nullable + as String?, + fileName: freezed == fileName + ? _value.fileName + : fileName // ignore: cast_nullable_to_non_nullable + as String?, + fileType: freezed == fileType + ? _value.fileType + : fileType // ignore: cast_nullable_to_non_nullable + as String?, + fileSize: freezed == fileSize + ? _value.fileSize + : fileSize // ignore: cast_nullable_to_non_nullable + as int?, + uploadedByName: freezed == uploadedByName + ? _value.uploadedByName + : uploadedByName // ignore: cast_nullable_to_non_nullable + as String?, + createdAt: freezed == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$GrnAttachmentModelImplCopyWith<$Res> + implements $GrnAttachmentModelCopyWith<$Res> { + factory _$$GrnAttachmentModelImplCopyWith( + _$GrnAttachmentModelImpl value, + $Res Function(_$GrnAttachmentModelImpl) then, + ) = __$$GrnAttachmentModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + @JsonKey(fromJson: _idFromJson) String id, + @JsonKey(name: 'grn_id', fromJson: _idFromJson) String? grnId, + @JsonKey(name: 'file_name') String? fileName, + @JsonKey(name: 'file_type') String? fileType, + @JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) int? fileSize, + @JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName) + String? uploadedByName, + @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) + DateTime? createdAt, + }); +} + +/// @nodoc +class __$$GrnAttachmentModelImplCopyWithImpl<$Res> + extends _$GrnAttachmentModelCopyWithImpl<$Res, _$GrnAttachmentModelImpl> + implements _$$GrnAttachmentModelImplCopyWith<$Res> { + __$$GrnAttachmentModelImplCopyWithImpl( + _$GrnAttachmentModelImpl _value, + $Res Function(_$GrnAttachmentModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of GrnAttachmentModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? grnId = freezed, + Object? fileName = freezed, + Object? fileType = freezed, + Object? fileSize = freezed, + Object? uploadedByName = freezed, + Object? createdAt = freezed, + }) { + return _then( + _$GrnAttachmentModelImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + grnId: freezed == grnId + ? _value.grnId + : grnId // ignore: cast_nullable_to_non_nullable + as String?, + fileName: freezed == fileName + ? _value.fileName + : fileName // ignore: cast_nullable_to_non_nullable + as String?, + fileType: freezed == fileType + ? _value.fileType + : fileType // ignore: cast_nullable_to_non_nullable + as String?, + fileSize: freezed == fileSize + ? _value.fileSize + : fileSize // ignore: cast_nullable_to_non_nullable + as int?, + uploadedByName: freezed == uploadedByName + ? _value.uploadedByName + : uploadedByName // ignore: cast_nullable_to_non_nullable + as String?, + createdAt: freezed == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$GrnAttachmentModelImpl extends _GrnAttachmentModel { + const _$GrnAttachmentModelImpl({ + @JsonKey(fromJson: _idFromJson) required this.id, + @JsonKey(name: 'grn_id', fromJson: _idFromJson) this.grnId, + @JsonKey(name: 'file_name') this.fileName, + @JsonKey(name: 'file_type') this.fileType, + @JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) this.fileSize, + @JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName) + this.uploadedByName, + @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) + this.createdAt, + }) : super._(); + + factory _$GrnAttachmentModelImpl.fromJson(Map json) => + _$$GrnAttachmentModelImplFromJson(json); + + @override + @JsonKey(fromJson: _idFromJson) + final String id; + @override + @JsonKey(name: 'grn_id', fromJson: _idFromJson) + final String? grnId; + @override + @JsonKey(name: 'file_name') + final String? fileName; + @override + @JsonKey(name: 'file_type') + final String? fileType; + @override + @JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) + final int? fileSize; + @override + @JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName) + final String? uploadedByName; + @override + @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) + final DateTime? createdAt; + + @override + String toString() { + return 'GrnAttachmentModel(id: $id, grnId: $grnId, fileName: $fileName, fileType: $fileType, fileSize: $fileSize, uploadedByName: $uploadedByName, createdAt: $createdAt)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$GrnAttachmentModelImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.grnId, grnId) || other.grnId == grnId) && + (identical(other.fileName, fileName) || + other.fileName == fileName) && + (identical(other.fileType, fileType) || + other.fileType == fileType) && + (identical(other.fileSize, fileSize) || + other.fileSize == fileSize) && + (identical(other.uploadedByName, uploadedByName) || + other.uploadedByName == uploadedByName) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + grnId, + fileName, + fileType, + fileSize, + uploadedByName, + createdAt, + ); + + /// Create a copy of GrnAttachmentModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$GrnAttachmentModelImplCopyWith<_$GrnAttachmentModelImpl> get copyWith => + __$$GrnAttachmentModelImplCopyWithImpl<_$GrnAttachmentModelImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$GrnAttachmentModelImplToJson(this); + } +} + +abstract class _GrnAttachmentModel extends GrnAttachmentModel { + const factory _GrnAttachmentModel({ + @JsonKey(fromJson: _idFromJson) required final String id, + @JsonKey(name: 'grn_id', fromJson: _idFromJson) final String? grnId, + @JsonKey(name: 'file_name') final String? fileName, + @JsonKey(name: 'file_type') final String? fileType, + @JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) + final int? fileSize, + @JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName) + final String? uploadedByName, + @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) + final DateTime? createdAt, + }) = _$GrnAttachmentModelImpl; + const _GrnAttachmentModel._() : super._(); + + factory _GrnAttachmentModel.fromJson(Map json) = + _$GrnAttachmentModelImpl.fromJson; + + @override + @JsonKey(fromJson: _idFromJson) + String get id; + @override + @JsonKey(name: 'grn_id', fromJson: _idFromJson) + String? get grnId; + @override + @JsonKey(name: 'file_name') + String? get fileName; + @override + @JsonKey(name: 'file_type') + String? get fileType; + @override + @JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) + int? get fileSize; + @override + @JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName) + String? get uploadedByName; + @override + @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) + DateTime? get createdAt; + + /// Create a copy of GrnAttachmentModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$GrnAttachmentModelImplCopyWith<_$GrnAttachmentModelImpl> get copyWith => + throw _privateConstructorUsedError; +} + GrnItemModel _$GrnItemModelFromJson(Map json) { return _GrnItemModel.fromJson(json); } @@ -811,10 +1175,18 @@ mixin _$GrnItemModel { DateTime? get expiryDate => throw _privateConstructorUsedError; @JsonKey(name: 'storage_location') String? get storageLocation => throw _privateConstructorUsedError; - @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) - int? get assetCategoryId => throw _privateConstructorUsedError; - @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) - int? get assetSubcategoryId => throw _privateConstructorUsedError; + @JsonKey( + name: 'item_category_id', + readValue: _readGrnItemCategoryId, + fromJson: _intFromJsonNullable, + ) + int? get itemCategoryId => throw _privateConstructorUsedError; + @JsonKey( + name: 'item_subcategory_id', + readValue: _readGrnItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + int? get itemSubcategoryId => throw _privateConstructorUsedError; @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? get uomId => throw _privateConstructorUsedError; @JsonKey(name: 'uom_name', readValue: _readUomName) @@ -866,10 +1238,18 @@ abstract class $GrnItemModelCopyWith<$Res> { @JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable) DateTime? expiryDate, @JsonKey(name: 'storage_location') String? storageLocation, - @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) - int? assetCategoryId, - @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) - int? assetSubcategoryId, + @JsonKey( + name: 'item_category_id', + readValue: _readGrnItemCategoryId, + fromJson: _intFromJsonNullable, + ) + int? itemCategoryId, + @JsonKey( + name: 'item_subcategory_id', + readValue: _readGrnItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + int? itemSubcategoryId, @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId, @JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName, String? remarks, @@ -910,8 +1290,8 @@ class _$GrnItemModelCopyWithImpl<$Res, $Val extends GrnItemModel> Object? mfgDate = freezed, Object? expiryDate = freezed, Object? storageLocation = freezed, - Object? assetCategoryId = freezed, - Object? assetSubcategoryId = freezed, + Object? itemCategoryId = freezed, + Object? itemSubcategoryId = freezed, Object? uomId = freezed, Object? uomName = freezed, Object? remarks = freezed, @@ -994,13 +1374,13 @@ class _$GrnItemModelCopyWithImpl<$Res, $Val extends GrnItemModel> ? _value.storageLocation : storageLocation // ignore: cast_nullable_to_non_nullable as String?, - assetCategoryId: freezed == assetCategoryId - ? _value.assetCategoryId - : assetCategoryId // ignore: cast_nullable_to_non_nullable + itemCategoryId: freezed == itemCategoryId + ? _value.itemCategoryId + : itemCategoryId // ignore: cast_nullable_to_non_nullable as int?, - assetSubcategoryId: freezed == assetSubcategoryId - ? _value.assetSubcategoryId - : assetSubcategoryId // ignore: cast_nullable_to_non_nullable + itemSubcategoryId: freezed == itemSubcategoryId + ? _value.itemSubcategoryId + : itemSubcategoryId // ignore: cast_nullable_to_non_nullable as int?, uomId: freezed == uomId ? _value.uomId @@ -1057,10 +1437,18 @@ abstract class _$$GrnItemModelImplCopyWith<$Res> @JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable) DateTime? expiryDate, @JsonKey(name: 'storage_location') String? storageLocation, - @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) - int? assetCategoryId, - @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) - int? assetSubcategoryId, + @JsonKey( + name: 'item_category_id', + readValue: _readGrnItemCategoryId, + fromJson: _intFromJsonNullable, + ) + int? itemCategoryId, + @JsonKey( + name: 'item_subcategory_id', + readValue: _readGrnItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + int? itemSubcategoryId, @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId, @JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName, String? remarks, @@ -1100,8 +1488,8 @@ class __$$GrnItemModelImplCopyWithImpl<$Res> Object? mfgDate = freezed, Object? expiryDate = freezed, Object? storageLocation = freezed, - Object? assetCategoryId = freezed, - Object? assetSubcategoryId = freezed, + Object? itemCategoryId = freezed, + Object? itemSubcategoryId = freezed, Object? uomId = freezed, Object? uomName = freezed, Object? remarks = freezed, @@ -1184,13 +1572,13 @@ class __$$GrnItemModelImplCopyWithImpl<$Res> ? _value.storageLocation : storageLocation // ignore: cast_nullable_to_non_nullable as String?, - assetCategoryId: freezed == assetCategoryId - ? _value.assetCategoryId - : assetCategoryId // ignore: cast_nullable_to_non_nullable + itemCategoryId: freezed == itemCategoryId + ? _value.itemCategoryId + : itemCategoryId // ignore: cast_nullable_to_non_nullable as int?, - assetSubcategoryId: freezed == assetSubcategoryId - ? _value.assetSubcategoryId - : assetSubcategoryId // ignore: cast_nullable_to_non_nullable + itemSubcategoryId: freezed == itemSubcategoryId + ? _value.itemSubcategoryId + : itemSubcategoryId // ignore: cast_nullable_to_non_nullable as int?, uomId: freezed == uomId ? _value.uomId @@ -1239,10 +1627,18 @@ class _$GrnItemModelImpl implements _GrnItemModel { @JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable) this.expiryDate, @JsonKey(name: 'storage_location') this.storageLocation, - @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) - this.assetCategoryId, - @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) - this.assetSubcategoryId, + @JsonKey( + name: 'item_category_id', + readValue: _readGrnItemCategoryId, + fromJson: _intFromJsonNullable, + ) + this.itemCategoryId, + @JsonKey( + name: 'item_subcategory_id', + readValue: _readGrnItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + this.itemSubcategoryId, @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) this.uomId, @JsonKey(name: 'uom_name', readValue: _readUomName) this.uomName, this.remarks, @@ -1309,11 +1705,19 @@ class _$GrnItemModelImpl implements _GrnItemModel { @JsonKey(name: 'storage_location') final String? storageLocation; @override - @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) - final int? assetCategoryId; + @JsonKey( + name: 'item_category_id', + readValue: _readGrnItemCategoryId, + fromJson: _intFromJsonNullable, + ) + final int? itemCategoryId; @override - @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) - final int? assetSubcategoryId; + @JsonKey( + name: 'item_subcategory_id', + readValue: _readGrnItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + final int? itemSubcategoryId; @override @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) final int? uomId; @@ -1325,7 +1729,7 @@ class _$GrnItemModelImpl implements _GrnItemModel { @override String toString() { - return 'GrnItemModel(id: $id, grnId: $grnId, poItemId: $poItemId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, currentQty: $currentQty, acceptedQty: $acceptedQty, damagedQty: $damagedQty, shortQty: $shortQty, excessQty: $excessQty, rejectedQty: $rejectedQty, rejectionReason: $rejectionReason, rate: $rate, batchNo: $batchNo, mfgDate: $mfgDate, expiryDate: $expiryDate, storageLocation: $storageLocation, assetCategoryId: $assetCategoryId, assetSubcategoryId: $assetSubcategoryId, uomId: $uomId, uomName: $uomName, remarks: $remarks)'; + return 'GrnItemModel(id: $id, grnId: $grnId, poItemId: $poItemId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, currentQty: $currentQty, acceptedQty: $acceptedQty, damagedQty: $damagedQty, shortQty: $shortQty, excessQty: $excessQty, rejectedQty: $rejectedQty, rejectionReason: $rejectionReason, rate: $rate, batchNo: $batchNo, mfgDate: $mfgDate, expiryDate: $expiryDate, storageLocation: $storageLocation, itemCategoryId: $itemCategoryId, itemSubcategoryId: $itemSubcategoryId, uomId: $uomId, uomName: $uomName, remarks: $remarks)'; } @override @@ -1364,10 +1768,10 @@ class _$GrnItemModelImpl implements _GrnItemModel { other.expiryDate == expiryDate) && (identical(other.storageLocation, storageLocation) || other.storageLocation == storageLocation) && - (identical(other.assetCategoryId, assetCategoryId) || - other.assetCategoryId == assetCategoryId) && - (identical(other.assetSubcategoryId, assetSubcategoryId) || - other.assetSubcategoryId == assetSubcategoryId) && + (identical(other.itemCategoryId, itemCategoryId) || + other.itemCategoryId == itemCategoryId) && + (identical(other.itemSubcategoryId, itemSubcategoryId) || + other.itemSubcategoryId == itemSubcategoryId) && (identical(other.uomId, uomId) || other.uomId == uomId) && (identical(other.uomName, uomName) || other.uomName == uomName) && (identical(other.remarks, remarks) || other.remarks == remarks)); @@ -1396,8 +1800,8 @@ class _$GrnItemModelImpl implements _GrnItemModel { mfgDate, expiryDate, storageLocation, - assetCategoryId, - assetSubcategoryId, + itemCategoryId, + itemSubcategoryId, uomId, uomName, remarks, @@ -1449,10 +1853,18 @@ abstract class _GrnItemModel implements GrnItemModel { @JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable) final DateTime? expiryDate, @JsonKey(name: 'storage_location') final String? storageLocation, - @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) - final int? assetCategoryId, - @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) - final int? assetSubcategoryId, + @JsonKey( + name: 'item_category_id', + readValue: _readGrnItemCategoryId, + fromJson: _intFromJsonNullable, + ) + final int? itemCategoryId, + @JsonKey( + name: 'item_subcategory_id', + readValue: _readGrnItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + final int? itemSubcategoryId, @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) final int? uomId, @JsonKey(name: 'uom_name', readValue: _readUomName) final String? uomName, final String? remarks, @@ -1519,11 +1931,19 @@ abstract class _GrnItemModel implements GrnItemModel { @JsonKey(name: 'storage_location') String? get storageLocation; @override - @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) - int? get assetCategoryId; + @JsonKey( + name: 'item_category_id', + readValue: _readGrnItemCategoryId, + fromJson: _intFromJsonNullable, + ) + int? get itemCategoryId; @override - @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) - int? get assetSubcategoryId; + @JsonKey( + name: 'item_subcategory_id', + readValue: _readGrnItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + int? get itemSubcategoryId; @override @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? get uomId; diff --git a/lib/shared/models/grn_model.g.dart b/lib/shared/models/grn_model.g.dart index ae2fe69..860049f 100644 --- a/lib/shared/models/grn_model.g.dart +++ b/lib/shared/models/grn_model.g.dart @@ -6,38 +6,42 @@ part of 'grn_model.dart'; // JsonSerializableGenerator // ************************************************************************** -_$GrnModelImpl _$$GrnModelImplFromJson(Map json) => - _$GrnModelImpl( - id: _idFromJson(json['id']), - grnNumber: _readPoNumber(json, 'grn_number') as String?, - grnDate: _dateFromJsonNullable(json['grn_date']), - status: json['status'] as String? ?? 'POSTED', - poId: _intFromJsonNullable(json['po_id']), - poNumber: _readPoRefNumber(json, 'po_number') as String?, - vendorId: _intFromJsonNullable(json['vendor_id']), - vendorName: _readVendorName(json, 'vendor_name') as String?, - warehouseId: _intFromJsonNullable(json['warehouse_id']), - warehouseName: _readWarehouseName(json, 'warehouse_name') as String?, - vendorInvoiceNo: json['vendor_invoice_no'] as String?, - vendorInvoiceDate: _dateFromJsonNullable(json['vendor_invoice_date']), - vendorInvoiceAmount: _doubleFromJsonNullable( - json['vendor_invoice_amount'], - ), - vehicleNo: json['vehicle_no'] as String?, - lrNo: json['lr_no'] as String?, - lrDate: _dateFromJsonNullable(json['lr_date']), - receivedBy: _intFromJsonNullable(json['received_by']), - qualityCheckedBy: _intFromJsonNullable(json['quality_checked_by']), - remarks: json['remarks'] as String?, - cancellationReason: json['cancellation_reason'] as String?, - createdAt: _dateFromJsonNullable(json['created_at']), - updatedAt: _dateFromJsonNullable(json['updated_at']), - items: - (json['items'] as List?) - ?.map((e) => GrnItemModel.fromJson(e as Map)) - .toList() ?? - const [], - ); +_$GrnModelImpl _$$GrnModelImplFromJson( + Map json, +) => _$GrnModelImpl( + id: _idFromJson(json['id']), + grnNumber: _readPoNumber(json, 'grn_number') as String?, + grnDate: _dateFromJsonNullable(json['grn_date']), + status: json['status'] as String? ?? 'POSTED', + poId: _intFromJsonNullable(json['po_id']), + poNumber: _readPoRefNumber(json, 'po_number') as String?, + vendorId: _intFromJsonNullable(json['vendor_id']), + vendorName: _readVendorName(json, 'vendor_name') as String?, + warehouseId: _intFromJsonNullable(json['warehouse_id']), + warehouseName: _readWarehouseName(json, 'warehouse_name') as String?, + vendorInvoiceNo: json['vendor_invoice_no'] as String?, + vendorInvoiceDate: _dateFromJsonNullable(json['vendor_invoice_date']), + vendorInvoiceAmount: _doubleFromJsonNullable(json['vendor_invoice_amount']), + vehicleNo: json['vehicle_no'] as String?, + lrNo: json['lr_no'] as String?, + lrDate: _dateFromJsonNullable(json['lr_date']), + receivedBy: _intFromJsonNullable(json['received_by']), + qualityCheckedBy: _intFromJsonNullable(json['quality_checked_by']), + remarks: json['remarks'] as String?, + cancellationReason: json['cancellation_reason'] as String?, + createdAt: _dateFromJsonNullable(json['created_at']), + updatedAt: _dateFromJsonNullable(json['updated_at']), + items: + (json['items'] as List?) + ?.map((e) => GrnItemModel.fromJson(e as Map)) + .toList() ?? + const [], + attachments: + (json['attachments'] as List?) + ?.map((e) => GrnAttachmentModel.fromJson(e as Map)) + .toList() ?? + const [], +); Map _$$GrnModelImplToJson(_$GrnModelImpl instance) => { @@ -64,8 +68,33 @@ Map _$$GrnModelImplToJson(_$GrnModelImpl instance) => 'created_at': instance.createdAt?.toIso8601String(), 'updated_at': instance.updatedAt?.toIso8601String(), 'items': instance.items, + 'attachments': instance.attachments, }; +_$GrnAttachmentModelImpl _$$GrnAttachmentModelImplFromJson( + Map json, +) => _$GrnAttachmentModelImpl( + id: _idFromJson(json['id']), + grnId: _idFromJson(json['grn_id']), + fileName: json['file_name'] as String?, + fileType: json['file_type'] as String?, + fileSize: _intFromJsonNullable(json['file_size']), + uploadedByName: _readUploadedByName(json, 'uploaded_by_name') as String?, + createdAt: _dateFromJsonNullable(json['created_at']), +); + +Map _$$GrnAttachmentModelImplToJson( + _$GrnAttachmentModelImpl instance, +) => { + 'id': instance.id, + 'grn_id': instance.grnId, + 'file_name': instance.fileName, + 'file_type': instance.fileType, + 'file_size': instance.fileSize, + 'uploaded_by_name': instance.uploadedByName, + 'created_at': instance.createdAt?.toIso8601String(), +}; + _$GrnItemModelImpl _$$GrnItemModelImplFromJson(Map json) => _$GrnItemModelImpl( id: _idFromJson(json['id']), @@ -87,8 +116,12 @@ _$GrnItemModelImpl _$$GrnItemModelImplFromJson(Map json) => mfgDate: _dateFromJsonNullable(json['mfg_date']), expiryDate: _dateFromJsonNullable(json['expiry_date']), storageLocation: json['storage_location'] as String?, - assetCategoryId: _intFromJsonNullable(json['asset_category_id']), - assetSubcategoryId: _intFromJsonNullable(json['asset_subcategory_id']), + itemCategoryId: _intFromJsonNullable( + _readGrnItemCategoryId(json, 'item_category_id'), + ), + itemSubcategoryId: _intFromJsonNullable( + _readGrnItemSubcategoryId(json, 'item_subcategory_id'), + ), uomId: _intFromJsonNullable(json['uom_id']), uomName: _readUomName(json, 'uom_name') as String?, remarks: json['remarks'] as String?, @@ -115,8 +148,8 @@ Map _$$GrnItemModelImplToJson(_$GrnItemModelImpl instance) => 'mfg_date': instance.mfgDate?.toIso8601String(), 'expiry_date': instance.expiryDate?.toIso8601String(), 'storage_location': instance.storageLocation, - 'asset_category_id': instance.assetCategoryId, - 'asset_subcategory_id': instance.assetSubcategoryId, + 'item_category_id': instance.itemCategoryId, + 'item_subcategory_id': instance.itemSubcategoryId, 'uom_id': instance.uomId, 'uom_name': instance.uomName, 'remarks': instance.remarks, diff --git a/lib/shared/models/purchase_order_model.dart b/lib/shared/models/purchase_order_model.dart index 8c2b5a4..2c64c40 100644 --- a/lib/shared/models/purchase_order_model.dart +++ b/lib/shared/models/purchase_order_model.dart @@ -48,7 +48,10 @@ Object? _readItemName(Map json, String key) { final flat = json['item_name']; if (flat is String && flat.isNotEmpty) return flat; final nested = json['item']; - if (nested is Map) return nested['name']; + if (nested is Map) { + final name = nested['item_name'] ?? nested['name']; + if (name is String && name.isNotEmpty) return name; + } return null; } @@ -56,7 +59,10 @@ Object? _readItemCode(Map json, String key) { final flat = json['item_code']; if (flat is String && flat.isNotEmpty) return flat; final nested = json['item']; - if (nested is Map) return nested['code']; + if (nested is Map) { + final code = nested['item_code'] ?? nested['code']; + if (code is String && code.isNotEmpty) return code; + } return null; } @@ -64,10 +70,46 @@ Object? _readUomName(Map json, String key) { final flat = json['uom_name']; if (flat is String && flat.isNotEmpty) return flat; final nested = json['uom']; - if (nested is Map) return nested['name']; + if (nested is Map) { + final name = nested['name'] ?? nested['uom_name'] ?? nested['code']; + if (name is String && name.isNotEmpty) return name; + } return null; } +Object? _readHsnCodeId(Map json, String key) { + final flat = json['hsn_code_id']; + if (flat != null) return flat; + final nested = json['hsn_code'] ?? json['hsn']; + if (nested is Map) return nested['id'] ?? nested['hsn_code_id']; + return null; +} + +Object? _readHsnCodeName(Map json, String key) { + final flat = json['hsn_code_name'] ?? json['hsn_code']; + if (flat is String && flat.isNotEmpty) return flat; + final nested = json['hsn_code'] ?? json['hsn']; + if (nested is Map) { + final code = nested['code'] ?? nested['hsn_code'] ?? nested['name']; + if (code is String && code.isNotEmpty) return code; + } + return null; +} + +Object? _readPoItemNestedInt(Map json, String field) { + final flat = json[field]; + if (flat != null) return flat; + final nested = json['item']; + if (nested is Map) return nested[field]; + return null; +} + +Object? _readPoItemCategoryId(Map json, String key) => + _readPoItemNestedInt(json, 'item_category_id'); + +Object? _readPoItemSubcategoryId(Map json, String key) => + _readPoItemNestedInt(json, 'item_subcategory_id'); + Object? _readPoNumber(Map json, String key) { final poNumber = json['po_number']; if (poNumber != null && poNumber.toString().trim().isNotEmpty) { @@ -176,9 +218,28 @@ class PurchaseOrderItemModel with _$PurchaseOrderItemModel { @JsonKey(name: 'discount_amount', fromJson: _doubleFromJsonNullable) double? discountAmount, @JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable) int? gstRateId, - @JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable) int? hsnCodeId, + @JsonKey( + name: 'hsn_code_id', + readValue: _readHsnCodeId, + fromJson: _intFromJsonNullable, + ) + int? hsnCodeId, + @JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName) + String? hsnCodeName, @JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable) double? lineAmount, + @JsonKey( + name: 'item_category_id', + readValue: _readPoItemCategoryId, + fromJson: _intFromJsonNullable, + ) + int? itemCategoryId, + @JsonKey( + name: 'item_subcategory_id', + readValue: _readPoItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + int? itemSubcategoryId, String? remarks, }) = _PurchaseOrderItemModel; diff --git a/lib/shared/models/purchase_order_model.freezed.dart b/lib/shared/models/purchase_order_model.freezed.dart index ef0b6dc..9e924c0 100644 --- a/lib/shared/models/purchase_order_model.freezed.dart +++ b/lib/shared/models/purchase_order_model.freezed.dart @@ -922,10 +922,28 @@ mixin _$PurchaseOrderItemModel { double? get discountAmount => throw _privateConstructorUsedError; @JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable) int? get gstRateId => throw _privateConstructorUsedError; - @JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable) + @JsonKey( + name: 'hsn_code_id', + readValue: _readHsnCodeId, + fromJson: _intFromJsonNullable, + ) int? get hsnCodeId => throw _privateConstructorUsedError; + @JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName) + String? get hsnCodeName => throw _privateConstructorUsedError; @JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable) double? get lineAmount => throw _privateConstructorUsedError; + @JsonKey( + name: 'item_category_id', + readValue: _readPoItemCategoryId, + fromJson: _intFromJsonNullable, + ) + int? get itemCategoryId => throw _privateConstructorUsedError; + @JsonKey( + name: 'item_subcategory_id', + readValue: _readPoItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + int? get itemSubcategoryId => throw _privateConstructorUsedError; String? get remarks => throw _privateConstructorUsedError; /// Serializes this PurchaseOrderItemModel to a JSON map. @@ -965,10 +983,28 @@ abstract class $PurchaseOrderItemModelCopyWith<$Res> { double? discountAmount, @JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable) int? gstRateId, - @JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable) + @JsonKey( + name: 'hsn_code_id', + readValue: _readHsnCodeId, + fromJson: _intFromJsonNullable, + ) int? hsnCodeId, + @JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName) + String? hsnCodeName, @JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable) double? lineAmount, + @JsonKey( + name: 'item_category_id', + readValue: _readPoItemCategoryId, + fromJson: _intFromJsonNullable, + ) + int? itemCategoryId, + @JsonKey( + name: 'item_subcategory_id', + readValue: _readPoItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + int? itemSubcategoryId, String? remarks, }); } @@ -1006,7 +1042,10 @@ class _$PurchaseOrderItemModelCopyWithImpl< Object? discountAmount = freezed, Object? gstRateId = freezed, Object? hsnCodeId = freezed, + Object? hsnCodeName = freezed, Object? lineAmount = freezed, + Object? itemCategoryId = freezed, + Object? itemSubcategoryId = freezed, Object? remarks = freezed, }) { return _then( @@ -1071,10 +1110,22 @@ class _$PurchaseOrderItemModelCopyWithImpl< ? _value.hsnCodeId : hsnCodeId // ignore: cast_nullable_to_non_nullable as int?, + hsnCodeName: freezed == hsnCodeName + ? _value.hsnCodeName + : hsnCodeName // ignore: cast_nullable_to_non_nullable + as String?, lineAmount: freezed == lineAmount ? _value.lineAmount : lineAmount // ignore: cast_nullable_to_non_nullable as double?, + itemCategoryId: freezed == itemCategoryId + ? _value.itemCategoryId + : itemCategoryId // ignore: cast_nullable_to_non_nullable + as int?, + itemSubcategoryId: freezed == itemSubcategoryId + ? _value.itemSubcategoryId + : itemSubcategoryId // ignore: cast_nullable_to_non_nullable + as int?, remarks: freezed == remarks ? _value.remarks : remarks // ignore: cast_nullable_to_non_nullable @@ -1114,10 +1165,28 @@ abstract class _$$PurchaseOrderItemModelImplCopyWith<$Res> double? discountAmount, @JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable) int? gstRateId, - @JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable) + @JsonKey( + name: 'hsn_code_id', + readValue: _readHsnCodeId, + fromJson: _intFromJsonNullable, + ) int? hsnCodeId, + @JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName) + String? hsnCodeName, @JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable) double? lineAmount, + @JsonKey( + name: 'item_category_id', + readValue: _readPoItemCategoryId, + fromJson: _intFromJsonNullable, + ) + int? itemCategoryId, + @JsonKey( + name: 'item_subcategory_id', + readValue: _readPoItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + int? itemSubcategoryId, String? remarks, }); } @@ -1152,7 +1221,10 @@ class __$$PurchaseOrderItemModelImplCopyWithImpl<$Res> Object? discountAmount = freezed, Object? gstRateId = freezed, Object? hsnCodeId = freezed, + Object? hsnCodeName = freezed, Object? lineAmount = freezed, + Object? itemCategoryId = freezed, + Object? itemSubcategoryId = freezed, Object? remarks = freezed, }) { return _then( @@ -1217,10 +1289,22 @@ class __$$PurchaseOrderItemModelImplCopyWithImpl<$Res> ? _value.hsnCodeId : hsnCodeId // ignore: cast_nullable_to_non_nullable as int?, + hsnCodeName: freezed == hsnCodeName + ? _value.hsnCodeName + : hsnCodeName // ignore: cast_nullable_to_non_nullable + as String?, lineAmount: freezed == lineAmount ? _value.lineAmount : lineAmount // ignore: cast_nullable_to_non_nullable as double?, + itemCategoryId: freezed == itemCategoryId + ? _value.itemCategoryId + : itemCategoryId // ignore: cast_nullable_to_non_nullable + as int?, + itemSubcategoryId: freezed == itemSubcategoryId + ? _value.itemSubcategoryId + : itemSubcategoryId // ignore: cast_nullable_to_non_nullable + as int?, remarks: freezed == remarks ? _value.remarks : remarks // ignore: cast_nullable_to_non_nullable @@ -1253,10 +1337,28 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel { this.discountAmount, @JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable) this.gstRateId, - @JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable) + @JsonKey( + name: 'hsn_code_id', + readValue: _readHsnCodeId, + fromJson: _intFromJsonNullable, + ) this.hsnCodeId, + @JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName) + this.hsnCodeName, @JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable) this.lineAmount, + @JsonKey( + name: 'item_category_id', + readValue: _readPoItemCategoryId, + fromJson: _intFromJsonNullable, + ) + this.itemCategoryId, + @JsonKey( + name: 'item_subcategory_id', + readValue: _readPoItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + this.itemSubcategoryId, this.remarks, }); @@ -1306,17 +1408,38 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel { @JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable) final int? gstRateId; @override - @JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable) + @JsonKey( + name: 'hsn_code_id', + readValue: _readHsnCodeId, + fromJson: _intFromJsonNullable, + ) final int? hsnCodeId; @override + @JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName) + final String? hsnCodeName; + @override @JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable) final double? lineAmount; @override + @JsonKey( + name: 'item_category_id', + readValue: _readPoItemCategoryId, + fromJson: _intFromJsonNullable, + ) + final int? itemCategoryId; + @override + @JsonKey( + name: 'item_subcategory_id', + readValue: _readPoItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + final int? itemSubcategoryId; + @override final String? remarks; @override String toString() { - return 'PurchaseOrderItemModel(id: $id, poId: $poId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, orderedQty: $orderedQty, receivedQty: $receivedQty, uomId: $uomId, uomName: $uomName, rate: $rate, discountPct: $discountPct, discountAmount: $discountAmount, gstRateId: $gstRateId, hsnCodeId: $hsnCodeId, lineAmount: $lineAmount, remarks: $remarks)'; + return 'PurchaseOrderItemModel(id: $id, poId: $poId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, orderedQty: $orderedQty, receivedQty: $receivedQty, uomId: $uomId, uomName: $uomName, rate: $rate, discountPct: $discountPct, discountAmount: $discountAmount, gstRateId: $gstRateId, hsnCodeId: $hsnCodeId, hsnCodeName: $hsnCodeName, lineAmount: $lineAmount, itemCategoryId: $itemCategoryId, itemSubcategoryId: $itemSubcategoryId, remarks: $remarks)'; } @override @@ -1347,14 +1470,20 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel { other.gstRateId == gstRateId) && (identical(other.hsnCodeId, hsnCodeId) || other.hsnCodeId == hsnCodeId) && + (identical(other.hsnCodeName, hsnCodeName) || + other.hsnCodeName == hsnCodeName) && (identical(other.lineAmount, lineAmount) || other.lineAmount == lineAmount) && + (identical(other.itemCategoryId, itemCategoryId) || + other.itemCategoryId == itemCategoryId) && + (identical(other.itemSubcategoryId, itemSubcategoryId) || + other.itemSubcategoryId == itemSubcategoryId) && (identical(other.remarks, remarks) || other.remarks == remarks)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ runtimeType, id, poId, @@ -1371,9 +1500,12 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel { discountAmount, gstRateId, hsnCodeId, + hsnCodeName, lineAmount, + itemCategoryId, + itemSubcategoryId, remarks, - ); + ]); /// Create a copy of PurchaseOrderItemModel /// with the given fields replaced by the non-null parameter values. @@ -1416,10 +1548,28 @@ abstract class _PurchaseOrderItemModel implements PurchaseOrderItemModel { final double? discountAmount, @JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable) final int? gstRateId, - @JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable) + @JsonKey( + name: 'hsn_code_id', + readValue: _readHsnCodeId, + fromJson: _intFromJsonNullable, + ) final int? hsnCodeId, + @JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName) + final String? hsnCodeName, @JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable) final double? lineAmount, + @JsonKey( + name: 'item_category_id', + readValue: _readPoItemCategoryId, + fromJson: _intFromJsonNullable, + ) + final int? itemCategoryId, + @JsonKey( + name: 'item_subcategory_id', + readValue: _readPoItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + final int? itemSubcategoryId, final String? remarks, }) = _$PurchaseOrderItemModelImpl; @@ -1469,12 +1619,33 @@ abstract class _PurchaseOrderItemModel implements PurchaseOrderItemModel { @JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable) int? get gstRateId; @override - @JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable) + @JsonKey( + name: 'hsn_code_id', + readValue: _readHsnCodeId, + fromJson: _intFromJsonNullable, + ) int? get hsnCodeId; @override + @JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName) + String? get hsnCodeName; + @override @JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable) double? get lineAmount; @override + @JsonKey( + name: 'item_category_id', + readValue: _readPoItemCategoryId, + fromJson: _intFromJsonNullable, + ) + int? get itemCategoryId; + @override + @JsonKey( + name: 'item_subcategory_id', + readValue: _readPoItemSubcategoryId, + fromJson: _intFromJsonNullable, + ) + int? get itemSubcategoryId; + @override String? get remarks; /// Create a copy of PurchaseOrderItemModel diff --git a/lib/shared/models/purchase_order_model.g.dart b/lib/shared/models/purchase_order_model.g.dart index 8e6a63f..cccadb4 100644 --- a/lib/shared/models/purchase_order_model.g.dart +++ b/lib/shared/models/purchase_order_model.g.dart @@ -93,8 +93,15 @@ _$PurchaseOrderItemModelImpl _$$PurchaseOrderItemModelImplFromJson( discountPct: _doubleFromJsonNullable(json['discount_pct']), discountAmount: _doubleFromJsonNullable(json['discount_amount']), gstRateId: _intFromJsonNullable(json['gst_rate_id']), - hsnCodeId: _intFromJsonNullable(json['hsn_code_id']), + hsnCodeId: _intFromJsonNullable(_readHsnCodeId(json, 'hsn_code_id')), + hsnCodeName: _readHsnCodeName(json, 'hsn_code_name') as String?, lineAmount: _doubleFromJsonNullable(json['line_amount']), + itemCategoryId: _intFromJsonNullable( + _readPoItemCategoryId(json, 'item_category_id'), + ), + itemSubcategoryId: _intFromJsonNullable( + _readPoItemSubcategoryId(json, 'item_subcategory_id'), + ), remarks: json['remarks'] as String?, ); @@ -116,6 +123,9 @@ Map _$$PurchaseOrderItemModelImplToJson( 'discount_amount': instance.discountAmount, 'gst_rate_id': instance.gstRateId, 'hsn_code_id': instance.hsnCodeId, + 'hsn_code_name': instance.hsnCodeName, 'line_amount': instance.lineAmount, + 'item_category_id': instance.itemCategoryId, + 'item_subcategory_id': instance.itemSubcategoryId, 'remarks': instance.remarks, }; diff --git a/lib/shared/routes/app_router.dart b/lib/shared/routes/app_router.dart index c708595..8e155ab 100644 --- a/lib/shared/routes/app_router.dart +++ b/lib/shared/routes/app_router.dart @@ -6,7 +6,6 @@ import '../../core/config/dev_config.dart'; import '../../core/constants/route_constants.dart'; import '../../modules/dashboard/presentation/screens/dashboard_screen.dart'; import '../../modules/assets/presentation/screens/asset_alerts_screen.dart'; -import '../../modules/assets/presentation/screens/asset_categories_screen.dart'; import '../../modules/assets/presentation/screens/asset_detail_screen.dart'; import '../../modules/assets/presentation/screens/asset_list_screen.dart'; import '../../modules/auth/presentation/screens/change_password_screen.dart'; @@ -215,18 +214,27 @@ final routerProvider = Provider((ref) { routes: [ GoRoute( path: 'add', - builder: (context, state) => const PurchaseOrderFormScreen(), + pageBuilder: (context, state) => shellPage( + state, + const PurchaseOrderFormScreen(), + ), ), GoRoute( path: ':id/edit', - builder: (context, state) => PurchaseOrderFormScreen( - purchaseOrderId: state.pathParameters['id']!, + pageBuilder: (context, state) => shellPage( + state, + PurchaseOrderFormScreen( + purchaseOrderId: state.pathParameters['id']!, + ), ), ), GoRoute( path: ':id', - builder: (context, state) => PurchaseOrderDetailScreen( - purchaseOrderId: state.pathParameters['id']!, + pageBuilder: (context, state) => shellPage( + state, + PurchaseOrderDetailScreen( + purchaseOrderId: state.pathParameters['id']!, + ), ), ), ], @@ -272,10 +280,6 @@ final routerProvider = Provider((ref) { pageBuilder: (context, state) => shellPage(state, const AssetListScreen()), routes: [ - GoRoute( - path: 'categories', - builder: (context, state) => const AssetCategoriesScreen(), - ), GoRoute( path: 'alerts', builder: (context, state) => const AssetAlertsScreen(), diff --git a/lib/shared/widgets/app_data_table.dart b/lib/shared/widgets/app_data_table.dart index 75e7db9..b5a83f7 100644 --- a/lib/shared/widgets/app_data_table.dart +++ b/lib/shared/widgets/app_data_table.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'app_card.dart'; +/// Fixed height for every row in [AppDataTable] and themed [DataTable] widgets. +const double kAppTableRowHeight = 52; + class AppDataColumn { const AppDataColumn({ required this.label, @@ -18,6 +21,32 @@ class AppDataColumn { final Alignment alignment; } +/// Helpers for table cell content — single-line text with ellipsis and tooltip. +class AppTableCell { + AppTableCell._(); + + /// Renders [value] on one line; shows the full text in a tooltip when truncated. + static Widget text( + String? value, { + TextStyle? style, + String placeholder = '—', + TextAlign? textAlign, + bool showTooltip = true, + }) { + final display = + (value == null || value.trim().isEmpty) ? placeholder : value.trim(); + return _EllipsisTooltipText( + text: display, + style: style, + textAlign: textAlign, + showTooltip: showTooltip && display != placeholder, + ); + } + + /// Wraps non-text cell widgets (chips, actions) inside the row height budget. + static Widget child(Widget widget) => widget; +} + class AppDataTable extends StatelessWidget { const AppDataTable({ super.key, @@ -38,6 +67,7 @@ class AppDataTable extends StatelessWidget { final void Function(String column, bool ascending)? onSort; final String emptyMessage; final bool wrapInCard; + /// Set true when the table is placed inside another scrollable. final bool shrinkWrap; @@ -64,9 +94,7 @@ class AppDataTable extends StatelessWidget { return ListView( padding: EdgeInsets.zero, shrinkWrap: shrinkWrap, - physics: shrinkWrap - ? const NeverScrollableScrollPhysics() - : null, + physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null, children: [ _TableHeaderRow( columns: columns, @@ -111,56 +139,63 @@ class _TableHeaderRow extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return Container( + return SizedBox( + height: kAppTableRowHeight, width: double.infinity, - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: Row( - children: columns.map((col) { - final isSorted = col.sortKey != null && col.sortKey == sortColumn; - final label = Text( - col.label.toUpperCase(), - style: theme.textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - color: theme.colorScheme.onSurfaceVariant, - ), - ); + child: ColoredBox( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: columns.map((col) { + final isSorted = col.sortKey != null && col.sortKey == sortColumn; + final label = Text( + col.label.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: theme.colorScheme.onSurfaceVariant, + ), + ); - Widget header = label; - if (col.sortKey != null && onSort != null) { - header = InkWell( - onTap: () => onSort!( - col.sortKey!, - isSorted ? !sortAscending : true, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - label, - if (isSorted) ...[ - const SizedBox(width: 4), - Icon( - sortAscending - ? Icons.arrow_upward - : Icons.arrow_downward, - size: 14, - color: theme.colorScheme.onSurfaceVariant, - ), - ], - ], - ), - ); - } + Widget header = label; + if (col.sortKey != null && onSort != null) { + header = InkWell( + onTap: () => onSort!( + col.sortKey!, + isSorted ? !sortAscending : true, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: label), + if (isSorted) ...[ + const SizedBox(width: 4), + Icon( + sortAscending + ? Icons.arrow_upward + : Icons.arrow_downward, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ], + ), + ); + } - return Expanded( - flex: col.flex, - child: Align( - alignment: col.alignment, - child: header, - ), - ); - }).toList(), + return Expanded( + flex: col.flex, + child: Align( + alignment: col.alignment, + child: header, + ), + ); + }).toList(), + ), + ), ), ); } @@ -179,28 +214,125 @@ class _TableDataRow extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return Container( + return SizedBox( + height: kAppTableRowHeight, width: double.infinity, - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: theme.colorScheme.outline.withValues(alpha: 0.08), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.08), + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: columns.map((col) { + return Expanded( + flex: col.flex, + child: Align( + alignment: col.alignment, + child: _TableCellSlot( + alignment: col.alignment, + child: col.cellBuilder(context, row), + ), + ), + ); + }).toList(), ), ), - ), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: columns.map((col) { - return Expanded( - flex: col.flex, - child: Align( - alignment: col.alignment, - child: col.cellBuilder(context, row), - ), - ); - }).toList(), ), ); } } + +class _TableCellSlot extends StatelessWidget { + const _TableCellSlot({ + required this.child, + required this.alignment, + }); + + final Widget child; + final Alignment alignment; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return SizedBox( + width: constraints.maxWidth, + child: Align( + alignment: alignment, + widthFactor: 1, + child: _coerceTableCell(child, context), + ), + ); + }, + ); + } + + Widget _coerceTableCell(Widget widget, BuildContext context) { + if (widget is Text) { + final text = widget.data ?? widget.textSpan?.toPlainText() ?? ''; + if (text.isEmpty) return widget; + return AppTableCell.text( + text, + style: widget.style ?? DefaultTextStyle.of(context).style, + textAlign: widget.textAlign, + ); + } + return widget; + } +} + +class _EllipsisTooltipText extends StatelessWidget { + const _EllipsisTooltipText({ + required this.text, + this.style, + this.textAlign, + this.showTooltip = true, + }); + + final String text; + final TextStyle? style; + final TextAlign? textAlign; + final bool showTooltip; + + @override + Widget build(BuildContext context) { + final effectiveStyle = style ?? DefaultTextStyle.of(context).style; + + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final painter = TextPainter( + text: TextSpan(text: text, style: effectiveStyle), + maxLines: 1, + textDirection: Directionality.of(context), + textAlign: textAlign ?? TextAlign.start, + )..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity); + + final overflows = maxWidth.isFinite && + (painter.didExceedMaxLines || painter.width > maxWidth); + + final textWidget = Text( + text, + style: effectiveStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: textAlign, + ); + + if (!showTooltip || !overflows) return textWidget; + + return Tooltip( + message: text, + waitDuration: const Duration(milliseconds: 400), + child: textWidget, + ); + }, + ); + } +} diff --git a/lib/shared/widgets/app_searchable_dropdown.dart b/lib/shared/widgets/app_searchable_dropdown.dart index e5ff9ba..db24061 100644 --- a/lib/shared/widgets/app_searchable_dropdown.dart +++ b/lib/shared/widgets/app_searchable_dropdown.dart @@ -35,10 +35,27 @@ class AppSearchableDropdown extends StatefulWidget { class _AppSearchableDropdownState extends State> { final _layerLink = LayerLink(); final _fieldKey = GlobalKey(); - final _formFieldKey = UniqueKey(); + final _formFieldStateKey = GlobalKey>(); OverlayEntry? _overlayEntry; bool _ignoreOutsideTap = false; + @override + void didUpdateWidget(covariant AppSearchableDropdown oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value == oldWidget.value) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final fieldState = _formFieldStateKey.currentState; + if (fieldState == null || fieldState.value == widget.value) return; + + fieldState.didChange(widget.value); + if (fieldState.hasError) { + fieldState.validate(); + } + }); + } + @override void dispose() { _removeOverlay(); @@ -167,7 +184,7 @@ class _AppSearchableDropdownState extends State> { final theme = Theme.of(context); return FormField( - key: widget.key ?? _formFieldKey, + key: _formFieldStateKey, initialValue: widget.value, validator: widget.validator, builder: (field) { @@ -342,3 +359,216 @@ class _SearchableDropdownPanelState ); } } + +/// Searchable lookup anchored to the field — same overlay UI as +/// [AppSearchableDropdown] but without a [FormField] wrapper. +class AppSearchableLookupField extends StatefulWidget { + const AppSearchableLookupField({ + super.key, + required this.label, + required this.value, + required this.options, + required this.onChanged, + this.hint, + this.searchHint = 'Search...', + this.enabled = true, + this.isDense = false, + }); + + final String label; + final T? value; + final List> options; + final ValueChanged onChanged; + final String? hint; + final String searchHint; + final bool enabled; + final bool isDense; + + @override + State> createState() => + _AppSearchableLookupFieldState(); +} + +class _AppSearchableLookupFieldState + extends State> { + final _layerLink = LayerLink(); + final _fieldKey = GlobalKey(); + OverlayEntry? _overlayEntry; + bool _ignoreOutsideTap = false; + + @override + void dispose() { + _removeOverlay(); + super.dispose(); + } + + String? _labelForValue(T? selected) { + if (selected == null) return null; + for (final option in widget.options) { + if (option.value == selected) return option.label; + } + return null; + } + + void _removeOverlay() { + if (_overlayEntry == null) return; + _overlayEntry!.remove(); + _overlayEntry = null; + _ignoreOutsideTap = false; + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() {}); + }); + } + } + + void _openPicker() { + if (!widget.enabled || widget.options.isEmpty) return; + if (_overlayEntry != null) { + _removeOverlay(); + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _overlayEntry != null) return; + _showOverlay(); + }); + } + + void _showOverlay() { + final renderBox = + _fieldKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null || !renderBox.hasSize) return; + + final fieldSize = renderBox.size; + final fieldTopLeft = renderBox.localToGlobal(Offset.zero); + final screenSize = MediaQuery.sizeOf(context); + final viewInsets = MediaQuery.viewInsetsOf(context); + + final spaceBelow = screenSize.height - + viewInsets.bottom - + fieldTopLeft.dy - + fieldSize.height; + final spaceAbove = fieldTopLeft.dy - viewInsets.top; + final showAbove = spaceBelow < 180 && spaceAbove > spaceBelow; + + final availableSpace = (showAbove ? spaceAbove : spaceBelow) - 8; + final maxPanelHeight = + availableSpace.clamp(120.0, screenSize.height * 0.45); + + _ignoreOutsideTap = true; + _overlayEntry = OverlayEntry( + builder: (overlayContext) { + final theme = Theme.of(overlayContext); + + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: _removeOverlay, + behavior: HitTestBehavior.translucent, + ), + ), + CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + targetAnchor: + showAbove ? Alignment.topLeft : Alignment.bottomLeft, + followerAnchor: + showAbove ? Alignment.bottomLeft : Alignment.topLeft, + offset: Offset(0, showAbove ? -4 : 4), + child: TapRegion( + onTapOutside: (_) { + if (_ignoreOutsideTap) return; + _removeOverlay(); + }, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.antiAlias, + color: theme.colorScheme.surface, + shadowColor: Colors.black45, + child: SizedBox( + width: fieldSize.width, + child: _SearchableDropdownPanel( + maxHeight: maxPanelHeight, + options: widget.options, + selected: widget.value, + searchHint: widget.searchHint, + onSelected: (value) { + _removeOverlay(); + widget.onChanged(value); + }, + ), + ), + ), + ), + ), + ], + ); + }, + ); + + final overlay = Overlay.maybeOf(context, rootOverlay: true) ?? + Overlay.of(context); + overlay.insert(_overlayEntry!); + setState(() {}); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _ignoreOutsideTap = false; + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectiveHint = + widget.hint ?? 'Select ${widget.label.toLowerCase()}'; + final canOpen = widget.enabled && widget.options.isNotEmpty; + final colors = theme.colorScheme; + final displayLabel = _labelForValue(widget.value); + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: CompositedTransformTarget( + link: _layerLink, + child: KeyedSubtree( + key: _fieldKey, + child: InkWell( + onTap: canOpen ? _openPicker : null, + borderRadius: BorderRadius.circular(8), + child: InputDecorator( + isFocused: _overlayEntry != null, + isEmpty: displayLabel == null, + decoration: InputDecoration( + labelText: widget.label, + hintText: displayLabel == null ? effectiveHint : null, + floatingLabelBehavior: FloatingLabelBehavior.always, + isDense: widget.isDense, + suffixIcon: Icon( + _overlayEntry != null + ? Icons.arrow_drop_up + : Icons.arrow_drop_down, + color: canOpen + ? colors.onSurfaceVariant + : theme.disabledColor, + ), + enabled: canOpen, + ), + child: Text( + displayLabel ?? '\u00A0', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + color: displayLabel == null + ? Colors.transparent + : colors.onSurface, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/app_sidebar.dart b/lib/shared/widgets/app_sidebar.dart index 2c0037d..a8e1e23 100644 --- a/lib/shared/widgets/app_sidebar.dart +++ b/lib/shared/widgets/app_sidebar.dart @@ -23,6 +23,10 @@ const _sidebarItemPadding = 12.0; const _sidebarIconSize = 20.0; const _sidebarChildIndent = 28.0; +/// Set to `true` to show the Light/Dark toggle in the sidebar again. +/// Kept hidden for now — do not delete `_buildThemeToggle`. +const showSidebarThemeToggle = false; + class AppSidebar extends ConsumerStatefulWidget { const AppSidebar({ super.key, @@ -149,7 +153,10 @@ class _AppSidebarState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildHeader(context, isNarrow: isNarrow), - if (!isNarrow) ...[ + // NOTE: Light/Dark theme toggle is temporarily hidden from the + // sidebar. Do not remove `_buildThemeToggle` — restore by + // setting [showSidebarThemeToggle] to true. + if (!isNarrow && showSidebarThemeToggle) ...[ const SizedBox(height: 16), _buildThemeToggle(context, isLightActive), ], @@ -221,14 +228,15 @@ class _AppSidebarState extends ConsumerState { }), const SizedBox(height: 16), if (!isNarrow) const _SectionLabel(label: 'SUPPORT'), - _SidebarNavItem( - icon: Icons.notifications_outlined, - label: 'Notifications', - selected: false, - collapsed: isNarrow, - badge: isNarrow ? null : '3', - onTap: () {}, - ), + if (AppConstants.showNotificationsMenu) + _SidebarNavItem( + icon: Icons.notifications_outlined, + label: 'Notifications', + selected: false, + collapsed: isNarrow, + badge: isNarrow ? null : '3', + onTap: () {}, + ), if (_hasSettings) _SidebarNavItem( icon: Icons.settings_outlined, diff --git a/lib/shared/widgets/app_top_nav.dart b/lib/shared/widgets/app_top_nav.dart index 63fdb35..e53543f 100644 --- a/lib/shared/widgets/app_top_nav.dart +++ b/lib/shared/widgets/app_top_nav.dart @@ -105,10 +105,11 @@ class AppTopNav extends ConsumerWidget { ), ), ), - IconButton( - icon: const Icon(Icons.notifications_outlined, size: 22), - onPressed: () {}, - ), + if (AppConstants.showNotificationsMenu) + IconButton( + icon: const Icon(Icons.notifications_outlined, size: 22), + onPressed: () {}, + ), if (DevConfig.screenPreviewEnabled) IconButton( icon: const Icon(Icons.apps_outlined, size: 22), diff --git a/lib/shared/widgets/sidebar_logo.dart b/lib/shared/widgets/sidebar_logo.dart index 0ec8cc3..fd57de9 100644 --- a/lib/shared/widgets/sidebar_logo.dart +++ b/lib/shared/widgets/sidebar_logo.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import '../../core/utils/media_url.dart'; + /// Displays company logo in the sidebar from URL, data URI, or fallback icon. class SidebarLogo extends StatelessWidget { const SidebarLogo({ @@ -53,7 +55,7 @@ class SidebarLogo extends StatelessWidget { } Widget _buildLogoContent(Widget fallback) { - final url = logoUrl?.trim(); + final url = resolveMediaUrl(logoUrl); if (url == null || url.isEmpty) { return Center(child: fallback); } @@ -99,9 +101,9 @@ String? resolveSidebarLogoUrl({ required String companyProfileLogo, required String? brandingLogo, }) { - if (companyProfileLogo.isNotEmpty) return companyProfileLogo; - if (brandingLogo != null && brandingLogo.isNotEmpty) return brandingLogo; - return null; + final fromProfile = resolveMediaUrl(companyProfileLogo); + if (fromProfile != null && fromProfile.isNotEmpty) return fromProfile; + return resolveMediaUrl(brandingLogo); } /// Resolves sidebar title from company name or app tagline. diff --git a/test/core/validators_test.dart b/test/core/validators_test.dart index ef7f8ec..476030a 100644 --- a/test/core/validators_test.dart +++ b/test/core/validators_test.dart @@ -182,4 +182,29 @@ void main() { ); }); }); + + group('hsnCode', () { + test('accepts 4–8 digit codes', () { + expect(Validators.hsnCode('3402'), isNull); + expect(Validators.hsnCode('34029099'), isNull); + }); + + test('rejects non-digit or wrong length codes', () { + expect(Validators.hsnCode('340'), isNotNull); + expect(Validators.hsnCode('340290991'), isNotNull); + expect(Validators.hsnCode('HSN3402'), isNotNull); + }); + + test('rejects duplicate HSN codes', () { + expect( + Validators.uniqueHsnCode( + '34029099', + existingRecords: const [ + {'id': '1', 'code': '34029099'}, + ], + ), + isNotNull, + ); + }); + }); }