Web approach uniformity done
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ class StockLedgerTab extends ConsumerStatefulWidget {
|
||||
class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
List<InventoryItem>? _items;
|
||||
bool _isLoading = true;
|
||||
String _selectedFilter = 'IN_STOCK'; // 'IN_STOCK', 'SOLD', 'ALL'
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -61,7 +62,19 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
}
|
||||
|
||||
if (_items == null || _items!.isEmpty) {
|
||||
return const Center(child: Text("No stock available."));
|
||||
return const Center(child: Text("No stock records found."));
|
||||
}
|
||||
|
||||
final inStockItems = _items!.where((i) => i.status != 'SOLD').toList();
|
||||
final soldItems = _items!.where((i) => i.status == 'SOLD').toList();
|
||||
|
||||
List<InventoryItem> displayItems;
|
||||
if (_selectedFilter == 'IN_STOCK') {
|
||||
displayItems = inStockItems;
|
||||
} else if (_selectedFilter == 'SOLD') {
|
||||
displayItems = soldItems;
|
||||
} else {
|
||||
displayItems = _items!;
|
||||
}
|
||||
|
||||
final categoriesState = ref.watch(productCategoriesProvider);
|
||||
@@ -86,235 +99,324 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
|
||||
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<String?>(
|
||||
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<String?>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,13 +130,42 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
||||
}
|
||||
|
||||
Future<void> _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<InvoiceBuilderScreen> {
|
||||
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<InvoiceBuilderScreen> {
|
||||
}
|
||||
|
||||
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<InvoiceBuilderScreen> {
|
||||
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,
|
||||
|
||||
@@ -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<InvoiceDetailsScreen> {
|
||||
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<InvoiceDetailsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String _getEffectiveStatus(Invoice invoice) {
|
||||
final balanceDue = invoice.totalAmount - invoice.amountPaid;
|
||||
if (invoice.status == 'PAID' || balanceDue.abs() < 0.01 || (invoice.totalAmount > 0 && invoice.amountPaid >= invoice.totalAmount - 0.01)) {
|
||||
return 'PAID';
|
||||
}
|
||||
if (invoice.amountPaid > 0.01) {
|
||||
return 'PARTIAL';
|
||||
}
|
||||
return invoice.status.isNotEmpty ? invoice.status : 'DRAFT';
|
||||
}
|
||||
|
||||
Color _getStatusColor(String status) {
|
||||
switch (status) {
|
||||
case 'PAID':
|
||||
@@ -542,60 +554,147 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
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<int, List<InvoiceItem>> groupedItems = {};
|
||||
final List<InvoiceItem> ungroupedItems = [];
|
||||
|
||||
for (final item in latestInvoice.items) {
|
||||
if (item.productId != null) {
|
||||
groupedItems.putIfAbsent(item.productId!, () => []).add(item);
|
||||
} else {
|
||||
ungroupedItems.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
final pdf = pw.Document();
|
||||
|
||||
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<InvoiceDetailsScreen> {
|
||||
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<InvoiceDetailsScreen> {
|
||||
),
|
||||
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<InvoiceDetailsScreen> {
|
||||
} 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<InvoiceDetailsScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<InvoicesListScreen> {
|
||||
|
||||
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<InvoicesListScreen> {
|
||||
),
|
||||
),
|
||||
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
|
||||
|
||||
@@ -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<ReceivePaymentSheet> {
|
||||
final TextEditingController _amountCtrl = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _amountCtrl;
|
||||
String _paymentMethod = 'Cash';
|
||||
int? _selectedWalletId;
|
||||
bool _isLoading = false;
|
||||
|
||||
final List<Map<String, dynamic>> _methods = [
|
||||
{'key': 'Cash', 'label': 'Cash', 'icon': LucideIcons.banknote},
|
||||
{'key': 'UPI', 'label': 'UPI', 'icon': LucideIcons.smartphone},
|
||||
{'key': 'Bank Transfer', 'label': 'Bank Transfer', 'icon': LucideIcons.building2},
|
||||
{'key': 'Card', 'label': 'Card', 'icon': LucideIcons.creditCard},
|
||||
];
|
||||
|
||||
@override
|
||||
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<ReceivePaymentSheet> {
|
||||
}
|
||||
|
||||
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<int>(
|
||||
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<String>(
|
||||
value: _paymentMethod,
|
||||
isExpanded: true,
|
||||
items: ['Cash', 'UPI', 'Bank Transfer', 'Card']
|
||||
.map((m) => DropdownMenuItem(value: m, child: Text(m)))
|
||||
.toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() {
|
||||
_paymentMethod = val;
|
||||
_selectedWalletId = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
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<int>(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,10 +114,11 @@ class _AddVendorSheetState extends ConsumerState<AddVendorSheet> {
|
||||
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',
|
||||
});
|
||||
|
||||
@@ -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<PayVendorSheet> {
|
||||
late TextEditingController _amountCtrl;
|
||||
final TextEditingController _notesCtrl = TextEditingController();
|
||||
DateTime _paymentDate = DateTime.now();
|
||||
String? _selectedMethod;
|
||||
String _selectedMethod = 'CASH';
|
||||
bool _isLoading = false;
|
||||
|
||||
final List<String> _paymentMethods = [
|
||||
'CASH',
|
||||
'BANK_TRANSFER',
|
||||
'UPI',
|
||||
'CARD',
|
||||
'CHEQUE',
|
||||
final List<Map<String, dynamic>> _methods = [
|
||||
{'key': 'CASH', 'label': 'Cash', 'icon': LucideIcons.banknote},
|
||||
{'key': 'UPI', 'label': 'UPI', 'icon': LucideIcons.smartphone},
|
||||
{'key': 'BANK_TRANSFER', 'label': 'Bank Transfer', 'icon': LucideIcons.building2},
|
||||
{'key': 'CARD', 'label': 'Card', 'icon': LucideIcons.creditCard},
|
||||
{'key': 'CHEQUE', 'label': 'Cheque', 'icon': LucideIcons.fileText},
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -38,7 +38,7 @@ class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
|
||||
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<PayVendorSheet> {
|
||||
|
||||
Future<void> _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<PayVendorSheet> {
|
||||
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<PayVendorSheet> {
|
||||
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<PayVendorSheet> {
|
||||
|
||||
@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<String>(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<void> _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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user