bug fix
This commit is contained in:
parent
1594274fa2
commit
ce0979d782
@ -142,6 +142,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
|
||||
Map<String, dynamic> _buildCreatePayload() {
|
||||
final poId = int.tryParse(_selectedPoId ?? '');
|
||||
final receivableLines =
|
||||
_lines.where((line) => line.currentQty > 0).toList();
|
||||
final payload = <String, dynamic>{
|
||||
'grn_date': DateFormatter.toApiDate(_grnDate ?? DateTime.now()),
|
||||
'po_id': poId,
|
||||
@ -162,7 +164,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
_putOptionalUserId(payload, 'received_by', _receivedById);
|
||||
_putOptionalUserId(payload, 'quality_checked_by', _qualityCheckedById);
|
||||
payload['remarks'] = _remarksController.text.trim();
|
||||
payload['items'] = _lines.map((line) => line.toPayload()).toList();
|
||||
payload['items'] =
|
||||
receivableLines.map((line) => line.toPayload()).toList();
|
||||
return payload;
|
||||
}
|
||||
|
||||
@ -188,14 +191,21 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
}
|
||||
|
||||
String? _lineItemsError() {
|
||||
if (_lines.isEmpty) return 'Add at least one line item with quantity';
|
||||
for (final line in _lines) {
|
||||
if (line.currentQty <= 0) {
|
||||
return 'Enter quantity for line ${line.lineNo}';
|
||||
}
|
||||
final receivableLines =
|
||||
_lines.where((line) => line.currentQty > 0).toList();
|
||||
if (receivableLines.isEmpty) {
|
||||
return 'Enter quantity for at least one line item';
|
||||
}
|
||||
for (final line in receivableLines) {
|
||||
if (line.acceptedQty < 0) {
|
||||
return 'Accepted quantity must be zero or more for line ${line.lineNo}';
|
||||
}
|
||||
if (line.acceptedQty > line.orderedQty) {
|
||||
return 'Line ${line.lineNo}: accepted qty cannot exceed ordered qty (${_formatOrdered(line.orderedQty)})';
|
||||
}
|
||||
if (line.currentQty > line.remainingQty) {
|
||||
return 'Line ${line.lineNo}: quantity cannot exceed remaining (${_formatOrdered(line.remainingQty)})';
|
||||
}
|
||||
if (line.rejectedQty > 0 &&
|
||||
line.rejectionReasonController.text.trim().isEmpty) {
|
||||
return 'Rejection reason is required for line ${line.lineNo}';
|
||||
@ -204,6 +214,11 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
return null;
|
||||
}
|
||||
|
||||
String _formatOrdered(double value) {
|
||||
if (value % 1 == 0) return value.toInt().toString();
|
||||
return value.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final formState = _formKey.currentState;
|
||||
if (formState == null) return;
|
||||
|
||||
@ -332,6 +332,26 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: _qtyFormatters,
|
||||
isDense: true,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
onChanged: (_) => widget.onChanged(),
|
||||
validator: (value) {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.isEmpty) return null;
|
||||
final accepted = double.tryParse(text);
|
||||
if (accepted == null) return 'Enter a valid quantity';
|
||||
if (accepted < 0) return 'Cannot be negative';
|
||||
if (accepted > item.orderedQty) {
|
||||
return 'Cannot exceed ordered qty (${_formatQty(item.orderedQty)})';
|
||||
}
|
||||
if (accepted > item.remainingQty) {
|
||||
return 'Cannot exceed remaining qty (${_formatQty(item.remainingQty)})';
|
||||
}
|
||||
final current = accepted + item.rejectedQty;
|
||||
if (current > item.remainingQty) {
|
||||
return 'Accepted + rejected exceeds remaining (${_formatQty(item.remainingQty)})';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-rejected'),
|
||||
@ -342,6 +362,20 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: _qtyFormatters,
|
||||
isDense: true,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
onChanged: (_) => widget.onChanged(),
|
||||
validator: (value) {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.isEmpty) return null;
|
||||
final rejected = double.tryParse(text);
|
||||
if (rejected == null) return 'Enter a valid quantity';
|
||||
if (rejected < 0) return 'Cannot be negative';
|
||||
final current = item.acceptedQty + rejected;
|
||||
if (current > item.remainingQty) {
|
||||
return 'Accepted + rejected exceeds remaining (${_formatQty(item.remainingQty)})';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_GrnLineReadOnlyField(
|
||||
key: ValueKey('$lineKey-current'),
|
||||
|
||||
@ -514,7 +514,7 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
|
||||
}
|
||||
final nested = row[baseKey];
|
||||
if (nested is Map) {
|
||||
for (final nestedKey in ['code', 'name', 'description']) {
|
||||
for (final nestedKey in ['name', 'code', 'description']) {
|
||||
final nestedValue = nested[nestedKey];
|
||||
if (nestedValue != null &&
|
||||
nestedValue.toString().trim().isNotEmpty) {
|
||||
|
||||
@ -84,6 +84,22 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
String _fieldLabel(MasterFieldDef field) =>
|
||||
field.required ? '${field.label} *' : field.label;
|
||||
|
||||
/// Matches [AppTextField] / [AppSearchableDropdown] label clearance.
|
||||
Widget _wrapField(Widget field) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: field,
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _inputDecoration(MasterFieldDef field) {
|
||||
return InputDecoration(
|
||||
labelText: _fieldLabel(field),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
alignLabelWithHint: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField(
|
||||
BuildContext context, {
|
||||
required MasterFieldDef field,
|
||||
@ -213,15 +229,17 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
);
|
||||
|
||||
case MasterFieldType.number:
|
||||
return TextFormField(
|
||||
key: ValueKey('$_formSessionId-${field.key}'),
|
||||
initialValue: value?.toString() ?? '',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(labelText: _fieldLabel(field)),
|
||||
validator: field.required
|
||||
? (v) => Validators.required(v, fieldName: field.label)
|
||||
: null,
|
||||
onChanged: (text) => notifier.updateValue(field.key, text),
|
||||
return _wrapField(
|
||||
TextFormField(
|
||||
key: ValueKey('$_formSessionId-${field.key}'),
|
||||
initialValue: value?.toString() ?? '',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: _inputDecoration(field),
|
||||
validator: field.required
|
||||
? (v) => Validators.required(v, fieldName: field.label)
|
||||
: null,
|
||||
onChanged: (text) => notifier.updateValue(field.key, text),
|
||||
),
|
||||
);
|
||||
|
||||
case MasterFieldType.text:
|
||||
@ -230,50 +248,52 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
final formatters = isHsnCodeField
|
||||
? Validators.hsnCodeInput
|
||||
: Validators.inputFormattersForFieldKey(field.key);
|
||||
return TextFormField(
|
||||
key: ValueKey('$_formSessionId-${field.key}'),
|
||||
initialValue: value?.toString() ?? '',
|
||||
maxLines: field.multiline ? 3 : 1,
|
||||
keyboardType: isHsnCodeField
|
||||
? TextInputType.number
|
||||
: _keyboardTypeForFieldKey(field.key),
|
||||
inputFormatters: formatters.isEmpty ? null : formatters,
|
||||
decoration: InputDecoration(labelText: _fieldLabel(field)),
|
||||
validator: (v) {
|
||||
if (isHsnCodeField) {
|
||||
return Validators.uniqueHsnCode(
|
||||
return _wrapField(
|
||||
TextFormField(
|
||||
key: ValueKey('$_formSessionId-${field.key}'),
|
||||
initialValue: value?.toString() ?? '',
|
||||
maxLines: field.multiline ? 3 : 1,
|
||||
keyboardType: isHsnCodeField
|
||||
? TextInputType.number
|
||||
: _keyboardTypeForFieldKey(field.key),
|
||||
inputFormatters: formatters.isEmpty ? null : formatters,
|
||||
decoration: _inputDecoration(field),
|
||||
validator: (v) {
|
||||
if (isHsnCodeField) {
|
||||
return Validators.uniqueHsnCode(
|
||||
v,
|
||||
existingRecords: formState.existingRecords,
|
||||
currentRecordId: widget.recordId,
|
||||
fieldName: field.label,
|
||||
);
|
||||
}
|
||||
if (Validators.isMasterNameFieldKey(field.key)) {
|
||||
return Validators.uniqueMasterName(
|
||||
v,
|
||||
nameKey: field.key,
|
||||
existingRecords: formState.existingRecords,
|
||||
currentRecordId: widget.recordId,
|
||||
fieldName: field.label,
|
||||
);
|
||||
}
|
||||
if (Validators.isMasterCodeFieldKey(field.key)) {
|
||||
return Validators.uniqueMasterCode(
|
||||
v,
|
||||
codeKey: field.key,
|
||||
existingRecords: formState.existingRecords,
|
||||
currentRecordId: widget.recordId,
|
||||
fieldName: field.label,
|
||||
);
|
||||
}
|
||||
return Validators.forFieldKey(
|
||||
field.key,
|
||||
v,
|
||||
existingRecords: formState.existingRecords,
|
||||
currentRecordId: widget.recordId,
|
||||
required: field.required,
|
||||
fieldName: field.label,
|
||||
);
|
||||
}
|
||||
if (Validators.isMasterNameFieldKey(field.key)) {
|
||||
return Validators.uniqueMasterName(
|
||||
v,
|
||||
nameKey: field.key,
|
||||
existingRecords: formState.existingRecords,
|
||||
currentRecordId: widget.recordId,
|
||||
fieldName: field.label,
|
||||
);
|
||||
}
|
||||
if (Validators.isMasterCodeFieldKey(field.key)) {
|
||||
return Validators.uniqueMasterCode(
|
||||
v,
|
||||
codeKey: field.key,
|
||||
existingRecords: formState.existingRecords,
|
||||
currentRecordId: widget.recordId,
|
||||
fieldName: field.label,
|
||||
);
|
||||
}
|
||||
return Validators.forFieldKey(
|
||||
field.key,
|
||||
v,
|
||||
required: field.required,
|
||||
fieldName: field.label,
|
||||
);
|
||||
},
|
||||
onChanged: (text) => notifier.updateValue(field.key, text),
|
||||
},
|
||||
onChanged: (text) => notifier.updateValue(field.key, text),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -328,7 +348,10 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildField(context, field: left, formState: formState),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _buildField(context, field: left, formState: formState),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -73,9 +73,16 @@ class MasterRemoteDataSource {
|
||||
uomByItemId[id] = _asInt(item['uom_id']);
|
||||
gstRateByItemId[id] = _asInt(item['gst_rate_id']);
|
||||
|
||||
final name = _optionLabel(item);
|
||||
if (name.isEmpty) continue;
|
||||
options.add(FilterOptionModel(id: id, name: name));
|
||||
final itemName = (item['item_name'] ?? item['name'])?.toString().trim() ?? '';
|
||||
if (itemName.isEmpty) continue;
|
||||
final itemCode = item['item_code']?.toString().trim();
|
||||
options.add(
|
||||
FilterOptionModel(
|
||||
id: id,
|
||||
name: itemName,
|
||||
slug: (itemCode == null || itemCode.isEmpty) ? null : itemCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -246,13 +253,8 @@ class MasterRemoteDataSource {
|
||||
? ratePct.toDouble()
|
||||
: double.tryParse(ratePct.toString());
|
||||
if (rate != null) {
|
||||
final rateLabel =
|
||||
rate % 1 == 0 ? '${rate.toInt()}%' : '$rate%';
|
||||
final desc = item['description'];
|
||||
if (desc is String && desc.trim().isNotEmpty) {
|
||||
return '$rateLabel — ${desc.trim()}';
|
||||
}
|
||||
return rateLabel;
|
||||
// Dropdowns show the rate only (e.g. "5%"), not the description.
|
||||
return rate % 1 == 0 ? '${rate.toInt()}%' : '$rate%';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -156,9 +156,16 @@ Future<List<FilterOptionModel>> _fetchActiveVendors(
|
||||
|
||||
final data = result.data!;
|
||||
vendors.addAll(
|
||||
data.items.map(
|
||||
(vendor) => FilterOptionModel(id: vendor.id, name: vendor.vendorName),
|
||||
),
|
||||
data.items
|
||||
.where((vendor) {
|
||||
if (!vendor.isActive) return false;
|
||||
final status = (vendor.status ?? '').trim().toLowerCase();
|
||||
return status != 'blacklisted';
|
||||
})
|
||||
.map(
|
||||
(vendor) =>
|
||||
FilterOptionModel(id: vendor.id, name: vendor.vendorName),
|
||||
),
|
||||
);
|
||||
totalPages = data.totalPages;
|
||||
page++;
|
||||
|
||||
@ -811,10 +811,23 @@ class _AmountSummaryCard extends StatelessWidget {
|
||||
final TextEditingController discountController;
|
||||
final bool isEditing;
|
||||
|
||||
String? _validateDiscount(String? value) {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.isEmpty) return null;
|
||||
final discount = double.tryParse(text);
|
||||
if (discount == null) return 'Enter a valid amount';
|
||||
if (discount < 0) return 'Cannot be negative';
|
||||
if (discount > totals.maxDiscountAmount) {
|
||||
return 'Cannot exceed ${CurrencyFormatter.format(totals.maxDiscountAmount)}';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final discountValue = double.tryParse(discountController.text.trim()) ?? 0;
|
||||
final discountInvalid = _validateDiscount(discountController.text) != null;
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final primaryTint = theme.colorScheme.primary.withValues(
|
||||
alpha: isDark ? 0.22 : 0.1,
|
||||
@ -846,7 +859,11 @@ class _AmountSummaryCard extends StatelessWidget {
|
||||
_SummaryInputRow(
|
||||
label: 'Discount amount',
|
||||
controller: discountController,
|
||||
valueColor: discountValue > 0 ? AppColors.error : null,
|
||||
valueColor: discountInvalid || discountValue > 0
|
||||
? AppColors.error
|
||||
: null,
|
||||
validator: _validateDiscount,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
@ -928,11 +945,15 @@ class _SummaryInputRow extends StatelessWidget {
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.valueColor,
|
||||
this.validator,
|
||||
this.autovalidateMode,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final Color? valueColor;
|
||||
final String? Function(String?)? validator;
|
||||
final AutovalidateMode? autovalidateMode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -940,21 +961,28 @@ class _SummaryInputRow extends StatelessWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
width: 140,
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
textAlign: TextAlign.right,
|
||||
autovalidateMode: autovalidateMode,
|
||||
validator: validator,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: valueColor,
|
||||
@ -968,6 +996,11 @@ class _SummaryInputRow extends StatelessWidget {
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
errorMaxLines: 2,
|
||||
errorStyle: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -60,16 +60,21 @@ class PoOrderTotals {
|
||||
required this.taxableAmount,
|
||||
required this.taxAmount,
|
||||
required this.grandTotal,
|
||||
required this.maxDiscountAmount,
|
||||
});
|
||||
|
||||
final double taxableAmount;
|
||||
final double taxAmount;
|
||||
final double grandTotal;
|
||||
|
||||
/// Taxable + Tax + Freight + Other (discount cannot exceed this).
|
||||
final double maxDiscountAmount;
|
||||
|
||||
static const zero = PoOrderTotals(
|
||||
taxableAmount: 0,
|
||||
taxAmount: 0,
|
||||
grandTotal: 0,
|
||||
maxDiscountAmount: 0,
|
||||
);
|
||||
|
||||
/// 3.5 Taxable = sum of Line Amounts
|
||||
@ -87,11 +92,15 @@ class PoOrderTotals {
|
||||
taxable += line.lineAmount;
|
||||
tax += line.gstAmount;
|
||||
}
|
||||
final grandTotal = taxable + tax + freight + otherCharges - discountAmount;
|
||||
final maxDiscount = taxable + tax + freight + otherCharges;
|
||||
final clampedDiscount =
|
||||
discountAmount < 0 ? 0.0 : discountAmount;
|
||||
final grandTotalRaw = maxDiscount - clampedDiscount;
|
||||
return PoOrderTotals(
|
||||
taxableAmount: taxable,
|
||||
taxAmount: tax,
|
||||
grandTotal: grandTotal,
|
||||
grandTotal: grandTotalRaw < 0 ? 0 : grandTotalRaw,
|
||||
maxDiscountAmount: maxDiscount < 0 ? 0 : maxDiscount,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -410,7 +419,12 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
.map((e) {
|
||||
final id = _parseId(e.id);
|
||||
if (id == null) return null;
|
||||
return AppDropdownOption(value: id, label: e.name);
|
||||
final code = e.slug?.trim();
|
||||
return AppDropdownOption(
|
||||
value: id,
|
||||
label: e.name,
|
||||
subtitle: (code == null || code.isEmpty) ? null : code,
|
||||
);
|
||||
})
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList();
|
||||
@ -427,107 +441,158 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
...widget.gstRates.map((e) {
|
||||
final id = _parseId(e.id);
|
||||
if (id == null) return null;
|
||||
return AppDropdownOption<int?>(value: id, label: e.name);
|
||||
final pct = widget.gstRatePctById[e.id];
|
||||
final label = pct != null
|
||||
? (pct % 1 == 0 ? '${pct.toInt()}%' : '$pct%')
|
||||
: e.name.split(' — ').first.trim();
|
||||
return AppDropdownOption<int?>(value: id, label: label);
|
||||
}),
|
||||
].whereType<AppDropdownOption<int?>>().toList();
|
||||
|
||||
const spacing = 8.0;
|
||||
const minRowWidth = 1040.0;
|
||||
|
||||
Widget flex({required int flex, required Widget child}) {
|
||||
return Expanded(flex: flex, child: child);
|
||||
}
|
||||
|
||||
final fields = <Widget>[
|
||||
flex(
|
||||
flex: 3,
|
||||
child: MasterQuickAddDropdown<int>(
|
||||
key: ValueKey('$lineKey-item'),
|
||||
masterId: 'items',
|
||||
label: 'Item *',
|
||||
value: line.itemId,
|
||||
hint: 'Select item',
|
||||
searchHint: 'Search item name or code...',
|
||||
options: itemOptions,
|
||||
refreshLookups: () =>
|
||||
ref.invalidate(purchaseOrderLookupsProvider),
|
||||
parseCreatedId: int.tryParse,
|
||||
onChanged: _onItemChanged,
|
||||
validator: (v) => v == null ? 'Item is required' : null,
|
||||
),
|
||||
),
|
||||
flex(
|
||||
flex: 1,
|
||||
child: AppTextField(
|
||||
key: ValueKey('$lineKey-qty'),
|
||||
controller: line.qtyController,
|
||||
label: 'Qty *',
|
||||
hint: '0',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Required';
|
||||
final qty = double.tryParse(v);
|
||||
if (qty == null || qty <= 0) return 'Invalid';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
flex(
|
||||
flex: 2,
|
||||
child: MasterQuickAddDropdown<int>(
|
||||
key: ValueKey('$lineKey-uom'),
|
||||
masterId: 'uom',
|
||||
label: 'UOM *',
|
||||
value: line.uomId,
|
||||
hint: 'Select UOM',
|
||||
searchHint: 'Search UOM...',
|
||||
options: uomOptions,
|
||||
refreshLookups: () =>
|
||||
ref.invalidate(purchaseOrderLookupsProvider),
|
||||
parseCreatedId: int.tryParse,
|
||||
onChanged: (v) => _updateLine(() => line.uomId = v),
|
||||
validator: (v) => v == null ? 'Required' : null,
|
||||
),
|
||||
),
|
||||
flex(
|
||||
flex: 1,
|
||||
child: AppTextField(
|
||||
key: ValueKey('$lineKey-rate'),
|
||||
controller: line.rateController,
|
||||
label: 'Rate *',
|
||||
hint: '0.00',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Required';
|
||||
final rate = double.tryParse(v);
|
||||
if (rate == null || rate < 0) return 'Invalid';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
flex(
|
||||
flex: 1,
|
||||
child: AppTextField(
|
||||
key: ValueKey('$lineKey-discount'),
|
||||
controller: line.discountController,
|
||||
label: 'Disc %',
|
||||
hint: '0',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
),
|
||||
flex(
|
||||
flex: 2,
|
||||
child: MasterQuickAddDropdown<int?>(
|
||||
key: ValueKey('$lineKey-gst'),
|
||||
masterId: 'gst_rates',
|
||||
label: 'GST %',
|
||||
value: line.gstRateId,
|
||||
hint: 'Select',
|
||||
searchHint: 'Search GST %...',
|
||||
options: gstOptions,
|
||||
refreshLookups: () =>
|
||||
ref.invalidate(purchaseOrderLookupsProvider),
|
||||
parseCreatedId: int.tryParse,
|
||||
onChanged: (v) => _updateLine(() => line.gstRateId = v),
|
||||
),
|
||||
),
|
||||
flex(
|
||||
flex: 2,
|
||||
child: _AmountWithRemove(
|
||||
amount: CurrencyFormatter.format(calc.lineAmount),
|
||||
backgroundColor: amountBg,
|
||||
onRemove: widget.onRemove,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: borderColor),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: FormRow(
|
||||
columnCount: 12,
|
||||
spans: const [3, 1, 2, 1, 1, 2, 2],
|
||||
spacing: 8,
|
||||
stackBelowWidth: 1100,
|
||||
children: [
|
||||
MasterQuickAddDropdown<int>(
|
||||
key: ValueKey('$lineKey-item'),
|
||||
masterId: 'items',
|
||||
label: 'Item *',
|
||||
value: line.itemId,
|
||||
hint: 'Select item',
|
||||
searchHint: 'Search item...',
|
||||
options: itemOptions,
|
||||
refreshLookups: () =>
|
||||
ref.invalidate(purchaseOrderLookupsProvider),
|
||||
parseCreatedId: int.tryParse,
|
||||
onChanged: _onItemChanged,
|
||||
validator: (v) => v == null ? 'Item is required' : null,
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-qty'),
|
||||
controller: line.qtyController,
|
||||
label: 'Qty *',
|
||||
hint: '0',
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) {
|
||||
return 'Required';
|
||||
}
|
||||
final qty = double.tryParse(v);
|
||||
if (qty == null || qty <= 0) return 'Invalid';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
MasterQuickAddDropdown<int>(
|
||||
key: ValueKey('$lineKey-uom'),
|
||||
masterId: 'uom',
|
||||
label: 'UOM *',
|
||||
value: line.uomId,
|
||||
hint: 'Select UOM',
|
||||
searchHint: 'Search UOM...',
|
||||
options: uomOptions,
|
||||
refreshLookups: () =>
|
||||
ref.invalidate(purchaseOrderLookupsProvider),
|
||||
parseCreatedId: int.tryParse,
|
||||
onChanged: (v) => _updateLine(() => line.uomId = v),
|
||||
validator: (v) => v == null ? 'Required' : null,
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-rate'),
|
||||
controller: line.rateController,
|
||||
label: 'Rate *',
|
||||
hint: '0.00',
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Required';
|
||||
final rate = double.tryParse(v);
|
||||
if (rate == null || rate < 0) return 'Invalid';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-discount'),
|
||||
controller: line.discountController,
|
||||
label: 'Disc %',
|
||||
hint: '0',
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
MasterQuickAddDropdown<int?>(
|
||||
key: ValueKey('$lineKey-gst'),
|
||||
masterId: 'gst_rates',
|
||||
label: 'GST rate',
|
||||
value: line.gstRateId,
|
||||
hint: 'Select GST rate',
|
||||
searchHint: 'Search GST rate...',
|
||||
options: gstOptions,
|
||||
refreshLookups: () =>
|
||||
ref.invalidate(purchaseOrderLookupsProvider),
|
||||
parseCreatedId: int.tryParse,
|
||||
onChanged: (v) => _updateLine(() => line.gstRateId = v),
|
||||
),
|
||||
_AmountWithRemove(
|
||||
amount: CurrencyFormatter.format(calc.lineAmount),
|
||||
backgroundColor: amountBg,
|
||||
onRemove: widget.onRemove,
|
||||
),
|
||||
],
|
||||
child: QuickAddInlineHost(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final rowWidth = constraints.maxWidth < minRowWidth
|
||||
? minRowWidth
|
||||
: constraints.maxWidth;
|
||||
final row = SizedBox(
|
||||
width: rowWidth,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var i = 0; i < fields.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: spacing),
|
||||
fields[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (constraints.maxWidth < minRowWidth) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: row,
|
||||
);
|
||||
}
|
||||
return row;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,10 +3,31 @@ import 'package:flutter/material.dart';
|
||||
import 'app_searchable_dropdown.dart';
|
||||
|
||||
class AppDropdownOption<T> {
|
||||
const AppDropdownOption({required this.value, required this.label});
|
||||
const AppDropdownOption({
|
||||
required this.value,
|
||||
required this.label,
|
||||
this.subtitle,
|
||||
this.searchText,
|
||||
});
|
||||
|
||||
final T value;
|
||||
final String label;
|
||||
|
||||
/// Secondary line under [label] in the picker (e.g. item code).
|
||||
final String? subtitle;
|
||||
|
||||
/// Extra searchable text (defaults to [label] + [subtitle]).
|
||||
final String? searchText;
|
||||
|
||||
String get searchable =>
|
||||
(searchText ?? [label, if (subtitle != null) subtitle!].join(' '))
|
||||
.toLowerCase();
|
||||
|
||||
bool matchesQuery(String query) {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.isEmpty) return true;
|
||||
return searchable.contains(q);
|
||||
}
|
||||
}
|
||||
|
||||
/// Searchable dropdown — all app dropdowns use [AppSearchableDropdown] under the hood.
|
||||
|
||||
@ -237,15 +237,22 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
||||
_overlayEntry != null
|
||||
? Icons.arrow_drop_up
|
||||
: Icons.arrow_drop_down,
|
||||
size: 24,
|
||||
color: canOpen
|
||||
? colors.onSurfaceVariant
|
||||
: theme.disabledColor,
|
||||
),
|
||||
// Match AppTextField height (default icon box is 48px).
|
||||
suffixIconConstraints: const BoxConstraints(
|
||||
minWidth: 40,
|
||||
minHeight: 40,
|
||||
),
|
||||
enabled: canOpen,
|
||||
),
|
||||
child: Text(
|
||||
displayLabel ?? '\u00A0',
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: displayLabel == null
|
||||
@ -301,9 +308,7 @@ class _SearchableDropdownPanelState<T>
|
||||
List<AppDropdownOption<T>> get _filtered {
|
||||
final q = _query.trim().toLowerCase();
|
||||
if (q.isEmpty) return widget.options;
|
||||
return widget.options
|
||||
.where((option) => option.label.toLowerCase().contains(q))
|
||||
.toList();
|
||||
return widget.options.where((option) => option.matchesQuery(q)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
@ -364,6 +369,7 @@ class _SearchableDropdownPanelState<T>
|
||||
itemBuilder: (context, index) {
|
||||
final option = filtered[index];
|
||||
final isSelected = option.value == widget.selected;
|
||||
final subtitle = option.subtitle?.trim();
|
||||
return ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
@ -371,6 +377,15 @@ class _SearchableDropdownPanelState<T>
|
||||
option.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: subtitle == null || subtitle.isEmpty
|
||||
? null
|
||||
: Text(
|
||||
subtitle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
@ -603,15 +618,21 @@ class _AppSearchableLookupFieldState<T>
|
||||
_overlayEntry != null
|
||||
? Icons.arrow_drop_up
|
||||
: Icons.arrow_drop_down,
|
||||
size: 24,
|
||||
color: canOpen
|
||||
? colors.onSurfaceVariant
|
||||
: theme.disabledColor,
|
||||
),
|
||||
suffixIconConstraints: const BoxConstraints(
|
||||
minWidth: 40,
|
||||
minHeight: 40,
|
||||
),
|
||||
enabled: canOpen,
|
||||
),
|
||||
child: Text(
|
||||
displayLabel ?? '\u00A0',
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: displayLabel == null
|
||||
|
||||
@ -197,10 +197,15 @@ class _AppSearchableMultiSelectDropdownState<T>
|
||||
_overlayEntry != null
|
||||
? Icons.arrow_drop_up
|
||||
: Icons.arrow_drop_down,
|
||||
size: 24,
|
||||
color: canOpen
|
||||
? colors.onSurfaceVariant
|
||||
: theme.disabledColor,
|
||||
),
|
||||
suffixIconConstraints: const BoxConstraints(
|
||||
minWidth: 40,
|
||||
minHeight: 40,
|
||||
),
|
||||
enabled: canOpen,
|
||||
),
|
||||
child: displayText.isEmpty
|
||||
@ -262,9 +267,7 @@ class _SearchableMultiSelectPanelState<T>
|
||||
List<AppDropdownOption<T>> get _filtered {
|
||||
final q = _query.trim().toLowerCase();
|
||||
if (q.isEmpty) return widget.options;
|
||||
return widget.options
|
||||
.where((option) => option.label.toLowerCase().contains(q))
|
||||
.toList();
|
||||
return widget.options.where((option) => option.matchesQuery(q)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
@ -328,6 +331,17 @@ class _SearchableMultiSelectPanelState<T>
|
||||
option.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: () {
|
||||
final subtitle = option.subtitle?.trim();
|
||||
if (subtitle == null || subtitle.isEmpty) return null;
|
||||
return Text(
|
||||
subtitle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}(),
|
||||
value: isSelected,
|
||||
onChanged: (checked) =>
|
||||
widget.onToggle(option.value, checked ?? false),
|
||||
|
||||
@ -251,9 +251,9 @@ class SidePanelSection extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Divider(height: 1, color: dividerColor),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
...children,
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ class AppTextField extends StatelessWidget {
|
||||
this.enabled = true,
|
||||
this.autofillHints,
|
||||
this.isDense = false,
|
||||
this.autovalidateMode,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
@ -36,6 +37,7 @@ class AppTextField extends StatelessWidget {
|
||||
final bool enabled;
|
||||
final Iterable<String>? autofillHints;
|
||||
final bool isDense;
|
||||
final AutovalidateMode? autovalidateMode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -46,6 +48,7 @@ class AppTextField extends StatelessWidget {
|
||||
obscureText: obscureText,
|
||||
keyboardType: keyboardType,
|
||||
validator: validator,
|
||||
autovalidateMode: autovalidateMode,
|
||||
onChanged: onChanged,
|
||||
maxLines: maxLines,
|
||||
maxLength: maxLength,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user