832 lines
40 KiB
Dart
832 lines
40 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../../business/providers/business_provider.dart';
|
|
import '../../../core/widgets/barcode_scanner_screen.dart';
|
|
import '../../../core/widgets/smart_search_dropdown.dart';
|
|
import '../../../core/widgets/premium_text_field.dart';
|
|
import '../providers/invoices_provider.dart';
|
|
import '../domain/invoice.dart';
|
|
import '../providers/customers_provider.dart';
|
|
import '../domain/customer.dart';
|
|
import '../../inventory/providers/products_provider.dart';
|
|
import '../../inventory/providers/inventory_items_provider.dart';
|
|
import '../../projects/providers/project_mode_provider.dart';
|
|
import '../../inventory/domain/product.dart';
|
|
import '../../inventory/domain/inventory_item.dart';
|
|
import 'add_customer_sheet.dart';
|
|
import '../../transactions/providers/providers.dart';
|
|
|
|
class InvoiceBuilderScreen extends ConsumerStatefulWidget {
|
|
final List<InvoiceItem>? initialItems;
|
|
final int? initialCustomerId;
|
|
|
|
const InvoiceBuilderScreen({super.key, this.initialItems, this.initialCustomerId});
|
|
|
|
@override
|
|
ConsumerState<InvoiceBuilderScreen> createState() => _InvoiceBuilderScreenState();
|
|
}
|
|
|
|
class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
Customer? _selectedCustomer;
|
|
final List<InvoiceItem> _items = [];
|
|
bool _isEmi = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (widget.initialItems != null) {
|
|
_items.addAll(widget.initialItems!);
|
|
}
|
|
}
|
|
|
|
void _loadInitialCustomer() {
|
|
if (widget.initialCustomerId != null && _selectedCustomer == null) {
|
|
final customers = ref.read(customersProvider).value;
|
|
if (customers != null) {
|
|
final cust = customers.where((c) => c.id == widget.initialCustomerId).firstOrNull;
|
|
if (cust != null) {
|
|
setState(() {
|
|
_selectedCustomer = cust;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
String _emiCycle = 'MONTHLY';
|
|
final TextEditingController _emiAmountCtrl = TextEditingController();
|
|
final TextEditingController _invoiceNumberCtrl = TextEditingController(text: 'INV-${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}');
|
|
final TextEditingController _invoiceDiscountCtrl = TextEditingController();
|
|
bool _invoiceDiscountIsPerc = false;
|
|
DateTime _invoiceDate = DateTime.now();
|
|
final TextEditingController _amountPaidCtrl = TextEditingController();
|
|
bool _isAmountPaidEdited = false;
|
|
DateTime? _nextPaymentDate;
|
|
String _paymentMethod = 'Cash';
|
|
int? _selectedWalletId;
|
|
|
|
double get _subtotal => _items.fold(0, (sum, item) => sum + (item.quantity * item.unitPrice));
|
|
double get _taxTotal => _items.fold(0, (sum, item) => sum + (item.quantity * item.unitPrice * (item.taxRate / 100)));
|
|
double get _itemDiscountTotal => _items.fold(0, (sum, item) => sum + item.discount);
|
|
|
|
double get _invoiceDiscountAmount {
|
|
final raw = double.tryParse(_invoiceDiscountCtrl.text) ?? 0;
|
|
if (_invoiceDiscountIsPerc) {
|
|
final taxableAmount = _subtotal + _makingChargeTotal + _otherChargesTotal;
|
|
return taxableAmount * (raw / 100);
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
double get _discountTotal => _itemDiscountTotal + _invoiceDiscountAmount;
|
|
double get _makingChargeTotal => _items.fold(0, (sum, item) => sum + item.makingCharge);
|
|
double get _otherChargesTotal => _items.fold(0, (sum, item) => sum + item.otherCharges);
|
|
double get _grandTotal => _subtotal + _taxTotal + _makingChargeTotal + _otherChargesTotal - _discountTotal;
|
|
|
|
double get _amountPaid {
|
|
if (!_isAmountPaidEdited) return _grandTotal;
|
|
final raw = double.tryParse(_amountPaidCtrl.text) ?? 0;
|
|
return raw > _grandTotal ? _grandTotal : raw; // Cap at grand total
|
|
}
|
|
double get _balanceDue => _grandTotal - _amountPaid;
|
|
|
|
Future<void> _saveInvoice() async {
|
|
if (!_formKey.currentState!.validate() || _items.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please add items and fill all required fields.')));
|
|
return;
|
|
}
|
|
|
|
final invoice = Invoice(
|
|
customerId: _selectedCustomer?.id,
|
|
invoiceNumber: _invoiceNumberCtrl.text,
|
|
issueDate: _invoiceDate,
|
|
dueDate: DateTime.now().add(const Duration(days: 30)),
|
|
subtotal: _subtotal,
|
|
taxTotal: _taxTotal,
|
|
discountTotal: _discountTotal,
|
|
totalAmount: _grandTotal,
|
|
amountPaid: _amountPaid,
|
|
paymentMethod: _amountPaid > 0 ? _paymentMethod : null,
|
|
paymentWalletId: _amountPaid > 0 ? (_selectedWalletId ?? (ref.read(walletProvider).value?.firstOrNull?.id)) : null,
|
|
nextPaymentDate: _balanceDue > 0 ? (_nextPaymentDate ?? DateTime.now().add(const Duration(days: 30))) : null,
|
|
isEmi: _isEmi,
|
|
emiAmount: _isEmi && _emiAmountCtrl.text.isNotEmpty ? double.parse(_emiAmountCtrl.text) : null,
|
|
emiCycle: _isEmi ? _emiCycle : null,
|
|
emiStartDate: _isEmi ? DateTime.now().add(const Duration(days: 30)) : null,
|
|
items: _items,
|
|
);
|
|
|
|
try {
|
|
await ref.read(invoicesProvider.notifier).createInvoice(invoice);
|
|
if (mounted) {
|
|
Navigator.pop(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Invoice Created!')));
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showAddItemDialog() {
|
|
final isProjectMode = ref.read(projectModeProvider);
|
|
Product? selectedProduct;
|
|
final TextEditingController descCtrl = TextEditingController();
|
|
final TextEditingController qtyCtrl = TextEditingController(text: '1');
|
|
final TextEditingController priceCtrl = TextEditingController();
|
|
final TextEditingController taxCtrl = TextEditingController(text: '0');
|
|
final TextEditingController discountCtrl = TextEditingController(text: '0');
|
|
final TextEditingController makingCtrl = TextEditingController(text: '0');
|
|
final TextEditingController otherCtrl = TextEditingController(text: '0');
|
|
String uomStr = '';
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) {
|
|
bool isDiscPerc = false;
|
|
return StatefulBuilder(
|
|
builder: (context, setDialogState) {
|
|
return AlertDialog(
|
|
title: const Text('Add Line Item'),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (!isProjectMode) Consumer(
|
|
builder: (context, dialogRef, _) {
|
|
final productsState = dialogRef.watch(productsProvider);
|
|
return productsState.when(
|
|
data: (products) => Row(
|
|
children: [
|
|
Expanded(
|
|
child: SmartSearchDropdown<Product>(
|
|
hintText: 'Select Product (Optional)',
|
|
value: selectedProduct,
|
|
items: products,
|
|
itemAsString: (p) => '${p.name} ${p.sku != null && p.sku!.isNotEmpty ? "(${p.sku})" : ""} (₹${p.sellingPrice})',
|
|
onChanged: (p) {
|
|
setDialogState(() {
|
|
selectedProduct = p;
|
|
if (p != null) {
|
|
descCtrl.text = p.name;
|
|
priceCtrl.text = p.sellingPrice?.toString() ?? '0';
|
|
taxCtrl.text = p.gstRate?.toString() ?? '0';
|
|
makingCtrl.text = p.makingCharges?.toString() ?? '0';
|
|
uomStr = '';
|
|
}
|
|
});
|
|
}
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
onPressed: () async {
|
|
final String? code = await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()),
|
|
);
|
|
if (code != null && code.isNotEmpty) {
|
|
final source = ref.read(businessFeatureProvider).value?.barcodeSource ?? 'SKU';
|
|
Product? matched;
|
|
if (source == 'BARCODE') {
|
|
matched = products.cast<Product?>().firstWhere((p) => p?.barcode == code, orElse: () => null);
|
|
} else {
|
|
matched = products.cast<Product?>().firstWhere((p) => p?.sku == code, orElse: () => null);
|
|
}
|
|
|
|
if (matched != null) {
|
|
setDialogState(() {
|
|
selectedProduct = matched;
|
|
descCtrl.text = matched!.name;
|
|
priceCtrl.text = matched!.sellingPrice?.toString() ?? '0';
|
|
taxCtrl.text = matched!.gstRate?.toString() ?? '0';
|
|
makingCtrl.text = matched!.makingCharges?.toString() ?? '0';
|
|
});
|
|
} else {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('No product found for $source: $code')));
|
|
}
|
|
}
|
|
}
|
|
},
|
|
icon: const Icon(LucideIcons.scanLine),
|
|
color: Theme.of(context).colorScheme.primary,
|
|
tooltip: 'Scan Barcode',
|
|
)
|
|
],
|
|
),
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, stack) => Text('Error loading products: $e'),
|
|
);
|
|
}
|
|
),
|
|
const SizedBox(height: 12),
|
|
PremiumTextField(controller: descCtrl, labelText: 'Description'),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(child: PremiumTextField(controller: qtyCtrl, labelText: uomStr.isNotEmpty ? 'Qty ($uomStr)' : 'Quantity', keyboardType: TextInputType.number)),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: PremiumTextField(controller: priceCtrl, labelText: 'Unit Price', keyboardType: TextInputType.number)),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(child: PremiumTextField(controller: taxCtrl, labelText: 'Tax %', keyboardType: TextInputType.number)),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: PremiumTextField(
|
|
controller: discountCtrl,
|
|
labelText: isDiscPerc ? 'Discount %' : 'Discount (₹)',
|
|
keyboardType: TextInputType.number,
|
|
suffixIcon: IconButton(
|
|
icon: Icon(isDiscPerc ? LucideIcons.percent : LucideIcons.indianRupee, size: 16),
|
|
onPressed: () => setDialogState(() => isDiscPerc = !isDiscPerc),
|
|
),
|
|
)
|
|
),
|
|
],
|
|
),
|
|
if (!isProjectMode) ...[
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(child: PremiumTextField(controller: makingCtrl, labelText: 'Making Chg', keyboardType: TextInputType.number)),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: PremiumTextField(controller: otherCtrl, labelText: 'Other Chg', keyboardType: TextInputType.number)),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
|
TextButton(
|
|
onPressed: () {
|
|
final qty = double.tryParse(qtyCtrl.text) ?? 1;
|
|
final price = double.tryParse(priceCtrl.text) ?? 0;
|
|
final tax = double.tryParse(taxCtrl.text) ?? 0;
|
|
final discRaw = double.tryParse(discountCtrl.text) ?? 0;
|
|
final disc = isDiscPerc ? ((qty * price) * (discRaw / 100)) : discRaw;
|
|
final making = double.tryParse(makingCtrl.text) ?? 0;
|
|
final other = double.tryParse(otherCtrl.text) ?? 0;
|
|
final total = (qty * price) + (qty * price * (tax / 100)) + making + other - disc;
|
|
|
|
setState(() {
|
|
_items.add(InvoiceItem(
|
|
productId: selectedProduct?.id,
|
|
sku: selectedProduct?.sku,
|
|
description: descCtrl.text,
|
|
quantity: qty,
|
|
unitPrice: price,
|
|
taxRate: tax,
|
|
discount: disc,
|
|
makingCharge: making,
|
|
otherCharges: other,
|
|
total: total,
|
|
));
|
|
});
|
|
Navigator.pop(context);
|
|
},
|
|
child: const Text('Add Item'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _scanAndAddBarcode() async {
|
|
final barcode = await Navigator.push<String>(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()),
|
|
);
|
|
if (barcode != null && barcode.isNotEmpty) {
|
|
final products = ref.read(productsProvider).value ?? [];
|
|
final inventoryItems = ref.read(inventoryItemsProvider).value ?? [];
|
|
final businessFeature = ref.read(businessFeatureProvider).value;
|
|
final bool useBarcodeField = businessFeature?.barcodeSource == 'BARCODE';
|
|
|
|
// 1. Try to match by HUID first
|
|
final invItem = inventoryItems.where((i) => i.huid?.toLowerCase() == barcode.toLowerCase()).firstOrNull;
|
|
|
|
if (invItem != null) {
|
|
final p = products.where((p) => p.id == invItem.productId).firstOrNull;
|
|
setState(() {
|
|
final existingIndex = _items.indexWhere((item) => item.inventoryItemId == invItem.id);
|
|
if (existingIndex >= 0) {
|
|
// HUIDs are unique, so this shouldn't normally increment qty, but for safety:
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Item with this HUID is already in the invoice')));
|
|
}
|
|
} else {
|
|
final price = invItem.purchaseCost ?? p?.sellingPrice ?? 0; // Ideally use selling price logic, but keeping simple
|
|
final tax = invItem.tax ?? p?.gstRate ?? 0;
|
|
final making = invItem.makingCharges ?? p?.makingCharges ?? 0;
|
|
final total = price + (price * (tax / 100)) + making;
|
|
_items.add(InvoiceItem(
|
|
productId: p?.id,
|
|
inventoryItemId: invItem.id,
|
|
sku: invItem.huid ?? invItem.sku ?? p?.sku,
|
|
description: p?.name ?? 'Inventory Item',
|
|
quantity: 1,
|
|
unitPrice: price,
|
|
taxRate: tax,
|
|
makingCharge: making,
|
|
otherCharges: 0,
|
|
discount: 0,
|
|
total: total,
|
|
));
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added HUID item to invoice')));
|
|
}
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 2. Fallback to matching by Product SKU or Barcode
|
|
final p = products.where((p) {
|
|
if (useBarcodeField) {
|
|
return p.barcode?.toLowerCase() == barcode.toLowerCase();
|
|
} else {
|
|
return p.sku?.toLowerCase() == barcode.toLowerCase();
|
|
}
|
|
}).firstOrNull;
|
|
|
|
if (p != null) {
|
|
setState(() {
|
|
final existingIndex = _items.indexWhere((item) => item.productId == p.id && item.inventoryItemId == null);
|
|
if (existingIndex >= 0) {
|
|
// Increment qty
|
|
final item = _items[existingIndex];
|
|
final newQty = item.quantity + 1;
|
|
final newTotal = (newQty * item.unitPrice) + (newQty * item.unitPrice * (item.taxRate / 100)) + item.makingCharge + item.otherCharges - item.discount;
|
|
_items[existingIndex] = item.copyWith(quantity: newQty, total: newTotal);
|
|
} else {
|
|
// Add new
|
|
final price = p.sellingPrice ?? 0;
|
|
final tax = p.gstRate ?? 0;
|
|
final making = p.makingCharges ?? 0;
|
|
final total = price + (price * (tax / 100)) + making;
|
|
_items.add(InvoiceItem(
|
|
productId: p.id,
|
|
sku: p.sku,
|
|
description: p.name,
|
|
quantity: 1,
|
|
unitPrice: price,
|
|
taxRate: tax,
|
|
makingCharge: making,
|
|
otherCharges: 0,
|
|
discount: 0,
|
|
total: total,
|
|
));
|
|
}
|
|
});
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${p.name} to invoice')));
|
|
}
|
|
} else {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Product/Item not found for barcode: $barcode')));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
_loadInitialCustomer();
|
|
final customersState = ref.watch(customersProvider);
|
|
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.grey[50],
|
|
appBar: AppBar(
|
|
title: const Text('Create Invoice'),
|
|
elevation: 0,
|
|
backgroundColor: Colors.white,
|
|
foregroundColor: Colors.black,
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _saveInvoice,
|
|
child: const Text('Save Invoice', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)),
|
|
)
|
|
],
|
|
),
|
|
body: GestureDetector(
|
|
onTap: () => FocusScope.of(context).unfocus(),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Customer Selection
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text('Customer', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (_) => const AddCustomerSheet(),
|
|
);
|
|
},
|
|
icon: const Icon(LucideIcons.plus, size: 16),
|
|
label: const Text('Add Customer'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
customersState.when(
|
|
loading: () => const CircularProgressIndicator(),
|
|
error: (err, stack) => Text('Error loading customers: $err'),
|
|
data: (customers) => SmartSearchDropdown<Customer>(
|
|
hintText: 'Search a Customer...',
|
|
value: _selectedCustomer,
|
|
items: customers,
|
|
itemAsString: (c) => c.name,
|
|
onChanged: (val) => setState(() => _selectedCustomer = val),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Invoice Details
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('Invoice Details', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
|
const SizedBox(height: 12),
|
|
PremiumTextField(
|
|
controller: _invoiceNumberCtrl,
|
|
labelText: 'Invoice Number',
|
|
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
|
),
|
|
const SizedBox(height: 12),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Invoice Date'),
|
|
subtitle: Text(DateFormat('yyyy-MM-dd').format(_invoiceDate)),
|
|
trailing: const Icon(LucideIcons.calendar),
|
|
onTap: () async {
|
|
final dt = await showDatePicker(
|
|
context: context,
|
|
initialDate: _invoiceDate,
|
|
firstDate: DateTime(2000),
|
|
lastDate: DateTime(2100),
|
|
);
|
|
if (dt != null) {
|
|
setState(() => _invoiceDate = dt);
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Items
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text('Line Items', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
|
Row(
|
|
children: [
|
|
Consumer(
|
|
builder: (context, ref, child) {
|
|
final feature = ref.watch(businessFeatureProvider).value;
|
|
final isBarcode = feature?.barcodeSource == 'BARCODE';
|
|
return Row(
|
|
children: [
|
|
Text('Scan by:', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
|
|
const SizedBox(width: 4),
|
|
Text('SKU', style: TextStyle(fontSize: 10, fontWeight: !isBarcode ? FontWeight.bold : FontWeight.normal, color: !isBarcode ? Colors.blue : Colors.grey)),
|
|
Transform.scale(
|
|
scale: 0.7,
|
|
child: Switch(
|
|
value: isBarcode,
|
|
activeColor: Colors.blue,
|
|
onChanged: (val) {
|
|
if (feature != null) {
|
|
ref.read(businessFeatureProvider.notifier).updateFeatures(
|
|
feature.copyWith(barcodeSource: val ? 'BARCODE' : 'SKU')
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
Text('EAN', style: TextStyle(fontSize: 10, fontWeight: isBarcode ? FontWeight.bold : FontWeight.normal, color: isBarcode ? Colors.blue : Colors.grey)),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(LucideIcons.scanLine, color: Colors.blue),
|
|
onPressed: _scanAndAddBarcode,
|
|
tooltip: 'Scan to add',
|
|
),
|
|
TextButton.icon(
|
|
onPressed: _showAddItemDialog,
|
|
icon: const Icon(LucideIcons.plus),
|
|
label: const Text('Add'),
|
|
),
|
|
],
|
|
)
|
|
],
|
|
),
|
|
if (_items.isEmpty)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 16.0),
|
|
child: Center(child: Text('No items added', style: TextStyle(color: Colors.grey))),
|
|
),
|
|
for (var i = 0; i < _items.length; i++)
|
|
Container(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[50],
|
|
border: Border.all(color: Colors.grey[200]!),
|
|
borderRadius: BorderRadius.circular(12)
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(_items[i].description ?? 'Item ${i+1}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 4),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 4,
|
|
children: [
|
|
if (_items[i].sku != null && _items[i].sku!.isNotEmpty) Text('SKU: ${_items[i].sku}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
|
Text('Qty: ${_items[i].quantity}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
|
Text('Rate: ${formatCurrency.format(_items[i].unitPrice)}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
|
if (_items[i].taxRate > 0) Text('Tax: ${_items[i].taxRate}%', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
|
if (_items[i].makingCharge > 0) Text('Making: ${formatCurrency.format(_items[i].makingCharge)}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
|
if (_items[i].otherCharges > 0) Text('Other: ${formatCurrency.format(_items[i].otherCharges)}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
|
if (_items[i].discount > 0) Text('Disc: -${formatCurrency.format(_items[i].discount)}', style: const TextStyle(fontSize: 12, color: Colors.red)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(formatCurrency.format(_items[i].total), style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)),
|
|
IconButton(
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
icon: const Icon(LucideIcons.trash2, color: Colors.red, size: 18),
|
|
onPressed: () => setState(() => _items.removeAt(i)),
|
|
),
|
|
],
|
|
)
|
|
],
|
|
),
|
|
),
|
|
const Divider(),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text('Invoice Discount', style: TextStyle(fontWeight: FontWeight.w600)),
|
|
SizedBox(
|
|
width: 150,
|
|
child: TextField(
|
|
controller: _invoiceDiscountCtrl,
|
|
textAlign: TextAlign.right,
|
|
decoration: InputDecoration(
|
|
hintText: '0.00',
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
|
prefixIcon: IconButton(
|
|
icon: Icon(_invoiceDiscountIsPerc ? LucideIcons.percent : LucideIcons.indianRupee, size: 16),
|
|
onPressed: () => setState(() => _invoiceDiscountIsPerc = !_invoiceDiscountIsPerc),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
),
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
onChanged: (val) {
|
|
setState(() {
|
|
if (!_isAmountPaidEdited) {
|
|
_amountPaidCtrl.text = _grandTotal.toStringAsFixed(2);
|
|
}
|
|
});
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text('Total Amount', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
|
Text(formatCurrency.format(_grandTotal), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.blue)),
|
|
],
|
|
),
|
|
const Divider(height: 32),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text('Amount Paid Now', style: TextStyle(fontWeight: FontWeight.w600)),
|
|
SizedBox(
|
|
width: 150,
|
|
child: TextField(
|
|
controller: _amountPaidCtrl,
|
|
textAlign: TextAlign.right,
|
|
decoration: InputDecoration(
|
|
hintText: _grandTotal.toStringAsFixed(2),
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
onChanged: (val) => setState(() {
|
|
_isAmountPaidEdited = true;
|
|
}),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_amountPaid > 0) ...[
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text('Receive Into Wallet', style: TextStyle(fontWeight: FontWeight.w600)),
|
|
SizedBox(
|
|
width: 150,
|
|
child: Consumer(
|
|
builder: (context, consumerRef, _) {
|
|
final walletsState = consumerRef.watch(walletProvider);
|
|
final allWallets = walletsState.value ?? [];
|
|
|
|
final wallets = allWallets.where((w) {
|
|
if (_paymentMethod == 'Cash') {
|
|
return w.nature == 'CASH';
|
|
} else {
|
|
return w.nature == 'INCOME' || w.nature == 'SAVINGS';
|
|
}
|
|
}).toList();
|
|
|
|
if (wallets.isNotEmpty && (_selectedWalletId == null || !wallets.any((w) => w.id == _selectedWalletId))) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) setState(() => _selectedWalletId = wallets.first.id);
|
|
});
|
|
}
|
|
|
|
return DropdownButtonHideUnderline(
|
|
child: DropdownButton<int>(
|
|
value: _selectedWalletId,
|
|
isDense: true,
|
|
hint: const Text('Wallet'),
|
|
items: wallets
|
|
.map((w) => DropdownMenuItem(value: w.id, child: Text(w.name)))
|
|
.toList(),
|
|
onChanged: (val) => setState(() => _selectedWalletId = val),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text('Payment Method', style: TextStyle(fontWeight: FontWeight.w600)),
|
|
SizedBox(
|
|
width: 150,
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: _paymentMethod,
|
|
isDense: true,
|
|
items: ['Cash', 'UPI', 'Bank Transfer', 'Card']
|
|
.map((m) => DropdownMenuItem(value: m, child: Text(m)))
|
|
.toList(),
|
|
onChanged: (val) {
|
|
if (val != null) {
|
|
setState(() {
|
|
_paymentMethod = val;
|
|
_selectedWalletId = null;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text('Balance Due', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red)),
|
|
Text(formatCurrency.format(_balanceDue), style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.red)),
|
|
],
|
|
),
|
|
if (_balanceDue > 0) ...[
|
|
const SizedBox(height: 12),
|
|
SwitchListTile(
|
|
title: const Text('Enable EMI / Installments'),
|
|
value: _isEmi,
|
|
onChanged: (val) => setState(() => _isEmi = val),
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
if (_isEmi) ...[
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: PremiumTextField(
|
|
controller: _emiAmountCtrl,
|
|
labelText: 'EMI Amount',
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: SmartSearchDropdown<String>(
|
|
hintText: 'Cycle',
|
|
value: _emiCycle,
|
|
items: const ['MONTHLY', 'WEEKLY'],
|
|
itemAsString: (val) => val == 'MONTHLY' ? 'Monthly' : 'Weekly',
|
|
onChanged: (val) => setState(() => _emiCycle = val!),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
] else ...[
|
|
const SizedBox(height: 12),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Next Payment Date', style: TextStyle(fontWeight: FontWeight.w600)),
|
|
subtitle: Text(_nextPaymentDate == null
|
|
? 'Not Selected'
|
|
: DateFormat('yyyy-MM-dd').format(_nextPaymentDate!)),
|
|
trailing: const Icon(LucideIcons.calendar),
|
|
onTap: () async {
|
|
final dt = await showDatePicker(
|
|
context: context,
|
|
initialDate: _nextPaymentDate ?? DateTime.now().add(const Duration(days: 30)),
|
|
firstDate: DateTime.now(),
|
|
lastDate: DateTime(2100),
|
|
);
|
|
if (dt != null) {
|
|
setState(() => _nextPaymentDate = dt);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|