Web approach uniformity done

This commit is contained in:
2026-08-30 19:54:54 +05:30
parent 372c2bc14d
commit eb39d5ff90
12 changed files with 2724 additions and 1201 deletions

View File

@@ -36,26 +36,26 @@ public class InventoryItemService {
return inventoryItemRepository.findById(itemId) return inventoryItemRepository.findById(itemId)
.filter(item -> item.getUserId().equals(userId)) .filter(item -> item.getUserId().equals(userId))
.flatMap(existingItem -> { .flatMap(existingItem -> {
existingItem.setTagNumber(updatedItem.getTagNumber()); if (updatedItem.getTagNumber() != null) existingItem.setTagNumber(updatedItem.getTagNumber());
existingItem.setSku(updatedItem.getSku()); if (updatedItem.getSku() != null) existingItem.setSku(updatedItem.getSku());
existingItem.setHuid(updatedItem.getHuid()); if (updatedItem.getHuid() != null) existingItem.setHuid(updatedItem.getHuid());
existingItem.setPurity(updatedItem.getPurity()); if (updatedItem.getPurity() != null) existingItem.setPurity(updatedItem.getPurity());
existingItem.setGrossWeight(updatedItem.getGrossWeight()); if (updatedItem.getGrossWeight() != null) existingItem.setGrossWeight(updatedItem.getGrossWeight());
existingItem.setNetWeight(updatedItem.getNetWeight()); if (updatedItem.getNetWeight() != null) existingItem.setNetWeight(updatedItem.getNetWeight());
existingItem.setStoneWeight(updatedItem.getStoneWeight()); if (updatedItem.getStoneWeight() != null) existingItem.setStoneWeight(updatedItem.getStoneWeight());
existingItem.setDiamondWeight(updatedItem.getDiamondWeight()); if (updatedItem.getDiamondWeight() != null) existingItem.setDiamondWeight(updatedItem.getDiamondWeight());
existingItem.setFineWeight(updatedItem.getFineWeight()); if (updatedItem.getFineWeight() != null) existingItem.setFineWeight(updatedItem.getFineWeight());
existingItem.setMakingCharges(updatedItem.getMakingCharges()); if (updatedItem.getMakingCharges() != null) existingItem.setMakingCharges(updatedItem.getMakingCharges());
existingItem.setMakingChargeType(updatedItem.getMakingChargeType()); if (updatedItem.getMakingChargeType() != null) existingItem.setMakingChargeType(updatedItem.getMakingChargeType());
existingItem.setPurchaseCost(updatedItem.getPurchaseCost()); if (updatedItem.getPurchaseCost() != null) existingItem.setPurchaseCost(updatedItem.getPurchaseCost());
existingItem.setMetalCost(updatedItem.getMetalCost()); if (updatedItem.getMetalCost() != null) existingItem.setMetalCost(updatedItem.getMetalCost());
existingItem.setStoneCost(updatedItem.getStoneCost()); if (updatedItem.getStoneCost() != null) existingItem.setStoneCost(updatedItem.getStoneCost());
existingItem.setCertificationCost(updatedItem.getCertificationCost()); if (updatedItem.getCertificationCost() != null) existingItem.setCertificationCost(updatedItem.getCertificationCost());
existingItem.setTax(updatedItem.getTax()); if (updatedItem.getTax() != null) existingItem.setTax(updatedItem.getTax());
existingItem.setVendorId(updatedItem.getVendorId()); if (updatedItem.getVendorId() != null) existingItem.setVendorId(updatedItem.getVendorId());
existingItem.setBranchId(updatedItem.getBranchId()); if (updatedItem.getBranchId() != null) existingItem.setBranchId(updatedItem.getBranchId());
existingItem.setPurchaseRef(updatedItem.getPurchaseRef()); if (updatedItem.getPurchaseRef() != null) existingItem.setPurchaseRef(updatedItem.getPurchaseRef());
existingItem.setStatus(updatedItem.getStatus()); if (updatedItem.getStatus() != null) existingItem.setStatus(updatedItem.getStatus());
existingItem.setUpdatedAt(LocalDateTime.now()); existingItem.setUpdatedAt(LocalDateTime.now());
return inventoryItemRepository.save(existingItem); return inventoryItemRepository.save(existingItem);
}); });

View File

@@ -66,18 +66,17 @@ public class InvoiceService {
invoice.setUserId(userId); invoice.setUserId(userId);
invoice.setCreatedAt(LocalDateTime.now()); invoice.setCreatedAt(LocalDateTime.now());
invoice.setUpdatedAt(LocalDateTime.now()); invoice.setUpdatedAt(LocalDateTime.now());
if (invoice.getStatus() == null || "DRAFT".equals(invoice.getStatus())) {
java.math.BigDecimal amountPaid = invoice.getAmountPaid() != null ? invoice.getAmountPaid() : java.math.BigDecimal.ZERO; java.math.BigDecimal amountPaid = invoice.getAmountPaid() != null ? invoice.getAmountPaid() : java.math.BigDecimal.ZERO;
java.math.BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : java.math.BigDecimal.ZERO; java.math.BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : java.math.BigDecimal.ZERO;
if (amountPaid.compareTo(total) >= 0 && total.compareTo(java.math.BigDecimal.ZERO) > 0) { java.math.BigDecimal balance = total.subtract(amountPaid);
if (balance.abs().compareTo(new java.math.BigDecimal("0.01")) <= 0 || (amountPaid.compareTo(total) >= 0 && total.compareTo(java.math.BigDecimal.ZERO) > 0)) {
invoice.setStatus("PAID"); invoice.setStatus("PAID");
} else if (amountPaid.compareTo(java.math.BigDecimal.ZERO) > 0) { } else if (amountPaid.compareTo(java.math.BigDecimal.ZERO) > 0) {
invoice.setStatus("PARTIAL"); invoice.setStatus("PARTIAL");
} else { } else if (invoice.getStatus() == null || invoice.getStatus().isEmpty()) {
invoice.setStatus("DRAFT"); invoice.setStatus("DRAFT");
} }
}
return invoiceRepository.save(invoice) return invoiceRepository.save(invoice)
.flatMap(savedInvoice -> { .flatMap(savedInvoice -> {

View File

@@ -4,7 +4,6 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../../core/theme/nature_colors.dart'; import '../../../../core/theme/nature_colors.dart';
import '../../../inventory/presentation/product_list_screen.dart'; import '../../../inventory/presentation/product_list_screen.dart';
import '../../../inventory/presentation/daily_rates_screen.dart'; import '../../../inventory/presentation/daily_rates_screen.dart';
import '../../../inventory/presentation/quick_adjust_stock_screen.dart';
import '../../../inventory/presentation/category_management_screen.dart'; import '../../../inventory/presentation/category_management_screen.dart';
import '../../../sales/presentation/customers_list_screen.dart'; import '../../../sales/presentation/customers_list_screen.dart';
import '../../../sales/presentation/invoices_list_screen.dart'; import '../../../sales/presentation/invoices_list_screen.dart';
@@ -60,12 +59,12 @@ class BusinessHubScreen extends ConsumerWidget {
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1), color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.primary.withOpacity(0.2), ).colorScheme.primary.withValues(alpha: 0.2),
), ),
), ),
child: Row( child: Row(
@@ -97,13 +96,19 @@ class BusinessHubScreen extends ConsumerWidget {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
GridView.count( LayoutBuilder(
crossAxisCount: 2, builder: (context, constraints) {
final isDesktop = constraints.maxWidth >= 768;
final crossAxisCount = isDesktop ? 3 : 2;
final childAspectRatio = isDesktop ? 2.2 : 1.25;
return GridView.count(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 16, crossAxisSpacing: 16,
mainAxisSpacing: 16, mainAxisSpacing: 16,
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1.2, childAspectRatio: childAspectRatio,
children: [ children: [
if (showInventory) ...[ if (showInventory) ...[
_buildActionCard( _buildActionCard(
@@ -118,6 +123,7 @@ class BusinessHubScreen extends ConsumerWidget {
builder: (_) => const CategoryManagementScreen(), builder: (_) => const CategoryManagementScreen(),
), ),
), ),
isDesktop: isDesktop,
), ),
_buildActionCard( _buildActionCard(
context, context,
@@ -131,6 +137,7 @@ class BusinessHubScreen extends ConsumerWidget {
builder: (_) => const ProductListScreen(), builder: (_) => const ProductListScreen(),
), ),
), ),
isDesktop: isDesktop,
), ),
], ],
if (showSales) if (showSales)
@@ -146,6 +153,7 @@ class BusinessHubScreen extends ConsumerWidget {
builder: (_) => const CustomersListScreen(), builder: (_) => const CustomersListScreen(),
), ),
), ),
isDesktop: isDesktop,
), ),
if (showPurchase) if (showPurchase)
_buildActionCard( _buildActionCard(
@@ -160,6 +168,7 @@ class BusinessHubScreen extends ConsumerWidget {
builder: (_) => const VendorsListScreen(), builder: (_) => const VendorsListScreen(),
), ),
), ),
isDesktop: isDesktop,
), ),
if (showPurchase) if (showPurchase)
_buildActionCard( _buildActionCard(
@@ -174,6 +183,7 @@ class BusinessHubScreen extends ConsumerWidget {
builder: (_) => const PurchaseOrdersListScreen(), builder: (_) => const PurchaseOrdersListScreen(),
), ),
), ),
isDesktop: isDesktop,
), ),
if (showSales) if (showSales)
_buildActionCard( _buildActionCard(
@@ -188,6 +198,7 @@ class BusinessHubScreen extends ConsumerWidget {
builder: (_) => const InvoicesListScreen(), builder: (_) => const InvoicesListScreen(),
), ),
), ),
isDesktop: isDesktop,
), ),
if (showInventory) if (showInventory)
_buildActionCard( _buildActionCard(
@@ -195,13 +206,14 @@ class BusinessHubScreen extends ConsumerWidget {
'Daily Rates', 'Daily Rates',
'Sync live gold/silver rates', 'Sync live gold/silver rates',
LucideIcons.refreshCw, LucideIcons.refreshCw,
Colors.orange, Colors.amber.shade800,
() => Navigator.push( () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => const DailyRatesScreen(), builder: (_) => const DailyRatesScreen(),
), ),
), ),
isDesktop: isDesktop,
), ),
_buildActionCard( _buildActionCard(
context, context,
@@ -213,8 +225,11 @@ class BusinessHubScreen extends ConsumerWidget {
context, context,
MaterialPageRoute(builder: (_) => const ReportsScreen()), MaterialPageRoute(builder: (_) => const ReportsScreen()),
), ),
isDesktop: isDesktop,
), ),
], ],
);
},
), ),
if (showInventory) ...[ if (showInventory) ...[
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -240,7 +255,7 @@ class BusinessHubScreen extends ConsumerWidget {
return Container( return Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.05), color: Colors.grey.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: const Center( child: const Center(
@@ -267,13 +282,13 @@ class BusinessHubScreen extends ConsumerWidget {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isOutOfStock color: isOutOfStock
? Colors.red.withOpacity(0.05) ? Colors.red.withValues(alpha: 0.05)
: Colors.orange.withOpacity(0.05), : Colors.orange.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: isOutOfStock color: isOutOfStock
? Colors.red.withOpacity(0.2) ? Colors.red.withValues(alpha: 0.2)
: Colors.orange.withOpacity(0.2), : Colors.orange.withValues(alpha: 0.2),
), ),
), ),
child: Row( child: Row(
@@ -282,8 +297,8 @@ class BusinessHubScreen extends ConsumerWidget {
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isOutOfStock color: isOutOfStock
? Colors.red.withOpacity(0.1) ? Colors.red.withValues(alpha: 0.1)
: Colors.orange.withOpacity(0.1), : Colors.orange.withValues(alpha: 0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
@@ -365,35 +380,98 @@ class BusinessHubScreen extends ConsumerWidget {
String subtitle, String subtitle,
IconData icon, IconData icon,
Color color, Color color,
VoidCallback onTap, VoidCallback onTap, {
) { bool isDesktop = false,
return GestureDetector( }) {
return InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withOpacity(0.1), color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withOpacity(0.2)), border: Border.all(color: color.withValues(alpha: 0.2)),
), ),
child: Column( child: isDesktop
? Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Icon(icon, color: color, size: 28), Row(
const SizedBox(height: 12), children: [
Text( Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Text(
title, title,
style: TextStyle( style: TextStyle(
color: color, color: color,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 16,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Icon(
LucideIcons.arrowUpRight,
color: color.withValues(alpha: 0.6),
size: 16,
),
],
),
Text(
subtitle,
style: TextStyle(
color: color.withValues(alpha: 0.8),
fontSize: 13,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(height: 12),
Text(
title,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 15,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
subtitle, subtitle,
style: TextStyle(color: color.withOpacity(0.7), fontSize: 12), style: TextStyle(
color: color.withValues(alpha: 0.7),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
], ],
), ),

View File

@@ -24,6 +24,7 @@ class StockLedgerTab extends ConsumerStatefulWidget {
class _StockLedgerTabState extends ConsumerState<StockLedgerTab> { class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
List<InventoryItem>? _items; List<InventoryItem>? _items;
bool _isLoading = true; bool _isLoading = true;
String _selectedFilter = 'IN_STOCK'; // 'IN_STOCK', 'SOLD', 'ALL'
@override @override
void initState() { void initState() {
@@ -61,7 +62,19 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
} }
if (_items == null || _items!.isEmpty) { if (_items == null || _items!.isEmpty) {
return const Center(child: Text("No stock available.")); return const Center(child: Text("No stock records found."));
}
final inStockItems = _items!.where((i) => i.status != 'SOLD').toList();
final soldItems = _items!.where((i) => i.status == 'SOLD').toList();
List<InventoryItem> displayItems;
if (_selectedFilter == 'IN_STOCK') {
displayItems = inStockItems;
} else if (_selectedFilter == 'SOLD') {
displayItems = soldItems;
} else {
displayItems = _items!;
} }
final categoriesState = ref.watch(productCategoriesProvider); final categoriesState = ref.watch(productCategoriesProvider);
@@ -86,15 +99,70 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
final double currentRate = commodityRate > 0 ? (commodityRate * purityFactor) : (category?.dailyRate ?? 0.0); final double currentRate = commodityRate > 0 ? (commodityRate * purityFactor) : (category?.dailyRate ?? 0.0);
return RefreshIndicator( return Column(
children: [
// Filter Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
children: [
_buildFilterChip('In Stock (${inStockItems.length})', 'IN_STOCK'),
const SizedBox(width: 8),
_buildFilterChip('Sold (${soldItems.length})', 'SOLD'),
const SizedBox(width: 8),
_buildFilterChip('All (${_items!.length})', 'ALL'),
],
),
),
Expanded(
child: displayItems.isEmpty
? Center(
child: Text(
_selectedFilter == 'IN_STOCK'
? 'No active stock available.'
: (_selectedFilter == 'SOLD' ? 'No sold items yet.' : 'No stock records.'),
style: const TextStyle(color: Colors.grey),
),
)
: RefreshIndicator(
onRefresh: _fetchLedger, onRefresh: _fetchLedger,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
itemCount: _items!.length, itemCount: displayItems.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = _items![index]; final item = displayItems[index];
final weight = item.grossWeight ?? 0.0; final isSold = item.status == 'SOLD';
final purchaseRate = item.purchaseCost ?? 0.0; final purchaseOrdersState = ref.watch(purchaseOrdersProvider);
final purchaseOrders = purchaseOrdersState.value ?? [];
double weight = (item.grossWeight != null && item.grossWeight! > 0)
? item.grossWeight!
: ((item.netWeight != null && item.netWeight! > 0)
? item.netWeight!
: (item.fineWeight ?? 0.0));
double purchaseRate = (item.purchaseCost != null && item.purchaseCost! > 0)
? item.purchaseCost!
: ((item.metalCost != null && item.metalCost! > 0 && weight > 0)
? (item.metalCost! / weight)
: 0.0);
// If weight or rate was lost due to earlier db overwrite, recover from purchase order
if ((weight == 0.0 || purchaseRate == 0.0) && item.purchaseRef != null) {
final po = purchaseOrders.where((p) => p.poNumber == item.purchaseRef).firstOrNull;
if (po != null) {
final poItem = po.items.where((i) => i.productId == widget.product.id || (item.huid != null && i.huid == item.huid) || (item.sku != null && i.sku == item.sku)).firstOrNull ?? po.items.firstOrNull;
if (poItem != null) {
if (weight == 0.0) {
weight = poItem.weight ?? (poItem.quantity > 0 ? poItem.quantity : 1.0);
}
if (purchaseRate == 0.0) {
purchaseRate = poItem.unitPrice;
}
}
}
}
final purchasePrice = weight * purchaseRate; final purchasePrice = weight * purchaseRate;
final currentPrice = weight * currentRate; final currentPrice = weight * currentRate;
final gainLoss = currentPrice - purchasePrice; final gainLoss = currentPrice - purchasePrice;
@@ -105,8 +173,13 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.withValues(alpha: 0.3)), side: BorderSide(
color: isSold
? Colors.grey.withValues(alpha: 0.2)
: Colors.grey.withValues(alpha: 0.3),
), ),
),
color: isSold ? Colors.grey.withValues(alpha: 0.03) : null,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
child: Column( child: Column(
@@ -202,24 +275,27 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1), color: isSold
? Colors.grey.withValues(alpha: 0.15)
: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: const Text( child: Text(
'IN STOCK', isSold ? 'SOLD' : 'IN STOCK',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.green, color: isSold ? Colors.grey.shade700 : Colors.green,
), ),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'${weight.toStringAsFixed(3)} $unit', '${weight.toStringAsFixed(3)} $unit',
style: const TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 14, fontSize: 14,
color: isSold ? Colors.grey.shade600 : null,
), ),
), ),
const SizedBox(height: 3), const SizedBox(height: 3),
@@ -270,14 +346,14 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Current Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)), Text(isSold ? 'Selling Rate' : 'Current Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${currentRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)), Text('${currentRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
], ],
), ),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text('Current Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)), Text(isSold ? 'Valuation' : 'Current Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${currentPrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)), Text('${currentPrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
], ],
), ),
@@ -300,7 +376,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'${isGain ? 'Gain' : 'Loss'}: ₹${gainLoss.abs().toStringAsFixed(2)}', '${isSold ? "Realized " : ""}${isGain ? 'Gain' : 'Loss'}: ₹${gainLoss.abs().toStringAsFixed(2)}',
style: TextStyle( style: TextStyle(
color: isGain ? Colors.green : Colors.red, color: isGain ? Colors.green : Colors.red,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -316,6 +392,32 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
); );
}, },
), ),
),
),
],
);
}
Widget _buildFilterChip(String label, String value) {
final isSelected = _selectedFilter == value;
return InkWell(
onTap: () => setState(() => _selectedFilter = value),
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? Colors.blue : Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
label,
style: TextStyle(
color: isSelected ? Colors.white : Colors.grey.shade700,
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
),
); );
} }
} }

