grn changes

This commit is contained in:
Surendiran 2026-07-10 11:24:44 +05:30
parent fa610b7efd
commit 83c73e31ae
12 changed files with 616 additions and 207 deletions

View File

@ -21,4 +21,24 @@ extension ResponsiveContext on BuildContext {
if (isDesktop) return 1200;
return double.infinity;
}
/// Form grid columns: 4 on medium+, 2 on small screens.
int get formGridColumns {
final width = MediaQuery.sizeOf(this).width;
return formGridColumnsForWidth(width);
}
}
/// Responsive form field columns based on viewport width.
int formGridColumnsForWidth(
double width, {
int smallColumns = 2,
int mediumColumns = 4,
int largeColumns = 4,
double mediumBreakpoint = AppBreakpoints.tablet,
double largeBreakpoint = AppBreakpoints.desktop,
}) {
if (width >= largeBreakpoint) return largeColumns;
if (width >= mediumBreakpoint) return mediumColumns;
return smallColumns;
}

View File

@ -138,7 +138,7 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Asset',
message: 'Soft delete this asset?',
message: 'Do you want to delete this asset?',
confirmLabel: 'Delete',
isDestructive: true,
);

View File

@ -5,17 +5,20 @@ import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart';
import '../../../purchase_orders/data/repositories/purchase_order_repository_impl.dart';
import '../../../users/presentation/providers/users_provider.dart';
class GrnLookups {
const GrnLookups({
this.warehouses = const [],
this.receivablePurchaseOrders = const [],
this.assetCategories = const [],
this.users = const [],
});
final List<FilterOptionModel> warehouses;
final List<PurchaseOrderModel> receivablePurchaseOrders;
final List<FilterOptionModel> assetCategories;
final List<FilterOptionModel> users;
}
final grnLookupsProvider = FutureProvider.autoDispose<GrnLookups>((ref) async {
@ -24,6 +27,7 @@ final grnLookupsProvider = FutureProvider.autoDispose<GrnLookups>((ref) async {
final warehouses = await _safeOptions(master.listWarehouses);
final assetCategories = await _safeOptions(master.listAssetCategories);
final users = await _safeUserOptions(ref);
final receivablePos = <PurchaseOrderModel>[];
for (final status in ['APPROVED', 'PARTIALLY_RECEIVED']) {
@ -43,6 +47,7 @@ final grnLookupsProvider = FutureProvider.autoDispose<GrnLookups>((ref) async {
warehouses: warehouses,
receivablePurchaseOrders: receivablePos,
assetCategories: assetCategories,
users: users,
);
});
@ -56,6 +61,45 @@ Future<List<FilterOptionModel>> _safeOptions(
}
}
Future<List<FilterOptionModel>> _safeUserOptions(Ref ref) async {
try {
final result = await ref.read(getUsersUseCaseProvider)(
const UserListQuery(
page: 1,
limit: AppConstants.maxPageSize,
status: 'active',
isActive: true,
),
);
if (result.failure != null || result.data == null) return const [];
return result.data!.items
.where((user) {
final status = user.status.trim().toLowerCase();
return status == 'active' && user.isActive;
})
.map(
(user) => FilterOptionModel(
id: user.id,
name: user.fullName,
),
)
.toList();
} catch (_) {
return const [];
}
}
final grnAssetSubcategoriesProvider =
FutureProvider.autoDispose.family<List<FilterOptionModel>, int?>(
(ref, categoryId) async {
if (categoryId == null) return const [];
final master = ref.watch(masterRemoteDataSourceProvider);
return _safeOptions(
() => master.listAssetSubcategories(assetCategoryId: categoryId),
);
},
);
final grnPurchaseOrderProvider =
FutureProvider.autoDispose.family<PurchaseOrderModel?, String>((ref, poId) async {
final result =

View File

@ -7,6 +7,7 @@ import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/widgets/app_card.dart';
@ -14,6 +15,7 @@ import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/grn_lookups_provider.dart';
import '../providers/grn_provider.dart';
import '../widgets/grn_line_items_editor.dart';
import '../widgets/grn_status_chip.dart';
@ -209,15 +211,25 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
}
}
class _OverviewCard extends StatelessWidget {
class _OverviewCard extends ConsumerWidget {
const _OverviewCard({required this.grn});
final GrnModel grn;
String _userLabel(List<FilterOptionModel> users, int? userId) {
if (userId == null) return '';
final match = users.where((u) => int.tryParse(u.id) == userId);
if (match.isNotEmpty) return match.first.name;
return 'User #$userId';
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final hasRemarks = grn.remarks?.trim().isNotEmpty == true;
final users = ref.watch(grnLookupsProvider).maybeWhen(
data: (lookups) => lookups.users,
orElse: () => const <FilterOptionModel>[],
);
return AppCard(
child: Padding(
@ -254,18 +266,16 @@ class _OverviewCard extends StatelessWidget {
_GrnInfo('Vehicle No', grn.vehicleNo ?? ''),
_GrnInfo('LR No', grn.lrNo ?? ''),
_GrnInfo('LR Date', DateFormatter.displayDate(grn.lrDate)),
_GrnInfo('Received By', _userLabel(users, grn.receivedBy)),
_GrnInfo(
'Quality Checked By',
_userLabel(users, grn.qualityCheckedBy),
),
_GrnInfo('Remarks', grn.remarks?.trim().isNotEmpty == true
? grn.remarks!
: ''),
],
),
if (hasRemarks) ...[
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Divider(height: 1),
),
_GrnInfoGrid(
columns: 1,
items: [_GrnInfo('Remarks', grn.remarks!)],
),
],
],
),
),

View File

@ -47,6 +47,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
DateTime? _lrDate;
String? _selectedPoId;
int? _warehouseId;
int? _receivedById;
int? _qualityCheckedById;
final List<GrnLineItemDraft> _lines = [];
bool _isSubmitting = false;
String? _populatedSignature;
@ -88,6 +90,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
_vehicleNoController.text = grn.vehicleNo ?? '';
_lrNoController.text = grn.lrNo ?? '';
_lrDate = grn.lrDate;
_receivedById = grn.receivedBy;
_qualityCheckedById = grn.qualityCheckedBy;
_remarksController.text = grn.remarks ?? '';
});
}
@ -124,14 +128,6 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
.toList();
}
List<AppDropdownOption<String>> _assetCategoryOptions(
List<FilterOptionModel> categories,
) {
return categories
.map((e) => AppDropdownOption(value: e.id, label: e.name))
.toList();
}
Map<String, dynamic> _buildCreatePayload() {
final poId = int.tryParse(_selectedPoId ?? '');
return {
@ -149,8 +145,9 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
'vehicle_no': _vehicleNoController.text.trim(),
if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(),
if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!),
if (_remarksController.text.trim().isNotEmpty)
'remarks': _remarksController.text.trim(),
if (_receivedById != null) 'received_by': _receivedById,
if (_qualityCheckedById != null) 'quality_checked_by': _qualityCheckedById,
'remarks': _remarksController.text.trim(),
'items': _lines.map((line) => line.toPayload()).toList(),
};
}
@ -168,8 +165,9 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
'vehicle_no': _vehicleNoController.text.trim(),
if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(),
if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!),
if (_remarksController.text.trim().isNotEmpty)
'remarks': _remarksController.text.trim(),
if (_receivedById != null) 'received_by': _receivedById,
if (_qualityCheckedById != null) 'quality_checked_by': _qualityCheckedById,
'remarks': _remarksController.text.trim(),
};
}
@ -364,7 +362,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FormRowThree(
ResponsiveFormGrid(
children: [
_DateField(
label: 'GRN Date *',
@ -382,6 +380,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
label: 'Purchase Order *',
value: _selectedPoId,
searchHint: 'Search PO...',
isDense: true,
options: poOptions,
onChanged: (v) async {
setState(() => _selectedPoId = v);
@ -408,16 +407,22 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
v == null ? 'Purchase order is required' : null,
)
else
InputDecorator(
decoration: const InputDecoration(
labelText: 'Purchase Order',
Padding(
padding: const EdgeInsets.only(top: 8),
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Purchase Order',
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
),
child: Text(existing?.poNumber ?? ''),
),
child: Text(existing?.poNumber ?? ''),
),
AppSearchableDropdown<int>(
label: 'Warehouse *',
value: _dropdownValue(_warehouseId, warehouseIds),
searchHint: 'Search warehouse...',
isDense: true,
options: _intOptions(lookups.warehouses),
onChanged: widget.isEditing
? (_) {}
@ -427,13 +432,10 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
: (v) => v == null ? 'Warehouse is required' : null,
enabled: !widget.isEditing,
),
],
),
FormRowThree(
children: [
AppTextField(
label: 'Vendor Invoice No',
controller: _vendorInvoiceNoController,
isDense: true,
),
_DateField(
label: 'Vendor Invoice Date',
@ -449,18 +451,17 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
controller: _vendorInvoiceAmountController,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
isDense: true,
),
],
),
FormRowThree(
children: [
AppTextField(
label: 'Vehicle No',
controller: _vehicleNoController,
isDense: true,
),
AppTextField(
label: 'LR No',
controller: _lrNoController,
isDense: true,
),
_DateField(
label: 'LR Date',
@ -470,13 +471,37 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
onPicked: (d) => setState(() => _lrDate = d),
),
),
AppSearchableDropdown<int>(
label: 'Received By',
value: _dropdownValue(
_receivedById,
lookups.users.map((e) => _parseId(e.id)).whereType<int>(),
),
searchHint: 'Search user...',
isDense: true,
options: _intOptions(lookups.users),
onChanged: (v) => setState(() => _receivedById = v),
),
AppSearchableDropdown<int>(
label: 'Quality Checked By',
value: _dropdownValue(
_qualityCheckedById,
lookups.users.map((e) => _parseId(e.id)).whereType<int>(),
),
searchHint: 'Search user...',
isDense: true,
options: _intOptions(lookups.users),
onChanged: (v) =>
setState(() => _qualityCheckedById = v),
),
AppTextField(
label: 'Remarks',
controller: _remarksController,
maxLines: 3,
isDense: true,
),
],
),
AppTextField(
label: 'Remarks',
controller: _remarksController,
maxLines: 3,
),
if (!widget.isEditing) ...[
const SizedBox(height: 24),
Text(
@ -488,8 +513,6 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
const SizedBox(height: 12),
GrnLineItemsEditor(
items: _lines,
assetCategoryOptions:
_assetCategoryOptions(lookups.assetCategories),
onChanged: () => setState(() {}),
),
] else ...[
@ -539,22 +562,27 @@ class _DateField extends StatelessWidget {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
enabled: enabled,
),
child: Text(
value != null ? DateFormatter.displayDate(value) : 'Select date',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: value != null
? null
: Theme.of(context).hintColor,
),
return Padding(
padding: const EdgeInsets.only(top: 8),
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
enabled: enabled,
),
child: Text(
value != null ? DateFormatter.displayDate(value) : 'Select date',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: value != null
? null
: Theme.of(context).hintColor,
),
),
),
),
);

