Files
Kifi/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart

691 lines
39 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:kifi_app/features/inventory/domain/inventory_item.dart';
import 'package:kifi_app/features/inventory/domain/product.dart';
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
import 'package:kifi_app/features/inventory/providers/product_categories_provider.dart';
import 'package:kifi_app/features/inventory/providers/commodity_rates_provider.dart';
import 'package:kifi_app/core/network/dio_client.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:kifi_app/features/vendor/presentation/purchase_order_details_screen.dart';
import 'package:kifi_app/features/vendor/providers/purchase_orders_provider.dart';
import 'package:kifi_app/features/sales/presentation/invoice_details_screen.dart';
import 'package:kifi_app/features/sales/providers/invoices_provider.dart';
import 'package:kifi_app/features/sales/domain/invoice.dart';
import 'package:kifi_app/features/business/providers/business_provider.dart';
import 'package:kifi_app/core/utils/purity_utils.dart';
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
class StockLedgerTab extends ConsumerStatefulWidget {
final Product product;
const StockLedgerTab({super.key, required this.product});
@override
ConsumerState<StockLedgerTab> createState() => _StockLedgerTabState();
}
class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
List<InventoryItem>? _items;
bool _isLoading = true;
String _selectedFilter = 'IN_STOCK'; // 'IN_STOCK', 'SOLD', 'ALL'
String _resolveImageUrl(String path) {
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
final base = DioClient().dio.options.baseUrl;
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
if (cleanPath.startsWith('api/kifi-v2/upload/view') || cleanPath.startsWith('upload/view')) {
return '$base/${cleanPath.replaceFirst('api/kifi-v2/', '')}';
}
return '$base/upload/view?path=${Uri.encodeComponent(cleanPath)}';
}
@override
void initState() {
super.initState();
_fetchLedger();
}
Future<void> _fetchLedger() async {
try {
final items = await ref
.read(productsProvider.notifier)
.fetchInventoryItems(widget.product.id!);
if (mounted) {
setState(() {
_items = items;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_items == null || _items!.isEmpty) {
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 businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final categoriesState = ref.watch(productCategoriesProvider);
final commodityRatesState = ref.watch(commodityRatesProvider);
final category = categoriesState.value?.where(
(c) => c.id == widget.product.categoryId,
).firstOrNull;
final bool isCommodity = isJewellery && category?.commodityCode != null && category!.commodityCode!.isNotEmpty;
final String unit = category?.baseUnit ?? 'g';
final double purityFactor = resolvePurity(categoryPurity: category?.purityFactor, productPurity: widget.product.purityFactor);
double commodityRate = 0.0;
if (isCommodity && commodityRatesState.value != null && category.commodityCode != null) {
final match = commodityRatesState.value!.where(
(r) => r.commodityCode.toUpperCase() == category.commodityCode!.toUpperCase()
).firstOrNull;
if (match != null) {
commodityRate = match.rate;
}
}
final double currentRate = isCommodity
? (commodityRate > 0 ? (commodityRate * purityFactor) : (category.dailyRate ?? 0.0))
: 0.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 ?? [];
final invoicesState = ref.watch(invoicesProvider);
final invoices = invoicesState.value ?? [];
// Resolve sales invoice for sold item
Invoice? matchingSaleInvoice;
if (isSold) {
if (item.salesRef != null && item.salesRef!.isNotEmpty) {
matchingSaleInvoice = invoices.where((i) => i.invoiceNumber == item.salesRef).firstOrNull;
}
matchingSaleInvoice ??= invoices.where((i) => i.items.any((invIt) =>
(invIt.inventoryItemId != null && invIt.inventoryItemId == item.id) ||
(item.huid != null && item.huid!.isNotEmpty && invIt.huid == item.huid)
)).firstOrNull;
}
double effectiveSaleRate = (item.saleRate != null && item.saleRate! > 0) ? item.saleRate! : 0.0;
if (isSold && effectiveSaleRate == 0.0 && matchingSaleInvoice != null) {
final matchItem = matchingSaleInvoice.items.where((invIt) =>
(invIt.inventoryItemId != null && invIt.inventoryItemId == item.id) ||
(item.huid != null && item.huid!.isNotEmpty && invIt.huid == item.huid)
).firstOrNull;
if (matchItem != null && matchItem.unitPrice > 0) {
effectiveSaleRate = matchItem.unitPrice;
}
}
double weight = (item.grossWeight != null && item.grossWeight! > 0)
? item.grossWeight!
: ((item.netWeight != null && item.netWeight! > 0)
? item.netWeight!
: (item.fineWeight ?? 0.0));
double purchaseRate = (item.purchaseCost != null && item.purchaseCost! > 0)
? item.purchaseCost!
: ((item.metalCost != null && item.metalCost! > 0 && weight > 0)
? (item.metalCost! / weight)
: 0.0);
// If weight or rate was lost due to earlier db overwrite, recover from purchase order
if ((weight == 0.0 || purchaseRate == 0.0) && item.purchaseRef != null) {
final po = purchaseOrders.where((p) => p.poNumber == item.purchaseRef).firstOrNull;
if (po != null) {
final poItem = po.items.where((i) => i.productId == widget.product.id || (item.huid != null && i.huid == item.huid) || (item.sku != null && i.sku == item.sku)).firstOrNull ?? po.items.firstOrNull;
if (poItem != null) {
if (weight == 0.0) {
weight = poItem.weight ?? (poItem.quantity > 0 ? poItem.quantity : 1.0);
}
if (purchaseRate == 0.0) {
purchaseRate = poItem.unitPrice;
}
}
}
}
// Resolve purchase item thumbnail photo
String? itemPhotoUrl = (item.photoUrl != null && item.photoUrl!.isNotEmpty) ? item.photoUrl : null;
if (itemPhotoUrl == null && item.purchaseRef != null) {
final po = purchaseOrders.where((p) => p.poNumber == item.purchaseRef).firstOrNull;
if (po != null) {
final poItem = po.items.where((i) =>
(item.huid != null && i.huid == item.huid) ||
(item.sku != null && i.sku == item.sku) ||
i.productId == widget.product.id
).firstOrNull ?? po.items.firstOrNull;
if (poItem != null && poItem.photoUrl != null && poItem.photoUrl!.isNotEmpty) {
itemPhotoUrl = poItem.photoUrl;
}
}
}
String? finalDisplayUrl;
bool isProductFallback = false;
if (itemPhotoUrl != null) {
finalDisplayUrl = _resolveImageUrl(itemPhotoUrl);
} else if (widget.product.imageIds.isNotEmpty) {
finalDisplayUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product.id}/images/${widget.product.imageIds.first}/content';
isProductFallback = true;
}
final purchasePrice = weight * purchaseRate;
final double displayedSellingRate = effectiveSaleRate > 0 ? effectiveSaleRate : (isCommodity ? currentRate : purchaseRate);
final currentPrice = weight * (isSold ? displayedSellingRate : currentRate);
final gainLoss = isSold ? ((weight * displayedSellingRate) - purchasePrice) : (currentPrice - purchasePrice);
final isGain = gainLoss >= 0;
final String? salesInvoiceNumber = matchingSaleInvoice?.invoiceNumber ?? item.salesRef;
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),
),
),
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: [
FutureBuilder<String?>(
future: DioClient().storage.read(key: 'jwt_token'),
builder: (context, snapshot) {
final hasImage = finalDisplayUrl != null;
return GestureDetector(
onTap: hasImage
? () {
final token = snapshot.data;
final headers = (token != null && isProductFallback)
? {'Authorization': 'Bearer $token'}
: null;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AttachmentGalleryScreen(
images: [
NetworkImage(
finalDisplayUrl!,
headers: headers,
),
],
initialIndex: 0,
),
),
);
}
: null,
child: MouseRegion(
cursor: hasImage ? SystemMouseCursors.click : SystemMouseCursors.basic,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.grey.withValues(alpha: 0.2),
),
),
child: hasImage
? Stack(
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(7),
child: Image.network(
finalDisplayUrl,
fit: BoxFit.cover,
headers: (snapshot.hasData && isProductFallback)
? {'Authorization': 'Bearer ${snapshot.data}'}
: null,
errorBuilder: (context, error, stackTrace) => const Icon(
LucideIcons.imageOff,
color: Colors.grey,
size: 20,
),
),
),
),
Positioned(
bottom: 2,
right: 2,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(4),
),
child: const Icon(
LucideIcons.maximize2,
size: 8,
color: Colors.white,
),
),
),
],
)
: 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 (isSold) ...[
if (salesInvoiceNumber != null && salesInvoiceNumber.isNotEmpty)
InkWell(
onTap: () async {
try {
var inv = matchingSaleInvoice;
if (inv == null) {
var invs = ref.read(invoicesProvider).value;
if (invs == null || invs.isEmpty) {
invs = await ref.read(invoicesProvider.future);
}
inv = invs?.where((i) => i.invoiceNumber == salesInvoiceNumber).firstOrNull;
}
if (inv == null) {
throw Exception('Sales invoice $salesInvoiceNumber not found');
}
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceDetailsScreen(invoice: inv!),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open sales invoice: $e')),
);
}
}
},
child: Text(
'INV: $salesInvoiceNumber',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
] else ...[
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 (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PurchaseOrderDetailsScreen(po: po),
),
);
}
} catch (e) {
if (context.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,
fontSize: 12,
),
),
),
],
if (isJewellery && item.huid != null && item.huid!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2.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,
),
),
if (isCommodity) ...[
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),
if (isCommodity) ...[
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('${displayedSellingRate.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,
),
),
],
),
),
] else ...[
// Non-Commodity / E-Commerce Products
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)),
],
),
],
),
if (isSold && effectiveSaleRate > 0) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Sold Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${effectiveSaleRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.purple)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Sold Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${(weight * effectiveSaleRate).toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.purple)),
],
),
],
),
const SizedBox(height: 16),
Builder(
builder: (context) {
final soldAmt = weight * effectiveSaleRate;
final realizedMargin = soldAmt - purchasePrice;
final isProfit = realizedMargin >= 0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isProfit ? Colors.green.withValues(alpha: 0.1) : Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isProfit ? LucideIcons.trendingUp : LucideIcons.trendingDown,
color: isProfit ? Colors.green : Colors.red,
size: 16,
),
const SizedBox(width: 8),
Text(
'Realized ${isProfit ? 'Profit' : 'Loss'}: ₹${realizedMargin.abs().toStringAsFixed(2)}',
style: TextStyle(
color: isProfit ? 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,
),
),
),
);
}
}