View File

@@ -130,13 +130,42 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
} }
Future<void> _pickInvoiceFile() async { Future<void> _pickInvoiceFile() async {
try {
final picker = ImagePicker(); final picker = ImagePicker();
final picked = await picker.pickImage( final picked = await picker.pickImage(
source: ImageSource.gallery, source: ImageSource.gallery,
imageQuality: 80, imageQuality: 80,
); );
if (picked != null) { if (picked != null) {
setState(() => _invoiceFile = picked); 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);
} }
} }
@@ -280,11 +309,12 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
try { try {
String? finalInvoiceUrl = _invoiceUrl; String? finalInvoiceUrl = _invoiceUrl;
if (_invoiceFile != null) { if (_invoiceFile != null && finalInvoiceUrl == null) {
final bytes = await _invoiceFile!.readAsBytes();
final formData = FormData.fromMap({ final formData = FormData.fromMap({
'file': await MultipartFile.fromFile( 'file': MultipartFile.fromBytes(
_invoiceFile!.path, bytes,
filename: _invoiceFile!.name, filename: _invoiceFile!.name.isNotEmpty ? _invoiceFile!.name : 'invoice.jpg',
), ),
'type': 'SALES_INVOICE', 'type': 'SALES_INVOICE',
}); });
@@ -366,12 +396,15 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
} }
final invoiceDiscount = double.tryParse(_discountCtrl.text) ?? 0.0; final invoiceDiscount = double.tryParse(_discountCtrl.text) ?? 0.0;
totalDiscount = invoiceDiscount; totalDiscount = double.parse(invoiceDiscount.toStringAsFixed(2));
grandTotal = (subtotal + totalMaking - invoiceDiscount).clamp(0.0, double.infinity) + totalTax; 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 amountPaid = double.tryParse(_amountPaidCtrl.text) ?? (_isAmountPaidEdited ? 0.0 : grandTotal);
final validAmountPaid = amountPaid > grandTotal ? grandTotal : amountPaid; final validAmountPaid = double.parse((amountPaid > grandTotal ? grandTotal : amountPaid).toStringAsFixed(2));
final balanceDue = grandTotal - validAmountPaid; 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( final invoice = Invoice(
id: widget.existingInvoice?.id, id: widget.existingInvoice?.id,
@@ -379,18 +412,20 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
invoiceNumber: _invoiceNumberCtrl.text.trim(), invoiceNumber: _invoiceNumberCtrl.text.trim(),
issueDate: _invoiceDate, issueDate: _invoiceDate,
dueDate: _dueDate, dueDate: _dueDate,
subtotal: subtotal, subtotal: double.parse(subtotal.toStringAsFixed(2)),
taxTotal: totalTax, taxTotal: double.parse(totalTax.toStringAsFixed(2)),
cgstTotal: totalCgst, cgstTotal: double.parse(totalCgst.toStringAsFixed(2)),
sgstTotal: totalSgst, sgstTotal: double.parse(totalSgst.toStringAsFixed(2)),
igstTotal: totalIgst, igstTotal: double.parse(totalIgst.toStringAsFixed(2)),
discountTotal: totalDiscount, discountTotal: totalDiscount,
totalAmount: grandTotal, totalAmount: grandTotal,
amountPaid: validAmountPaid, amountPaid: validAmountPaid,
paymentMethod: validAmountPaid > 0 ? _paymentMethod : null, paymentMethod: validAmountPaid > 0 ? _paymentMethod : null,
paymentWalletId: validAmountPaid > 0 ? (_selectedWalletId ?? ref.read(walletProvider).value?.firstOrNull?.id) : null, paymentWalletId: validAmountPaid > 0 ? (_selectedWalletId ?? ref.read(walletProvider).value?.firstOrNull?.id) : null,
nextPaymentDate: balanceDue > 0 ? (_nextPaymentDate ?? DateTime.now().add(const Duration(days: 30))) : null, nextPaymentDate: balanceDue > 0.01 ? (_nextPaymentDate ?? DateTime.now().add(const Duration(days: 30))) : null,
status: widget.existingInvoice?.status ?? (validAmountPaid >= grandTotal ? 'PAID' : (validAmountPaid > 0 ? 'PARTIAL' : 'DRAFT')), status: widget.existingInvoice != null
? (isFullyPaid ? 'PAID' : (validAmountPaid > 0.01 ? 'PARTIAL' : widget.existingInvoice!.status))
: determinedStatus,
notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(), notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
invoiceUrl: finalInvoiceUrl, invoiceUrl: finalInvoiceUrl,
isEmi: _isEmi, isEmi: _isEmi,

View File

@@ -9,6 +9,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:pdf/pdf.dart'; import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw; import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../../inventory/providers/products_provider.dart'; import '../../inventory/providers/products_provider.dart';
import '../../inventory/providers/product_categories_provider.dart'; import '../../inventory/providers/product_categories_provider.dart';
import '../domain/invoice.dart'; import '../domain/invoice.dart';
@@ -147,15 +148,15 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getStatusColor(latestInvoice.status).withValues(alpha: 0.15), color: _getStatusColor(_getEffectiveStatus(latestInvoice)).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
latestInvoice.status, _getEffectiveStatus(latestInvoice),
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: _getStatusColor(latestInvoice.status), color: _getStatusColor(_getEffectiveStatus(latestInvoice)),
), ),
), ),
), ),
@@ -464,6 +465,17 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
); );
} }
String _getEffectiveStatus(Invoice invoice) {
final balanceDue = invoice.totalAmount - invoice.amountPaid;
if (invoice.status == 'PAID' || balanceDue.abs() < 0.01 || (invoice.totalAmount > 0 && invoice.amountPaid >= invoice.totalAmount - 0.01)) {
return 'PAID';
}
if (invoice.amountPaid > 0.01) {
return 'PARTIAL';
}
return invoice.status.isNotEmpty ? invoice.status : 'DRAFT';
}
Color _getStatusColor(String status) { Color _getStatusColor(String status) {
switch (status) { switch (status) {
case 'PAID': case 'PAID':
@@ -542,60 +554,147 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
final customer = customers.where((c) => c.id == latestInvoice.customerId).firstOrNull; final customer = customers.where((c) => c.id == latestInvoice.customerId).firstOrNull;
final business = ref.read(businessProfileProvider).value; final business = ref.read(businessProfileProvider).value;
final formatCurrency = NumberFormat.currency(symbol: '', decimalDigits: 2); final products = ref.read(productsProvider).value ?? [];
final categories = ref.read(productCategoriesProvider).value ?? [];
final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2);
final formatDate = DateFormat('dd MMM yyyy'); final formatDate = DateFormat('dd MMM yyyy');
final font = await PdfGoogleFonts.robotoRegular();
final boldFont = await PdfGoogleFonts.robotoBold();
final businessStateId = business?.stateId;
final customerStateId = customer?.stateId;
final isSameState = businessStateId != null && customerStateId != null
? (businessStateId == customerStateId)
: true;
// Group items by product ID if available, or itemize
final Map<int, List<InvoiceItem>> groupedItems = {};
final List<InvoiceItem> ungroupedItems = [];
for (final item in latestInvoice.items) {
if (item.productId != null) {
groupedItems.putIfAbsent(item.productId!, () => []).add(item);
} else {
ungroupedItems.add(item);
}
}
final pdf = pw.Document(); final pdf = pw.Document();
pdf.addPage( pdf.addPage(
pw.MultiPage( pw.MultiPage(
pageFormat: PdfPageFormat.a4, pageFormat: PdfPageFormat.a4,
theme: pw.ThemeData.withFont(
base: font,
bold: boldFont,
),
margin: const pw.EdgeInsets.all(28), margin: const pw.EdgeInsets.all(28),
build: (pw.Context context) { build: (pw.Context context) {
return [ return [
// Header with Business Details & TAX INVOICE title // 1. Header with Business Details & TAX INVOICE title
pw.Row( pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.start, crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [ children: [
pw.Column( pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start, crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [ children: [
pw.Text( pw.Text(
business?.businessName ?? 'KIFI JEWELLERS', business?.businessName ?? 'KIFI JEWELLERS',
style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900), style: pw.TextStyle(
fontSize: 22,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blue900,
),
), ),
pw.SizedBox(height: 4), pw.SizedBox(height: 4),
if (business?.address != null) if (business?.address != null && business!.address!.isNotEmpty)
pw.Text(business!.address!, style: const pw.TextStyle(fontSize: 10)), pw.Text(
if (business?.contactNumber != null) business.address!,
pw.Text('Phone: ${business!.contactNumber}', style: const pw.TextStyle(fontSize: 10)), style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
if (business?.gstin != null) ),
pw.Text('GSTIN: ${business!.gstin}', style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.blue800)), if (business?.contactNumber != null && business!.contactNumber!.isNotEmpty)
pw.Text(
'Phone: ${business.contactNumber}',
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
),
if (business?.gstin != null && business!.gstin!.isNotEmpty)
pw.Text(
'GSTIN: ${business.gstin}',
style: pw.TextStyle(
fontSize: 9,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blue800,
),
),
], ],
), ),
),
pw.Column( pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end, crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [ children: [
pw.Text( pw.Text(
'TAX INVOICE', 'TAX INVOICE',
style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900), style: pw.TextStyle(
fontSize: 22,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blue900,
),
),
pw.SizedBox(height: 6),
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: pw.BoxDecoration(
color: _getEffectiveStatus(latestInvoice) == 'PAID'
? PdfColors.green100
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange100 : PdfColors.grey200),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4)),
border: pw.Border.all(
color: _getEffectiveStatus(latestInvoice) == 'PAID'
? PdfColors.green700
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange700 : PdfColors.grey500),
width: 0.5,
),
),
child: pw.Text(
_getEffectiveStatus(latestInvoice),
style: pw.TextStyle(
fontSize: 9,
fontWeight: pw.FontWeight.bold,
color: _getEffectiveStatus(latestInvoice) == 'PAID'
? PdfColors.green900
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange900 : PdfColors.grey800),
),
),
),
pw.SizedBox(height: 6),
pw.Text(
'Invoice #: ${latestInvoice.invoiceNumber}',
style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold),
),
pw.Text(
'Date: ${formatDate.format(latestInvoice.issueDate)}',
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
), ),
pw.SizedBox(height: 4),
pw.Text('Invoice #: ${latestInvoice.invoiceNumber}', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
pw.Text('Date: ${formatDate.format(latestInvoice.issueDate)}', style: const pw.TextStyle(fontSize: 10)),
if (latestInvoice.dueDate != null) if (latestInvoice.dueDate != null)
pw.Text('Due Date: ${formatDate.format(latestInvoice.dueDate!)}', style: const pw.TextStyle(fontSize: 10)), pw.Text(
'Due Date: ${formatDate.format(latestInvoice.dueDate!)}',
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
),
], ],
), ),
], ],
), ),
pw.SizedBox(height: 20), pw.SizedBox(height: 18),
// Bill To Box // 2. Bill To Box (Customer Details)
pw.Container( pw.Container(
padding: const pw.EdgeInsets.all(10), padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration( decoration: pw.BoxDecoration(
color: PdfColors.grey100,
border: pw.Border.all(color: PdfColors.grey400, width: 0.5), border: pw.Border.all(color: PdfColors.grey400, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)), borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
), ),
@@ -606,12 +705,31 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
child: pw.Column( child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start, crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [ children: [
pw.Text('BILL TO (CUSTOMER):', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)), pw.Text(
'BILL TO (CUSTOMER):',
style: pw.TextStyle(
fontSize: 8.5,
fontWeight: pw.FontWeight.bold,
color: PdfColors.grey700,
),
),
pw.SizedBox(height: 2), pw.SizedBox(height: 2),
pw.Text(customer?.name ?? 'Walk-in Customer', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)), pw.Text(
if (customer?.phone != null) pw.Text('Phone: ${customer!.phone}', style: const pw.TextStyle(fontSize: 9)), customer?.name ?? 'Walk-in Customer',
if (customer?.address != null) pw.Text('Address: ${customer!.address}', style: const pw.TextStyle(fontSize: 9)), style: pw.TextStyle(
if (customer?.gstin != null) pw.Text('GSTIN: ${customer!.gstin}', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold)), fontSize: 12,
fontWeight: pw.FontWeight.bold,
),
),
if (customer?.phone != null && customer!.phone!.isNotEmpty)
pw.Text('Phone: ${customer.phone}', style: const pw.TextStyle(fontSize: 9)),
if (customer?.address != null && customer!.address!.isNotEmpty)
pw.Text('Address: ${customer.address}', style: const pw.TextStyle(fontSize: 9)),
if (customer?.gstin != null && customer!.gstin!.isNotEmpty)
pw.Text(
'GSTIN: ${customer.gstin}',
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
),
], ],
), ),
), ),
@@ -620,30 +738,28 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
), ),
pw.SizedBox(height: 16), pw.SizedBox(height: 16),
// Table of Items with HUID, SKU, Making Charges, etc. // 3. Product Groups with Full Details (Category, Purity, HSN, HUID, Weight, Making Charges, GST Breakup)
pw.TableHelper.fromTextArray( ...groupedItems.entries.map((entry) {
context: context, final productId = entry.key;
border: const pw.TableBorder( final group = entry.value;
bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5), final product = products.where((p) => p.id == productId).firstOrNull;
horizontalInside: pw.BorderSide(color: PdfColors.grey300, width: 0.5), final category = categories.where((c) => c.id == product?.categoryId).firstOrNull;
),
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.white, fontSize: 9), final productName = product?.name ?? group.first.productName ?? 'Item $productId';
headerDecoration: const pw.BoxDecoration(color: PdfColors.blue900), final categoryName = category?.name ?? group.first.categoryName ?? 'General Category';
cellStyle: const pw.TextStyle(fontSize: 8.5), final skuStr = product?.sku ?? group.first.sku;
cellAlignments: { final purityStr = product?.purityFactor != null
0: pw.Alignment.centerLeft, ? '${(product!.purityFactor! * 100).toStringAsFixed(1)}%'
1: pw.Alignment.centerLeft, : (category?.purityFactor != null ? '${(category!.purityFactor! * 100).toStringAsFixed(1)}%' : '0.916');
2: pw.Alignment.centerRight, final hsnStr = product?.hsnCode ?? group.first.hsnCode ?? category?.defaultHsn ?? '-';
3: pw.Alignment.centerRight,
4: pw.Alignment.centerRight, double groupSubtotal = 0;
5: pw.Alignment.centerRight, double groupMaking = 0;
6: pw.Alignment.centerRight, double groupTax = 0;
},
headers: ['Item / Particulars', 'HSN / HUID', 'Weight', 'Rate', 'Making Chg', 'GST', 'Total'], for (final item in group) {
data: latestInvoice.items.map((item) {
final weight = item.weight ?? item.quantity; final weight = item.weight ?? item.quantity;
final metalAmt = weight * item.unitPrice; final metalAmt = weight * item.unitPrice;
double makingAmt = 0.0; double makingAmt = 0.0;
if (item.makingChargesType == 'PERCENTAGE') { if (item.makingChargesType == 'PERCENTAGE') {
makingAmt = metalAmt * (item.makingCharge / 100.0); makingAmt = metalAmt * (item.makingCharge / 100.0);
@@ -652,76 +768,402 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
} else { } else {
makingAmt = weight * item.makingCharge; makingAmt = weight * item.makingCharge;
} }
groupSubtotal += metalAmt;
groupMaking += makingAmt;
groupTax += (item.cgst + item.sgst + item.igst);
}
String particulars = item.productName ?? item.description ?? 'Item'; final groupTotal = group.fold(0.0, (sum, i) => sum + i.total);
if (item.sku != null && item.sku!.isNotEmpty) particulars += '\nSKU: ${item.sku}'; final gstRate = group.first.taxRate > 0 ? group.first.taxRate : (product?.gstRate ?? (category?.defaultGst ?? 0.0));
if (item.categoryName != null) particulars += ' (${item.categoryName})';
String hsnHuid = 'HSN: ${item.hsnCode ?? '-'}'; return pw.Container(
if (item.huid != null && item.huid!.isNotEmpty) hsnHuid += '\nHUID: ${item.huid}'; margin: const pw.EdgeInsets.only(bottom: 14),
decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.grey300, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// Product Title & Badges
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: const pw.BoxDecoration(
color: PdfColors.grey200,
borderRadius: pw.BorderRadius.vertical(top: pw.Radius.circular(5.5)),
),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Expanded(
child: pw.Text(
productName,
style: pw.TextStyle(
fontSize: 11,
fontWeight: pw.FontWeight.bold,
color: PdfColors.black,
),
),
),
pw.Wrap(
spacing: 6,
runSpacing: 4,
children: [
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: pw.BoxDecoration(
color: PdfColors.orange50,
border: pw.Border.all(color: PdfColors.orange400, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)),
),
child: pw.Text(
categoryName,
style: pw.TextStyle(
fontSize: 7.5,
fontWeight: pw.FontWeight.bold,
color: PdfColors.deepOrange900,
),
),
),
if (skuStr != null && skuStr.isNotEmpty)
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: pw.BoxDecoration(
color: PdfColors.grey100,
border: pw.Border.all(color: PdfColors.grey500, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)),
),
child: pw.Text(
'SKU: $skuStr',
style: const pw.TextStyle(fontSize: 7.5, color: PdfColors.grey800),
),
),
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: pw.BoxDecoration(
color: PdfColors.amber50,
border: pw.Border.all(color: PdfColors.amber600, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)),
),
child: pw.Text(
'Purity: $purityStr',
style: pw.TextStyle(
fontSize: 7.5,
fontWeight: pw.FontWeight.bold,
color: PdfColors.brown800,
),
),
),
if (hsnStr != '-')
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: pw.BoxDecoration(
color: PdfColors.purple50,
border: pw.Border.all(color: PdfColors.purple400, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)),
),
child: pw.Text(
'HSN: $hsnStr',
style: pw.TextStyle(
fontSize: 7.5,
fontWeight: pw.FontWeight.bold,
color: PdfColors.purple900,
),
),
),
],
),
],
),
),
// Items Table for this Product
pw.TableHelper.fromTextArray(
context: context,
border: const pw.TableBorder(
bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
horizontalInside: pw.BorderSide(color: PdfColors.grey200, width: 0.5),
),
headerStyle: pw.TextStyle(
fontWeight: pw.FontWeight.bold,
color: PdfColors.white,
fontSize: 8,
),
headerDecoration: const pw.BoxDecoration(color: PdfColors.blue900),
cellStyle: const pw.TextStyle(fontSize: 8),
cellAlignments: {
0: pw.Alignment.centerLeft,
1: pw.Alignment.centerRight,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
4: pw.Alignment.centerRight,
5: pw.Alignment.centerRight,
},
headers: [
'Item / HUID',
'Weight / Qty',
'Rate',
'Making Chg',
'Other Chg',
'Total Amt',
],
data: group.map((item) {
String particular = '';
if (item.huid != null && item.huid!.isNotEmpty) {
particular = 'HUID: ${item.huid}';
} else if (item.sku != null && item.sku!.isNotEmpty) {
particular = 'SKU: ${item.sku}';
} else {
particular = 'Standard Item';
}
final weight = item.weight ?? item.quantity;
final weightQty = item.weight != null && item.weight! > 0
? '${item.weight!.toStringAsFixed(item.weight!.truncateToDouble() == item.weight ? 0 : 3)} g'
: '${item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 0)} pcs';
final rateUnit = item.weight != null && item.weight! > 0 ? '/ g' : '/ pc';
double makingAmt = 0.0;
if (item.makingChargesType == 'PERCENTAGE') {
makingAmt = (weight * item.unitPrice) * (item.makingCharge / 100.0);
} else if (item.makingChargesType == 'PER_PIECE') {
makingAmt = item.makingCharge;
} else {
makingAmt = weight * item.makingCharge;
}
return [ return [
particulars, particular,
hsnHuid, weightQty,
'${weight.toStringAsFixed(3)} g', '${formatCurrency.format(item.unitPrice)} $rateUnit',
formatCurrency.format(item.unitPrice), makingAmt > 0 ? formatCurrency.format(makingAmt) : '-',
formatCurrency.format(makingAmt), item.otherCharges > 0 ? formatCurrency.format(item.otherCharges) : '-',
'${item.taxRate.toStringAsFixed(1)}%',
formatCurrency.format(item.total), formatCurrency.format(item.total),
]; ];
}).toList(), }).toList(),
), ),
pw.SizedBox(height: 16),
// Summary Section // Group Subtotal & GST Calculation Box
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.end,
children: [
pw.Container( pw.Container(
width: 240, padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: const pw.BoxDecoration(
color: PdfColors.blue50,
borderRadius: pw.BorderRadius.vertical(bottom: pw.Radius.circular(5.5)),
),
child: pw.Column( child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch, crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [ children: [
pw.Row( pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Text('Metal Subtotal:', style: const pw.TextStyle(fontSize: 10)), pw.Text(
pw.Text(formatCurrency.format(latestInvoice.subtotal), style: const pw.TextStyle(fontSize: 10)), 'Metal Subtotal: ${formatCurrency.format(groupSubtotal)} | Making: ${formatCurrency.format(groupMaking)}',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
),
pw.Text(
'Taxable: ${formatCurrency.format(groupSubtotal + groupMaking)}',
style: const pw.TextStyle(fontSize: 8),
),
],
),
if (gstRate > 0) ...[
pw.SizedBox(height: 2),
if (isSameState) ...[
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'CGST (${(gstRate / 2).toStringAsFixed(1)}%):',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
),
pw.Text(
'+ ${formatCurrency.format(groupTax / 2)}',
style: const pw.TextStyle(fontSize: 8),
),
], ],
), ),
if (latestInvoice.cgstTotal > 0 && latestInvoice.sgstTotal > 0) ...[
pw.SizedBox(height: 2), pw.SizedBox(height: 2),
pw.Row( pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Text('CGST:', style: const pw.TextStyle(fontSize: 9)), pw.Text(
pw.Text(formatCurrency.format(latestInvoice.cgstTotal), style: const pw.TextStyle(fontSize: 9)), 'SGST (${(gstRate / 2).toStringAsFixed(1)}%):',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
),
pw.Text(
'+ ${formatCurrency.format(groupTax / 2)}',
style: const pw.TextStyle(fontSize: 8),
),
], ],
), ),
] else ...[
pw.Row( pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Text('SGST:', style: const pw.TextStyle(fontSize: 9)), pw.Text(
pw.Text(formatCurrency.format(latestInvoice.sgstTotal), style: const pw.TextStyle(fontSize: 9)), 'IGST (${gstRate.toStringAsFixed(1)}%):',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
),
pw.Text(
'+ ${formatCurrency.format(groupTax)}',
style: const pw.TextStyle(fontSize: 8),
),
],
),
],
],
pw.Divider(color: PdfColors.grey300, height: 8),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'Group Total:',
style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold),
),
pw.Text(
formatCurrency.format(groupTotal),
style: pw.TextStyle(
fontSize: 8.5,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blue900,
),
),
],
),
],
),
),
],
),
);
}),
// 3b. Ungrouped Items (if any)
if (ungroupedItems.isNotEmpty) ...[
pw.TableHelper.fromTextArray(
context: context,
border: const pw.TableBorder(
bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
horizontalInside: pw.BorderSide(color: PdfColors.grey200, width: 0.5),
),
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.white, fontSize: 8),
headerDecoration: const pw.BoxDecoration(color: PdfColors.blue900),
cellStyle: const pw.TextStyle(fontSize: 8),
headers: ['Particulars', 'Weight / Qty', 'Rate', 'Making Chg', 'Other Chg', 'Total'],
data: ungroupedItems.map((item) {
final weightQty = item.weight != null && item.weight! > 0
? '${item.weight!.toStringAsFixed(3)} g'
: '${item.quantity.toInt()} pcs';
return [
item.productName ?? item.description ?? 'Item',
weightQty,
formatCurrency.format(item.unitPrice),
item.makingCharge > 0 ? formatCurrency.format(item.makingCharge) : '-',
item.otherCharges > 0 ? formatCurrency.format(item.otherCharges) : '-',
formatCurrency.format(item.total),
];
}).toList(),
),
pw.SizedBox(height: 12),
],
pw.SizedBox(height: 12),
// 4. Overall Invoice Summary
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
// Left Side: Notes & Declarations
pw.Expanded(
flex: 3,
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
if (latestInvoice.notes != null && latestInvoice.notes!.isNotEmpty) ...[
pw.Text(
'Notes:',
style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold),
),
pw.Text(
latestInvoice.notes!,
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey800),
),
pw.SizedBox(height: 8),
],
pw.Text(
'Terms & Conditions:',
style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold),
),
pw.SizedBox(height: 2),
pw.Text(
'1. Goods once sold are subject to standard hallmarking certification.',
style: const pw.TextStyle(fontSize: 7.5, color: PdfColors.grey700),
),
pw.Text(
'2. Weight & purity tested under industry standard electronic balance.',
style: const pw.TextStyle(fontSize: 7.5, color: PdfColors.grey700),
),
],
),
),
pw.SizedBox(width: 20),
// Right Side: Grand Total & Tax Summary Box
pw.Expanded(
flex: 2,
child: pw.Container(
padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
color: PdfColors.grey100,
border: pw.Border.all(color: PdfColors.grey400, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
),
child: pw.Column(
children: [
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Metal Subtotal:', style: const pw.TextStyle(fontSize: 8.5)),
pw.Text(formatCurrency.format(latestInvoice.subtotal), style: const pw.TextStyle(fontSize: 8.5)),
],
),
if (latestInvoice.cgstTotal > 0 || latestInvoice.sgstTotal > 0 || latestInvoice.igstTotal > 0 || latestInvoice.taxTotal > 0) ...[
pw.SizedBox(height: 4),
if (isSameState) ...[
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('CGST Total:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)),
pw.Text('+ ${formatCurrency.format(latestInvoice.cgstTotal > 0 ? latestInvoice.cgstTotal : (latestInvoice.taxTotal / 2))}', style: const pw.TextStyle(fontSize: 8.5)),
], ],
), ),
] else if (latestInvoice.igstTotal > 0) ...[
pw.SizedBox(height: 2), pw.SizedBox(height: 2),
pw.Row( pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Text('IGST:', style: const pw.TextStyle(fontSize: 9)), pw.Text('SGST Total:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)),
pw.Text(formatCurrency.format(latestInvoice.igstTotal), style: const pw.TextStyle(fontSize: 9)), pw.Text('+ ${formatCurrency.format(latestInvoice.sgstTotal > 0 ? latestInvoice.sgstTotal : (latestInvoice.taxTotal / 2))}', style: const pw.TextStyle(fontSize: 8.5)),
], ],
), ),
] else ...[
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('IGST Total:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)),
pw.Text('+ ${formatCurrency.format(latestInvoice.igstTotal > 0 ? latestInvoice.igstTotal : latestInvoice.taxTotal)}', style: const pw.TextStyle(fontSize: 8.5)),
],
),
],
], ],
if (latestInvoice.discountTotal > 0) ...[ if (latestInvoice.discountTotal > 0) ...[
pw.SizedBox(height: 2), pw.SizedBox(height: 4),
pw.Row( pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Text('Discount:', style: const pw.TextStyle(fontSize: 9, color: PdfColors.green700)), pw.Text('Discount:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green700)),
pw.Text('-${formatCurrency.format(latestInvoice.discountTotal)}', style: const pw.TextStyle(fontSize: 9, color: PdfColors.green700)), pw.Text('- ${formatCurrency.format(latestInvoice.discountTotal)}', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green700)),
], ],
), ),
], ],
@@ -729,52 +1171,78 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
pw.Row( pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Text('Grand Total:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 13)), pw.Text(
pw.Text(formatCurrency.format(latestInvoice.totalAmount), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 13)), 'Grand Total:',
style: pw.TextStyle(
fontSize: 11,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blue900,
),
),
pw.Text(
formatCurrency.format(latestInvoice.totalAmount),
style: pw.TextStyle(
fontSize: 11,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blue900,
),
),
], ],
), ),
pw.SizedBox(height: 4), if (latestInvoice.amountPaid > 0) ...[
pw.SizedBox(height: 6),
pw.Row( pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Text('Amount Received:', style: const pw.TextStyle(fontSize: 10, color: PdfColors.green800)), pw.Text('Amount Received:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green800)),
pw.Text(formatCurrency.format(latestInvoice.amountPaid), style: const pw.TextStyle(fontSize: 10, color: PdfColors.green800)), pw.Text(formatCurrency.format(latestInvoice.amountPaid), style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green800)),
], ],
), ),
pw.SizedBox(height: 2),
pw.Row( pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Text('Balance Due:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 11, color: PdfColors.red800)), pw.Text(
pw.Text(formatCurrency.format(latestInvoice.totalAmount - latestInvoice.amountPaid), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 11, color: PdfColors.red800)), 'Balance Due:',
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.red800),
),
pw.Text(
formatCurrency.format(latestInvoice.totalAmount - latestInvoice.amountPaid),
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.red800),
),
], ],
), ),
], ],
],
),
), ),
), ),
], ],
), ),
pw.SizedBox(height: 30), pw.SizedBox(height: 24),
// Terms & Signatory // 5. Signatures
pw.Divider(color: PdfColors.grey300),
pw.Row( pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [ children: [
pw.Column( pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start, crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [ children: [
pw.Text('Terms & Conditions:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 8)), pw.SizedBox(height: 25),
pw.Text('1. Goods once sold are subject to standard hallmarking certification.', style: const pw.TextStyle(fontSize: 7, color: PdfColors.grey700)), pw.Text(
pw.Text('2. Weight & purity tested under industry standard electronic balance.', style: const pw.TextStyle(fontSize: 7, color: PdfColors.grey700)), 'Customer\'s Signature',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
),
], ],
), ),
pw.Column( pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.center, crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [ children: [
pw.Text('For ${business?.businessName ?? "KIFI JEWELLERS"}', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 9)), pw.SizedBox(height: 25),
pw.SizedBox(height: 24), pw.Text(
pw.Text('Authorized Signatory', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700)), 'For ${business?.businessName ?? "KIFI JEWELLERS"} (Authorized Signatory)',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
),
], ],
), ),
], ],
@@ -784,13 +1252,19 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
), ),
); );
final directory = await getTemporaryDirectory(); final pdfBytes = await pdf.save();
final pdfPath = await File('${directory.path}/TaxInvoice_$invoiceNumber.pdf').create();
await pdfPath.writeAsBytes(await pdf.save());
await Share.shareXFiles([XFile(pdfPath.path)], text: 'Tax Invoice $invoiceNumber'); // Cross-platform PDF sharing / printing
await Printing.sharePdf(
bytes: pdfBytes,
filename: 'TaxInvoice_${invoiceNumber.replaceAll('/', '_')}.pdf',
);
} catch (e) { } catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing PDF: $e'))); if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error sharing PDF: $e')),
);
}
} }
} }
} }

