diff --git a/lib/modules/assets/presentation/widgets/asset_form_panel.dart b/lib/modules/assets/presentation/widgets/asset_form_panel.dart index f46c2c0..3bd2941 100644 --- a/lib/modules/assets/presentation/widgets/asset_form_panel.dart +++ b/lib/modules/assets/presentation/widgets/asset_form_panel.dart @@ -18,6 +18,7 @@ import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../masters/data/datasources/master_remote_data_source.dart'; +import '../../../master_data/presentation/widgets/master_quick_add.dart'; import '../../data/repositories/asset_repository_impl.dart'; import '../providers/asset_categories_provider.dart'; import '../providers/asset_form_lookups_provider.dart'; @@ -675,12 +676,14 @@ class _AssetFormPanelState extends ConsumerState { label: 'Department', value: _departmentId, options: lookups.departments, + masterId: 'departments', onChanged: (v) => setState(() => _departmentId = v), ), right: _optionalLookupDropdown( label: 'Warehouse', value: _warehouseId, options: lookups.warehouses, + masterId: 'warehouses', onChanged: (v) => setState(() => _warehouseId = v), ), ), @@ -980,6 +983,7 @@ class _AssetFormPanelState extends ConsumerState { required int? value, required List options, required ValueChanged onChanged, + String? masterId, String? emptyHint, bool enabled = true, bool required = false, @@ -998,17 +1002,43 @@ class _AssetFormPanelState extends ConsumerState { .map((option) => option.value) .whereType() .toList(); + final resolvedValue = + value != null && validIds.contains(value) ? value : null; + final fieldEnabled = + enabled && (options.isNotEmpty || masterId != null); - return AppSearchableDropdown( + if (masterId == null) { + return AppSearchableDropdown( + label: required ? '$label *' : label, + value: resolvedValue, + hint: emptyHint ?? 'None', + searchHint: 'Search ${label.toLowerCase()}...', + isDense: true, + enabled: fieldEnabled, + options: dropdownOptions, + onChanged: onChanged, + validator: + required ? (v) => v == null ? '$label is required' : null : null, + ); + } + + return MasterQuickAddDropdown( + masterId: masterId, label: required ? '$label *' : label, - value: value != null && validIds.contains(value) ? value : null, + value: resolvedValue, hint: emptyHint ?? 'None', searchHint: 'Search ${label.toLowerCase()}...', isDense: true, - enabled: enabled && options.isNotEmpty, + enabled: fieldEnabled, options: dropdownOptions, + refreshLookups: () { + ref.invalidate(assetFormLookupsProvider); + ref.invalidate(assetPlantsProvider); + }, + parseCreatedId: int.tryParse, onChanged: onChanged, - validator: required ? (v) => v == null ? '$label is required' : null : null, + validator: + required ? (v) => v == null ? '$label is required' : null : null, ); } @@ -1024,7 +1054,8 @@ class _AssetFormPanelState extends ConsumerState { ) .where((option) => option.value != 0) .toList(); - return AppSearchableDropdown( + return MasterQuickAddDropdown( + masterId: 'item_subcategories', label: 'Subcategory *', value: _dropdownValue(_subcategoryId, ids), hint: !hasCategory @@ -1034,8 +1065,13 @@ class _AssetFormPanelState extends ConsumerState { : 'Select subcategory *', searchHint: 'Search subcategory...', isDense: true, - enabled: hasCategory && options.isNotEmpty, + enabled: hasCategory, options: options, + initialValues: {'item_category_id': _categoryId}, + refreshLookups: () { + ref.invalidate(itemSubcategoriesProvider(_categoryId)); + }, + parseCreatedId: int.tryParse, onChanged: (v) => setState(() => _subcategoryId = v), validator: (v) => v == null ? 'Subcategory is required' : null, ); @@ -1046,7 +1082,8 @@ class _AssetFormPanelState extends ConsumerState { .map((c) => int.tryParse(c.id)) .whereType() .toList(); - return AppSearchableDropdown( + return MasterQuickAddDropdown( + masterId: 'item_categories', label: 'Category *', value: _dropdownValue(_categoryId, categoryIds), searchHint: 'Search category...', @@ -1060,6 +1097,10 @@ class _AssetFormPanelState extends ConsumerState { ) .where((option) => option.value != 0) .toList(), + refreshLookups: () { + ref.invalidate(itemCategoriesProvider); + }, + parseCreatedId: int.tryParse, onChanged: (v) => setState(() { _categoryId = v; _subcategoryId = null; @@ -1087,7 +1128,8 @@ class _AssetFormPanelState extends ConsumerState { .map((p) => int.tryParse(p.id)) .whereType() .toList(); - return AppSearchableDropdown( + return MasterQuickAddDropdown( + masterId: 'plants', label: 'Plant *', value: _dropdownValue(_plantId, plantIds), searchHint: 'Search plant...', @@ -1101,6 +1143,11 @@ class _AssetFormPanelState extends ConsumerState { ) .where((option) => option.value != 0) .toList(), + refreshLookups: () { + ref.invalidate(assetPlantsProvider); + ref.invalidate(assetFormLookupsProvider); + }, + parseCreatedId: int.tryParse, onChanged: (v) => setState(() => _plantId = v), validator: (v) => v == null ? 'Plant is required' : null, ); diff --git a/lib/modules/assets/presentation/widgets/asset_side_panels.dart b/lib/modules/assets/presentation/widgets/asset_side_panels.dart index 7057374..4cd1172 100644 --- a/lib/modules/assets/presentation/widgets/asset_side_panels.dart +++ b/lib/modules/assets/presentation/widgets/asset_side_panels.dart @@ -15,6 +15,7 @@ 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 '../../../master_data/presentation/widgets/master_quick_add.dart'; import '../providers/asset_form_lookups_provider.dart'; import '../providers/assets_provider.dart'; @@ -1334,7 +1335,8 @@ class _TransferAssetPanelState extends ConsumerState { onPick: _pickDate, ), const SizedBox(height: 12), - AppSearchableDropdown( + MasterQuickAddDropdown( + masterId: 'plants', label: 'To Plant', value: _toPlantId, searchHint: 'Search plant...', @@ -1346,10 +1348,14 @@ class _TransferAssetPanelState extends ConsumerState { }) .whereType>() .toList(), + refreshLookups: () => + ref.invalidate(assetFormLookupsProvider), + parseCreatedId: int.tryParse, onChanged: (v) => setState(() => _toPlantId = v), ), const SizedBox(height: 12), - AppSearchableDropdown( + MasterQuickAddDropdown( + masterId: 'departments', label: 'To Department', value: _toDepartmentId, searchHint: 'Search department...', @@ -1361,6 +1367,9 @@ class _TransferAssetPanelState extends ConsumerState { }) .whereType>() .toList(), + refreshLookups: () => + ref.invalidate(assetFormLookupsProvider), + parseCreatedId: int.tryParse, onChanged: (v) => setState(() => _toDepartmentId = v), ), const SizedBox(height: 12), @@ -1379,7 +1388,8 @@ class _TransferAssetPanelState extends ConsumerState { onChanged: (v) => setState(() => _toUserId = v), ), const SizedBox(height: 12), - AppSearchableDropdown( + MasterQuickAddDropdown( + masterId: 'warehouses', label: 'To Warehouse', value: _toWarehouseId, searchHint: 'Search warehouse...', @@ -1391,6 +1401,9 @@ class _TransferAssetPanelState extends ConsumerState { }) .whereType>() .toList(), + refreshLookups: () => + ref.invalidate(assetFormLookupsProvider), + parseCreatedId: int.tryParse, onChanged: (v) => setState(() => _toWarehouseId = v), ), const SizedBox(height: 12), diff --git a/lib/modules/grn/presentation/screens/grn_form_screen.dart b/lib/modules/grn/presentation/screens/grn_form_screen.dart index 4736f12..bf1c4dc 100644 --- a/lib/modules/grn/presentation/screens/grn_form_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_form_screen.dart @@ -18,6 +18,7 @@ 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 '../../../master_data/presentation/widgets/master_quick_add.dart'; import '../providers/grn_lookups_provider.dart'; import '../providers/grn_provider.dart'; import '../widgets/grn_line_items_editor.dart'; @@ -439,12 +440,16 @@ class _GrnFormScreenState extends ConsumerState { label: 'Purchase order', value: existing?.poNumber ?? '—', ), - AppSearchableDropdown( + MasterQuickAddDropdown( + masterId: 'warehouses', label: 'Warehouse *', value: _dropdownValue(_warehouseId, warehouseIds), hint: 'Select warehouse', searchHint: 'Search warehouse...', options: _intOptions(lookups.warehouses), + refreshLookups: () => + ref.invalidate(grnLookupsProvider), + parseCreatedId: int.tryParse, onChanged: widget.isEditing ? (_) {} : (v) => setState(() => _warehouseId = v), diff --git a/lib/modules/master_data/domain/entities/master_definition.dart b/lib/modules/master_data/domain/entities/master_definition.dart index d6e2aa9..1cd897a 100644 --- a/lib/modules/master_data/domain/entities/master_definition.dart +++ b/lib/modules/master_data/domain/entities/master_definition.dart @@ -282,7 +282,7 @@ const masterDefinitions = [ ), MasterFieldDef(key: 'contact_person', label: 'Contact Person'), MasterFieldDef(key: 'phone', label: 'Phone'), - MasterFieldDef(key: 'email', label: 'Email'), + MasterFieldDef(key: 'email', label: 'Email', required: true), _activeField, ], ), diff --git a/lib/modules/master_data/presentation/providers/master_provider.dart b/lib/modules/master_data/presentation/providers/master_provider.dart index b3228a9..7817376 100644 --- a/lib/modules/master_data/presentation/providers/master_provider.dart +++ b/lib/modules/master_data/presentation/providers/master_provider.dart @@ -230,7 +230,12 @@ class MasterListNotifier extends FamilyAsyncNotifier { } } -typedef MasterFormArgs = ({String masterId, String? recordId}); +typedef MasterFormArgs = ({ + String masterId, + String? recordId, + Map? initialValues, + String formSessionId, +}); final masterFormProvider = AsyncNotifierProvider.family< MasterFormNotifier, MasterFormState, MasterFormArgs>(MasterFormNotifier.new); @@ -260,6 +265,10 @@ class MasterFormNotifier extends FamilyAsyncNotifier reloadDropdownOptions() async { + final current = state.valueOrNull; + if (current == null) return; + final options = await _loadDropdownOptions(); + state = AsyncData(current.copyWith(dropdownOptions: options)); + } + Map _buildPayload(MasterFormState current) { final payload = {}; for (final field in _definition.formFields) { @@ -368,7 +384,8 @@ class MasterFormNotifier extends FamilyAsyncNotifier submit() async { + /// 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)); @@ -386,10 +403,12 @@ class MasterFormNotifier extends FamilyAsyncNotifier { } Future _openFormPanel({String? recordId}) async { - ref.invalidate(masterFormProvider((masterId: widget.masterId, recordId: recordId))); - final saved = await showSidePanel( + final sessionId = + '${widget.masterId}-${recordId ?? 'new'}-${DateTime.now().microsecondsSinceEpoch}'; + ref.invalidate(masterFormProvider(( + masterId: widget.masterId, + recordId: recordId, + initialValues: null, + formSessionId: sessionId, + ))); + final saved = await showSidePanel( context, - MasterFormPanel(masterId: widget.masterId, recordId: recordId), + MasterFormPanel( + masterId: widget.masterId, + recordId: recordId, + formSessionId: sessionId, + ), width: 560, ); - if (saved == true && mounted) { + if (saved != null && mounted) { ref.invalidate(masterListProvider(widget.masterId)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( 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 fa9f3f4..b601bf8 100644 --- a/lib/modules/master_data/presentation/widgets/master_form_panel.dart +++ b/lib/modules/master_data/presentation/widgets/master_form_panel.dart @@ -11,17 +11,26 @@ import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../domain/entities/master_definition.dart'; import '../providers/master_provider.dart'; +import 'master_quick_add.dart'; class MasterFormPanel extends ConsumerStatefulWidget { const MasterFormPanel({ super.key, required this.masterId, this.recordId, + this.initialValues, + this.formSessionId, }); final String masterId; final String? recordId; + /// Prefill values for create mode (used by Quick Add for filtered FKs). + final Map? initialValues; + + /// Unique per open so create forms always start empty. + final String? formSessionId; + bool get isEditing => recordId != null; @override @@ -30,6 +39,9 @@ class MasterFormPanel extends ConsumerStatefulWidget { class _MasterFormPanelState extends ConsumerState { final _formKey = GlobalKey(); + late final String _formSessionId = + widget.formSessionId ?? + '${widget.masterId}-${widget.recordId ?? 'new'}-${DateTime.now().microsecondsSinceEpoch}'; MasterDefinition get _definition { final def = masterDefinitionById(widget.masterId); @@ -37,18 +49,22 @@ class _MasterFormPanelState extends ConsumerState { return def; } - MasterFormArgs get _args => - (masterId: widget.masterId, recordId: widget.recordId); + MasterFormArgs get _args => ( + masterId: widget.masterId, + recordId: widget.recordId, + initialValues: widget.initialValues, + formSessionId: _formSessionId, + ); Future _submit() async { if (!_formKey.currentState!.validate()) return; - final success = + final createdId = await ref.read(masterFormProvider(_args).notifier).submit(); if (!mounted) return; - if (success) { - Navigator.of(context, rootNavigator: true).pop(true); + if (createdId != null) { + Navigator.of(context, rootNavigator: true).pop(createdId); return; } @@ -127,9 +143,11 @@ class _MasterFormPanelState extends ConsumerState { final parentSelected = filterField == null || (formState.values[filterField] != null && formState.values[filterField].toString().isNotEmpty); - final enabled = field.staticOptions != null - ? true - : parentSelected && dropdownOptions.isNotEmpty; + final optionsMasterKey = field.optionsMasterKey; + final canQuickAdd = field.staticOptions == null && + optionsMasterKey != null && + masterDefinitionById(optionsMasterKey) != null && + parentSelected; String parentLabel = 'parent'; if (filterField != null) { for (final f in _definition.formFields) { @@ -140,9 +158,41 @@ class _MasterFormPanelState extends ConsumerState { } } + if (canQuickAdd) { + return MasterQuickAddDropdown( + key: ValueKey( + '$_formSessionId-${field.key}-${filterField == null ? '' : formState.values[filterField]}', + ), + masterId: optionsMasterKey, + label: _fieldLabel(field), + value: value?.toString(), + options: dropdownOptions, + hint: !parentSelected + ? 'Select $parentLabel first' + : dropdownOptions.isEmpty + ? 'No options available' + : 'Select ${field.label.toLowerCase()}', + searchHint: 'Search ${field.label.toLowerCase()}...', + enabled: parentSelected, + initialValues: filterField == null + ? null + : {filterField: formState.values[filterField]}, + refreshLookups: () { + ref + .read(masterFormProvider(_args).notifier) + .reloadDropdownOptions(); + }, + parseCreatedId: (id) => id, + onChanged: (selected) => notifier.updateValue(field.key, selected), + validator: field.required + ? (v) => v == null ? '${field.label} is required' : null + : null, + ); + } + return AppSearchableDropdown( key: ValueKey( - '${field.key}-${filterField == null ? '' : formState.values[filterField]}', + '$_formSessionId-${field.key}-${filterField == null ? '' : formState.values[filterField]}', ), label: _fieldLabel(field), value: value?.toString(), @@ -153,7 +203,9 @@ class _MasterFormPanelState extends ConsumerState { ? 'No options available' : 'Select ${field.label.toLowerCase()}', searchHint: 'Search ${field.label.toLowerCase()}...', - enabled: enabled, + enabled: field.staticOptions != null + ? true + : parentSelected && dropdownOptions.isNotEmpty, onChanged: (selected) => notifier.updateValue(field.key, selected), validator: field.required ? (v) => v == null ? '${field.label} is required' : null @@ -162,8 +214,8 @@ class _MasterFormPanelState extends ConsumerState { case MasterFieldType.number: return TextFormField( - key: ValueKey(field.key), - initialValue: value?.toString(), + key: ValueKey('$_formSessionId-${field.key}'), + initialValue: value?.toString() ?? '', keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: InputDecoration(labelText: _fieldLabel(field)), validator: field.required @@ -179,8 +231,8 @@ class _MasterFormPanelState extends ConsumerState { ? Validators.hsnCodeInput : Validators.inputFormattersForFieldKey(field.key); return TextFormField( - key: ValueKey(field.key), - initialValue: value?.toString(), + key: ValueKey('$_formSessionId-${field.key}'), + initialValue: value?.toString() ?? '', maxLines: field.multiline ? 3 : 1, keyboardType: isHsnCodeField ? TextInputType.number @@ -335,9 +387,12 @@ class _MasterFormPanelState extends ConsumerState { data: (formState) { return Form( key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: _buildFieldLayout(context, formState), + child: KeyedSubtree( + key: ValueKey(_formSessionId), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _buildFieldLayout(context, formState), + ), ), ); }, diff --git a/lib/modules/master_data/presentation/widgets/master_inline_create_form.dart b/lib/modules/master_data/presentation/widgets/master_inline_create_form.dart new file mode 100644 index 0000000..f8f858f --- /dev/null +++ b/lib/modules/master_data/presentation/widgets/master_inline_create_form.dart @@ -0,0 +1,488 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/errors/failure.dart'; +import '../../../../core/utils/validators.dart'; +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/error_view.dart'; +import '../../domain/entities/master_definition.dart'; +import '../providers/master_provider.dart'; + +/// Compact create form for inline Quick Add (no side panel / dialog). +class MasterInlineCreateForm extends ConsumerStatefulWidget { + const MasterInlineCreateForm({ + super.key, + required this.masterId, + required this.formSessionId, + required this.onSaved, + required this.onCancel, + this.initialValues, + }); + + final String masterId; + final String formSessionId; + final Map? initialValues; + final ValueChanged onSaved; + final VoidCallback onCancel; + + @override + ConsumerState createState() => + _MasterInlineCreateFormState(); +} + +class _MasterInlineCreateFormState + extends ConsumerState { + final _formKey = GlobalKey(); + + MasterDefinition get _definition { + final def = masterDefinitionById(widget.masterId); + if (def == null) throw StateError('Unknown master: ${widget.masterId}'); + return def; + } + + MasterFormArgs get _args => ( + masterId: widget.masterId, + recordId: null, + initialValues: widget.initialValues, + formSessionId: widget.formSessionId, + ); + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + final createdId = + await ref.read(masterFormProvider(_args).notifier).submit(); + if (!mounted) return; + + if (createdId != null) { + widget.onSaved(createdId); + return; + } + + final error = ref.read(masterFormProvider(_args)).valueOrNull?.errorMessage; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + error ?? 'Failed to create ${_definition.title.toLowerCase()}', + ), + ), + ); + } + + String _fieldLabel(MasterFieldDef field) => + field.required ? '${field.label} *' : field.label; + + /// Matches [AppTextField] / [AppSearchableDropdown] label clearance. + Widget _wrapField(Widget field) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: field, + ); + } + + InputDecoration _inputDecoration(MasterFieldDef field) { + return InputDecoration( + labelText: _fieldLabel(field), + isDense: true, + floatingLabelBehavior: FloatingLabelBehavior.always, + alignLabelWithHint: true, + ); + } + + Widget _buildField({ + required MasterFieldDef field, + required MasterFormState formState, + }) { + final notifier = ref.read(masterFormProvider(_args).notifier); + final value = formState.values[field.key]; + + switch (field.type) { + case MasterFieldType.boolean: + if (field.key == 'is_active') { + return SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text(field.label), + value: value == true, + onChanged: (checked) => notifier.updateValue(field.key, checked), + ); + } + return CheckboxListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text(field.label), + value: value == true, + onChanged: (checked) => + notifier.updateValue(field.key, checked ?? false), + ); + + case MasterFieldType.dropdown: + final List> dropdownOptions; + if (field.staticOptions != null) { + dropdownOptions = stringDropdownOptions(field.staticOptions!); + } else { + 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(); + if (id == null || id.isEmpty) continue; + dropdownOptions.add( + AppDropdownOption(value: id, label: masterRecordLabel(item)), + ); + } + } + + final filterField = field.filterByFieldKey; + final parentSelected = filterField == null || + (formState.values[filterField] != null && + formState.values[filterField].toString().isNotEmpty); + + String parentLabel = 'parent'; + if (filterField != null) { + for (final f in _definition.formFields) { + if (f.key == filterField) { + parentLabel = f.label.toLowerCase(); + break; + } + } + } + + // AppSearchableDropdown already applies top label padding. + return AppSearchableDropdown( + key: ValueKey( + '${widget.formSessionId}-${field.key}-${formState.values[filterField]}', + ), + label: _fieldLabel(field), + value: value?.toString(), + options: dropdownOptions, + isDense: true, + hint: !parentSelected + ? 'Select $parentLabel first' + : dropdownOptions.isEmpty + ? 'No options available' + : 'Select ${field.label.toLowerCase()}', + searchHint: 'Search ${field.label.toLowerCase()}...', + enabled: field.staticOptions != null + ? true + : parentSelected && dropdownOptions.isNotEmpty, + onChanged: (selected) => notifier.updateValue(field.key, selected), + validator: field.required + ? (v) => v == null ? '${field.label} is required' : null + : null, + ); + + case MasterFieldType.number: + return _wrapField( + TextFormField( + key: ValueKey('${widget.formSessionId}-${field.key}'), + initialValue: value?.toString() ?? '', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: _inputDecoration(field), + validator: field.required + ? (v) => Validators.required(v, fieldName: field.label) + : null, + onChanged: (text) => notifier.updateValue(field.key, text), + ), + ); + + case MasterFieldType.text: + final isHsnCodeField = + widget.masterId == 'hsn_codes' && field.key == 'code'; + final formatters = isHsnCodeField + ? Validators.hsnCodeInput + : Validators.inputFormattersForFieldKey(field.key); + final isEmailField = field.key.trim().toLowerCase() == 'email'; + final isPhoneField = field.key.trim().toLowerCase() == 'phone' || + field.key.trim().toLowerCase() == 'mobile'; + return _wrapField( + TextFormField( + key: ValueKey('${widget.formSessionId}-${field.key}'), + initialValue: value?.toString() ?? '', + maxLines: field.multiline ? 3 : 1, + keyboardType: isHsnCodeField + ? TextInputType.number + : _keyboardTypeForFieldKey(field.key), + inputFormatters: formatters.isEmpty ? null : formatters, + autovalidateMode: (isEmailField || isPhoneField) + ? AutovalidateMode.onUserInteraction + : AutovalidateMode.disabled, + decoration: _inputDecoration(field), + validator: (v) { + if (isHsnCodeField) { + return Validators.uniqueHsnCode( + v, + existingRecords: formState.existingRecords, + currentRecordId: null, + fieldName: field.label, + ); + } + if (Validators.isMasterNameFieldKey(field.key)) { + return Validators.uniqueMasterName( + v, + nameKey: field.key, + existingRecords: formState.existingRecords, + currentRecordId: null, + fieldName: field.label, + ); + } + if (Validators.isMasterCodeFieldKey(field.key)) { + return Validators.uniqueMasterCode( + v, + codeKey: field.key, + existingRecords: formState.existingRecords, + currentRecordId: null, + fieldName: field.label, + ); + } + if (isEmailField) { + return field.required + ? Validators.email(v) + : Validators.optionalEmail(v); + } + return Validators.forFieldKey( + field.key, + v, + required: field.required, + fieldName: field.label, + ); + }, + onChanged: (text) => notifier.updateValue(field.key, text), + ), + ); + } + } + + TextInputType? _keyboardTypeForFieldKey(String key) { + final normalizedKey = key.trim().toLowerCase(); + if (normalizedKey == 'phone' || normalizedKey == 'mobile') { + return TextInputType.phone; + } + if (normalizedKey == 'email') { + return TextInputType.emailAddress; + } + if (normalizedKey == 'pincode' || + normalizedKey == 'postal_code' || + normalizedKey == 'zip') { + return TextInputType.number; + } + if (normalizedKey == 'account_number' || normalizedKey == 'account_no') { + return TextInputType.number; + } + return null; + } + + List _buildFieldRows({ + required List fields, + required MasterFormState formState, + required bool twoColumns, + }) { + if (!twoColumns) { + return [ + for (var i = 0; i < fields.length; i++) ...[ + SizedBox( + width: double.infinity, + child: _buildField(field: fields[i], formState: formState), + ), + if (i < fields.length - 1) const SizedBox(height: 4), + ], + ]; + } + + final rows = []; + var i = 0; + while (i < fields.length) { + final left = fields[i]; + + if (left.multiline) { + rows.add( + SizedBox( + width: double.infinity, + child: _buildField(field: left, formState: formState), + ), + ); + if (i < fields.length - 1) rows.add(const SizedBox(height: 4)); + i += 1; + continue; + } + + final hasRight = + i + 1 < fields.length && !fields[i + 1].multiline; + final right = hasRight ? fields[i + 1] : null; + + if (right == null) { + // Odd last field: span full width (no empty right column gap). + rows.add( + SizedBox( + width: double.infinity, + child: _buildField(field: left, formState: formState), + ), + ); + } else { + rows.add( + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _buildField(field: left, formState: formState), + ), + const SizedBox(width: 12), + Expanded( + child: _buildField(field: right, formState: formState), + ), + ], + ), + ); + } + + final consumed = right != null ? 2 : 1; + if (i + consumed < fields.length) { + rows.add(const SizedBox(height: 4)); + } + i += consumed; + } + return rows; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final formAsync = ref.watch(masterFormProvider(_args)); + final def = _definition; + final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false; + + return Material( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.none, + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.28), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.add_circle_outline, + size: 18, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Add ${def.title.toLowerCase()}', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + tooltip: 'Close', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + onPressed: isSubmitting ? null : widget.onCancel, + icon: const Icon(Icons.close, size: 18), + ), + ], + ), + const SizedBox(height: 4), + formAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: AppLoadingView(message: 'Preparing form...'), + ), + error: (error, _) => ErrorView.fromFailure( + error is Failure + ? error + : Failure.unknown(message: error.toString()), + onRetry: () => ref.invalidate(masterFormProvider(_args)), + ), + data: (formState) { + final fields = def.formFields + .where((f) => f.key != 'is_active') + .toList(); + final active = def.formFields + .where((f) => f.key == 'is_active') + .firstOrNull; + + return Form( + key: _formKey, + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final useTwoColumns = + width.isFinite && width >= 420; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ..._buildFieldRows( + fields: fields, + formState: formState, + twoColumns: useTwoColumns, + ), + if (active != null) ...[ + const SizedBox(height: 4), + _buildField( + field: active, + formState: formState, + ), + ], + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: + isSubmitting ? null : widget.onCancel, + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + AppButton( + label: 'Save', + expand: false, + icon: Icons.check, + isLoading: isSubmitting, + onPressed: isSubmitting ? null : _submit, + ), + ], + ), + ], + ); + }, + ), + ); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/modules/master_data/presentation/widgets/master_quick_add.dart b/lib/modules/master_data/presentation/widgets/master_quick_add.dart new file mode 100644 index 0000000..f901eaf --- /dev/null +++ b/lib/modules/master_data/presentation/widgets/master_quick_add.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/constants/enums.dart'; +import '../../../../shared/providers/permissions_provider.dart'; +import '../../../../shared/widgets/app_dropdown.dart'; +import '../../../../shared/widgets/app_searchable_dropdown.dart'; +import '../../../../shared/widgets/app_side_panel.dart'; +import '../providers/master_provider.dart'; +import 'master_inline_create_form.dart'; + +/// Dropdown with inline Quick Add that expands below the field (no dialog). +/// +/// When placed inside a [QuickAddInlineHost] (FormRow / SidePanelFormRow), the +/// create form is rendered at full row width under the row so every field and +/// button stays clickable. Without a host, the form expands directly below the +/// dropdown at the available column width. +class MasterQuickAddDropdown extends ConsumerStatefulWidget { + const MasterQuickAddDropdown({ + super.key, + required this.masterId, + required this.label, + required this.value, + required this.options, + required this.onChanged, + required this.parseCreatedId, + this.validator, + this.hint, + this.searchHint, + this.enabled = true, + this.isDense = false, + this.initialValues, + this.refreshLookups, + this.addNewLabel, + }); + + final String masterId; + final String label; + final T? value; + final List> options; + final ValueChanged onChanged; + final T? Function(String createdId) parseCreatedId; + final String? Function(T?)? validator; + final String? hint; + final String? searchHint; + final bool enabled; + final bool isDense; + final Map? initialValues; + final VoidCallback? refreshLookups; + final String? addNewLabel; + + @override + ConsumerState> createState() => + _MasterQuickAddDropdownState(); +} + +class _MasterQuickAddDropdownState + extends ConsumerState> { + bool _expanded = false; + String? _sessionId; + final _dropdownFocus = FocusNode(); + QuickAddInlineController? _host; + + @override + void dispose() { + _host?.dismiss(this); + _dropdownFocus.dispose(); + super.dispose(); + } + + bool get _canQuickAdd => ref.can('masters', PermissionAction.create); + + void _openInlineForm() { + if (!_canQuickAdd) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('You do not have permission to add master data'), + ), + ); + return; + } + + final sessionId = + 'inline-${widget.masterId}-${DateTime.now().microsecondsSinceEpoch}'; + ref.invalidate( + masterFormProvider(( + masterId: widget.masterId, + recordId: null, + initialValues: widget.initialValues, + formSessionId: sessionId, + )), + ); + + _host = QuickAddInlineScope.maybeOf(context); + setState(() { + _sessionId = sessionId; + _expanded = true; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _publishHostForm(); + }); + } + + void _collapse() { + if (!_expanded) return; + _host?.dismiss(this); + setState(() { + _expanded = false; + _sessionId = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _dropdownFocus.requestFocus(); + }); + } + + void _onSaved(String createdId) { + widget.refreshLookups?.call(); + + if (createdId != 'created') { + final parsed = widget.parseCreatedId(createdId); + if (parsed != null) { + widget.onChanged(parsed); + } + } + + _collapse(); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text('${_titleCase(masterQuickAddNoun(widget.masterId))} added'), + ), + ); + } + + Widget _buildCreateForm() { + return MasterInlineCreateForm( + key: ValueKey(_sessionId), + masterId: widget.masterId, + formSessionId: _sessionId!, + initialValues: widget.initialValues, + onSaved: _onSaved, + onCancel: _collapse, + ); + } + + void _publishHostForm() { + if (!_expanded || _sessionId == null) return; + final host = _host ?? QuickAddInlineScope.maybeOf(context); + if (host == null) return; + _host = host; + host.present(owner: this, form: _buildCreateForm()); + } + + @override + Widget build(BuildContext context) { + final addLabel = widget.addNewLabel ?? masterQuickAddLabel(widget.masterId); + _host ??= QuickAddInlineScope.maybeOf(context); + + final dropdown = Focus( + focusNode: _dropdownFocus, + child: AppSearchableDropdown( + label: widget.label, + value: widget.value, + options: widget.options, + onChanged: widget.onChanged, + validator: widget.validator, + hint: widget.hint, + searchHint: + widget.searchHint ?? 'Search ${widget.label.toLowerCase()}...', + enabled: widget.enabled, + isDense: widget.isDense, + addNewLabel: _canQuickAdd ? addLabel : null, + onAddNew: !_canQuickAdd || !widget.enabled + ? null + : () async => _openInlineForm(), + ), + ); + + // Host renders the form at full row width. + if (_host != null || !_expanded || _sessionId == null) { + return dropdown; + } + + // Fallback when no FormRow/SidePanelFormRow host is present. + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + dropdown, + AnimatedSize( + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 10), + child: _buildCreateForm(), + ), + ), + ], + ); + } +} + +String masterQuickAddLabel(String masterId) => + 'Add ${masterQuickAddNoun(masterId)}'; + +String masterQuickAddNoun(String masterId) { + return switch (masterId) { + 'uom' => 'UOM', + 'item_categories' => 'category', + 'item_subcategories' => 'subcategory', + 'items' => 'item', + 'hsn_codes' => 'HSN code', + 'brands' => 'brand', + 'plants' => 'plant', + 'warehouses' => 'warehouse', + 'designations' => 'designation', + 'departments' => 'department', + 'delivery_terms' => 'delivery term', + 'payment_terms' => 'payment term', + 'gst_rates' => 'GST rate', + 'document_series' => 'document series', + _ => masterId.replaceAll('_', ' '), + }; +} + +String _titleCase(String value) { + if (value.isEmpty) return value; + return value[0].toUpperCase() + value.substring(1); +} 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 702265d..2c645a5 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 @@ -20,6 +20,7 @@ import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../../data/repositories/purchase_order_repository_impl.dart'; import '../../../grn/presentation/providers/grn_lookups_provider.dart'; +import '../../../master_data/presentation/widgets/master_quick_add.dart'; import '../providers/purchase_order_lookups_provider.dart'; import '../providers/purchase_orders_provider.dart'; import '../widgets/po_status_chip.dart'; @@ -420,12 +421,16 @@ class _PurchaseOrderFormScreenState extends ConsumerState v == null ? 'Vendor is required' : null, ), - AppSearchableDropdown( + MasterQuickAddDropdown( + masterId: 'plants', label: 'Plant *', value: _dropdownValue(_plantId, plantIds), hint: 'Select plant', searchHint: 'Search plant...', options: _intOptions(lookups.plants), + refreshLookups: () => + ref.invalidate(purchaseOrderLookupsProvider), + parseCreatedId: int.tryParse, onChanged: (v) => setState(() => _plantId = v), validator: (v) => v == null ? 'Plant is required' : null, @@ -434,40 +439,56 @@ class _PurchaseOrderFormScreenState extends ConsumerState( + MasterQuickAddDropdown( + masterId: 'warehouses', label: 'Warehouse', value: _warehouseId, hint: 'Select warehouse', searchHint: 'Search warehouse...', options: _nullableIntOptions(lookups.warehouses), + refreshLookups: () => + ref.invalidate(purchaseOrderLookupsProvider), + parseCreatedId: int.tryParse, onChanged: (v) => setState(() => _warehouseId = v), ), - AppSearchableDropdown( + MasterQuickAddDropdown( + masterId: 'brands', label: 'Brand', value: _brandId, hint: 'Select brand', searchHint: 'Search brand...', options: _nullableIntOptions(lookups.brands), + refreshLookups: () => + ref.invalidate(purchaseOrderLookupsProvider), + parseCreatedId: int.tryParse, onChanged: (v) => setState(() => _brandId = v), ), - AppSearchableDropdown( + MasterQuickAddDropdown( + masterId: 'payment_terms', label: 'Payment term', value: _paymentTermId, hint: 'Select payment term', searchHint: 'Search payment term...', options: _nullableIntOptions(lookups.paymentTerms), + refreshLookups: () => + ref.invalidate(purchaseOrderLookupsProvider), + parseCreatedId: int.tryParse, onChanged: (v) => setState(() => _paymentTermId = v), ), - AppSearchableDropdown( + MasterQuickAddDropdown( + masterId: 'delivery_terms', label: 'Delivery term', value: _deliveryTermId, hint: 'Select delivery term', searchHint: 'Search delivery term...', options: _nullableIntOptions(lookups.deliveryTerms), + refreshLookups: () => + ref.invalidate(purchaseOrderLookupsProvider), + parseCreatedId: int.tryParse, onChanged: (v) => setState(() => _deliveryTermId = v), ), diff --git a/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart b/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart index e485bf2..3ecdc26 100644 --- a/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart +++ b/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/utils/formatters.dart'; import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/user_management_models.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 '../../../master_data/presentation/widgets/master_quick_add.dart'; +import '../providers/purchase_order_lookups_provider.dart'; /// Per-line amount breakdown for PO calculations. class PoLineCalculation { @@ -173,7 +175,7 @@ class PoLineItemDraft { } } -class PurchaseOrderLineItemsEditor extends StatefulWidget { +class PurchaseOrderLineItemsEditor extends ConsumerStatefulWidget { const PurchaseOrderLineItemsEditor({ super.key, required this.lines, @@ -202,12 +204,12 @@ class PurchaseOrderLineItemsEditor extends StatefulWidget { final VoidCallback? onChanged; @override - State createState() => + ConsumerState createState() => _PurchaseOrderLineItemsEditorState(); } class _PurchaseOrderLineItemsEditorState - extends State { + extends ConsumerState { void _notifyChanged() { widget.onChanged?.call(); setState(() {}); @@ -310,7 +312,7 @@ class _PurchaseOrderLineItemsEditorState } } -class _LineItemCard extends StatefulWidget { +class _LineItemCard extends ConsumerStatefulWidget { const _LineItemCard({ super.key, required this.line, @@ -337,10 +339,10 @@ class _LineItemCard extends StatefulWidget { final VoidCallback? onRemove; @override - State<_LineItemCard> createState() => _LineItemCardState(); + ConsumerState<_LineItemCard> createState() => _LineItemCardState(); } -class _LineItemCardState extends State<_LineItemCard> { +class _LineItemCardState extends ConsumerState<_LineItemCard> { @override void initState() { super.initState(); @@ -441,13 +443,17 @@ class _LineItemCardState extends State<_LineItemCard> { spacing: 8, stackBelowWidth: 1100, children: [ - AppSearchableDropdown( + MasterQuickAddDropdown( key: ValueKey('$lineKey-item'), + masterId: 'items', label: 'Item *', value: line.itemId, hint: 'Select item', searchHint: 'Search item...', options: itemOptions, + refreshLookups: () => + ref.invalidate(purchaseOrderLookupsProvider), + parseCreatedId: int.tryParse, onChanged: _onItemChanged, validator: (v) => v == null ? 'Item is required' : null, ), @@ -467,13 +473,17 @@ class _LineItemCardState extends State<_LineItemCard> { return null; }, ), - AppSearchableDropdown( + MasterQuickAddDropdown( key: ValueKey('$lineKey-uom'), + masterId: 'uom', label: 'UOM *', value: line.uomId, hint: 'Select UOM', searchHint: 'Search UOM...', options: uomOptions, + refreshLookups: () => + ref.invalidate(purchaseOrderLookupsProvider), + parseCreatedId: int.tryParse, onChanged: (v) => _updateLine(() => line.uomId = v), validator: (v) => v == null ? 'Required' : null, ), @@ -499,13 +509,17 @@ class _LineItemCardState extends State<_LineItemCard> { keyboardType: const TextInputType.numberWithOptions(decimal: true), ), - AppSearchableDropdown( + MasterQuickAddDropdown( key: ValueKey('$lineKey-gst'), + masterId: 'gst_rates', label: 'GST rate', value: line.gstRateId, hint: 'Select GST rate', searchHint: 'Search GST rate...', options: gstOptions, + refreshLookups: () => + ref.invalidate(purchaseOrderLookupsProvider), + parseCreatedId: int.tryParse, onChanged: (v) => _updateLine(() => line.gstRateId = v), ), _AmountWithRemove( diff --git a/lib/modules/rbac/presentation/widgets/add_user_panel.dart b/lib/modules/rbac/presentation/widgets/add_user_panel.dart index 79d5ad0..25f36fa 100644 --- a/lib/modules/rbac/presentation/widgets/add_user_panel.dart +++ b/lib/modules/rbac/presentation/widgets/add_user_panel.dart @@ -12,6 +12,7 @@ import '../../../../shared/widgets/app_searchable_multi_select_dropdown.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/app_side_panel.dart'; +import '../../../master_data/presentation/widgets/master_quick_add.dart'; import '../providers/add_user_form_provider.dart'; class AddUserPanel extends ConsumerStatefulWidget { @@ -184,16 +185,40 @@ class _AddUserPanelState extends ConsumerState { required ValueChanged onChanged, String? hint, bool required = false, + String? masterId, }) { - return AppSearchableDropdown( - label: required ? '$label *' : label, + final fieldLabel = required ? '$label *' : label; + final fieldHint = hint ?? 'Select ${label.toLowerCase()}'; + final fieldEnabled = options.isNotEmpty || masterId != null; + final validator = + required ? (String? v) => v == null ? 'Please select $label' : null : null; + + if (masterId == null) { + return AppSearchableDropdown( + label: fieldLabel, + value: value, + hint: fieldHint, + searchHint: 'Search $label...', + enabled: fieldEnabled, + options: _toOptions(options), + onChanged: onChanged, + validator: validator, + ); + } + + return MasterQuickAddDropdown( + masterId: masterId, + label: fieldLabel, value: value, - hint: hint ?? 'Select ${label.toLowerCase()}', + hint: fieldHint, searchHint: 'Search $label...', - enabled: options.isNotEmpty, + enabled: fieldEnabled, options: _toOptions(options), + refreshLookups: () => + ref.invalidate(addUserFormProvider(widget.userId)), + parseCreatedId: (id) => id, onChanged: onChanged, - validator: required ? (v) => v == null ? 'Please select $label' : null : null, + validator: validator, ); } @@ -313,6 +338,7 @@ class _AddUserPanelState extends ConsumerState { label: 'Department', value: _selectedDepartmentId, options: formState.departments, + masterId: 'departments', onChanged: (v) => setState(() => _selectedDepartmentId = v), ), @@ -320,6 +346,7 @@ class _AddUserPanelState extends ConsumerState { label: 'Designation', value: _selectedDesignationId, options: formState.designations, + masterId: 'designations', onChanged: (v) => setState(() => _selectedDesignationId = v), ), @@ -329,6 +356,7 @@ class _AddUserPanelState extends ConsumerState { label: 'Plant / Unit', value: _selectedPlantId, options: formState.plants, + masterId: 'plants', onChanged: (v) => setState(() => _selectedPlantId = v), ), right: _buildDropdown( diff --git a/lib/modules/users/presentation/screens/user_form_screen.dart b/lib/modules/users/presentation/screens/user_form_screen.dart index 863e819..6445987 100644 --- a/lib/modules/users/presentation/screens/user_form_screen.dart +++ b/lib/modules/users/presentation/screens/user_form_screen.dart @@ -11,6 +11,7 @@ import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/error_view.dart'; +import '../../../master_data/presentation/widgets/master_quick_add.dart'; import '../providers/users_provider.dart'; class UserFormScreen extends ConsumerStatefulWidget { @@ -223,7 +224,8 @@ class _UserFormScreenState extends ConsumerState { validator: (v) => v == null ? 'Role is required' : null, ), const SizedBox(height: 16), - AppSearchableDropdown( + MasterQuickAddDropdown( + masterId: 'departments', label: 'Department', value: _selectedDepartmentId, searchHint: 'Search department...', @@ -236,7 +238,11 @@ class _UserFormScreenState extends ConsumerState { ), ), ], - onChanged: (v) => setState(() => _selectedDepartmentId = v), + refreshLookups: () => + ref.invalidate(usersListProvider), + parseCreatedId: int.tryParse, + onChanged: (v) => + setState(() => _selectedDepartmentId = v), ), const SizedBox(height: 16), AppSearchableDropdown( diff --git a/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart b/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart index 833fe41..b584e97 100644 --- a/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart +++ b/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart @@ -13,6 +13,7 @@ import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../masters/data/datasources/master_remote_data_source.dart'; +import '../../../master_data/presentation/widgets/master_quick_add.dart'; import '../../data/repositories/vendor_repository_impl.dart'; import '../providers/vendors_provider.dart'; @@ -344,7 +345,8 @@ class _VendorFormPanelState extends ConsumerState { final value = _paymentTermId != null && termIds.contains(_paymentTermId) ? _paymentTermId : null; - return AppSearchableDropdown( + return MasterQuickAddDropdown( + masterId: 'payment_terms', label: 'Payment Term', value: value, searchHint: 'Search payment term...', @@ -357,6 +359,8 @@ class _VendorFormPanelState extends ConsumerState { ) .where((option) => option.value != 0) .toList(), + refreshLookups: () => ref.invalidate(vendorPaymentTermsProvider), + parseCreatedId: int.tryParse, onChanged: (v) => setState(() => _paymentTermId = v), ); } diff --git a/lib/shared/widgets/app_dropdown.dart b/lib/shared/widgets/app_dropdown.dart index 70a8aa7..26196c6 100644 --- a/lib/shared/widgets/app_dropdown.dart +++ b/lib/shared/widgets/app_dropdown.dart @@ -22,6 +22,8 @@ class AppDropdown extends StatelessWidget { this.searchHint, this.enabled = true, this.isDense = false, + this.addNewLabel, + this.onAddNew, }); final String label; @@ -33,6 +35,8 @@ class AppDropdown extends StatelessWidget { final String? searchHint; final bool enabled; final bool isDense; + final String? addNewLabel; + final Future Function()? onAddNew; @override Widget build(BuildContext context) { @@ -46,6 +50,8 @@ class AppDropdown extends StatelessWidget { searchHint: searchHint ?? 'Search ${label.toLowerCase()}...', enabled: enabled, isDense: isDense, + addNewLabel: addNewLabel, + onAddNew: onAddNew, ); } } diff --git a/lib/shared/widgets/app_searchable_dropdown.dart b/lib/shared/widgets/app_searchable_dropdown.dart index db24061..f239b17 100644 --- a/lib/shared/widgets/app_searchable_dropdown.dart +++ b/lib/shared/widgets/app_searchable_dropdown.dart @@ -15,6 +15,8 @@ class AppSearchableDropdown extends StatefulWidget { this.searchHint = 'Search...', this.enabled = true, this.isDense = false, + this.addNewLabel, + this.onAddNew, }); final String label; @@ -27,6 +29,13 @@ class AppSearchableDropdown extends StatefulWidget { final bool enabled; final bool isDense; + /// Shown as a footer action in the picker (e.g. "Add plant"). + final String? addNewLabel; + + /// Opens Quick Add / create flow. When set, the field can open even if + /// [options] is empty. + final Future Function()? onAddNew; + @override State> createState() => _AppSearchableDropdownState(); @@ -83,7 +92,9 @@ class _AppSearchableDropdownState extends State> { } void _openPicker(FormFieldState field) { - if (!widget.enabled || widget.options.isEmpty) return; + final canOpen = + widget.enabled && (widget.options.isNotEmpty || widget.onAddNew != null); + if (!canOpen) return; if (_overlayEntry != null) { _removeOverlay(); return; @@ -153,6 +164,13 @@ class _AppSearchableDropdownState extends State> { options: widget.options, selected: field.value ?? widget.value, searchHint: widget.searchHint, + addNewLabel: widget.addNewLabel, + onAddNew: widget.onAddNew == null + ? null + : () async { + _removeOverlay(); + await widget.onAddNew!(); + }, onSelected: (value) { _removeOverlay(); field.didChange(value); @@ -189,7 +207,8 @@ class _AppSearchableDropdownState extends State> { validator: widget.validator, builder: (field) { final effectiveHint = widget.hint ?? 'Select ${widget.label.toLowerCase()}'; - final canOpen = widget.enabled && widget.options.isNotEmpty; + final canOpen = widget.enabled && + (widget.options.isNotEmpty || widget.onAddNew != null); final colors = theme.colorScheme; final selected = field.value ?? widget.value; final displayLabel = _labelForValue(selected); @@ -251,6 +270,8 @@ class _SearchableDropdownPanel extends StatefulWidget { required this.selected, required this.searchHint, required this.onSelected, + this.addNewLabel, + this.onAddNew, }); final double maxHeight; @@ -258,6 +279,8 @@ class _SearchableDropdownPanel extends StatefulWidget { final T? selected; final String searchHint; final ValueChanged onSelected; + final String? addNewLabel; + final Future Function()? onAddNew; @override State<_SearchableDropdownPanel> createState() => @@ -287,6 +310,8 @@ class _SearchableDropdownPanelState Widget build(BuildContext context) { final theme = Theme.of(context); final filtered = _filtered; + final addLabel = widget.addNewLabel ?? 'Quick add'; + final hasAdd = widget.onAddNew != null; return Column( mainAxisSize: MainAxisSize.min, @@ -310,12 +335,16 @@ class _SearchableDropdownPanelState ), ), ConstrainedBox( - constraints: BoxConstraints(maxHeight: widget.maxHeight - 56), + constraints: BoxConstraints( + maxHeight: widget.maxHeight - (hasAdd ? 108 : 56), + ), child: filtered.isEmpty ? Padding( padding: const EdgeInsets.all(20), child: Text( - 'No options found', + hasAdd + ? 'No options found. Use Quick add below.' + : 'No options found', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, @@ -355,6 +384,17 @@ class _SearchableDropdownPanelState }, ), ), + if (hasAdd) ...[ + Divider( + height: 1, + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + TextButton.icon( + onPressed: () => widget.onAddNew?.call(), + icon: const Icon(Icons.add, size: 18), + label: Text(addLabel), + ), + ], ], ); } @@ -373,6 +413,8 @@ class AppSearchableLookupField extends StatefulWidget { this.searchHint = 'Search...', this.enabled = true, this.isDense = false, + this.addNewLabel, + this.onAddNew, }); final String label; @@ -383,6 +425,8 @@ class AppSearchableLookupField extends StatefulWidget { final String searchHint; final bool enabled; final bool isDense; + final String? addNewLabel; + final Future Function()? onAddNew; @override State> createState() => @@ -423,7 +467,9 @@ class _AppSearchableLookupFieldState } void _openPicker() { - if (!widget.enabled || widget.options.isEmpty) return; + final canOpen = + widget.enabled && (widget.options.isNotEmpty || widget.onAddNew != null); + if (!canOpen) return; if (_overlayEntry != null) { _removeOverlay(); return; @@ -495,6 +541,13 @@ class _AppSearchableLookupFieldState options: widget.options, selected: widget.value, searchHint: widget.searchHint, + addNewLabel: widget.addNewLabel, + onAddNew: widget.onAddNew == null + ? null + : () async { + _removeOverlay(); + await widget.onAddNew!(); + }, onSelected: (value) { _removeOverlay(); widget.onChanged(value); @@ -524,7 +577,8 @@ class _AppSearchableLookupFieldState final theme = Theme.of(context); final effectiveHint = widget.hint ?? 'Select ${widget.label.toLowerCase()}'; - final canOpen = widget.enabled && widget.options.isNotEmpty; + final canOpen = widget.enabled && + (widget.options.isNotEmpty || widget.onAddNew != null); final colors = theme.colorScheme; final displayLabel = _labelForValue(widget.value); diff --git a/lib/shared/widgets/app_side_panel.dart b/lib/shared/widgets/app_side_panel.dart index 46394e2..39a9dc2 100644 --- a/lib/shared/widgets/app_side_panel.dart +++ b/lib/shared/widgets/app_side_panel.dart @@ -2,6 +2,98 @@ import 'package:flutter/material.dart'; import '../../core/utils/responsive_utils.dart'; +/// Holds a full-width Quick Add form below a form row (so it stays clickable). +class QuickAddInlineController extends ChangeNotifier { + Object? _owner; + Widget? _form; + + Widget? get form => _form; + + void present({required Object owner, required Widget form}) { + _owner = owner; + _form = form; + notifyListeners(); + } + + void dismiss(Object owner) { + if (_owner != owner) return; + _owner = null; + _form = null; + notifyListeners(); + } + + void dismissAll() { + if (_owner == null && _form == null) return; + _owner = null; + _form = null; + notifyListeners(); + } +} + +class QuickAddInlineScope extends InheritedNotifier { + const QuickAddInlineScope({ + super.key, + required QuickAddInlineController controller, + required super.child, + }) : super(notifier: controller); + + static QuickAddInlineController? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.notifier; + } +} + +/// Wraps a form row and renders any active Quick Add form at full row width. +class QuickAddInlineHost extends StatefulWidget { + const QuickAddInlineHost({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _QuickAddInlineHostState(); +} + +class _QuickAddInlineHostState extends State { + final _controller = QuickAddInlineController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return QuickAddInlineScope( + controller: _controller, + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final form = _controller.form; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + widget.child, + AnimatedSize( + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: form == null + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only(top: 10, bottom: 4), + child: form, + ), + ), + ], + ); + }, + ), + ); + } +} + Future showSidePanel( BuildContext context, Widget panel, { @@ -181,36 +273,38 @@ class SidePanelFormRow extends StatelessWidget { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final stack = constraints.maxWidth < 420; + return QuickAddInlineHost( + child: LayoutBuilder( + builder: (context, constraints) { + final stack = constraints.maxWidth < 420; + + if (stack) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + left, + const SizedBox(height: 12), + right, + ], + ), + ); + } - if (stack) { return Padding( padding: const EdgeInsets.only(bottom: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - left, - const SizedBox(height: 12), - right, + Expanded(child: left), + SizedBox(width: spacing), + Expanded(child: right), ], ), ); - } - - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(child: left), - SizedBox(width: spacing), - Expanded(child: right), - ], - ), - ); - }, + }, + ), ); } } @@ -403,66 +497,68 @@ class FormRow extends StatelessWidget { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final contentPadding = EdgeInsets.fromLTRB( - horizontalPadding, - 0, - horizontalPadding, - spacing, - ); - - if (constraints.maxWidth < stackBelowWidth) { - return Padding( - padding: contentPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (var i = 0; i < children.length; i++) ...[ - if (i > 0) SizedBox(height: spacing), - children[i], - ], - ], - ), + return QuickAddInlineHost( + child: LayoutBuilder( + builder: (context, constraints) { + final contentPadding = EdgeInsets.fromLTRB( + horizontalPadding, + 0, + horizontalPadding, + spacing, ); - } - if (columnCount != null) { - final slots = _columnSlots(); - final cols = columnCount!; - final innerWidth = constraints.maxWidth - (horizontalPadding * 2); - final columnWidth = (innerWidth - (cols - 1) * spacing) / cols; + if (constraints.maxWidth < stackBelowWidth) { + return Padding( + padding: contentPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) SizedBox(height: spacing), + children[i], + ], + ], + ), + ); + } + + if (columnCount != null) { + final slots = _columnSlots(); + final cols = columnCount!; + final innerWidth = constraints.maxWidth - (horizontalPadding * 2); + final columnWidth = (innerWidth - (cols - 1) * spacing) / cols; + + return Padding( + padding: contentPadding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < slots.length; i++) ...[ + if (i > 0) SizedBox(width: spacing), + SizedBox( + width: _spanWidth(columnWidth, slots[i].span), + child: slots[i].child, + ), + ], + ], + ), + ); + } return Padding( padding: contentPadding, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (var i = 0; i < slots.length; i++) ...[ + for (var i = 0; i < children.length; i++) ...[ if (i > 0) SizedBox(width: spacing), - SizedBox( - width: _spanWidth(columnWidth, slots[i].span), - child: slots[i].child, - ), + Expanded(child: children[i]), ], ], ), ); - } - - return Padding( - padding: contentPadding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var i = 0; i < children.length; i++) ...[ - if (i > 0) SizedBox(width: spacing), - Expanded(child: children[i]), - ], - ], - ), - ); - }, + }, + ), ); } } diff --git a/lib/shared/widgets/master_inline_quick_add_form.dart b/lib/shared/widgets/master_inline_quick_add_form.dart new file mode 100644 index 0000000..e5ee054 --- /dev/null +++ b/lib/shared/widgets/master_inline_quick_add_form.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/utils/validators.dart'; +import '../../modules/master_data/data/repositories/master_repository_impl.dart'; +import '../../modules/master_data/domain/entities/master_definition.dart'; + +/// Compact master create form for use inside a dropdown panel. +class MasterInlineQuickAddForm extends ConsumerStatefulWidget { + const MasterInlineQuickAddForm({ + super.key, + required this.masterId, + required this.onCreated, + required this.onCancel, + this.initialValues, + }); + + final String masterId; + final ValueChanged onCreated; + final VoidCallback onCancel; + final Map? initialValues; + + @override + ConsumerState createState() => + _MasterInlineQuickAddFormState(); +} + +class _MasterInlineQuickAddFormState + extends ConsumerState { + final _formKey = GlobalKey(); + final _values = {}; + final _controllers = {}; + var _loadingOptions = true; + var _submitting = false; + String? _error; + Map>> _dropdownOptions = {}; + + MasterDefinition get _definition { + final def = masterDefinitionById(widget.masterId); + if (def == null) { + throw StateError('Unknown master: ${widget.masterId}'); + } + return def; + } + + @override + void initState() { + super.initState(); + for (final field in _definition.formFields) { + if (field.type == MasterFieldType.boolean) { + _values[field.key] = field.key == 'is_active' ? true : false; + } + } + final initials = widget.initialValues; + if (initials != null) { + _values.addAll(initials); + } + for (final field in _definition.formFields) { + if (field.type == MasterFieldType.text || + field.type == MasterFieldType.number) { + _controllers[field.key] = TextEditingController( + text: _values[field.key]?.toString() ?? '', + ); + } + } + _loadOptions(); + } + + @override + void dispose() { + for (final controller in _controllers.values) { + controller.dispose(); + } + super.dispose(); + } + + Future _loadOptions() async { + final options = >>{}; + final keys = _definition.formFields + .where((f) => f.optionsMasterKey != null && f.staticOptions == null) + .map((f) => f.optionsMasterKey!) + .toSet(); + + for (final key in keys) { + final def = masterDefinitionById(key); + if (def == null) continue; + final result = await ref.read(masterRepositoryProvider).listOptions(def); + if (result.failure == null) { + options[key] = result.data ?? const []; + } + } + + if (!mounted) return; + setState(() { + _dropdownOptions = options; + _loadingOptions = false; + }); + } + + Map _buildPayload() { + final payload = {}; + for (final field in _definition.formFields) { + var value = _values[field.key]; + if (field.type == MasterFieldType.text || + field.type == MasterFieldType.number) { + value = _controllers[field.key]?.text.trim(); + } + if (value == null || value == '') continue; + payload[field.key] = switch (field.type) { + MasterFieldType.number => num.tryParse(value.toString()) ?? value, + MasterFieldType.dropdown => int.tryParse(value.toString()) ?? value, + MasterFieldType.boolean => value == true, + MasterFieldType.text => value.toString().trim(), + }; + } + return payload; + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _submitting = true; + _error = null; + }); + + final result = await ref.read(masterRepositoryProvider).create( + _definition, + _buildPayload(), + ); + + if (!mounted) return; + + if (result.failure != null) { + setState(() { + _submitting = false; + _error = result.failure!.message; + }); + return; + } + + final id = result.data?['id']?.toString(); + if (id == null || id.isEmpty) { + setState(() { + _submitting = false; + _error = 'Created, but no id was returned'; + }); + return; + } + + widget.onCreated(id); + } + + List> _optionsFor(MasterFieldDef field) { + if (field.staticOptions != null) { + return field.staticOptions! + .map((v) => {'id': v, 'name': v}) + .toList(); + } + var options = + _dropdownOptions[field.optionsMasterKey] ?? const >[]; + final filterField = field.filterByFieldKey; + if (filterField != null) { + final parentId = _values[filterField]?.toString(); + final optionKey = field.filterByOptionKey ?? filterField; + if (parentId == null || parentId.isEmpty) return const []; + options = options + .where((item) => item[optionKey]?.toString() == parentId) + .toList(); + } + return options; + } + + Widget _buildField(MasterFieldDef field) { + final prefilled = widget.initialValues?.containsKey(field.key) == true; + + switch (field.type) { + case MasterFieldType.boolean: + if (field.key == 'is_active') { + return SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text(field.label, style: const TextStyle(fontSize: 13)), + value: _values[field.key] == true, + onChanged: _submitting + ? null + : (v) => setState(() => _values[field.key] = v), + ); + } + return CheckboxListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text(field.label, style: const TextStyle(fontSize: 13)), + value: _values[field.key] == true, + onChanged: _submitting + ? null + : (v) => setState(() => _values[field.key] = v ?? false), + ); + + case MasterFieldType.dropdown: + final options = _optionsFor(field); + final current = _values[field.key]?.toString(); + return DropdownButtonFormField( + // ignore: deprecated_member_use + value: options.any((o) => o['id']?.toString() == current) + ? current + : null, + isDense: true, + decoration: InputDecoration( + labelText: field.required ? '${field.label} *' : field.label, + isDense: true, + ), + items: [ + for (final item in options) + if (item['id'] != null) + DropdownMenuItem( + value: item['id'].toString(), + child: Text( + (item['name'] ?? item['label'] ?? item['id']).toString(), + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: (_submitting || prefilled) + ? null + : (v) => setState(() => _values[field.key] = v), + validator: field.required + ? (v) => v == null ? '${field.label} is required' : null + : null, + ); + + case MasterFieldType.number: + case MasterFieldType.text: + return TextFormField( + controller: _controllers[field.key], + enabled: !_submitting && !prefilled, + maxLines: field.multiline ? 2 : 1, + keyboardType: field.type == MasterFieldType.number + ? const TextInputType.numberWithOptions(decimal: true) + : null, + decoration: InputDecoration( + labelText: field.required ? '${field.label} *' : field.label, + isDense: true, + ), + validator: field.required + ? (v) => Validators.required(v, fieldName: field.label) + : null, + onChanged: (text) => _values[field.key] = text, + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fields = _definition.formFields; + + return Material( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 10), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(Icons.add_circle_outline, + size: 16, color: theme.colorScheme.primary), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Add ${_definition.title.toLowerCase()}', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + tooltip: 'Cancel', + visualDensity: VisualDensity.compact, + onPressed: _submitting ? null : widget.onCancel, + icon: const Icon(Icons.close, size: 18), + ), + ], + ), + if (_loadingOptions) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else ...[ + for (final field in fields) ...[ + _buildField(field), + const SizedBox(height: 8), + ], + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + _error!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + Row( + children: [ + const Spacer(), + TextButton( + onPressed: _submitting ? null : widget.onCancel, + child: const Text('Cancel'), + ), + const SizedBox(width: 4), + FilledButton( + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Save'), + ), + ], + ), + ], + ], + ), + ), + ), + ); + } +}