View File

@ -15,7 +15,6 @@ import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
@ -165,12 +164,21 @@ class _FiltersBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final searchField = SizedBox(
width: wrapped ? double.infinity : null,
child: AppSearchField(
controller: searchController,
hint: 'Search GRN number, PO, vendor...',
onChanged: onSearch,
final searchField = Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
width: wrapped ? double.infinity : null,
child: TextField(
controller: searchController,
onChanged: onSearch,
decoration: const InputDecoration(
labelText: 'Search',
hintText: 'Search GRN number, PO, vendor...',
prefixIcon: Icon(Icons.search, size: 20),
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
),
),
),
);
@ -203,6 +211,7 @@ class _FiltersBar extends StatelessWidget {
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),

View File

@ -1,12 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../assets/presentation/providers/asset_categories_provider.dart';
import '../providers/grn_lookups_provider.dart';
String _formatQty(double value) {
if (value % 1 == 0) return value.toInt().toString();
@ -22,22 +30,27 @@ class GrnLineItemDraft {
required this.receivedQty,
required this.remainingQty,
TextEditingController? acceptedQtyController,
TextEditingController? damagedQtyController,
TextEditingController? shortQtyController,
TextEditingController? excessQtyController,
TextEditingController? rejectedQtyController,
TextEditingController? rateController,
TextEditingController? batchNoController,
TextEditingController? expiryDateController,
TextEditingController? storageLocationController,
TextEditingController? rejectionReasonController,
TextEditingController? remarksController,
this.mfgDate,
this.expiryDate,
this.assetCategoryId,
this.assetSubcategoryId,
}) : acceptedQtyController = acceptedQtyController ??
TextEditingController(
text: remainingQty > 0 ? _formatQty(remainingQty) : '',
),
damagedQtyController = damagedQtyController ?? TextEditingController(),
shortQtyController = shortQtyController ?? TextEditingController(),
excessQtyController = excessQtyController ?? TextEditingController(),
rejectedQtyController = rejectedQtyController ?? TextEditingController(),
rateController = rateController ?? TextEditingController(),
batchNoController = batchNoController ?? TextEditingController(),
expiryDateController = expiryDateController ?? TextEditingController(),
storageLocationController =
storageLocationController ?? TextEditingController(),
rejectionReasonController =
rejectionReasonController ?? TextEditingController(),
remarksController = remarksController ?? TextEditingController();
final String poItemId;
@ -47,27 +60,28 @@ class GrnLineItemDraft {
final double receivedQty;
final double remainingQty;
final TextEditingController acceptedQtyController;
final TextEditingController damagedQtyController;
final TextEditingController shortQtyController;
final TextEditingController excessQtyController;
final TextEditingController rejectedQtyController;
final TextEditingController rateController;
final TextEditingController batchNoController;
final TextEditingController expiryDateController;
final TextEditingController storageLocationController;
final TextEditingController rejectionReasonController;
final TextEditingController remarksController;
String? assetCategoryId;
DateTime? mfgDate;
DateTime? expiryDate;
int? assetCategoryId;
int? assetSubcategoryId;
double get acceptedQty => double.tryParse(acceptedQtyController.text.trim()) ?? 0;
double get damagedQty => double.tryParse(damagedQtyController.text.trim()) ?? 0;
double get shortQty => double.tryParse(shortQtyController.text.trim()) ?? 0;
double get excessQty => double.tryParse(excessQtyController.text.trim()) ?? 0;
double get currentQty => acceptedQty + damagedQty + shortQty + excessQty;
double get rejectedQty => double.tryParse(rejectedQtyController.text.trim()) ?? 0;
double get currentQty => acceptedQty + rejectedQty;
void dispose() {
acceptedQtyController.dispose();
damagedQtyController.dispose();
shortQtyController.dispose();
excessQtyController.dispose();
rejectedQtyController.dispose();
rateController.dispose();
batchNoController.dispose();
expiryDateController.dispose();
storageLocationController.dispose();
rejectionReasonController.dispose();
remarksController.dispose();
}
@ -78,15 +92,19 @@ class GrnLineItemDraft {
'line_no': lineNo,
'current_qty': currentQty,
'accepted_qty': acceptedQty,
if (damagedQty > 0) 'damaged_qty': damagedQty,
if (shortQty > 0) 'short_qty': shortQty,
if (excessQty > 0) 'excess_qty': excessQty,
if (rejectedQty > 0) 'rejected_qty': rejectedQty,
if (rejectionReasonController.text.trim().isNotEmpty)
'rejection_reason': rejectionReasonController.text.trim(),
if (rateController.text.trim().isNotEmpty)
'rate': double.tryParse(rateController.text.trim()),
if (batchNoController.text.trim().isNotEmpty)
'batch_no': batchNoController.text.trim(),
if (expiryDateController.text.trim().isNotEmpty)
'expiry_date': expiryDateController.text.trim(),
if (assetCategoryId != null && assetCategoryId!.isNotEmpty)
'asset_category_id': int.tryParse(assetCategoryId!) ?? assetCategoryId,
if (mfgDate != null) 'mfg_date': DateFormatter.toApiDate(mfgDate!),
if (expiryDate != null) 'expiry_date': DateFormatter.toApiDate(expiryDate!),
if (storageLocationController.text.trim().isNotEmpty)
'storage_location': storageLocationController.text.trim(),
if (assetCategoryId != null) 'asset_category_id': assetCategoryId,
if (assetSubcategoryId != null) 'asset_subcategory_id': assetSubcategoryId,
if (remarksController.text.trim().isNotEmpty)
'remarks': remarksController.text.trim(),
};
@ -98,6 +116,7 @@ List<GrnLineItemDraft> draftsFromPurchaseOrder(PurchaseOrderModel po) {
final ordered = item.orderedQty ?? 0;
final received = item.receivedQty ?? 0;
final remaining = (ordered - received).clamp(0.0, double.infinity);
final rate = item.rate;
return GrnLineItemDraft(
poItemId: item.id,
lineNo: item.lineNo ?? 1,
@ -105,6 +124,9 @@ List<GrnLineItemDraft> draftsFromPurchaseOrder(PurchaseOrderModel po) {
orderedQty: ordered,
receivedQty: received,
remainingQty: remaining,
rateController: TextEditingController(
text: rate != null ? _formatQty(rate) : '',
),
);
}).toList();
}
@ -114,13 +136,11 @@ class GrnLineItemsEditor extends StatelessWidget {
super.key,
required this.items,
required this.onChanged,
this.assetCategoryOptions = const [],
this.readOnly = false,
});
final List<GrnLineItemDraft> items;
final VoidCallback onChanged;
final List<AppDropdownOption<String>> assetCategoryOptions;
final bool readOnly;
@override
@ -152,7 +172,6 @@ class GrnLineItemsEditor extends StatelessWidget {
padding: EdgeInsets.only(bottom: i == items.length - 1 ? 0 : 12),
child: _GrnLineItemCard(
item: items[i],
assetCategoryOptions: assetCategoryOptions,
readOnly: readOnly,
onChanged: onChanged,
),
@ -162,25 +181,80 @@ class GrnLineItemsEditor extends StatelessWidget {
}
}
class _GrnLineItemCard extends StatelessWidget {
class _GrnLineItemCard extends ConsumerWidget {
const _GrnLineItemCard({
required this.item,
required this.onChanged,
required this.assetCategoryOptions,
required this.readOnly,
});
final GrnLineItemDraft item;
final VoidCallback onChanged;
final List<AppDropdownOption<String>> assetCategoryOptions;
final bool readOnly;
static final _qtyFormatters = [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,4}')),
];
int? _dropdownValue(int? selected, Iterable<int> validIds) {
if (selected == null) return null;
return validIds.contains(selected) ? selected : null;
}
List<AppDropdownOption<int>> _intOptions(List<FilterOptionModel> options) {
return options
.map((e) {
final id = int.tryParse(e.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: e.name);
})
.whereType<AppDropdownOption<int>>()
.toList();
}
List<AppDropdownOption<int>> _categoryOptions(
List<AssetCategoryModel> categories,
) {
return categories
.map((c) {
final id = int.tryParse(c.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: c.name);
})
.whereType<AppDropdownOption<int>>()
.toList();
}
Future<void> _pickDate(
BuildContext context, {
required DateTime? current,
required ValueChanged<DateTime?> onPicked,
}) async {
final picked = await showDatePicker(
context: context,
initialDate: current ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (picked != null) onPicked(picked);
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final categoryId = item.assetCategoryId;
final categoriesAsync = ref.watch(assetCategoriesProvider);
final categoryOptions = categoriesAsync.maybeWhen(
data: _categoryOptions,
orElse: () => const <AppDropdownOption<int>>[],
);
final categoryIds = categoryOptions.map((e) => e.value);
final subcategoriesAsync = ref.watch(grnAssetSubcategoriesProvider(categoryId));
final subcategoryOptions = subcategoriesAsync.maybeWhen(
data: _intOptions,
orElse: () => const <AppDropdownOption<int>>[],
);
final subcategoryIds = subcategoryOptions.map((e) => e.value);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@ -218,94 +292,20 @@ class _GrnLineItemCard extends StatelessWidget {
),
),
if (!readOnly) ...[
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: AppTextField(
label: 'Accepted Qty *',
controller: item.acceptedQtyController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 12),
Expanded(
child: AppTextField(
label: 'Damaged Qty',
controller: item.damagedQtyController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 12),
Expanded(
child: AppTextField(
label: 'Short Qty',
controller: item.shortQtyController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
onChanged: (_) => onChanged(),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: AppTextField(
label: 'Excess Qty',
controller: item.excessQtyController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 12),
Expanded(
child: AppTextField(
label: 'Batch No',
controller: item.batchNoController,
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 12),
Expanded(
child: AppTextField(
label: 'Expiry Date',
controller: item.expiryDateController,
hint: 'YYYY-MM-DD',
onChanged: (_) => onChanged(),
),
),
],
),
if (assetCategoryOptions.isNotEmpty) ...[
const SizedBox(height: 12),
AppDropdown<String>(
label: 'Asset Category',
value: item.assetCategoryId,
options: assetCategoryOptions,
onChanged: (v) {
item.assetCategoryId = v;
onChanged();
},
ResponsiveFormGrid(
children: _buildLineItemFields(
context: context,
categoryOptions: categoryOptions,
categoryIds: categoryIds,
subcategoryOptions: subcategoryOptions,
subcategoryIds: subcategoryIds,
),
],
const SizedBox(height: 12),
AppTextField(
label: 'Remarks',
controller: item.remarksController,
maxLines: 2,
onChanged: (_) => onChanged(),
),
] else ...[
const SizedBox(height: 8),
Text(
'Accepted: ${_formatQty(item.acceptedQty)} · '
'Rejected: ${_formatQty(item.rejectedQty)} · '
'Current: ${_formatQty(item.currentQty)}',
style: Theme.of(context).textTheme.bodySmall,
),
@ -314,6 +314,160 @@ class _GrnLineItemCard extends StatelessWidget {
),
);
}
List<Widget> _buildLineItemFields({
required BuildContext context,
required List<AppDropdownOption<int>> categoryOptions,
required Iterable<int> categoryIds,
required List<AppDropdownOption<int>> subcategoryOptions,
required Iterable<int> subcategoryIds,
}) {
final hasCategory = item.assetCategoryId != null;
return [
AppTextField(
label: 'Accepted Qty *',
controller: item.acceptedQtyController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
isDense: true,
onChanged: (_) => onChanged(),
),
AppTextField(
label: 'Rejected Qty',
controller: item.rejectedQtyController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
isDense: true,
onChanged: (_) => onChanged(),
),
AppTextField(
label: 'Rate',
controller: item.rateController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
isDense: true,
onChanged: (_) => onChanged(),
),
AppTextField(
label: 'Batch No',
controller: item.batchNoController,
isDense: true,
onChanged: (_) => onChanged(),
),
_GrnLineDateField(
label: 'Mfg Date',
value: item.mfgDate,
onTap: () => _pickDate(
context,
current: item.mfgDate,
onPicked: (date) {
item.mfgDate = date;
onChanged();
},
),
),
_GrnLineDateField(
label: 'Expiry Date',
value: item.expiryDate,
onTap: () => _pickDate(
context,
current: item.expiryDate,
onPicked: (date) {
item.expiryDate = date;
onChanged();
},
),
),
AppTextField(
label: 'Storage Location',
controller: item.storageLocationController,
isDense: true,
onChanged: (_) => onChanged(),
),
AppTextField(
label: 'Rejection Reason',
controller: item.rejectionReasonController,
isDense: true,
onChanged: (_) => onChanged(),
),
AppSearchableDropdown<int>(
key: ValueKey('line_${item.lineNo}_asset_category'),
label: 'Asset Category',
value: _dropdownValue(item.assetCategoryId, categoryIds),
searchHint: 'Search asset category...',
isDense: true,
enabled: categoryOptions.isNotEmpty,
options: categoryOptions,
onChanged: (v) {
item.assetCategoryId = v;
item.assetSubcategoryId = null;
onChanged();
},
),
AppSearchableDropdown<int>(
key: ValueKey('line_${item.lineNo}_asset_subcategory'),
label: 'Asset Subcategory',
value: _dropdownValue(item.assetSubcategoryId, subcategoryIds),
searchHint: 'Search subcategory...',
isDense: true,
enabled: hasCategory && subcategoryOptions.isNotEmpty,
hint: !hasCategory
? 'Select category first'
: subcategoryOptions.isEmpty
? 'No subcategories found'
: null,
options: subcategoryOptions,
onChanged: (v) {
item.assetSubcategoryId = v;
onChanged();
},
),
AppTextField(
label: 'Remarks',
controller: item.remarksController,
isDense: true,
maxLines: 2,
onChanged: (_) => onChanged(),
),
];
}
}
class _GrnLineDateField extends StatelessWidget {
const _GrnLineDateField({
required this.label,
required this.value,
required this.onTap,
});
final String label;
final DateTime? value;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
),
child: Text(
value != null ? DateFormatter.displayDate(value) : 'Select date',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: value != null ? null : Theme.of(context).hintColor,
),
),
),
),
);
}
}
class GrnItemsTable extends StatelessWidget {
@ -344,25 +498,47 @@ class GrnItemsTable extends StatelessWidget {
cellBuilder: (_, item) => Text(_formatQty(item.acceptedQty ?? 0)),
),
AppDataColumn(
label: 'Damaged',
label: 'Rejected',
flex: 1,
cellBuilder: (_, item) => Text(_formatQty(item.damagedQty ?? 0)),
cellBuilder: (_, item) => Text(_formatQty(item.rejectedQty ?? 0)),
),
AppDataColumn(
label: 'Short',
label: 'Rate',
flex: 1,
cellBuilder: (_, item) => Text(_formatQty(item.shortQty ?? 0)),
),
AppDataColumn(
label: 'Excess',
flex: 1,
cellBuilder: (_, item) => Text(_formatQty(item.excessQty ?? 0)),
cellBuilder: (_, item) => Text(
item.rate != null ? _formatQty(item.rate!) : '',
),
),
AppDataColumn(
label: 'Batch',
flex: 1,
cellBuilder: (_, item) => Text(item.batchNo ?? ''),
),
AppDataColumn(
label: 'Mfg Date',
flex: 1,
cellBuilder: (_, item) => Text(DateFormatter.displayDate(item.mfgDate)),
),
AppDataColumn(
label: 'Expiry',
flex: 1,
cellBuilder: (_, item) => Text(DateFormatter.displayDate(item.expiryDate)),
),
AppDataColumn(
label: 'Storage',
flex: 1,
cellBuilder: (_, item) => Text(item.storageLocation ?? ''),
),
AppDataColumn(
label: 'Rejection Reason',
flex: 2,
cellBuilder: (_, item) => Text(item.rejectionReason ?? ''),
),
AppDataColumn(
label: 'Remarks',
flex: 2,
cellBuilder: (_, item) => Text(item.remarks ?? ''),
),
],
rows: items,
);

