2193 lines
89 KiB
Dart
2193 lines
89 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/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 '../../business/providers/business_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';
|
||
|
||
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');
|
||
|
||
DateTime _invoiceDate = DateTime.now();
|
||
DateTime? _dueDate;
|
||
int? _selectedCustomerId;
|
||
final List<InvoiceItem> _items = [];
|
||
bool _isLoading = false;
|
||
|
||
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;
|
||
_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();
|
||
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 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 isSameState = businessStateId != null && customer?.stateId != null && businessStateId == customer!.stateId;
|
||
|
||
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 gstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? 0.0);
|
||
|
||
final weight = item.weight ?? item.quantity;
|
||
final metalAmount = weight * item.unitPrice;
|
||
|
||
double makingChargeAmt = 0.0;
|
||
if (item.makingChargesType == 'PERCENTAGE') {
|
||
makingChargeAmt = metalAmount * (item.makingCharge / 100.0);
|
||
} else if (item.makingChargesType == 'PER_PIECE') {
|
||
makingChargeAmt = item.makingCharge;
|
||
} else {
|
||
makingChargeAmt = weight * item.makingCharge;
|
||
}
|
||
|
||
final taxableAmount = metalAmount + 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 += metalAmount;
|
||
totalMaking += makingChargeAmt;
|
||
totalTax += taxAmount;
|
||
|
||
processedItems.add(item.copyWith(
|
||
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,
|
||
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: [
|
||
// 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;
|
||
});
|
||
},
|
||
),
|
||
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: 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(
|
||
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(
|
||
children: [
|
||
Icon(LucideIcons.shoppingBag, 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/HUID to add jewellery',
|
||
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),
|
||
|
||
// 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 weight = item.weight ?? item.quantity;
|
||
final metalAmount = weight * item.unitPrice;
|
||
|
||
double makingChargeAmt = 0.0;
|
||
if (item.makingChargesType == 'PERCENTAGE') {
|
||
makingChargeAmt = metalAmount * (item.makingCharge / 100.0);
|
||
} else if (item.makingChargesType == 'PER_PIECE') {
|
||
makingChargeAmt = item.makingCharge;
|
||
} else {
|
||
makingChargeAmt = weight * item.makingCharge;
|
||
}
|
||
|
||
final calculatedTotal = item.total > 0 ? item.total : (metalAmount + makingChargeAmt + item.otherCharges - item.discount);
|
||
|
||
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 (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),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'Metal: ${weight.toStringAsFixed(3)}g × ₹${item.unitPrice.toStringAsFixed(2)}',
|
||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||
),
|
||
Text(
|
||
'₹${metalAmount.toStringAsFixed(2)}',
|
||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
||
),
|
||
],
|
||
),
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
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(
|
||
'+ ₹${((metalAmount + makingChargeAmt) * (item.taxRate / 100.0)).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 products = ref.watch(productsProvider).value ?? [];
|
||
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 discountAmount = double.tryParse(_discountCtrl.text) ?? 0.0;
|
||
final isSameState = businessStateId != null && customer?.stateId != null && businessStateId == customer!.stateId;
|
||
|
||
double metalSubtotal = 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 gstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? 3.0);
|
||
final weight = item.weight ?? item.quantity;
|
||
final metalAmt = weight * item.unitPrice;
|
||
|
||
double makingAmt = 0.0;
|
||
if (item.makingChargesType == 'PERCENTAGE') {
|
||
makingAmt = metalAmt * (item.makingCharge / 100.0);
|
||
} else if (item.makingChargesType == 'PER_PIECE') {
|
||
makingAmt = item.makingCharge;
|
||
} else {
|
||
makingAmt = weight * item.makingCharge;
|
||
}
|
||
|
||
metalSubtotal += metalAmt;
|
||
makingChargesTotal += makingAmt;
|
||
rawTaxableTotal += (metalAmt + makingAmt + item.otherCharges);
|
||
}
|
||
|
||
final effectiveTaxable = (rawTaxableTotal - discountAmount).clamp(0.0, double.infinity);
|
||
final avgGstRate = _items.isNotEmpty
|
||
? (_items.map((i) => i.taxRate > 0 ? i.taxRate : 3.0).reduce((a, b) => a + b) / _items.length)
|
||
: 3.0;
|
||
taxTotal = (effectiveTaxable * avgGstRate) / 100.0;
|
||
final grandTotal = effectiveTaxable + taxTotal;
|
||
|
||
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),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Metal Subtotal:'),
|
||
Text('₹${metalSubtotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Making Charges:'),
|
||
Text('₹${makingChargesTotal.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 (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)),
|
||
Text('₹${(taxTotal / 2.0).toStringAsFixed(2)}'),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text('SGST (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)),
|
||
Text('₹${(taxTotal / 2.0).toStringAsFixed(2)}'),
|
||
],
|
||
),
|
||
] else ...[
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text('IGST (${(taxTotal > 0 ? '3.0%' : '0%')}):', 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 products = ref.watch(productsProvider).value ?? [];
|
||
final discountAmount = double.tryParse(_discountCtrl.text) ?? 0.0;
|
||
double rawTaxable = 0;
|
||
for (var item in _items) {
|
||
final weight = item.weight ?? item.quantity;
|
||
final metalAmt = weight * item.unitPrice;
|
||
double makingAmt = 0.0;
|
||
if (item.makingChargesType == 'PERCENTAGE') {
|
||
makingAmt = metalAmt * (item.makingCharge / 100.0);
|
||
} else if (item.makingChargesType == 'PER_PIECE') {
|
||
makingAmt = item.makingCharge;
|
||
} else {
|
||
makingAmt = weight * item.makingCharge;
|
||
}
|
||
rawTaxable += (metalAmt + makingAmt + item.otherCharges);
|
||
}
|
||
final effectiveTaxable = (rawTaxable - discountAmount).clamp(0.0, double.infinity);
|
||
final avgGst = _items.isNotEmpty
|
||
? (_items.map((i) => i.taxRate > 0 ? i.taxRate : 3.0).reduce((a, b) => a + b) / _items.length)
|
||
: 3.0;
|
||
final grandTotal = effectiveTaxable + (effectiveTaxable * avgGst / 100.0);
|
||
|
||
// 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!),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
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);
|
||
|
||
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,
|
||
makingCharges: it.makingCharge,
|
||
makingChargesType: it.makingChargesType,
|
||
);
|
||
if (it.inventoryItemId != null) {
|
||
_selectedInventoryItem = invItems.where((i) => i.id == it.inventoryItemId).firstOrNull;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_searchCtrl.dispose();
|
||
_makingChargeCtrl.dispose();
|
||
_discountCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onProductSelected(Product product, ProductCategory? category, double liveCommodityRate) {
|
||
setState(() {
|
||
_selectedProduct = product;
|
||
_selectedInventoryItem = null;
|
||
|
||
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) {
|
||
setState(() {
|
||
_selectedProduct = product;
|
||
_selectedInventoryItem = item;
|
||
|
||
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) {
|
||
if (_selectedProduct == null) return;
|
||
|
||
final weight = _selectedInventoryItem?.grossWeight ?? 1.0;
|
||
final makingCharge = double.tryParse(_makingChargeCtrl.text) ?? 0.0;
|
||
final discount = double.tryParse(_discountCtrl.text) ?? 0.0;
|
||
|
||
final metalAmount = weight * unitRate;
|
||
double makingAmt = 0.0;
|
||
if (_makingChargeType == 'PERCENTAGE') {
|
||
makingAmt = metalAmount * (makingCharge / 100.0);
|
||
} else if (_makingChargeType == 'PER_PIECE') {
|
||
makingAmt = makingCharge;
|
||
} else {
|
||
makingAmt = weight * makingCharge;
|
||
}
|
||
|
||
final taxable = metalAmount + makingAmt - discount;
|
||
final gstRate = _selectedProduct?.gstRate ?? (category?.defaultGst ?? 3.0);
|
||
final taxAmount = (taxable * gstRate) / 100.0;
|
||
final total = taxable + taxAmount;
|
||
|
||
final item = InvoiceItem(
|
||
productId: _selectedProduct!.id,
|
||
inventoryItemId: _selectedInventoryItem?.id,
|
||
productName: _selectedProduct!.name,
|
||
categoryName: category?.name,
|
||
commodityCode: category?.commodityCode,
|
||
hsnCode: category?.defaultHsn ?? _selectedProduct?.hsnCode,
|
||
sku: _selectedInventoryItem?.sku ?? _selectedProduct?.sku,
|
||
huid: _selectedInventoryItem?.huid,
|
||
description: _selectedProduct!.name,
|
||
quantity: 1.0,
|
||
weight: weight,
|
||
unitPrice: unitRate,
|
||
makingCharge: makingCharge,
|
||
makingChargesType: _makingChargeType,
|
||
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 isDark = Theme.of(context).brightness == Brightness.dark;
|
||
|
||
ProductCategory? category;
|
||
if (_selectedProduct?.categoryId != null) {
|
||
category = categories.where((c) => c.id == _selectedProduct!.categoryId).firstOrNull;
|
||
}
|
||
|
||
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 = commodityRate > 0 ? (commodityRate * purityFactor) : (_selectedProduct?.sellingPrice ?? 0.0);
|
||
|
||
final weight = _selectedInventoryItem?.grossWeight ?? 1.0;
|
||
final metalAmount = weight * effectiveUnitRate;
|
||
final makingCharge = double.tryParse(_makingChargeCtrl.text) ?? 0.0;
|
||
|
||
double makingAmt = 0.0;
|
||
if (_makingChargeType == 'PERCENTAGE') {
|
||
makingAmt = metalAmount * (makingCharge / 100.0);
|
||
} else if (_makingChargeType == 'PER_PIECE') {
|
||
makingAmt = makingCharge;
|
||
} else {
|
||
makingAmt = weight * makingCharge;
|
||
}
|
||
|
||
final discount = double.tryParse(_discountCtrl.text) ?? 0.0;
|
||
final taxable = metalAmount + makingAmt - discount;
|
||
final gstRate = _selectedProduct?.gstRate ?? (category?.defaultGst ?? 3.0);
|
||
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 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: 'Search Product by HUID / 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(
|
||
query.isEmpty
|
||
? 'Available Stock in Inventory (${displayInventoryItems.length}):'
|
||
: 'Matched Inventory Items (${displayInventoryItems.length}):',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.bold,
|
||
color: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
|
||
),
|
||
),
|
||
if (query.isEmpty && availableItems.length > 20)
|
||
Text(
|
||
'Showing 20 newest',
|
||
style: TextStyle(fontSize: 11, color: Colors.grey.shade500, fontStyle: FontStyle.italic),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
if (displayInventoryItems.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 inventory 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;
|
||
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,
|
||
isDark: isDark,
|
||
onTap: () => _onInventoryItemSelected(item, p, cat),
|
||
);
|
||
}),
|
||
] 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 (_selectedInventoryItem?.huid != null)
|
||
_buildSheetBadge('HUID: ${_selectedInventoryItem!.huid}', Colors.purple),
|
||
if (category?.commodityCode != null)
|
||
_buildSheetBadge('${category!.commodityCode} (${formatPurity(purityFactor)})', Colors.teal),
|
||
_buildSheetBadge('Purity: ${formatPurity(purityFactor)}', Colors.amber.shade800),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
// DISABLED FIELDS: Rounded 12px borders
|
||
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: TextFormField(
|
||
initialValue: '${weight.toStringAsFixed(3)} g',
|
||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||
enabled: false,
|
||
decoration: InputDecoration(
|
||
labelText: 'Weight',
|
||
prefixIcon: const Icon(LucideIcons.scale, size: 18),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextFormField(
|
||
key: ValueKey(effectiveUnitRate),
|
||
initialValue: '₹${effectiveUnitRate.toStringAsFixed(2)}/g',
|
||
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),
|
||
|
||
// ENABLED FIELDS: Making Charges & Type
|
||
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!),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
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: 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),
|
||
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 _buildSearchItemCard({
|
||
required Product product,
|
||
ProductCategory? category,
|
||
InventoryItem? inventoryItem,
|
||
required double commodityRate,
|
||
required bool isDark,
|
||
required VoidCallback onTap,
|
||
}) {
|
||
final purity = resolvePurity(categoryPurity: category?.purityFactor, productPurity: product.purityFactor);
|
||
final unitRate = commodityRate > 0 ? (commodityRate * purity) : (product.sellingPrice ?? 0.0);
|
||
final weight = inventoryItem?.grossWeight ?? 1.0;
|
||
final metalAmount = 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 (makingType == 'PERCENTAGE') {
|
||
makingAmt = metalAmount * (makingCharge / 100.0);
|
||
} else if (makingType == 'PER_PIECE') {
|
||
makingAmt = makingCharge;
|
||
} else {
|
||
makingAmt = weight * makingCharge;
|
||
}
|
||
|
||
final gstRate = product.gstRate ?? (category?.defaultGst ?? 3.0);
|
||
final taxable = metalAmount + 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: (huid != null ? Colors.purple : Colors.blue).withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Icon(
|
||
huid != null ? LucideIcons.gem : LucideIcons.tag,
|
||
size: 20,
|
||
color: huid != null ? Colors.purple : 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 (huid != null && huid.isNotEmpty)
|
||
_buildSheetBadge('HUID: $huid', Colors.purple),
|
||
_buildSheetBadge('Purity: ${formatPurity(purity)}', Colors.amber.shade900),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const Divider(height: 18),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'Rate: ₹${unitRate.toStringAsFixed(2)}/g • ${weight.toStringAsFixed(3)}g',
|
||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600, fontWeight: FontWeight.w500),
|
||
),
|
||
Text(
|
||
'Metal: ₹${metalAmount.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),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|