diff --git a/kifi-api/src/main/java/com/kifi/api/entity/inventory/InventoryItem.java b/kifi-api/src/main/java/com/kifi/api/entity/inventory/InventoryItem.java index a867140..7c8f282 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/inventory/InventoryItem.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/inventory/InventoryItem.java @@ -39,6 +39,7 @@ public class InventoryItem { private Long vendorId; private Long branchId; private String purchaseRef; + private String photoUrl; @Builder.Default private String status = "AVAILABLE"; private LocalDateTime createdAt; diff --git a/kifi-api/src/main/java/com/kifi/api/service/inventory/InventoryItemService.java b/kifi-api/src/main/java/com/kifi/api/service/inventory/InventoryItemService.java index 1d04d16..5f919fb 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/inventory/InventoryItemService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/inventory/InventoryItemService.java @@ -55,6 +55,7 @@ public class InventoryItemService { 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.getPhotoUrl() != null) existingItem.setPhotoUrl(updatedItem.getPhotoUrl()); if (updatedItem.getStatus() != null) existingItem.setStatus(updatedItem.getStatus()); existingItem.setUpdatedAt(LocalDateTime.now()); return inventoryItemRepository.save(existingItem); diff --git a/kifi-api/src/main/java/com/kifi/api/service/vendor/PurchaseOrderService.java b/kifi-api/src/main/java/com/kifi/api/service/vendor/PurchaseOrderService.java index 59bf2aa..660fb23 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/vendor/PurchaseOrderService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/vendor/PurchaseOrderService.java @@ -195,6 +195,7 @@ public class PurchaseOrderService { .huid(item.getHuid()) .grossWeight(item.getWeight()) .netWeight(item.getWeight()) + .photoUrl(item.getPhotoUrl()) .status("AVAILABLE") .build(); diff --git a/kifi-api/src/main/resources/schema.sql b/kifi-api/src/main/resources/schema.sql index c8f30df..7a83909 100644 --- a/kifi-api/src/main/resources/schema.sql +++ b/kifi-api/src/main/resources/schema.sql @@ -383,6 +383,7 @@ CREATE TABLE IF NOT EXISTS inventory_items ( vendor_id INTEGER REFERENCES vendors(id) ON DELETE SET NULL, branch_id INTEGER, purchase_ref VARCHAR(100), + photo_url VARCHAR(1024), status VARCHAR(50) DEFAULT 'AVAILABLE', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP diff --git a/kifi-app/lib/features/inventory/domain/inventory_item.dart b/kifi-app/lib/features/inventory/domain/inventory_item.dart index 6cb3251..a452993 100644 --- a/kifi-app/lib/features/inventory/domain/inventory_item.dart +++ b/kifi-app/lib/features/inventory/domain/inventory_item.dart @@ -21,6 +21,7 @@ class InventoryItem { final int? vendorId; final int? branchId; final String? purchaseRef; + final String? photoUrl; final String? status; final DateTime? createdAt; @@ -47,6 +48,7 @@ class InventoryItem { this.vendorId, this.branchId, this.purchaseRef, + this.photoUrl, this.status, this.createdAt, }); @@ -75,6 +77,7 @@ class InventoryItem { vendorId: json['vendorId'], branchId: json['branchId'], purchaseRef: json['purchaseRef'], + photoUrl: json['photoUrl'] ?? json['photo_url'], status: json['status'], createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, ); diff --git a/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart b/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart index 4a4620f..96e9cea 100644 --- a/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart +++ b/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart @@ -11,6 +11,7 @@ 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/core/utils/purity_utils.dart'; +import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; class StockLedgerTab extends ConsumerStatefulWidget { final Product product; @@ -26,6 +27,18 @@ class _StockLedgerTabState extends ConsumerState { 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(); @@ -163,6 +176,31 @@ class _StockLedgerTabState extends ConsumerState { } } + // 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 currentPrice = weight * currentRate; final gainLoss = currentPrice - purchasePrice; @@ -188,29 +226,92 @@ class _StockLedgerTabState extends ConsumerState { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: Colors.grey.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: FutureBuilder( - future: DioClient().storage.read(key: 'jwt_token'), - builder: (context, snapshot) { - if (snapshot.hasData && widget.product.imageIds.isNotEmpty) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product.id}/images/${widget.product.imageIds.first}/content', - fit: BoxFit.cover, - headers: {'Authorization': 'Bearer ${snapshot.data}'}, + FutureBuilder( + 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), + ), ), - ); - } - return const Icon(LucideIcons.image, color: Colors.grey, size: 20); - }, - ), + 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(