View File

@@ -5,7 +5,6 @@ import 'package:intl/intl.dart';
import '../providers/invoices_provider.dart'; import '../providers/invoices_provider.dart';
import '../domain/invoice.dart'; import '../domain/invoice.dart';
import '../providers/customers_provider.dart'; import '../providers/customers_provider.dart';
import '../domain/customer.dart';
import 'invoice_builder_screen.dart'; import 'invoice_builder_screen.dart';
import 'invoice_details_screen.dart'; import 'invoice_details_screen.dart';
@@ -22,9 +21,12 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
String _getStatusLabel(Invoice invoice) { String _getStatusLabel(Invoice invoice) {
if (invoice.isEmi && invoice.status != 'PAID') return 'EMI'; if (invoice.isEmi && invoice.status != 'PAID') return 'EMI';
if (invoice.status == 'PAID' || invoice.amountPaid >= invoice.totalAmount && invoice.totalAmount > 0) return 'PAID'; final balanceDue = invoice.totalAmount - invoice.amountPaid;
if (invoice.amountPaid > 0) return 'PARTIAL'; if (invoice.status == 'PAID' || balanceDue.abs() < 0.01 || (invoice.totalAmount > 0 && invoice.amountPaid >= invoice.totalAmount - 0.01)) {
return invoice.status; return 'PAID';
}
if (invoice.amountPaid > 0.01) return 'PARTIAL';
return invoice.status.isNotEmpty ? invoice.status : 'DRAFT';
} }
Color _getStatusColor(Invoice invoice) { Color _getStatusColor(Invoice invoice) {
@@ -237,12 +239,72 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
), ),
), ),
if (invoice.items.isNotEmpty) if (invoice.items.isNotEmpty)
Text( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${invoice.items.length} ${invoice.items.length == 1 ? "item" : "items"}', '${invoice.items.length} ${invoice.items.length == 1 ? "item" : "items"}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500), style: const TextStyle(fontSize: 11, color: Colors.blue, fontWeight: FontWeight.w600),
),
), ),
], ],
), ),
// Row 2.5: Sold Items Details
if (invoice.items.isNotEmpty) ...[
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: isDark ? Colors.white.withValues(alpha: 0.04) : Colors.grey.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...invoice.items.take(3).map((item) {
final weightStr = item.weight != null && item.weight! > 0
? '${item.weight!.toStringAsFixed(3)} g'
: '${item.quantity.toInt()} pcs';
final name = item.productName ?? item.description ?? 'Jewellery Item';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row(
children: [
Icon(LucideIcons.sparkles, size: 12, color: Colors.amber.shade700),
const SizedBox(width: 6),
Expanded(
child: Text(
name,
style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
weightStr,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600),
),
],
),
);
}),
if (invoice.items.length > 3)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'+ ${invoice.items.length - 3} more items',
style: TextStyle(fontSize: 11, color: Colors.blue.shade600, fontWeight: FontWeight.w500),
),
),
],
),
),
],
const SizedBox(height: 10), const SizedBox(height: 10),
// Row 3: Date & Total Amount // Row 3: Date & Total Amount

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../../../core/widgets/premium_text_field.dart'; import '../../../../core/widgets/premium_text_field.dart';
import '../../../transactions/providers/providers.dart'; import '../../../transactions/providers/providers.dart';
import '../../providers/invoices_provider.dart'; import '../../providers/invoices_provider.dart';
import '../../providers/customers_provider.dart';
import '../../domain/invoice.dart'; import '../../domain/invoice.dart';
class ReceivePaymentSheet extends ConsumerStatefulWidget { class ReceivePaymentSheet extends ConsumerStatefulWidget {
@@ -17,20 +19,43 @@ class ReceivePaymentSheet extends ConsumerStatefulWidget {
} }
class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> { class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
final TextEditingController _amountCtrl = TextEditingController(); final _formKey = GlobalKey<FormState>();
late TextEditingController _amountCtrl;
String _paymentMethod = 'Cash'; String _paymentMethod = 'Cash';
int? _selectedWalletId; int? _selectedWalletId;
bool _isLoading = false; bool _isLoading = false;
final List<Map<String, dynamic>> _methods = [
{'key': 'Cash', 'label': 'Cash', 'icon': LucideIcons.banknote},
{'key': 'UPI', 'label': 'UPI', 'icon': LucideIcons.smartphone},
{'key': 'Bank Transfer', 'label': 'Bank Transfer', 'icon': LucideIcons.building2},
{'key': 'Card', 'label': 'Card', 'icon': LucideIcons.creditCard},
];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final balance = widget.invoice.totalAmount - widget.invoice.amountPaid; final balance = widget.invoice.totalAmount - widget.invoice.amountPaid;
_amountCtrl.text = balance.toStringAsFixed(2); _amountCtrl = TextEditingController(
text: balance > 0 ? balance.toStringAsFixed(2) : '0.00',
);
}
@override
void dispose() {
_amountCtrl.dispose();
super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final formatCurrency = NumberFormat.currency(symbol: '');
final balanceDue = (widget.invoice.totalAmount - widget.invoice.amountPaid).clamp(0.0, double.infinity);
final customersState = ref.watch(customersProvider);
final customer = customersState.value?.where((c) => c.id == widget.invoice.customerId).firstOrNull;
final walletsState = ref.watch(walletProvider); final walletsState = ref.watch(walletProvider);
final allWallets = walletsState.value ?? []; final allWallets = walletsState.value ?? [];
@@ -49,55 +74,247 @@ class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
} }
return Container( return Container(
decoration: const BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 20,
offset: const Offset(0, -5),
),
],
), ),
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom, bottom: MediaQuery.of(context).viewInsets.bottom + 20,
top: 24, top: 12,
left: 24, left: 20,
right: 24, right: 20,
), ),
child: SafeArea(
top: false,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Drag Handle
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: isDark ? Colors.white24 : Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 14),
// Header
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(LucideIcons.arrowDownLeft, size: 20, color: Colors.blue),
),
const SizedBox(width: 12),
const Text( const Text(
'Receive Payment', 'Receive Payment',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold),
),
],
), ),
IconButton( IconButton(
icon: const Icon(LucideIcons.x), icon: const Icon(LucideIcons.x, size: 20),
style: IconButton.styleFrom(
backgroundColor: isDark ? Colors.white10 : Colors.grey.shade100,
),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Invoice Summary Card
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark ? Colors.white.withValues(alpha: 0.04) : Colors.grey.shade50,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
customer?.name ?? 'Customer',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'Invoice #${widget.invoice.invoiceNumber}',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Balance Due',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
),
),
const SizedBox(height: 2),
Text(
formatCurrency.format(balanceDue),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.deepOrange,
),
),
],
),
],
),
),
const SizedBox(height: 18),
// Amount Field
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Payment Amount',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
if (balanceDue > 0)
InkWell(
onTap: () {
setState(() {
_amountCtrl.text = balanceDue.toStringAsFixed(2);
});
},
child: Text(
'Receive Full (${formatCurrency.format(balanceDue)})',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.blue.shade600,
),
),
),
],
),
const SizedBox(height: 8),
PremiumTextField( PremiumTextField(
controller: _amountCtrl, controller: _amountCtrl,
labelText: 'Amount Paid (₹)', labelText: 'Amount Paid (₹)',
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
prefixIcon: const Icon(LucideIcons.indianRupee, size: 18),
validator: (val) {
if (val == null || val.trim().isEmpty) return 'Required';
final parsed = double.tryParse(val);
if (parsed == null || parsed <= 0) return 'Invalid amount';
return null;
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Payment Method Chips
const Text(
'Payment Method',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _methods.map((m) {
final isSelected = _paymentMethod == m['key'];
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ChoiceChip(
avatar: Icon(
m['icon'] as IconData,
size: 15,
color: isSelected ? Colors.white : (isDark ? Colors.grey.shade300 : Colors.grey.shade700),
),
label: Text(m['label'] as String),
labelStyle: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.white : (isDark ? Colors.grey.shade300 : Colors.grey.shade800),
),
selected: isSelected,
selectedColor: Colors.blue.shade600,
backgroundColor: isDark ? Colors.white.withValues(alpha: 0.05) : Colors.grey.shade100,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: isSelected ? Colors.blue.shade600 : (isDark ? Colors.white10 : Colors.grey.shade300),
width: 0.8,
),
),
onSelected: (_) {
setState(() {
_paymentMethod = m['key'] as String;
_selectedWalletId = null;
});
},
),
);
}).toList(),
),
),
const SizedBox(height: 16),
// Receive Into Wallet
if (wallets.isNotEmpty) ...[
const Text( const Text(
'Receive Into Wallet', 'Receive Into Wallet',
style: TextStyle(fontWeight: FontWeight.w600, color: Colors.grey), style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[100], color: isDark ? Colors.white.withValues(alpha: 0.04) : Colors.grey.shade50,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? Colors.white12 : Colors.grey.shade300,
),
), ),
child: DropdownButtonHideUnderline( child: DropdownButtonHideUnderline(
child: DropdownButton<int>( child: DropdownButton<int>(
value: _selectedWalletId, value: _selectedWalletId,
isExpanded: true, isExpanded: true,
dropdownColor: isDark ? const Color(0xFF1E293B) : Colors.white,
hint: const Text('Select Wallet'), hint: const Text('Select Wallet'),
items: wallets items: wallets
.map( .map(
@@ -108,51 +325,27 @@ class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 22),
const Text( ] else
'Payment Method',
style: TextStyle(fontWeight: FontWeight.w600, color: Colors.grey),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16), // Submit Button
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(16),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _paymentMethod,
isExpanded: true,
items: ['Cash', 'UPI', 'Bank Transfer', 'Card']
.map((m) => DropdownMenuItem(value: m, child: Text(m)))
.toList(),
onChanged: (val) {
if (val != null) {
setState(() {
_paymentMethod = val;
_selectedWalletId = null;
});
}
},
),
),
),
const SizedBox(height: 24),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 52,
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue, backgroundColor: Colors.blue.shade600,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16), elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(14),
), ),
), ),
onPressed: _isLoading onPressed: _isLoading
? null ? null
: () async { : () async {
if (!_formKey.currentState!.validate()) return;
final amount = double.tryParse(_amountCtrl.text) ?? 0; final amount = double.tryParse(_amountCtrl.text) ?? 0;
if (amount <= 0) return; if (amount <= 0) return;
@@ -172,10 +365,11 @@ class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
Navigator.pop( Navigator.pop(
context, context,
true, true,
); // true indicates success );
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Payment Received Successfully!'), content: Text('Payment of ₹${amount.toStringAsFixed(2)} received successfully!'),
backgroundColor: Colors.green.shade700,
), ),
); );
} }
@@ -190,25 +384,34 @@ class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
}, },
child: _isLoading child: _isLoading
? const SizedBox( ? const SizedBox(
width: 24, width: 22,
height: 24, height: 22,
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Colors.white, color: Colors.white,
strokeWidth: 2, strokeWidth: 2.5,
), ),
) )
: const Text( : const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.checkCircle2, size: 18),
SizedBox(width: 8),
Text(
'Confirm Payment', 'Confirm Payment',
style: TextStyle( style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16,
), ),
), ),
),
),
const SizedBox(height: 24),
], ],
), ),
),
),
],
),
),
),
),
); );
} }
} }

