Files
Kifi/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart

709 lines
34 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/domain/product.dart';
import 'add_customer_sheet.dart';
import '../../transactions/providers/providers.dart';
class InvoiceBuilderScreen extends ConsumerStatefulWidget {
const InvoiceBuilderScreen({super.key});
@override
ConsumerState<InvoiceBuilderScreen> createState() => _InvoiceBuilderScreenState();
}
class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
final _formKey = GlobalKey<FormState>();
Customer? _selectedCustomer;
final List<InvoiceItem> _items = [];
bool _isEmi = false;
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() {
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: [
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),
),
)
),
],
),
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 businessFeature = ref.read(businessFeatureProvider).value;
final bool useBarcodeField = businessFeature?.barcodeSource == '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);
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 not found for barcode: $barcode')));
}
}
}
}
@override
Widget build(BuildContext context) {
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: [
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 wallets = walletsState.value ?? [];
return DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _selectedWalletId ?? wallets.firstOrNull?.id,
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) => setState(() => _paymentMethod = val!),
),
),
),
],
),
],
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),
],
),
),
),
),
);
}
}