diff --git a/kifi-api/src/main/java/com/kifi/api/service/inventory/InventoryItemService.java b/kifi-api/src/main/java/com/kifi/api/service/inventory/InventoryItemService.java index 19fd8b7..1d04d16 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/inventory/InventoryItemService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/inventory/InventoryItemService.java @@ -36,26 +36,26 @@ public class InventoryItemService { return inventoryItemRepository.findById(itemId) .filter(item -> item.getUserId().equals(userId)) .flatMap(existingItem -> { - existingItem.setTagNumber(updatedItem.getTagNumber()); - existingItem.setSku(updatedItem.getSku()); - existingItem.setHuid(updatedItem.getHuid()); - existingItem.setPurity(updatedItem.getPurity()); - existingItem.setGrossWeight(updatedItem.getGrossWeight()); - existingItem.setNetWeight(updatedItem.getNetWeight()); - existingItem.setStoneWeight(updatedItem.getStoneWeight()); - existingItem.setDiamondWeight(updatedItem.getDiamondWeight()); - existingItem.setFineWeight(updatedItem.getFineWeight()); - existingItem.setMakingCharges(updatedItem.getMakingCharges()); - existingItem.setMakingChargeType(updatedItem.getMakingChargeType()); - existingItem.setPurchaseCost(updatedItem.getPurchaseCost()); - existingItem.setMetalCost(updatedItem.getMetalCost()); - existingItem.setStoneCost(updatedItem.getStoneCost()); - existingItem.setCertificationCost(updatedItem.getCertificationCost()); - existingItem.setTax(updatedItem.getTax()); - existingItem.setVendorId(updatedItem.getVendorId()); - existingItem.setBranchId(updatedItem.getBranchId()); - existingItem.setPurchaseRef(updatedItem.getPurchaseRef()); - existingItem.setStatus(updatedItem.getStatus()); + if (updatedItem.getTagNumber() != null) existingItem.setTagNumber(updatedItem.getTagNumber()); + if (updatedItem.getSku() != null) existingItem.setSku(updatedItem.getSku()); + if (updatedItem.getHuid() != null) existingItem.setHuid(updatedItem.getHuid()); + if (updatedItem.getPurity() != null) existingItem.setPurity(updatedItem.getPurity()); + if (updatedItem.getGrossWeight() != null) existingItem.setGrossWeight(updatedItem.getGrossWeight()); + if (updatedItem.getNetWeight() != null) existingItem.setNetWeight(updatedItem.getNetWeight()); + if (updatedItem.getStoneWeight() != null) existingItem.setStoneWeight(updatedItem.getStoneWeight()); + if (updatedItem.getDiamondWeight() != null) existingItem.setDiamondWeight(updatedItem.getDiamondWeight()); + if (updatedItem.getFineWeight() != null) existingItem.setFineWeight(updatedItem.getFineWeight()); + if (updatedItem.getMakingCharges() != null) existingItem.setMakingCharges(updatedItem.getMakingCharges()); + if (updatedItem.getMakingChargeType() != null) existingItem.setMakingChargeType(updatedItem.getMakingChargeType()); + if (updatedItem.getPurchaseCost() != null) existingItem.setPurchaseCost(updatedItem.getPurchaseCost()); + if (updatedItem.getMetalCost() != null) existingItem.setMetalCost(updatedItem.getMetalCost()); + if (updatedItem.getStoneCost() != null) existingItem.setStoneCost(updatedItem.getStoneCost()); + if (updatedItem.getCertificationCost() != null) existingItem.setCertificationCost(updatedItem.getCertificationCost()); + if (updatedItem.getTax() != null) existingItem.setTax(updatedItem.getTax()); + if (updatedItem.getVendorId() != null) existingItem.setVendorId(updatedItem.getVendorId()); + if (updatedItem.getBranchId() != null) existingItem.setBranchId(updatedItem.getBranchId()); + if (updatedItem.getPurchaseRef() != null) existingItem.setPurchaseRef(updatedItem.getPurchaseRef()); + if (updatedItem.getStatus() != null) existingItem.setStatus(updatedItem.getStatus()); existingItem.setUpdatedAt(LocalDateTime.now()); return inventoryItemRepository.save(existingItem); }); diff --git a/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java b/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java index d01ab8d..f671d2d 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java @@ -66,17 +66,16 @@ public class InvoiceService { invoice.setUserId(userId); invoice.setCreatedAt(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 total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : java.math.BigDecimal.ZERO; - - if (amountPaid.compareTo(total) >= 0 && total.compareTo(java.math.BigDecimal.ZERO) > 0) { - invoice.setStatus("PAID"); - } else if (amountPaid.compareTo(java.math.BigDecimal.ZERO) > 0) { - invoice.setStatus("PARTIAL"); - } else { - invoice.setStatus("DRAFT"); - } + 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 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"); + } else if (amountPaid.compareTo(java.math.BigDecimal.ZERO) > 0) { + invoice.setStatus("PARTIAL"); + } else if (invoice.getStatus() == null || invoice.getStatus().isEmpty()) { + invoice.setStatus("DRAFT"); } return invoiceRepository.save(invoice) diff --git a/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart b/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart index 239dc64..60db8cb 100644 --- a/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart +++ b/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart @@ -4,7 +4,6 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../../core/theme/nature_colors.dart'; import '../../../inventory/presentation/product_list_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 '../../../sales/presentation/customers_list_screen.dart'; import '../../../sales/presentation/invoices_list_screen.dart'; @@ -60,12 +59,12 @@ class BusinessHubScreen extends ConsumerWidget { child: Container( padding: const EdgeInsets.all(16), 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), border: Border.all( color: Theme.of( context, - ).colorScheme.primary.withOpacity(0.2), + ).colorScheme.primary.withValues(alpha: 0.2), ), ), child: Row( @@ -97,124 +96,140 @@ class BusinessHubScreen extends ConsumerWidget { ), ), const SizedBox(height: 24), - GridView.count( - crossAxisCount: 2, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - childAspectRatio: 1.2, - children: [ - if (showInventory) ...[ - _buildActionCard( - context, - 'Categories', - 'Manage catalog structure', - LucideIcons.listTree, - Colors.teal, - () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const CategoryManagementScreen(), + LayoutBuilder( + 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, + mainAxisSpacing: 16, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + childAspectRatio: childAspectRatio, + children: [ + if (showInventory) ...[ + _buildActionCard( + context, + 'Categories', + 'Manage catalog structure', + LucideIcons.listTree, + Colors.teal, + () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CategoryManagementScreen(), + ), + ), + isDesktop: isDesktop, ), - ), - ), - _buildActionCard( - context, - 'Products Catalog', - 'View all products', - LucideIcons.packageSearch, - Colors.blue, - () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const ProductListScreen(), + _buildActionCard( + context, + 'Products Catalog', + 'View all products', + LucideIcons.packageSearch, + Colors.blue, + () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ProductListScreen(), + ), + ), + isDesktop: isDesktop, ), - ), - ), - ], - if (showSales) - _buildActionCard( - context, - 'Customers', - 'Manage clients', - LucideIcons.users, - NatureColors.getColor('RECEIVABLES'), - () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const CustomersListScreen(), + ], + if (showSales) + _buildActionCard( + context, + 'Customers', + 'Manage clients', + LucideIcons.users, + NatureColors.getColor('RECEIVABLES'), + () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CustomersListScreen(), + ), + ), + isDesktop: isDesktop, ), - ), - ), - if (showPurchase) - _buildActionCard( - context, - 'Vendors', - 'Manage suppliers', - LucideIcons.truck, - Colors.orange, - () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const VendorsListScreen(), + if (showPurchase) + _buildActionCard( + context, + 'Vendors', + 'Manage suppliers', + LucideIcons.truck, + Colors.orange, + () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const VendorsListScreen(), + ), + ), + isDesktop: isDesktop, ), - ), - ), - if (showPurchase) - _buildActionCard( - context, - 'Purchases', - 'Manage inward stock', - LucideIcons.clipboardList, - Colors.deepOrange, - () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const PurchaseOrdersListScreen(), + if (showPurchase) + _buildActionCard( + context, + 'Purchases', + 'Manage inward stock', + LucideIcons.clipboardList, + Colors.deepOrange, + () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const PurchaseOrdersListScreen(), + ), + ), + isDesktop: isDesktop, ), - ), - ), - if (showSales) - _buildActionCard( - context, - 'Sales & Invoices', - 'Create invoices', - LucideIcons.receipt, - Colors.green, - () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const InvoicesListScreen(), + if (showSales) + _buildActionCard( + context, + 'Sales & Invoices', + 'Create invoices', + LucideIcons.receipt, + Colors.green, + () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const InvoicesListScreen(), + ), + ), + isDesktop: isDesktop, ), - ), - ), - if (showInventory) - _buildActionCard( - context, - 'Daily Rates', - 'Sync live gold/silver rates', - LucideIcons.refreshCw, - Colors.orange, - () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const DailyRatesScreen(), + if (showInventory) + _buildActionCard( + context, + 'Daily Rates', + 'Sync live gold/silver rates', + LucideIcons.refreshCw, + Colors.amber.shade800, + () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const DailyRatesScreen(), + ), + ), + isDesktop: isDesktop, ), + _buildActionCard( + context, + 'Reports', + 'Analytics & Valuation', + LucideIcons.barChart2, + Colors.indigo, + () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ReportsScreen()), + ), + isDesktop: isDesktop, ), - ), - _buildActionCard( - context, - 'Reports', - 'Analytics & Valuation', - LucideIcons.barChart2, - Colors.indigo, - () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const ReportsScreen()), - ), - ), - ], + ], + ); + }, ), if (showInventory) ...[ const SizedBox(height: 32), @@ -240,7 +255,7 @@ class BusinessHubScreen extends ConsumerWidget { return Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.05), + color: Colors.grey.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(16), ), child: const Center( @@ -267,13 +282,13 @@ class BusinessHubScreen extends ConsumerWidget { padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: isOutOfStock - ? Colors.red.withOpacity(0.05) - : Colors.orange.withOpacity(0.05), + ? Colors.red.withValues(alpha: 0.05) + : Colors.orange.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(12), border: Border.all( color: isOutOfStock - ? Colors.red.withOpacity(0.2) - : Colors.orange.withOpacity(0.2), + ? Colors.red.withValues(alpha: 0.2) + : Colors.orange.withValues(alpha: 0.2), ), ), child: Row( @@ -282,8 +297,8 @@ class BusinessHubScreen extends ConsumerWidget { padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: isOutOfStock - ? Colors.red.withOpacity(0.1) - : Colors.orange.withOpacity(0.1), + ? Colors.red.withValues(alpha: 0.1) + : Colors.orange.withValues(alpha: 0.1), shape: BoxShape.circle, ), child: Icon( @@ -365,38 +380,101 @@ class BusinessHubScreen extends ConsumerWidget { String subtitle, IconData icon, Color color, - VoidCallback onTap, - ) { - return GestureDetector( + VoidCallback onTap, { + bool isDesktop = false, + }) { + return InkWell( onTap: onTap, + borderRadius: BorderRadius.circular(16), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: color.withOpacity(0.1), + color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(16), - border: Border.all(color: color.withOpacity(0.2)), + border: Border.all(color: color.withValues(alpha: 0.2)), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, color: color, size: 28), - const SizedBox(height: 12), - Text( - title, - style: TextStyle( - color: color, - fontWeight: FontWeight.bold, - fontSize: 16, + child: isDesktop + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + 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: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + 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), + Text( + subtitle, + style: TextStyle( + color: color.withValues(alpha: 0.7), + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: TextStyle(color: color.withOpacity(0.7), fontSize: 12), - ), - ], - ), ), ); } diff --git a/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart b/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart index 56e8cfe..4a4620f 100644 --- a/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart +++ b/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart @@ -24,6 +24,7 @@ class StockLedgerTab extends ConsumerStatefulWidget { class _StockLedgerTabState extends ConsumerState { List? _items; bool _isLoading = true; + String _selectedFilter = 'IN_STOCK'; // 'IN_STOCK', 'SOLD', 'ALL' @override void initState() { @@ -61,7 +62,19 @@ class _StockLedgerTabState extends ConsumerState { } 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 displayItems; + if (_selectedFilter == 'IN_STOCK') { + displayItems = inStockItems; + } else if (_selectedFilter == 'SOLD') { + displayItems = soldItems; + } else { + displayItems = _items!; } final categoriesState = ref.watch(productCategoriesProvider); @@ -86,235 +99,324 @@ class _StockLedgerTabState extends ConsumerState { final double currentRate = commodityRate > 0 ? (commodityRate * purityFactor) : (category?.dailyRate ?? 0.0); - return RefreshIndicator( - onRefresh: _fetchLedger, - child: ListView.builder( - padding: const EdgeInsets.all(16.0), - itemCount: _items!.length, - itemBuilder: (context, index) { - final item = _items![index]; - final weight = item.grossWeight ?? 0.0; - final purchaseRate = item.purchaseCost ?? 0.0; - final purchasePrice = weight * purchaseRate; - final currentPrice = weight * currentRate; - final gainLoss = currentPrice - purchasePrice; - final isGain = gainLoss >= 0; + 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, + child: ListView.builder( + padding: const EdgeInsets.all(16.0), + itemCount: displayItems.length, + itemBuilder: (context, index) { + final item = displayItems[index]; + final isSold = item.status == 'SOLD'; + final purchaseOrdersState = ref.watch(purchaseOrdersProvider); + final purchaseOrders = purchaseOrdersState.value ?? []; - return Card( - elevation: 0, - margin: const EdgeInsets.only(bottom: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: Colors.grey.withValues(alpha: 0.3)), - ), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: Colors.grey.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: FutureBuilder( - future: DioClient().storage.read(key: 'jwt_token'), - builder: (context, snapshot) { - if (snapshot.hasData && widget.product.imageIds.isNotEmpty) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product.id}/images/${widget.product.imageIds.first}/content', - fit: BoxFit.cover, - headers: {'Authorization': 'Bearer ${snapshot.data}'}, - ), - ); + 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); } - return const Icon(LucideIcons.image, color: Colors.grey, size: 20); - }, + if (purchaseRate == 0.0) { + purchaseRate = poItem.unitPrice; + } + } + } + } + + final purchasePrice = weight * purchaseRate; + final currentPrice = weight * currentRate; + final gainLoss = currentPrice - purchasePrice; + final isGain = gainLoss >= 0; + + return Card( + elevation: 0, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: isSold + ? Colors.grey.withValues(alpha: 0.2) + : Colors.grey.withValues(alpha: 0.3), + ), ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.createdAt != null - ? DateFormat('dd MMM yyyy').format(item.createdAt!) - : '', - style: TextStyle(color: Colors.grey[600], fontSize: 12), - ), - const SizedBox(height: 4), - if (item.purchaseRef != null) - InkWell( - onTap: () async { - try { - var pos = ref.read(purchaseOrdersProvider).value; - if (pos == null || pos.isEmpty) { - pos = await ref.read(purchaseOrdersProvider.future); - } - final po = pos!.firstWhere( - (p) => p.poNumber == item.purchaseRef, - orElse: () => throw Exception('Purchase invoice not found'), - ); - if (mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PurchaseOrderDetailsScreen(po: po), - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Could not open purchase invoice: $e')), - ); - } - } - }, - child: Text( - 'INV: ${item.purchaseRef}', - style: const TextStyle( - color: Colors.blue, - decoration: TextDecoration.underline, - fontWeight: FontWeight.w600, + color: isSold ? Colors.grey.withValues(alpha: 0.03) : null, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: FutureBuilder( + future: DioClient().storage.read(key: 'jwt_token'), + builder: (context, snapshot) { + if (snapshot.hasData && widget.product.imageIds.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product.id}/images/${widget.product.imageIds.first}/content', + fit: BoxFit.cover, + headers: {'Authorization': 'Bearer ${snapshot.data}'}, + ), + ); + } + return const Icon(LucideIcons.image, color: Colors.grey, size: 20); + }, + ), ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.createdAt != null + ? DateFormat('dd MMM yyyy').format(item.createdAt!) + : '', + style: TextStyle(color: Colors.grey[600], fontSize: 12), + ), + const SizedBox(height: 4), + if (item.purchaseRef != null) + InkWell( + onTap: () async { + try { + var pos = ref.read(purchaseOrdersProvider).value; + if (pos == null || pos.isEmpty) { + pos = await ref.read(purchaseOrdersProvider.future); + } + final po = pos!.firstWhere( + (p) => p.poNumber == item.purchaseRef, + orElse: () => throw Exception('Purchase invoice not found'), + ); + if (mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PurchaseOrderDetailsScreen(po: po), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not open purchase invoice: $e')), + ); + } + } + }, + child: Text( + 'INV: ${item.purchaseRef}', + style: const TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + fontWeight: FontWeight.w600, + ), + ), + ), + if (item.huid != null && item.huid!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text('HUID: ${item.huid}', style: const TextStyle(fontWeight: FontWeight.w500)), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isSold + ? Colors.grey.withValues(alpha: 0.15) + : Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + isSold ? 'SOLD' : 'IN STOCK', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: isSold ? Colors.grey.shade700 : Colors.green, + ), + ), + ), + const SizedBox(height: 4), + Text( + '${weight.toStringAsFixed(3)} $unit', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: isSold ? Colors.grey.shade600 : null, + ), + ), + const SizedBox(height: 3), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), + ), + child: Text( + 'Purity: ${formatPurity(double.tryParse(item.purity ?? '') ?? purityFactor)}', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.amber.shade900, + ), + ), + ), + ], + ), + ], + ), + const Divider(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Purchase Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)), + Text('₹${purchaseRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('Purchase Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)), + Text('₹${purchasePrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)), + ], + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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)), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(isSold ? 'Valuation' : 'Current Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)), + Text('₹${currentPrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)), + ], + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isGain ? Colors.green.withValues(alpha: 0.1) : Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + isGain ? LucideIcons.trendingUp : LucideIcons.trendingDown, + color: isGain ? Colors.green : Colors.red, + size: 16, + ), + const SizedBox(width: 8), + Text( + '${isSold ? "Realized " : ""}${isGain ? 'Gain' : 'Loss'}: ₹${gainLoss.abs().toStringAsFixed(2)}', + style: TextStyle( + color: isGain ? Colors.green : Colors.red, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], ), ), - if (item.huid != null && item.huid!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Text('HUID: ${item.huid}', style: const TextStyle(fontWeight: FontWeight.w500)), - ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'IN STOCK', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Colors.green, - ), - ), - ), - const SizedBox(height: 4), - Text( - '${weight.toStringAsFixed(3)} $unit', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - const SizedBox(height: 3), - Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), - decoration: BoxDecoration( - color: Colors.amber.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), - ), - child: Text( - 'Purity: ${formatPurity(double.tryParse(item.purity ?? '') ?? purityFactor)}', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Colors.amber.shade900, - ), - ), - ), - ], - ), - ], - ), - const Divider(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Purchase Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)), - Text('₹${purchaseRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)), - ], - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text('Purchase Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)), - Text('₹${purchasePrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)), - ], - ), - ], - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Current Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)), - Text('₹${currentRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)), - ], - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text('Current Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)), - Text('₹${currentPrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)), - ], - ), - ], - ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: isGain ? Colors.green.withValues(alpha: 0.1) : Colors.red.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - isGain ? LucideIcons.trendingUp : LucideIcons.trendingDown, - color: isGain ? Colors.green : Colors.red, - size: 16, - ), - const SizedBox(width: 8), - Text( - '${isGain ? 'Gain' : 'Loss'}: ₹${gainLoss.abs().toStringAsFixed(2)}', - style: TextStyle( - color: isGain ? Colors.green : Colors.red, - fontWeight: FontWeight.bold, - fontSize: 14, + ], ), ), - ], - ), + ); + }, ), - ], - ), - ), - ); - }, + ), + ), + ], + ); + } + + 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, + ), + ), ), ); } diff --git a/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart b/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart index 7a4c29f..999b008 100644 --- a/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart @@ -130,13 +130,42 @@ class _InvoiceBuilderScreenState extends ConsumerState { } Future _pickInvoiceFile() async { - final picker = ImagePicker(); - final picked = await picker.pickImage( - source: ImageSource.gallery, - imageQuality: 80, - ); - if (picked != null) { - setState(() => _invoiceFile = picked); + try { + final picker = ImagePicker(); + final picked = await picker.pickImage( + source: ImageSource.gallery, + imageQuality: 80, + ); + if (picked != null) { + setState(() { + _invoiceFile = picked; + _isLoading = true; + }); + + final bytes = await picked.readAsBytes(); + final formData = FormData.fromMap({ + 'file': MultipartFile.fromBytes( + bytes, + filename: picked.name.isNotEmpty ? picked.name : 'invoice_${DateTime.now().millisecondsSinceEpoch}.jpg', + ), + 'type': 'SALES_INVOICE', + }); + + final uploadResp = await DioClient().dio.post( + '/upload', + data: formData, + ); + + if (uploadResp.statusCode == 200) { + setState(() { + _invoiceUrl = uploadResp.data['url']; + }); + } + } + } catch (e) { + debugPrint('Error uploading sales invoice attachment: $e'); + } finally { + if (mounted) setState(() => _isLoading = false); } } @@ -280,11 +309,12 @@ class _InvoiceBuilderScreenState extends ConsumerState { try { String? finalInvoiceUrl = _invoiceUrl; - if (_invoiceFile != null) { + if (_invoiceFile != null && finalInvoiceUrl == null) { + final bytes = await _invoiceFile!.readAsBytes(); final formData = FormData.fromMap({ - 'file': await MultipartFile.fromFile( - _invoiceFile!.path, - filename: _invoiceFile!.name, + 'file': MultipartFile.fromBytes( + bytes, + filename: _invoiceFile!.name.isNotEmpty ? _invoiceFile!.name : 'invoice.jpg', ), 'type': 'SALES_INVOICE', }); @@ -366,12 +396,15 @@ class _InvoiceBuilderScreenState extends ConsumerState { } final invoiceDiscount = double.tryParse(_discountCtrl.text) ?? 0.0; - totalDiscount = invoiceDiscount; - grandTotal = (subtotal + totalMaking - invoiceDiscount).clamp(0.0, double.infinity) + totalTax; + totalDiscount = double.parse(invoiceDiscount.toStringAsFixed(2)); + final rawGrandTotal = (subtotal + totalMaking - invoiceDiscount).clamp(0.0, double.infinity) + totalTax; + grandTotal = double.parse(rawGrandTotal.toStringAsFixed(2)); final amountPaid = double.tryParse(_amountPaidCtrl.text) ?? (_isAmountPaidEdited ? 0.0 : grandTotal); - final validAmountPaid = amountPaid > grandTotal ? grandTotal : amountPaid; - final balanceDue = grandTotal - validAmountPaid; + final validAmountPaid = double.parse((amountPaid > grandTotal ? grandTotal : amountPaid).toStringAsFixed(2)); + final balanceDue = double.parse((grandTotal - validAmountPaid).clamp(0.0, double.infinity).toStringAsFixed(2)); + final isFullyPaid = balanceDue.abs() < 0.01 || validAmountPaid >= grandTotal - 0.01; + final determinedStatus = isFullyPaid ? 'PAID' : (validAmountPaid > 0.01 ? 'PARTIAL' : 'DRAFT'); final invoice = Invoice( id: widget.existingInvoice?.id, @@ -379,18 +412,20 @@ class _InvoiceBuilderScreenState extends ConsumerState { invoiceNumber: _invoiceNumberCtrl.text.trim(), issueDate: _invoiceDate, dueDate: _dueDate, - subtotal: subtotal, - taxTotal: totalTax, - cgstTotal: totalCgst, - sgstTotal: totalSgst, - igstTotal: totalIgst, + subtotal: double.parse(subtotal.toStringAsFixed(2)), + taxTotal: double.parse(totalTax.toStringAsFixed(2)), + cgstTotal: double.parse(totalCgst.toStringAsFixed(2)), + sgstTotal: double.parse(totalSgst.toStringAsFixed(2)), + igstTotal: double.parse(totalIgst.toStringAsFixed(2)), discountTotal: totalDiscount, totalAmount: grandTotal, amountPaid: validAmountPaid, paymentMethod: validAmountPaid > 0 ? _paymentMethod : null, paymentWalletId: validAmountPaid > 0 ? (_selectedWalletId ?? ref.read(walletProvider).value?.firstOrNull?.id) : null, - nextPaymentDate: balanceDue > 0 ? (_nextPaymentDate ?? DateTime.now().add(const Duration(days: 30))) : null, - status: widget.existingInvoice?.status ?? (validAmountPaid >= grandTotal ? 'PAID' : (validAmountPaid > 0 ? 'PARTIAL' : 'DRAFT')), + nextPaymentDate: balanceDue > 0.01 ? (_nextPaymentDate ?? DateTime.now().add(const Duration(days: 30))) : null, + status: widget.existingInvoice != null + ? (isFullyPaid ? 'PAID' : (validAmountPaid > 0.01 ? 'PARTIAL' : widget.existingInvoice!.status)) + : determinedStatus, notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(), invoiceUrl: finalInvoiceUrl, isEmi: _isEmi, diff --git a/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart b/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart index 4e7c716..983bb79 100644 --- a/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart @@ -9,6 +9,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; import '../../inventory/providers/products_provider.dart'; import '../../inventory/providers/product_categories_provider.dart'; import '../domain/invoice.dart'; @@ -147,15 +148,15 @@ class _InvoiceDetailsScreenState extends ConsumerState { Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( - color: _getStatusColor(latestInvoice.status).withValues(alpha: 0.15), + color: _getStatusColor(_getEffectiveStatus(latestInvoice)).withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), child: Text( - latestInvoice.status, + _getEffectiveStatus(latestInvoice), style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, - color: _getStatusColor(latestInvoice.status), + color: _getStatusColor(_getEffectiveStatus(latestInvoice)), ), ), ), @@ -464,6 +465,17 @@ class _InvoiceDetailsScreenState extends ConsumerState { ); } + 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) { switch (status) { case 'PAID': @@ -542,60 +554,147 @@ class _InvoiceDetailsScreenState extends ConsumerState { final customer = customers.where((c) => c.id == latestInvoice.customerId).firstOrNull; 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 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> groupedItems = {}; + final List 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(); pdf.addPage( pw.MultiPage( pageFormat: PdfPageFormat.a4, + theme: pw.ThemeData.withFont( + base: font, + bold: boldFont, + ), margin: const pw.EdgeInsets.all(28), build: (pw.Context context) { return [ - // Header with Business Details & TAX INVOICE title + // 1. Header with Business Details & TAX INVOICE title pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - business?.businessName ?? 'KIFI JEWELLERS', - style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900), - ), - pw.SizedBox(height: 4), - if (business?.address != null) - pw.Text(business!.address!, style: const pw.TextStyle(fontSize: 10)), - if (business?.contactNumber != null) - pw.Text('Phone: ${business!.contactNumber}', style: const pw.TextStyle(fontSize: 10)), - if (business?.gstin != null) - pw.Text('GSTIN: ${business!.gstin}', style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.blue800)), - ], + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + business?.businessName ?? 'KIFI JEWELLERS', + style: pw.TextStyle( + fontSize: 22, + fontWeight: pw.FontWeight.bold, + color: PdfColors.blue900, + ), + ), + pw.SizedBox(height: 4), + if (business?.address != null && business!.address!.isNotEmpty) + pw.Text( + business.address!, + style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), + ), + 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( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ pw.Text( '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) - 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( 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)), ), @@ -606,12 +705,31 @@ class _InvoiceDetailsScreenState extends ConsumerState { child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, 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.Text(customer?.name ?? 'Walk-in Customer', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)), - if (customer?.phone != null) pw.Text('Phone: ${customer!.phone}', style: const pw.TextStyle(fontSize: 9)), - if (customer?.address != null) pw.Text('Address: ${customer!.address}', style: const pw.TextStyle(fontSize: 9)), - if (customer?.gstin != null) pw.Text('GSTIN: ${customer!.gstin}', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold)), + pw.Text( + customer?.name ?? 'Walk-in Customer', + style: pw.TextStyle( + 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 { ), pw.SizedBox(height: 16), - // Table of Items with HUID, SKU, Making Charges, etc. - pw.TableHelper.fromTextArray( - context: context, - border: const pw.TableBorder( - bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5), - horizontalInside: pw.BorderSide(color: PdfColors.grey300, width: 0.5), - ), - headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.white, fontSize: 9), - headerDecoration: const pw.BoxDecoration(color: PdfColors.blue900), - cellStyle: const pw.TextStyle(fontSize: 8.5), - cellAlignments: { - 0: pw.Alignment.centerLeft, - 1: pw.Alignment.centerLeft, - 2: pw.Alignment.centerRight, - 3: pw.Alignment.centerRight, - 4: pw.Alignment.centerRight, - 5: pw.Alignment.centerRight, - 6: pw.Alignment.centerRight, - }, - headers: ['Item / Particulars', 'HSN / HUID', 'Weight', 'Rate', 'Making Chg', 'GST', 'Total'], - data: latestInvoice.items.map((item) { + // 3. Product Groups with Full Details (Category, Purity, HSN, HUID, Weight, Making Charges, GST Breakup) + ...groupedItems.entries.map((entry) { + final productId = entry.key; + final group = entry.value; + final product = products.where((p) => p.id == productId).firstOrNull; + final category = categories.where((c) => c.id == product?.categoryId).firstOrNull; + + final productName = product?.name ?? group.first.productName ?? 'Item $productId'; + final categoryName = category?.name ?? group.first.categoryName ?? 'General Category'; + final skuStr = product?.sku ?? group.first.sku; + final purityStr = product?.purityFactor != null + ? '${(product!.purityFactor! * 100).toStringAsFixed(1)}%' + : (category?.purityFactor != null ? '${(category!.purityFactor! * 100).toStringAsFixed(1)}%' : '0.916'); + final hsnStr = product?.hsnCode ?? group.first.hsnCode ?? category?.defaultHsn ?? '-'; + + double groupSubtotal = 0; + double groupMaking = 0; + double groupTax = 0; + + for (final item in group) { final weight = item.weight ?? item.quantity; final metalAmt = weight * item.unitPrice; - double makingAmt = 0.0; if (item.makingChargesType == 'PERCENTAGE') { makingAmt = metalAmt * (item.makingCharge / 100.0); @@ -652,129 +768,481 @@ class _InvoiceDetailsScreenState extends ConsumerState { } else { makingAmt = weight * item.makingCharge; } + groupSubtotal += metalAmt; + groupMaking += makingAmt; + groupTax += (item.cgst + item.sgst + item.igst); + } - String particulars = item.productName ?? item.description ?? 'Item'; - if (item.sku != null && item.sku!.isNotEmpty) particulars += '\nSKU: ${item.sku}'; - if (item.categoryName != null) particulars += ' (${item.categoryName})'; + final groupTotal = group.fold(0.0, (sum, i) => sum + i.total); + final gstRate = group.first.taxRate > 0 ? group.first.taxRate : (product?.gstRate ?? (category?.defaultGst ?? 0.0)); - String hsnHuid = 'HSN: ${item.hsnCode ?? '-'}'; - if (item.huid != null && item.huid!.isNotEmpty) hsnHuid += '\nHUID: ${item.huid}'; + return pw.Container( + 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, + ), + ), + ), + ], + ), + ], + ), + ), - return [ - particulars, - hsnHuid, - '${weight.toStringAsFixed(3)} g', - formatCurrency.format(item.unitPrice), - formatCurrency.format(makingAmt), - '${item.taxRate.toStringAsFixed(1)}%', - formatCurrency.format(item.total), - ]; - }).toList(), - ), - pw.SizedBox(height: 16), + // 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'; + } - // Summary Section + 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 [ + particular, + weightQty, + '${formatCurrency.format(item.unitPrice)} $rateUnit', + makingAmt > 0 ? formatCurrency.format(makingAmt) : '-', + item.otherCharges > 0 ? formatCurrency.format(item.otherCharges) : '-', + formatCurrency.format(item.total), + ]; + }).toList(), + ), + + // Group Subtotal & GST Calculation Box + pw.Container( + 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( + crossAxisAlignment: pw.CrossAxisAlignment.stretch, + children: [ + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + '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), + ), + ], + ), + pw.SizedBox(height: 2), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + '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( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + '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( - mainAxisAlignment: pw.MainAxisAlignment.end, + crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.Container( - width: 240, + // Left Side: Notes & Declarations + pw.Expanded( + flex: 3, child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.stretch, + crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text('Metal Subtotal:', style: const pw.TextStyle(fontSize: 10)), - pw.Text(formatCurrency.format(latestInvoice.subtotal), style: const pw.TextStyle(fontSize: 10)), - ], - ), - if (latestInvoice.cgstTotal > 0 && latestInvoice.sgstTotal > 0) ...[ - pw.SizedBox(height: 2), - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text('CGST:', style: const pw.TextStyle(fontSize: 9)), - pw.Text(formatCurrency.format(latestInvoice.cgstTotal), style: const pw.TextStyle(fontSize: 9)), - ], + if (latestInvoice.notes != null && latestInvoice.notes!.isNotEmpty) ...[ + pw.Text( + 'Notes:', + style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold), ), - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text('SGST:', style: const pw.TextStyle(fontSize: 9)), - pw.Text(formatCurrency.format(latestInvoice.sgstTotal), style: const pw.TextStyle(fontSize: 9)), - ], - ), - ] else if (latestInvoice.igstTotal > 0) ...[ - pw.SizedBox(height: 2), - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text('IGST:', style: const pw.TextStyle(fontSize: 9)), - pw.Text(formatCurrency.format(latestInvoice.igstTotal), style: const pw.TextStyle(fontSize: 9)), - ], + pw.Text( + latestInvoice.notes!, + style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey800), ), + pw.SizedBox(height: 8), ], - if (latestInvoice.discountTotal > 0) ...[ - pw.SizedBox(height: 2), - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text('Discount:', style: const pw.TextStyle(fontSize: 9, color: PdfColors.green700)), - pw.Text('-${formatCurrency.format(latestInvoice.discountTotal)}', style: const pw.TextStyle(fontSize: 9, color: PdfColors.green700)), - ], - ), - ], - pw.Divider(color: PdfColors.grey400, height: 10), - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text('Grand Total:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 13)), - pw.Text(formatCurrency.format(latestInvoice.totalAmount), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 13)), - ], + pw.Text( + 'Terms & Conditions:', + style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold), ), - pw.SizedBox(height: 4), - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text('Amount Received:', style: const pw.TextStyle(fontSize: 10, color: PdfColors.green800)), - pw.Text(formatCurrency.format(latestInvoice.amountPaid), style: const pw.TextStyle(fontSize: 10, color: PdfColors.green800)), - ], + 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.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text('Balance Due:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 11, color: PdfColors.red800)), - pw.Text(formatCurrency.format(latestInvoice.totalAmount - latestInvoice.amountPaid), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 11, color: PdfColors.red800)), - ], + 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)), + ], + ), + pw.SizedBox(height: 2), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('SGST Total:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)), + 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) ...[ + pw.SizedBox(height: 4), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('Discount:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green700)), + pw.Text('- ${formatCurrency.format(latestInvoice.discountTotal)}', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green700)), + ], + ), + ], + pw.Divider(color: PdfColors.grey400, height: 10), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + '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, + ), + ), + ], + ), + if (latestInvoice.amountPaid > 0) ...[ + pw.SizedBox(height: 6), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + 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: 8.5, color: PdfColors.green800)), + ], + ), + pw.SizedBox(height: 2), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + '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 - pw.Divider(color: PdfColors.grey300), + // 5. Signatures pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.Text('Terms & Conditions:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 8)), - pw.Text('1. Goods once sold are subject to standard hallmarking certification.', style: const pw.TextStyle(fontSize: 7, color: PdfColors.grey700)), - pw.Text('2. Weight & purity tested under industry standard electronic balance.', style: const pw.TextStyle(fontSize: 7, color: PdfColors.grey700)), + pw.SizedBox(height: 25), + pw.Text( + 'Customer\'s Signature', + style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), + ), ], ), pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.center, + crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ - pw.Text('For ${business?.businessName ?? "KIFI JEWELLERS"}', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 9)), - pw.SizedBox(height: 24), - pw.Text('Authorized Signatory', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700)), + pw.SizedBox(height: 25), + pw.Text( + 'For ${business?.businessName ?? "KIFI JEWELLERS"} (Authorized Signatory)', + style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), + ), ], ), ], @@ -784,13 +1252,19 @@ class _InvoiceDetailsScreenState extends ConsumerState { ), ); - final directory = await getTemporaryDirectory(); - final pdfPath = await File('${directory.path}/TaxInvoice_$invoiceNumber.pdf').create(); - await pdfPath.writeAsBytes(await pdf.save()); + final pdfBytes = 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) { - 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')), + ); + } } } } diff --git a/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart b/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart index 7b48aa2..aa5e3d3 100644 --- a/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart @@ -5,7 +5,6 @@ import 'package:intl/intl.dart'; import '../providers/invoices_provider.dart'; import '../domain/invoice.dart'; import '../providers/customers_provider.dart'; -import '../domain/customer.dart'; import 'invoice_builder_screen.dart'; import 'invoice_details_screen.dart'; @@ -22,9 +21,12 @@ class _InvoicesListScreenState extends ConsumerState { String _getStatusLabel(Invoice invoice) { if (invoice.isEmi && invoice.status != 'PAID') return 'EMI'; - if (invoice.status == 'PAID' || invoice.amountPaid >= invoice.totalAmount && invoice.totalAmount > 0) return 'PAID'; - if (invoice.amountPaid > 0) return 'PARTIAL'; - return invoice.status; + 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(Invoice invoice) { @@ -237,12 +239,72 @@ class _InvoicesListScreenState extends ConsumerState { ), ), if (invoice.items.isNotEmpty) - Text( - '${invoice.items.length} ${invoice.items.length == 1 ? "item" : "items"}', - style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + 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"}', + 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), // Row 3: Date & Total Amount diff --git a/kifi-app/lib/features/sales/presentation/widgets/receive_payment_sheet.dart b/kifi-app/lib/features/sales/presentation/widgets/receive_payment_sheet.dart index c06ac42..215533b 100644 --- a/kifi-app/lib/features/sales/presentation/widgets/receive_payment_sheet.dart +++ b/kifi-app/lib/features/sales/presentation/widgets/receive_payment_sheet.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:intl/intl.dart'; import '../../../../core/widgets/premium_text_field.dart'; import '../../../transactions/providers/providers.dart'; import '../../providers/invoices_provider.dart'; +import '../../providers/customers_provider.dart'; import '../../domain/invoice.dart'; class ReceivePaymentSheet extends ConsumerStatefulWidget { @@ -17,20 +19,43 @@ class ReceivePaymentSheet extends ConsumerStatefulWidget { } class _ReceivePaymentSheetState extends ConsumerState { - final TextEditingController _amountCtrl = TextEditingController(); + final _formKey = GlobalKey(); + late TextEditingController _amountCtrl; String _paymentMethod = 'Cash'; int? _selectedWalletId; bool _isLoading = false; + final List> _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 void initState() { super.initState(); 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 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 allWallets = walletsState.value ?? []; @@ -49,165 +74,343 @@ class _ReceivePaymentSheetState extends ConsumerState { } return Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + 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, - top: 24, - left: 24, - right: 24, + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + top: 12, + left: 20, + right: 20, ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Receive Payment', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - IconButton( - icon: const Icon(LucideIcons.x), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 16), - PremiumTextField( - controller: _amountCtrl, - labelText: 'Amount Paid (₹)', - keyboardType: const TextInputType.numberWithOptions(decimal: true), - ), - const SizedBox(height: 16), - const Text( - 'Receive Into Wallet', - style: TextStyle(fontWeight: FontWeight.w600, color: Colors.grey), - ), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(16), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedWalletId, - isExpanded: true, - hint: const Text('Select Wallet'), - items: wallets - .map( - (w) => DropdownMenuItem(value: w.id, child: Text(w.name)), - ) - .toList(), - onChanged: (val) => setState(() => _selectedWalletId = val), - ), - ), - ), - const SizedBox(height: 16), - const Text( - 'Payment Method', - style: TextStyle(fontWeight: FontWeight.w600, color: Colors.grey), - ), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(16), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - 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( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + child: SafeArea( + top: false, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag Handle + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: isDark ? Colors.white24 : Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), ), - ), - onPressed: _isLoading - ? null - : () async { - final amount = double.tryParse(_amountCtrl.text) ?? 0; - if (amount <= 0) return; + const SizedBox(height: 14), - setState(() => _isLoading = true); - try { - await ref - .read(invoicesProvider.notifier) - .addPayment( - widget.invoice.id!, - InvoicePayment( - amount: amount, - paymentMethod: _paymentMethod, - walletId: _selectedWalletId, - ), - ); - if (mounted) { - Navigator.pop( - context, - true, - ); // true indicates success - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Payment Received Successfully!'), - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(e.toString()))); - setState(() => _isLoading = false); - } - } - }, - child: _isLoading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + 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( + 'Receive Payment', + style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold), + ), + ], + ), + IconButton( + icon: const Icon(LucideIcons.x, size: 20), + style: IconButton.styleFrom( + backgroundColor: isDark ? Colors.white10 : Colors.grey.shade100, ), - ) - : const Text( - 'Confirm Payment', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, + onPressed: () => Navigator.pop(context), + ), + ], + ), + 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( + controller: _amountCtrl, + labelText: 'Amount Paid (₹)', + 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), + + // 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( + 'Receive Into Wallet', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), + 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: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedWalletId, + isExpanded: true, + dropdownColor: isDark ? const Color(0xFF1E293B) : Colors.white, + hint: const Text('Select Wallet'), + items: wallets + .map( + (w) => DropdownMenuItem(value: w.id, child: Text(w.name)), + ) + .toList(), + onChanged: (val) => setState(() => _selectedWalletId = val), + ), + ), + ), + const SizedBox(height: 22), + ] else + const SizedBox(height: 8), + + // Submit Button + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade600, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: _isLoading + ? null + : () async { + if (!_formKey.currentState!.validate()) return; + final amount = double.tryParse(_amountCtrl.text) ?? 0; + if (amount <= 0) return; + + setState(() => _isLoading = true); + try { + await ref + .read(invoicesProvider.notifier) + .addPayment( + widget.invoice.id!, + InvoicePayment( + amount: amount, + paymentMethod: _paymentMethod, + walletId: _selectedWalletId, + ), + ); + if (mounted) { + Navigator.pop( + context, + true, + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Payment of ₹${amount.toStringAsFixed(2)} received successfully!'), + backgroundColor: Colors.green.shade700, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(e.toString()))); + setState(() => _isLoading = false); + } + } + }, + child: _isLoading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ) + : 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), - ], + ), ), ); } diff --git a/kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart b/kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart index cd5fd4f..6c671a6 100644 --- a/kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart +++ b/kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart @@ -114,10 +114,11 @@ class _AddVendorSheetState extends ConsumerState { try { String? photoUrl = widget.vendor?.photoUrl; if (_photo != null && _token != null) { + final bytes = await _photo!.readAsBytes(); final formData = FormData.fromMap({ - 'file': await MultipartFile.fromFile( - _photo!.path, - filename: _photo!.name, + 'file': MultipartFile.fromBytes( + bytes, + filename: _photo!.name.isNotEmpty ? _photo!.name : 'vendor.jpg', ), 'type': 'VENDOR', }); diff --git a/kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart b/kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart index 3971b2c..e134b19 100644 --- a/kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart +++ b/kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart @@ -3,10 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:intl/intl.dart'; import '../../../core/widgets/premium_text_field.dart'; -import '../../../core/widgets/smart_search_dropdown.dart'; import '../domain/purchase_order.dart'; import '../domain/purchase_payment.dart'; import '../providers/purchase_orders_provider.dart'; +import '../providers/vendors_provider.dart'; class PayVendorSheet extends ConsumerStatefulWidget { final PurchaseOrder po; @@ -22,15 +22,15 @@ class _PayVendorSheetState extends ConsumerState { late TextEditingController _amountCtrl; final TextEditingController _notesCtrl = TextEditingController(); DateTime _paymentDate = DateTime.now(); - String? _selectedMethod; + String _selectedMethod = 'CASH'; bool _isLoading = false; - final List _paymentMethods = [ - 'CASH', - 'BANK_TRANSFER', - 'UPI', - 'CARD', - 'CHEQUE', + final List> _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}, + {'key': 'CHEQUE', 'label': 'Cheque', 'icon': LucideIcons.fileText}, ]; @override @@ -38,7 +38,7 @@ class _PayVendorSheetState extends ConsumerState { super.initState(); final balance = widget.po.totalAmount - widget.po.amountPaid; _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 { Future _savePayment() async { if (!_formKey.currentState!.validate()) return; - if (_selectedMethod == null) { + + final amount = double.tryParse(_amountCtrl.text); + if (amount == null || amount <= 0) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please select a payment method')), + const SnackBar(content: Text('Please enter a valid amount')), ); return; } @@ -61,10 +63,10 @@ class _PayVendorSheetState extends ConsumerState { setState(() => _isLoading = true); try { final payment = PurchasePayment( - amount: double.parse(_amountCtrl.text), + amount: amount, paymentMethod: _selectedMethod, paymentDate: _paymentDate, - notes: _notesCtrl.text.isEmpty ? null : _notesCtrl.text, + notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(), ); await ref @@ -74,14 +76,17 @@ class _PayVendorSheetState extends ConsumerState { if (mounted) { Navigator.pop(context, true); 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) { if (mounted) { ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); + ).showSnackBar(SnackBar(content: Text('Error recording payment: $e'))); } } finally { if (mounted) setState(() => _isLoading = false); @@ -90,101 +95,325 @@ class _PayVendorSheetState extends ConsumerState { @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - left: 24, - right: 24, - top: 24, + final isDark = Theme.of(context).brightness == Brightness.dark; + final formatCurrency = NumberFormat.currency(symbol: '₹'); + final formatDate = DateFormat('dd MMM yyyy'); + + final balanceDue = (widget.po.totalAmount - widget.po.amountPaid).clamp(0.0, double.infinity); + 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), + ), + ], ), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + left: 20, + right: 20, + top: 12, + ), + child: SafeArea( + top: false, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Record Payment', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + // Drag Handle + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: isDark ? Colors.white24 : Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), ), - IconButton( - icon: const Icon(LucideIcons.x), - onPressed: () => Navigator.pop(context), + const SizedBox(height: 14), + + // Title & Close Button + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + 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( + 'Record Payment', + style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold), + ), + ], + ), + IconButton( + icon: const Icon(LucideIcons.x, size: 20), + style: IconButton.styleFrom( + backgroundColor: isDark ? Colors.white10 : Colors.grey.shade100, + ), + onPressed: () => Navigator.pop(context), + ), + ], + ), + 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( + controller: _amountCtrl, + labelText: 'Amount Paid (₹)', + 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), + + // 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 = _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), + + // Payment Date + const Text( + 'Payment Date', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + InkWell( + onTap: () async { + final d = await showDatePicker( + context: context, + initialDate: _paymentDate, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (d != null) setState(() => _paymentDate = d); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + 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), + + // Notes Field + PremiumTextField( + controller: _notesCtrl, + labelText: 'Notes / Reference (Optional)', + prefixIcon: const Icon(LucideIcons.stickyNote, size: 18), + maxLines: 2, + ), + const SizedBox(height: 22), + + // Submit Button + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _isLoading ? null : _savePayment, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green.shade600, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: _isLoading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ) + : 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: 16), - PremiumTextField( - controller: _amountCtrl, - labelText: 'Amount Paid', - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - prefixIcon: const Icon(LucideIcons.indianRupee), - validator: (val) { - if (val == null || val.isEmpty) return 'Required'; - if (double.tryParse(val) == null) return 'Invalid amount'; - return null; - }, - ), - const SizedBox(height: 16), - SmartSearchDropdown( - labelText: 'Payment Method', - hintText: 'Select Method', - value: _selectedMethod, - items: _paymentMethods, - itemAsString: (val) => val.replaceAll('_', ' '), - onChanged: (val) => setState(() => _selectedMethod = val), - ), - const SizedBox(height: 16), - InkWell( - onTap: () async { - final d = await showDatePicker( - context: context, - initialDate: _paymentDate, - firstDate: DateTime(2000), - lastDate: DateTime(2100), - ); - if (d != null) setState(() => _paymentDate = d); - }, - child: InputDecorator( - decoration: const InputDecoration(labelText: 'Payment Date'), - child: Text(DateFormat('dd MMM yyyy').format(_paymentDate)), - ), - ), - const SizedBox(height: 16), - PremiumTextField( - controller: _notesCtrl, - labelText: 'Notes (Optional)', - maxLines: 2, - ), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _isLoading ? null : _savePayment, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - backgroundColor: Colors.green, - foregroundColor: Colors.white, - ), - child: _isLoading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) - : const Text('Save Payment'), - ), - ), - const SizedBox(height: 24), - ], + ), ), ), ); diff --git a/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart b/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart index 78882f3..01e9417 100644 --- a/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart +++ b/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart @@ -29,8 +29,12 @@ String _resolveImageUrl(String path) { if (path.startsWith('http://') || path.startsWith('https://')) { return path; } - final baseUrl = DioClient().dio.options.baseUrl; - return '$baseUrl/upload/view?path=${Uri.encodeComponent(path)}'; + final base = DioClient().dio.options.baseUrl; + final cleanPath = path.startsWith('/') ? path.substring(1) : path; + if (cleanPath.startsWith('api/kifi-v2/upload/view') || cleanPath.startsWith('upload/view')) { + return '$base/${cleanPath.replaceFirst('api/kifi-v2/', '')}'; + } + return '$base/upload/view?path=${Uri.encodeComponent(cleanPath)}'; } class PurchaseOrderBuilderScreen extends ConsumerStatefulWidget { @@ -138,10 +142,11 @@ class _PurchaseOrderBuilderScreenState String? finalInvoiceUrl = _invoiceUrl; if (_invoiceFile != null) { + final bytes = await _invoiceFile!.readAsBytes(); final formData = FormData.fromMap({ - 'file': await MultipartFile.fromFile( - _invoiceFile!.path, - filename: _invoiceFile!.name, + 'file': MultipartFile.fromBytes( + bytes, + filename: _invoiceFile!.name.isNotEmpty ? _invoiceFile!.name : 'invoice.jpg', ), 'type': 'PO_INVOICE', }); @@ -171,18 +176,24 @@ class _PurchaseOrderBuilderScreenState for (var item in _items) { String? finalPhotoUrl = item.photoUrl; - if (item.localPhotoPath != null && item.photoUrl == null) { + if (finalPhotoUrl == null && item.localPhotoPath != null) { try { - final itemFile = File(item.localPhotoPath!); - final formData = FormData.fromMap({ - 'file': await MultipartFile.fromFile(itemFile.path, filename: itemFile.path.split('/').last), - }); - final uploadResp = await DioClient().dio.post('/upload', data: formData); - if (uploadResp.statusCode == 200) { - finalPhotoUrl = uploadResp.data['url'] as String?; + if (!kIsWeb) { + final itemFile = File(item.localPhotoPath!); + if (await itemFile.exists()) { + final bytes = await itemFile.readAsBytes(); + final formData = FormData.fromMap({ + 'file': MultipartFile.fromBytes(bytes, filename: itemFile.path.split('/').last), + 'type': 'PO_ITEM_PHOTO', + }); + final uploadResp = await DioClient().dio.post('/upload', data: formData); + if (uploadResp.statusCode == 200) { + finalPhotoUrl = uploadResp.data['url'] as String?; + } + } } } catch (e) { - debugPrint('Item photo upload failed: $e'); + debugPrint('Item photo upload fallback failed: $e'); } } @@ -1335,12 +1346,48 @@ class _POItemRowState extends State<_POItemRow> { widget.onChanged(updated); } + bool _isUploadingPhoto = false; + Future _pickPhoto() async { - final picker = ImagePicker(); - final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 70); - if (picked != null) { - final updated = widget.item.copyWith(localPhotoPath: picked.path); - widget.onChanged(updated); + try { + final picker = ImagePicker(); + final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 75); + if (picked != null) { + 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); + } + } 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); + } } } @@ -1358,17 +1405,8 @@ class _POItemRowState extends State<_POItemRow> { children: [ GestureDetector( onTap: () { - if (widget.item.localPhotoPath != null) { - Navigator.push( - 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) { + if (_isUploadingPhoto) return; + if (widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty) { Navigator.push( context, 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 { _pickPhoto(); } }, - onLongPress: _pickPhoto, + onLongPress: _isUploadingPhoto ? null : _pickPhoto, child: Container( width: 52, height: 52, @@ -1394,21 +1446,36 @@ class _POItemRowState extends State<_POItemRow> { child: Stack( children: [ Positioned.fill( - child: widget.item.localPhotoPath != null - ? ClipRRect( - borderRadius: BorderRadius.circular(7), - child: kIsWeb - ? Image.network(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), + child: _isUploadingPhoto + ? const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), ) - : const Center(child: Icon(LucideIcons.camera, color: Colors.grey, size: 22)), + : 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( + borderRadius: BorderRadius.circular(7), + child: kIsWeb + ? Image.network(widget.item.localPhotoPath!, fit: BoxFit.cover) + : Image.file(File(widget.item.localPhotoPath!), fit: BoxFit.cover), + ) + : 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( bottom: 2, right: 2, diff --git a/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart b/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart index 43a665e..3306eec 100644 --- a/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart +++ b/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart @@ -1,10 +1,9 @@ -import 'dart:io'; +import 'dart:io' show File; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:intl/intl.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:share_plus/share_plus.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:printing/printing.dart'; @@ -67,8 +66,12 @@ class _PurchaseOrderDetailsScreenState if (path.startsWith('http://') || path.startsWith('https://')) { return path; } - final baseUrl = DioClient().dio.options.baseUrl; - return '$baseUrl/upload/view?path=${Uri.encodeComponent(path)}'; + final base = DioClient().dio.options.baseUrl; + final cleanPath = path.startsWith('/') ? path.substring(1) : path; + if (cleanPath.startsWith('api/kifi-v2/upload/view') || cleanPath.startsWith('upload/view')) { + return '$base/${cleanPath.replaceFirst('api/kifi-v2/', '')}'; + } + return '$base/upload/view?path=${Uri.encodeComponent(cleanPath)}'; } void _showPaymentSheet( @@ -201,14 +204,29 @@ class _PurchaseOrderDetailsScreenState dynamic vendor, dynamic business, List products, + List categories, ) async { try { - final formatCurrency = NumberFormat.currency(symbol: 'Rs. '); + final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2); final formatDate = DateFormat('dd MMM yyyy'); final font = await PdfGoogleFonts.robotoRegular(); final boldFont = await PdfGoogleFonts.robotoBold(); final pdf = pw.Document(); + final businessStateId = business?.stateId; + final vendorStateId = vendor?.stateId; + final isSameState = businessStateId != null && vendorStateId != null + ? (businessStateId == vendorStateId) + : true; + + // Group items by product ID + final Map> groupedItems = {}; + for (final item in po.items) { + if (item.productId != null) { + groupedItems.putIfAbsent(item.productId!, () => []).add(item); + } + } + pdf.addPage( pw.MultiPage( pageFormat: PdfPageFormat.a4, @@ -216,42 +234,48 @@ class _PurchaseOrderDetailsScreenState base: font, bold: boldFont, ), - margin: const pw.EdgeInsets.all(32), + margin: const pw.EdgeInsets.all(28), build: (pw.Context context) { return [ + // 1. Header: Business Details & Title pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - business?.businessName ?? 'Your Company Name', - style: pw.TextStyle( - fontSize: 24, - fontWeight: pw.FontWeight.bold, - color: PdfColors.deepOrange800, - ), - ), - pw.SizedBox(height: 4), - if (business?.address != null) + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ pw.Text( - business!.address!, - style: const pw.TextStyle( - fontSize: 10, - color: PdfColors.grey700, + business?.businessName ?? 'KIFI JEWELLERS', + style: pw.TextStyle( + fontSize: 22, + fontWeight: pw.FontWeight.bold, + color: PdfColors.deepOrange900, ), ), - if (business?.contactNumber != null) - pw.Text( - 'Ph: ${business!.contactNumber}', - style: const pw.TextStyle( - fontSize: 10, - color: PdfColors.grey700, + pw.SizedBox(height: 4), + if (business?.address != null && business!.address!.isNotEmpty) + pw.Text( + business!.address!, + style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), ), - ), - ], + 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.deepOrange800, + ), + ), + ], + ), ), pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, @@ -259,322 +283,558 @@ class _PurchaseOrderDetailsScreenState pw.Text( 'PURCHASE INVOICE', style: pw.TextStyle( - fontSize: 24, + fontSize: 22, fontWeight: pw.FontWeight.bold, - color: PdfColors.deepOrange800, + color: PdfColors.deepOrange900, ), ), - pw.SizedBox(height: 8), + pw.SizedBox(height: 6), pw.Container( - padding: const pw.EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), + padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: pw.BoxDecoration( - color: PdfColors.grey200, - borderRadius: const pw.BorderRadius.all( - pw.Radius.circular(4), + color: po.status == 'RECEIVED' ? PdfColors.green100 : PdfColors.orange100, + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4)), + border: pw.Border.all( + color: po.status == 'RECEIVED' ? PdfColors.green700 : PdfColors.orange700, + width: 0.5, ), ), child: pw.Text( po.status, style: pw.TextStyle( - fontSize: 10, + fontSize: 9, fontWeight: pw.FontWeight.bold, + color: po.status == 'RECEIVED' ? PdfColors.green900 : PdfColors.orange900, ), ), ), + pw.SizedBox(height: 6), + pw.Text( + 'PO #: ${po.poNumber}', + style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold), + ), + pw.Text( + 'Date: ${formatDate.format(po.issueDate)}', + style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), + ), + if (po.dueDate != null) + pw.Text( + 'Due Date: ${formatDate.format(po.dueDate!)}', + style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), + ), ], ), ], ), - pw.SizedBox(height: 30), + pw.SizedBox(height: 18), + + // 2. Vendor Card (Bill From) + 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.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'BILL FROM (VENDOR):', + style: pw.TextStyle( + fontSize: 8.5, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey700, + ), + ), + pw.SizedBox(height: 2), + pw.Text( + vendor?.name ?? 'Unknown Vendor', + style: pw.TextStyle( + fontSize: 12, + fontWeight: pw.FontWeight.bold, + ), + ), + if (vendor?.phone != null && vendor!.phone!.isNotEmpty) + pw.Text('Phone: ${vendor!.phone}', style: const pw.TextStyle(fontSize: 9)), + if (vendor?.address != null && vendor!.address!.isNotEmpty) + pw.Text('Address: ${vendor!.address}', style: const pw.TextStyle(fontSize: 9)), + if (vendor?.gstin != null && vendor!.gstin!.isNotEmpty) + pw.Text( + 'GSTIN: ${vendor!.gstin}', + style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold), + ), + ], + ), + ), + ], + ), + ), + pw.SizedBox(height: 16), + + // 3. Product Groups with Full Details (Category, Purity, HSN, HUID, Weight, GST Breakdown) + ...groupedItems.entries.map((entry) { + final productId = entry.key; + final group = entry.value; + final product = products.firstWhere( + (p) => p.id == productId, + orElse: () => Product(name: 'Item $productId'), + ); + final category = categories.firstWhere( + (c) => c.id == product.categoryId, + orElse: () => ProductCategory(name: 'General Category'), + ); + + double groupSubtotal = 0; + for (final item in group) { + groupSubtotal += item.total; + } + + final gstRate = product.gstRate ?? (category.defaultGst ?? 0.0); + final taxAmount = (groupSubtotal * gstRate) / 100.0; + final finalTotal = groupSubtotal + taxAmount; + + final purityStr = product.purityFactor != null + ? '${(product.purityFactor! * 100).toStringAsFixed(1)}%' + : (category.purityFactor != null ? '${(category.purityFactor! * 100).toStringAsFixed(1)}%' : '0.916'); + + final hsnStr = (product.hsnCode != null && product.hsnCode!.isNotEmpty) + ? product.hsnCode! + : (category.defaultHsn ?? '-'); + + return pw.Container( + 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 and Info Pills + 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( + product.name, + 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( + category.name, + style: pw.TextStyle( + fontSize: 7.5, + fontWeight: pw.FontWeight.bold, + color: PdfColors.deepOrange900, + ), + ), + ), + if (product.sku != null && product.sku!.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: ${product.sku}', + 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.deepOrange800), + 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', + 'Purchase 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 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'; + + return [ + particular, + weightQty, + '${formatCurrency.format(item.unitPrice)} $rateUnit', + item.makingCharge > 0 ? formatCurrency.format(item.makingCharge) : '-', + item.otherCharges > 0 ? formatCurrency.format(item.otherCharges) : '-', + formatCurrency.format(item.total), + ]; + }).toList(), + ), + + // Group Subtotal & GST Calculation Box + pw.Container( + 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( + crossAxisAlignment: pw.CrossAxisAlignment.stretch, + children: [ + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + 'Group Subtotal (${group.length} items):', + style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), + ), + pw.Text( + formatCurrency.format(groupSubtotal), + 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(taxAmount / 2)}', + style: const pw.TextStyle(fontSize: 8), + ), + ], + ), + pw.SizedBox(height: 2), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + 'SGST (${(gstRate / 2).toStringAsFixed(1)}%):', + style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), + ), + pw.Text( + '+ ${formatCurrency.format(taxAmount / 2)}', + style: const pw.TextStyle(fontSize: 8), + ), + ], + ), + ] else ...[ + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + 'IGST (${gstRate.toStringAsFixed(1)}%):', + style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), + ), + pw.Text( + '+ ${formatCurrency.format(taxAmount)}', + 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(finalTotal), + style: pw.TextStyle( + fontSize: 8.5, + fontWeight: pw.FontWeight.bold, + color: PdfColors.deepOrange900, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + }), + + pw.SizedBox(height: 12), + + // 4. Overall Invoice Summary pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ + // Left Side: Notes & Declarations pw.Expanded( + flex: 3, child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ + if (po.notes != null && po.notes!.isNotEmpty) ...[ + pw.Text( + 'Notes:', + style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold), + ), + pw.Text( + po.notes!, + style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey800), + ), + pw.SizedBox(height: 8), + ], pw.Text( - 'VENDOR:', - style: pw.TextStyle( - fontSize: 10, - fontWeight: pw.FontWeight.bold, - color: PdfColors.grey600, - ), + 'Terms & Declarations:', + style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold), ), - pw.SizedBox(height: 4), + pw.SizedBox(height: 2), pw.Text( - vendor?.name ?? 'Unknown Vendor', - style: pw.TextStyle( - fontSize: 14, - fontWeight: pw.FontWeight.bold, - ), + '1. Goods received are subject to physical verification and hallmark certification standards.', + 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), ), - if (vendor?.address != null) - pw.Text( - vendor!.address!, - style: const pw.TextStyle(fontSize: 10), - ), - if (vendor?.phone != null) - pw.Text( - 'Ph: ${vendor!.phone}', - style: const pw.TextStyle(fontSize: 10), - ), - if (vendor?.gstin != null) - pw.Text( - 'GSTIN: ${vendor!.gstin}', - style: const pw.TextStyle(fontSize: 10), - ), ], ), ), + pw.SizedBox(width: 20), + + // Right Side: Grand Total & Tax Summary Box pw.Expanded( - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.end, - children: [ - pw.Text( - 'PO NUMBER:', - style: pw.TextStyle( - fontSize: 10, - fontWeight: pw.FontWeight.bold, - color: PdfColors.grey600, + 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('Items Subtotal:', style: const pw.TextStyle(fontSize: 8.5)), + pw.Text(formatCurrency.format(po.subtotal), style: const pw.TextStyle(fontSize: 8.5)), + ], ), - ), - pw.Text( - po.poNumber, - style: pw.TextStyle( - fontSize: 12, - fontWeight: pw.FontWeight.bold, + if (po.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(po.cgstTotal ?? (po.taxTotal / 2))}', style: const pw.TextStyle(fontSize: 8.5)), + ], + ), + pw.SizedBox(height: 2), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('SGST Total:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)), + pw.Text('+ ${formatCurrency.format(po.sgstTotal ?? (po.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(po.igstTotal ?? po.taxTotal)}', style: const pw.TextStyle(fontSize: 8.5)), + ], + ), + ], + ], + if (po.discountTotal > 0) ...[ + pw.SizedBox(height: 4), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('Invoice Discount:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green700)), + pw.Text('- ${formatCurrency.format(po.discountTotal)}', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green700)), + ], + ), + ], + pw.Divider(color: PdfColors.grey400, height: 10), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + 'Grand Total:', + style: pw.TextStyle( + fontSize: 11, + fontWeight: pw.FontWeight.bold, + color: PdfColors.deepOrange900, + ), + ), + pw.Text( + formatCurrency.format(po.totalAmount), + style: pw.TextStyle( + fontSize: 11, + fontWeight: pw.FontWeight.bold, + color: PdfColors.deepOrange900, + ), + ), + ], ), - ), - pw.SizedBox(height: 8), - pw.Text( - 'ISSUE DATE:', - style: pw.TextStyle( - fontSize: 10, - fontWeight: pw.FontWeight.bold, - color: PdfColors.grey600, - ), - ), - pw.Text( - formatDate.format(po.issueDate), - style: pw.TextStyle( - fontSize: 12, - fontWeight: pw.FontWeight.bold, - ), - ), - ], + if (po.status == 'RECEIVED' || po.amountPaid > 0) ...[ + pw.SizedBox(height: 6), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('Amount Paid:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green800)), + pw.Text(formatCurrency.format(po.amountPaid), style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green800)), + ], + ), + pw.SizedBox(height: 2), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + 'Balance Due:', + style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.red800), + ), + pw.Text( + formatCurrency.format(po.totalAmount - po.amountPaid), + style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.red800), + ), + ], + ), + ], + ], + ), ), ), ], ), - pw.SizedBox(height: 30), - pw.Table( - border: const pw.TableBorder( - bottom: pw.BorderSide(color: PdfColors.grey300, width: .5), - horizontalInside: pw.BorderSide( - color: PdfColors.grey300, - width: .5, - ), - ), - columnWidths: { - 0: const pw.FlexColumnWidth(3), - 1: const pw.FlexColumnWidth(1), - 2: const pw.FlexColumnWidth(1.5), - 3: const pw.FlexColumnWidth(1.5), - }, + pw.SizedBox(height: 24), + + // 5. Signature Section + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ - pw.TableRow( - decoration: const pw.BoxDecoration( - color: PdfColors.deepOrange800, - ), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.Padding( - padding: const pw.EdgeInsets.all(8), - child: pw.Text( - 'Item Description', - style: pw.TextStyle( - color: PdfColors.white, - fontWeight: pw.FontWeight.bold, - ), - ), - ), - pw.Padding( - padding: const pw.EdgeInsets.all(8), - child: pw.Text( - 'Qty', - textAlign: pw.TextAlign.center, - style: pw.TextStyle( - color: PdfColors.white, - fontWeight: pw.FontWeight.bold, - ), - ), - ), - pw.Padding( - padding: const pw.EdgeInsets.all(8), - child: pw.Text( - 'Rate', - textAlign: pw.TextAlign.right, - style: pw.TextStyle( - color: PdfColors.white, - fontWeight: pw.FontWeight.bold, - ), - ), - ), - pw.Padding( - padding: const pw.EdgeInsets.all(8), - child: pw.Text( - 'Amount', - textAlign: pw.TextAlign.right, - style: pw.TextStyle( - color: PdfColors.white, - fontWeight: pw.FontWeight.bold, - ), - ), + pw.SizedBox(height: 25), + pw.Text( + 'Receiver\'s Signature', + style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), ), ], ), - ...po.items.map((item) { - final product = products.firstWhere( - (p) => p.id == item.productId, - orElse: () => Product(name: 'Item ${item.productId}'), - ); - final pName = product.name; - return pw.TableRow( - children: [ - pw.Padding( - padding: const pw.EdgeInsets.all(8), - child: pw.Text( - pName, - style: const pw.TextStyle(fontSize: 10), - ), - ), - pw.Padding( - padding: const pw.EdgeInsets.all(8), - child: pw.Text( - item.quantity.toString(), - textAlign: pw.TextAlign.center, - style: const pw.TextStyle(fontSize: 10), - ), - ), - pw.Padding( - padding: const pw.EdgeInsets.all(8), - child: pw.Text( - formatCurrency.format(item.unitPrice), - textAlign: pw.TextAlign.right, - style: const pw.TextStyle(fontSize: 10), - ), - ), - pw.Padding( - padding: const pw.EdgeInsets.all(8), - child: pw.Text( - formatCurrency.format( - item.quantity * item.unitPrice, - ), - textAlign: pw.TextAlign.right, - style: const pw.TextStyle(fontSize: 10), - ), - ), - ], - ); - }), - ], - ), - pw.SizedBox(height: 20), - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.end, - children: [ - pw.Container( - width: 250, - child: pw.Column( - children: [ - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text( - 'Subtotal:', - style: pw.TextStyle( - fontWeight: pw.FontWeight.bold, - color: PdfColors.grey700, - ), - ), - pw.Text( - formatCurrency.format(po.subtotal), - style: pw.TextStyle( - fontWeight: pw.FontWeight.bold, - ), - ), - ], - ), - pw.SizedBox(height: 8), - pw.Divider(color: PdfColors.grey400), - pw.SizedBox(height: 8), - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text( - 'Total Amount:', - style: pw.TextStyle( - fontSize: 14, - fontWeight: pw.FontWeight.bold, - color: PdfColors.deepOrange800, - ), - ), - pw.Text( - formatCurrency.format(po.totalAmount), - style: pw.TextStyle( - fontSize: 14, - fontWeight: pw.FontWeight.bold, - color: PdfColors.deepOrange800, - ), - ), - ], - ), - if (po.status == 'RECEIVED') ...[ - pw.SizedBox(height: 8), - pw.Row( - mainAxisAlignment: - pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text( - 'Amount Paid:', - style: pw.TextStyle(color: PdfColors.green700), - ), - pw.Text( - formatCurrency.format(po.amountPaid), - style: pw.TextStyle(color: PdfColors.green700), - ), - ], - ), - pw.SizedBox(height: 4), - pw.Row( - mainAxisAlignment: - pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text( - 'Balance Due:', - style: pw.TextStyle( - fontWeight: pw.FontWeight.bold, - color: PdfColors.red700, - ), - ), - pw.Text( - formatCurrency.format( - po.totalAmount - po.amountPaid, - ), - style: pw.TextStyle( - fontWeight: pw.FontWeight.bold, - color: PdfColors.red700, - ), - ), - ], - ), - ], - ], - ), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.SizedBox(height: 25), + pw.Text( + 'Authorized Signatory', + style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), + ), + ], ), ], ), @@ -583,18 +843,19 @@ class _PurchaseOrderDetailsScreenState ), ); - final directory = await getApplicationDocumentsDirectory(); - final pdfPath = await File('${directory.path}/PO_$poNumber.pdf').create(); - await pdfPath.writeAsBytes(await pdf.save()); + final pdfBytes = await pdf.save(); - await Share.shareXFiles([ - XFile(pdfPath.path), - ], text: 'Purchase Invoice $poNumber'); + // Cross-platform PDF sharing / printing + await Printing.sharePdf( + bytes: pdfBytes, + filename: 'PurchaseInvoice_${poNumber.replaceAll('/', '_')}.pdf', + ); } catch (e) { - if (mounted) + if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Error sharing PDF: $e'))); + } } } @@ -604,6 +865,7 @@ class _PurchaseOrderDetailsScreenState dynamic vendor, dynamic business, List products, + List categories, ) { showModalBottomSheet( context: context, @@ -623,10 +885,10 @@ class _PurchaseOrderDetailsScreenState ), ListTile( leading: const Icon(LucideIcons.fileText, color: Colors.red), - title: const Text('Share as PDF'), + title: const Text('Share as PDF (Tax Invoice)'), onTap: () { Navigator.pop(ctx); - _shareAsPdf(po.poNumber, po, vendor, business, products); + _shareAsPdf(po.poNumber, po, vendor, business, products, categories); }, ), ], @@ -691,6 +953,7 @@ class _PurchaseOrderDetailsScreenState vendor, business, products, + categories, ), ), ], @@ -1025,7 +1288,8 @@ class _PurchaseOrderDetailsScreenState ), const Divider(height: 1), ...group.map((item) { - final hasPhoto = (item.photoUrl != null && item.photoUrl!.isNotEmpty); + final hasPhoto = (item.photoUrl != null && item.photoUrl!.isNotEmpty) || + (item.localPhotoPath != null && item.localPhotoPath!.isNotEmpty); return Padding( padding: const EdgeInsets.all(16.0), child: Row( @@ -1034,11 +1298,16 @@ class _PurchaseOrderDetailsScreenState if (hasPhoto) ...[ GestureDetector( onTap: () { + final ImageProvider imageProvider = (item.photoUrl != null && item.photoUrl!.isNotEmpty) + ? NetworkImage(_resolveImageUrl(item.photoUrl!)) + : (kIsWeb + ? NetworkImage(item.localPhotoPath!) + : FileImage(File(item.localPhotoPath!)) as ImageProvider); Navigator.push( context, MaterialPageRoute( builder: (_) => AttachmentGalleryScreen( - images: [NetworkImage(_resolveImageUrl(item.photoUrl!))], + images: [imageProvider], initialIndex: 0, ), ), @@ -1058,12 +1327,16 @@ class _PurchaseOrderDetailsScreenState Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular(9), - child: Image.network( - _resolveImageUrl(item.photoUrl!), - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => - const Icon(LucideIcons.imageOff, color: Colors.grey, size: 24), - ), + child: (item.photoUrl != null && item.photoUrl!.isNotEmpty) + ? Image.network( + _resolveImageUrl(item.photoUrl!), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon(LucideIcons.imageOff, color: Colors.grey, size: 24), + ) + : (kIsWeb + ? Image.network(item.localPhotoPath!, fit: BoxFit.cover) + : Image.file(File(item.localPhotoPath!), fit: BoxFit.cover)), ), ), Positioned(