View File

@@ -114,10 +114,11 @@ class _AddVendorSheetState extends ConsumerState<AddVendorSheet> {
try { try {
String? photoUrl = widget.vendor?.photoUrl; String? photoUrl = widget.vendor?.photoUrl;
if (_photo != null && _token != null) { if (_photo != null && _token != null) {
final bytes = await _photo!.readAsBytes();
final formData = FormData.fromMap({ final formData = FormData.fromMap({
'file': await MultipartFile.fromFile( 'file': MultipartFile.fromBytes(
_photo!.path, bytes,
filename: _photo!.name, filename: _photo!.name.isNotEmpty ? _photo!.name : 'vendor.jpg',
), ),
'type': 'VENDOR', 'type': 'VENDOR',
}); });

View File

@@ -3,10 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../core/widgets/premium_text_field.dart'; import '../../../core/widgets/premium_text_field.dart';
import '../../../core/widgets/smart_search_dropdown.dart';
import '../domain/purchase_order.dart'; import '../domain/purchase_order.dart';
import '../domain/purchase_payment.dart'; import '../domain/purchase_payment.dart';
import '../providers/purchase_orders_provider.dart'; import '../providers/purchase_orders_provider.dart';
import '../providers/vendors_provider.dart';
class PayVendorSheet extends ConsumerStatefulWidget { class PayVendorSheet extends ConsumerStatefulWidget {
final PurchaseOrder po; final PurchaseOrder po;
@@ -22,15 +22,15 @@ class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
late TextEditingController _amountCtrl; late TextEditingController _amountCtrl;
final TextEditingController _notesCtrl = TextEditingController(); final TextEditingController _notesCtrl = TextEditingController();
DateTime _paymentDate = DateTime.now(); DateTime _paymentDate = DateTime.now();
String? _selectedMethod; String _selectedMethod = 'CASH';
bool _isLoading = false; bool _isLoading = false;
final List<String> _paymentMethods = [ final List<Map<String, dynamic>> _methods = [
'CASH', {'key': 'CASH', 'label': 'Cash', 'icon': LucideIcons.banknote},
'BANK_TRANSFER', {'key': 'UPI', 'label': 'UPI', 'icon': LucideIcons.smartphone},
'UPI', {'key': 'BANK_TRANSFER', 'label': 'Bank Transfer', 'icon': LucideIcons.building2},
'CARD', {'key': 'CARD', 'label': 'Card', 'icon': LucideIcons.creditCard},
'CHEQUE', {'key': 'CHEQUE', 'label': 'Cheque', 'icon': LucideIcons.fileText},
]; ];
@override @override
@@ -38,7 +38,7 @@ class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
super.initState(); super.initState();
final balance = widget.po.totalAmount - widget.po.amountPaid; final balance = widget.po.totalAmount - widget.po.amountPaid;
_amountCtrl = TextEditingController( _amountCtrl = TextEditingController(
text: balance > 0 ? balance.toStringAsFixed(2) : '0', text: balance > 0 ? balance.toStringAsFixed(2) : '0.00',
); );
} }
@@ -51,9 +51,11 @@ class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
Future<void> _savePayment() async { Future<void> _savePayment() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
if (_selectedMethod == null) {
final amount = double.tryParse(_amountCtrl.text);
if (amount == null || amount <= 0) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select a payment method')), const SnackBar(content: Text('Please enter a valid amount')),
); );
return; return;
} }
@@ -61,10 +63,10 @@ class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final payment = PurchasePayment( final payment = PurchasePayment(
amount: double.parse(_amountCtrl.text), amount: amount,
paymentMethod: _selectedMethod, paymentMethod: _selectedMethod,
paymentDate: _paymentDate, paymentDate: _paymentDate,
notes: _notesCtrl.text.isEmpty ? null : _notesCtrl.text, notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
); );
await ref await ref
@@ -74,14 +76,17 @@ class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
if (mounted) { if (mounted) {
Navigator.pop(context, true); Navigator.pop(context, true);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Payment recorded successfully')), SnackBar(
content: Text('Payment of ₹${amount.toStringAsFixed(2)} recorded successfully'),
backgroundColor: Colors.green.shade700,
),
); );
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text('Error: $e'))); ).showSnackBar(SnackBar(content: Text('Error recording payment: $e')));
} }
} finally { } finally {
if (mounted) setState(() => _isLoading = false); if (mounted) setState(() => _isLoading = false);
@@ -90,56 +95,238 @@ class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( final isDark = Theme.of(context).brightness == Brightness.dark;
padding: EdgeInsets.only( final formatCurrency = NumberFormat.currency(symbol: '');
bottom: MediaQuery.of(context).viewInsets.bottom, final formatDate = DateFormat('dd MMM yyyy');
left: 24,
right: 24, final balanceDue = (widget.po.totalAmount - widget.po.amountPaid).clamp(0.0, double.infinity);
top: 24, final vendorsState = ref.watch(vendorsProvider);
final vendor = vendorsState.value?.where((v) => v.id == widget.po.vendorId).firstOrNull;
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 20,
offset: const Offset(0, -5),
), ),
],
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
left: 20,
right: 20,
top: 12,
),
child: SafeArea(
top: false,
child: Form( child: Form(
key: _formKey, key: _formKey,
child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Drag Handle
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: isDark ? Colors.white24 : Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 14),
// Title & Close Button
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(LucideIcons.indianRupee, size: 20, color: Colors.green),
),
const SizedBox(width: 12),
const Text( const Text(
'Record Payment', 'Record Payment',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold),
),
],
), ),
IconButton( IconButton(
icon: const Icon(LucideIcons.x), icon: const Icon(LucideIcons.x, size: 20),
style: IconButton.styleFrom(
backgroundColor: isDark ? Colors.white10 : Colors.grey.shade100,
),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// PO Summary & Balance Card
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark ? Colors.white.withValues(alpha: 0.04) : Colors.grey.shade50,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
vendor?.name ?? 'Vendor',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'PO #${widget.po.poNumber}',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Balance Due',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
),
),
const SizedBox(height: 2),
Text(
formatCurrency.format(balanceDue),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.deepOrange,
),
),
],
),
],
),
),
const SizedBox(height: 18),
// Amount Field
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Payment Amount',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
if (balanceDue > 0)
InkWell(
onTap: () {
setState(() {
_amountCtrl.text = balanceDue.toStringAsFixed(2);
});
},
child: Text(
'Pay Full (${formatCurrency.format(balanceDue)})',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.blue.shade600,
),
),
),
],
),
const SizedBox(height: 8),
PremiumTextField( PremiumTextField(
controller: _amountCtrl, controller: _amountCtrl,
labelText: 'Amount Paid', labelText: 'Amount Paid (₹)',
keyboardType: const TextInputType.numberWithOptions( keyboardType: const TextInputType.numberWithOptions(decimal: true),
decimal: true, prefixIcon: const Icon(LucideIcons.indianRupee, size: 18),
),
prefixIcon: const Icon(LucideIcons.indianRupee),
validator: (val) { validator: (val) {
if (val == null || val.isEmpty) return 'Required'; if (val == null || val.trim().isEmpty) return 'Required';
if (double.tryParse(val) == null) return 'Invalid amount'; final parsed = double.tryParse(val);
if (parsed == null || parsed <= 0) return 'Invalid amount';
return null; return null;
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
SmartSearchDropdown<String>(
labelText: 'Payment Method', // Payment Method Chips
hintText: 'Select Method', const Text(
value: _selectedMethod, 'Payment Method',
items: _paymentMethods, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
itemAsString: (val) => val.replaceAll('_', ' '), ),
onChanged: (val) => setState(() => _selectedMethod = val), const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _methods.map((m) {
final isSelected = _selectedMethod == m['key'];
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ChoiceChip(
avatar: Icon(
m['icon'] as IconData,
size: 15,
color: isSelected ? Colors.white : (isDark ? Colors.grey.shade300 : Colors.grey.shade700),
),
label: Text(m['label'] as String),
labelStyle: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.white : (isDark ? Colors.grey.shade300 : Colors.grey.shade800),
),
selected: isSelected,
selectedColor: Colors.blue.shade600,
backgroundColor: isDark ? Colors.white.withValues(alpha: 0.05) : Colors.grey.shade100,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: isSelected ? Colors.blue.shade600 : (isDark ? Colors.white10 : Colors.grey.shade300),
width: 0.8,
),
),
onSelected: (_) {
setState(() => _selectedMethod = m['key'] as String);
},
),
);
}).toList(),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Payment Date
const Text(
'Payment Date',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
InkWell( InkWell(
onTap: () async { onTap: () async {
final d = await showDatePicker( final d = await showDatePicker(
@@ -150,43 +337,85 @@ class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
); );
if (d != null) setState(() => _paymentDate = d); if (d != null) setState(() => _paymentDate = d);
}, },
child: InputDecorator( borderRadius: BorderRadius.circular(12),
decoration: const InputDecoration(labelText: 'Payment Date'), child: Container(
child: Text(DateFormat('dd MMM yyyy').format(_paymentDate)), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
decoration: BoxDecoration(
color: isDark ? Colors.white.withValues(alpha: 0.04) : Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? Colors.white12 : Colors.grey.shade300,
),
),
child: Row(
children: [
Icon(LucideIcons.calendar, size: 18, color: Colors.grey.shade600),
const SizedBox(width: 10),
Text(
formatDate.format(_paymentDate),
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
),
const Spacer(),
Icon(LucideIcons.chevronDown, size: 16, color: Colors.grey.shade500),
],
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Notes Field
PremiumTextField( PremiumTextField(
controller: _notesCtrl, controller: _notesCtrl,
labelText: 'Notes (Optional)', labelText: 'Notes / Reference (Optional)',
prefixIcon: const Icon(LucideIcons.stickyNote, size: 18),
maxLines: 2, maxLines: 2,
), ),
const SizedBox(height: 24), const SizedBox(height: 22),
// Submit Button
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 52,
child: ElevatedButton( child: ElevatedButton(
onPressed: _isLoading ? null : _savePayment, onPressed: _isLoading ? null : _savePayment,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16), backgroundColor: Colors.green.shade600,
backgroundColor: Colors.green,
foregroundColor: Colors.white, foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
), ),
child: _isLoading child: _isLoading
? const SizedBox( ? const SizedBox(
width: 24, width: 22,
height: 24, height: 22,
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Colors.white, color: Colors.white,
strokeWidth: 2, strokeWidth: 2.5,
), ),
) )
: const Text('Save Payment'), : const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.checkCircle2, size: 18),
SizedBox(width: 8),
Text(
'Confirm Payment',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 24),
], ],
), ),
), ),
),
],
),
),
),
),
); );
} }
} }

