po changes done

This commit is contained in:
Surendiran 2026-07-17 11:07:08 +05:30
parent eb94390961
commit 6721b308a9
31 changed files with 918 additions and 400 deletions

View File

@ -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';

View File

@ -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';

View File

@ -444,7 +444,6 @@ class LogServiceVisitPanel extends ConsumerStatefulWidget {
class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
final _formKey = GlobalKey<FormState>();
final _visitNumberController = TextEditingController();
final _complaintNoController = TextEditingController();
final _complaintDescController = TextEditingController();
final _engineerNameController = TextEditingController();
@ -468,7 +467,6 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
@override
void dispose() {
_visitNumberController.dispose();
_complaintNoController.dispose();
_complaintDescController.dispose();
_engineerNameController.dispose();
@ -488,9 +486,6 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
_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<LogServiceVisitPanel> {
}
Map<String, dynamic> _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<LogServiceVisitPanel> {
),
),
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),

View File

@ -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,
};

View File

@ -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<FilterOptionModel> warehouses;
/// Plants and warehouses (any location type) for list filters.
final List<FilterOptionModel> locations;
final List<PurchaseOrderModel> receivablePurchaseOrders;
final List<FilterOptionModel> users;
}
@ -23,12 +24,7 @@ final grnLookupsProvider = FutureProvider.autoDispose<GrnLookups>((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 = <PurchaseOrderModel>[];
@ -40,7 +36,7 @@ final grnLookupsProvider = FutureProvider.autoDispose<GrnLookups>((ref) async {
}
return GrnLookups(
warehouses: warehouses,
locations: locations,
receivablePurchaseOrders: receivablePos,
users: users,
);

View File

@ -109,6 +109,12 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
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;

View File

@ -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',

View File

@ -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<GrnFormScreen> {
DateTime? _vendorInvoiceDate;
DateTime? _lrDate;
String? _selectedPoId;
int? _warehouseId;
String? _locationLabel;
int? _receivedById;
int? _qualityCheckedById;
final List<GrnLineItemDraft> _lines = [];
@ -90,7 +89,10 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
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<GrnFormScreen> {
_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<GrnFormScreen> {
final payload = <String, dynamic>{
'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<GrnFormScreen> {
}
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<GrnFormScreen> {
);
}
final warehouseIds =
lookups.warehouses.map((e) => _parseId(e.id)).whereType<int>();
final userIds =
lookups.users.map((e) => _parseId(e.id)).whereType<int>();
final poOptions = lookups.receivablePurchaseOrders
@ -421,7 +431,10 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
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<GrnFormScreen> {
label: 'Purchase Order',
value: existing?.poNumber ?? '',
),
MasterQuickAddDropdown<int>(
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),
),
);
}

View File

@ -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<GrnListScreen> {
@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<GrnListScreen> {
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<FilterOptionModel> locationOptions;
final ValueChanged<String> onSearch;
final ValueChanged<String?> onStatusChanged;
final ValueChanged<int?> onLocationChanged;
final bool showExport;
final bool isExporting;
final VoidCallback? onExport;
@ -233,6 +243,21 @@ class _FiltersBar extends StatelessWidget {
],
onChanged: onStatusChanged,
),
AppSearchableDropdown<int?>(
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<AppDropdownOption<int?>>(),
],
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(

View File

@ -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 = <MasterDefinition>[
_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',

View File

@ -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('_', ' '),

View File

@ -103,6 +103,40 @@ class MasterRemoteDataSource {
Future<List<FilterOptionModel>> listDeliveryTerms() =>
_listOptions(ApiEndpoints.deliveryTerms);
/// Active terms/notes text for a document type (`PO` | `INVOICE`).
Future<String?> 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<String, dynamic>.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<List<String>> 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<String, dynamic>.from(item);
return (map['value'] ?? map['type'] ?? map['id'] ?? '')
.toString()
.trim();
}
return '';
})
.where((value) => value.isNotEmpty)
.toList();
}
Future<List<FilterOptionModel>> listWarehouses() =>
_listOptions(ApiEndpoints.warehouses);

View File

@ -22,6 +22,17 @@ class PurchaseOrderRemoteDataSource {
return _parsePaginated(response.data, PurchaseOrderModel.fromJson);
}
/// Approver-only list of POs with status PENDING_APPROVAL.
Future<PaginatedResponse<PurchaseOrderModel>> 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<List<PurchaseOrderModel>> listPurchaseOrderOptions({
String? status,

View File

@ -32,6 +32,14 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository {
return safeApiCall(() => dataSource.getPurchaseOrders(query));
}
@override
Future<Result<PaginatedResponse<PurchaseOrderModel>>>
getPendingApprovalPurchaseOrders(PurchaseOrderListQuery query) {
return safeApiCall(
() => dataSource.getPendingApprovalPurchaseOrders(query),
);
}
@override
Future<Result<List<PurchaseOrderModel>>> listPurchaseOrderOptions({
String? status,

View File

@ -8,6 +8,8 @@ abstract class PurchaseOrderRepository {
Future<Result<PaginatedResponse<PurchaseOrderModel>>> getPurchaseOrders(
PurchaseOrderListQuery query,
);
Future<Result<PaginatedResponse<PurchaseOrderModel>>>
getPendingApprovalPurchaseOrders(PurchaseOrderListQuery query);
Future<Result<List<PurchaseOrderModel>>> listPurchaseOrderOptions({
String? status,
});

View File

@ -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<String?>((ref) async {
final master = ref.watch(masterRemoteDataSourceProvider);
try {
return await master.getTermsNotesByType('PO');
} catch (_) {
return null;
}
});
class PurchaseOrderLookups {
const PurchaseOrderLookups({
this.vendors = const [],

View File

@ -58,6 +58,12 @@ final purchaseOrdersListProvider = AsyncNotifierProvider.autoDispose<
PurchaseOrdersListNotifier.new,
);
final pendingApprovalPurchaseOrdersListProvider =
AsyncNotifierProvider.autoDispose<PendingApprovalPurchaseOrdersListNotifier,
PurchaseOrdersListState>(
PendingApprovalPurchaseOrdersListNotifier.new,
);
class PurchaseOrdersListNotifier
extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> {
@override
@ -169,6 +175,89 @@ class PurchaseOrdersListNotifier
}
}
class PendingApprovalPurchaseOrdersListNotifier
extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> {
@override
Future<PurchaseOrdersListState> build() async {
return _load(const PurchaseOrderListQuery(limit: 20));
}
Future<PurchaseOrdersListState> _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<void> 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<void> 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<bool> 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<PurchaseOrderModel?,
final result = await repository.createPurchaseOrder(data);
if (result.failure != null) throw result.failure!;
ref.invalidate(purchaseOrdersListProvider);
ref.invalidate(pendingApprovalPurchaseOrdersListProvider);
ref.invalidate(grnLookupsProvider);
return result.data!;
}
@ -324,6 +419,7 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier<PurchaseOrderModel?,
final result = await repository.updatePurchaseOrder(id, data);
if (result.failure != null) throw result.failure!;
ref.invalidate(purchaseOrdersListProvider);
ref.invalidate(pendingApprovalPurchaseOrdersListProvider);
ref.invalidate(purchaseOrderDetailProvider(id));
ref.invalidate(grnLookupsProvider);
return result.data!;

View File

@ -65,73 +65,68 @@ class _PurchaseOrderDetailScreenState
final lookups = lookupsAsync.asData?.value;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
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),
],
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),
],
),
);
},

View File

@ -58,6 +58,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
final List<PoLineItemDraft> _lines = [];
bool _isSubmitting = false;
String? _populatedSignature;
bool _defaultTermsApplied = false;
@override
void initState() {
@ -506,8 +507,9 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
AppTextField(
controller: _termsController,
label: 'Terms & Conditions',
hint:
'Payment terms, inspection conditions, etc.',
hint: widget.isEditing
? 'Payment terms, inspection conditions, etc.'
: 'Defaults from Terms & Notes master when empty',
maxLines: 5,
),
AppTextField(
@ -685,6 +687,17 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
Widget build(BuildContext context) {
final lookupsAsync = ref.watch(purchaseOrderLookupsProvider);
if (!widget.isEditing) {
ref.listen(poDefaultTermsNotesProvider, (prev, next) {
if (_defaultTermsApplied) return;
final notes = next.asData?.value;
if (notes == null || notes.isEmpty) return;
if (_termsController.text.trim().isNotEmpty) return;
_defaultTermsApplied = true;
_termsController.text = notes;
});
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: lookupsAsync.when(

View File

@ -31,7 +31,12 @@ import '../widgets/po_status_chip.dart';
import '../../../../shared/widgets/app_toast.dart';
class PurchaseOrderListScreen extends ConsumerStatefulWidget {
const PurchaseOrderListScreen({super.key});
const PurchaseOrderListScreen({
super.key,
this.pendingApprovalOnly = false,
});
final bool pendingApprovalOnly;
@override
ConsumerState<PurchaseOrderListScreen> createState() =>
@ -59,12 +64,23 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
@override
Widget build(BuildContext context) {
final ordersAsync = ref.watch(purchaseOrdersListProvider);
final canEdit = ref.can('purchase_orders', PermissionAction.update);
final canDelete = ref.can('purchase_orders', PermissionAction.delete);
final canExport = ref.can('purchase_orders', PermissionAction.export);
final pendingOnly = widget.pendingApprovalOnly;
final ordersAsync = pendingOnly
? ref.watch(pendingApprovalPurchaseOrdersListProvider)
: ref.watch(purchaseOrdersListProvider);
final canEdit =
!pendingOnly && ref.can('purchase_orders', PermissionAction.update);
final canDelete =
!pendingOnly && ref.can('purchase_orders', PermissionAction.delete);
final canExport =
!pendingOnly && ref.can('purchase_orders', PermissionAction.export);
final canApprove =
pendingOnly && ref.can('purchase_orders', PermissionAction.approve);
ref.listen(purchaseOrdersListProvider, (prev, next) {
void listenListMessages(
AsyncValue<PurchaseOrdersListState>? prev,
AsyncValue<PurchaseOrdersListState> 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<PurchaseOrderListScree
if (success != null && success != prev?.valueOrNull?.actionSuccess) {
showAppToastFromSnackBar(context, SnackBar(content: Text(success)));
}
});
}
if (pendingOnly) {
ref.listen(pendingApprovalPurchaseOrdersListProvider, listenListMessages);
} else {
ref.listen(purchaseOrdersListProvider, listenListMessages);
}
return Padding(
padding: const EdgeInsets.all(24),
child: ordersAsync.when(
loading: () => 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<PurchaseOrderListScree
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: 'Purchase Orders',
subtitle: 'Create, approve and track procurement orders',
title: pendingOnly ? 'Pending Approval' : 'Purchase Orders',
subtitle: pendingOnly
? 'Purchase orders awaiting approval'
: 'Create, approve and track procurement orders',
actions: [
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'),
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<PurchaseOrderListScree
searchController: _searchController,
query: state.query,
statusOptions: _statusFilterOptions(state.orders),
showStatusFilter: !pendingOnly,
showExport: canExport,
isExporting: state.isExporting,
onExport: _exportPurchaseOrders,
onSearch: ref.read(purchaseOrdersListProvider.notifier).setSearch,
onStatusChanged:
ref.read(purchaseOrdersListProvider.notifier).setStatusFilter,
onSearch: pendingOnly
? ref
.read(
pendingApprovalPurchaseOrdersListProvider.notifier,
)
.setSearch
: ref.read(purchaseOrdersListProvider.notifier).setSearch,
onStatusChanged: pendingOnly
? (_) {}
: ref
.read(purchaseOrdersListProvider.notifier)
.setStatusFilter,
),
footer: AppPagination(
currentPage: state.query.page,
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
itemLabel: 'purchase orders',
onPageChanged:
ref.read(purchaseOrdersListProvider.notifier).setPage,
onPageSizeChanged:
ref.read(purchaseOrdersListProvider.notifier).setPageSize,
itemLabel: pendingOnly
? 'pending approvals'
: 'purchase orders',
onPageChanged: pendingOnly
? ref
.read(
pendingApprovalPurchaseOrdersListProvider.notifier,
)
.setPage
: ref.read(purchaseOrdersListProvider.notifier).setPage,
onPageSizeChanged: pendingOnly
? ref
.read(
pendingApprovalPurchaseOrdersListProvider.notifier,
)
.setPageSize
: ref
.read(purchaseOrdersListProvider.notifier)
.setPageSize,
),
child: RefreshIndicator(
onRefresh: () =>
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<PurchaseOrderListScree
onView: _viewOrder,
onEdit: canEdit ? _editOrder : null,
onDelete: canDelete ? _deleteOrder : null,
onApprove: canApprove ? _approveOrder : null,
)
: _PoDataTable(
orders: state.orders,
onView: _viewOrder,
onEdit: canEdit ? _editOrder : null,
onDelete: canDelete ? _deleteOrder : null,
onApprove: canApprove ? _approveOrder : null,
),
),
),
@ -216,6 +284,26 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
);
}
Future<void> _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<void> _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<AppDropdownOption<String?>> statusOptions;
final ValueChanged<String> onSearch;
final ValueChanged<String?> 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<String?>(
label: 'Status',
value: query.status,
searchHint: 'Search status...',
isDense: true,
options: statusOptions,
onChanged: onStatusChanged,
),
final filters = <Widget>[
if (showStatusFilter)
AppSearchableDropdown<String?>(
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<PurchaseOrderModel> orders;
final ValueChanged<PurchaseOrderModel> onView;
final ValueChanged<PurchaseOrderModel>? onEdit;
final ValueChanged<PurchaseOrderModel>? onDelete;
final ValueChanged<PurchaseOrderModel>? onApprove;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppDataTable<PurchaseOrderModel>(
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<PurchaseOrderModel> orders;
final ValueChanged<PurchaseOrderModel> onView;
final ValueChanged<PurchaseOrderModel>? onEdit;
final ValueChanged<PurchaseOrderModel>? onDelete;
final ValueChanged<PurchaseOrderModel>? 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),
),
);

View File

@ -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<String>(
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<void> _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<void> _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<String>(
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,
),

View File

@ -39,10 +39,15 @@ Future<bool?> openVendorBankDetailPanel(
BuildContext context, {
required String vendorId,
VendorBankDetailModel? bankDetail,
bool hasPrimaryAccount = false,
}) {
return showSidePanel<bool>(
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<VendorBankDetailPanel> {
_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<VendorBankDetailPanel> {
} 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<VendorBankDetailPanel> {
@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<VendorBankDetailPanel> {
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppTextField(
controller: _bankNameController,
@ -488,11 +500,39 @@ class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
.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<bool>(
groupValue: _isPrimary,
onChanged: (v) {
if (v != null) setState(() => _isPrimary = v);
},
child: Column(
children: const [
RadioListTile<bool>(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text('Primary'),
value: true,
),
RadioListTile<bool>(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text('Secondary'),
value: false,
),
],
),
),
AppFormToggleField(
label: 'Active',

View File

@ -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,

View File

@ -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

View File

@ -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<String, dynamic> _$$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,

View File

@ -38,9 +38,23 @@ Object? _readNestedName(Map<dynamic, dynamic> json, String flatKey, String neste
Object? _readVendorName(Map<dynamic, dynamic> json, String key) =>
_readNestedName(json, 'vendor_name', 'vendor');
Object? _readWarehouseName(Map<dynamic, dynamic> json, String key) =>
_readNestedName(json, 'warehouse_name', 'warehouse');
Object? _readLocationName(Map<dynamic, dynamic> 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<dynamic, dynamic> 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<dynamic, dynamic> 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;

View File

@ -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

View File

@ -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<String, dynamic> _$$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,

View File

@ -227,6 +227,13 @@ final routerProvider = Provider<GoRouter>((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(

View File

@ -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<MenuItem> children;
final UserRole? requiredRole;
/// When set, menu visibility requires this action instead of default view.
final PermissionAction? requiredAction;
}
const List<MenuItem> appMenuItems = [
@ -79,6 +83,21 @@ const List<MenuItem> 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<MenuItem> 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<MenuItem> 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<MenuItem> getVisibleMenuItems({
return visible;
}
bool _canSeeMenuItem({
required List<String> 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,
);
}

View File

@ -96,14 +96,31 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
return _expandedMenus.contains(item.route) || _isGroupActive(item);
}
Iterable<menu.MenuItem> 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<AppSidebar> {
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;
}
}
}