diff --git a/lib/core/utils/responsive_utils.dart b/lib/core/utils/responsive_utils.dart index 1bf34a2..5f15e81 100644 --- a/lib/core/utils/responsive_utils.dart +++ b/lib/core/utils/responsive_utils.dart @@ -21,4 +21,24 @@ extension ResponsiveContext on BuildContext { if (isDesktop) return 1200; return double.infinity; } + + /// Form grid columns: 4 on medium+, 2 on small screens. + int get formGridColumns { + final width = MediaQuery.sizeOf(this).width; + return formGridColumnsForWidth(width); + } +} + +/// Responsive form field columns based on viewport width. +int formGridColumnsForWidth( + double width, { + int smallColumns = 2, + int mediumColumns = 4, + int largeColumns = 4, + double mediumBreakpoint = AppBreakpoints.tablet, + double largeBreakpoint = AppBreakpoints.desktop, +}) { + if (width >= largeBreakpoint) return largeColumns; + if (width >= mediumBreakpoint) return mediumColumns; + return smallColumns; } diff --git a/lib/modules/assets/presentation/screens/asset_detail_screen.dart b/lib/modules/assets/presentation/screens/asset_detail_screen.dart index a57ee96..d191a23 100644 --- a/lib/modules/assets/presentation/screens/asset_detail_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_detail_screen.dart @@ -138,7 +138,7 @@ class _AssetDetailScreenState extends ConsumerState final confirmed = await showAppConfirmationDialog( context: context, title: 'Delete Asset', - message: 'Soft delete this asset?', + message: 'Do you want to delete this asset?', confirmLabel: 'Delete', isDestructive: true, ); diff --git a/lib/modules/grn/presentation/providers/grn_lookups_provider.dart b/lib/modules/grn/presentation/providers/grn_lookups_provider.dart index cc775cc..272f5ef 100644 --- a/lib/modules/grn/presentation/providers/grn_lookups_provider.dart +++ b/lib/modules/grn/presentation/providers/grn_lookups_provider.dart @@ -5,17 +5,20 @@ import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../../masters/data/datasources/master_remote_data_source.dart'; import '../../../purchase_orders/data/repositories/purchase_order_repository_impl.dart'; +import '../../../users/presentation/providers/users_provider.dart'; class GrnLookups { const GrnLookups({ this.warehouses = const [], this.receivablePurchaseOrders = const [], this.assetCategories = const [], + this.users = const [], }); final List warehouses; final List receivablePurchaseOrders; final List assetCategories; + final List users; } final grnLookupsProvider = FutureProvider.autoDispose((ref) async { @@ -24,6 +27,7 @@ final grnLookupsProvider = FutureProvider.autoDispose((ref) async { final warehouses = await _safeOptions(master.listWarehouses); final assetCategories = await _safeOptions(master.listAssetCategories); + final users = await _safeUserOptions(ref); final receivablePos = []; for (final status in ['APPROVED', 'PARTIALLY_RECEIVED']) { @@ -43,6 +47,7 @@ final grnLookupsProvider = FutureProvider.autoDispose((ref) async { warehouses: warehouses, receivablePurchaseOrders: receivablePos, assetCategories: assetCategories, + users: users, ); }); @@ -56,6 +61,45 @@ Future> _safeOptions( } } +Future> _safeUserOptions(Ref ref) async { + try { + final result = await ref.read(getUsersUseCaseProvider)( + const UserListQuery( + page: 1, + limit: AppConstants.maxPageSize, + status: 'active', + isActive: true, + ), + ); + if (result.failure != null || result.data == null) return const []; + return result.data!.items + .where((user) { + final status = user.status.trim().toLowerCase(); + return status == 'active' && user.isActive; + }) + .map( + (user) => FilterOptionModel( + id: user.id, + name: user.fullName, + ), + ) + .toList(); + } catch (_) { + return const []; + } +} + +final grnAssetSubcategoriesProvider = + FutureProvider.autoDispose.family, int?>( + (ref, categoryId) async { + if (categoryId == null) return const []; + final master = ref.watch(masterRemoteDataSourceProvider); + return _safeOptions( + () => master.listAssetSubcategories(assetCategoryId: categoryId), + ); + }, +); + final grnPurchaseOrderProvider = FutureProvider.autoDispose.family((ref, poId) async { final result = diff --git a/lib/modules/grn/presentation/screens/grn_detail_screen.dart b/lib/modules/grn/presentation/screens/grn_detail_screen.dart index 1c761f3..73f163d 100644 --- a/lib/modules/grn/presentation/screens/grn_detail_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_detail_screen.dart @@ -7,6 +7,7 @@ import '../../../../core/constants/route_constants.dart'; import '../../../../core/errors/failure.dart'; import '../../../../core/utils/formatters.dart'; import '../../../../shared/models/grn_model.dart'; +import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/widgets/app_card.dart'; @@ -14,6 +15,7 @@ import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; +import '../providers/grn_lookups_provider.dart'; import '../providers/grn_provider.dart'; import '../widgets/grn_line_items_editor.dart'; import '../widgets/grn_status_chip.dart'; @@ -209,15 +211,25 @@ class _GrnDetailScreenState extends ConsumerState { } } -class _OverviewCard extends StatelessWidget { +class _OverviewCard extends ConsumerWidget { const _OverviewCard({required this.grn}); final GrnModel grn; + String _userLabel(List users, int? userId) { + if (userId == null) return '—'; + final match = users.where((u) => int.tryParse(u.id) == userId); + if (match.isNotEmpty) return match.first.name; + return 'User #$userId'; + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - final hasRemarks = grn.remarks?.trim().isNotEmpty == true; + final users = ref.watch(grnLookupsProvider).maybeWhen( + data: (lookups) => lookups.users, + orElse: () => const [], + ); return AppCard( child: Padding( @@ -254,18 +266,16 @@ class _OverviewCard extends StatelessWidget { _GrnInfo('Vehicle No', grn.vehicleNo ?? '—'), _GrnInfo('LR No', grn.lrNo ?? '—'), _GrnInfo('LR Date', DateFormatter.displayDate(grn.lrDate)), + _GrnInfo('Received By', _userLabel(users, grn.receivedBy)), + _GrnInfo( + 'Quality Checked By', + _userLabel(users, grn.qualityCheckedBy), + ), + _GrnInfo('Remarks', grn.remarks?.trim().isNotEmpty == true + ? grn.remarks! + : '—'), ], ), - if (hasRemarks) ...[ - const Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Divider(height: 1), - ), - _GrnInfoGrid( - columns: 1, - items: [_GrnInfo('Remarks', grn.remarks!)], - ), - ], ], ), ), diff --git a/lib/modules/grn/presentation/screens/grn_form_screen.dart b/lib/modules/grn/presentation/screens/grn_form_screen.dart index c9dae5e..570d01a 100644 --- a/lib/modules/grn/presentation/screens/grn_form_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_form_screen.dart @@ -47,6 +47,8 @@ class _GrnFormScreenState extends ConsumerState { DateTime? _lrDate; String? _selectedPoId; int? _warehouseId; + int? _receivedById; + int? _qualityCheckedById; final List _lines = []; bool _isSubmitting = false; String? _populatedSignature; @@ -88,6 +90,8 @@ class _GrnFormScreenState extends ConsumerState { _vehicleNoController.text = grn.vehicleNo ?? ''; _lrNoController.text = grn.lrNo ?? ''; _lrDate = grn.lrDate; + _receivedById = grn.receivedBy; + _qualityCheckedById = grn.qualityCheckedBy; _remarksController.text = grn.remarks ?? ''; }); } @@ -124,14 +128,6 @@ class _GrnFormScreenState extends ConsumerState { .toList(); } - List> _assetCategoryOptions( - List categories, - ) { - return categories - .map((e) => AppDropdownOption(value: e.id, label: e.name)) - .toList(); - } - Map _buildCreatePayload() { final poId = int.tryParse(_selectedPoId ?? ''); return { @@ -149,8 +145,9 @@ class _GrnFormScreenState extends ConsumerState { 'vehicle_no': _vehicleNoController.text.trim(), if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(), if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!), - if (_remarksController.text.trim().isNotEmpty) - 'remarks': _remarksController.text.trim(), + if (_receivedById != null) 'received_by': _receivedById, + if (_qualityCheckedById != null) 'quality_checked_by': _qualityCheckedById, + 'remarks': _remarksController.text.trim(), 'items': _lines.map((line) => line.toPayload()).toList(), }; } @@ -168,8 +165,9 @@ class _GrnFormScreenState extends ConsumerState { 'vehicle_no': _vehicleNoController.text.trim(), if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(), if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!), - if (_remarksController.text.trim().isNotEmpty) - 'remarks': _remarksController.text.trim(), + if (_receivedById != null) 'received_by': _receivedById, + if (_qualityCheckedById != null) 'quality_checked_by': _qualityCheckedById, + 'remarks': _remarksController.text.trim(), }; } @@ -364,7 +362,7 @@ class _GrnFormScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - FormRowThree( + ResponsiveFormGrid( children: [ _DateField( label: 'GRN Date *', @@ -382,6 +380,7 @@ class _GrnFormScreenState extends ConsumerState { label: 'Purchase Order *', value: _selectedPoId, searchHint: 'Search PO...', + isDense: true, options: poOptions, onChanged: (v) async { setState(() => _selectedPoId = v); @@ -408,16 +407,22 @@ class _GrnFormScreenState extends ConsumerState { v == null ? 'Purchase order is required' : null, ) else - InputDecorator( - decoration: const InputDecoration( - labelText: 'Purchase Order', + Padding( + padding: const EdgeInsets.only(top: 8), + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'Purchase Order', + floatingLabelBehavior: FloatingLabelBehavior.always, + isDense: true, + ), + child: Text(existing?.poNumber ?? '—'), ), - child: Text(existing?.poNumber ?? '—'), ), AppSearchableDropdown( label: 'Warehouse *', value: _dropdownValue(_warehouseId, warehouseIds), searchHint: 'Search warehouse...', + isDense: true, options: _intOptions(lookups.warehouses), onChanged: widget.isEditing ? (_) {} @@ -427,13 +432,10 @@ class _GrnFormScreenState extends ConsumerState { : (v) => v == null ? 'Warehouse is required' : null, enabled: !widget.isEditing, ), - ], - ), - FormRowThree( - children: [ AppTextField( label: 'Vendor Invoice No', controller: _vendorInvoiceNoController, + isDense: true, ), _DateField( label: 'Vendor Invoice Date', @@ -449,18 +451,17 @@ class _GrnFormScreenState extends ConsumerState { controller: _vendorInvoiceAmountController, keyboardType: const TextInputType.numberWithOptions(decimal: true), + isDense: true, ), - ], - ), - FormRowThree( - children: [ AppTextField( label: 'Vehicle No', controller: _vehicleNoController, + isDense: true, ), AppTextField( label: 'LR No', controller: _lrNoController, + isDense: true, ), _DateField( label: 'LR Date', @@ -470,13 +471,37 @@ class _GrnFormScreenState extends ConsumerState { onPicked: (d) => setState(() => _lrDate = d), ), ), + AppSearchableDropdown( + label: 'Received By', + value: _dropdownValue( + _receivedById, + lookups.users.map((e) => _parseId(e.id)).whereType(), + ), + searchHint: 'Search user...', + isDense: true, + options: _intOptions(lookups.users), + onChanged: (v) => setState(() => _receivedById = v), + ), + AppSearchableDropdown( + label: 'Quality Checked By', + value: _dropdownValue( + _qualityCheckedById, + lookups.users.map((e) => _parseId(e.id)).whereType(), + ), + searchHint: 'Search user...', + isDense: true, + options: _intOptions(lookups.users), + onChanged: (v) => + setState(() => _qualityCheckedById = v), + ), + AppTextField( + label: 'Remarks', + controller: _remarksController, + maxLines: 3, + isDense: true, + ), ], ), - AppTextField( - label: 'Remarks', - controller: _remarksController, - maxLines: 3, - ), if (!widget.isEditing) ...[ const SizedBox(height: 24), Text( @@ -488,8 +513,6 @@ class _GrnFormScreenState extends ConsumerState { const SizedBox(height: 12), GrnLineItemsEditor( items: _lines, - assetCategoryOptions: - _assetCategoryOptions(lookups.assetCategories), onChanged: () => setState(() {}), ), ] else ...[ @@ -539,22 +562,27 @@ class _DateField extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( - onTap: enabled ? onTap : null, - borderRadius: BorderRadius.circular(8), - child: InputDecorator( - decoration: InputDecoration( - labelText: label, - suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18), - enabled: enabled, - ), - child: Text( - value != null ? DateFormatter.displayDate(value) : 'Select date', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: value != null - ? null - : Theme.of(context).hintColor, - ), + return Padding( + padding: const EdgeInsets.only(top: 8), + child: InkWell( + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(8), + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + floatingLabelBehavior: FloatingLabelBehavior.always, + isDense: true, + suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18), + enabled: enabled, + ), + child: Text( + value != null ? DateFormatter.displayDate(value) : 'Select date', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: value != null + ? null + : Theme.of(context).hintColor, + ), + ), ), ), ); diff --git a/lib/modules/grn/presentation/screens/grn_list_screen.dart b/lib/modules/grn/presentation/screens/grn_list_screen.dart index 553aa2f..a61fc79 100644 --- a/lib/modules/grn/presentation/screens/grn_list_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_list_screen.dart @@ -15,7 +15,6 @@ import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; -import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/app_table_action_icon.dart'; @@ -165,12 +164,21 @@ class _FiltersBar extends StatelessWidget { @override Widget build(BuildContext context) { - final searchField = SizedBox( - width: wrapped ? double.infinity : null, - child: AppSearchField( - controller: searchController, - hint: 'Search GRN number, PO, vendor...', - onChanged: onSearch, + final searchField = Padding( + padding: const EdgeInsets.only(top: 8), + child: SizedBox( + width: wrapped ? double.infinity : null, + child: TextField( + controller: searchController, + onChanged: onSearch, + decoration: const InputDecoration( + labelText: 'Search', + hintText: 'Search GRN number, PO, vendor...', + prefixIcon: Icon(Icons.search, size: 20), + floatingLabelBehavior: FloatingLabelBehavior.always, + isDense: true, + ), + ), ), ); @@ -203,6 +211,7 @@ class _FiltersBar extends StatelessWidget { } return Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded(flex: 3, child: searchField), const SizedBox(width: 12), diff --git a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart index 4c24ce6..bb9b7e5 100644 --- a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart +++ b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart @@ -1,12 +1,20 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/theme/app_colors.dart'; +import '../../../../core/utils/formatters.dart'; +import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/purchase_order_model.dart'; +import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_dropdown.dart'; +import '../../../../shared/widgets/app_searchable_dropdown.dart'; +import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_text_field.dart'; +import '../../../assets/presentation/providers/asset_categories_provider.dart'; +import '../providers/grn_lookups_provider.dart'; String _formatQty(double value) { if (value % 1 == 0) return value.toInt().toString(); @@ -22,22 +30,27 @@ class GrnLineItemDraft { required this.receivedQty, required this.remainingQty, TextEditingController? acceptedQtyController, - TextEditingController? damagedQtyController, - TextEditingController? shortQtyController, - TextEditingController? excessQtyController, + TextEditingController? rejectedQtyController, + TextEditingController? rateController, TextEditingController? batchNoController, - TextEditingController? expiryDateController, + TextEditingController? storageLocationController, + TextEditingController? rejectionReasonController, TextEditingController? remarksController, + this.mfgDate, + this.expiryDate, this.assetCategoryId, + this.assetSubcategoryId, }) : acceptedQtyController = acceptedQtyController ?? TextEditingController( text: remainingQty > 0 ? _formatQty(remainingQty) : '', ), - damagedQtyController = damagedQtyController ?? TextEditingController(), - shortQtyController = shortQtyController ?? TextEditingController(), - excessQtyController = excessQtyController ?? TextEditingController(), + rejectedQtyController = rejectedQtyController ?? TextEditingController(), + rateController = rateController ?? TextEditingController(), batchNoController = batchNoController ?? TextEditingController(), - expiryDateController = expiryDateController ?? TextEditingController(), + storageLocationController = + storageLocationController ?? TextEditingController(), + rejectionReasonController = + rejectionReasonController ?? TextEditingController(), remarksController = remarksController ?? TextEditingController(); final String poItemId; @@ -47,27 +60,28 @@ class GrnLineItemDraft { final double receivedQty; final double remainingQty; final TextEditingController acceptedQtyController; - final TextEditingController damagedQtyController; - final TextEditingController shortQtyController; - final TextEditingController excessQtyController; + final TextEditingController rejectedQtyController; + final TextEditingController rateController; final TextEditingController batchNoController; - final TextEditingController expiryDateController; + final TextEditingController storageLocationController; + final TextEditingController rejectionReasonController; final TextEditingController remarksController; - String? assetCategoryId; + DateTime? mfgDate; + DateTime? expiryDate; + int? assetCategoryId; + int? assetSubcategoryId; double get acceptedQty => double.tryParse(acceptedQtyController.text.trim()) ?? 0; - double get damagedQty => double.tryParse(damagedQtyController.text.trim()) ?? 0; - double get shortQty => double.tryParse(shortQtyController.text.trim()) ?? 0; - double get excessQty => double.tryParse(excessQtyController.text.trim()) ?? 0; - double get currentQty => acceptedQty + damagedQty + shortQty + excessQty; + double get rejectedQty => double.tryParse(rejectedQtyController.text.trim()) ?? 0; + double get currentQty => acceptedQty + rejectedQty; void dispose() { acceptedQtyController.dispose(); - damagedQtyController.dispose(); - shortQtyController.dispose(); - excessQtyController.dispose(); + rejectedQtyController.dispose(); + rateController.dispose(); batchNoController.dispose(); - expiryDateController.dispose(); + storageLocationController.dispose(); + rejectionReasonController.dispose(); remarksController.dispose(); } @@ -78,15 +92,19 @@ class GrnLineItemDraft { 'line_no': lineNo, 'current_qty': currentQty, 'accepted_qty': acceptedQty, - if (damagedQty > 0) 'damaged_qty': damagedQty, - if (shortQty > 0) 'short_qty': shortQty, - if (excessQty > 0) 'excess_qty': excessQty, + if (rejectedQty > 0) 'rejected_qty': rejectedQty, + if (rejectionReasonController.text.trim().isNotEmpty) + 'rejection_reason': rejectionReasonController.text.trim(), + if (rateController.text.trim().isNotEmpty) + 'rate': double.tryParse(rateController.text.trim()), if (batchNoController.text.trim().isNotEmpty) 'batch_no': batchNoController.text.trim(), - if (expiryDateController.text.trim().isNotEmpty) - 'expiry_date': expiryDateController.text.trim(), - if (assetCategoryId != null && assetCategoryId!.isNotEmpty) - 'asset_category_id': int.tryParse(assetCategoryId!) ?? assetCategoryId, + if (mfgDate != null) 'mfg_date': DateFormatter.toApiDate(mfgDate!), + if (expiryDate != null) 'expiry_date': DateFormatter.toApiDate(expiryDate!), + if (storageLocationController.text.trim().isNotEmpty) + 'storage_location': storageLocationController.text.trim(), + if (assetCategoryId != null) 'asset_category_id': assetCategoryId, + if (assetSubcategoryId != null) 'asset_subcategory_id': assetSubcategoryId, if (remarksController.text.trim().isNotEmpty) 'remarks': remarksController.text.trim(), }; @@ -98,6 +116,7 @@ List draftsFromPurchaseOrder(PurchaseOrderModel po) { final ordered = item.orderedQty ?? 0; final received = item.receivedQty ?? 0; final remaining = (ordered - received).clamp(0.0, double.infinity); + final rate = item.rate; return GrnLineItemDraft( poItemId: item.id, lineNo: item.lineNo ?? 1, @@ -105,6 +124,9 @@ List draftsFromPurchaseOrder(PurchaseOrderModel po) { orderedQty: ordered, receivedQty: received, remainingQty: remaining, + rateController: TextEditingController( + text: rate != null ? _formatQty(rate) : '', + ), ); }).toList(); } @@ -114,13 +136,11 @@ class GrnLineItemsEditor extends StatelessWidget { super.key, required this.items, required this.onChanged, - this.assetCategoryOptions = const [], this.readOnly = false, }); final List items; final VoidCallback onChanged; - final List> assetCategoryOptions; final bool readOnly; @override @@ -152,7 +172,6 @@ class GrnLineItemsEditor extends StatelessWidget { padding: EdgeInsets.only(bottom: i == items.length - 1 ? 0 : 12), child: _GrnLineItemCard( item: items[i], - assetCategoryOptions: assetCategoryOptions, readOnly: readOnly, onChanged: onChanged, ), @@ -162,25 +181,80 @@ class GrnLineItemsEditor extends StatelessWidget { } } -class _GrnLineItemCard extends StatelessWidget { +class _GrnLineItemCard extends ConsumerWidget { const _GrnLineItemCard({ required this.item, required this.onChanged, - required this.assetCategoryOptions, required this.readOnly, }); final GrnLineItemDraft item; final VoidCallback onChanged; - final List> assetCategoryOptions; final bool readOnly; static final _qtyFormatters = [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,4}')), ]; + int? _dropdownValue(int? selected, Iterable validIds) { + if (selected == null) return null; + return validIds.contains(selected) ? selected : null; + } + + List> _intOptions(List options) { + return options + .map((e) { + final id = int.tryParse(e.id); + if (id == null) return null; + return AppDropdownOption(value: id, label: e.name); + }) + .whereType>() + .toList(); + } + + List> _categoryOptions( + List categories, + ) { + return categories + .map((c) { + final id = int.tryParse(c.id); + if (id == null) return null; + return AppDropdownOption(value: id, label: c.name); + }) + .whereType>() + .toList(); + } + + Future _pickDate( + BuildContext context, { + required DateTime? current, + required ValueChanged onPicked, + }) async { + final picked = await showDatePicker( + context: context, + initialDate: current ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime(2100), + ); + if (picked != null) onPicked(picked); + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final categoryId = item.assetCategoryId; + final categoriesAsync = ref.watch(assetCategoriesProvider); + final categoryOptions = categoriesAsync.maybeWhen( + data: _categoryOptions, + orElse: () => const >[], + ); + final categoryIds = categoryOptions.map((e) => e.value); + final subcategoriesAsync = ref.watch(grnAssetSubcategoriesProvider(categoryId)); + final subcategoryOptions = subcategoriesAsync.maybeWhen( + data: _intOptions, + orElse: () => const >[], + ); + final subcategoryIds = subcategoryOptions.map((e) => e.value); + return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -218,94 +292,20 @@ class _GrnLineItemCard extends StatelessWidget { ), ), if (!readOnly) ...[ - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: AppTextField( - label: 'Accepted Qty *', - controller: item.acceptedQtyController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: _qtyFormatters, - onChanged: (_) => onChanged(), - ), - ), - const SizedBox(width: 12), - Expanded( - child: AppTextField( - label: 'Damaged Qty', - controller: item.damagedQtyController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: _qtyFormatters, - onChanged: (_) => onChanged(), - ), - ), - const SizedBox(width: 12), - Expanded( - child: AppTextField( - label: 'Short Qty', - controller: item.shortQtyController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: _qtyFormatters, - onChanged: (_) => onChanged(), - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: AppTextField( - label: 'Excess Qty', - controller: item.excessQtyController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: _qtyFormatters, - onChanged: (_) => onChanged(), - ), - ), - const SizedBox(width: 12), - Expanded( - child: AppTextField( - label: 'Batch No', - controller: item.batchNoController, - onChanged: (_) => onChanged(), - ), - ), - const SizedBox(width: 12), - Expanded( - child: AppTextField( - label: 'Expiry Date', - controller: item.expiryDateController, - hint: 'YYYY-MM-DD', - onChanged: (_) => onChanged(), - ), - ), - ], - ), - if (assetCategoryOptions.isNotEmpty) ...[ - const SizedBox(height: 12), - AppDropdown( - label: 'Asset Category', - value: item.assetCategoryId, - options: assetCategoryOptions, - onChanged: (v) { - item.assetCategoryId = v; - onChanged(); - }, + ResponsiveFormGrid( + children: _buildLineItemFields( + context: context, + categoryOptions: categoryOptions, + categoryIds: categoryIds, + subcategoryOptions: subcategoryOptions, + subcategoryIds: subcategoryIds, ), - ], - const SizedBox(height: 12), - AppTextField( - label: 'Remarks', - controller: item.remarksController, - maxLines: 2, - onChanged: (_) => onChanged(), ), ] else ...[ const SizedBox(height: 8), Text( 'Accepted: ${_formatQty(item.acceptedQty)} · ' + 'Rejected: ${_formatQty(item.rejectedQty)} · ' 'Current: ${_formatQty(item.currentQty)}', style: Theme.of(context).textTheme.bodySmall, ), @@ -314,6 +314,160 @@ class _GrnLineItemCard extends StatelessWidget { ), ); } + + List _buildLineItemFields({ + required BuildContext context, + required List> categoryOptions, + required Iterable categoryIds, + required List> subcategoryOptions, + required Iterable subcategoryIds, + }) { + final hasCategory = item.assetCategoryId != null; + return [ + AppTextField( + label: 'Accepted Qty *', + controller: item.acceptedQtyController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: _qtyFormatters, + isDense: true, + onChanged: (_) => onChanged(), + ), + AppTextField( + label: 'Rejected Qty', + controller: item.rejectedQtyController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: _qtyFormatters, + isDense: true, + onChanged: (_) => onChanged(), + ), + AppTextField( + label: 'Rate', + controller: item.rateController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: _qtyFormatters, + isDense: true, + onChanged: (_) => onChanged(), + ), + AppTextField( + label: 'Batch No', + controller: item.batchNoController, + isDense: true, + onChanged: (_) => onChanged(), + ), + _GrnLineDateField( + label: 'Mfg Date', + value: item.mfgDate, + onTap: () => _pickDate( + context, + current: item.mfgDate, + onPicked: (date) { + item.mfgDate = date; + onChanged(); + }, + ), + ), + _GrnLineDateField( + label: 'Expiry Date', + value: item.expiryDate, + onTap: () => _pickDate( + context, + current: item.expiryDate, + onPicked: (date) { + item.expiryDate = date; + onChanged(); + }, + ), + ), + AppTextField( + label: 'Storage Location', + controller: item.storageLocationController, + isDense: true, + onChanged: (_) => onChanged(), + ), + AppTextField( + label: 'Rejection Reason', + controller: item.rejectionReasonController, + isDense: true, + onChanged: (_) => onChanged(), + ), + AppSearchableDropdown( + key: ValueKey('line_${item.lineNo}_asset_category'), + label: 'Asset Category', + value: _dropdownValue(item.assetCategoryId, categoryIds), + searchHint: 'Search asset category...', + isDense: true, + enabled: categoryOptions.isNotEmpty, + options: categoryOptions, + onChanged: (v) { + item.assetCategoryId = v; + item.assetSubcategoryId = null; + onChanged(); + }, + ), + AppSearchableDropdown( + key: ValueKey('line_${item.lineNo}_asset_subcategory'), + label: 'Asset Subcategory', + value: _dropdownValue(item.assetSubcategoryId, subcategoryIds), + searchHint: 'Search subcategory...', + isDense: true, + enabled: hasCategory && subcategoryOptions.isNotEmpty, + hint: !hasCategory + ? 'Select category first' + : subcategoryOptions.isEmpty + ? 'No subcategories found' + : null, + options: subcategoryOptions, + onChanged: (v) { + item.assetSubcategoryId = v; + onChanged(); + }, + ), + AppTextField( + label: 'Remarks', + controller: item.remarksController, + isDense: true, + maxLines: 2, + onChanged: (_) => onChanged(), + ), + ]; + } +} + +class _GrnLineDateField extends StatelessWidget { + const _GrnLineDateField({ + required this.label, + required this.value, + required this.onTap, + }); + + final String label; + final DateTime? value; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + floatingLabelBehavior: FloatingLabelBehavior.always, + isDense: true, + suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18), + ), + child: Text( + value != null ? DateFormatter.displayDate(value) : 'Select date', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: value != null ? null : Theme.of(context).hintColor, + ), + ), + ), + ), + ); + } } class GrnItemsTable extends StatelessWidget { @@ -344,25 +498,47 @@ class GrnItemsTable extends StatelessWidget { cellBuilder: (_, item) => Text(_formatQty(item.acceptedQty ?? 0)), ), AppDataColumn( - label: 'Damaged', + label: 'Rejected', flex: 1, - cellBuilder: (_, item) => Text(_formatQty(item.damagedQty ?? 0)), + cellBuilder: (_, item) => Text(_formatQty(item.rejectedQty ?? 0)), ), AppDataColumn( - label: 'Short', + label: 'Rate', flex: 1, - cellBuilder: (_, item) => Text(_formatQty(item.shortQty ?? 0)), - ), - AppDataColumn( - label: 'Excess', - flex: 1, - cellBuilder: (_, item) => Text(_formatQty(item.excessQty ?? 0)), + cellBuilder: (_, item) => Text( + item.rate != null ? _formatQty(item.rate!) : '—', + ), ), AppDataColumn( label: 'Batch', flex: 1, cellBuilder: (_, item) => Text(item.batchNo ?? '—'), ), + AppDataColumn( + label: 'Mfg Date', + flex: 1, + cellBuilder: (_, item) => Text(DateFormatter.displayDate(item.mfgDate)), + ), + AppDataColumn( + label: 'Expiry', + flex: 1, + cellBuilder: (_, item) => Text(DateFormatter.displayDate(item.expiryDate)), + ), + AppDataColumn( + label: 'Storage', + flex: 1, + cellBuilder: (_, item) => Text(item.storageLocation ?? '—'), + ), + AppDataColumn( + label: 'Rejection Reason', + flex: 2, + cellBuilder: (_, item) => Text(item.rejectionReason ?? '—'), + ), + AppDataColumn( + label: 'Remarks', + flex: 2, + cellBuilder: (_, item) => Text(item.remarks ?? '—'), + ), ], rows: items, ); diff --git a/lib/shared/models/grn_model.dart b/lib/shared/models/grn_model.dart index 283304c..b5eb13b 100644 --- a/lib/shared/models/grn_model.dart +++ b/lib/shared/models/grn_model.dart @@ -151,6 +151,8 @@ class GrnItemModel with _$GrnItemModel { @JsonKey(name: 'storage_location') String? storageLocation, @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) int? assetCategoryId, + @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) + int? assetSubcategoryId, @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId, @JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName, String? remarks, diff --git a/lib/shared/models/grn_model.freezed.dart b/lib/shared/models/grn_model.freezed.dart index 6f339c9..68cc3c7 100644 --- a/lib/shared/models/grn_model.freezed.dart +++ b/lib/shared/models/grn_model.freezed.dart @@ -813,6 +813,8 @@ mixin _$GrnItemModel { String? get storageLocation => throw _privateConstructorUsedError; @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) int? get assetCategoryId => throw _privateConstructorUsedError; + @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) + int? get assetSubcategoryId => throw _privateConstructorUsedError; @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? get uomId => throw _privateConstructorUsedError; @JsonKey(name: 'uom_name', readValue: _readUomName) @@ -866,6 +868,8 @@ abstract class $GrnItemModelCopyWith<$Res> { @JsonKey(name: 'storage_location') String? storageLocation, @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) int? assetCategoryId, + @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) + int? assetSubcategoryId, @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId, @JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName, String? remarks, @@ -907,6 +911,7 @@ class _$GrnItemModelCopyWithImpl<$Res, $Val extends GrnItemModel> Object? expiryDate = freezed, Object? storageLocation = freezed, Object? assetCategoryId = freezed, + Object? assetSubcategoryId = freezed, Object? uomId = freezed, Object? uomName = freezed, Object? remarks = freezed, @@ -993,6 +998,10 @@ class _$GrnItemModelCopyWithImpl<$Res, $Val extends GrnItemModel> ? _value.assetCategoryId : assetCategoryId // ignore: cast_nullable_to_non_nullable as int?, + assetSubcategoryId: freezed == assetSubcategoryId + ? _value.assetSubcategoryId + : assetSubcategoryId // ignore: cast_nullable_to_non_nullable + as int?, uomId: freezed == uomId ? _value.uomId : uomId // ignore: cast_nullable_to_non_nullable @@ -1050,6 +1059,8 @@ abstract class _$$GrnItemModelImplCopyWith<$Res> @JsonKey(name: 'storage_location') String? storageLocation, @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) int? assetCategoryId, + @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) + int? assetSubcategoryId, @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId, @JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName, String? remarks, @@ -1090,6 +1101,7 @@ class __$$GrnItemModelImplCopyWithImpl<$Res> Object? expiryDate = freezed, Object? storageLocation = freezed, Object? assetCategoryId = freezed, + Object? assetSubcategoryId = freezed, Object? uomId = freezed, Object? uomName = freezed, Object? remarks = freezed, @@ -1176,6 +1188,10 @@ class __$$GrnItemModelImplCopyWithImpl<$Res> ? _value.assetCategoryId : assetCategoryId // ignore: cast_nullable_to_non_nullable as int?, + assetSubcategoryId: freezed == assetSubcategoryId + ? _value.assetSubcategoryId + : assetSubcategoryId // ignore: cast_nullable_to_non_nullable + as int?, uomId: freezed == uomId ? _value.uomId : uomId // ignore: cast_nullable_to_non_nullable @@ -1225,6 +1241,8 @@ class _$GrnItemModelImpl implements _GrnItemModel { @JsonKey(name: 'storage_location') this.storageLocation, @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) this.assetCategoryId, + @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) + this.assetSubcategoryId, @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) this.uomId, @JsonKey(name: 'uom_name', readValue: _readUomName) this.uomName, this.remarks, @@ -1294,6 +1312,9 @@ class _$GrnItemModelImpl implements _GrnItemModel { @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) final int? assetCategoryId; @override + @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) + final int? assetSubcategoryId; + @override @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) final int? uomId; @override @@ -1304,7 +1325,7 @@ class _$GrnItemModelImpl implements _GrnItemModel { @override String toString() { - return 'GrnItemModel(id: $id, grnId: $grnId, poItemId: $poItemId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, currentQty: $currentQty, acceptedQty: $acceptedQty, damagedQty: $damagedQty, shortQty: $shortQty, excessQty: $excessQty, rejectedQty: $rejectedQty, rejectionReason: $rejectionReason, rate: $rate, batchNo: $batchNo, mfgDate: $mfgDate, expiryDate: $expiryDate, storageLocation: $storageLocation, assetCategoryId: $assetCategoryId, uomId: $uomId, uomName: $uomName, remarks: $remarks)'; + return 'GrnItemModel(id: $id, grnId: $grnId, poItemId: $poItemId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, currentQty: $currentQty, acceptedQty: $acceptedQty, damagedQty: $damagedQty, shortQty: $shortQty, excessQty: $excessQty, rejectedQty: $rejectedQty, rejectionReason: $rejectionReason, rate: $rate, batchNo: $batchNo, mfgDate: $mfgDate, expiryDate: $expiryDate, storageLocation: $storageLocation, assetCategoryId: $assetCategoryId, assetSubcategoryId: $assetSubcategoryId, uomId: $uomId, uomName: $uomName, remarks: $remarks)'; } @override @@ -1345,6 +1366,8 @@ class _$GrnItemModelImpl implements _GrnItemModel { other.storageLocation == storageLocation) && (identical(other.assetCategoryId, assetCategoryId) || other.assetCategoryId == assetCategoryId) && + (identical(other.assetSubcategoryId, assetSubcategoryId) || + other.assetSubcategoryId == assetSubcategoryId) && (identical(other.uomId, uomId) || other.uomId == uomId) && (identical(other.uomName, uomName) || other.uomName == uomName) && (identical(other.remarks, remarks) || other.remarks == remarks)); @@ -1374,6 +1397,7 @@ class _$GrnItemModelImpl implements _GrnItemModel { expiryDate, storageLocation, assetCategoryId, + assetSubcategoryId, uomId, uomName, remarks, @@ -1427,6 +1451,8 @@ abstract class _GrnItemModel implements GrnItemModel { @JsonKey(name: 'storage_location') final String? storageLocation, @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) final int? assetCategoryId, + @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) + final int? assetSubcategoryId, @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) final int? uomId, @JsonKey(name: 'uom_name', readValue: _readUomName) final String? uomName, final String? remarks, @@ -1496,6 +1522,9 @@ abstract class _GrnItemModel implements GrnItemModel { @JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable) int? get assetCategoryId; @override + @JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable) + int? get assetSubcategoryId; + @override @JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? get uomId; @override diff --git a/lib/shared/models/grn_model.g.dart b/lib/shared/models/grn_model.g.dart index 7818157..ae2fe69 100644 --- a/lib/shared/models/grn_model.g.dart +++ b/lib/shared/models/grn_model.g.dart @@ -88,6 +88,7 @@ _$GrnItemModelImpl _$$GrnItemModelImplFromJson(Map json) => expiryDate: _dateFromJsonNullable(json['expiry_date']), storageLocation: json['storage_location'] as String?, assetCategoryId: _intFromJsonNullable(json['asset_category_id']), + assetSubcategoryId: _intFromJsonNullable(json['asset_subcategory_id']), uomId: _intFromJsonNullable(json['uom_id']), uomName: _readUomName(json, 'uom_name') as String?, remarks: json['remarks'] as String?, @@ -115,6 +116,7 @@ Map _$$GrnItemModelImplToJson(_$GrnItemModelImpl instance) => 'expiry_date': instance.expiryDate?.toIso8601String(), 'storage_location': instance.storageLocation, 'asset_category_id': instance.assetCategoryId, + 'asset_subcategory_id': instance.assetSubcategoryId, 'uom_id': instance.uomId, 'uom_name': instance.uomName, 'remarks': instance.remarks, diff --git a/lib/shared/widgets/app_searchable_dropdown.dart b/lib/shared/widgets/app_searchable_dropdown.dart index 29ad613..e5ff9ba 100644 --- a/lib/shared/widgets/app_searchable_dropdown.dart +++ b/lib/shared/widgets/app_searchable_dropdown.dart @@ -35,6 +35,7 @@ class AppSearchableDropdown extends StatefulWidget { class _AppSearchableDropdownState extends State> { final _layerLink = LayerLink(); final _fieldKey = GlobalKey(); + final _formFieldKey = UniqueKey(); OverlayEntry? _overlayEntry; bool _ignoreOutsideTap = false; @@ -166,7 +167,7 @@ class _AppSearchableDropdownState extends State> { final theme = Theme.of(context); return FormField( - key: ValueKey(widget.value), + key: widget.key ?? _formFieldKey, initialValue: widget.value, validator: widget.validator, builder: (field) { @@ -206,16 +207,16 @@ class _AppSearchableDropdownState extends State> { ), enabled: canOpen, ), - child: displayLabel == null - ? const SizedBox.shrink() - : Text( - displayLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyLarge?.copyWith( - color: colors.onSurface, - ), - ), + child: Text( + displayLabel ?? '\u00A0', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + color: displayLabel == null + ? Colors.transparent + : colors.onSurface, + ), + ), ), ), ), diff --git a/lib/shared/widgets/app_side_panel.dart b/lib/shared/widgets/app_side_panel.dart index 8815c05..46394e2 100644 --- a/lib/shared/widgets/app_side_panel.dart +++ b/lib/shared/widgets/app_side_panel.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../core/utils/responsive_utils.dart'; + Future showSidePanel( BuildContext context, Widget panel, { @@ -266,6 +268,92 @@ class FormRowFour extends StatelessWidget { } } +/// Responsive form grid: 4 cols (medium+), 2 cols (small). +class ResponsiveFormGrid extends StatelessWidget { + const ResponsiveFormGrid({ + super.key, + required this.children, + this.fullWidthChildren = const [], + this.spacing = 12, + this.smallColumns = 2, + this.mediumColumns = 4, + this.largeColumns = 4, + this.mediumBreakpoint = AppBreakpoints.tablet, + this.largeBreakpoint = AppBreakpoints.desktop, + }); + + final List children; + final List fullWidthChildren; + final double spacing; + final int smallColumns; + final int mediumColumns; + final int largeColumns; + final double mediumBreakpoint; + final double largeBreakpoint; + + List> _chunk(List items, int columns) { + if (items.isEmpty) return const []; + final rows = >[]; + for (var i = 0; i < items.length; i += columns) { + final end = (i + columns < items.length) ? i + columns : items.length; + rows.add(items.sublist(i, end)); + } + return rows; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth.isFinite && constraints.maxWidth > 0 + ? constraints.maxWidth + : MediaQuery.sizeOf(context).width; + final columns = formGridColumnsForWidth( + width, + smallColumns: smallColumns, + mediumColumns: mediumColumns, + largeColumns: largeColumns, + mediumBreakpoint: mediumBreakpoint, + largeBreakpoint: largeBreakpoint, + ); + final rows = _chunk(children, columns); + final columnWidth = (width - (columns - 1) * spacing) / columns; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final row in rows) + Padding( + padding: EdgeInsets.only(bottom: spacing), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var col = 0; col < columns; col++) ...[ + if (col > 0) SizedBox(width: spacing), + SizedBox( + width: columnWidth, + child: col < row.length + ? row[col] + : const SizedBox.shrink(), + ), + ], + ], + ), + ), + for (var i = 0; i < fullWidthChildren.length; i++) + Padding( + padding: EdgeInsets.only( + bottom: i == fullWidthChildren.length - 1 ? 0 : spacing, + ), + child: fullWidthChildren[i], + ), + ], + ); + }, + ); + } +} + /// Responsive row of equal-width form fields. class FormRow extends StatelessWidget { const FormRow({