View File

@@ -29,8 +29,12 @@ String _resolveImageUrl(String path) {
if (path.startsWith('http://') || path.startsWith('https://')) { if (path.startsWith('http://') || path.startsWith('https://')) {
return path; return path;
} }
final baseUrl = DioClient().dio.options.baseUrl; final base = DioClient().dio.options.baseUrl;
return '$baseUrl/upload/view?path=${Uri.encodeComponent(path)}'; 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 PurchaseOrderBuilderScreen extends ConsumerStatefulWidget { class PurchaseOrderBuilderScreen extends ConsumerStatefulWidget {
@@ -138,10 +142,11 @@ class _PurchaseOrderBuilderScreenState
String? finalInvoiceUrl = _invoiceUrl; String? finalInvoiceUrl = _invoiceUrl;
if (_invoiceFile != null) { if (_invoiceFile != null) {
final bytes = await _invoiceFile!.readAsBytes();
final formData = FormData.fromMap({ final formData = FormData.fromMap({
'file': await MultipartFile.fromFile( 'file': MultipartFile.fromBytes(
_invoiceFile!.path, bytes,
filename: _invoiceFile!.name, filename: _invoiceFile!.name.isNotEmpty ? _invoiceFile!.name : 'invoice.jpg',
), ),
'type': 'PO_INVOICE', 'type': 'PO_INVOICE',
}); });
@@ -171,18 +176,24 @@ class _PurchaseOrderBuilderScreenState
for (var item in _items) { for (var item in _items) {
String? finalPhotoUrl = item.photoUrl; String? finalPhotoUrl = item.photoUrl;
if (item.localPhotoPath != null && item.photoUrl == null) { if (finalPhotoUrl == null && item.localPhotoPath != null) {
try { try {
if (!kIsWeb) {
final itemFile = File(item.localPhotoPath!); final itemFile = File(item.localPhotoPath!);
if (await itemFile.exists()) {
final bytes = await itemFile.readAsBytes();
final formData = FormData.fromMap({ final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(itemFile.path, filename: itemFile.path.split('/').last), 'file': MultipartFile.fromBytes(bytes, filename: itemFile.path.split('/').last),
'type': 'PO_ITEM_PHOTO',
}); });
final uploadResp = await DioClient().dio.post('/upload', data: formData); final uploadResp = await DioClient().dio.post('/upload', data: formData);
if (uploadResp.statusCode == 200) { if (uploadResp.statusCode == 200) {
finalPhotoUrl = uploadResp.data['url'] as String?; finalPhotoUrl = uploadResp.data['url'] as String?;
} }
}
}
} catch (e) { } catch (e) {
debugPrint('Item photo upload failed: $e'); debugPrint('Item photo upload fallback failed: $e');
} }
} }
@@ -1335,13 +1346,49 @@ class _POItemRowState extends State<_POItemRow> {
widget.onChanged(updated); widget.onChanged(updated);
} }
bool _isUploadingPhoto = false;
Future<void> _pickPhoto() async { Future<void> _pickPhoto() async {
try {
final picker = ImagePicker(); final picker = ImagePicker();
final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 70); final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 75);
if (picked != null) { if (picked != null) {
final updated = widget.item.copyWith(localPhotoPath: picked.path); setState(() => _isUploadingPhoto = true);
final bytes = await picked.readAsBytes();
final fileName = picked.name.isNotEmpty
? picked.name
: 'photo_${DateTime.now().millisecondsSinceEpoch}.jpg';
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: fileName),
'type': 'PO_ITEM_PHOTO',
});
final uploadResp = await DioClient().dio.post('/upload', data: formData);
String? serverUrl;
if (uploadResp.statusCode == 200) {
serverUrl = uploadResp.data['url'] as String?;
}
final updated = widget.item.copyWith(
localPhotoPath: picked.path,
photoUrl: serverUrl ?? widget.item.photoUrl,
);
widget.onChanged(updated); widget.onChanged(updated);
} }
} catch (e) {
debugPrint('Error picking/uploading item photo: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to upload image: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isUploadingPhoto = false);
}
}
} }
@override @override
@@ -1358,17 +1405,8 @@ class _POItemRowState extends State<_POItemRow> {
children: [ children: [
GestureDetector( GestureDetector(
onTap: () { onTap: () {
if (widget.item.localPhotoPath != null) { if (_isUploadingPhoto) return;
Navigator.push( if (widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty) {
context,
MaterialPageRoute(
builder: (_) => AttachmentGalleryScreen(
images: [kIsWeb ? NetworkImage(widget.item.localPhotoPath!) : FileImage(File(widget.item.localPhotoPath!))],
initialIndex: 0,
),
),
);
} else if (widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -1378,11 +1416,25 @@ class _POItemRowState extends State<_POItemRow> {
), ),
), ),
); );
} else if (widget.item.localPhotoPath != null && widget.item.localPhotoPath!.isNotEmpty) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AttachmentGalleryScreen(
images: [
kIsWeb
? NetworkImage(widget.item.localPhotoPath!)
: FileImage(File(widget.item.localPhotoPath!)) as ImageProvider,
],
initialIndex: 0,
),
),
);
} else { } else {
_pickPhoto(); _pickPhoto();
} }
}, },
onLongPress: _pickPhoto, onLongPress: _isUploadingPhoto ? null : _pickPhoto,
child: Container( child: Container(
width: 52, width: 52,
height: 52, height: 52,
@@ -1394,21 +1446,36 @@ class _POItemRowState extends State<_POItemRow> {
child: Stack( child: Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
child: widget.item.localPhotoPath != null child: _isUploadingPhoto
? const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(7),
child: Image.network(
_resolveImageUrl(widget.item.photoUrl!),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(LucideIcons.imageOff, color: Colors.grey, size: 22),
),
)
: widget.item.localPhotoPath != null && widget.item.localPhotoPath!.isNotEmpty
? ClipRRect( ? ClipRRect(
borderRadius: BorderRadius.circular(7), borderRadius: BorderRadius.circular(7),
child: kIsWeb child: kIsWeb
? Image.network(widget.item.localPhotoPath!, fit: BoxFit.cover) ? Image.network(widget.item.localPhotoPath!, fit: BoxFit.cover)
: Image.file(File(widget.item.localPhotoPath!), fit: BoxFit.cover), : Image.file(File(widget.item.localPhotoPath!), fit: BoxFit.cover),
) )
: widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(7),
child: Image.network(_resolveImageUrl(widget.item.photoUrl!), fit: BoxFit.cover),
)
: const Center(child: Icon(LucideIcons.camera, color: Colors.grey, size: 22)), : const Center(child: Icon(LucideIcons.camera, color: Colors.grey, size: 22)),
), ),
if (widget.item.localPhotoPath != null || (widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty)) if (!_isUploadingPhoto &&
((widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty) ||
(widget.item.localPhotoPath != null && widget.item.localPhotoPath!.isNotEmpty)))
Positioned( Positioned(
bottom: 2, bottom: 2,
right: 2, right: 2,