bharat_erp/lib/modules/master_data/presentation/providers/master_provider.dart

676 lines
21 KiB
Dart

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<Map<String, dynamic>> 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<Map<String, dynamic>>? 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<String, dynamic> values;
final Map<String, List<Map<String, dynamic>>> dropdownOptions;
final List<Map<String, dynamic>> existingRecords;
final bool isSubmitting;
final String? errorMessage;
MasterFormState copyWith({
Map<String, dynamic>? values,
Map<String, List<Map<String, dynamic>>>? dropdownOptions,
List<Map<String, dynamic>>? 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<MasterListState, String> {
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<MasterListState> build(String arg) async {
ref.keepAlive();
return _load();
}
Future<MasterListState> _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<Map<String, dynamic>>.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<void> 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<void> setSearch(String search) async {
await _reload(page: 1, search: search);
}
Future<void> 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<void> clearSearch() async {
final current = state.valueOrNull;
if (current != null && current.search.isEmpty) return;
await _reload(page: 1, search: '');
}
Future<void> setPage(int page) async {
await _reload(page: page);
}
Future<void> setPageSize(int limit) async {
await _reload(page: 1, limit: limit);
}
Future<void> _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<bool> 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<ExportFileResult?> 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<String, dynamic>? initialValues,
String formSessionId,
});
final masterFormProvider = AsyncNotifierProvider.family<
MasterFormNotifier, MasterFormState, MasterFormArgs>(MasterFormNotifier.new);
class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterFormArgs> {
MasterDefinition get _definition {
final def = masterDefinitionById(arg.masterId);
if (def == null) throw StateError('Unknown master: ${arg.masterId}');
return def;
}
@override
Future<MasterFormState> build(MasterFormArgs arg) async {
final existingRecords = await _loadExistingRecords();
Map<String, dynamic> values = {};
if (arg.recordId != null) {
final result = await ref
.read(masterRepositoryProvider)
.getById(_definition, arg.recordId!);
if (result.failure != null) throw result.failure!;
values = Map<String, dynamic>.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<List<Map<String, dynamic>>> _loadExistingRecords() async {
final allItems = <Map<String, dynamic>>[];
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<Map<String, List<Map<String, dynamic>>>>
_loadDropdownOptions({Map<String, dynamic>? values}) async {
final formValues = values ?? state.valueOrNull?.values ?? const {};
final options = <String, List<Map<String, dynamic>>>{};
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) => <String, dynamic>{
'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) => <String, dynamic>{
'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<String, dynamic> 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<String, dynamic>? 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<String, dynamic>.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<String, dynamic> 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<String, dynamic> 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<String, dynamic>.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<void> _reloadItemCategoryOptions(Map<String, dynamic> 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<void> 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<String, dynamic> _buildPayload(MasterFormState current) {
final payload = <String, dynamic>{};
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<String> _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<String?> 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<String, dynamic>? 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;
}
}