View File

@ -151,6 +151,8 @@ class GrnItemModel with _$GrnItemModel {
@JsonKey(name: 'storage_location') String? storageLocation,
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
int? assetCategoryId,
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
int? assetSubcategoryId,
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId,
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
String? remarks,

View File

@ -813,6 +813,8 @@ mixin _$GrnItemModel {
String? get storageLocation => throw _privateConstructorUsedError;
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
int? get assetCategoryId => throw _privateConstructorUsedError;
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
int? get assetSubcategoryId => throw _privateConstructorUsedError;
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable)
int? get uomId => throw _privateConstructorUsedError;
@JsonKey(name: 'uom_name', readValue: _readUomName)
@ -866,6 +868,8 @@ abstract class $GrnItemModelCopyWith<$Res> {
@JsonKey(name: 'storage_location') String? storageLocation,
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
int? assetCategoryId,
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
int? assetSubcategoryId,
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId,
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
String? remarks,
@ -907,6 +911,7 @@ class _$GrnItemModelCopyWithImpl<$Res, $Val extends GrnItemModel>
Object? expiryDate = freezed,
Object? storageLocation = freezed,
Object? assetCategoryId = freezed,
Object? assetSubcategoryId = freezed,
Object? uomId = freezed,
Object? uomName = freezed,
Object? remarks = freezed,
@ -993,6 +998,10 @@ class _$GrnItemModelCopyWithImpl<$Res, $Val extends GrnItemModel>
? _value.assetCategoryId
: assetCategoryId // ignore: cast_nullable_to_non_nullable
as int?,
assetSubcategoryId: freezed == assetSubcategoryId
? _value.assetSubcategoryId
: assetSubcategoryId // ignore: cast_nullable_to_non_nullable
as int?,
uomId: freezed == uomId
? _value.uomId
: uomId // ignore: cast_nullable_to_non_nullable
@ -1050,6 +1059,8 @@ abstract class _$$GrnItemModelImplCopyWith<$Res>
@JsonKey(name: 'storage_location') String? storageLocation,
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
int? assetCategoryId,
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
int? assetSubcategoryId,
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId,
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
String? remarks,
@ -1090,6 +1101,7 @@ class __$$GrnItemModelImplCopyWithImpl<$Res>
Object? expiryDate = freezed,
Object? storageLocation = freezed,
Object? assetCategoryId = freezed,
Object? assetSubcategoryId = freezed,
Object? uomId = freezed,
Object? uomName = freezed,
Object? remarks = freezed,
@ -1176,6 +1188,10 @@ class __$$GrnItemModelImplCopyWithImpl<$Res>
? _value.assetCategoryId
: assetCategoryId // ignore: cast_nullable_to_non_nullable
as int?,
assetSubcategoryId: freezed == assetSubcategoryId
? _value.assetSubcategoryId
: assetSubcategoryId // ignore: cast_nullable_to_non_nullable
as int?,
uomId: freezed == uomId
? _value.uomId
: uomId // ignore: cast_nullable_to_non_nullable
@ -1225,6 +1241,8 @@ class _$GrnItemModelImpl implements _GrnItemModel {
@JsonKey(name: 'storage_location') this.storageLocation,
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
this.assetCategoryId,
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
this.assetSubcategoryId,
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) this.uomId,
@JsonKey(name: 'uom_name', readValue: _readUomName) this.uomName,
this.remarks,
@ -1294,6 +1312,9 @@ class _$GrnItemModelImpl implements _GrnItemModel {
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
final int? assetCategoryId;
@override
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
final int? assetSubcategoryId;
@override
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable)
final int? uomId;
@override
@ -1304,7 +1325,7 @@ class _$GrnItemModelImpl implements _GrnItemModel {
@override
String toString() {
return 'GrnItemModel(id: $id, grnId: $grnId, poItemId: $poItemId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, currentQty: $currentQty, acceptedQty: $acceptedQty, damagedQty: $damagedQty, shortQty: $shortQty, excessQty: $excessQty, rejectedQty: $rejectedQty, rejectionReason: $rejectionReason, rate: $rate, batchNo: $batchNo, mfgDate: $mfgDate, expiryDate: $expiryDate, storageLocation: $storageLocation, assetCategoryId: $assetCategoryId, uomId: $uomId, uomName: $uomName, remarks: $remarks)';
return 'GrnItemModel(id: $id, grnId: $grnId, poItemId: $poItemId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, currentQty: $currentQty, acceptedQty: $acceptedQty, damagedQty: $damagedQty, shortQty: $shortQty, excessQty: $excessQty, rejectedQty: $rejectedQty, rejectionReason: $rejectionReason, rate: $rate, batchNo: $batchNo, mfgDate: $mfgDate, expiryDate: $expiryDate, storageLocation: $storageLocation, assetCategoryId: $assetCategoryId, assetSubcategoryId: $assetSubcategoryId, uomId: $uomId, uomName: $uomName, remarks: $remarks)';
}
@override
@ -1345,6 +1366,8 @@ class _$GrnItemModelImpl implements _GrnItemModel {
other.storageLocation == storageLocation) &&
(identical(other.assetCategoryId, assetCategoryId) ||
other.assetCategoryId == assetCategoryId) &&
(identical(other.assetSubcategoryId, assetSubcategoryId) ||
other.assetSubcategoryId == assetSubcategoryId) &&
(identical(other.uomId, uomId) || other.uomId == uomId) &&
(identical(other.uomName, uomName) || other.uomName == uomName) &&
(identical(other.remarks, remarks) || other.remarks == remarks));
@ -1374,6 +1397,7 @@ class _$GrnItemModelImpl implements _GrnItemModel {
expiryDate,
storageLocation,
assetCategoryId,
assetSubcategoryId,
uomId,
uomName,
remarks,
@ -1427,6 +1451,8 @@ abstract class _GrnItemModel implements GrnItemModel {
@JsonKey(name: 'storage_location') final String? storageLocation,
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
final int? assetCategoryId,
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
final int? assetSubcategoryId,
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) final int? uomId,
@JsonKey(name: 'uom_name', readValue: _readUomName) final String? uomName,
final String? remarks,
@ -1496,6 +1522,9 @@ abstract class _GrnItemModel implements GrnItemModel {
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
int? get assetCategoryId;
@override
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
int? get assetSubcategoryId;
@override
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable)
int? get uomId;
@override

View File

@ -88,6 +88,7 @@ _$GrnItemModelImpl _$$GrnItemModelImplFromJson(Map<String, dynamic> json) =>
expiryDate: _dateFromJsonNullable(json['expiry_date']),
storageLocation: json['storage_location'] as String?,
assetCategoryId: _intFromJsonNullable(json['asset_category_id']),
assetSubcategoryId: _intFromJsonNullable(json['asset_subcategory_id']),
uomId: _intFromJsonNullable(json['uom_id']),
uomName: _readUomName(json, 'uom_name') as String?,
remarks: json['remarks'] as String?,
@ -115,6 +116,7 @@ Map<String, dynamic> _$$GrnItemModelImplToJson(_$GrnItemModelImpl instance) =>
'expiry_date': instance.expiryDate?.toIso8601String(),
'storage_location': instance.storageLocation,
'asset_category_id': instance.assetCategoryId,
'asset_subcategory_id': instance.assetSubcategoryId,
'uom_id': instance.uomId,
'uom_name': instance.uomName,
'remarks': instance.remarks,

View File

@ -35,6 +35,7 @@ class AppSearchableDropdown<T> extends StatefulWidget {
class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
final _layerLink = LayerLink();
final _fieldKey = GlobalKey();
final _formFieldKey = UniqueKey();
OverlayEntry? _overlayEntry;
bool _ignoreOutsideTap = false;
@ -166,7 +167,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
final theme = Theme.of(context);
return FormField<T>(
key: ValueKey<T?>(widget.value),
key: widget.key ?? _formFieldKey,
initialValue: widget.value,
validator: widget.validator,
builder: (field) {
@ -206,16 +207,16 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
),
enabled: canOpen,
),
child: displayLabel == null
? const SizedBox.shrink()
: Text(
displayLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color: colors.onSurface,
),
),
child: Text(
displayLabel ?? '\u00A0',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color: displayLabel == null
? Colors.transparent
: colors.onSurface,
),
),
),
),
),

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../../core/utils/responsive_utils.dart';
Future<T?> showSidePanel<T>(
BuildContext context,
Widget panel, {
@ -266,6 +268,92 @@ class FormRowFour extends StatelessWidget {
}
}
/// Responsive form grid: 4 cols (medium+), 2 cols (small).
class ResponsiveFormGrid extends StatelessWidget {
const ResponsiveFormGrid({
super.key,
required this.children,
this.fullWidthChildren = const [],
this.spacing = 12,
this.smallColumns = 2,
this.mediumColumns = 4,
this.largeColumns = 4,
this.mediumBreakpoint = AppBreakpoints.tablet,
this.largeBreakpoint = AppBreakpoints.desktop,
});
final List<Widget> children;
final List<Widget> fullWidthChildren;
final double spacing;
final int smallColumns;
final int mediumColumns;
final int largeColumns;
final double mediumBreakpoint;
final double largeBreakpoint;
List<List<Widget>> _chunk(List<Widget> items, int columns) {
if (items.isEmpty) return const [];
final rows = <List<Widget>>[];
for (var i = 0; i < items.length; i += columns) {
final end = (i + columns < items.length) ? i + columns : items.length;
rows.add(items.sublist(i, end));
}
return rows;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth.isFinite && constraints.maxWidth > 0
? constraints.maxWidth
: MediaQuery.sizeOf(context).width;
final columns = formGridColumnsForWidth(
width,
smallColumns: smallColumns,
mediumColumns: mediumColumns,
largeColumns: largeColumns,
mediumBreakpoint: mediumBreakpoint,
largeBreakpoint: largeBreakpoint,
);
final rows = _chunk(children, columns);
final columnWidth = (width - (columns - 1) * spacing) / columns;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final row in rows)
Padding(
padding: EdgeInsets.only(bottom: spacing),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var col = 0; col < columns; col++) ...[
if (col > 0) SizedBox(width: spacing),
SizedBox(
width: columnWidth,
child: col < row.length
? row[col]
: const SizedBox.shrink(),
),
],
],
),
),
for (var i = 0; i < fullWidthChildren.length; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == fullWidthChildren.length - 1 ? 0 : spacing,
),
child: fullWidthChildren[i],
),
],
);
},
);
}
}
/// Responsive row of equal-width form fields.
class FormRow extends StatelessWidget {
const FormRow({