From 6721b308a9d65ac3474c80583f0dabc62ffbe498 Mon Sep 17 00:00:00 2001 From: SurendarSuri30 Date: Fri, 17 Jul 2026 11:07:08 +0530 Subject: [PATCH] po changes done --- lib/core/constants/api_endpoints.dart | 7 + lib/core/constants/route_constants.dart | 2 + .../widgets/asset_side_panels.dart | 32 +-- .../datasources/grn_remote_data_source.dart | 2 +- .../providers/grn_lookups_provider.dart | 14 +- .../presentation/providers/grn_provider.dart | 6 + .../screens/grn_detail_screen.dart | 15 +- .../presentation/screens/grn_form_screen.dart | 78 ++++--- .../presentation/screens/grn_list_screen.dart | 32 +++ .../domain/entities/master_definition.dart | 29 +++ .../widgets/master_quick_add.dart | 1 + .../master_remote_data_source.dart | 34 +++ .../purchase_order_remote_data_source.dart | 11 + .../purchase_order_repository_impl.dart | 8 + .../purchase_order_repository.dart | 2 + .../purchase_order_lookups_provider.dart | 11 + .../providers/purchase_orders_provider.dart | 96 ++++++++ .../screens/purchase_order_detail_screen.dart | 127 +++++------ .../screens/purchase_order_form_screen.dart | 17 +- .../screens/purchase_order_list_screen.dart | 209 ++++++++++++++---- .../screens/vendor_detail_screen.dart | 206 ++++++++++------- .../widgets/vendor_sub_resource_panels.dart | 52 ++++- lib/shared/models/asset_model.dart | 1 - lib/shared/models/asset_model.freezed.dart | 31 +-- lib/shared/models/asset_model.g.dart | 2 - lib/shared/models/grn_model.dart | 27 ++- lib/shared/models/grn_model.freezed.dart | 169 ++++++++------ lib/shared/models/grn_model.g.dart | 10 +- lib/shared/routes/app_router.dart | 7 + lib/shared/routes/menu_config.dart | 51 ++++- lib/shared/widgets/app_sidebar.dart | 29 ++- 31 files changed, 918 insertions(+), 400 deletions(-) diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index ee20da8..ab5013e 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -65,6 +65,11 @@ class ApiEndpoints { static String deliveryTermById(String id) => '/masters/delivery-terms/$id'; static const String paymentTerms = '/masters/payment-terms'; static String paymentTermById(String id) => '/masters/payment-terms/$id'; + static const String termsNotes = '/masters/terms-notes'; + static String termsNoteById(String id) => '/masters/terms-notes/$id'; + static const String termsNotesTypes = '/masters/terms-notes/types'; + static String termsNotesByType(String type) => + '/masters/terms-notes/by-type/$type'; static const String gstRates = '/masters/gst-rates'; static String gstRateById(String id) => '/masters/gst-rates/$id'; static const String hsnCodes = '/masters/hsn-codes'; @@ -95,6 +100,8 @@ class ApiEndpoints { // Purchase Orders static const String purchaseOrders = '/purchase-orders'; + static const String purchaseOrdersPendingApproval = + '/purchase-orders/pending-approval'; static const String purchaseOrdersExport = '/purchase-orders/export'; static String purchaseOrderById(String id) => '/purchase-orders/$id'; static String purchaseOrderSubmit(String id) => '/purchase-orders/$id/submit'; diff --git a/lib/core/constants/route_constants.dart b/lib/core/constants/route_constants.dart index a43b1eb..afcc1f1 100644 --- a/lib/core/constants/route_constants.dart +++ b/lib/core/constants/route_constants.dart @@ -47,6 +47,8 @@ class RouteConstants { // Purchase Orders static const String purchaseOrders = '/purchase-orders'; + static const String purchaseOrdersPendingApproval = + '/purchase-orders/pending-approval'; static const String purchaseOrderAdd = '/purchase-orders/add'; static const String purchaseOrderEdit = '/purchase-orders/:id/edit'; static const String purchaseOrderDetail = '/purchase-orders/:id'; diff --git a/lib/modules/assets/presentation/widgets/asset_side_panels.dart b/lib/modules/assets/presentation/widgets/asset_side_panels.dart index ebc2a79..192879a 100644 --- a/lib/modules/assets/presentation/widgets/asset_side_panels.dart +++ b/lib/modules/assets/presentation/widgets/asset_side_panels.dart @@ -444,7 +444,6 @@ class LogServiceVisitPanel extends ConsumerStatefulWidget { class _LogServiceVisitPanelState extends ConsumerState { final _formKey = GlobalKey(); - final _visitNumberController = TextEditingController(); final _complaintNoController = TextEditingController(); final _complaintDescController = TextEditingController(); final _engineerNameController = TextEditingController(); @@ -468,7 +467,6 @@ class _LogServiceVisitPanelState extends ConsumerState { @override void dispose() { - _visitNumberController.dispose(); _complaintNoController.dispose(); _complaintDescController.dispose(); _engineerNameController.dispose(); @@ -488,9 +486,6 @@ class _LogServiceVisitPanelState extends ConsumerState { _visitType = visit.visitType; _visitDate = visit.visitDate; _amcContractId = visit.amcContractId; - if (visit.visitNumber != null) { - _visitNumberController.text = visit.visitNumber.toString(); - } _complaintNoController.text = visit.complaintNo ?? ''; _complaintDate = visit.complaintDate; _complaintDescController.text = visit.complaintDesc ?? ''; @@ -529,14 +524,12 @@ class _LogServiceVisitPanelState extends ConsumerState { } Map _buildPayload() { - final visitNumber = int.tryParse(_visitNumberController.text.trim()); final downtimeHours = double.tryParse(_downtimeHoursController.text.trim()); final serviceCost = double.tryParse(_serviceCostController.text.trim()); return { if (_visitType != null && _visitType!.trim().isNotEmpty) 'visit_type': _visitType, 'visit_date': DateFormatter.toApiDate(_visitDate!), if (_amcContractId != null) 'amc_contract_id': _amcContractId, - if (visitNumber != null) 'visit_number': visitNumber, if (_complaintNoController.text.trim().isNotEmpty) 'complaint_no': _complaintNoController.text.trim(), if (_complaintDate != null) @@ -654,24 +647,13 @@ class _LogServiceVisitPanelState extends ConsumerState { ), ), const SizedBox(height: 12), - SidePanelFormRow( - left: _SidePanelDateField( - label: 'Visit Date', - isRequired: true, - value: _visitDate, - onPick: () => _pickDate( - current: _visitDate, - onPicked: (date) => setState(() => _visitDate = date), - ), - ), - right: AppTextField( - controller: _visitNumberController, - label: 'Visit Number', - keyboardType: TextInputType.number, - validator: (v) => Validators.optionalPositiveInt( - v, - fieldName: 'Visit Number', - ), + _SidePanelDateField( + label: 'Visit Date', + isRequired: true, + value: _visitDate, + onPick: () => _pickDate( + current: _visitDate, + onPicked: (date) => setState(() => _visitDate = date), ), ), const SizedBox(height: 12), diff --git a/lib/modules/grn/data/datasources/grn_remote_data_source.dart b/lib/modules/grn/data/datasources/grn_remote_data_source.dart index 5162367..53ec9c6 100644 --- a/lib/modules/grn/data/datasources/grn_remote_data_source.dart +++ b/lib/modules/grn/data/datasources/grn_remote_data_source.dart @@ -150,7 +150,7 @@ class GrnRemoteDataSource { if (query.status != null) 'status': query.status, if (query.poId != null) 'po_id': query.poId, if (query.vendorId != null) 'vendor_id': query.vendorId, - if (query.warehouseId != null) 'warehouse_id': query.warehouseId, + if (query.locationId != null) 'location_id': query.locationId, if (query.dateFrom != null) 'date_from': query.dateFrom, if (query.dateTo != null) 'date_to': query.dateTo, }; diff --git a/lib/modules/grn/presentation/providers/grn_lookups_provider.dart b/lib/modules/grn/presentation/providers/grn_lookups_provider.dart index e3d71f6..6afaa0f 100644 --- a/lib/modules/grn/presentation/providers/grn_lookups_provider.dart +++ b/lib/modules/grn/presentation/providers/grn_lookups_provider.dart @@ -9,12 +9,13 @@ import '../../../users/data/repositories/user_repository_impl.dart'; class GrnLookups { const GrnLookups({ - this.warehouses = const [], + this.locations = const [], this.receivablePurchaseOrders = const [], this.users = const [], }); - final List warehouses; + /// Plants and warehouses (any location type) for list filters. + final List locations; final List receivablePurchaseOrders; final List users; } @@ -23,12 +24,7 @@ final grnLookupsProvider = FutureProvider.autoDispose((ref) async { final master = ref.watch(masterRemoteDataSourceProvider); final poRepo = ref.watch(purchaseOrderRepositoryProvider); - final warehouses = await _safeOptions(() async { - final locations = await master.listLocations(); - return locations - .where((location) => location.slug?.toLowerCase() == 'warehouse') - .toList(); - }); + final locations = await _safeOptions(master.listLocations); final users = await _safeUserOptions(ref); final receivablePos = []; @@ -40,7 +36,7 @@ final grnLookupsProvider = FutureProvider.autoDispose((ref) async { } return GrnLookups( - warehouses: warehouses, + locations: locations, receivablePurchaseOrders: receivablePos, users: users, ); diff --git a/lib/modules/grn/presentation/providers/grn_provider.dart b/lib/modules/grn/presentation/providers/grn_provider.dart index 7d210ac..7a852d7 100644 --- a/lib/modules/grn/presentation/providers/grn_provider.dart +++ b/lib/modules/grn/presentation/providers/grn_provider.dart @@ -109,6 +109,12 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier { applyQuery(current.query.copyWith(status: status, page: 1)); } + void setLocationFilter(int? locationId) { + final current = state.valueOrNull; + if (current == null) return; + applyQuery(current.query.copyWith(locationId: locationId, page: 1)); + } + void setPage(int page) { final current = state.valueOrNull; if (current == null) return; diff --git a/lib/modules/grn/presentation/screens/grn_detail_screen.dart b/lib/modules/grn/presentation/screens/grn_detail_screen.dart index b5dc22f..6ac1a22 100644 --- a/lib/modules/grn/presentation/screens/grn_detail_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_detail_screen.dart @@ -240,8 +240,8 @@ class _DetailHeader extends StatelessWidget { final subtitleParts = [ if (grn.poNumber?.trim().isNotEmpty == true) 'PO ${grn.poNumber!.trim()}', if (grn.vendorName?.trim().isNotEmpty == true) grn.vendorName!.trim(), - if (grn.warehouseName?.trim().isNotEmpty == true) - grn.warehouseName!.trim(), + if (grn.locationName?.trim().isNotEmpty == true) + grn.locationName!.trim(), ]; final actions = Wrap( @@ -533,8 +533,15 @@ class _ReceiptDetailsCard extends StatelessWidget { value: _displayOrDash(grn.vendorName), ), _DetailField( - label: 'Warehouse', - value: _displayOrDash(grn.warehouseName), + label: 'Location', + value: _displayOrDash( + [ + if (grn.locationName?.trim().isNotEmpty == true) + grn.locationName!.trim(), + if (grn.locationType?.trim().isNotEmpty == true) + '(${grn.locationType!.trim()})', + ].join(' ').trim(), + ), ), _DetailField( label: 'Vendor Invoice No', diff --git a/lib/modules/grn/presentation/screens/grn_form_screen.dart b/lib/modules/grn/presentation/screens/grn_form_screen.dart index fba0519..1750040 100644 --- a/lib/modules/grn/presentation/screens/grn_form_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_form_screen.dart @@ -20,7 +20,6 @@ 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'; @@ -51,7 +50,7 @@ class _GrnFormScreenState extends ConsumerState { DateTime? _vendorInvoiceDate; DateTime? _lrDate; String? _selectedPoId; - int? _warehouseId; + String? _locationLabel; int? _receivedById; int? _qualityCheckedById; final List _lines = []; @@ -90,7 +89,10 @@ class _GrnFormScreenState extends ConsumerState { setState(() { _grnDate = grn.grnDate ?? DateTime.now(); _selectedPoId = grn.poId?.toString(); - _warehouseId = grn.warehouseId; + _locationLabel = _formatLocationLabel( + name: grn.locationName, + type: grn.locationType, + ); _vendorInvoiceNoController.text = grn.vendorInvoiceNo ?? ''; _vendorInvoiceDate = grn.vendorInvoiceDate; _vendorInvoiceAmountController.text = @@ -112,12 +114,23 @@ class _GrnFormScreenState extends ConsumerState { _lines ..clear() ..addAll(draftsFromPurchaseOrder(po)); - if (_warehouseId == null && po.shippingId != null) { - _warehouseId = po.shippingId; - } + _locationLabel = _formatLocationLabel( + name: po.shippingName, + type: null, + ); }); } + String? _formatLocationLabel({String? name, String? type}) { + final trimmedName = name?.trim(); + if (trimmedName == null || trimmedName.isEmpty) return null; + final trimmedType = type?.trim(); + if (trimmedType == null || trimmedType.isEmpty) return trimmedName; + final typeLabel = trimmedType[0].toUpperCase() + + trimmedType.substring(1).toLowerCase(); + return '$trimmedName ($typeLabel)'; + } + int? _parseId(String value) => int.tryParse(value.trim()); int? _normalizeUserId(int? id) => id != null && id > 0 ? id : null; @@ -150,7 +163,6 @@ class _GrnFormScreenState extends ConsumerState { final payload = { 'grn_date': DateFormatter.toApiDate(_grnDate ?? DateTime.now()), 'po_id': poId, - 'warehouse_id': _warehouseId, if (_vendorInvoiceNoController.text.trim().isNotEmpty) 'vendor_invoice_no': _vendorInvoiceNoController.text.trim(), if (_vendorInvoiceDate != null) @@ -236,9 +248,9 @@ class _GrnFormScreenState extends ConsumerState { } if (!widget.isEditing) { - if (_selectedPoId == null || _warehouseId == null) { + if (_selectedPoId == null) { showAppToastFromSnackBar(context, - const SnackBar(content: Text('Please select PO and warehouse')), + const SnackBar(content: Text('Please select a purchase order')), ); return; } @@ -371,8 +383,6 @@ class _GrnFormScreenState extends ConsumerState { ); } - final warehouseIds = - lookups.warehouses.map((e) => _parseId(e.id)).whereType(); final userIds = lookups.users.map((e) => _parseId(e.id)).whereType(); final poOptions = lookups.receivablePurchaseOrders @@ -421,7 +431,10 @@ class _GrnFormScreenState extends ConsumerState { searchHint: 'Search PO...', options: poOptions, onChanged: (v) async { - setState(() => _selectedPoId = v); + setState(() { + _selectedPoId = v; + if (v == null) _locationLabel = null; + }); if (v == null) { for (final line in _lines) { line.dispose(); @@ -452,25 +465,17 @@ class _GrnFormScreenState extends ConsumerState { label: 'Purchase Order', value: existing?.poNumber ?? '—', ), - MasterQuickAddDropdown( - masterId: 'locations', - label: 'Warehouse *', - value: _dropdownValue(_warehouseId, warehouseIds), - hint: 'Select warehouse', - searchHint: 'Search warehouse...', - options: _intOptions(lookups.warehouses), - initialValues: const {'type': 'warehouse'}, - refreshLookups: () => - ref.invalidate(grnLookupsProvider), - parseCreatedId: int.tryParse, - onChanged: widget.isEditing - ? (_) {} - : (v) => setState(() => _warehouseId = v), - validator: widget.isEditing + _ReadOnlyField( + label: 'Location', + value: _locationLabel ?? + _formatLocationLabel( + name: existing?.locationName, + type: existing?.locationType, + ) ?? + '—', + hint: widget.isEditing ? null - : (v) => - v == null ? 'Warehouse is required' : null, - enabled: !widget.isEditing, + : 'From PO shipping location', ), AppTextField( label: 'Vendor Invoice No', @@ -768,21 +773,32 @@ class _ReadOnlyField extends StatelessWidget { const _ReadOnlyField({ required this.label, required this.value, + this.hint, }); final String label; final String value; + final String? hint; @override Widget build(BuildContext context) { + final theme = Theme.of(context); return Padding( padding: const EdgeInsets.only(top: 8), child: InputDecorator( decoration: InputDecoration( labelText: label, floatingLabelBehavior: FloatingLabelBehavior.always, + helperText: hint, + ), + child: Text( + value, + style: value == '—' + ? theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ) + : null, ), - child: Text(value), ), ); } diff --git a/lib/modules/grn/presentation/screens/grn_list_screen.dart b/lib/modules/grn/presentation/screens/grn_list_screen.dart index e56b407..34769c5 100644 --- a/lib/modules/grn/presentation/screens/grn_list_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_list_screen.dart @@ -8,6 +8,7 @@ import '../../../../core/errors/failure.dart'; import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/responsive_utils.dart'; import '../../../../shared/models/grn_model.dart'; +import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_data_table.dart'; @@ -23,6 +24,7 @@ import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/utils/file_download_helper.dart'; +import '../providers/grn_lookups_provider.dart'; import '../providers/grn_provider.dart'; import '../widgets/grn_status_chip.dart'; import '../../../../shared/widgets/app_toast.dart'; @@ -46,6 +48,7 @@ class _GrnListScreenState extends ConsumerState { @override Widget build(BuildContext context) { final listAsync = ref.watch(grnListProvider); + final lookupsAsync = ref.watch(grnLookupsProvider); final canEdit = ref.can('grn', PermissionAction.update); final canExport = ref.can('grn', PermissionAction.export); @@ -87,12 +90,15 @@ class _GrnListScreenState extends ConsumerState { toolbar: _FiltersBar( searchController: _searchController, query: state.query, + locationOptions: lookupsAsync.valueOrNull?.locations ?? const [], showExport: canExport, isExporting: state.isExporting, onExport: _exportGrns, onSearch: ref.read(grnListProvider.notifier).setSearch, onStatusChanged: ref.read(grnListProvider.notifier).setStatusFilter, + onLocationChanged: + ref.read(grnListProvider.notifier).setLocationFilter, ), footer: AppPagination( currentPage: state.query.page, @@ -187,8 +193,10 @@ class _FiltersBar extends StatelessWidget { const _FiltersBar({ required this.searchController, required this.query, + required this.locationOptions, required this.onSearch, required this.onStatusChanged, + required this.onLocationChanged, this.showExport = false, this.isExporting = false, this.onExport, @@ -196,8 +204,10 @@ class _FiltersBar extends StatelessWidget { final TextEditingController searchController; final GrnListQuery query; + final List locationOptions; final ValueChanged onSearch; final ValueChanged onStatusChanged; + final ValueChanged onLocationChanged; final bool showExport; final bool isExporting; final VoidCallback? onExport; @@ -233,6 +243,21 @@ class _FiltersBar extends StatelessWidget { ], onChanged: onStatusChanged, ), + AppSearchableDropdown( + label: 'Location', + value: query.locationId, + searchHint: 'Search location...', + isDense: true, + options: [ + const AppDropdownOption(value: null, label: 'All Locations'), + ...locationOptions.map((location) { + final id = int.tryParse(location.id); + if (id == null) return null; + return AppDropdownOption(value: id, label: location.name); + }).whereType>(), + ], + onChanged: onLocationChanged, + ), ]; return AppResponsiveFilterBar( @@ -295,6 +320,12 @@ class _GrnDataTable extends StatelessWidget { searchText: (grn) => grn.vendorName ?? '', cellBuilder: (_, grn) => Text(grn.vendorName ?? '—'), ), + AppDataColumn( + label: 'Location', + flex: 2, + searchText: (grn) => grn.locationName ?? '', + cellBuilder: (_, grn) => Text(grn.locationName ?? '—'), + ), AppDataColumn( label: 'Status', flex: 1, @@ -371,6 +402,7 @@ class _GrnCardList extends StatelessWidget { const SizedBox(height: 8), Text('PO: ${grn.poNumber ?? '—'}'), Text('Vendor: ${grn.vendorName ?? '—'}'), + Text('Location: ${grn.locationName ?? '—'}'), Text('Date: ${DateFormatter.displayDate(grn.grnDate)}'), const SizedBox(height: 8), Row( diff --git a/lib/modules/master_data/domain/entities/master_definition.dart b/lib/modules/master_data/domain/entities/master_definition.dart index 3a8730b..5985328 100644 --- a/lib/modules/master_data/domain/entities/master_definition.dart +++ b/lib/modules/master_data/domain/entities/master_definition.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; enum MasterFieldType { text, number, boolean, dropdown } const categoryTypeOptions = ['STOCK', 'ASSET']; +const termsNotesTypeOptions = ['PO', 'INVOICE']; class MasterFieldDef { const MasterFieldDef({ @@ -465,6 +466,34 @@ const masterDefinitions = [ _activeField, ], ), + MasterDefinition( + id: 'terms_notes', + title: 'Terms & Notes', + subtitle: 'Default terms by document type (PO, Invoice)', + category: 'Finance & Terms', + routeKey: 'terms-notes', + apiPath: '/masters/terms-notes', + module: 'terms_notes', + icon: Icons.notes_outlined, + fields: [ + MasterFieldDef( + key: 'type', + label: 'Document Type', + type: MasterFieldType.dropdown, + required: true, + showInList: true, + staticOptions: termsNotesTypeOptions, + ), + MasterFieldDef( + key: 'notes', + label: 'Notes', + required: true, + showInList: true, + multiline: true, + ), + _activeField, + ], + ), MasterDefinition( id: 'gst_rates', title: 'GST Rates', diff --git a/lib/modules/master_data/presentation/widgets/master_quick_add.dart b/lib/modules/master_data/presentation/widgets/master_quick_add.dart index 461b7a7..ad975a9 100644 --- a/lib/modules/master_data/presentation/widgets/master_quick_add.dart +++ b/lib/modules/master_data/presentation/widgets/master_quick_add.dart @@ -231,6 +231,7 @@ String masterQuickAddNoun(String masterId) { 'departments' => 'department', 'delivery_terms' => 'delivery term', 'payment_terms' => 'payment term', + 'terms_notes' => 'terms note', 'gst_rates' => 'GST rate', 'document_series' => 'document series', _ => masterId.replaceAll('_', ' '), diff --git a/lib/modules/masters/data/datasources/master_remote_data_source.dart b/lib/modules/masters/data/datasources/master_remote_data_source.dart index bb83541..cf1d094 100644 --- a/lib/modules/masters/data/datasources/master_remote_data_source.dart +++ b/lib/modules/masters/data/datasources/master_remote_data_source.dart @@ -103,6 +103,40 @@ class MasterRemoteDataSource { Future> listDeliveryTerms() => _listOptions(ApiEndpoints.deliveryTerms); + /// Active terms/notes text for a document type (`PO` | `INVOICE`). + Future getTermsNotesByType(String type) async { + final response = await dio.get(ApiEndpoints.termsNotesByType(type)); + final body = response.data; + final raw = body is Map ? body['data'] : null; + if (raw is! Map) return null; + final map = Map.from(raw); + if (!isActiveOptionRow(map)) return null; + final notes = map['notes']?.toString().trim(); + if (notes == null || notes.isEmpty) return null; + return notes; + } + + /// Document types supported by terms-notes master. + Future> listTermsNoteTypes() async { + final response = await dio.get(ApiEndpoints.termsNotesTypes); + final body = response.data; + final raw = body is Map ? body['data'] : null; + if (raw is! List) return const []; + return raw + .map((item) { + if (item is String) return item.trim(); + if (item is Map) { + final map = Map.from(item); + return (map['value'] ?? map['type'] ?? map['id'] ?? '') + .toString() + .trim(); + } + return ''; + }) + .where((value) => value.isNotEmpty) + .toList(); + } + Future> listWarehouses() => _listOptions(ApiEndpoints.warehouses); diff --git a/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart b/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart index d609489..c9ae1b5 100644 --- a/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart +++ b/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart @@ -22,6 +22,17 @@ class PurchaseOrderRemoteDataSource { return _parsePaginated(response.data, PurchaseOrderModel.fromJson); } + /// Approver-only list of POs with status PENDING_APPROVAL. + Future> getPendingApprovalPurchaseOrders( + PurchaseOrderListQuery query, + ) async { + final response = await dio.get( + ApiEndpoints.purchaseOrdersPendingApproval, + queryParameters: _queryToMap(query), + ); + return _parsePaginated(response.data, PurchaseOrderModel.fromJson); + } + /// Form-dropdown loader (`dropdown_call=true`). Optional status filter. Future> listPurchaseOrderOptions({ String? status, diff --git a/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart b/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart index 7fa8aa5..493bb1e 100644 --- a/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart +++ b/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart @@ -32,6 +32,14 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository { return safeApiCall(() => dataSource.getPurchaseOrders(query)); } + @override + Future>> + getPendingApprovalPurchaseOrders(PurchaseOrderListQuery query) { + return safeApiCall( + () => dataSource.getPendingApprovalPurchaseOrders(query), + ); + } + @override Future>> listPurchaseOrderOptions({ String? status, diff --git a/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart b/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart index 338e0e6..85e9f9f 100644 --- a/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart +++ b/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart @@ -8,6 +8,8 @@ abstract class PurchaseOrderRepository { Future>> getPurchaseOrders( PurchaseOrderListQuery query, ); + Future>> + getPendingApprovalPurchaseOrders(PurchaseOrderListQuery query); Future>> listPurchaseOrderOptions({ String? status, }); diff --git a/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart b/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart index b371f78..3e26848 100644 --- a/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart +++ b/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart @@ -6,6 +6,17 @@ import '../../../masters/data/datasources/master_remote_data_source.dart'; import '../../../vendors/data/repositories/vendor_repository_impl.dart'; import '../../../vendors/domain/repositories/vendor_repository.dart'; +/// Default terms text from active `terms_notes` where type = PO. +final poDefaultTermsNotesProvider = + FutureProvider.autoDispose((ref) async { + final master = ref.watch(masterRemoteDataSourceProvider); + try { + return await master.getTermsNotesByType('PO'); + } catch (_) { + return null; + } +}); + class PurchaseOrderLookups { const PurchaseOrderLookups({ this.vendors = const [], diff --git a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart index a174d5f..ab75db7 100644 --- a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart +++ b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart @@ -58,6 +58,12 @@ final purchaseOrdersListProvider = AsyncNotifierProvider.autoDispose< PurchaseOrdersListNotifier.new, ); +final pendingApprovalPurchaseOrdersListProvider = + AsyncNotifierProvider.autoDispose( + PendingApprovalPurchaseOrdersListNotifier.new, +); + class PurchaseOrdersListNotifier extends AutoDisposeAsyncNotifier { @override @@ -169,6 +175,89 @@ class PurchaseOrdersListNotifier } } +class PendingApprovalPurchaseOrdersListNotifier + extends AutoDisposeAsyncNotifier { + @override + Future build() async { + return _load(const PurchaseOrderListQuery(limit: 20)); + } + + Future _load(PurchaseOrderListQuery query) async { + final repository = ref.read(purchaseOrderRepositoryProvider); + final result = await repository.getPendingApprovalPurchaseOrders(query); + if (result.failure != null) throw result.failure!; + final page = result.data!; + return PurchaseOrdersListState( + orders: page.items, + query: query, + total: page.total, + totalPages: page.totalPages, + ); + } + + Future refresh() async { + final current = state.valueOrNull ?? const PurchaseOrdersListState(); + state = AsyncData(current.copyWith(isRefreshing: true, clearMessages: true)); + try { + state = AsyncData(await _load(current.query)); + } catch (e, st) { + state = AsyncError(e, st); + } + } + + Future applyQuery(PurchaseOrderListQuery query) async { + final previous = state.valueOrNull; + if (previous == null) { + state = const AsyncLoading(); + } + try { + state = AsyncData(await _load(query)); + } catch (e, st) { + state = AsyncError(e, st); + } + } + + void setSearch(String search) { + final current = state.valueOrNull; + if (current == null) return; + applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); + } + + void setPage(int page) { + final current = state.valueOrNull; + if (current == null) return; + applyQuery(current.query.copyWith(page: page)); + } + + void setPageSize(int limit) { + final current = state.valueOrNull; + if (current == null) return; + applyQuery(current.query.copyWith(limit: limit, page: 1)); + } + + Future approvePurchaseOrder(String id, {String? remarks}) async { + final repository = ref.read(purchaseOrderRepositoryProvider); + final result = await repository.approvePurchaseOrder(id, remarks: remarks); + if (result.failure != null) { + final current = state.valueOrNull; + if (current != null) { + state = AsyncData(current.copyWith(actionError: result.failure!.message)); + } + return false; + } + ref.invalidate(purchaseOrdersListProvider); + ref.invalidate(grnLookupsProvider); + await refresh(); + final current = state.valueOrNull; + if (current != null) { + state = AsyncData( + current.copyWith(actionSuccess: 'Purchase order approved'), + ); + } + return true; + } +} + final purchaseOrderDetailProvider = AsyncNotifierProvider.family< PurchaseOrderDetailNotifier, PurchaseOrderModel, String>( PurchaseOrderDetailNotifier.new, @@ -195,6 +284,7 @@ class PurchaseOrderDetailNotifier if (result.failure != null) throw result.failure!; state = AsyncData(result.data!); ref.invalidate(purchaseOrdersListProvider); + ref.invalidate(pendingApprovalPurchaseOrdersListProvider); ref.invalidate(grnLookupsProvider); return result.data!; } @@ -205,6 +295,7 @@ class PurchaseOrderDetailNotifier if (result.failure != null) throw result.failure!; state = AsyncData(result.data!); ref.invalidate(purchaseOrdersListProvider); + ref.invalidate(pendingApprovalPurchaseOrdersListProvider); ref.invalidate(grnLookupsProvider); return result.data!; } @@ -215,6 +306,7 @@ class PurchaseOrderDetailNotifier if (result.failure != null) throw result.failure!; state = AsyncData(result.data!); ref.invalidate(purchaseOrdersListProvider); + ref.invalidate(pendingApprovalPurchaseOrdersListProvider); ref.invalidate(grnLookupsProvider); return result.data!; } @@ -224,6 +316,7 @@ class PurchaseOrderDetailNotifier final result = await repository.amendPurchaseOrder(arg); if (result.failure != null) throw result.failure!; ref.invalidate(purchaseOrdersListProvider); + ref.invalidate(pendingApprovalPurchaseOrdersListProvider); ref.invalidate(grnLookupsProvider); return result.data!; } @@ -234,6 +327,7 @@ class PurchaseOrderDetailNotifier if (result.failure != null) throw result.failure!; state = AsyncData(result.data!); ref.invalidate(purchaseOrdersListProvider); + ref.invalidate(pendingApprovalPurchaseOrdersListProvider); ref.invalidate(grnLookupsProvider); return result.data!; } @@ -315,6 +409,7 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier context.go(RouteConstants.purchaseOrders), - onPdf: () => _downloadPdf(order), - onEdit: () => context.push( - '${RouteConstants.purchaseOrders}/${order.id}/edit', - ), - onSubmit: () => _submit(order), - onApprove: () => _approve(order), - onReject: () => _reject(order), - onAmend: () => _amend(order), - onCancel: () => _cancel(order), - onDelete: _delete, - ), - const SizedBox(height: 16), - _OrderDetailsCard(order: order, lookups: lookups), - const SizedBox(height: 16), - _LineItemsCard(order: order, lookups: lookups), - const SizedBox(height: 16), - LayoutBuilder( - builder: (context, constraints) { - final stack = constraints.maxWidth < 900; - final terms = _TermsRemarksColumn(order: order); - final summary = _AmountSummaryCard(order: order); - if (stack) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - terms, - const SizedBox(height: 16), - summary, - ], - ); - } - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(flex: 3, child: terms), - const SizedBox(width: 16), - Expanded(flex: 2, child: summary), - ], - ); - }, - ), - const SizedBox(height: 16), - _PoAttachmentsSection( - poId: order.id, - canUpload: canEdit, - canDelete: canDelete, - ), - const SizedBox(height: 20), - _DetailFooter(order: order), - ], + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DetailHeader( + order: order, + isWorking: _isWorking, + isDownloadingPdf: _isDownloadingPdf, + canEdit: canEdit, + canDelete: canDelete, + canApprove: canApprove, + canExport: canExport, + onBack: () => context.go(RouteConstants.purchaseOrders), + onPdf: () => _downloadPdf(order), + onEdit: () => context.push( + '${RouteConstants.purchaseOrders}/${order.id}/edit', + ), + onSubmit: () => _submit(order), + onApprove: () => _approve(order), + onReject: () => _reject(order), + onAmend: () => _amend(order), + onCancel: () => _cancel(order), + onDelete: _delete, ), - ), + const SizedBox(height: 16), + _OrderDetailsCard(order: order, lookups: lookups), + const SizedBox(height: 16), + _LineItemsCard(order: order, lookups: lookups), + const SizedBox(height: 16), + LayoutBuilder( + builder: (context, constraints) { + final stack = constraints.maxWidth < 900; + final terms = _TermsRemarksColumn(order: order); + final summary = _AmountSummaryCard(order: order); + if (stack) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + terms, + const SizedBox(height: 16), + summary, + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 3, child: terms), + const SizedBox(width: 16), + Expanded(flex: 2, child: summary), + ], + ); + }, + ), + const SizedBox(height: 16), + _PoAttachmentsSection( + poId: order.id, + canUpload: canEdit, + canDelete: canDelete, + ), + const SizedBox(height: 20), + _DetailFooter(order: order), + ], ), ); }, 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 6b94807..cdaa5ee 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 @@ -58,6 +58,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState _lines = []; bool _isSubmitting = false; String? _populatedSignature; + bool _defaultTermsApplied = false; @override void initState() { @@ -506,8 +507,9 @@ class _PurchaseOrderFormScreenState extends ConsumerState createState() => @@ -59,12 +64,23 @@ class _PurchaseOrderListScreenState extends ConsumerState? prev, + AsyncValue next, + ) { final error = next.valueOrNull?.actionError; final success = next.valueOrNull?.actionSuccess; if (error != null && error != prev?.valueOrNull?.actionError) { @@ -73,15 +89,27 @@ class _PurchaseOrderListScreenState extends ConsumerState const AppLoadingView(message: 'Loading purchase orders...'), + loading: () => AppLoadingView( + message: pendingOnly + ? 'Loading pending approvals...' + : 'Loading purchase orders...', + ), error: (error, _) => ErrorView.fromFailure( error is Failure ? error : Failure.unknown(message: error.toString()), - onRetry: () => ref.invalidate(purchaseOrdersListProvider), + onRetry: () => pendingOnly + ? ref.invalidate(pendingApprovalPurchaseOrdersListProvider) + : ref.invalidate(purchaseOrdersListProvider), ), data: (state) { _rememberStatuses(state.orders); @@ -89,18 +117,22 @@ class _PurchaseOrderListScreenState extends ConsumerState context.go(RouteConstants.purchaseOrderAdd), - icon: const Icon(Icons.add), - label: const Text('Create PO'), + if (!pendingOnly) + CanPermission( + module: 'purchase_orders', + action: PermissionAction.create, + child: ElevatedButton.icon( + onPressed: () => + context.go(RouteConstants.purchaseOrderAdd), + icon: const Icon(Icons.add), + label: const Text('Create PO'), + ), ), - ), ], ), Expanded( @@ -109,38 +141,72 @@ class _PurchaseOrderListScreenState extends ConsumerState - ref.read(purchaseOrdersListProvider.notifier).refresh(), + onRefresh: () => pendingOnly + ? ref + .read( + pendingApprovalPurchaseOrdersListProvider.notifier, + ) + .refresh() + : ref.read(purchaseOrdersListProvider.notifier).refresh(), child: state.orders.isEmpty ? ListView( physics: const AlwaysScrollableScrollPhysics(), - children: const [ + children: [ SizedBox( height: 240, child: AppEmptyState( - title: 'No purchase orders found', - description: - 'Try adjusting filters or create a new purchase order.', - icon: Icons.receipt_long_outlined, + title: pendingOnly + ? 'No pending approvals' + : 'No purchase orders found', + description: pendingOnly + ? 'There are no purchase orders waiting for approval.' + : 'Try adjusting filters or create a new purchase order.', + icon: pendingOnly + ? Icons.pending_actions_outlined + : Icons.receipt_long_outlined, ), ), ], @@ -151,12 +217,14 @@ class _PurchaseOrderListScreenState extends ConsumerState _approveOrder(PurchaseOrderModel order) async { + if (!order.canApprove) { + showAppToastFromSnackBar( + context, + const SnackBar(content: Text('This order cannot be approved')), + ); + return; + } + final confirmed = await showAppConfirmationDialog( + context: context, + title: 'Approve Purchase Order', + message: 'Approve ${order.poNo ?? order.id}?', + confirmLabel: 'Approve', + ); + if (confirmed != true || !mounted) return; + await ref + .read(pendingApprovalPurchaseOrdersListProvider.notifier) + .approvePurchaseOrder(order.id); + } + Future _deleteOrder(PurchaseOrderModel order) async { if (!order.canDelete) { showAppToastFromSnackBar(context, @@ -272,6 +360,7 @@ class _FiltersBar extends StatelessWidget { required this.statusOptions, required this.onSearch, required this.onStatusChanged, + this.showStatusFilter = true, this.showExport = false, this.isExporting = false, this.onExport, @@ -282,6 +371,7 @@ class _FiltersBar extends StatelessWidget { final List> statusOptions; final ValueChanged onSearch; final ValueChanged onStatusChanged; + final bool showStatusFilter; final bool showExport; final bool isExporting; final VoidCallback? onExport; @@ -294,15 +384,16 @@ class _FiltersBar extends StatelessWidget { onChanged: onSearch, ); - final filters = [ - AppSearchableDropdown( - label: 'Status', - value: query.status, - searchHint: 'Search status...', - isDense: true, - options: statusOptions, - onChanged: onStatusChanged, - ), + final filters = [ + if (showStatusFilter) + AppSearchableDropdown( + label: 'Status', + value: query.status, + searchHint: 'Search status...', + isDense: true, + options: statusOptions, + onChanged: onStatusChanged, + ), ]; return AppResponsiveFilterBar( @@ -331,15 +422,18 @@ class _PoDataTable extends StatelessWidget { required this.onView, this.onEdit, this.onDelete, + this.onApprove, }); final List orders; final ValueChanged onView; final ValueChanged? onEdit; final ValueChanged? onDelete; + final ValueChanged? onApprove; @override Widget build(BuildContext context) { + final theme = Theme.of(context); return AppDataTable( wrapInCard: false, columns: [ @@ -385,7 +479,7 @@ class _PoDataTable extends StatelessWidget { ), AppDataColumn( label: 'Actions', - flex: 1, + flex: onApprove != null ? 2 : 1, alignment: Alignment.centerRight, enableSearch: false, cellBuilder: (_, order) => AppTableActions( @@ -395,6 +489,13 @@ class _PoDataTable extends StatelessWidget { icon: Icons.visibility_outlined, onPressed: () => onView(order), ), + if (onApprove != null && order.canApprove) + AppTableActionIcon( + tooltip: 'Approve', + icon: Icons.check_circle_outline, + color: theme.colorScheme.primary, + onPressed: () => onApprove!(order), + ), if (onEdit != null && order.canEdit) AppTableActionIcon( tooltip: 'Edit', @@ -422,12 +523,14 @@ class _PoCardList extends StatelessWidget { required this.onView, this.onEdit, this.onDelete, + this.onApprove, }); final List orders; final ValueChanged onView; final ValueChanged? onEdit; final ValueChanged? onDelete; + final ValueChanged? onApprove; @override Widget build(BuildContext context) { @@ -442,7 +545,23 @@ class _PoCardList extends StatelessWidget { subtitle: Text( '${order.vendorName ?? '—'} · ${vendorTypeLabel(order.vendorType)}', ), - trailing: PoStatusChip(status: order.status, compact: true), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PoStatusChip(status: order.status, compact: true), + if (onApprove != null && order.canApprove) ...[ + const SizedBox(width: 4), + IconButton( + tooltip: 'Approve', + icon: Icon( + Icons.check_circle_outline, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () => onApprove!(order), + ), + ], + ], + ), onTap: () => onView(order), ), ); diff --git a/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart b/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart index 8f155ba..44e112e 100644 --- a/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart @@ -578,9 +578,11 @@ class _BankDetailsTab extends ConsumerWidget { alignment: Alignment.centerRight, child: ElevatedButton.icon( onPressed: () async { + final hasPrimary = bankDetails.any((b) => b.isPrimary && b.isActive); final saved = await openVendorBankDetailPanel( context, vendorId: vendorId, + hasPrimaryAccount: hasPrimary, ); if (saved == true && context.mounted) { showAppToastFromSnackBar(context, @@ -600,39 +602,103 @@ class _BankDetailsTab extends ConsumerWidget { description: 'Add bank accounts for payments.', icon: Icons.account_balance_outlined, ) - : GridView.builder( - padding: EdgeInsets.zero, - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 320, - mainAxisExtent: 175, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - ), - itemCount: bankDetails.length, - itemBuilder: (context, index) { - final detail = bankDetails[index]; - return _VendorBankDetailCard( - bankDetail: detail, - canEdit: canEdit, - onEdit: () => openVendorBankDetailPanel( - context, - vendorId: vendorId, - bankDetail: detail, - ), - onDelete: () => _deleteBankDetail( - context, - ref, - vendorId, - detail.id, - ), - ); + : RadioGroup( + groupValue: bankDetails + .where((b) => b.isPrimary && b.isActive) + .map((b) => b.id) + .firstOrNull, + onChanged: (selectedId) { + if (selectedId == null || !canEdit) return; + final detail = bankDetails + .where((b) => b.id == selectedId) + .firstOrNull; + if (detail == null || !detail.isActive || detail.isPrimary) { + return; + } + _setPrimaryBankDetail(context, ref, vendorId, detail); }, + child: GridView.builder( + padding: EdgeInsets.zero, + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 320, + mainAxisExtent: 190, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: bankDetails.length, + itemBuilder: (context, index) { + final detail = bankDetails[index]; + return _VendorBankDetailCard( + bankDetail: detail, + canEdit: canEdit, + onEdit: () => openVendorBankDetailPanel( + context, + vendorId: vendorId, + bankDetail: detail, + hasPrimaryAccount: bankDetails.any( + (b) => + b.isPrimary && + b.isActive && + b.id != detail.id, + ), + ), + showPrimaryRadio: canEdit && detail.isActive, + onSetPrimary: canEdit && detail.isActive + ? () => _setPrimaryBankDetail( + context, + ref, + vendorId, + detail, + ) + : null, + onDelete: () => _deleteBankDetail( + context, + ref, + vendorId, + detail.id, + ), + ); + }, + ), ), ), ], ); } + Future _setPrimaryBankDetail( + BuildContext context, + WidgetRef ref, + String vendorId, + VendorBankDetailModel detail, + ) async { + if (detail.isPrimary) return; + try { + await ref.read(vendorDetailProvider(vendorId).notifier).updateBankDetail( + detail.id, + { + 'bank_name': detail.bankName, + if (detail.branch != null && detail.branch!.trim().isNotEmpty) + 'branch': detail.branch, + if (detail.accountNumber != null && + detail.accountNumber!.trim().isNotEmpty) + 'account_number': detail.accountNumber, + if (detail.ifsc != null) 'ifsc': detail.ifsc, + if (detail.accountHolderName != null) + 'account_holder_name': detail.accountHolderName, + if (detail.accountType != null) 'account_type': detail.accountType, + 'is_primary': true, + 'is_active': detail.isActive, + }, + ); + } catch (e) { + if (context.mounted) { + showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString()))); + } + } + } + Future _deleteBankDetail( BuildContext context, WidgetRef ref, @@ -999,12 +1065,16 @@ class _VendorBankDetailCard extends StatelessWidget { required this.canEdit, required this.onEdit, required this.onDelete, + this.showPrimaryRadio = false, + this.onSetPrimary, }); final VendorBankDetailModel bankDetail; final bool canEdit; final VoidCallback onEdit; final VoidCallback onDelete; + final bool showPrimaryRadio; + final VoidCallback? onSetPrimary; @override Widget build(BuildContext context) { @@ -1070,13 +1140,13 @@ class _VendorBankDetailCard extends StatelessWidget { ], ], ), - const SizedBox(height: 8), + const SizedBox(height: 10), Text( - bankDetail.bankName?.trim().isNotEmpty == true - ? bankDetail.bankName! - : 'Bank Account', + bankDetail.bankName ?? 'Bank account', + maxLines: 1, + overflow: TextOverflow.ellipsis, style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, ), ), if (subtitle.isNotEmpty) ...[ @@ -1090,69 +1160,33 @@ class _VendorBankDetailCard extends StatelessWidget { ), ), ], - if (bankDetail.ifsc?.trim().isNotEmpty == true) ...[ - const SizedBox(height: 8), - Row( - children: [ - Icon( - Icons.tag_outlined, - size: 14, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Expanded( - child: Text( - bankDetail.ifsc!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ], const Spacer(), Row( children: [ - if (bankDetail.accountNumber?.trim().isNotEmpty == true) - Flexible( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.numbers_outlined, - size: 14, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Flexible( - child: Text( - 'A/C: ${bankDetail.accountNumber!}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ], + if (showPrimaryRadio) + SizedBox( + height: 32, + child: Radio( + value: bankDetail.id, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + if (onSetPrimary != null) + GestureDetector( + onTap: onSetPrimary, + child: Text( + isPrimary ? 'Primary' : 'Set primary', + style: theme.textTheme.bodySmall?.copyWith( + color: primaryColor, + fontWeight: FontWeight.w600, + ), ), ), const Spacer(), - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: statusColor, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), Text( bankDetail.isActive ? 'Active' : 'Inactive', - style: theme.textTheme.bodySmall?.copyWith( + style: theme.textTheme.labelSmall?.copyWith( color: statusColor, fontWeight: FontWeight.w600, ), diff --git a/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart b/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart index 8a0affb..1eb42b3 100644 --- a/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart +++ b/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart @@ -39,10 +39,15 @@ Future openVendorBankDetailPanel( BuildContext context, { required String vendorId, VendorBankDetailModel? bankDetail, + bool hasPrimaryAccount = false, }) { return showSidePanel( context, - VendorBankDetailPanel(vendorId: vendorId, bankDetail: bankDetail), + VendorBankDetailPanel( + vendorId: vendorId, + bankDetail: bankDetail, + hasPrimaryAccount: hasPrimaryAccount, + ), width: 520, ); } @@ -347,10 +352,12 @@ class VendorBankDetailPanel extends ConsumerStatefulWidget { super.key, required this.vendorId, this.bankDetail, + this.hasPrimaryAccount = false, }); final String vendorId; final VendorBankDetailModel? bankDetail; + final bool hasPrimaryAccount; bool get isEditing => bankDetail != null; @@ -386,6 +393,8 @@ class _VendorBankDetailPanelState extends ConsumerState { _isActive = detail.isActive; } else { _accountType = 'CURRENT'; + // Only one active primary per vendor — default first account to primary. + _isPrimary = !widget.hasPrimaryAccount; } } @@ -423,6 +432,7 @@ class _VendorBankDetailPanelState extends ConsumerState { } else { await notifier.createBankDetail(payload); } + // Provider reload refreshes bank list so other primaries show false. if (mounted) Navigator.of(context, rootNavigator: true).pop(true); } catch (e) { if (mounted) { @@ -435,6 +445,7 @@ class _VendorBankDetailPanelState extends ConsumerState { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return SidePanelScaffold( title: widget.isEditing ? 'Edit Bank Detail' : 'Add Bank Detail', footer: _panelFooter( @@ -446,6 +457,7 @@ class _VendorBankDetailPanelState extends ConsumerState { child: Form( key: _formKey, child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ AppTextField( controller: _bankNameController, @@ -488,11 +500,39 @@ class _VendorBankDetailPanelState extends ConsumerState { .toList(), onChanged: (v) => setState(() => _accountType = v), ), - const SizedBox(height: 12), - AppFormToggleField( - label: 'Primary Account', - value: _isPrimary, - onChanged: (v) => setState(() => _isPrimary = v), + const SizedBox(height: 16), + Text( + 'Primary account', + style: theme.textTheme.bodyLarge, + ), + const SizedBox(height: 4), + Text( + 'Only one active primary account is allowed per vendor.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + RadioGroup( + groupValue: _isPrimary, + onChanged: (v) { + if (v != null) setState(() => _isPrimary = v); + }, + child: Column( + children: const [ + RadioListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text('Primary'), + value: true, + ), + RadioListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text('Secondary'), + value: false, + ), + ], + ), ), AppFormToggleField( label: 'Active', diff --git a/lib/shared/models/asset_model.dart b/lib/shared/models/asset_model.dart index 7f650ee..0bb7dea 100644 --- a/lib/shared/models/asset_model.dart +++ b/lib/shared/models/asset_model.dart @@ -514,7 +514,6 @@ class ServiceVisitModel with _$ServiceVisitModel { @JsonKey(name: 'visit_type') required String visitType, @JsonKey(name: 'visit_date', fromJson: _dateFromJson) required DateTime visitDate, @JsonKey(name: 'amc_contract_id', fromJson: _intFromJsonNullable) int? amcContractId, - @JsonKey(name: 'visit_number', fromJson: _intFromJsonNullable) int? visitNumber, @JsonKey(name: 'complaint_no') String? complaintNo, @JsonKey(name: 'complaint_date', fromJson: _dateFromJsonNullable) DateTime? complaintDate, @JsonKey(name: 'complaint_desc') String? complaintDesc, diff --git a/lib/shared/models/asset_model.freezed.dart b/lib/shared/models/asset_model.freezed.dart index ab8d9e8..cbdbc8d 100644 --- a/lib/shared/models/asset_model.freezed.dart +++ b/lib/shared/models/asset_model.freezed.dart @@ -2660,8 +2660,6 @@ mixin _$ServiceVisitModel { DateTime get visitDate => throw _privateConstructorUsedError; @JsonKey(name: 'amc_contract_id', fromJson: _intFromJsonNullable) int? get amcContractId => throw _privateConstructorUsedError; - @JsonKey(name: 'visit_number', fromJson: _intFromJsonNullable) - int? get visitNumber => throw _privateConstructorUsedError; @JsonKey(name: 'complaint_no') String? get complaintNo => throw _privateConstructorUsedError; @JsonKey(name: 'complaint_date', fromJson: _dateFromJsonNullable) @@ -2719,8 +2717,6 @@ abstract class $ServiceVisitModelCopyWith<$Res> { @JsonKey(name: 'visit_date', fromJson: _dateFromJson) DateTime visitDate, @JsonKey(name: 'amc_contract_id', fromJson: _intFromJsonNullable) int? amcContractId, - @JsonKey(name: 'visit_number', fromJson: _intFromJsonNullable) - int? visitNumber, @JsonKey(name: 'complaint_no') String? complaintNo, @JsonKey(name: 'complaint_date', fromJson: _dateFromJsonNullable) DateTime? complaintDate, @@ -2767,7 +2763,6 @@ class _$ServiceVisitModelCopyWithImpl<$Res, $Val extends ServiceVisitModel> Object? visitType = null, Object? visitDate = null, Object? amcContractId = freezed, - Object? visitNumber = freezed, Object? complaintNo = freezed, Object? complaintDate = freezed, Object? complaintDesc = freezed, @@ -2808,10 +2803,6 @@ class _$ServiceVisitModelCopyWithImpl<$Res, $Val extends ServiceVisitModel> ? _value.amcContractId : amcContractId // ignore: cast_nullable_to_non_nullable as int?, - visitNumber: freezed == visitNumber - ? _value.visitNumber - : visitNumber // ignore: cast_nullable_to_non_nullable - as int?, complaintNo: freezed == complaintNo ? _value.complaintNo : complaintNo // ignore: cast_nullable_to_non_nullable @@ -2902,8 +2893,6 @@ abstract class _$$ServiceVisitModelImplCopyWith<$Res> @JsonKey(name: 'visit_date', fromJson: _dateFromJson) DateTime visitDate, @JsonKey(name: 'amc_contract_id', fromJson: _intFromJsonNullable) int? amcContractId, - @JsonKey(name: 'visit_number', fromJson: _intFromJsonNullable) - int? visitNumber, @JsonKey(name: 'complaint_no') String? complaintNo, @JsonKey(name: 'complaint_date', fromJson: _dateFromJsonNullable) DateTime? complaintDate, @@ -2949,7 +2938,6 @@ class __$$ServiceVisitModelImplCopyWithImpl<$Res> Object? visitType = null, Object? visitDate = null, Object? amcContractId = freezed, - Object? visitNumber = freezed, Object? complaintNo = freezed, Object? complaintDate = freezed, Object? complaintDesc = freezed, @@ -2990,10 +2978,6 @@ class __$$ServiceVisitModelImplCopyWithImpl<$Res> ? _value.amcContractId : amcContractId // ignore: cast_nullable_to_non_nullable as int?, - visitNumber: freezed == visitNumber - ? _value.visitNumber - : visitNumber // ignore: cast_nullable_to_non_nullable - as int?, complaintNo: freezed == complaintNo ? _value.complaintNo : complaintNo // ignore: cast_nullable_to_non_nullable @@ -3078,8 +3062,6 @@ class _$ServiceVisitModelImpl implements _ServiceVisitModel { required this.visitDate, @JsonKey(name: 'amc_contract_id', fromJson: _intFromJsonNullable) this.amcContractId, - @JsonKey(name: 'visit_number', fromJson: _intFromJsonNullable) - this.visitNumber, @JsonKey(name: 'complaint_no') this.complaintNo, @JsonKey(name: 'complaint_date', fromJson: _dateFromJsonNullable) this.complaintDate, @@ -3124,9 +3106,6 @@ class _$ServiceVisitModelImpl implements _ServiceVisitModel { @JsonKey(name: 'amc_contract_id', fromJson: _intFromJsonNullable) final int? amcContractId; @override - @JsonKey(name: 'visit_number', fromJson: _intFromJsonNullable) - final int? visitNumber; - @override @JsonKey(name: 'complaint_no') final String? complaintNo; @override @@ -3179,7 +3158,7 @@ class _$ServiceVisitModelImpl implements _ServiceVisitModel { @override String toString() { - return 'ServiceVisitModel(id: $id, assetId: $assetId, visitType: $visitType, visitDate: $visitDate, amcContractId: $amcContractId, visitNumber: $visitNumber, complaintNo: $complaintNo, complaintDate: $complaintDate, complaintDesc: $complaintDesc, engineerName: $engineerName, engineerPhone: $engineerPhone, vendorId: $vendorId, vendorName: $vendorName, workDone: $workDone, partsReplaced: $partsReplaced, nextServiceDate: $nextServiceDate, status: $status, downtimeHours: $downtimeHours, serviceCost: $serviceCost, isUnderAmc: $isUnderAmc, assetConditionAfter: $assetConditionAfter, remarks: $remarks, createdAt: $createdAt)'; + return 'ServiceVisitModel(id: $id, assetId: $assetId, visitType: $visitType, visitDate: $visitDate, amcContractId: $amcContractId, complaintNo: $complaintNo, complaintDate: $complaintDate, complaintDesc: $complaintDesc, engineerName: $engineerName, engineerPhone: $engineerPhone, vendorId: $vendorId, vendorName: $vendorName, workDone: $workDone, partsReplaced: $partsReplaced, nextServiceDate: $nextServiceDate, status: $status, downtimeHours: $downtimeHours, serviceCost: $serviceCost, isUnderAmc: $isUnderAmc, assetConditionAfter: $assetConditionAfter, remarks: $remarks, createdAt: $createdAt)'; } @override @@ -3195,8 +3174,6 @@ class _$ServiceVisitModelImpl implements _ServiceVisitModel { other.visitDate == visitDate) && (identical(other.amcContractId, amcContractId) || other.amcContractId == amcContractId) && - (identical(other.visitNumber, visitNumber) || - other.visitNumber == visitNumber) && (identical(other.complaintNo, complaintNo) || other.complaintNo == complaintNo) && (identical(other.complaintDate, complaintDate) || @@ -3240,7 +3217,6 @@ class _$ServiceVisitModelImpl implements _ServiceVisitModel { visitType, visitDate, amcContractId, - visitNumber, complaintNo, complaintDate, complaintDesc, @@ -3286,8 +3262,6 @@ abstract class _ServiceVisitModel implements ServiceVisitModel { required final DateTime visitDate, @JsonKey(name: 'amc_contract_id', fromJson: _intFromJsonNullable) final int? amcContractId, - @JsonKey(name: 'visit_number', fromJson: _intFromJsonNullable) - final int? visitNumber, @JsonKey(name: 'complaint_no') final String? complaintNo, @JsonKey(name: 'complaint_date', fromJson: _dateFromJsonNullable) final DateTime? complaintDate, @@ -3333,9 +3307,6 @@ abstract class _ServiceVisitModel implements ServiceVisitModel { @JsonKey(name: 'amc_contract_id', fromJson: _intFromJsonNullable) int? get amcContractId; @override - @JsonKey(name: 'visit_number', fromJson: _intFromJsonNullable) - int? get visitNumber; - @override @JsonKey(name: 'complaint_no') String? get complaintNo; @override diff --git a/lib/shared/models/asset_model.g.dart b/lib/shared/models/asset_model.g.dart index b818a36..a659729 100644 --- a/lib/shared/models/asset_model.g.dart +++ b/lib/shared/models/asset_model.g.dart @@ -217,7 +217,6 @@ _$ServiceVisitModelImpl _$$ServiceVisitModelImplFromJson( visitType: json['visit_type'] as String, visitDate: _dateFromJson(json['visit_date']), amcContractId: _intFromJsonNullable(json['amc_contract_id']), - visitNumber: _intFromJsonNullable(json['visit_number']), complaintNo: json['complaint_no'] as String?, complaintDate: _dateFromJsonNullable(json['complaint_date']), complaintDesc: json['complaint_desc'] as String?, @@ -245,7 +244,6 @@ Map _$$ServiceVisitModelImplToJson( 'visit_type': instance.visitType, 'visit_date': instance.visitDate.toIso8601String(), 'amc_contract_id': instance.amcContractId, - 'visit_number': instance.visitNumber, 'complaint_no': instance.complaintNo, 'complaint_date': instance.complaintDate?.toIso8601String(), 'complaint_desc': instance.complaintDesc, diff --git a/lib/shared/models/grn_model.dart b/lib/shared/models/grn_model.dart index 5df1034..03acc9c 100644 --- a/lib/shared/models/grn_model.dart +++ b/lib/shared/models/grn_model.dart @@ -38,9 +38,23 @@ Object? _readNestedName(Map json, String flatKey, String neste Object? _readVendorName(Map json, String key) => _readNestedName(json, 'vendor_name', 'vendor'); -Object? _readWarehouseName(Map json, String key) => - _readNestedName(json, 'warehouse_name', 'warehouse'); +Object? _readLocationName(Map json, String key) { + final flat = json['location_name']; + if (flat is String && flat.isNotEmpty) return flat; + final nested = json['location']; + if (nested is Map) { + return nested['name'] ?? nested['code']; + } + return null; +} +Object? _readLocationType(Map json, String key) { + final flat = json['location_type']; + if (flat is String && flat.isNotEmpty) return flat; + final nested = json['location']; + if (nested is Map) return nested['type']; + return null; +} Object? _readPoNumber(Map json, String key) { final grnNumber = json['grn_number']; if (grnNumber != null && grnNumber.toString().trim().isNotEmpty) { @@ -130,8 +144,11 @@ class GrnModel with _$GrnModel { @JsonKey(name: 'po_number', readValue: _readPoRefNumber) String? poNumber, @JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId, @JsonKey(name: 'vendor_name', readValue: _readVendorName) String? vendorName, - @JsonKey(name: 'warehouse_id', fromJson: _intFromJsonNullable) int? warehouseId, - @JsonKey(name: 'warehouse_name', readValue: _readWarehouseName) String? warehouseName, + @JsonKey(name: 'location_id', fromJson: _intFromJsonNullable) int? locationId, + @JsonKey(name: 'location_name', readValue: _readLocationName) + String? locationName, + @JsonKey(name: 'location_type', readValue: _readLocationType) + String? locationType, @JsonKey(name: 'vendor_invoice_no') String? vendorInvoiceNo, @JsonKey(name: 'vendor_invoice_date', fromJson: _dateFromJsonNullable) DateTime? vendorInvoiceDate, @@ -246,7 +263,7 @@ class GrnListQuery with _$GrnListQuery { String? status, int? poId, int? vendorId, - int? warehouseId, + int? locationId, String? dateFrom, String? dateTo, }) = _GrnListQuery; diff --git a/lib/shared/models/grn_model.freezed.dart b/lib/shared/models/grn_model.freezed.dart index 444442d..9bd9d8f 100644 --- a/lib/shared/models/grn_model.freezed.dart +++ b/lib/shared/models/grn_model.freezed.dart @@ -36,10 +36,12 @@ mixin _$GrnModel { int? get vendorId => throw _privateConstructorUsedError; @JsonKey(name: 'vendor_name', readValue: _readVendorName) String? get vendorName => throw _privateConstructorUsedError; - @JsonKey(name: 'warehouse_id', fromJson: _intFromJsonNullable) - int? get warehouseId => throw _privateConstructorUsedError; - @JsonKey(name: 'warehouse_name', readValue: _readWarehouseName) - String? get warehouseName => throw _privateConstructorUsedError; + @JsonKey(name: 'location_id', fromJson: _intFromJsonNullable) + int? get locationId => throw _privateConstructorUsedError; + @JsonKey(name: 'location_name', readValue: _readLocationName) + String? get locationName => throw _privateConstructorUsedError; + @JsonKey(name: 'location_type', readValue: _readLocationType) + String? get locationType => throw _privateConstructorUsedError; @JsonKey(name: 'vendor_invoice_no') String? get vendorInvoiceNo => throw _privateConstructorUsedError; @JsonKey(name: 'vendor_invoice_date', fromJson: _dateFromJsonNullable) @@ -93,10 +95,12 @@ abstract class $GrnModelCopyWith<$Res> { @JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId, @JsonKey(name: 'vendor_name', readValue: _readVendorName) String? vendorName, - @JsonKey(name: 'warehouse_id', fromJson: _intFromJsonNullable) - int? warehouseId, - @JsonKey(name: 'warehouse_name', readValue: _readWarehouseName) - String? warehouseName, + @JsonKey(name: 'location_id', fromJson: _intFromJsonNullable) + int? locationId, + @JsonKey(name: 'location_name', readValue: _readLocationName) + String? locationName, + @JsonKey(name: 'location_type', readValue: _readLocationType) + String? locationType, @JsonKey(name: 'vendor_invoice_no') String? vendorInvoiceNo, @JsonKey(name: 'vendor_invoice_date', fromJson: _dateFromJsonNullable) DateTime? vendorInvoiceDate, @@ -143,8 +147,9 @@ class _$GrnModelCopyWithImpl<$Res, $Val extends GrnModel> Object? poNumber = freezed, Object? vendorId = freezed, Object? vendorName = freezed, - Object? warehouseId = freezed, - Object? warehouseName = freezed, + Object? locationId = freezed, + Object? locationName = freezed, + Object? locationType = freezed, Object? vendorInvoiceNo = freezed, Object? vendorInvoiceDate = freezed, Object? vendorInvoiceAmount = freezed, @@ -194,13 +199,17 @@ class _$GrnModelCopyWithImpl<$Res, $Val extends GrnModel> ? _value.vendorName : vendorName // ignore: cast_nullable_to_non_nullable as String?, - warehouseId: freezed == warehouseId - ? _value.warehouseId - : warehouseId // ignore: cast_nullable_to_non_nullable + locationId: freezed == locationId + ? _value.locationId + : locationId // ignore: cast_nullable_to_non_nullable as int?, - warehouseName: freezed == warehouseName - ? _value.warehouseName - : warehouseName // ignore: cast_nullable_to_non_nullable + locationName: freezed == locationName + ? _value.locationName + : locationName // ignore: cast_nullable_to_non_nullable + as String?, + locationType: freezed == locationType + ? _value.locationType + : locationType // ignore: cast_nullable_to_non_nullable as String?, vendorInvoiceNo: freezed == vendorInvoiceNo ? _value.vendorInvoiceNo @@ -284,10 +293,12 @@ abstract class _$$GrnModelImplCopyWith<$Res> @JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId, @JsonKey(name: 'vendor_name', readValue: _readVendorName) String? vendorName, - @JsonKey(name: 'warehouse_id', fromJson: _intFromJsonNullable) - int? warehouseId, - @JsonKey(name: 'warehouse_name', readValue: _readWarehouseName) - String? warehouseName, + @JsonKey(name: 'location_id', fromJson: _intFromJsonNullable) + int? locationId, + @JsonKey(name: 'location_name', readValue: _readLocationName) + String? locationName, + @JsonKey(name: 'location_type', readValue: _readLocationType) + String? locationType, @JsonKey(name: 'vendor_invoice_no') String? vendorInvoiceNo, @JsonKey(name: 'vendor_invoice_date', fromJson: _dateFromJsonNullable) DateTime? vendorInvoiceDate, @@ -333,8 +344,9 @@ class __$$GrnModelImplCopyWithImpl<$Res> Object? poNumber = freezed, Object? vendorId = freezed, Object? vendorName = freezed, - Object? warehouseId = freezed, - Object? warehouseName = freezed, + Object? locationId = freezed, + Object? locationName = freezed, + Object? locationType = freezed, Object? vendorInvoiceNo = freezed, Object? vendorInvoiceDate = freezed, Object? vendorInvoiceAmount = freezed, @@ -384,13 +396,17 @@ class __$$GrnModelImplCopyWithImpl<$Res> ? _value.vendorName : vendorName // ignore: cast_nullable_to_non_nullable as String?, - warehouseId: freezed == warehouseId - ? _value.warehouseId - : warehouseId // ignore: cast_nullable_to_non_nullable + locationId: freezed == locationId + ? _value.locationId + : locationId // ignore: cast_nullable_to_non_nullable as int?, - warehouseName: freezed == warehouseName - ? _value.warehouseName - : warehouseName // ignore: cast_nullable_to_non_nullable + locationName: freezed == locationName + ? _value.locationName + : locationName // ignore: cast_nullable_to_non_nullable + as String?, + locationType: freezed == locationType + ? _value.locationType + : locationType // ignore: cast_nullable_to_non_nullable as String?, vendorInvoiceNo: freezed == vendorInvoiceNo ? _value.vendorInvoiceNo @@ -465,10 +481,12 @@ class _$GrnModelImpl extends _GrnModel { @JsonKey(name: 'po_number', readValue: _readPoRefNumber) this.poNumber, @JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) this.vendorId, @JsonKey(name: 'vendor_name', readValue: _readVendorName) this.vendorName, - @JsonKey(name: 'warehouse_id', fromJson: _intFromJsonNullable) - this.warehouseId, - @JsonKey(name: 'warehouse_name', readValue: _readWarehouseName) - this.warehouseName, + @JsonKey(name: 'location_id', fromJson: _intFromJsonNullable) + this.locationId, + @JsonKey(name: 'location_name', readValue: _readLocationName) + this.locationName, + @JsonKey(name: 'location_type', readValue: _readLocationType) + this.locationType, @JsonKey(name: 'vendor_invoice_no') this.vendorInvoiceNo, @JsonKey(name: 'vendor_invoice_date', fromJson: _dateFromJsonNullable) this.vendorInvoiceDate, @@ -521,11 +539,14 @@ class _$GrnModelImpl extends _GrnModel { @JsonKey(name: 'vendor_name', readValue: _readVendorName) final String? vendorName; @override - @JsonKey(name: 'warehouse_id', fromJson: _intFromJsonNullable) - final int? warehouseId; + @JsonKey(name: 'location_id', fromJson: _intFromJsonNullable) + final int? locationId; @override - @JsonKey(name: 'warehouse_name', readValue: _readWarehouseName) - final String? warehouseName; + @JsonKey(name: 'location_name', readValue: _readLocationName) + final String? locationName; + @override + @JsonKey(name: 'location_type', readValue: _readLocationType) + final String? locationType; @override @JsonKey(name: 'vendor_invoice_no') final String? vendorInvoiceNo; @@ -581,7 +602,7 @@ class _$GrnModelImpl extends _GrnModel { @override String toString() { - return 'GrnModel(id: $id, grnNumber: $grnNumber, grnDate: $grnDate, status: $status, poId: $poId, poNumber: $poNumber, vendorId: $vendorId, vendorName: $vendorName, warehouseId: $warehouseId, warehouseName: $warehouseName, vendorInvoiceNo: $vendorInvoiceNo, vendorInvoiceDate: $vendorInvoiceDate, vendorInvoiceAmount: $vendorInvoiceAmount, vehicleNo: $vehicleNo, lrNo: $lrNo, lrDate: $lrDate, receivedBy: $receivedBy, qualityCheckedBy: $qualityCheckedBy, remarks: $remarks, cancellationReason: $cancellationReason, createdAt: $createdAt, updatedAt: $updatedAt, items: $items, attachments: $attachments)'; + return 'GrnModel(id: $id, grnNumber: $grnNumber, grnDate: $grnDate, status: $status, poId: $poId, poNumber: $poNumber, vendorId: $vendorId, vendorName: $vendorName, locationId: $locationId, locationName: $locationName, locationType: $locationType, vendorInvoiceNo: $vendorInvoiceNo, vendorInvoiceDate: $vendorInvoiceDate, vendorInvoiceAmount: $vendorInvoiceAmount, vehicleNo: $vehicleNo, lrNo: $lrNo, lrDate: $lrDate, receivedBy: $receivedBy, qualityCheckedBy: $qualityCheckedBy, remarks: $remarks, cancellationReason: $cancellationReason, createdAt: $createdAt, updatedAt: $updatedAt, items: $items, attachments: $attachments)'; } @override @@ -601,10 +622,12 @@ class _$GrnModelImpl extends _GrnModel { other.vendorId == vendorId) && (identical(other.vendorName, vendorName) || other.vendorName == vendorName) && - (identical(other.warehouseId, warehouseId) || - other.warehouseId == warehouseId) && - (identical(other.warehouseName, warehouseName) || - other.warehouseName == warehouseName) && + (identical(other.locationId, locationId) || + other.locationId == locationId) && + (identical(other.locationName, locationName) || + other.locationName == locationName) && + (identical(other.locationType, locationType) || + other.locationType == locationType) && (identical(other.vendorInvoiceNo, vendorInvoiceNo) || other.vendorInvoiceNo == vendorInvoiceNo) && (identical(other.vendorInvoiceDate, vendorInvoiceDate) || @@ -645,8 +668,9 @@ class _$GrnModelImpl extends _GrnModel { poNumber, vendorId, vendorName, - warehouseId, - warehouseName, + locationId, + locationName, + locationType, vendorInvoiceNo, vendorInvoiceDate, vendorInvoiceAmount, @@ -692,10 +716,12 @@ abstract class _GrnModel extends GrnModel { final int? vendorId, @JsonKey(name: 'vendor_name', readValue: _readVendorName) final String? vendorName, - @JsonKey(name: 'warehouse_id', fromJson: _intFromJsonNullable) - final int? warehouseId, - @JsonKey(name: 'warehouse_name', readValue: _readWarehouseName) - final String? warehouseName, + @JsonKey(name: 'location_id', fromJson: _intFromJsonNullable) + final int? locationId, + @JsonKey(name: 'location_name', readValue: _readLocationName) + final String? locationName, + @JsonKey(name: 'location_type', readValue: _readLocationType) + final String? locationType, @JsonKey(name: 'vendor_invoice_no') final String? vendorInvoiceNo, @JsonKey(name: 'vendor_invoice_date', fromJson: _dateFromJsonNullable) final DateTime? vendorInvoiceDate, @@ -747,11 +773,14 @@ abstract class _GrnModel extends GrnModel { @JsonKey(name: 'vendor_name', readValue: _readVendorName) String? get vendorName; @override - @JsonKey(name: 'warehouse_id', fromJson: _intFromJsonNullable) - int? get warehouseId; + @JsonKey(name: 'location_id', fromJson: _intFromJsonNullable) + int? get locationId; @override - @JsonKey(name: 'warehouse_name', readValue: _readWarehouseName) - String? get warehouseName; + @JsonKey(name: 'location_name', readValue: _readLocationName) + String? get locationName; + @override + @JsonKey(name: 'location_type', readValue: _readLocationType) + String? get locationType; @override @JsonKey(name: 'vendor_invoice_no') String? get vendorInvoiceNo; @@ -1969,7 +1998,7 @@ mixin _$GrnListQuery { String? get status => throw _privateConstructorUsedError; int? get poId => throw _privateConstructorUsedError; int? get vendorId => throw _privateConstructorUsedError; - int? get warehouseId => throw _privateConstructorUsedError; + int? get locationId => throw _privateConstructorUsedError; String? get dateFrom => throw _privateConstructorUsedError; String? get dateTo => throw _privateConstructorUsedError; @@ -1994,7 +2023,7 @@ abstract class $GrnListQueryCopyWith<$Res> { String? status, int? poId, int? vendorId, - int? warehouseId, + int? locationId, String? dateFrom, String? dateTo, }); @@ -2021,7 +2050,7 @@ class _$GrnListQueryCopyWithImpl<$Res, $Val extends GrnListQuery> Object? status = freezed, Object? poId = freezed, Object? vendorId = freezed, - Object? warehouseId = freezed, + Object? locationId = freezed, Object? dateFrom = freezed, Object? dateTo = freezed, }) { @@ -2051,9 +2080,9 @@ class _$GrnListQueryCopyWithImpl<$Res, $Val extends GrnListQuery> ? _value.vendorId : vendorId // ignore: cast_nullable_to_non_nullable as int?, - warehouseId: freezed == warehouseId - ? _value.warehouseId - : warehouseId // ignore: cast_nullable_to_non_nullable + locationId: freezed == locationId + ? _value.locationId + : locationId // ignore: cast_nullable_to_non_nullable as int?, dateFrom: freezed == dateFrom ? _value.dateFrom @@ -2085,7 +2114,7 @@ abstract class _$$GrnListQueryImplCopyWith<$Res> String? status, int? poId, int? vendorId, - int? warehouseId, + int? locationId, String? dateFrom, String? dateTo, }); @@ -2111,7 +2140,7 @@ class __$$GrnListQueryImplCopyWithImpl<$Res> Object? status = freezed, Object? poId = freezed, Object? vendorId = freezed, - Object? warehouseId = freezed, + Object? locationId = freezed, Object? dateFrom = freezed, Object? dateTo = freezed, }) { @@ -2141,9 +2170,9 @@ class __$$GrnListQueryImplCopyWithImpl<$Res> ? _value.vendorId : vendorId // ignore: cast_nullable_to_non_nullable as int?, - warehouseId: freezed == warehouseId - ? _value.warehouseId - : warehouseId // ignore: cast_nullable_to_non_nullable + locationId: freezed == locationId + ? _value.locationId + : locationId // ignore: cast_nullable_to_non_nullable as int?, dateFrom: freezed == dateFrom ? _value.dateFrom @@ -2168,7 +2197,7 @@ class _$GrnListQueryImpl implements _GrnListQuery { this.status, this.poId, this.vendorId, - this.warehouseId, + this.locationId, this.dateFrom, this.dateTo, }); @@ -2188,7 +2217,7 @@ class _$GrnListQueryImpl implements _GrnListQuery { @override final int? vendorId; @override - final int? warehouseId; + final int? locationId; @override final String? dateFrom; @override @@ -2196,7 +2225,7 @@ class _$GrnListQueryImpl implements _GrnListQuery { @override String toString() { - return 'GrnListQuery(page: $page, limit: $limit, search: $search, status: $status, poId: $poId, vendorId: $vendorId, warehouseId: $warehouseId, dateFrom: $dateFrom, dateTo: $dateTo)'; + return 'GrnListQuery(page: $page, limit: $limit, search: $search, status: $status, poId: $poId, vendorId: $vendorId, locationId: $locationId, dateFrom: $dateFrom, dateTo: $dateTo)'; } @override @@ -2211,8 +2240,8 @@ class _$GrnListQueryImpl implements _GrnListQuery { (identical(other.poId, poId) || other.poId == poId) && (identical(other.vendorId, vendorId) || other.vendorId == vendorId) && - (identical(other.warehouseId, warehouseId) || - other.warehouseId == warehouseId) && + (identical(other.locationId, locationId) || + other.locationId == locationId) && (identical(other.dateFrom, dateFrom) || other.dateFrom == dateFrom) && (identical(other.dateTo, dateTo) || other.dateTo == dateTo)); @@ -2227,7 +2256,7 @@ class _$GrnListQueryImpl implements _GrnListQuery { status, poId, vendorId, - warehouseId, + locationId, dateFrom, dateTo, ); @@ -2249,7 +2278,7 @@ abstract class _GrnListQuery implements GrnListQuery { final String? status, final int? poId, final int? vendorId, - final int? warehouseId, + final int? locationId, final String? dateFrom, final String? dateTo, }) = _$GrnListQueryImpl; @@ -2267,7 +2296,7 @@ abstract class _GrnListQuery implements GrnListQuery { @override int? get vendorId; @override - int? get warehouseId; + int? get locationId; @override String? get dateFrom; @override diff --git a/lib/shared/models/grn_model.g.dart b/lib/shared/models/grn_model.g.dart index 860049f..2cc3188 100644 --- a/lib/shared/models/grn_model.g.dart +++ b/lib/shared/models/grn_model.g.dart @@ -17,8 +17,9 @@ _$GrnModelImpl _$$GrnModelImplFromJson( poNumber: _readPoRefNumber(json, 'po_number') as String?, vendorId: _intFromJsonNullable(json['vendor_id']), vendorName: _readVendorName(json, 'vendor_name') as String?, - warehouseId: _intFromJsonNullable(json['warehouse_id']), - warehouseName: _readWarehouseName(json, 'warehouse_name') as String?, + locationId: _intFromJsonNullable(json['location_id']), + locationName: _readLocationName(json, 'location_name') as String?, + locationType: _readLocationType(json, 'location_type') as String?, vendorInvoiceNo: json['vendor_invoice_no'] as String?, vendorInvoiceDate: _dateFromJsonNullable(json['vendor_invoice_date']), vendorInvoiceAmount: _doubleFromJsonNullable(json['vendor_invoice_amount']), @@ -53,8 +54,9 @@ Map _$$GrnModelImplToJson(_$GrnModelImpl instance) => 'po_number': instance.poNumber, 'vendor_id': instance.vendorId, 'vendor_name': instance.vendorName, - 'warehouse_id': instance.warehouseId, - 'warehouse_name': instance.warehouseName, + 'location_id': instance.locationId, + 'location_name': instance.locationName, + 'location_type': instance.locationType, 'vendor_invoice_no': instance.vendorInvoiceNo, 'vendor_invoice_date': instance.vendorInvoiceDate?.toIso8601String(), 'vendor_invoice_amount': instance.vendorInvoiceAmount, diff --git a/lib/shared/routes/app_router.dart b/lib/shared/routes/app_router.dart index 058367a..72b4b92 100644 --- a/lib/shared/routes/app_router.dart +++ b/lib/shared/routes/app_router.dart @@ -227,6 +227,13 @@ final routerProvider = Provider((ref) { pageBuilder: (context, state) => shellPage(state, const PurchaseOrderListScreen()), routes: [ + GoRoute( + path: 'pending-approval', + pageBuilder: (context, state) => shellPage( + state, + const PurchaseOrderListScreen(pendingApprovalOnly: true), + ), + ), GoRoute( path: 'add', pageBuilder: (context, state) => shellPage( diff --git a/lib/shared/routes/menu_config.dart b/lib/shared/routes/menu_config.dart index c473112..6440ada 100644 --- a/lib/shared/routes/menu_config.dart +++ b/lib/shared/routes/menu_config.dart @@ -12,6 +12,7 @@ class MenuItem { required this.module, this.children = const [], this.requiredRole, + this.requiredAction, }); final String label; @@ -20,6 +21,9 @@ class MenuItem { final String module; final List children; final UserRole? requiredRole; + + /// When set, menu visibility requires this action instead of default view. + final PermissionAction? requiredAction; } const List appMenuItems = [ @@ -79,6 +83,21 @@ const List appMenuItems = [ icon: Icons.receipt_long_outlined, route: RouteConstants.purchaseOrders, module: 'purchase_orders', + children: [ + MenuItem( + label: 'All Orders', + icon: Icons.receipt_long_outlined, + route: RouteConstants.purchaseOrders, + module: 'purchase_orders', + ), + MenuItem( + label: 'Pending Approval', + icon: Icons.pending_actions_outlined, + route: RouteConstants.purchaseOrdersPendingApproval, + module: 'purchase_orders', + requiredAction: PermissionAction.approve, + ), + ], ), MenuItem( label: 'GRN', @@ -145,9 +164,9 @@ List getVisibleMenuItems({ if (item.children.isNotEmpty) { final visibleChildren = item.children .where( - (child) => canSeeMenuModule( + (child) => _canSeeMenuItem( permissions: permissions, - module: child.module, + item: child, role: role, ), ) @@ -161,16 +180,13 @@ List getVisibleMenuItems({ module: item.module, children: visibleChildren, requiredRole: item.requiredRole, + requiredAction: item.requiredAction, ), ); continue; } - if (!canSeeMenuModule( - permissions: permissions, - module: item.module, - role: role, - )) { + if (!_canSeeMenuItem(permissions: permissions, item: item, role: role)) { continue; } @@ -179,3 +195,24 @@ List getVisibleMenuItems({ return visible; } + +bool _canSeeMenuItem({ + required List permissions, + required MenuItem item, + required UserRole role, +}) { + if (item.requiredAction != null) { + if (permissions.contains('*')) return true; + return hasPermission( + userPermissions: permissions, + module: item.module, + action: item.requiredAction!, + ); + } + + return canSeeMenuModule( + permissions: permissions, + module: item.module, + role: role, + ); +} diff --git a/lib/shared/widgets/app_sidebar.dart b/lib/shared/widgets/app_sidebar.dart index 04d285b..378f800 100644 --- a/lib/shared/widgets/app_sidebar.dart +++ b/lib/shared/widgets/app_sidebar.dart @@ -96,14 +96,31 @@ class _AppSidebarState extends ConsumerState { return _expandedMenus.contains(item.route) || _isGroupActive(item); } + Iterable get _allGroupItems => + [..._primaryMenuItems, ..._supportMenuItems] + .where((item) => item.children.isNotEmpty); + + /// Accordion: keep only [keepRoute] expanded; collapse every other group. + void _expandOnly(String keepRoute) { + _expandedMenus + ..clear() + ..add(keepRoute); + _manuallyCollapsedMenus + ..clear() + ..addAll( + _allGroupItems + .map((item) => item.route) + .where((route) => route != keepRoute), + ); + } + void _toggleGroup(menu.MenuItem item) { setState(() { if (_isGroupExpanded(item)) { _expandedMenus.remove(item.route); _manuallyCollapsedMenus.add(item.route); } else { - _manuallyCollapsedMenus.remove(item.route); - _expandedMenus.add(item.route); + _expandOnly(item.route); } }); } @@ -112,10 +129,10 @@ class _AppSidebarState extends ConsumerState { void didUpdateWidget(covariant AppSidebar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.currentRoute != widget.currentRoute) { - for (final item in [..._primaryMenuItems, ..._supportMenuItems]) { - if (item.children.isNotEmpty && _isGroupActive(item)) { - _expandedMenus.add(item.route); - _manuallyCollapsedMenus.remove(item.route); + for (final item in _allGroupItems) { + if (_isGroupActive(item)) { + _expandOnly(item.route); + break; } } }