3382 lines
147 KiB
Dart
3382 lines
147 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
import 'package:image_picker/image_picker.dart';
|
||
import 'dart:io';
|
||
import 'package:dio/dio.dart';
|
||
import '../../../core/network/dio_client.dart';
|
||
import 'package:intl/intl.dart';
|
||
import '../../../core/widgets/premium_text_field.dart';
|
||
import '../../../core/widgets/smart_search_dropdown.dart';
|
||
import '../../../core/widgets/barcode_scanner_screen.dart';
|
||
import '../../inventory/providers/products_provider.dart';
|
||
import '../../inventory/providers/product_categories_provider.dart';
|
||
import '../../inventory/providers/commodity_rates_provider.dart';
|
||
import '../../inventory/providers/inventory_items_provider.dart';
|
||
import '../../inventory/providers/uoms_provider.dart';
|
||
import '../../inventory/domain/product.dart';
|
||
import '../../inventory/domain/inventory_item.dart';
|
||
import '../providers/customers_provider.dart';
|
||
import '../providers/invoices_provider.dart';
|
||
import '../domain/customer.dart';
|
||
import '../domain/invoice.dart';
|
||
import '../providers/sales_channels_provider.dart';
|
||
import '../../business/providers/business_provider.dart';
|
||
import '../../business/providers/indian_states_provider.dart';
|
||
import '../../transactions/providers/providers.dart';
|
||
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
|
||
import 'widgets/quick_add_customer_sheet.dart';
|
||
import '../../../core/utils/purity_utils.dart';
|
||
import 'invoice_details_screen.dart';
|
||
|
||
bool _isCommodityProduct({
|
||
ProductCategory? category,
|
||
Product? product,
|
||
InventoryItem? inventoryItem,
|
||
InvoiceItem? invoiceItem,
|
||
String? commodityCode,
|
||
String? huid,
|
||
}) {
|
||
final h = huid ?? invoiceItem?.huid ?? inventoryItem?.huid;
|
||
if (h != null && h.trim().isNotEmpty) return true;
|
||
|
||
final code = commodityCode ??
|
||
category?.commodityCode ??
|
||
invoiceItem?.commodityCode;
|
||
if (code != null && code.trim().isNotEmpty && code.trim().toUpperCase() != 'NONE') {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
String _resolveProductUnit({
|
||
required Product? product,
|
||
required ProductCategory? category,
|
||
required List<UnitOfMeasure> uoms,
|
||
bool isCommodity = false,
|
||
}) {
|
||
if (isCommodity) {
|
||
return (category != null && category.baseUnit.isNotEmpty) ? category.baseUnit : 'g';
|
||
}
|
||
if (product?.uomId != null) {
|
||
final uom = uoms.where((u) => u.id == product!.uomId).firstOrNull;
|
||
if (uom != null && uom.abbreviation != null && uom.abbreviation!.isNotEmpty) return uom.abbreviation!;
|
||
if (uom != null && uom.name.isNotEmpty) return uom.name;
|
||
}
|
||
if (category != null && category.baseUnit.isNotEmpty) {
|
||
return category.baseUnit;
|
||
}
|
||
return 'pcs';
|
||
}
|
||
|
||
String _resolveImageUrl(String path) {
|
||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||
return path;
|
||
}
|
||
final base = DioClient().dio.options.baseUrl;
|
||
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
|
||
if (cleanPath.startsWith('api/kifi-v2/upload/view') || cleanPath.startsWith('upload/view')) {
|
||
return '$base/${cleanPath.replaceFirst('api/kifi-v2/', '')}';
|
||
}
|
||
return '$base/upload/view?path=${Uri.encodeComponent(cleanPath)}';
|
||
}
|
||
|
||
class InvoiceBuilderScreen extends ConsumerStatefulWidget {
|
||
final Invoice? existingInvoice;
|
||
final int? initialCustomerId;
|
||
final List<InvoiceItem>? initialItems;
|
||
|
||
const InvoiceBuilderScreen({
|
||
super.key,
|
||
this.existingInvoice,
|
||
this.initialCustomerId,
|
||
this.initialItems,
|
||
});
|
||
|
||
@override
|
||
ConsumerState<InvoiceBuilderScreen> createState() => _InvoiceBuilderScreenState();
|
||
}
|
||
|
||
class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
||
final _formKey = GlobalKey<FormState>();
|
||
final _invoiceNumberCtrl = TextEditingController();
|
||
final _notesCtrl = TextEditingController();
|
||
final _amountPaidCtrl = TextEditingController();
|
||
final _emiAmountCtrl = TextEditingController();
|
||
final _discountCtrl = TextEditingController(text: '0.0');
|
||
final _marketplaceOrderIdCtrl = TextEditingController();
|
||
final _courierPartnerCtrl = TextEditingController();
|
||
final _trackingNumberCtrl = TextEditingController();
|
||
final _shippingAddressCtrl = TextEditingController();
|
||
final _shippingPincodeCtrl = TextEditingController();
|
||
|
||
DateTime _invoiceDate = DateTime.now();
|
||
DateTime? _dueDate;
|
||
int? _selectedCustomerId;
|
||
int? _selectedSalesChannelId;
|
||
String? _selectedSalesChannelName;
|
||
int? _selectedPlaceOfSupplyStateId;
|
||
String? _selectedPlaceOfSupplyStateName;
|
||
final List<InvoiceItem> _items = [];
|
||
bool _isLoading = false;
|
||
|
||
// Logistics & Delivery State
|
||
String _dispatchStatus = 'PENDING';
|
||
bool _sameAsCustomerAddress = true;
|
||
DateTime? _shippedAt;
|
||
|
||
XFile? _invoiceFile;
|
||
String? _invoiceUrl;
|
||
|
||
String _paymentMethod = 'Cash';
|
||
int? _selectedWalletId;
|
||
DateTime? _nextPaymentDate;
|
||
bool _isAmountPaidEdited = false;
|
||
|
||
// EMI State
|
||
bool _isEmi = false;
|
||
String _emiCycle = 'MONTHLY';
|
||
DateTime? _emiStartDate;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (widget.existingInvoice != null) {
|
||
final inv = widget.existingInvoice!;
|
||
_invoiceNumberCtrl.text = inv.invoiceNumber;
|
||
_invoiceDate = inv.issueDate;
|
||
_dueDate = inv.dueDate;
|
||
_selectedCustomerId = inv.customerId;
|
||
_selectedSalesChannelId = inv.salesChannelId;
|
||
_selectedSalesChannelName = inv.salesChannel;
|
||
_marketplaceOrderIdCtrl.text = inv.marketplaceOrderId ?? '';
|
||
_courierPartnerCtrl.text = inv.courierPartner ?? '';
|
||
_trackingNumberCtrl.text = inv.trackingNumber ?? '';
|
||
_dispatchStatus = inv.dispatchStatus ?? 'PENDING';
|
||
_shippedAt = inv.shippedAt;
|
||
_shippingAddressCtrl.text = inv.shippingAddress ?? '';
|
||
_shippingPincodeCtrl.text = inv.shippingPincode ?? '';
|
||
_sameAsCustomerAddress = (inv.shippingAddress == null || inv.shippingAddress!.trim().isEmpty);
|
||
_selectedPlaceOfSupplyStateId = inv.placeOfSupplyStateId;
|
||
_selectedPlaceOfSupplyStateName = inv.placeOfSupply;
|
||
_notesCtrl.text = inv.notes ?? '';
|
||
_items.addAll(inv.items);
|
||
_invoiceUrl = inv.invoiceUrl;
|
||
if (inv.discountTotal > 0) {
|
||
_discountCtrl.text = inv.discountTotal.toStringAsFixed(2);
|
||
}
|
||
_amountPaidCtrl.text = inv.amountPaid > 0 ? inv.amountPaid.toStringAsFixed(2) : '';
|
||
_selectedWalletId = inv.paymentWalletId;
|
||
_paymentMethod = inv.paymentMethod ?? 'Cash';
|
||
_nextPaymentDate = inv.nextPaymentDate;
|
||
_isAmountPaidEdited = inv.amountPaid > 0;
|
||
_isEmi = inv.isEmi;
|
||
_emiCycle = inv.emiCycle ?? 'MONTHLY';
|
||
_emiStartDate = inv.emiStartDate;
|
||
if (inv.emiAmount != null) {
|
||
_emiAmountCtrl.text = inv.emiAmount!.toStringAsFixed(2);
|
||
}
|
||
} else {
|
||
_invoiceNumberCtrl.text = 'INV-${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}';
|
||
_selectedCustomerId = widget.initialCustomerId;
|
||
_dueDate = DateTime.now().add(const Duration(days: 30));
|
||
_emiStartDate = DateTime.now().add(const Duration(days: 30));
|
||
if (widget.initialItems != null) {
|
||
_items.addAll(widget.initialItems!);
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_invoiceNumberCtrl.dispose();
|
||
_notesCtrl.dispose();
|
||
_amountPaidCtrl.dispose();
|
||
_emiAmountCtrl.dispose();
|
||
_discountCtrl.dispose();
|
||
_marketplaceOrderIdCtrl.dispose();
|
||
_courierPartnerCtrl.dispose();
|
||
_trackingNumberCtrl.dispose();
|
||
_shippingAddressCtrl.dispose();
|
||
_shippingPincodeCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _pickInvoiceFile() async {
|
||
try {
|
||
final picker = ImagePicker();
|
||
final picked = await picker.pickImage(
|
||
source: ImageSource.gallery,
|
||
imageQuality: 80,
|
||
);
|
||
if (picked != null) {
|
||
setState(() {
|
||
_invoiceFile = picked;
|
||
_isLoading = true;
|
||
});
|
||
|
||
final bytes = await picked.readAsBytes();
|
||
final formData = FormData.fromMap({
|
||
'file': MultipartFile.fromBytes(
|
||
bytes,
|
||
filename: picked.name.isNotEmpty ? picked.name : 'invoice_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||
),
|
||
'type': 'SALES_INVOICE',
|
||
});
|
||
|
||
final uploadResp = await DioClient().dio.post(
|
||
'/upload',
|
||
data: formData,
|
||
);
|
||
|
||
if (uploadResp.statusCode == 200) {
|
||
setState(() {
|
||
_invoiceUrl = uploadResp.data['url'];
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Error uploading sales invoice attachment: $e');
|
||
} finally {
|
||
if (mounted) setState(() => _isLoading = false);
|
||
}
|
||
}
|
||
|
||
void _showQuickAddCustomer() {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (_) => const QuickAddCustomerSheet(),
|
||
).then((result) {
|
||
if (result != null) {
|
||
if (result is Customer && result.id != null) {
|
||
setState(() => _selectedCustomerId = result.id);
|
||
} else {
|
||
ref.refresh(customersProvider);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
void _openAddItemSheet({int? editIndex}) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (_) => _AddSalesItemSheet(
|
||
initialItem: editIndex != null ? _items[editIndex] : null,
|
||
onItemAdded: (item) {
|
||
setState(() {
|
||
if (editIndex != null) {
|
||
_items[editIndex] = item;
|
||
} else {
|
||
_items.add(item);
|
||
}
|
||
});
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _scanBarcode() async {
|
||
final scannedCode = await Navigator.push<String>(
|
||
context,
|
||
MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()),
|
||
);
|
||
if (scannedCode != null && scannedCode.isNotEmpty) {
|
||
final inventoryItems = ref.read(inventoryItemsProvider).value ?? [];
|
||
final products = ref.read(productsProvider).value ?? [];
|
||
final categories = ref.read(productCategoriesProvider).value ?? [];
|
||
final commodityRates = ref.read(commodityRatesProvider).value ?? [];
|
||
|
||
// Check inventory item by HUID or SKU or Barcode
|
||
final invItem = inventoryItems.where((i) =>
|
||
(i.huid != null && i.huid!.toUpperCase() == scannedCode.toUpperCase()) ||
|
||
(i.sku != null && i.sku!.toUpperCase() == scannedCode.toUpperCase())
|
||
).firstOrNull;
|
||
|
||
Product? matchedProduct;
|
||
if (invItem != null) {
|
||
matchedProduct = products.where((p) => p.id == invItem.productId).firstOrNull;
|
||
} else {
|
||
matchedProduct = products.where((p) =>
|
||
(p.barcode != null && p.barcode == scannedCode) ||
|
||
(p.sku != null && p.sku!.toUpperCase() == scannedCode.toUpperCase())
|
||
).firstOrNull;
|
||
}
|
||
|
||
if (matchedProduct != null) {
|
||
final cat = categories.where((c) => c.id == matchedProduct!.categoryId).firstOrNull;
|
||
double commodityRate = 0.0;
|
||
if (cat?.commodityCode != null) {
|
||
final match = commodityRates.where((r) => r.commodityCode.toUpperCase() == cat!.commodityCode!.toUpperCase()).firstOrNull;
|
||
if (match != null) commodityRate = match.rate;
|
||
}
|
||
final purity = resolvePurity(categoryPurity: cat?.purityFactor, productPurity: matchedProduct.purityFactor);
|
||
final effectiveRate = commodityRate > 0 ? (commodityRate * purity) : (matchedProduct.sellingPrice ?? 0.0);
|
||
final weight = invItem?.grossWeight ?? 1.0;
|
||
final makingCharge = invItem?.makingCharges ?? matchedProduct.makingCharges ?? cat?.defaultMakingCharge ?? 0.0;
|
||
final makingType = invItem?.makingChargeType ?? matchedProduct.makingChargesType ?? cat?.makingChargeType ?? 'PER_GRAM';
|
||
final metalAmount = weight * effectiveRate;
|
||
|
||
double makingAmt = 0.0;
|
||
if (makingType == 'PERCENTAGE') {
|
||
makingAmt = metalAmount * (makingCharge / 100.0);
|
||
} else if (makingType == 'PER_PIECE') {
|
||
makingAmt = makingCharge;
|
||
} else {
|
||
makingAmt = weight * makingCharge;
|
||
}
|
||
|
||
final gstRate = matchedProduct.gstRate ?? (cat?.defaultGst ?? 3.0);
|
||
final taxable = metalAmount + makingAmt;
|
||
final taxAmount = (taxable * gstRate) / 100.0;
|
||
final total = taxable + taxAmount;
|
||
|
||
final item = InvoiceItem(
|
||
productId: matchedProduct.id,
|
||
inventoryItemId: invItem?.id,
|
||
productName: matchedProduct.name,
|
||
categoryName: cat?.name,
|
||
commodityCode: cat?.commodityCode,
|
||
hsnCode: cat?.defaultHsn ?? matchedProduct.hsnCode,
|
||
sku: invItem?.sku ?? matchedProduct.sku,
|
||
huid: invItem?.huid,
|
||
description: matchedProduct.name,
|
||
quantity: 1.0,
|
||
weight: weight,
|
||
unitPrice: effectiveRate,
|
||
makingCharge: makingCharge,
|
||
makingChargesType: makingType,
|
||
taxRate: gstRate,
|
||
total: total,
|
||
);
|
||
|
||
setState(() => _items.add(item));
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('Added ${matchedProduct.name} via barcode scan!'), backgroundColor: Colors.green),
|
||
);
|
||
}
|
||
} else {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('No item found with barcode/HUID: $scannedCode'), backgroundColor: Colors.orange),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _saveInvoice() async {
|
||
if (!_formKey.currentState!.validate()) return;
|
||
if (_items.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Please add at least one line item')),
|
||
);
|
||
return;
|
||
}
|
||
|
||
setState(() => _isLoading = true);
|
||
try {
|
||
String? finalInvoiceUrl = _invoiceUrl;
|
||
|
||
if (_invoiceFile != null && finalInvoiceUrl == null) {
|
||
final bytes = await _invoiceFile!.readAsBytes();
|
||
final formData = FormData.fromMap({
|
||
'file': MultipartFile.fromBytes(
|
||
bytes,
|
||
filename: _invoiceFile!.name.isNotEmpty ? _invoiceFile!.name : 'invoice.jpg',
|
||
),
|
||
'type': 'SALES_INVOICE',
|
||
});
|
||
final uploadResp = await DioClient().dio.post(
|
||
'/upload',
|
||
data: formData,
|
||
);
|
||
if (uploadResp.statusCode == 200) {
|
||
finalInvoiceUrl = uploadResp.data['url'];
|
||
}
|
||
}
|
||
|
||
final products = ref.read(productsProvider).value ?? [];
|
||
final categories = ref.read(productCategoriesProvider).value ?? [];
|
||
final businessStateId = ref.read(businessProfileProvider).value?.stateId;
|
||
final customers = ref.read(customersProvider).value ?? [];
|
||
final customer = _selectedCustomerId == null
|
||
? null
|
||
: customers.where((c) => c.id == _selectedCustomerId).firstOrNull;
|
||
|
||
final effectiveSupplyStateId = _selectedPlaceOfSupplyStateId ?? customer?.stateId ?? businessStateId;
|
||
final isSameState = businessStateId != null && effectiveSupplyStateId != null && businessStateId == effectiveSupplyStateId;
|
||
|
||
double subtotal = 0;
|
||
double totalCgst = 0;
|
||
double totalSgst = 0;
|
||
double totalIgst = 0;
|
||
double totalTax = 0;
|
||
double totalMaking = 0;
|
||
double totalDiscount = 0;
|
||
double grandTotal = 0;
|
||
|
||
final List<InvoiceItem> processedItems = [];
|
||
|
||
for (var item in _items) {
|
||
final product = products.where((p) => p.id == item.productId).firstOrNull;
|
||
final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull;
|
||
final gstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? 0.0);
|
||
|
||
final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
|
||
final baseItemAmt = qtyOrWeight * item.unitPrice;
|
||
|
||
final isItemCommodity = _isCommodityProduct(category: category, product: product, invoiceItem: item);
|
||
|
||
double makingChargeAmt = 0.0;
|
||
if (isItemCommodity) {
|
||
if (item.makingChargesType == 'PERCENTAGE') {
|
||
makingChargeAmt = baseItemAmt * (item.makingCharge / 100.0);
|
||
} else if (item.makingChargesType == 'PER_PIECE') {
|
||
makingChargeAmt = item.makingCharge;
|
||
} else {
|
||
makingChargeAmt = qtyOrWeight * item.makingCharge;
|
||
}
|
||
}
|
||
|
||
final taxableAmount = baseItemAmt + makingChargeAmt + item.otherCharges - item.discount;
|
||
final taxAmount = (taxableAmount * gstRate) / 100.0;
|
||
|
||
double cgst = 0;
|
||
double sgst = 0;
|
||
double igst = 0;
|
||
|
||
if (isSameState) {
|
||
cgst = taxAmount / 2.0;
|
||
sgst = taxAmount / 2.0;
|
||
totalCgst += cgst;
|
||
totalSgst += sgst;
|
||
} else {
|
||
igst = taxAmount;
|
||
totalIgst += igst;
|
||
}
|
||
|
||
final lineTotal = taxableAmount + taxAmount;
|
||
|
||
subtotal += baseItemAmt;
|
||
totalMaking += makingChargeAmt;
|
||
totalTax += taxAmount;
|
||
|
||
processedItems.add(item.copyWith(
|
||
quantity: item.quantity > 0 ? item.quantity : qtyOrWeight,
|
||
weight: item.weight,
|
||
taxRate: gstRate,
|
||
cgst: cgst,
|
||
sgst: sgst,
|
||
igst: igst,
|
||
total: lineTotal,
|
||
));
|
||
}
|
||
|
||
final invoiceDiscount = double.tryParse(_discountCtrl.text) ?? 0.0;
|
||
totalDiscount = double.parse(invoiceDiscount.toStringAsFixed(2));
|
||
final rawGrandTotal = (subtotal + totalMaking - invoiceDiscount).clamp(0.0, double.infinity) + totalTax;
|
||
grandTotal = double.parse(rawGrandTotal.toStringAsFixed(2));
|
||
|
||
final amountPaid = double.tryParse(_amountPaidCtrl.text) ?? (_isAmountPaidEdited ? 0.0 : grandTotal);
|
||
final validAmountPaid = double.parse((amountPaid > grandTotal ? grandTotal : amountPaid).toStringAsFixed(2));
|
||
final balanceDue = double.parse((grandTotal - validAmountPaid).clamp(0.0, double.infinity).toStringAsFixed(2));
|
||
final isFullyPaid = balanceDue.abs() < 0.01 || validAmountPaid >= grandTotal - 0.01;
|
||
final determinedStatus = isFullyPaid ? 'PAID' : (validAmountPaid > 0.01 ? 'PARTIAL' : 'DRAFT');
|
||
|
||
final invoice = Invoice(
|
||
id: widget.existingInvoice?.id,
|
||
customerId: _selectedCustomerId,
|
||
invoiceNumber: _invoiceNumberCtrl.text.trim(),
|
||
issueDate: _invoiceDate,
|
||
dueDate: _dueDate,
|
||
subtotal: double.parse(subtotal.toStringAsFixed(2)),
|
||
taxTotal: double.parse(totalTax.toStringAsFixed(2)),
|
||
cgstTotal: double.parse(totalCgst.toStringAsFixed(2)),
|
||
sgstTotal: double.parse(totalSgst.toStringAsFixed(2)),
|
||
igstTotal: double.parse(totalIgst.toStringAsFixed(2)),
|
||
discountTotal: totalDiscount,
|
||
totalAmount: grandTotal,
|
||
amountPaid: validAmountPaid,
|
||
paymentMethod: validAmountPaid > 0 ? _paymentMethod : null,
|
||
paymentWalletId: validAmountPaid > 0
|
||
? (_selectedWalletId ??
|
||
ref.read(walletProvider).value?.where((w) => w.nature == 'CASH' || w.nature == 'SAVINGS').firstOrNull?.id)
|
||
: null,
|
||
nextPaymentDate: balanceDue > 0.01 ? (_nextPaymentDate ?? DateTime.now().add(const Duration(days: 30))) : null,
|
||
status: widget.existingInvoice != null
|
||
? (isFullyPaid ? 'PAID' : (validAmountPaid > 0.01 ? 'PARTIAL' : widget.existingInvoice!.status))
|
||
: determinedStatus,
|
||
notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
|
||
invoiceUrl: finalInvoiceUrl,
|
||
isEmi: _isEmi,
|
||
emiAmount: _isEmi && _emiAmountCtrl.text.isNotEmpty ? double.tryParse(_emiAmountCtrl.text) : null,
|
||
emiCycle: _isEmi ? _emiCycle : null,
|
||
emiStartDate: _isEmi ? (_emiStartDate ?? DateTime.now().add(const Duration(days: 30))) : null,
|
||
salesChannelId: _selectedSalesChannelId,
|
||
salesChannel: _selectedSalesChannelName,
|
||
marketplaceOrderId: _marketplaceOrderIdCtrl.text.trim().isNotEmpty ? _marketplaceOrderIdCtrl.text.trim() : null,
|
||
courierPartner: _courierPartnerCtrl.text.trim().isNotEmpty ? _courierPartnerCtrl.text.trim() : null,
|
||
trackingNumber: _trackingNumberCtrl.text.trim().isNotEmpty ? _trackingNumberCtrl.text.trim() : null,
|
||
dispatchStatus: _dispatchStatus,
|
||
shippedAt: _shippedAt ?? (_dispatchStatus == 'SHIPPED' || _dispatchStatus == 'IN_TRANSIT' || _dispatchStatus == 'DELIVERED' ? DateTime.now() : null),
|
||
shippingAddress: !_sameAsCustomerAddress && _shippingAddressCtrl.text.trim().isNotEmpty ? _shippingAddressCtrl.text.trim() : null,
|
||
shippingPincode: !_sameAsCustomerAddress && _shippingPincodeCtrl.text.trim().isNotEmpty ? _shippingPincodeCtrl.text.trim() : null,
|
||
placeOfSupplyStateId: effectiveSupplyStateId,
|
||
placeOfSupply: _selectedPlaceOfSupplyStateName ?? ref.read(indianStatesProvider).value?.where((s) => s.id == effectiveSupplyStateId).firstOrNull?.name,
|
||
items: processedItems,
|
||
);
|
||
|
||
Invoice? savedInvoice;
|
||
if (widget.existingInvoice == null) {
|
||
savedInvoice = await ref.read(invoicesProvider.notifier).createInvoice(invoice);
|
||
} else {
|
||
final resp = await DioClient().dio.put(
|
||
'/invoices/${widget.existingInvoice!.id}',
|
||
data: invoice.toJson(),
|
||
);
|
||
if (resp.statusCode == 200) {
|
||
savedInvoice = Invoice.fromJson(resp.data);
|
||
ref.refresh(invoicesProvider);
|
||
}
|
||
}
|
||
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Sales Invoice saved successfully!'), backgroundColor: Colors.green),
|
||
);
|
||
if (savedInvoice != null) {
|
||
Navigator.pushReplacement(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => InvoiceDetailsScreen(invoice: savedInvoice!),
|
||
),
|
||
);
|
||
} else {
|
||
Navigator.pop(context);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('Error saving invoice: $e'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _isLoading = false);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final customersState = ref.watch(customersProvider);
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: Text(
|
||
widget.existingInvoice == null
|
||
? 'New Sales Invoice'
|
||
: 'Edit Invoice ${_invoiceNumberCtrl.text}',
|
||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||
),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(LucideIcons.qrCode),
|
||
tooltip: 'Scan Barcode/HUID',
|
||
onPressed: _scanBarcode,
|
||
),
|
||
if (_isLoading)
|
||
const Padding(
|
||
padding: EdgeInsets.all(16.0),
|
||
child: SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
)
|
||
else
|
||
TextButton.icon(
|
||
onPressed: _saveInvoice,
|
||
icon: const Icon(LucideIcons.check, size: 18),
|
||
label: const Text('Save', style: TextStyle(fontWeight: FontWeight.bold)),
|
||
),
|
||
],
|
||
),
|
||
body: GestureDetector(
|
||
onTap: () => FocusScope.of(context).unfocus(),
|
||
behavior: HitTestBehavior.translucent,
|
||
child: Form(
|
||
key: _formKey,
|
||
child: ListView(
|
||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||
padding: const EdgeInsets.all(16.0),
|
||
children: [
|
||
// E-Commerce / Sales Channels Selector (If enabled)
|
||
Builder(
|
||
builder: (context) {
|
||
final featureState = ref.watch(businessFeatureProvider);
|
||
final businessProfile = ref.watch(businessProfileProvider).value;
|
||
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
|
||
final isJewellery = nature == 'JEWELLERY';
|
||
final isChannelsEnabled = featureState.value?.enableEcommerceChannels ?? (!isJewellery);
|
||
|
||
if (!isChannelsEnabled) return const SizedBox.shrink();
|
||
|
||
final salesChannelsState = ref.watch(salesChannelsProvider);
|
||
|
||
return salesChannelsState.when(
|
||
data: (channels) {
|
||
final activeChannels = channels.where((c) => c.isActive).toList();
|
||
if (activeChannels.isEmpty) return const SizedBox.shrink();
|
||
|
||
final currentSelectedId = _selectedSalesChannelId;
|
||
final selectedChannel = activeChannels.where((c) =>
|
||
(currentSelectedId != null && c.id == currentSelectedId) ||
|
||
(currentSelectedId == null && c.name.toUpperCase() == (_selectedSalesChannelName ?? '').toUpperCase())
|
||
).firstOrNull ?? activeChannels.where((c) => c.code == 'DIRECT' || c.name.toLowerCase().contains('direct')).firstOrNull ?? activeChannels.first;
|
||
|
||
final isMarketplace = (selectedChannel.code != 'DIRECT' &&
|
||
!selectedChannel.name.toLowerCase().contains('direct') &&
|
||
!selectedChannel.name.toLowerCase().contains('store'));
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 16.0),
|
||
padding: const EdgeInsets.all(12.0),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: Colors.grey.withValues(alpha: 0.2)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(LucideIcons.shoppingBag, size: 16, color: Colors.blue.shade700),
|
||
const SizedBox(width: 6),
|
||
const Text(
|
||
'Sales Channel / Marketplace',
|
||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(
|
||
children: activeChannels.map((channel) {
|
||
final isSelected = (currentSelectedId != null && currentSelectedId == channel.id) ||
|
||
(currentSelectedId == null && selectedChannel.id == channel.id);
|
||
|
||
IconData iconData = LucideIcons.store;
|
||
if (channel.code == 'AMAZON' || channel.name.toLowerCase().contains('amazon')) {
|
||
iconData = LucideIcons.shoppingCart;
|
||
} else if (channel.code == 'FLIPKART' || channel.name.toLowerCase().contains('flipkart')) {
|
||
iconData = LucideIcons.package;
|
||
} else if (channel.code == 'SHOPIFY' || channel.name.toLowerCase().contains('shopify')) {
|
||
iconData = LucideIcons.globe;
|
||
} else if (channel.code == 'MEESHO' || channel.name.toLowerCase().contains('meesho')) {
|
||
iconData = LucideIcons.tag;
|
||
} else if (channel.code == 'QUICK_COMMERCE' || channel.name.toLowerCase().contains('quick')) {
|
||
iconData = LucideIcons.zap;
|
||
}
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.only(right: 8.0),
|
||
child: ChoiceChip(
|
||
selected: isSelected,
|
||
avatar: Icon(
|
||
iconData,
|
||
size: 15,
|
||
color: isSelected ? Colors.white : Colors.grey.shade700,
|
||
),
|
||
label: Text(
|
||
channel.name,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||
color: isSelected ? Colors.white : Colors.grey.shade800,
|
||
),
|
||
),
|
||
selectedColor: Colors.blue.shade700,
|
||
onSelected: (selected) {
|
||
if (selected) {
|
||
setState(() {
|
||
_selectedSalesChannelId = channel.id;
|
||
_selectedSalesChannelName = channel.name;
|
||
|
||
// If customer is not yet selected, attempt matching default channel customer (e.g. Amazon)
|
||
if (_selectedCustomerId == null) {
|
||
final customersList = ref.read(customersProvider).value ?? [];
|
||
final match = customersList.where((c) =>
|
||
c.name.toLowerCase().contains(channel.name.toLowerCase()) ||
|
||
(channel.code != null && c.name.toLowerCase().contains(channel.code!.toLowerCase()))
|
||
).firstOrNull;
|
||
if (match != null) {
|
||
_selectedCustomerId = match.id;
|
||
if (match.stateId != null) {
|
||
_selectedPlaceOfSupplyStateId = match.stateId;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
},
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
if (isMarketplace) ...[
|
||
const SizedBox(height: 12),
|
||
PremiumTextField(
|
||
controller: _marketplaceOrderIdCtrl,
|
||
labelText: '${selectedChannel.name} Order ID (e.g. 408-1234567-8901234)',
|
||
prefixIcon: const Icon(LucideIcons.hash, size: 16),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
},
|
||
loading: () => const SizedBox.shrink(),
|
||
error: (err, stack) => const SizedBox.shrink(),
|
||
);
|
||
},
|
||
),
|
||
// Customer Selection Row + Quick Add
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: customersState.when(
|
||
data: (customers) => SmartSearchDropdown<Customer>(
|
||
labelText: 'Customer *',
|
||
hintText: 'Search Customer by Name / Phone / GSTIN / City',
|
||
value: customers.where((c) => c.id == _selectedCustomerId).firstOrNull,
|
||
items: customers,
|
||
itemAsString: (c) => c.name,
|
||
filterFn: (c, query) {
|
||
final q = query.toLowerCase();
|
||
return c.name.toLowerCase().contains(q) ||
|
||
(c.phone != null && c.phone!.contains(q)) ||
|
||
(c.email != null && c.email!.toLowerCase().contains(q)) ||
|
||
(c.gstin != null && c.gstin!.toLowerCase().contains(q)) ||
|
||
(c.address != null && c.address!.toLowerCase().contains(q));
|
||
},
|
||
itemBuilder: (context, c) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 10.0),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
CircleAvatar(
|
||
radius: 18,
|
||
backgroundColor: Colors.blue.withValues(alpha: 0.15),
|
||
child: Text(
|
||
c.name.isNotEmpty ? c.name[0].toUpperCase() : 'C',
|
||
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.blue, fontSize: 14),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
c.name,
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||
),
|
||
const SizedBox(height: 3),
|
||
Wrap(
|
||
spacing: 6,
|
||
runSpacing: 3,
|
||
children: [
|
||
if (c.phone != null && c.phone!.isNotEmpty)
|
||
_buildSmallBadge(c.phone!, Colors.teal),
|
||
if (c.address != null && c.address!.isNotEmpty)
|
||
_buildSmallBadge(c.address!, Colors.purple),
|
||
if (c.gstin != null && c.gstin!.isNotEmpty)
|
||
_buildSmallBadge('GST: ${c.gstin}', Colors.indigo),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
onChanged: (val) {
|
||
setState(() {
|
||
_selectedCustomerId = val?.id;
|
||
if (val?.stateId != null) {
|
||
_selectedPlaceOfSupplyStateId = val!.stateId;
|
||
}
|
||
});
|
||
},
|
||
),
|
||
loading: () => const Center(child: CircularProgressIndicator()),
|
||
error: (e, _) => Text('Error loading customers: $e'),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 4.0),
|
||
child: IconButton.filledTonal(
|
||
onPressed: _showQuickAddCustomer,
|
||
icon: const Icon(LucideIcons.userPlus, size: 20),
|
||
tooltip: 'Quick Add Customer',
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
// Place of Supply / Destination Delivery State
|
||
Consumer(
|
||
builder: (context, ref, child) {
|
||
final statesState = ref.watch(indianStatesProvider);
|
||
final businessStateId = ref.watch(businessProfileProvider).value?.stateId;
|
||
final customers = ref.watch(customersProvider).value ?? [];
|
||
final customer = _selectedCustomerId == null
|
||
? null
|
||
: customers.where((c) => c.id == _selectedCustomerId).firstOrNull;
|
||
|
||
final effectiveStateId = _selectedPlaceOfSupplyStateId ?? customer?.stateId ?? businessStateId;
|
||
final isSameState = businessStateId != null && effectiveStateId != null && businessStateId == effectiveStateId;
|
||
|
||
return statesState.when(
|
||
data: (states) {
|
||
final selectedState = states.where((s) => s.id == effectiveStateId).firstOrNull;
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: isSameState
|
||
? Colors.green.withValues(alpha: 0.05)
|
||
: Colors.purple.withValues(alpha: 0.05),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(
|
||
color: isSameState
|
||
? Colors.green.withValues(alpha: 0.25)
|
||
: Colors.purple.withValues(alpha: 0.25),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
LucideIcons.mapPin,
|
||
size: 18,
|
||
color: isSameState ? Colors.green.shade700 : Colors.purple.shade700,
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text(
|
||
'Place of Supply (Delivery State)',
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.bold,
|
||
color: Colors.grey.shade600,
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
|
||
decoration: BoxDecoration(
|
||
color: isSameState ? Colors.green.shade100 : Colors.purple.shade100,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
isSameState ? 'Intra-state (CGST + SGST)' : 'Inter-state (IGST)',
|
||
style: TextStyle(
|
||
fontSize: 9.5,
|
||
fontWeight: FontWeight.bold,
|
||
color: isSameState ? Colors.green.shade900 : Colors.purple.shade900,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 2),
|
||
DropdownButtonHideUnderline(
|
||
child: DropdownButton<int>(
|
||
value: selectedState?.id,
|
||
isDense: true,
|
||
isExpanded: true,
|
||
hint: const Text('Select State of Supply', style: TextStyle(fontSize: 13)),
|
||
items: states.map((s) {
|
||
final isHome = s.id == businessStateId;
|
||
return DropdownMenuItem<int>(
|
||
value: s.id,
|
||
child: Text(
|
||
'${s.name} (${s.gstCode})${isHome ? " - Home State" : ""}',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: isHome ? FontWeight.bold : FontWeight.normal,
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
onChanged: (val) {
|
||
if (val != null) {
|
||
final picked = states.where((s) => s.id == val).firstOrNull;
|
||
setState(() {
|
||
_selectedPlaceOfSupplyStateId = val;
|
||
_selectedPlaceOfSupplyStateName = picked?.name;
|
||
});
|
||
}
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
loading: () => const SizedBox.shrink(),
|
||
error: (err, stack) => const SizedBox.shrink(),
|
||
);
|
||
},
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
PremiumTextField(
|
||
controller: _invoiceNumberCtrl,
|
||
labelText: 'Invoice Number *',
|
||
prefixIcon: const Icon(LucideIcons.fileText),
|
||
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
||
),
|
||
const SizedBox(height: 16),
|
||
InkWell(
|
||
onTap: () async {
|
||
final picked = await showDatePicker(
|
||
context: context,
|
||
initialDate: _invoiceDate,
|
||
firstDate: DateTime(2000),
|
||
lastDate: DateTime(2100),
|
||
);
|
||
if (picked != null) {
|
||
setState(() => _invoiceDate = picked);
|
||
}
|
||
},
|
||
child: InputDecorator(
|
||
decoration: InputDecoration(
|
||
labelText: 'Invoice Date',
|
||
prefixIcon: const Icon(LucideIcons.calendar),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
child: Text(
|
||
DateFormat('dd MMM yyyy').format(_invoiceDate),
|
||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
// Items Section Header with Barcode + Add Item
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'Invoice Items (${_items.length})',
|
||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||
),
|
||
Row(
|
||
children: [
|
||
IconButton.filledTonal(
|
||
onPressed: _scanBarcode,
|
||
icon: const Icon(LucideIcons.qrCode, size: 18),
|
||
tooltip: 'Scan Barcode',
|
||
),
|
||
const SizedBox(width: 8),
|
||
FilledButton.tonalIcon(
|
||
onPressed: () => _openAddItemSheet(),
|
||
icon: const Icon(LucideIcons.plus, size: 16),
|
||
label: const Text('Add Item'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
// Items List
|
||
if (_items.isEmpty)
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(32),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: Colors.grey.withValues(alpha: 0.2)),
|
||
),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Icon(LucideIcons.packageOpen, size: 48, color: Colors.grey.shade400),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
'No items added yet',
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.grey.shade600),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'Tap "Add Item" or scan a barcode/SKU to add sales items',
|
||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||
),
|
||
],
|
||
),
|
||
)
|
||
else
|
||
..._items.asMap().entries.map((entry) {
|
||
final idx = entry.key;
|
||
final item = entry.value;
|
||
return _buildSalesItemCard(item, idx, isDark);
|
||
}),
|
||
|
||
const SizedBox(height: 20),
|
||
|
||
// Invoice Discount Input Field before Summary
|
||
PremiumTextField(
|
||
controller: _discountCtrl,
|
||
labelText: 'Invoice Discount (₹)',
|
||
prefixIcon: const Icon(LucideIcons.tag, color: Colors.red),
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
onChanged: (_) => setState(() {}),
|
||
),
|
||
|
||
const SizedBox(height: 20),
|
||
|
||
// Grand Total & Tax Summary Box
|
||
_buildTotalsSummaryCard(isDark),
|
||
|
||
const SizedBox(height: 24),
|
||
|
||
// Payment / Settlement Details (Full Partial Payment & EMI Support)
|
||
_buildPaymentSection(isDark),
|
||
|
||
const SizedBox(height: 24),
|
||
|
||
// Shipping & Courier Logistics Section
|
||
_buildShippingLogisticsSection(isDark),
|
||
|
||
const SizedBox(height: 24),
|
||
|
||
// Notes & Remarks
|
||
PremiumTextField(
|
||
controller: _notesCtrl,
|
||
labelText: 'Notes & Terms',
|
||
maxLines: 3,
|
||
prefixIcon: const Icon(LucideIcons.alignLeft),
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
// Attachments
|
||
Text(
|
||
'Attached Invoice / Documents',
|
||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||
),
|
||
const SizedBox(height: 8),
|
||
if (_invoiceFile != null)
|
||
ListTile(
|
||
leading: const Icon(LucideIcons.image, color: Colors.blue),
|
||
title: Text(_invoiceFile!.name),
|
||
subtitle: const Text('Tap to view image', style: TextStyle(fontSize: 12, color: Colors.blue)),
|
||
onTap: () {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => AttachmentGalleryScreen(
|
||
images: [kIsWeb ? NetworkImage(_invoiceFile!.path) : FileImage(File(_invoiceFile!.path))],
|
||
initialIndex: 0,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
trailing: IconButton(
|
||
icon: const Icon(LucideIcons.x),
|
||
onPressed: () => setState(() => _invoiceFile = null),
|
||
),
|
||
)
|
||
else if (_invoiceUrl != null && _invoiceUrl!.isNotEmpty)
|
||
ListTile(
|
||
leading: const Icon(LucideIcons.image, color: Colors.blue),
|
||
title: const Text('View Attached Invoice'),
|
||
subtitle: const Text('Tap to view full image', style: TextStyle(fontSize: 12, color: Colors.blue)),
|
||
onTap: () {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => AttachmentGalleryScreen(
|
||
images: [NetworkImage(_resolveImageUrl(_invoiceUrl!))],
|
||
initialIndex: 0,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
trailing: IconButton(
|
||
icon: const Icon(LucideIcons.x),
|
||
onPressed: () => setState(() => _invoiceUrl = null),
|
||
),
|
||
)
|
||
else
|
||
OutlinedButton.icon(
|
||
onPressed: _pickInvoiceFile,
|
||
icon: const Icon(LucideIcons.paperclip),
|
||
label: const Text('Attach Sales Bill / Slip'),
|
||
style: OutlinedButton.styleFrom(
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||
),
|
||
),
|
||
const SizedBox(height: 40),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSalesItemCard(InvoiceItem item, int index, bool isDark) {
|
||
final uoms = ref.watch(uomsProvider).value ?? [];
|
||
final products = ref.watch(productsProvider).value ?? [];
|
||
final categories = ref.watch(productCategoriesProvider).value ?? [];
|
||
final product = products.where((p) => p.id == item.productId).firstOrNull;
|
||
final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull;
|
||
|
||
final isItemCommodity = _isCommodityProduct(
|
||
category: category,
|
||
product: product,
|
||
invoiceItem: item,
|
||
);
|
||
|
||
final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
|
||
final baseAmount = qtyOrWeight * item.unitPrice;
|
||
|
||
double makingChargeAmt = 0.0;
|
||
if (isItemCommodity) {
|
||
if (item.makingChargesType == 'PERCENTAGE') {
|
||
makingChargeAmt = baseAmount * (item.makingCharge / 100.0);
|
||
} else if (item.makingChargesType == 'PER_PIECE') {
|
||
makingChargeAmt = item.makingCharge;
|
||
} else {
|
||
makingChargeAmt = qtyOrWeight * item.makingCharge;
|
||
}
|
||
}
|
||
|
||
final taxableAmount = baseAmount + makingChargeAmt + item.otherCharges - item.discount;
|
||
final taxAmount = (taxableAmount * item.taxRate) / 100.0;
|
||
final calculatedTotal = item.total > 0 ? item.total : (taxableAmount + taxAmount);
|
||
final unit = _resolveProductUnit(product: product, category: category, uoms: uoms, isCommodity: isItemCommodity);
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
|
||
blurRadius: 10,
|
||
offset: const Offset(0, 4),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (item.photoUrl != null && item.photoUrl!.isNotEmpty)
|
||
Container(
|
||
width: 48,
|
||
height: 48,
|
||
margin: const EdgeInsets.only(right: 12),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(8),
|
||
image: DecorationImage(
|
||
image: NetworkImage(_resolveImageUrl(item.photoUrl!)),
|
||
fit: BoxFit.cover,
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
item.productName ?? item.description ?? 'Item #${index + 1}',
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Wrap(
|
||
spacing: 6,
|
||
runSpacing: 4,
|
||
children: [
|
||
if (item.categoryName != null && item.categoryName!.isNotEmpty)
|
||
_buildSmallBadge(item.categoryName!, const Color(0xFFD4AF37)),
|
||
if (item.sku != null && item.sku!.isNotEmpty)
|
||
_buildSmallBadge('SKU: ${item.sku}', Colors.blue),
|
||
if (isItemCommodity && item.huid != null && item.huid!.isNotEmpty)
|
||
_buildSmallBadge('HUID: ${item.huid}', Colors.purple),
|
||
if (item.hsnCode != null && item.hsnCode!.isNotEmpty)
|
||
_buildSmallBadge('HSN: ${item.hsnCode}', Colors.grey),
|
||
if (!isItemCommodity)
|
||
_buildSmallBadge('Unit: $unit', Colors.teal),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(LucideIcons.edit2, size: 18, color: Colors.blue),
|
||
tooltip: 'Edit Item',
|
||
onPressed: () => _openAddItemSheet(editIndex: index),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(LucideIcons.trash2, size: 18, color: Colors.red),
|
||
tooltip: 'Remove Item',
|
||
onPressed: () {
|
||
setState(() => _items.removeAt(index));
|
||
},
|
||
),
|
||
],
|
||
),
|
||
const Divider(height: 20),
|
||
if (isItemCommodity) ...[
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'Metal: ${qtyOrWeight.toStringAsFixed(3)}$unit × ₹${item.unitPrice.toStringAsFixed(2)}',
|
||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||
),
|
||
Text(
|
||
'₹${baseAmount.toStringAsFixed(2)}',
|
||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
||
),
|
||
],
|
||
),
|
||
if (makingChargeAmt > 0) ...[
|
||
const SizedBox(height: 4),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'Making Charges (${item.makingChargesType == 'PERCENTAGE' ? '${item.makingCharge}%' : (item.makingChargesType == 'PER_PIECE' ? '₹${item.makingCharge}/pc' : '₹${item.makingCharge}/g')}):',
|
||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||
),
|
||
Text(
|
||
'₹${makingChargeAmt.toStringAsFixed(2)}',
|
||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, color: Colors.indigo),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
] else ...[
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'Rate: ₹${item.unitPrice.toStringAsFixed(2)} / $unit • Qty: ${qtyOrWeight.toStringAsFixed(qtyOrWeight.truncateToDouble() == qtyOrWeight ? 0 : 2)} $unit',
|
||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600, fontWeight: FontWeight.w500),
|
||
),
|
||
Text(
|
||
'₹${baseAmount.toStringAsFixed(2)}',
|
||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
if (item.discount > 0) ...[
|
||
const SizedBox(height: 4),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Discount:', style: TextStyle(fontSize: 12, color: Colors.red)),
|
||
Text('- ₹${item.discount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.red)),
|
||
],
|
||
),
|
||
],
|
||
if (item.taxRate > 0) ...[
|
||
const SizedBox(height: 4),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'GST (${item.taxRate.toStringAsFixed(1)}%):',
|
||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||
),
|
||
Text(
|
||
'+ ₹${taxAmount.toStringAsFixed(2)}',
|
||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Item Total:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||
Text(
|
||
'₹${calculatedTotal.toStringAsFixed(2)}',
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.green),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSmallBadge(String text, Color color) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: color.withValues(alpha: 0.3), width: 0.5),
|
||
),
|
||
child: Text(
|
||
text,
|
||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: color),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildTotalsSummaryCard(bool isDark) {
|
||
final businessProfile = ref.watch(businessProfileProvider).value;
|
||
final businessStateId = businessProfile?.stateId;
|
||
final products = ref.watch(productsProvider).value ?? [];
|
||
final categories = ref.watch(productCategoriesProvider).value ?? [];
|
||
|
||
final customers = ref.watch(customersProvider).value ?? [];
|
||
final customer = _selectedCustomerId == null
|
||
? null
|
||
: customers.where((c) => c.id == _selectedCustomerId).firstOrNull;
|
||
|
||
final discountAmount = double.tryParse(_discountCtrl.text) ?? 0.0;
|
||
final effectiveSupplyStateId = _selectedPlaceOfSupplyStateId ?? customer?.stateId ?? businessStateId;
|
||
final isSameState = businessStateId != null && effectiveSupplyStateId != null && businessStateId == effectiveSupplyStateId;
|
||
|
||
final hasCommodityItems = _items.any((item) {
|
||
final product = products.where((p) => p.id == item.productId).firstOrNull;
|
||
final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull;
|
||
return _isCommodityProduct(category: category, product: product, invoiceItem: item);
|
||
});
|
||
|
||
double itemsSubtotal = 0;
|
||
double makingChargesTotal = 0;
|
||
double rawTaxableTotal = 0;
|
||
double taxTotal = 0;
|
||
|
||
for (var item in _items) {
|
||
final product = products.where((p) => p.id == item.productId).firstOrNull;
|
||
final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull;
|
||
final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
|
||
final baseAmt = qtyOrWeight * item.unitPrice;
|
||
|
||
final isItemCommodity = _isCommodityProduct(category: category, product: product, invoiceItem: item);
|
||
|
||
double makingAmt = 0.0;
|
||
if (isItemCommodity) {
|
||
if (item.makingChargesType == 'PERCENTAGE') {
|
||
makingAmt = baseAmt * (item.makingCharge / 100.0);
|
||
} else if (item.makingChargesType == 'PER_PIECE') {
|
||
makingAmt = item.makingCharge;
|
||
} else {
|
||
makingAmt = qtyOrWeight * item.makingCharge;
|
||
}
|
||
}
|
||
|
||
final itemTaxable = baseAmt + makingAmt + item.otherCharges - item.discount;
|
||
final itemGstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? (category?.defaultGst ?? (isItemCommodity ? 3.0 : 18.0)));
|
||
final itemTax = (itemTaxable.clamp(0.0, double.infinity) * itemGstRate) / 100.0;
|
||
|
||
itemsSubtotal += baseAmt;
|
||
makingChargesTotal += makingAmt;
|
||
rawTaxableTotal += (baseAmt + makingAmt + item.otherCharges);
|
||
taxTotal += itemTax;
|
||
}
|
||
|
||
final effectiveTaxable = (rawTaxableTotal - discountAmount).clamp(0.0, double.infinity);
|
||
if (rawTaxableTotal > 0 && discountAmount > 0) {
|
||
taxTotal = taxTotal * (effectiveTaxable / rawTaxableTotal);
|
||
}
|
||
final grandTotal = effectiveTaxable + taxTotal;
|
||
|
||
final effectiveGstRate = (effectiveTaxable > 0 && taxTotal > 0)
|
||
? (taxTotal / effectiveTaxable) * 100.0
|
||
: (_items.isNotEmpty ? _items.first.taxRate : (hasCommodityItems ? 3.0 : 18.0));
|
||
final cgstRate = effectiveGstRate / 2.0;
|
||
final sgstRate = effectiveGstRate / 2.0;
|
||
final igstRate = effectiveGstRate;
|
||
|
||
String formatRate(double rate) {
|
||
if (rate <= 0) return '0%';
|
||
final rounded = (rate * 100).round() / 100;
|
||
if (rounded.truncateToDouble() == rounded) {
|
||
return '${rounded.toInt()}%';
|
||
}
|
||
return '${rounded.toStringAsFixed(1)}%';
|
||
}
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.05),
|
||
blurRadius: 15,
|
||
offset: const Offset(0, 5),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('INVOICE SUMMARY', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 0.8, color: Colors.grey)),
|
||
const SizedBox(height: 14),
|
||
if (hasCommodityItems) ...[
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Metal Subtotal:'),
|
||
Text('₹${itemsSubtotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
],
|
||
),
|
||
if (makingChargesTotal > 0) ...[
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Making Charges:'),
|
||
Text('₹${makingChargesTotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
],
|
||
),
|
||
],
|
||
] else ...[
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Items Subtotal (Taxable):'),
|
||
Text('₹${itemsSubtotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
],
|
||
),
|
||
],
|
||
if (discountAmount > 0) ...[
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Discount:', style: TextStyle(color: Colors.red, fontWeight: FontWeight.w600)),
|
||
Text('- ₹${discountAmount.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.red)),
|
||
],
|
||
),
|
||
],
|
||
const SizedBox(height: 8),
|
||
if (isSameState) ...[
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text('CGST (${formatRate(cgstRate)}):', style: TextStyle(color: Colors.grey.shade600)),
|
||
Text('₹${(taxTotal / 2.0).toStringAsFixed(2)}'),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text('SGST (${formatRate(sgstRate)}):', style: TextStyle(color: Colors.grey.shade600)),
|
||
Text('₹${(taxTotal / 2.0).toStringAsFixed(2)}'),
|
||
],
|
||
),
|
||
] else ...[
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text('IGST (${formatRate(igstRate)}):', style: TextStyle(color: Colors.grey.shade600)),
|
||
Text('₹${taxTotal.toStringAsFixed(2)}'),
|
||
],
|
||
),
|
||
],
|
||
const Divider(height: 24),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Grand Total:', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||
Text(
|
||
'₹${grandTotal.toStringAsFixed(2)}',
|
||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPaymentSection(bool isDark) {
|
||
final wallets = ref.watch(walletProvider).value ?? [];
|
||
final activeWallets = wallets.where((w) => w.nature == 'CASH' || w.nature == 'SAVINGS').toList();
|
||
|
||
// Compute grand total for live balance due
|
||
final discountAmount = double.tryParse(_discountCtrl.text) ?? 0.0;
|
||
final products = ref.watch(productsProvider).value ?? [];
|
||
final categories = ref.watch(productCategoriesProvider).value ?? [];
|
||
double rawTaxable = 0;
|
||
double taxTotal = 0;
|
||
for (var item in _items) {
|
||
final product = products.where((p) => p.id == item.productId).firstOrNull;
|
||
final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull;
|
||
final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
|
||
final baseAmt = qtyOrWeight * item.unitPrice;
|
||
final isItemCommodity = _isCommodityProduct(category: category, product: product, invoiceItem: item);
|
||
double makingAmt = 0.0;
|
||
if (isItemCommodity) {
|
||
if (item.makingChargesType == 'PERCENTAGE') {
|
||
makingAmt = baseAmt * (item.makingCharge / 100.0);
|
||
} else if (item.makingChargesType == 'PER_PIECE') {
|
||
makingAmt = item.makingCharge;
|
||
} else {
|
||
makingAmt = qtyOrWeight * item.makingCharge;
|
||
}
|
||
}
|
||
final itemTaxable = baseAmt + makingAmt + item.otherCharges - item.discount;
|
||
final itemGstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? (category?.defaultGst ?? (isItemCommodity ? 3.0 : 18.0)));
|
||
final itemTax = (itemTaxable.clamp(0.0, double.infinity) * itemGstRate) / 100.0;
|
||
|
||
rawTaxable += (baseAmt + makingAmt + item.otherCharges);
|
||
taxTotal += itemTax;
|
||
}
|
||
final effectiveTaxable = (rawTaxable - discountAmount).clamp(0.0, double.infinity);
|
||
if (rawTaxable > 0 && discountAmount > 0) {
|
||
taxTotal = taxTotal * (effectiveTaxable / rawTaxable);
|
||
}
|
||
final grandTotal = effectiveTaxable + taxTotal;
|
||
|
||
// If user has not manually changed amount paid and grandTotal > 0, auto-track grand total
|
||
if (!_isAmountPaidEdited && grandTotal > 0 && _amountPaidCtrl.text != grandTotal.toStringAsFixed(2)) {
|
||
_amountPaidCtrl.text = grandTotal.toStringAsFixed(2);
|
||
}
|
||
|
||
final enteredAmount = double.tryParse(_amountPaidCtrl.text) ?? (_isAmountPaidEdited ? 0.0 : grandTotal);
|
||
final amountPaid = enteredAmount > grandTotal ? grandTotal : enteredAmount;
|
||
final balanceDue = grandTotal - amountPaid;
|
||
|
||
Widget? statusBadge;
|
||
if (grandTotal > 0 && (_isAmountPaidEdited || _amountPaidCtrl.text.isNotEmpty)) {
|
||
if (amountPaid >= grandTotal) {
|
||
statusBadge = Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: Colors.green.withValues(alpha: 0.15),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: const Text(
|
||
'Fully Paid',
|
||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.green),
|
||
),
|
||
);
|
||
} else if (amountPaid > 0) {
|
||
statusBadge = Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: Colors.orange.withValues(alpha: 0.15),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: const Text(
|
||
'Partial Payment',
|
||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.orange),
|
||
),
|
||
);
|
||
} else {
|
||
statusBadge = Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: Colors.red.withValues(alpha: 0.15),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: const Text(
|
||
'Unpaid',
|
||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.red),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('PAYMENT & SETTLEMENT', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 0.8, color: Colors.grey)),
|
||
if (statusBadge != null)
|
||
Flexible(child: statusBadge),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: PremiumTextField(
|
||
controller: _amountPaidCtrl,
|
||
labelText: 'Amount Received (₹)',
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
prefixIcon: const Icon(LucideIcons.indianRupee),
|
||
onChanged: (val) {
|
||
setState(() => _isAmountPaidEdited = true);
|
||
},
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: DropdownButtonFormField<String>(
|
||
value: _paymentMethod,
|
||
decoration: InputDecoration(
|
||
labelText: 'Method',
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
items: const [
|
||
DropdownMenuItem(value: 'Cash', child: Text('Cash')),
|
||
DropdownMenuItem(value: 'UPI', child: Text('UPI / QR')),
|
||
DropdownMenuItem(value: 'Bank Transfer', child: Text('Bank Transfer')),
|
||
DropdownMenuItem(value: 'Card', child: Text('Card')),
|
||
],
|
||
onChanged: (val) => setState(() => _paymentMethod = val!),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 14),
|
||
if (activeWallets.isNotEmpty)
|
||
DropdownButtonFormField<int>(
|
||
value: _selectedWalletId ?? activeWallets.first.id,
|
||
decoration: InputDecoration(
|
||
labelText: 'Deposit To Account',
|
||
prefixIcon: const Icon(LucideIcons.wallet),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
items: activeWallets.map((w) => DropdownMenuItem(
|
||
value: w.id,
|
||
child: Text('${w.name} (₹${w.balance.toStringAsFixed(0)})'),
|
||
)).toList(),
|
||
onChanged: (val) => setState(() => _selectedWalletId = val),
|
||
),
|
||
|
||
if (balanceDue > 0) ...[
|
||
const SizedBox(height: 14),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: (amountPaid > 0 ? Colors.orange : Colors.red).withValues(alpha: 0.08),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: (amountPaid > 0 ? Colors.orange : Colors.red).withValues(alpha: 0.25)),
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
amountPaid > 0 ? LucideIcons.circleDot : LucideIcons.alertCircle,
|
||
size: 16,
|
||
color: amountPaid > 0 ? Colors.orange : Colors.red,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
amountPaid > 0 ? 'Remaining Balance Due:' : 'Total Amount Due:',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
color: isDark ? Colors.white70 : Colors.black87,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
Text(
|
||
'₹${balanceDue.toStringAsFixed(2)}',
|
||
style: TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.bold,
|
||
color: amountPaid > 0 ? Colors.orange.shade700 : Colors.red.shade700,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
InkWell(
|
||
onTap: () async {
|
||
final picked = await showDatePicker(
|
||
context: context,
|
||
initialDate: _nextPaymentDate ?? DateTime.now().add(const Duration(days: 30)),
|
||
firstDate: DateTime.now(),
|
||
lastDate: DateTime(2100),
|
||
);
|
||
if (picked != null) {
|
||
setState(() => _nextPaymentDate = picked);
|
||
}
|
||
},
|
||
child: InputDecorator(
|
||
decoration: InputDecoration(
|
||
labelText: 'Next Due / Balance Settlement Date',
|
||
prefixIcon: const Icon(LucideIcons.calendarClock),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
child: Text(
|
||
_nextPaymentDate != null ? DateFormat('dd MMM yyyy').format(_nextPaymentDate!) : 'Select Settlement Date (Default: 30 Days)',
|
||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
|
||
const Divider(height: 28),
|
||
|
||
// EMI Option Toggle
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Row(
|
||
children: [
|
||
Icon(LucideIcons.creditCard, size: 18, color: Colors.purple),
|
||
SizedBox(width: 8),
|
||
Text('Enable EMI Installments', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||
],
|
||
),
|
||
Switch(
|
||
value: _isEmi,
|
||
activeColor: Colors.purple,
|
||
onChanged: (val) => setState(() => _isEmi = val),
|
||
),
|
||
],
|
||
),
|
||
|
||
if (_isEmi) ...[
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: PremiumTextField(
|
||
controller: _emiAmountCtrl,
|
||
labelText: 'EMI Amount (₹)',
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
prefixIcon: const Icon(LucideIcons.indianRupee),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: DropdownButtonFormField<String>(
|
||
value: _emiCycle,
|
||
decoration: InputDecoration(
|
||
labelText: 'Cycle',
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
items: const [
|
||
DropdownMenuItem(value: 'MONTHLY', child: Text('Monthly')),
|
||
DropdownMenuItem(value: 'WEEKLY', child: Text('Weekly')),
|
||
],
|
||
onChanged: (val) => setState(() => _emiCycle = val!),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _scanTrackingBarcode() async {
|
||
final code = await Navigator.push<String>(
|
||
context,
|
||
MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()),
|
||
);
|
||
if (code != null && code.isNotEmpty) {
|
||
setState(() {
|
||
_trackingNumberCtrl.text = code.trim();
|
||
if (_dispatchStatus == 'PENDING') {
|
||
_dispatchStatus = 'SHIPPED';
|
||
_shippedAt = DateTime.now();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
Widget _buildShippingLogisticsSection(bool isDark) {
|
||
final customers = ref.watch(customersProvider).value ?? [];
|
||
final customer = _selectedCustomerId == null
|
||
? null
|
||
: customers.where((c) => c.id == _selectedCustomerId).firstOrNull;
|
||
|
||
final courierSuggestions = [
|
||
'Delhivery',
|
||
'BlueDart',
|
||
'Shiprocket',
|
||
'DTDC',
|
||
'India Post',
|
||
'Amazon Shipping',
|
||
'Ekart',
|
||
'Shadowfax',
|
||
'Direct / Hand Delivery',
|
||
];
|
||
|
||
final dispatchStatuses = [
|
||
{'status': 'PENDING', 'label': 'Pending', 'color': Colors.grey},
|
||
{'status': 'PACKED', 'label': 'Packed', 'color': Colors.blue},
|
||
{'status': 'SHIPPED', 'label': 'Shipped', 'color': Colors.indigo},
|
||
{'status': 'IN_TRANSIT', 'label': 'In Transit', 'color': Colors.amber.shade800},
|
||
{'status': 'DELIVERED', 'label': 'Delivered', 'color': Colors.green},
|
||
{'status': 'RTO', 'label': 'RTO / Return', 'color': Colors.red},
|
||
];
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.05),
|
||
blurRadius: 15,
|
||
offset: const Offset(0, 5),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(6),
|
||
decoration: BoxDecoration(
|
||
color: Colors.indigo.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: const Icon(LucideIcons.truck, size: 16, color: Colors.indigo),
|
||
),
|
||
const SizedBox(width: 8),
|
||
const Text(
|
||
'SHIPPING & COURIER LOGISTICS',
|
||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 0.8, color: Colors.grey),
|
||
),
|
||
],
|
||
),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: Colors.indigo.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: Colors.indigo.withValues(alpha: 0.3)),
|
||
),
|
||
child: Text(
|
||
_dispatchStatus,
|
||
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.indigo),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Dispatch Status Selector
|
||
const Text('Dispatch Status:', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.grey)),
|
||
const SizedBox(height: 6),
|
||
SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(
|
||
children: dispatchStatuses.map((st) {
|
||
final statusKey = st['status'] as String;
|
||
final statusLabel = st['label'] as String;
|
||
final statusColor = st['color'] as Color;
|
||
final isSelected = _dispatchStatus == statusKey;
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.only(right: 8.0),
|
||
child: ChoiceChip(
|
||
label: Text(
|
||
statusLabel,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||
color: isSelected ? Colors.white : (isDark ? Colors.white70 : Colors.black87),
|
||
),
|
||
),
|
||
selected: isSelected,
|
||
selectedColor: statusColor,
|
||
backgroundColor: isDark ? const Color(0xFF0F172A) : Colors.grey.shade100,
|
||
onSelected: (selected) {
|
||
if (selected) {
|
||
setState(() {
|
||
_dispatchStatus = statusKey;
|
||
if (statusKey == 'SHIPPED' && _shippedAt == null) {
|
||
_shippedAt = DateTime.now();
|
||
}
|
||
});
|
||
}
|
||
},
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
|
||
// Courier Partner & Quick Selection Chips
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: PremiumTextField(
|
||
controller: _courierPartnerCtrl,
|
||
labelText: 'Courier Partner (e.g. Delhivery, BlueDart)',
|
||
prefixIcon: const Icon(LucideIcons.packageCheck),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(
|
||
children: courierSuggestions.map((name) {
|
||
final isCurrent = _courierPartnerCtrl.text.trim().toLowerCase() == name.toLowerCase();
|
||
return Padding(
|
||
padding: const EdgeInsets.only(right: 6.0),
|
||
child: ActionChip(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
|
||
label: Text(name, style: TextStyle(fontSize: 11, color: isCurrent ? Colors.blue : null)),
|
||
backgroundColor: isCurrent ? Colors.blue.withValues(alpha: 0.15) : null,
|
||
onPressed: () {
|
||
setState(() {
|
||
_courierPartnerCtrl.text = name;
|
||
});
|
||
},
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
|
||
// AWB / Tracking Number with Scanner Button
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: PremiumTextField(
|
||
controller: _trackingNumberCtrl,
|
||
labelText: 'AWB / Tracking Number',
|
||
prefixIcon: const Icon(LucideIcons.barcode),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
IconButton.filledTonal(
|
||
icon: const Icon(LucideIcons.scanLine),
|
||
tooltip: 'Scan AWB Barcode',
|
||
onPressed: _scanTrackingBarcode,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 14),
|
||
|
||
// Delivery Address Toggle & Form
|
||
CheckboxListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
dense: true,
|
||
title: const Text('Deliver to customer billing address', style: TextStyle(fontWeight: FontWeight.w600)),
|
||
subtitle: Text(
|
||
customer?.address != null && customer!.address!.isNotEmpty
|
||
? customer.address!
|
||
: 'Customer address on file',
|
||
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
value: _sameAsCustomerAddress,
|
||
onChanged: (val) {
|
||
setState(() => _sameAsCustomerAddress = val ?? true);
|
||
},
|
||
),
|
||
if (!_sameAsCustomerAddress) ...[
|
||
const SizedBox(height: 8),
|
||
PremiumTextField(
|
||
controller: _shippingAddressCtrl,
|
||
labelText: 'Shipping / Delivery Address',
|
||
maxLines: 2,
|
||
prefixIcon: const Icon(LucideIcons.mapPin),
|
||
),
|
||
const SizedBox(height: 10),
|
||
PremiumTextField(
|
||
controller: _shippingPincodeCtrl,
|
||
labelText: 'Shipping Pincode',
|
||
keyboardType: TextInputType.number,
|
||
prefixIcon: const Icon(LucideIcons.hash),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _AddSalesItemSheet extends ConsumerStatefulWidget {
|
||
final ValueChanged<InvoiceItem> onItemAdded;
|
||
final InvoiceItem? initialItem;
|
||
|
||
const _AddSalesItemSheet({
|
||
required this.onItemAdded,
|
||
this.initialItem,
|
||
});
|
||
|
||
@override
|
||
ConsumerState<_AddSalesItemSheet> createState() => _AddSalesItemSheetState();
|
||
}
|
||
|
||
class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> {
|
||
final _searchCtrl = TextEditingController();
|
||
Product? _selectedProduct;
|
||
InventoryItem? _selectedInventoryItem;
|
||
double _selectedProductAvailableStock = 0.0;
|
||
|
||
final _unitPriceCtrl = TextEditingController();
|
||
final _quantityCtrl = TextEditingController(text: '1');
|
||
final _weightCtrl = TextEditingController(text: '1.000');
|
||
final _makingChargeCtrl = TextEditingController(text: '0.0');
|
||
String _makingChargeType = 'PER_GRAM';
|
||
final _discountCtrl = TextEditingController(text: '0.0');
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
Future.microtask(() => ref.invalidate(inventoryItemsProvider));
|
||
if (widget.initialItem != null) {
|
||
final it = widget.initialItem!;
|
||
_makingChargeCtrl.text = it.makingCharge.toStringAsFixed(2);
|
||
_makingChargeType = it.makingChargesType ?? 'PER_GRAM';
|
||
_discountCtrl.text = it.discount.toStringAsFixed(2);
|
||
_unitPriceCtrl.text = it.unitPrice.toStringAsFixed(2);
|
||
_quantityCtrl.text = (it.quantity > 0 ? it.quantity : 1.0).toStringAsFixed(it.quantity.truncateToDouble() == it.quantity ? 0 : 2);
|
||
_weightCtrl.text = (it.weight ?? 1.0).toStringAsFixed(3);
|
||
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
final products = ref.read(productsProvider).value ?? [];
|
||
final invItems = ref.read(inventoryItemsProvider).value ?? [];
|
||
setState(() {
|
||
_selectedProduct = products.where((p) => p.id == it.productId).firstOrNull ??
|
||
Product(
|
||
id: it.productId,
|
||
name: it.productName ?? 'Item',
|
||
sku: it.sku,
|
||
hsnCode: it.hsnCode,
|
||
gstRate: it.taxRate,
|
||
sellingPrice: it.unitPrice,
|
||
makingCharges: it.makingCharge,
|
||
makingChargesType: it.makingChargesType,
|
||
);
|
||
if (it.inventoryItemId != null) {
|
||
_selectedInventoryItem = invItems.where((i) => i.id == it.inventoryItemId).firstOrNull;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_searchCtrl.dispose();
|
||
_unitPriceCtrl.dispose();
|
||
_quantityCtrl.dispose();
|
||
_weightCtrl.dispose();
|
||
_makingChargeCtrl.dispose();
|
||
_discountCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onProductSelected(
|
||
Product product,
|
||
ProductCategory? category,
|
||
double liveCommodityRate,
|
||
bool isCommodity, {
|
||
double? avgPrice,
|
||
double? availableStock,
|
||
}) {
|
||
setState(() {
|
||
_selectedProduct = product;
|
||
_selectedInventoryItem = null;
|
||
_selectedProductAvailableStock = availableStock ?? product.currentStock ?? 0.0;
|
||
final resolvedPrice = (avgPrice != null && avgPrice > 0)
|
||
? avgPrice
|
||
: (product.sellingPrice ?? 0.0);
|
||
_unitPriceCtrl.text = resolvedPrice > 0 ? resolvedPrice.toStringAsFixed(2) : '';
|
||
_quantityCtrl.text = '1';
|
||
_weightCtrl.text = '1.000';
|
||
|
||
if (product.makingCharges != null && product.makingCharges! > 0) {
|
||
_makingChargeCtrl.text = product.makingCharges!.toStringAsFixed(2);
|
||
_makingChargeType = product.makingChargesType ?? (category?.makingChargeType ?? 'PER_GRAM');
|
||
} else if (category?.defaultMakingCharge != null && category!.defaultMakingCharge! > 0) {
|
||
_makingChargeCtrl.text = category.defaultMakingCharge!.toStringAsFixed(2);
|
||
_makingChargeType = category.makingChargeType ?? 'PER_GRAM';
|
||
} else {
|
||
_makingChargeCtrl.text = '0.0';
|
||
_makingChargeType = category?.makingChargeType ?? 'PER_GRAM';
|
||
}
|
||
});
|
||
}
|
||
|
||
void _onInventoryItemSelected(InventoryItem item, Product product, ProductCategory? category, bool isCommodity) {
|
||
setState(() {
|
||
_selectedProduct = product;
|
||
_selectedInventoryItem = item;
|
||
_selectedProductAvailableStock = item.grossWeight ?? item.netWeight ?? 1.0;
|
||
final resolvedPrice = item.saleRate ?? product.sellingPrice ?? 0.0;
|
||
_unitPriceCtrl.text = resolvedPrice > 0 ? resolvedPrice.toStringAsFixed(2) : '';
|
||
_quantityCtrl.text = '1';
|
||
_weightCtrl.text = (item.grossWeight ?? 1.0).toStringAsFixed(3);
|
||
|
||
if (item.makingCharges != null && item.makingCharges! > 0) {
|
||
_makingChargeCtrl.text = item.makingCharges!.toStringAsFixed(2);
|
||
_makingChargeType = item.makingChargeType ?? 'PER_GRAM';
|
||
} else if (product.makingCharges != null && product.makingCharges! > 0) {
|
||
_makingChargeCtrl.text = product.makingCharges!.toStringAsFixed(2);
|
||
_makingChargeType = product.makingChargesType ?? (category?.makingChargeType ?? 'PER_GRAM');
|
||
} else if (category?.defaultMakingCharge != null && category!.defaultMakingCharge! > 0) {
|
||
_makingChargeCtrl.text = category.defaultMakingCharge!.toStringAsFixed(2);
|
||
_makingChargeType = category.makingChargeType ?? 'PER_GRAM';
|
||
} else {
|
||
_makingChargeCtrl.text = '0.0';
|
||
_makingChargeType = category?.makingChargeType ?? 'PER_GRAM';
|
||
}
|
||
});
|
||
}
|
||
|
||
Future<void> _scanBarcodeInSheet() async {
|
||
final scanned = await Navigator.push<String>(
|
||
context,
|
||
MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()),
|
||
);
|
||
if (scanned != null && scanned.isNotEmpty) {
|
||
_searchCtrl.text = scanned;
|
||
setState(() {});
|
||
}
|
||
}
|
||
|
||
void _submitItem(ProductCategory? category, double unitRate, bool isCommodity, String uomUnit) {
|
||
if (_selectedProduct == null) return;
|
||
|
||
final discount = double.tryParse(_discountCtrl.text) ?? 0.0;
|
||
final gstRate = _selectedProduct?.gstRate ?? (category?.defaultGst ?? (isCommodity ? 3.0 : 18.0));
|
||
|
||
double effectivePrice = unitRate;
|
||
double qty = 1.0;
|
||
double? weight;
|
||
double makingCharge = 0.0;
|
||
String makingType = 'PER_PIECE';
|
||
double lineTaxable = 0.0;
|
||
double taxAmount = 0.0;
|
||
double total = 0.0;
|
||
|
||
if (isCommodity) {
|
||
weight = double.tryParse(_weightCtrl.text) ?? (_selectedInventoryItem?.grossWeight ?? 1.0);
|
||
qty = 1.0;
|
||
effectivePrice = unitRate;
|
||
makingCharge = double.tryParse(_makingChargeCtrl.text) ?? 0.0;
|
||
makingType = _makingChargeType;
|
||
|
||
final metalAmount = weight * effectivePrice;
|
||
double makingAmt = 0.0;
|
||
if (makingType == 'PERCENTAGE') {
|
||
makingAmt = metalAmount * (makingCharge / 100.0);
|
||
} else if (makingType == 'PER_PIECE') {
|
||
makingAmt = makingCharge;
|
||
} else {
|
||
makingAmt = weight * makingCharge;
|
||
}
|
||
|
||
lineTaxable = metalAmount + makingAmt - discount;
|
||
taxAmount = (lineTaxable.clamp(0.0, double.infinity) * gstRate) / 100.0;
|
||
total = lineTaxable.clamp(0.0, double.infinity) + taxAmount;
|
||
} else {
|
||
effectivePrice = double.tryParse(_unitPriceCtrl.text) ?? (_selectedProduct?.sellingPrice ?? unitRate);
|
||
qty = double.tryParse(_quantityCtrl.text) ?? 1.0;
|
||
weight = null;
|
||
makingCharge = 0.0;
|
||
makingType = 'PER_PIECE';
|
||
|
||
lineTaxable = (qty * effectivePrice) - discount;
|
||
taxAmount = (lineTaxable.clamp(0.0, double.infinity) * gstRate) / 100.0;
|
||
total = lineTaxable.clamp(0.0, double.infinity) + taxAmount;
|
||
}
|
||
|
||
final item = InvoiceItem(
|
||
productId: _selectedProduct!.id,
|
||
inventoryItemId: _selectedInventoryItem?.id,
|
||
productName: _selectedProduct!.name,
|
||
categoryName: category?.name,
|
||
commodityCode: isCommodity ? category?.commodityCode : null,
|
||
hsnCode: category?.defaultHsn ?? _selectedProduct?.hsnCode,
|
||
sku: _selectedInventoryItem?.sku ?? _selectedProduct?.sku,
|
||
huid: isCommodity ? _selectedInventoryItem?.huid : null,
|
||
description: _selectedProduct!.name,
|
||
quantity: qty,
|
||
weight: weight,
|
||
unitPrice: effectivePrice,
|
||
makingCharge: makingCharge,
|
||
makingChargesType: makingType,
|
||
taxRate: gstRate,
|
||
discount: discount,
|
||
photoUrl: _selectedInventoryItem?.tagNumber,
|
||
total: total,
|
||
);
|
||
|
||
widget.onItemAdded(item);
|
||
Navigator.pop(context);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final products = ref.watch(productsProvider).value ?? [];
|
||
final categories = ref.watch(productCategoriesProvider).value ?? [];
|
||
final commodityRates = ref.watch(commodityRatesProvider).value ?? [];
|
||
final inventoryItems = ref.watch(inventoryItemsProvider).value ?? [];
|
||
final uoms = ref.watch(uomsProvider).value ?? [];
|
||
|
||
final businessProfile = ref.watch(businessProfileProvider).value;
|
||
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
|
||
final isJewellery = nature == 'JEWELLERY';
|
||
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
|
||
ProductCategory? category;
|
||
if (_selectedProduct?.categoryId != null) {
|
||
category = categories.where((c) => c.id == _selectedProduct!.categoryId).firstOrNull;
|
||
}
|
||
|
||
final isCommodity = _isCommodityProduct(
|
||
category: category,
|
||
product: _selectedProduct,
|
||
inventoryItem: _selectedInventoryItem,
|
||
);
|
||
final uomUnit = _resolveProductUnit(product: _selectedProduct, category: category, uoms: uoms, isCommodity: isCommodity);
|
||
|
||
double commodityRate = 0.0;
|
||
if (category?.commodityCode != null) {
|
||
final match = commodityRates.where((r) => r.commodityCode.toUpperCase() == category!.commodityCode!.toUpperCase()).firstOrNull;
|
||
if (match != null) {
|
||
commodityRate = match.rate;
|
||
}
|
||
}
|
||
final purityFactor = resolvePurity(categoryPurity: category?.purityFactor, productPurity: _selectedProduct?.purityFactor);
|
||
final effectiveUnitRate = isCommodity
|
||
? (commodityRate > 0 ? (commodityRate * purityFactor) : (_selectedProduct?.sellingPrice ?? 0.0))
|
||
: (double.tryParse(_unitPriceCtrl.text) ?? (_selectedProduct?.sellingPrice ?? 0.0));
|
||
|
||
final weight = double.tryParse(_weightCtrl.text) ?? (_selectedInventoryItem?.grossWeight ?? 1.0);
|
||
final quantity = double.tryParse(_quantityCtrl.text) ?? 1.0;
|
||
final discount = double.tryParse(_discountCtrl.text) ?? 0.0;
|
||
final gstRate = _selectedProduct?.gstRate ?? (category?.defaultGst ?? (isCommodity ? 3.0 : 18.0));
|
||
|
||
double taxable = 0.0;
|
||
double makingAmt = 0.0;
|
||
double metalAmount = 0.0;
|
||
|
||
if (isCommodity) {
|
||
metalAmount = weight * effectiveUnitRate;
|
||
final makingCharge = double.tryParse(_makingChargeCtrl.text) ?? 0.0;
|
||
if (_makingChargeType == 'PERCENTAGE') {
|
||
makingAmt = metalAmount * (makingCharge / 100.0);
|
||
} else if (_makingChargeType == 'PER_PIECE') {
|
||
makingAmt = makingCharge;
|
||
} else {
|
||
makingAmt = weight * makingCharge;
|
||
}
|
||
taxable = (metalAmount + makingAmt - discount).clamp(0.0, double.infinity);
|
||
} else {
|
||
final subtotal = quantity * effectiveUnitRate;
|
||
taxable = (subtotal - discount).clamp(0.0, double.infinity);
|
||
}
|
||
|
||
final taxAmount = (taxable * gstRate) / 100.0;
|
||
final total = taxable + taxAmount;
|
||
|
||
final query = _searchCtrl.text.trim().toLowerCase();
|
||
final availableItems = inventoryItems
|
||
.where((item) => item.status == null || item.status == 'AVAILABLE')
|
||
.toList();
|
||
|
||
availableItems.sort((a, b) {
|
||
if (a.createdAt == null && b.createdAt == null) return 0;
|
||
if (a.createdAt == null) return 1;
|
||
if (b.createdAt == null) return -1;
|
||
return b.createdAt!.compareTo(a.createdAt!);
|
||
});
|
||
|
||
final List<InventoryItem> displayInventoryItems;
|
||
if (query.isEmpty) {
|
||
displayInventoryItems = availableItems.take(20).toList();
|
||
} else {
|
||
displayInventoryItems = availableItems.where((item) {
|
||
final p = products.where((pr) => pr.id == item.productId).firstOrNull;
|
||
final cat = p != null ? categories.where((c) => c.id == p.categoryId).firstOrNull : null;
|
||
|
||
final matchesHuid = item.huid != null && item.huid!.toLowerCase().contains(query);
|
||
final matchesSku = (item.sku != null && item.sku!.toLowerCase().contains(query)) ||
|
||
(p?.sku != null && p!.sku!.toLowerCase().contains(query));
|
||
final matchesName = p != null && p.name.toLowerCase().contains(query);
|
||
final matchesBarcode = p != null && p.barcode != null && p.barcode!.toLowerCase().contains(query);
|
||
final matchesCategory = cat != null && cat.name.toLowerCase().contains(query);
|
||
final matchesTag = item.tagNumber != null && item.tagNumber!.toLowerCase().contains(query);
|
||
|
||
return matchesHuid || matchesSku || matchesName || matchesBarcode || matchesCategory || matchesTag;
|
||
}).take(20).toList();
|
||
}
|
||
|
||
final matchingProductIdsInInv = displayInventoryItems.map((i) => i.productId).toSet();
|
||
final directProducts = products.where((p) {
|
||
if (matchingProductIdsInInv.contains(p.id)) return false;
|
||
if (query.isEmpty) return true;
|
||
final q = query.toLowerCase();
|
||
final cat = categories.where((c) => c.id == p.categoryId).firstOrNull;
|
||
return p.name.toLowerCase().contains(q) ||
|
||
(p.sku != null && p.sku!.toLowerCase().contains(q)) ||
|
||
(p.barcode != null && p.barcode!.toLowerCase().contains(q)) ||
|
||
(cat != null && cat.name.toLowerCase().contains(q));
|
||
}).take(20).toList();
|
||
|
||
final matchedProducts = products.where((p) {
|
||
if (query.isEmpty) return true;
|
||
final cat = categories.where((c) => c.id == p.categoryId).firstOrNull;
|
||
return p.name.toLowerCase().contains(query) ||
|
||
(p.sku != null && p.sku!.toLowerCase().contains(query)) ||
|
||
(p.barcode != null && p.barcode!.toLowerCase().contains(query)) ||
|
||
(cat != null && cat.name.toLowerCase().contains(query));
|
||
}).toList();
|
||
|
||
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
||
|
||
return AnimatedPadding(
|
||
padding: EdgeInsets.only(bottom: keyboardHeight),
|
||
duration: const Duration(milliseconds: 150),
|
||
curve: Curves.easeOut,
|
||
child: GestureDetector(
|
||
onTap: () => FocusScope.of(context).unfocus(),
|
||
behavior: HitTestBehavior.translucent,
|
||
child: Container(
|
||
height: MediaQuery.of(context).size.height * 0.88,
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).scaffoldBackgroundColor,
|
||
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Center(
|
||
child: Container(
|
||
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey.withValues(alpha: 0.4),
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Add Item to Invoice', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (keyboardHeight > 0)
|
||
TextButton.icon(
|
||
style: TextButton.styleFrom(
|
||
backgroundColor: Colors.blue.withValues(alpha: 0.12),
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
),
|
||
icon: const Icon(LucideIcons.keyboard, size: 16, color: Colors.blue),
|
||
label: const Text('Done', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)),
|
||
onPressed: () => FocusScope.of(context).unfocus(),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(LucideIcons.x, size: 20),
|
||
onPressed: () => Navigator.pop(context),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const Divider(height: 1),
|
||
|
||
// Keyboard Toolbar when active on iOS/Android
|
||
if (keyboardHeight > 0)
|
||
Container(
|
||
color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'Editing ${_selectedProduct?.name ?? 'Item'}',
|
||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w500),
|
||
),
|
||
TextButton.icon(
|
||
style: TextButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||
backgroundColor: Colors.blue,
|
||
foregroundColor: Colors.white,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
),
|
||
icon: const Icon(LucideIcons.check, size: 15),
|
||
label: const Text('Done / Dismiss', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||
onPressed: () => FocusScope.of(context).unfocus(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
Expanded(
|
||
child: ListView(
|
||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 40),
|
||
children: [
|
||
// Search Input with Barcode Action & Rounded Borders
|
||
TextField(
|
||
controller: _searchCtrl,
|
||
decoration: InputDecoration(
|
||
labelText: isJewellery
|
||
? 'Search Product by HUID / Name / SKU / Barcode'
|
||
: 'Search Product by Name / SKU / Barcode',
|
||
prefixIcon: const Icon(LucideIcons.search),
|
||
suffixIcon: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (_searchCtrl.text.isNotEmpty)
|
||
IconButton(
|
||
icon: const Icon(LucideIcons.x),
|
||
onPressed: () => setState(() => _searchCtrl.clear()),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(LucideIcons.qrCode, color: Colors.blue),
|
||
tooltip: 'Scan Barcode',
|
||
onPressed: _scanBarcodeInSheet,
|
||
),
|
||
],
|
||
),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
onChanged: (val) => setState(() {}),
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
if (_selectedProduct == null) ...[
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
!isJewellery
|
||
? (query.isEmpty ? 'Available Products in Catalog (${matchedProducts.length}):' : 'Matched Products (${matchedProducts.length}):')
|
||
: (query.isEmpty ? 'Available Stock in Inventory (${displayInventoryItems.length + directProducts.length}):' : 'Matched Items (${displayInventoryItems.length + directProducts.length}):'),
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.bold,
|
||
color: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
if (!isJewellery) ...[
|
||
// NON-JEWELLERY MODULE: Show List of Unique Products with Weighted Avg Price & Aggregated Available Stock
|
||
if (matchedProducts.isEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 40.0),
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(LucideIcons.packageOpen, size: 40, color: Colors.grey.shade400),
|
||
const SizedBox(height: 10),
|
||
Text(
|
||
query.isEmpty
|
||
? 'No products available in catalog'
|
||
: 'No matching products found',
|
||
style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
)
|
||
else ...[
|
||
...matchedProducts.map((p) {
|
||
final cat = categories.where((c) => c.id == p.categoryId).firstOrNull;
|
||
final itemUom = _resolveProductUnit(product: p, category: cat, uoms: uoms, isCommodity: false);
|
||
|
||
// Find all available batches for this product in inventory
|
||
final pBatches = availableItems.where((i) => i.productId == p.id).toList();
|
||
|
||
double totalAvailableStock = 0.0;
|
||
double totalCostOrPrice = 0.0;
|
||
for (var batch in pBatches) {
|
||
final qty = (batch.grossWeight != null && batch.grossWeight! > 0)
|
||
? batch.grossWeight!
|
||
: ((batch.netWeight != null && batch.netWeight! > 0) ? batch.netWeight! : 1.0);
|
||
final rate = (batch.saleRate != null && batch.saleRate! > 0)
|
||
? batch.saleRate!
|
||
: (batch.purchaseCost != null && batch.purchaseCost! > 0 ? batch.purchaseCost! : (p.sellingPrice ?? 0.0));
|
||
totalAvailableStock += qty;
|
||
totalCostOrPrice += (qty * rate);
|
||
}
|
||
|
||
if (pBatches.isEmpty && (p.currentStock != null && p.currentStock! > 0)) {
|
||
totalAvailableStock = p.currentStock!;
|
||
totalCostOrPrice = totalAvailableStock * (p.sellingPrice ?? 0.0);
|
||
}
|
||
|
||
final avgPrice = totalAvailableStock > 0
|
||
? (totalCostOrPrice / totalAvailableStock)
|
||
: (p.sellingPrice ?? 0.0);
|
||
|
||
return _buildProductSearchCard(
|
||
product: p,
|
||
category: cat,
|
||
availableStock: totalAvailableStock,
|
||
avgPrice: avgPrice,
|
||
uomUnit: itemUom,
|
||
isDark: isDark,
|
||
onTap: () => _onProductSelected(
|
||
p,
|
||
cat,
|
||
0.0,
|
||
false,
|
||
avgPrice: avgPrice,
|
||
availableStock: totalAvailableStock,
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
] else ...[
|
||
// JEWELLERY MODULE: Show Serialized Inventory Items (HUID, Tag No, Purity, Gross Weight)
|
||
if (displayInventoryItems.isEmpty && directProducts.isEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 40.0),
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(LucideIcons.packageOpen, size: 40, color: Colors.grey.shade400),
|
||
const SizedBox(height: 10),
|
||
Text(
|
||
query.isEmpty
|
||
? 'No items available in stock'
|
||
: 'No matching items found',
|
||
style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
)
|
||
else ...[
|
||
...displayInventoryItems.map((item) {
|
||
final p = products.where((pr) => pr.id == item.productId).firstOrNull ??
|
||
Product(
|
||
id: item.productId,
|
||
name: item.sku ?? 'Inventory Item',
|
||
sku: item.sku,
|
||
);
|
||
final cat = categories.where((c) => c.id == p.categoryId).firstOrNull;
|
||
final itemIsCommodity = _isCommodityProduct(category: cat, product: p, inventoryItem: item);
|
||
final itemUom = _resolveProductUnit(product: p, category: cat, uoms: uoms, isCommodity: itemIsCommodity);
|
||
|
||
double commRate = 0.0;
|
||
if (cat?.commodityCode != null) {
|
||
final m = commodityRates.where((r) => r.commodityCode.toUpperCase() == cat!.commodityCode!.toUpperCase()).firstOrNull;
|
||
if (m != null) commRate = m.rate;
|
||
}
|
||
return _buildSearchItemCard(
|
||
product: p,
|
||
category: cat,
|
||
inventoryItem: item,
|
||
commodityRate: commRate,
|
||
isCommodity: itemIsCommodity,
|
||
uomUnit: itemUom,
|
||
isDark: isDark,
|
||
onTap: () => _onInventoryItemSelected(item, p, cat, itemIsCommodity),
|
||
);
|
||
}),
|
||
...directProducts.map((p) {
|
||
final cat = categories.where((c) => c.id == p.categoryId).firstOrNull;
|
||
final itemIsCommodity = _isCommodityProduct(category: cat, product: p);
|
||
final itemUom = _resolveProductUnit(product: p, category: cat, uoms: uoms, isCommodity: itemIsCommodity);
|
||
|
||
double commRate = 0.0;
|
||
if (cat?.commodityCode != null) {
|
||
final m = commodityRates.where((r) => r.commodityCode.toUpperCase() == cat!.commodityCode!.toUpperCase()).firstOrNull;
|
||
if (m != null) commRate = m.rate;
|
||
}
|
||
return _buildSearchItemCard(
|
||
product: p,
|
||
category: cat,
|
||
inventoryItem: null,
|
||
commodityRate: commRate,
|
||
isCommodity: itemIsCommodity,
|
||
uomUnit: itemUom,
|
||
isDark: isDark,
|
||
onTap: () => _onProductSelected(p, cat, commRate, itemIsCommodity),
|
||
);
|
||
}),
|
||
],
|
||
],
|
||
] else ...[
|
||
// SELECTED PRODUCT DETAILS CARD
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? const Color(0xFF1E293B) : Colors.blue.withValues(alpha: 0.05),
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
_selectedProduct!.name,
|
||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: () => setState(() {
|
||
_selectedProduct = null;
|
||
_selectedInventoryItem = null;
|
||
}),
|
||
child: const Text('Change'),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 4,
|
||
children: [
|
||
if (category?.name != null)
|
||
_buildSheetBadge(category!.name, const Color(0xFFD4AF37)),
|
||
if (_selectedProduct!.sku != null)
|
||
_buildSheetBadge('SKU: ${_selectedProduct!.sku}', Colors.blue),
|
||
if (isCommodity && _selectedInventoryItem?.huid != null)
|
||
_buildSheetBadge('HUID: ${_selectedInventoryItem!.huid}', Colors.purple),
|
||
if (isCommodity && category?.commodityCode != null)
|
||
_buildSheetBadge('${category!.commodityCode} (${formatPurity(purityFactor)})', Colors.teal),
|
||
if (isCommodity)
|
||
_buildSheetBadge('Purity: ${formatPurity(purityFactor)}', Colors.amber.shade800),
|
||
if (!isCommodity) ...[
|
||
_buildSheetBadge('Unit: $uomUnit', Colors.teal),
|
||
if (_selectedProductAvailableStock > 0)
|
||
_buildSheetBadge('Available Stock: ${_selectedProductAvailableStock.toStringAsFixed(_selectedProductAvailableStock.truncateToDouble() == _selectedProductAvailableStock ? 0 : 2)} $uomUnit', Colors.green.shade800),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
if (isCommodity) ...[
|
||
// COMMODITY / JEWELLERY FORM
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextFormField(
|
||
initialValue: category?.defaultHsn ?? _selectedProduct?.hsnCode ?? '7113',
|
||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||
enabled: false,
|
||
decoration: InputDecoration(
|
||
labelText: 'HSN Code',
|
||
prefixIcon: const Icon(LucideIcons.hash, size: 18),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: PremiumTextField(
|
||
controller: _weightCtrl,
|
||
labelText: 'Weight ($uomUnit)',
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
prefixIcon: const Icon(LucideIcons.scale, size: 18),
|
||
onChanged: (_) => setState(() {}),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextFormField(
|
||
key: ValueKey(effectiveUnitRate),
|
||
initialValue: '₹${effectiveUnitRate.toStringAsFixed(2)}/$uomUnit',
|
||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||
enabled: false,
|
||
decoration: InputDecoration(
|
||
labelText: 'Live Rate',
|
||
prefixIcon: const Icon(LucideIcons.trendingUp, size: 18),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: TextFormField(
|
||
key: ValueKey(metalAmount),
|
||
initialValue: '₹${metalAmount.toStringAsFixed(2)}',
|
||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||
enabled: false,
|
||
decoration: InputDecoration(
|
||
labelText: 'Metal Amount',
|
||
prefixIcon: const Icon(LucideIcons.coins, size: 18),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: PremiumTextField(
|
||
controller: _makingChargeCtrl,
|
||
labelText: 'Making Charges',
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
prefixIcon: const Icon(LucideIcons.hammer),
|
||
onChanged: (_) => setState(() {}),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: DropdownButtonFormField<String>(
|
||
value: _makingChargeType,
|
||
decoration: InputDecoration(
|
||
labelText: 'Type',
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
items: const [
|
||
DropdownMenuItem(value: 'PER_GRAM', child: Text('Per Gram')),
|
||
DropdownMenuItem(value: 'PER_PIECE', child: Text('Per Piece')),
|
||
DropdownMenuItem(value: 'PERCENTAGE', child: Text('Percentage %')),
|
||
],
|
||
onChanged: (val) => setState(() => _makingChargeType = val!),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (makingAmt > 0) ...[
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.indigo.withValues(alpha: 0.08),
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: Colors.indigo.withValues(alpha: 0.2)),
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text(
|
||
'Calculated Making Charge:',
|
||
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||
),
|
||
Text(
|
||
'₹${makingAmt.toStringAsFixed(2)}',
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.indigo),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: 12),
|
||
PremiumTextField(
|
||
controller: _discountCtrl,
|
||
labelText: 'Item Discount (₹)',
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
prefixIcon: const Icon(LucideIcons.tag),
|
||
onChanged: (_) => setState(() {}),
|
||
),
|
||
] else ...[
|
||
// E-COMMERCE / GENERAL / NON-COMMODITY FORM
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: PremiumTextField(
|
||
controller: _unitPriceCtrl,
|
||
labelText: 'Unit Price (₹ / $uomUnit)',
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
prefixIcon: const Icon(LucideIcons.indianRupee),
|
||
onChanged: (_) => setState(() {}),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
PremiumTextField(
|
||
controller: _quantityCtrl,
|
||
labelText: 'Quantity ($uomUnit)',
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
prefixIcon: const Icon(LucideIcons.package),
|
||
onChanged: (_) => setState(() {}),
|
||
),
|
||
if (_selectedProductAvailableStock > 0 &&
|
||
(double.tryParse(_quantityCtrl.text) ?? 0.0) > _selectedProductAvailableStock) ...[
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'Exceeds available stock (${_selectedProductAvailableStock.toStringAsFixed(_selectedProductAvailableStock.truncateToDouble() == _selectedProductAvailableStock ? 0 : 2)} $uomUnit)',
|
||
style: const TextStyle(fontSize: 11, color: Colors.orange, fontWeight: FontWeight.w600),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: PremiumTextField(
|
||
controller: _discountCtrl,
|
||
labelText: 'Item Discount (₹)',
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
prefixIcon: const Icon(LucideIcons.tag),
|
||
onChanged: (_) => setState(() {}),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: TextFormField(
|
||
initialValue: category?.defaultHsn ?? _selectedProduct?.hsnCode ?? '-',
|
||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||
enabled: false,
|
||
decoration: InputDecoration(
|
||
labelText: 'HSN Code',
|
||
prefixIcon: const Icon(LucideIcons.hash, size: 18),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
// Calculated Subtotal & GST Display
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? Colors.black26 : Colors.grey.shade100,
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Taxable Subtotal:'),
|
||
Text('₹${taxable.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text('GST (${gstRate.toStringAsFixed(1)}%):'),
|
||
Text('+ ₹${taxAmount.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.indigo)),
|
||
],
|
||
),
|
||
const Divider(height: 16),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Item Total:', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||
Text('₹${total.toStringAsFixed(2)}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.green)),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 24),
|
||
|
||
ElevatedButton(
|
||
onPressed: () => _submitItem(category, effectiveUnitRate, isCommodity, uomUnit),
|
||
style: ElevatedButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||
backgroundColor: Colors.blue,
|
||
foregroundColor: Colors.white,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
child: const Text('Add to Invoice', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSheetBadge(String text, Color color) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: color.withValues(alpha: 0.4)),
|
||
),
|
||
child: Text(
|
||
text,
|
||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: color),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildProductSearchCard({
|
||
required Product product,
|
||
ProductCategory? category,
|
||
required double availableStock,
|
||
required double avgPrice,
|
||
required String uomUnit,
|
||
required bool isDark,
|
||
required VoidCallback onTap,
|
||
}) {
|
||
final gstRate = product.gstRate ?? (category?.defaultGst ?? 18.0);
|
||
final unitWithGst = avgPrice > 0 ? avgPrice * (1 + gstRate / 100.0) : 0.0;
|
||
final totalStockValue = availableStock * avgPrice;
|
||
final sku = product.sku;
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
|
||
blurRadius: 8,
|
||
offset: const Offset(0, 3),
|
||
),
|
||
],
|
||
),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
borderRadius: BorderRadius.circular(16),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(16),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.blue.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: const Icon(
|
||
LucideIcons.package,
|
||
size: 20,
|
||
color: Colors.blue,
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
product.name,
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Wrap(
|
||
spacing: 6,
|
||
runSpacing: 4,
|
||
children: [
|
||
if (category?.name != null)
|
||
_buildSheetBadge(category!.name, const Color(0xFFD4AF37)),
|
||
if (sku != null && sku.isNotEmpty)
|
||
_buildSheetBadge('SKU: $sku', Colors.blue),
|
||
_buildSheetBadge('Unit: $uomUnit', Colors.teal),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const Divider(height: 18),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
avgPrice > 0
|
||
? 'Avg Price: ₹${avgPrice.toStringAsFixed(2)} / $uomUnit'
|
||
: 'Price: Not Set',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: avgPrice > 0 ? (isDark ? Colors.white70 : Colors.grey.shade800) : Colors.orange.shade700,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 3),
|
||
Text(
|
||
'Available Stock: ${availableStock.toStringAsFixed(availableStock.truncateToDouble() == availableStock ? 0 : 2)} $uomUnit',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.bold,
|
||
color: availableStock > 0 ? Colors.green.shade700 : Colors.red.shade600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
if (avgPrice > 0 && availableStock > 0) ...[
|
||
Text(
|
||
'₹${totalStockValue.toStringAsFixed(2)}',
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.green),
|
||
),
|
||
Text(
|
||
'₹${unitWithGst.toStringAsFixed(2)}/$uomUnit (incl. ${gstRate.toStringAsFixed(1)}% GST)',
|
||
style: TextStyle(fontSize: 10, color: Colors.grey.shade500),
|
||
),
|
||
] else if (avgPrice > 0) ...[
|
||
Text(
|
||
'₹${unitWithGst.toStringAsFixed(2)} / $uomUnit',
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: Colors.green),
|
||
),
|
||
Text(
|
||
'incl. ${gstRate.toStringAsFixed(1)}% GST',
|
||
style: TextStyle(fontSize: 10, color: Colors.grey.shade500),
|
||
),
|
||
] else ...[
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: Colors.blue.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
|
||
),
|
||
child: const Text(
|
||
'Tap to enter price',
|
||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.blue),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSearchItemCard({
|
||
required Product product,
|
||
ProductCategory? category,
|
||
InventoryItem? inventoryItem,
|
||
required double commodityRate,
|
||
required bool isCommodity,
|
||
required String uomUnit,
|
||
required bool isDark,
|
||
required VoidCallback onTap,
|
||
}) {
|
||
final purity = resolvePurity(categoryPurity: category?.purityFactor, productPurity: product.purityFactor);
|
||
final unitRate = isCommodity
|
||
? (commodityRate > 0 ? (commodityRate * purity) : (inventoryItem?.saleRate ?? product.sellingPrice ?? 0.0))
|
||
: (inventoryItem?.saleRate ?? product.sellingPrice ?? 0.0);
|
||
final weight = inventoryItem?.grossWeight ?? 1.0;
|
||
final baseAmount = weight * unitRate;
|
||
|
||
final makingCharge = (inventoryItem?.makingCharges != null && inventoryItem!.makingCharges! > 0)
|
||
? inventoryItem.makingCharges!
|
||
: ((product.makingCharges != null && product.makingCharges! > 0)
|
||
? product.makingCharges!
|
||
: (category?.defaultMakingCharge ?? 0.0));
|
||
final makingType = (inventoryItem?.makingChargeType != null && inventoryItem!.makingChargeType!.isNotEmpty)
|
||
? inventoryItem.makingChargeType!
|
||
: ((product.makingCharges != null && product.makingCharges! > 0 && product.makingChargesType != null)
|
||
? product.makingChargesType!
|
||
: (category?.makingChargeType ?? 'PER_GRAM'));
|
||
|
||
double makingAmt = 0.0;
|
||
if (isCommodity) {
|
||
if (makingType == 'PERCENTAGE') {
|
||
makingAmt = baseAmount * (makingCharge / 100.0);
|
||
} else if (makingType == 'PER_PIECE') {
|
||
makingAmt = makingCharge;
|
||
} else {
|
||
makingAmt = weight * makingCharge;
|
||
}
|
||
}
|
||
|
||
final gstRate = product.gstRate ?? (category?.defaultGst ?? (isCommodity ? 3.0 : 18.0));
|
||
final taxable = baseAmount + makingAmt;
|
||
final taxAmount = (taxable * gstRate) / 100.0;
|
||
final totalWithTax = taxable + taxAmount;
|
||
|
||
final huid = inventoryItem?.huid;
|
||
final sku = inventoryItem?.sku ?? product.sku;
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
|
||
blurRadius: 8,
|
||
offset: const Offset(0, 3),
|
||
),
|
||
],
|
||
),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
borderRadius: BorderRadius.circular(16),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(16),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(
|
||
color: (isCommodity ? (huid != null ? Colors.purple : const Color(0xFFD4AF37)) : Colors.blue).withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Icon(
|
||
isCommodity ? (huid != null ? LucideIcons.gem : LucideIcons.sparkles) : LucideIcons.tag,
|
||
size: 20,
|
||
color: isCommodity ? (huid != null ? Colors.purple : const Color(0xFFD4AF37)) : Colors.blue,
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
product.name,
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Wrap(
|
||
spacing: 6,
|
||
runSpacing: 4,
|
||
children: [
|
||
if (category?.name != null)
|
||
_buildSheetBadge(category!.name, const Color(0xFFD4AF37)),
|
||
if (sku != null && sku.isNotEmpty)
|
||
_buildSheetBadge('SKU: $sku', Colors.blue),
|
||
if (isCommodity && huid != null && huid.isNotEmpty)
|
||
_buildSheetBadge('HUID: $huid', Colors.purple),
|
||
if (isCommodity)
|
||
_buildSheetBadge('Purity: ${formatPurity(purity)}', Colors.amber.shade900),
|
||
if (!isCommodity)
|
||
_buildSheetBadge('Unit: $uomUnit', Colors.teal),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const Divider(height: 18),
|
||
if (isCommodity) ...[
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'Rate: ₹${unitRate.toStringAsFixed(2)}/$uomUnit • ${weight.toStringAsFixed(3)}$uomUnit',
|
||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600, fontWeight: FontWeight.w500),
|
||
),
|
||
Text(
|
||
'Metal: ₹${baseAmount.toStringAsFixed(2)}',
|
||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: Colors.indigo.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: Colors.indigo.withValues(alpha: 0.2)),
|
||
),
|
||
child: Text(
|
||
'Making: ${makingType == 'PERCENTAGE' ? '$makingCharge%' : (makingType == 'PER_PIECE' ? '₹$makingCharge/pc' : '₹$makingCharge/g')} (₹${makingAmt.toStringAsFixed(2)})',
|
||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.indigo),
|
||
),
|
||
),
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text(
|
||
'₹${totalWithTax.toStringAsFixed(2)}',
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.green),
|
||
),
|
||
Text(
|
||
'incl. ${gstRate.toStringAsFixed(1)}% GST',
|
||
style: TextStyle(fontSize: 10, color: Colors.grey.shade500),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
] else ...[
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
unitRate > 0
|
||
? 'Price: ₹${unitRate.toStringAsFixed(2)} / $uomUnit'
|
||
: 'Price: Not Set',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: unitRate > 0 ? (isDark ? Colors.white70 : Colors.grey.shade800) : Colors.orange.shade700,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
if (inventoryItem?.grossWeight != null && inventoryItem!.grossWeight! > 0) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
'In Stock: ${(inventoryItem!.grossWeight!).toStringAsFixed((inventoryItem!.grossWeight!).truncateToDouble() == inventoryItem!.grossWeight! ? 0 : 2)} $uomUnit',
|
||
style: TextStyle(fontSize: 11, color: isDark ? Colors.white54 : Colors.grey.shade600),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
if (unitRate > 0) ...[
|
||
Text(
|
||
'₹${totalWithTax.toStringAsFixed(2)}',
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.green),
|
||
),
|
||
Text(
|
||
'incl. ${gstRate.toStringAsFixed(1)}% GST',
|
||
style: TextStyle(fontSize: 10, color: Colors.grey.shade500),
|
||
),
|
||
] else ...[
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: Colors.blue.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
|
||
),
|
||
child: const Text(
|
||
'Tap to enter price',
|
||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.blue),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|