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; if (isDesktop) return 1200;
return double.infinity; 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( final confirmed = await showAppConfirmationDialog(
context: context, context: context,
title: 'Delete Asset', title: 'Delete Asset',
message: 'Soft delete this asset?', message: 'Do you want to delete this asset?',
confirmLabel: 'Delete', confirmLabel: 'Delete',
isDestructive: true, isDestructive: true,
); );

View File

@ -5,17 +5,20 @@ import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart'; import '../../../masters/data/datasources/master_remote_data_source.dart';
import '../../../purchase_orders/data/repositories/purchase_order_repository_impl.dart'; import '../../../purchase_orders/data/repositories/purchase_order_repository_impl.dart';
import '../../../users/presentation/providers/users_provider.dart';
class GrnLookups { class GrnLookups {
const GrnLookups({ const GrnLookups({
this.warehouses = const [], this.warehouses = const [],
this.receivablePurchaseOrders = const [], this.receivablePurchaseOrders = const [],
this.assetCategories = const [], this.assetCategories = const [],
this.users = const [],
}); });
final List<FilterOptionModel> warehouses; final List<FilterOptionModel> warehouses;
final List<PurchaseOrderModel> receivablePurchaseOrders; final List<PurchaseOrderModel> receivablePurchaseOrders;
final List<FilterOptionModel> assetCategories; final List<FilterOptionModel> assetCategories;
final List<FilterOptionModel> users;
} }
final grnLookupsProvider = FutureProvider.autoDispose<GrnLookups>((ref) async { 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 warehouses = await _safeOptions(master.listWarehouses);
final assetCategories = await _safeOptions(master.listAssetCategories); final assetCategories = await _safeOptions(master.listAssetCategories);
final users = await _safeUserOptions(ref);
final receivablePos = <PurchaseOrderModel>[]; final receivablePos = <PurchaseOrderModel>[];
for (final status in ['APPROVED', 'PARTIALLY_RECEIVED']) { for (final status in ['APPROVED', 'PARTIALLY_RECEIVED']) {
@ -43,6 +47,7 @@ final grnLookupsProvider = FutureProvider.autoDispose<GrnLookups>((ref) async {
warehouses: warehouses, warehouses: warehouses,
receivablePurchaseOrders: receivablePos, receivablePurchaseOrders: receivablePos,
assetCategories: assetCategories, 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 = final grnPurchaseOrderProvider =
FutureProvider.autoDispose.family<PurchaseOrderModel?, String>((ref, poId) async { FutureProvider.autoDispose.family<PurchaseOrderModel?, String>((ref, poId) async {
final result = final result =

View File

@ -7,6 +7,7 @@ import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/widgets/app_card.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/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../providers/grn_lookups_provider.dart';
import '../providers/grn_provider.dart'; import '../providers/grn_provider.dart';
import '../widgets/grn_line_items_editor.dart'; import '../widgets/grn_line_items_editor.dart';
import '../widgets/grn_status_chip.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}); const _OverviewCard({required this.grn});
final GrnModel 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context); 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( return AppCard(
child: Padding( child: Padding(
@ -254,18 +266,16 @@ class _OverviewCard extends StatelessWidget {
_GrnInfo('Vehicle No', grn.vehicleNo ?? ''), _GrnInfo('Vehicle No', grn.vehicleNo ?? ''),
_GrnInfo('LR No', grn.lrNo ?? ''), _GrnInfo('LR No', grn.lrNo ?? ''),
_GrnInfo('LR Date', DateFormatter.displayDate(grn.lrDate)), _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; DateTime? _lrDate;
String? _selectedPoId; String? _selectedPoId;
int? _warehouseId; int? _warehouseId;
int? _receivedById;
int? _qualityCheckedById;
final List<GrnLineItemDraft> _lines = []; final List<GrnLineItemDraft> _lines = [];
bool _isSubmitting = false; bool _isSubmitting = false;
String? _populatedSignature; String? _populatedSignature;
@ -88,6 +90,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
_vehicleNoController.text = grn.vehicleNo ?? ''; _vehicleNoController.text = grn.vehicleNo ?? '';
_lrNoController.text = grn.lrNo ?? ''; _lrNoController.text = grn.lrNo ?? '';
_lrDate = grn.lrDate; _lrDate = grn.lrDate;
_receivedById = grn.receivedBy;
_qualityCheckedById = grn.qualityCheckedBy;
_remarksController.text = grn.remarks ?? ''; _remarksController.text = grn.remarks ?? '';
}); });
} }
@ -124,14 +128,6 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
.toList(); .toList();
} }
List<AppDropdownOption<String>> _assetCategoryOptions(
List<FilterOptionModel> categories,
) {
return categories
.map((e) => AppDropdownOption(value: e.id, label: e.name))
.toList();
}
Map<String, dynamic> _buildCreatePayload() { Map<String, dynamic> _buildCreatePayload() {
final poId = int.tryParse(_selectedPoId ?? ''); final poId = int.tryParse(_selectedPoId ?? '');
return { return {
@ -149,8 +145,9 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
'vehicle_no': _vehicleNoController.text.trim(), 'vehicle_no': _vehicleNoController.text.trim(),
if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(), if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(),
if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!), if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!),
if (_remarksController.text.trim().isNotEmpty) if (_receivedById != null) 'received_by': _receivedById,
'remarks': _remarksController.text.trim(), if (_qualityCheckedById != null) 'quality_checked_by': _qualityCheckedById,
'remarks': _remarksController.text.trim(),
'items': _lines.map((line) => line.toPayload()).toList(), 'items': _lines.map((line) => line.toPayload()).toList(),
}; };
} }
@ -168,8 +165,9 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
'vehicle_no': _vehicleNoController.text.trim(), 'vehicle_no': _vehicleNoController.text.trim(),
if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(), if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(),
if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!), if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!),
if (_remarksController.text.trim().isNotEmpty) if (_receivedById != null) 'received_by': _receivedById,
'remarks': _remarksController.text.trim(), if (_qualityCheckedById != null) 'quality_checked_by': _qualityCheckedById,
'remarks': _remarksController.text.trim(),
}; };
} }
@ -364,7 +362,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
FormRowThree( ResponsiveFormGrid(
children: [ children: [
_DateField( _DateField(
label: 'GRN Date *', label: 'GRN Date *',
@ -382,6 +380,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
label: 'Purchase Order *', label: 'Purchase Order *',
value: _selectedPoId, value: _selectedPoId,
searchHint: 'Search PO...', searchHint: 'Search PO...',
isDense: true,
options: poOptions, options: poOptions,
onChanged: (v) async { onChanged: (v) async {
setState(() => _selectedPoId = v); setState(() => _selectedPoId = v);
@ -408,16 +407,22 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
v == null ? 'Purchase order is required' : null, v == null ? 'Purchase order is required' : null,
) )
else else
InputDecorator( Padding(
decoration: const InputDecoration( padding: const EdgeInsets.only(top: 8),
labelText: 'Purchase Order', child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Purchase Order',
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
),
child: Text(existing?.poNumber ?? ''),
), ),
child: Text(existing?.poNumber ?? ''),
), ),
AppSearchableDropdown<int>( AppSearchableDropdown<int>(
label: 'Warehouse *', label: 'Warehouse *',
value: _dropdownValue(_warehouseId, warehouseIds), value: _dropdownValue(_warehouseId, warehouseIds),
searchHint: 'Search warehouse...', searchHint: 'Search warehouse...',
isDense: true,
options: _intOptions(lookups.warehouses), options: _intOptions(lookups.warehouses),
onChanged: widget.isEditing onChanged: widget.isEditing
? (_) {} ? (_) {}
@ -427,13 +432,10 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
: (v) => v == null ? 'Warehouse is required' : null, : (v) => v == null ? 'Warehouse is required' : null,
enabled: !widget.isEditing, enabled: !widget.isEditing,
), ),
],
),
FormRowThree(
children: [
AppTextField( AppTextField(
label: 'Vendor Invoice No', label: 'Vendor Invoice No',
controller: _vendorInvoiceNoController, controller: _vendorInvoiceNoController,
isDense: true,
), ),
_DateField( _DateField(
label: 'Vendor Invoice Date', label: 'Vendor Invoice Date',
@ -449,18 +451,17 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
controller: _vendorInvoiceAmountController, controller: _vendorInvoiceAmountController,
keyboardType: keyboardType:
const TextInputType.numberWithOptions(decimal: true), const TextInputType.numberWithOptions(decimal: true),
isDense: true,
), ),
],
),
FormRowThree(
children: [
AppTextField( AppTextField(
label: 'Vehicle No', label: 'Vehicle No',
controller: _vehicleNoController, controller: _vehicleNoController,
isDense: true,
), ),
AppTextField( AppTextField(
label: 'LR No', label: 'LR No',
controller: _lrNoController, controller: _lrNoController,
isDense: true,
), ),
_DateField( _DateField(
label: 'LR Date', label: 'LR Date',
@ -470,13 +471,37 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
onPicked: (d) => setState(() => _lrDate = d), 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) ...[ if (!widget.isEditing) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
@ -488,8 +513,6 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
GrnLineItemsEditor( GrnLineItemsEditor(
items: _lines, items: _lines,
assetCategoryOptions:
_assetCategoryOptions(lookups.assetCategories),
onChanged: () => setState(() {}), onChanged: () => setState(() {}),
), ),
] else ...[ ] else ...[
@ -539,22 +562,27 @@ class _DateField extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return Padding(
onTap: enabled ? onTap : null, padding: const EdgeInsets.only(top: 8),
borderRadius: BorderRadius.circular(8), child: InkWell(
child: InputDecorator( onTap: enabled ? onTap : null,
decoration: InputDecoration( borderRadius: BorderRadius.circular(8),
labelText: label, child: InputDecorator(
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18), decoration: InputDecoration(
enabled: enabled, labelText: label,
), floatingLabelBehavior: FloatingLabelBehavior.always,
child: Text( isDense: true,
value != null ? DateFormatter.displayDate(value) : 'Select date', suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
style: Theme.of(context).textTheme.bodyLarge?.copyWith( enabled: enabled,
color: value != null ),
? null child: Text(
: Theme.of(context).hintColor, 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_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_action_icon.dart';
@ -165,12 +164,21 @@ class _FiltersBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final searchField = SizedBox( final searchField = Padding(
width: wrapped ? double.infinity : null, padding: const EdgeInsets.only(top: 8),
child: AppSearchField( child: SizedBox(
controller: searchController, width: wrapped ? double.infinity : null,
hint: 'Search GRN number, PO, vendor...', child: TextField(
onChanged: onSearch, 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( return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Expanded(flex: 3, child: searchField), Expanded(flex: 3, child: searchField),
const SizedBox(width: 12), const SizedBox(width: 12),

View File

@ -1,12 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/theme/app_colors.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/grn_model.dart';
import '../../../../shared/models/purchase_order_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_data_table.dart';
import '../../../../shared/widgets/app_dropdown.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 '../../../../shared/widgets/app_text_field.dart';
import '../../../assets/presentation/providers/asset_categories_provider.dart';
import '../providers/grn_lookups_provider.dart';
String _formatQty(double value) { String _formatQty(double value) {
if (value % 1 == 0) return value.toInt().toString(); if (value % 1 == 0) return value.toInt().toString();
@ -22,22 +30,27 @@ class GrnLineItemDraft {
required this.receivedQty, required this.receivedQty,
required this.remainingQty, required this.remainingQty,
TextEditingController? acceptedQtyController, TextEditingController? acceptedQtyController,
TextEditingController? damagedQtyController, TextEditingController? rejectedQtyController,
TextEditingController? shortQtyController, TextEditingController? rateController,
TextEditingController? excessQtyController,
TextEditingController? batchNoController, TextEditingController? batchNoController,
TextEditingController? expiryDateController, TextEditingController? storageLocationController,
TextEditingController? rejectionReasonController,
TextEditingController? remarksController, TextEditingController? remarksController,
this.mfgDate,
this.expiryDate,
this.assetCategoryId, this.assetCategoryId,
this.assetSubcategoryId,
}) : acceptedQtyController = acceptedQtyController ?? }) : acceptedQtyController = acceptedQtyController ??
TextEditingController( TextEditingController(
text: remainingQty > 0 ? _formatQty(remainingQty) : '', text: remainingQty > 0 ? _formatQty(remainingQty) : '',
), ),
damagedQtyController = damagedQtyController ?? TextEditingController(), rejectedQtyController = rejectedQtyController ?? TextEditingController(),
shortQtyController = shortQtyController ?? TextEditingController(), rateController = rateController ?? TextEditingController(),
excessQtyController = excessQtyController ?? TextEditingController(),
batchNoController = batchNoController ?? TextEditingController(), batchNoController = batchNoController ?? TextEditingController(),
expiryDateController = expiryDateController ?? TextEditingController(), storageLocationController =
storageLocationController ?? TextEditingController(),
rejectionReasonController =
rejectionReasonController ?? TextEditingController(),
remarksController = remarksController ?? TextEditingController(); remarksController = remarksController ?? TextEditingController();
final String poItemId; final String poItemId;
@ -47,27 +60,28 @@ class GrnLineItemDraft {
final double receivedQty; final double receivedQty;
final double remainingQty; final double remainingQty;
final TextEditingController acceptedQtyController; final TextEditingController acceptedQtyController;
final TextEditingController damagedQtyController; final TextEditingController rejectedQtyController;
final TextEditingController shortQtyController; final TextEditingController rateController;
final TextEditingController excessQtyController;
final TextEditingController batchNoController; final TextEditingController batchNoController;
final TextEditingController expiryDateController; final TextEditingController storageLocationController;
final TextEditingController rejectionReasonController;
final TextEditingController remarksController; final TextEditingController remarksController;
String? assetCategoryId; DateTime? mfgDate;
DateTime? expiryDate;
int? assetCategoryId;
int? assetSubcategoryId;
double get acceptedQty => double.tryParse(acceptedQtyController.text.trim()) ?? 0; double get acceptedQty => double.tryParse(acceptedQtyController.text.trim()) ?? 0;
double get damagedQty => double.tryParse(damagedQtyController.text.trim()) ?? 0; double get rejectedQty => double.tryParse(rejectedQtyController.text.trim()) ?? 0;
double get shortQty => double.tryParse(shortQtyController.text.trim()) ?? 0; double get currentQty => acceptedQty + rejectedQty;
double get excessQty => double.tryParse(excessQtyController.text.trim()) ?? 0;
double get currentQty => acceptedQty + damagedQty + shortQty + excessQty;
void dispose() { void dispose() {
acceptedQtyController.dispose(); acceptedQtyController.dispose();
damagedQtyController.dispose(); rejectedQtyController.dispose();
shortQtyController.dispose(); rateController.dispose();
excessQtyController.dispose();
batchNoController.dispose(); batchNoController.dispose();
expiryDateController.dispose(); storageLocationController.dispose();
rejectionReasonController.dispose();
remarksController.dispose(); remarksController.dispose();
} }
@ -78,15 +92,19 @@ class GrnLineItemDraft {
'line_no': lineNo, 'line_no': lineNo,
'current_qty': currentQty, 'current_qty': currentQty,
'accepted_qty': acceptedQty, 'accepted_qty': acceptedQty,
if (damagedQty > 0) 'damaged_qty': damagedQty, if (rejectedQty > 0) 'rejected_qty': rejectedQty,
if (shortQty > 0) 'short_qty': shortQty, if (rejectionReasonController.text.trim().isNotEmpty)
if (excessQty > 0) 'excess_qty': excessQty, 'rejection_reason': rejectionReasonController.text.trim(),
if (rateController.text.trim().isNotEmpty)
'rate': double.tryParse(rateController.text.trim()),
if (batchNoController.text.trim().isNotEmpty) if (batchNoController.text.trim().isNotEmpty)
'batch_no': batchNoController.text.trim(), 'batch_no': batchNoController.text.trim(),
if (expiryDateController.text.trim().isNotEmpty) if (mfgDate != null) 'mfg_date': DateFormatter.toApiDate(mfgDate!),
'expiry_date': expiryDateController.text.trim(), if (expiryDate != null) 'expiry_date': DateFormatter.toApiDate(expiryDate!),
if (assetCategoryId != null && assetCategoryId!.isNotEmpty) if (storageLocationController.text.trim().isNotEmpty)
'asset_category_id': int.tryParse(assetCategoryId!) ?? assetCategoryId, 'storage_location': storageLocationController.text.trim(),
if (assetCategoryId != null) 'asset_category_id': assetCategoryId,
if (assetSubcategoryId != null) 'asset_subcategory_id': assetSubcategoryId,
if (remarksController.text.trim().isNotEmpty) if (remarksController.text.trim().isNotEmpty)
'remarks': remarksController.text.trim(), 'remarks': remarksController.text.trim(),
}; };
@ -98,6 +116,7 @@ List<GrnLineItemDraft> draftsFromPurchaseOrder(PurchaseOrderModel po) {
final ordered = item.orderedQty ?? 0; final ordered = item.orderedQty ?? 0;
final received = item.receivedQty ?? 0; final received = item.receivedQty ?? 0;
final remaining = (ordered - received).clamp(0.0, double.infinity); final remaining = (ordered - received).clamp(0.0, double.infinity);
final rate = item.rate;
return GrnLineItemDraft( return GrnLineItemDraft(
poItemId: item.id, poItemId: item.id,
lineNo: item.lineNo ?? 1, lineNo: item.lineNo ?? 1,
@ -105,6 +124,9 @@ List<GrnLineItemDraft> draftsFromPurchaseOrder(PurchaseOrderModel po) {
orderedQty: ordered, orderedQty: ordered,
receivedQty: received, receivedQty: received,
remainingQty: remaining, remainingQty: remaining,
rateController: TextEditingController(
text: rate != null ? _formatQty(rate) : '',
),
); );
}).toList(); }).toList();
} }
@ -114,13 +136,11 @@ class GrnLineItemsEditor extends StatelessWidget {
super.key, super.key,
required this.items, required this.items,
required this.onChanged, required this.onChanged,
this.assetCategoryOptions = const [],
this.readOnly = false, this.readOnly = false,
}); });
final List<GrnLineItemDraft> items; final List<GrnLineItemDraft> items;
final VoidCallback onChanged; final VoidCallback onChanged;
final List<AppDropdownOption<String>> assetCategoryOptions;
final bool readOnly; final bool readOnly;
@override @override
@ -152,7 +172,6 @@ class GrnLineItemsEditor extends StatelessWidget {
padding: EdgeInsets.only(bottom: i == items.length - 1 ? 0 : 12), padding: EdgeInsets.only(bottom: i == items.length - 1 ? 0 : 12),
child: _GrnLineItemCard( child: _GrnLineItemCard(
item: items[i], item: items[i],
assetCategoryOptions: assetCategoryOptions,
readOnly: readOnly, readOnly: readOnly,
onChanged: onChanged, onChanged: onChanged,
), ),
@ -162,25 +181,80 @@ class GrnLineItemsEditor extends StatelessWidget {
} }
} }
class _GrnLineItemCard extends StatelessWidget { class _GrnLineItemCard extends ConsumerWidget {
const _GrnLineItemCard({ const _GrnLineItemCard({
required this.item, required this.item,
required this.onChanged, required this.onChanged,
required this.assetCategoryOptions,
required this.readOnly, required this.readOnly,
}); });
final GrnLineItemDraft item; final GrnLineItemDraft item;
final VoidCallback onChanged; final VoidCallback onChanged;
final List<AppDropdownOption<String>> assetCategoryOptions;
final bool readOnly; final bool readOnly;
static final _qtyFormatters = [ static final _qtyFormatters = [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,4}')), 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 @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( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -218,94 +292,20 @@ class _GrnLineItemCard extends StatelessWidget {
), ),
), ),
if (!readOnly) ...[ if (!readOnly) ...[
const SizedBox(height: 12), ResponsiveFormGrid(
Row( children: _buildLineItemFields(
children: [ context: context,
Expanded( categoryOptions: categoryOptions,
child: AppTextField( categoryIds: categoryIds,
label: 'Accepted Qty *', subcategoryOptions: subcategoryOptions,
controller: item.acceptedQtyController, subcategoryIds: subcategoryIds,
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();
},
), ),
],
const SizedBox(height: 12),
AppTextField(
label: 'Remarks',
controller: item.remarksController,
maxLines: 2,
onChanged: (_) => onChanged(),
), ),
] else ...[ ] else ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Accepted: ${_formatQty(item.acceptedQty)} · ' 'Accepted: ${_formatQty(item.acceptedQty)} · '
'Rejected: ${_formatQty(item.rejectedQty)} · '
'Current: ${_formatQty(item.currentQty)}', 'Current: ${_formatQty(item.currentQty)}',
style: Theme.of(context).textTheme.bodySmall, 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 { class GrnItemsTable extends StatelessWidget {
@ -344,25 +498,47 @@ class GrnItemsTable extends StatelessWidget {
cellBuilder: (_, item) => Text(_formatQty(item.acceptedQty ?? 0)), cellBuilder: (_, item) => Text(_formatQty(item.acceptedQty ?? 0)),
), ),
AppDataColumn( AppDataColumn(
label: 'Damaged', label: 'Rejected',
flex: 1, flex: 1,
cellBuilder: (_, item) => Text(_formatQty(item.damagedQty ?? 0)), cellBuilder: (_, item) => Text(_formatQty(item.rejectedQty ?? 0)),
), ),
AppDataColumn( AppDataColumn(
label: 'Short', label: 'Rate',
flex: 1, flex: 1,
cellBuilder: (_, item) => Text(_formatQty(item.shortQty ?? 0)), cellBuilder: (_, item) => Text(
), item.rate != null ? _formatQty(item.rate!) : '',
AppDataColumn( ),
label: 'Excess',
flex: 1,
cellBuilder: (_, item) => Text(_formatQty(item.excessQty ?? 0)),
), ),
AppDataColumn( AppDataColumn(
label: 'Batch', label: 'Batch',
flex: 1, flex: 1,
cellBuilder: (_, item) => Text(item.batchNo ?? ''), 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, rows: items,
); );

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/utils/responsive_utils.dart';
Future<T?> showSidePanel<T>( Future<T?> showSidePanel<T>(
BuildContext context, BuildContext context,
Widget panel, { 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. /// Responsive row of equal-width form fields.
class FormRow extends StatelessWidget { class FormRow extends StatelessWidget {
const FormRow({ const FormRow({