import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/app_constants.dart'; import '../../../../core/utils/column_search_paging.dart'; import '../../../../core/utils/pagination_meta.dart'; import '../../../../core/utils/table_search.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../../assets/data/repositories/asset_repository_impl.dart'; import '../../../masters/data/datasources/master_remote_data_source.dart'; import '../../data/repositories/master_repository_impl.dart'; import '../../domain/entities/master_definition.dart'; import 'master_consumer_invalidation.dart'; class MasterListState { const MasterListState({ this.items = const [], this.search = '', this.page = 1, this.limit = 20, this.total = 0, this.totalPages = 1, this.isDeleting = false, this.isExporting = false, this.actionError, }); final List> items; final String search; final int page; final int limit; final int total; final int totalPages; final bool isDeleting; final bool isExporting; final String? actionError; MasterListState copyWith({ List>? items, String? search, int? page, int? limit, int? total, int? totalPages, bool? isDeleting, bool? isExporting, String? actionError, bool clearError = false, }) { return MasterListState( items: items ?? this.items, search: search ?? this.search, page: page ?? this.page, limit: limit ?? this.limit, total: total ?? this.total, totalPages: totalPages ?? this.totalPages, isDeleting: isDeleting ?? this.isDeleting, isExporting: isExporting ?? this.isExporting, actionError: clearError ? null : actionError ?? this.actionError, ); } } class MasterFormState { const MasterFormState({ this.values = const {}, this.dropdownOptions = const {}, this.existingRecords = const [], this.isSubmitting = false, this.errorMessage, }); final Map values; final Map>> dropdownOptions; final List> existingRecords; final bool isSubmitting; final String? errorMessage; MasterFormState copyWith({ Map? values, Map>>? dropdownOptions, List>? existingRecords, bool? isSubmitting, String? errorMessage, bool clearError = false, }) { return MasterFormState( values: values ?? this.values, dropdownOptions: dropdownOptions ?? this.dropdownOptions, existingRecords: existingRecords ?? this.existingRecords, isSubmitting: isSubmitting ?? this.isSubmitting, errorMessage: clearError ? null : errorMessage ?? this.errorMessage, ); } } final masterListProvider = AsyncNotifierProvider.family< MasterListNotifier, MasterListState, String>(MasterListNotifier.new); class MasterListNotifier extends FamilyAsyncNotifier { final _columnSearch = ColumnSearchPaging( defaultLimit: AppConstants.defaultPageSize, ); MasterDefinition get _definition { final def = masterDefinitionById(arg); if (def == null) throw StateError('Unknown master: $arg'); return def; } @override Future build(String arg) async { ref.keepAlive(); return _load(); } Future _load({ int? page, int? limit, String? search, }) async { final current = state.valueOrNull; final nextPage = page ?? current?.page ?? 1; final nextLimit = limit ?? current?.limit ?? AppConstants.defaultPageSize; final nextSearch = TableSearch.normalize(search ?? current?.search); final repository = ref.read(masterRepositoryProvider); final result = await repository.list( _definition, page: nextPage, limit: nextLimit, search: nextSearch.isEmpty ? null : nextSearch, ); if (result.failure != null) throw result.failure!; var items = List>.from(result.data!.items); // API `search` often ignores related/enum fields (e.g. category_type). // Supplement with dedicated filters and client-side multi-field matching. if (nextSearch.isNotEmpty) { if (_definition.id == 'item_categories') { var typeMatches = 0; for (final type in categoryTypeOptions) { if (!TableSearch.matches(nextSearch, [type])) continue; if (++typeMatches > 5) break; final typed = await repository.list( _definition, page: nextPage, limit: nextLimit, extraQueryParameters: {'category_type': type}, ); if (typed.failure == null && typed.data != null) { items = TableSearch.mergeById( items, typed.data!.items, (row) => row['id']?.toString() ?? '', ); } } } // Keep API page results + enriched matches; pagination stays server-driven. } final data = result.data!; return MasterListState( items: items, search: nextSearch, page: data.page, // Keep the requested page size so the /page dropdown stays valid // even if API meta omits or mismatches `limit`. limit: nextLimit, total: data.total, totalPages: resolveTotalPages(total: data.total, limit: nextLimit), ); } Future refresh() async { final previous = state.valueOrNull; if (previous == null) { state = const AsyncLoading(); } try { state = AsyncData(await _load()); } catch (e, st) { state = AsyncError(e, st); } } Future setSearch(String search) async { await _reload(page: 1, search: search); } Future ensureColumnSearchDataset() async { final current = state.valueOrNull; if (current == null) return; final limit = _columnSearch.beginFullDataset( currentLimit: current.limit, total: current.total, ); if (limit == null) return; await _reload(page: 1, limit: limit, search: ''); } void clearColumnSearchDataset() { final current = state.valueOrNull; if (current == null) return; final limit = _columnSearch.endFullDataset(); if (limit == null) return; _reload(page: 1, limit: limit, search: ''); } /// Clears generic search and reloads the full list. Future clearSearch() async { final current = state.valueOrNull; if (current != null && current.search.isEmpty) return; await _reload(page: 1, search: ''); } Future setPage(int page) async { await _reload(page: page); } Future setPageSize(int limit) async { await _reload(page: 1, limit: limit); } Future _reload({int? page, int? limit, String? search}) async { final previous = state.valueOrNull; if (previous == null) { state = const AsyncLoading(); } try { state = AsyncData( await _load(page: page, limit: limit, search: search), ); } catch (e, st) { state = AsyncError(e, st); } } Future deleteRecord(String id) async { final current = state.valueOrNull ?? const MasterListState(); state = AsyncData(current.copyWith(isDeleting: true)); final result = await ref.read(masterRepositoryProvider).delete(_definition, id); if (result.failure != null) { state = AsyncData(current.copyWith(isDeleting: false)); return false; } await refresh(); invalidateMasterConsumerLookups(ref.invalidate, _definition.id); return true; } Future exportRecords() async { final current = state.valueOrNull; if (current == null) return null; state = AsyncData(current.copyWith(isExporting: true, clearError: true)); final result = await ref.read(masterRepositoryProvider).export( _definition, search: current.search.isEmpty ? null : current.search, ); 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; } } typedef MasterFormArgs = ({ String masterId, String? recordId, Map? initialValues, String formSessionId, }); final masterFormProvider = AsyncNotifierProvider.family< MasterFormNotifier, MasterFormState, MasterFormArgs>(MasterFormNotifier.new); class MasterFormNotifier extends FamilyAsyncNotifier { MasterDefinition get _definition { final def = masterDefinitionById(arg.masterId); if (def == null) throw StateError('Unknown master: ${arg.masterId}'); return def; } @override Future build(MasterFormArgs arg) async { final existingRecords = await _loadExistingRecords(); Map values = {}; if (arg.recordId != null) { final result = await ref .read(masterRepositoryProvider) .getById(_definition, arg.recordId!); if (result.failure != null) throw result.failure!; values = Map.from(result.data ?? const {}); final tags = values['tags']; if (tags is List) { values['tags'] = tags .map((e) => e.toString().trim()) .where((e) => e.isNotEmpty) .join(', '); } } else { for (final field in _definition.formFields) { if (field.type == MasterFieldType.boolean) { values[field.key] = field.key == 'is_active' ? true : false; } } final initials = arg.initialValues; if (initials != null && initials.isNotEmpty) { values.addAll(initials); } } _sanitizeLocationValues(values); // Load after values so Items category options use is_asset_item. final dropdownOptions = await _loadDropdownOptions(values: values); if (_definition.id == 'items') { final syncState = MasterFormState( values: values, dropdownOptions: dropdownOptions, existingRecords: existingRecords, ); _applyGstFromHsn(values, syncState); } return MasterFormState( values: values, dropdownOptions: dropdownOptions, existingRecords: existingRecords, ); } Future>> _loadExistingRecords() async { final allItems = >[]; var page = 1; while (true) { final result = await ref.read(masterRepositoryProvider).list( _definition, page: page, limit: AppConstants.defaultPageSize, ); if (result.failure != null) break; final data = result.data!; allItems.addAll(data.items); if (page >= data.totalPages) break; page++; } return allItems; } Future>>> _loadDropdownOptions({Map? values}) async { final formValues = values ?? state.valueOrNull?.values ?? const {}; final options = >>{}; final fields = _definition.formFields .where((field) => field.optionsMasterKey != null); for (final field in fields) { final lookupKey = masterFieldDropdownLookupKey( masterId: _definition.id, field: field, values: formValues, ); if (options.containsKey(lookupKey)) continue; final key = field.optionsMasterKey!; if (key == 'asset_depreciation_methods') { final result = await ref.read(assetRepositoryProvider).getDepreciationMethods(); if (result.failure != null) continue; options[lookupKey] = (result.data ?? const []) .where((option) => option.value.trim().isNotEmpty) .map( (option) => { 'id': option.value, 'name': option.label, }, ) .toList(); continue; } if (key == 'location_states') { try { final states = await ref .read(masterRemoteDataSourceProvider) .listLocationStates(); options[lookupKey] = states .map( (option) => { 'id': option.id, 'name': option.name, }, ) .toList(); } catch (_) {} continue; } final def = masterDefinitionById(key); if (def == null) continue; final queryParameters = masterFieldOptionsQuery( masterId: _definition.id, field: field, values: formValues, ); final result = await ref.read(masterRepositoryProvider).listOptions( def, queryParameters: queryParameters, ); if (result.failure != null) continue; options[lookupKey] = result.data ?? const []; } return options; } void _applyGstFromHsn(Map values, MasterFormState current) { if (_definition.id != 'items') return; final hsnId = values['hsn_code_id']?.toString(); if (hsnId == null || hsnId.isEmpty) { values['gst_rate_id'] = null; values['gst_rate'] = null; values['gst_rate_label'] = null; return; } final hsnOptions = current.dropdownOptions['hsn_codes'] ?? const []; Map? hsnRow; for (final row in hsnOptions) { if (row['id']?.toString() == hsnId) { hsnRow = row; break; } } if (hsnRow == null) return; final nestedGst = hsnRow['gst_rate']; final gstId = hsnRow['gst_rate_id'] ?? (nestedGst is Map ? nestedGst['id'] : null); if (gstId != null && gstId.toString().isNotEmpty) { values['gst_rate_id'] = gstId.toString(); } else { values['gst_rate_id'] = null; } if (nestedGst is Map) { values['gst_rate'] = Map.from(nestedGst); final desc = nestedGst['description']; values['gst_rate_label'] = desc != null && desc.toString().trim().isNotEmpty ? desc.toString().trim() : gstRateDisplayFromValues(values); } else { values['gst_rate'] = null; values['gst_rate_label'] = null; } } void _clearHiddenFieldValues(Map values) { for (final field in _definition.formFields) { if (field.isVisibleInForm(values)) continue; values[field.key] = null; } } /// Warehouses are no longer nested under plants — drop obsolete keys. void _sanitizeLocationValues(Map values) { if (_definition.id != 'locations') return; values.remove('parent_id'); values.remove('plant_id'); values.remove('plant'); } void updateValue(String key, dynamic value) { final current = state.valueOrNull; if (current == null) return; final values = Map.from(current.values); values[key] = value; if (key == 'hsn_code_id') { _applyGstFromHsn(values, current); } if (key == 'category_type') { _clearHiddenFieldValues(values); } if (key == 'type' && _definition.id == 'locations') { _clearHiddenFieldValues(values); _sanitizeLocationValues(values); } // Asset Item toggles Items category list between STOCK / ASSET // and hides stock-only fields (min order qty / reorder level). if (key == 'is_asset_item' && _definition.id == 'items') { values['item_category_id'] = null; values['item_subcategory_id'] = null; _clearHiddenFieldValues(values); state = AsyncData(current.copyWith(values: values)); _reloadItemCategoryOptions(values); return; } // 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 lookupKey = masterFieldDropdownLookupKey( masterId: _definition.id, field: field, values: values, ); final options = current.dropdownOptions[lookupKey] ?? 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)); } Future _reloadItemCategoryOptions(Map values) async { final current = state.valueOrNull; if (current == null) return; final options = await _loadDropdownOptions(values: values); final latest = state.valueOrNull; if (latest == null) return; state = AsyncData(latest.copyWith(dropdownOptions: options)); } Future reloadDropdownOptions() async { final current = state.valueOrNull; if (current == null) return; final options = await _loadDropdownOptions(values: current.values); // Re-read latest state so a Quick Add selection applied while we were // loading is not overwritten by the snapshot from the start of this call. final latest = state.valueOrNull; if (latest == null) return; state = AsyncData(latest.copyWith(dropdownOptions: options)); } Map _buildPayload(MasterFormState current) { final payload = {}; for (final field in _definition.formFields) { if (!field.isVisibleInForm(current.values)) continue; final value = current.values[field.key]; if (value == null || value == '') continue; if (field.key == 'tags') { final tags = _parseTags(value); if (tags.isNotEmpty) payload['tags'] = tags; continue; } payload[field.key] = switch (field.type) { MasterFieldType.number => num.tryParse(value.toString()) ?? value, MasterFieldType.dropdown => field.staticOptions != null || field.optionsMasterKey == 'asset_depreciation_methods' || field.optionsMasterKey == 'location_states' ? value.toString() : int.tryParse(value.toString()) ?? value, MasterFieldType.boolean => value == true, MasterFieldType.text => value.toString().trim(), }; } // Never send plant nesting fields for locations (API breaking change). if (_definition.id == 'locations') { payload.remove('parent_id'); payload.remove('plant_id'); payload.remove('plant'); } return payload; } List _parseTags(dynamic value) { if (value is List) { return value .map((e) => e.toString().trim()) .where((e) => e.isNotEmpty) .take(50) .map((e) => e.length > 50 ? e.substring(0, 50) : e) .toSet() .toList(); } final parts = value .toString() .split(RegExp(r'[,;\n]+')) .map((e) => e.trim()) .where((e) => e.isNotEmpty) .take(50) .map((e) => e.length > 50 ? e.substring(0, 50) : e); return parts.toSet().toList(); } /// Returns the created/updated record id on success, otherwise null. Future submit() async { final current = state.valueOrNull ?? const MasterFormState(); state = AsyncData(current.copyWith(isSubmitting: true, clearError: true)); final payload = _buildPayload(current); final result = arg.recordId == null ? await ref.read(masterRepositoryProvider).create(_definition, payload) : await ref .read(masterRepositoryProvider) .update(_definition, arg.recordId!, payload); if (result.failure != null) { state = AsyncData( current.copyWith( isSubmitting: false, errorMessage: result.failure!.message, ), ); return null; } state = AsyncData(current.copyWith(isSubmitting: false, clearError: true)); // Keep Asset/PO/GRN/User/Vendor dropdown caches in sync with Master Data. invalidateMasterConsumerLookups(ref.invalidate, _definition.id); ref.invalidate(masterListProvider(_definition.id)); final data = result.data; final createdId = _readCreatedId(data); if (createdId != null && createdId.isNotEmpty) return createdId; return arg.recordId ?? 'created'; } String? _readCreatedId(Map? data) { if (data == null) return null; for (final key in const ['id', 'ID', 'gst_rate_id']) { final value = data[key]; if (value != null && value.toString().trim().isNotEmpty) { return value.toString().trim(); } } final nested = data['data']; if (nested is Map) { final value = nested['id']; if (value != null && value.toString().trim().isNotEmpty) { return value.toString().trim(); } } return null; } }