From b4772ccf1962bcffb9f1287ce03a4e2a20aa4ad2 Mon Sep 17 00:00:00 2001 From: Narayanan Madaswamy Date: Tue, 1 Sep 2026 21:11:32 +0530 Subject: [PATCH] Tuned ui to support ecommerce product catalogue and purchases. --- .../inventory/ProductCategoryController.java | 1 + .../api/entity/inventory/InventoryItem.java | 2 + .../kifi/api/entity/inventory/Product.java | 20 + .../api/entity/inventory/ProductCategory.java | 2 + .../api/service/inventory/ProductService.java | 20 + .../api/service/invoice/InvoiceService.java | 125 ++- kifi-api/src/main/resources/schema.sql | 18 + .../inventory/domain/inventory_item.dart | 6 + .../features/inventory/domain/product.dart | 65 ++ .../presentation/add_product_screen.dart | 899 +++++++++++++----- .../category_management_screen.dart | 89 +- .../presentation/product_detail_screen.dart | 415 ++++++-- .../presentation/stock_ledger_tab.dart | 400 +++++--- .../product_categories_provider.dart | 4 + .../inventory/providers/uoms_provider.dart | 13 +- .../purchase_order_builder_screen.dart | 319 ++++--- 16 files changed, 1798 insertions(+), 600 deletions(-) diff --git a/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductCategoryController.java b/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductCategoryController.java index bf296b5..ef7df13 100644 --- a/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductCategoryController.java +++ b/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductCategoryController.java @@ -55,6 +55,7 @@ public class ProductCategoryController { existing.setDefaultMakingCharge(category.getDefaultMakingCharge()); existing.setMakingChargeType(category.getMakingChargeType()); existing.setBaseUnit(category.getBaseUnit()); + existing.setDefaultLengthUom(category.getDefaultLengthUom()); existing.setIsActive(category.getIsActive()); existing.setSortOrder(category.getSortOrder()); 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 7c8f282..f2ac9c8 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,8 @@ public class InventoryItem { private Long vendorId; private Long branchId; private String purchaseRef; + private String salesRef; + private BigDecimal saleRate; private String photoUrl; @Builder.Default private String status = "AVAILABLE"; diff --git a/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java b/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java index 702ecb6..2c1ba8c 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java @@ -33,6 +33,26 @@ public class Product { private String dimensions; private String color; private String size; + + private Double itemLength; + private Double itemWidth; + private Double itemHeight; + private String dimensionUom; + + private Double packageLength; + private Double packageWidth; + private Double packageHeight; + private String packageDimensionUom; + + private String manufacturerCode; + private String material; + private String brandName; + private String countryOfOrigin; + + private Double weightInG; + private Double packageWeightInG; + private Double volumetricWeightInKg; + private String priceCalcRule; private Double purityFactor; private Double makingCharges; diff --git a/kifi-api/src/main/java/com/kifi/api/entity/inventory/ProductCategory.java b/kifi-api/src/main/java/com/kifi/api/entity/inventory/ProductCategory.java index 2667148..94c8001 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/inventory/ProductCategory.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/inventory/ProductCategory.java @@ -34,6 +34,8 @@ public class ProductCategory { private String makingChargeType; private String baseUnit; + @Builder.Default + private String defaultLengthUom = "cm"; @Builder.Default private Boolean isActive = true; diff --git a/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java b/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java index 842a361..64391d3 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java @@ -102,6 +102,26 @@ public class ProductService { existingProduct.setColor(updatedProduct.getColor()); existingProduct.setSize(updatedProduct.getSize()); existingProduct.setDimensions(updatedProduct.getDimensions()); + + existingProduct.setItemLength(updatedProduct.getItemLength()); + existingProduct.setItemWidth(updatedProduct.getItemWidth()); + existingProduct.setItemHeight(updatedProduct.getItemHeight()); + existingProduct.setDimensionUom(updatedProduct.getDimensionUom()); + + existingProduct.setPackageLength(updatedProduct.getPackageLength()); + existingProduct.setPackageWidth(updatedProduct.getPackageWidth()); + existingProduct.setPackageHeight(updatedProduct.getPackageHeight()); + existingProduct.setPackageDimensionUom(updatedProduct.getPackageDimensionUom()); + + existingProduct.setManufacturerCode(updatedProduct.getManufacturerCode()); + existingProduct.setMaterial(updatedProduct.getMaterial()); + existingProduct.setBrandName(updatedProduct.getBrandName()); + existingProduct.setCountryOfOrigin(updatedProduct.getCountryOfOrigin()); + + existingProduct.setWeightInG(updatedProduct.getWeightInG()); + existingProduct.setPackageWeightInG(updatedProduct.getPackageWeightInG()); + existingProduct.setVolumetricWeightInKg(updatedProduct.getVolumetricWeightInKg()); + existingProduct.setPriceCalcRule(updatedProduct.getPriceCalcRule()); existingProduct.setPurityFactor(normalizePurity(updatedProduct.getPurityFactor())); existingProduct.setMakingCharges(updatedProduct.getMakingCharges()); diff --git a/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java b/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java index 295192f..b3f164c 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java @@ -146,15 +146,64 @@ public class InvoiceService { if (shouldDeduct && invoice.getItems() != null && !invoice.getItems().isEmpty()) { return Flux.fromIterable(invoice.getItems()) .concatMap(item -> { + BigDecimal soldQty = (item.getWeight() != null && item.getWeight().compareTo(BigDecimal.ZERO) > 0) + ? item.getWeight() + : (item.getQuantity() != null && item.getQuantity().compareTo(BigDecimal.ZERO) > 0 ? item.getQuantity() : BigDecimal.ONE); + BigDecimal soldRate = item.getUnitPrice() != null ? item.getUnitPrice() : BigDecimal.ZERO; + if (item.getInventoryItemId() != null) { return inventoryItemRepository.findById(item.getInventoryItemId()) .flatMap(invItem -> { - invItem.setStatus("SOLD"); - invItem.setUpdatedAt(LocalDateTime.now()); - BigDecimal cost = invItem.getPurchaseCost() != null ? invItem.getPurchaseCost() : BigDecimal.ZERO; - return inventoryItemRepository.save(invItem).thenReturn(cost); + BigDecimal currentWeight = (invItem.getGrossWeight() != null && invItem.getGrossWeight().compareTo(BigDecimal.ZERO) > 0) + ? invItem.getGrossWeight() + : ((invItem.getNetWeight() != null && invItem.getNetWeight().compareTo(BigDecimal.ZERO) > 0) + ? invItem.getNetWeight() + : BigDecimal.ONE); + + BigDecimal unitCost = invItem.getPurchaseCost() != null ? invItem.getPurchaseCost() : BigDecimal.ZERO; + BigDecimal cost = unitCost.multiply(soldQty); + + if (currentWeight.compareTo(soldQty) > 0) { + // Partial sale: Reduce existing available item weight + BigDecimal remaining = currentWeight.subtract(soldQty); + invItem.setGrossWeight(remaining); + invItem.setNetWeight(remaining); + invItem.setUpdatedAt(LocalDateTime.now()); + + // Create new SOLD item record + com.kifi.api.entity.inventory.InventoryItem soldItem = com.kifi.api.entity.inventory.InventoryItem.builder() + .userId(userId) + .productId(invItem.getProductId()) + .vendorId(invItem.getVendorId()) + .purchaseRef(invItem.getPurchaseRef()) + .salesRef(invoice.getInvoiceNumber()) + .purchaseCost(invItem.getPurchaseCost()) + .saleRate(soldRate) + .makingCharges(invItem.getMakingCharges()) + .sku(invItem.getSku()) + .huid(invItem.getHuid()) + .grossWeight(soldQty) + .netWeight(soldQty) + .photoUrl(invItem.getPhotoUrl()) + .status("SOLD") + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + return inventoryItemRepository.save(invItem) + .then(inventoryItemRepository.save(soldItem)) + .thenReturn(cost); + } else { + // Full sale + invItem.setStatus("SOLD"); + invItem.setSalesRef(invoice.getInvoiceNumber()); + invItem.setSaleRate(soldRate); + invItem.setUpdatedAt(LocalDateTime.now()); + return inventoryItemRepository.save(invItem).thenReturn(cost); + } }).defaultIfEmpty(BigDecimal.ZERO); } else if (item.getProductId() != null) { + // Product sale without explicit inventory item: adjust product stock & FIFO deduct inventory items InventoryMovement movement = InventoryMovement.builder() .userId(userId) .type("REDUCTION") @@ -167,8 +216,72 @@ public class InvoiceService { movementItem.setQuantity(item.getQuantity()); movement.setItems(java.util.Collections.singletonList(movementItem)); - return productService.adjustStock(userId, item.getProductId(), movement) - .thenReturn(BigDecimal.ZERO); + Mono adjustMono = productService.adjustStock(userId, item.getProductId(), movement).then(); + + Mono fifoMono = inventoryItemRepository.findByUserIdAndProductId(userId, item.getProductId()) + .filter(i -> "AVAILABLE".equalsIgnoreCase(i.getStatus())) + .collectList() + .flatMap(availItems -> { + if (availItems.isEmpty()) { + return Mono.just(BigDecimal.ZERO); + } + BigDecimal needed = soldQty; + java.util.List> saves = new java.util.ArrayList<>(); + BigDecimal totalCost = BigDecimal.ZERO; + + for (com.kifi.api.entity.inventory.InventoryItem invItem : availItems) { + if (needed.compareTo(BigDecimal.ZERO) <= 0) break; + BigDecimal itemWeight = (invItem.getGrossWeight() != null && invItem.getGrossWeight().compareTo(BigDecimal.ZERO) > 0) + ? invItem.getGrossWeight() + : ((invItem.getNetWeight() != null && invItem.getNetWeight().compareTo(BigDecimal.ZERO) > 0) + ? invItem.getNetWeight() + : BigDecimal.ONE); + + BigDecimal take = needed.min(itemWeight); + needed = needed.subtract(take); + BigDecimal unitPurchase = invItem.getPurchaseCost() != null ? invItem.getPurchaseCost() : BigDecimal.ZERO; + totalCost = totalCost.add(unitPurchase.multiply(take)); + + if (itemWeight.compareTo(take) > 0) { + BigDecimal remaining = itemWeight.subtract(take); + invItem.setGrossWeight(remaining); + invItem.setNetWeight(remaining); + invItem.setUpdatedAt(LocalDateTime.now()); + saves.add(inventoryItemRepository.save(invItem).then()); + + com.kifi.api.entity.inventory.InventoryItem soldRecord = com.kifi.api.entity.inventory.InventoryItem.builder() + .userId(userId) + .productId(invItem.getProductId()) + .vendorId(invItem.getVendorId()) + .purchaseRef(invItem.getPurchaseRef()) + .salesRef(invoice.getInvoiceNumber()) + .purchaseCost(invItem.getPurchaseCost()) + .saleRate(soldRate) + .makingCharges(invItem.getMakingCharges()) + .sku(invItem.getSku()) + .huid(invItem.getHuid()) + .grossWeight(take) + .netWeight(take) + .photoUrl(invItem.getPhotoUrl()) + .status("SOLD") + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + saves.add(inventoryItemRepository.save(soldRecord).then()); + } else { + invItem.setStatus("SOLD"); + invItem.setSalesRef(invoice.getInvoiceNumber()); + invItem.setSaleRate(soldRate); + invItem.setUpdatedAt(LocalDateTime.now()); + saves.add(inventoryItemRepository.save(invItem).then()); + } + } + + BigDecimal finalCost = totalCost; + return Flux.concat(saves).then(Mono.just(finalCost)); + }); + + return adjustMono.then(fifoMono); } return Mono.just(BigDecimal.ZERO); }) diff --git a/kifi-api/src/main/resources/schema.sql b/kifi-api/src/main/resources/schema.sql index 7a83909..3673112 100644 --- a/kifi-api/src/main/resources/schema.sql +++ b/kifi-api/src/main/resources/schema.sql @@ -208,6 +208,7 @@ CREATE TABLE IF NOT EXISTS product_categories ( commodity_code VARCHAR(10), purity_factor DECIMAL(5, 4) DEFAULT 1.0, base_unit VARCHAR(20) DEFAULT 'pcs', + default_length_uom VARCHAR(20) DEFAULT 'cm', is_active BOOLEAN DEFAULT TRUE, sort_order INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP @@ -249,6 +250,21 @@ CREATE TABLE IF NOT EXISTS products ( weight DECIMAL(10, 3), color VARCHAR(50), size VARCHAR(50), + item_length DECIMAL(10, 2), + item_width DECIMAL(10, 2), + item_height DECIMAL(10, 2), + dimension_uom VARCHAR(20) DEFAULT 'cm', + package_length DECIMAL(10, 2), + package_width DECIMAL(10, 2), + package_height DECIMAL(10, 2), + package_dimension_uom VARCHAR(20) DEFAULT 'cm', + manufacturer_code VARCHAR(100), + material VARCHAR(100), + brand_name VARCHAR(100), + country_of_origin VARCHAR(100), + weight_in_g DECIMAL(10, 3), + package_weight_in_g DECIMAL(10, 3), + volumetric_weight_in_kg DECIMAL(10, 3), price_calc_rule VARCHAR(50) DEFAULT 'MANUAL', purity_factor DECIMAL(5, 4), making_charges DECIMAL(15, 2) DEFAULT 0.0, @@ -383,6 +399,8 @@ CREATE TABLE IF NOT EXISTS inventory_items ( vendor_id INTEGER REFERENCES vendors(id) ON DELETE SET NULL, branch_id INTEGER, purchase_ref VARCHAR(100), + sales_ref VARCHAR(100), + sale_rate DECIMAL(15, 2), photo_url VARCHAR(1024), status VARCHAR(50) DEFAULT 'AVAILABLE', created_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 a452993..6c2d6b8 100644 --- a/kifi-app/lib/features/inventory/domain/inventory_item.dart +++ b/kifi-app/lib/features/inventory/domain/inventory_item.dart @@ -21,6 +21,8 @@ class InventoryItem { final int? vendorId; final int? branchId; final String? purchaseRef; + final String? salesRef; + final double? saleRate; final String? photoUrl; final String? status; final DateTime? createdAt; @@ -48,6 +50,8 @@ class InventoryItem { this.vendorId, this.branchId, this.purchaseRef, + this.salesRef, + this.saleRate, this.photoUrl, this.status, this.createdAt, @@ -77,6 +81,8 @@ class InventoryItem { vendorId: json['vendorId'], branchId: json['branchId'], purchaseRef: json['purchaseRef'], + salesRef: json['salesRef'] ?? json['sales_ref'], + saleRate: (json['saleRate'] ?? json['sale_rate'])?.toDouble(), 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/domain/product.dart b/kifi-app/lib/features/inventory/domain/product.dart index 4757866..4a3aa27 100644 --- a/kifi-app/lib/features/inventory/domain/product.dart +++ b/kifi-app/lib/features/inventory/domain/product.dart @@ -14,6 +14,26 @@ class Product { final String? dimensions; final String? color; final String? size; + + final double? itemLength; + final double? itemWidth; + final double? itemHeight; + final String? dimensionUom; + + final double? packageLength; + final double? packageWidth; + final double? packageHeight; + final String? packageDimensionUom; + + final String? manufacturerCode; + final String? material; + final String? brandName; + final String? countryOfOrigin; + + final double? weightInG; + final double? packageWeightInG; + final double? volumetricWeightInKg; + final String priceCalcRule; final bool autoCalculatePrice; final double? purityFactor; @@ -39,6 +59,21 @@ class Product { this.dimensions, this.color, this.size, + this.itemLength, + this.itemWidth, + this.itemHeight, + this.dimensionUom = 'cm', + this.packageLength, + this.packageWidth, + this.packageHeight, + this.packageDimensionUom = 'cm', + this.manufacturerCode, + this.material, + this.brandName, + this.countryOfOrigin, + this.weightInG, + this.packageWeightInG, + this.volumetricWeightInKg, this.priceCalcRule = 'MANUAL', this.autoCalculatePrice = false, this.purityFactor, @@ -67,6 +102,21 @@ class Product { dimensions: json['dimensions'], color: json['color'], size: json['size'], + itemLength: (json['itemLength'] ?? json['item_length'] as num?)?.toDouble(), + itemWidth: (json['itemWidth'] ?? json['item_width'] as num?)?.toDouble(), + itemHeight: (json['itemHeight'] ?? json['item_height'] as num?)?.toDouble(), + dimensionUom: json['dimensionUom'] ?? json['dimension_uom'] ?? 'cm', + packageLength: (json['packageLength'] ?? json['package_length'] as num?)?.toDouble(), + packageWidth: (json['packageWidth'] ?? json['package_width'] as num?)?.toDouble(), + packageHeight: (json['packageHeight'] ?? json['package_height'] as num?)?.toDouble(), + packageDimensionUom: json['packageDimensionUom'] ?? json['package_dimension_uom'] ?? 'cm', + manufacturerCode: json['manufacturerCode'] ?? json['manufacturer_code'], + material: json['material'], + brandName: json['brandName'] ?? json['brand_name'], + countryOfOrigin: json['countryOfOrigin'] ?? json['country_of_origin'], + weightInG: (json['weightInG'] ?? json['weight_in_g'] as num?)?.toDouble(), + packageWeightInG: (json['packageWeightInG'] ?? json['package_weight_in_g'] as num?)?.toDouble(), + volumetricWeightInKg: (json['volumetricWeightInKg'] ?? json['volumetric_weight_in_kg'] as num?)?.toDouble(), priceCalcRule: json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL', autoCalculatePrice: @@ -116,6 +166,21 @@ class Product { 'dimensions': dimensions, 'color': color, 'size': size, + 'itemLength': itemLength, + 'itemWidth': itemWidth, + 'itemHeight': itemHeight, + 'dimensionUom': dimensionUom, + 'packageLength': packageLength, + 'packageWidth': packageWidth, + 'packageHeight': packageHeight, + 'packageDimensionUom': packageDimensionUom, + 'manufacturerCode': manufacturerCode, + 'material': material, + 'brandName': brandName, + 'countryOfOrigin': countryOfOrigin, + 'weightInG': weightInG, + 'packageWeightInG': packageWeightInG, + 'volumetricWeightInKg': volumetricWeightInKg, 'priceCalcRule': priceCalcRule, 'autoCalculatePrice': autoCalculatePrice, 'purityFactor': purityFactor, diff --git a/kifi-app/lib/features/inventory/presentation/add_product_screen.dart b/kifi-app/lib/features/inventory/presentation/add_product_screen.dart index 60b0437..d0e88d5 100644 --- a/kifi-app/lib/features/inventory/presentation/add_product_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/add_product_screen.dart @@ -36,8 +36,27 @@ class _AddProductScreenState extends ConsumerState { ProductCategory? _selectedCategory; int? _uomId; - // Properties - String _color = ''; + // Specifications + final _colorController = TextEditingController(); + final _manufacturerCodeController = TextEditingController(); + final _materialController = TextEditingController(); + final _brandNameController = TextEditingController(); + final _countryOfOriginController = TextEditingController(); + + // Item Dimensions & Weight + final _itemLengthController = TextEditingController(); + final _itemWidthController = TextEditingController(); + final _itemHeightController = TextEditingController(); + String _dimensionUom = 'cm'; + final _weightInGController = TextEditingController(); + + // Package Dimensions & Weight + final _packageLengthController = TextEditingController(); + final _packageWidthController = TextEditingController(); + final _packageHeightController = TextEditingController(); + String _packageDimensionUom = 'cm'; + final _packageWeightInGController = TextEditingController(); + double? _volumetricWeightInKg; // Media final List _images = []; @@ -58,10 +77,31 @@ class _AddProductScreenState extends ConsumerState { _sku = p.sku ?? ''; _hsnController.text = p.hsnCode ?? ''; _uomId = p.uomId; - _color = p.color ?? ''; _gstController.text = p.gstRate != null && p.gstRate! > 0 ? p.gstRate.toString() : ''; _makingChargesController.text = p.makingCharges != null && p.makingCharges! > 0 ? p.makingCharges!.toString() : ''; _makingChargesType = p.makingChargesType ?? 'PER_GRAM'; + + _colorController.text = p.color ?? ''; + _manufacturerCodeController.text = p.manufacturerCode ?? ''; + _materialController.text = p.material ?? ''; + _brandNameController.text = p.brandName ?? ''; + _countryOfOriginController.text = p.countryOfOrigin ?? ''; + + _itemLengthController.text = p.itemLength != null ? p.itemLength.toString() : ''; + _itemWidthController.text = p.itemWidth != null ? p.itemWidth.toString() : ''; + _itemHeightController.text = p.itemHeight != null ? p.itemHeight.toString() : ''; + _dimensionUom = p.dimensionUom ?? 'cm'; + _weightInGController.text = p.weightInG != null ? p.weightInG.toString() : ''; + + _packageLengthController.text = p.packageLength != null ? p.packageLength.toString() : ''; + _packageWidthController.text = p.packageWidth != null ? p.packageWidth.toString() : ''; + _packageHeightController.text = p.packageHeight != null ? p.packageHeight.toString() : ''; + _packageDimensionUom = p.packageDimensionUom ?? 'cm'; + _packageWeightInGController.text = p.packageWeightInG != null ? p.packageWeightInG.toString() : ''; + _volumetricWeightInKg = p.volumetricWeightInKg; + if (_volumetricWeightInKg == null) { + _calculateVolumetricWeight(); + } if (p.imageIds.isNotEmpty) { _existingImageIds.addAll(p.imageIds); @@ -83,9 +123,58 @@ class _AddProductScreenState extends ConsumerState { _hsnController.dispose(); _gstController.dispose(); _makingChargesController.dispose(); + _colorController.dispose(); + _manufacturerCodeController.dispose(); + _materialController.dispose(); + _brandNameController.dispose(); + _countryOfOriginController.dispose(); + _itemLengthController.dispose(); + _itemWidthController.dispose(); + _itemHeightController.dispose(); + _weightInGController.dispose(); + _packageLengthController.dispose(); + _packageWidthController.dispose(); + _packageHeightController.dispose(); + _packageWeightInGController.dispose(); super.dispose(); } + void _calculateVolumetricWeight() { + final l = double.tryParse(_packageLengthController.text) ?? 0.0; + final w = double.tryParse(_packageWidthController.text) ?? 0.0; + final h = double.tryParse(_packageHeightController.text) ?? 0.0; + + if (l > 0 && w > 0 && h > 0) { + double factor = 1.0; + switch (_packageDimensionUom.toLowerCase()) { + case 'in': + factor = 2.54; + break; + case 'mm': + factor = 0.1; + break; + case 'm': + factor = 100.0; + break; + case 'cm': + default: + factor = 1.0; + break; + } + final lCm = l * factor; + final wCm = w * factor; + final hCm = h * factor; + final volKg = (lCm * wCm * hCm) / 5000.0; + setState(() { + _volumetricWeightInKg = double.parse(volKg.toStringAsFixed(3)); + }); + } else { + setState(() { + _volumetricWeightInKg = null; + }); + } + } + Future _pickImages() async { if ((_images.length + _existingImageIds.length) >= 4) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed'))); @@ -148,16 +237,31 @@ class _AddProductScreenState extends ConsumerState { final product = Product( id: widget.product?.id, name: _name, - hsnCode: _hsnController.text.isNotEmpty ? _hsnController.text : null, - sku: _sku.isNotEmpty ? _sku : null, + hsnCode: _hsnController.text.isNotEmpty ? _hsnController.text.trim() : null, + sku: _sku.isNotEmpty ? _sku.trim() : null, categoryId: _selectedCategory?.id, uomId: _uomId, - color: _color.isNotEmpty ? _color : null, + color: _colorController.text.isNotEmpty ? _colorController.text.trim() : null, gstRate: double.tryParse(_gstController.text) ?? 0, makingCharges: double.tryParse(_makingChargesController.text) ?? 0.0, makingChargesType: _makingChargesType, priceCalcRule: 'MANUAL', autoCalculatePrice: false, + itemLength: double.tryParse(_itemLengthController.text), + itemWidth: double.tryParse(_itemWidthController.text), + itemHeight: double.tryParse(_itemHeightController.text), + dimensionUom: _dimensionUom, + packageLength: double.tryParse(_packageLengthController.text), + packageWidth: double.tryParse(_packageWidthController.text), + packageHeight: double.tryParse(_packageHeightController.text), + packageDimensionUom: _packageDimensionUom, + manufacturerCode: _manufacturerCodeController.text.isNotEmpty ? _manufacturerCodeController.text.trim() : null, + material: _materialController.text.isNotEmpty ? _materialController.text.trim() : null, + brandName: _brandNameController.text.isNotEmpty ? _brandNameController.text.trim() : null, + countryOfOrigin: _countryOfOriginController.text.isNotEmpty ? _countryOfOriginController.text.trim() : null, + weightInG: double.tryParse(_weightInGController.text), + packageWeightInG: double.tryParse(_packageWeightInGController.text), + volumetricWeightInKg: _volumetricWeightInKg, ); if (widget.product != null) { @@ -177,6 +281,9 @@ class _AddProductScreenState extends ConsumerState { Widget build(BuildContext context) { final categoriesState = ref.watch(productCategoriesProvider); final uomsState = ref.watch(uomsProvider); + final businessProfile = ref.watch(businessProfileProvider).value; + final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase(); + final isJewellery = nature == 'JEWELLERY'; return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, @@ -191,278 +298,550 @@ class _AddProductScreenState extends ConsumerState { behavior: HitTestBehavior.translucent, onTap: () => FocusScope.of(context).unfocus(), child: Column( - children: [ - Expanded( - child: Form( - key: _formKey, - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Basic Information', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - - _buildPremiumTextField( - label: 'Product Name*', - initialValue: _name, - validator: (val) => val == null || val.isEmpty ? 'Required' : null, - onChanged: (val) => _name = val, - ), - const SizedBox(height: 16), - _buildPremiumTextField( - label: 'SKU / Barcode', - initialValue: _sku, - onChanged: (val) => _sku = val, - ), - const SizedBox(height: 24), - - const Text('Category & Unit', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(height: 12), - categoriesState.when( - loading: () => const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: LinearProgressIndicator(), + children: [ + Expanded( + child: Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Basic Information', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + + _buildPremiumTextField( + label: 'Product Name*', + initialValue: _name, + validator: (val) => val == null || val.isEmpty ? 'Required' : null, + onChanged: (val) => _name = val, ), - error: (err, stack) => Text('Error loading categories: $err', style: const TextStyle(color: Colors.red)), - data: (categories) { - final activeCategories = categories.where((c) => c.isActive).toList(); - final uoms = uomsState.value ?? []; + const SizedBox(height: 16), + _buildPremiumTextField( + label: 'SKU / Barcode', + initialValue: _sku, + onChanged: (val) => _sku = val, + ), + const SizedBox(height: 24), + + const Text('Category & Unit', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + categoriesState.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: LinearProgressIndicator(), + ), + error: (err, stack) => Text('Error loading categories: $err', style: const TextStyle(color: Colors.red)), + data: (categories) { + final activeCategories = categories.where((c) => c.isActive).toList(); + final uoms = uomsState.value ?? []; - return activeCategories.isEmpty - ? const Text('No categories found. Manage categories in Business Hub.', style: TextStyle(color: Colors.orange)) - : SmartSearchDropdown( - hintText: 'Select Category*', - value: _selectedCategory, - items: activeCategories, - itemAsString: (c) => c.name, - itemBuilder: (context, item) { - List path = []; - ProductCategory? current = item; - while (current != null) { - path.insert(0, current.name); - if (current.parentCategoryId != null) { - current = categories.where((p) => p.id == current!.parentCategoryId).firstOrNull; - } else { - current = null; + return activeCategories.isEmpty + ? const Text('No categories found. Manage categories in Business Hub.', style: TextStyle(color: Colors.orange)) + : SmartSearchDropdown( + hintText: 'Select Category*', + value: _selectedCategory, + items: activeCategories, + itemAsString: (c) => c.name, + itemBuilder: (context, item) { + List path = []; + ProductCategory? current = item; + while (current != null) { + path.insert(0, current.name); + if (current.parentCategoryId != null) { + current = categories.where((p) => p.id == current!.parentCategoryId).firstOrNull; + } else { + current = null; + } } - } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(item.name, style: const TextStyle(fontWeight: FontWeight.bold)), - if (path.length > 1) - Text(path.join(' -> '), style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), - ], - ), - ); - }, - onChanged: (val) { - setState(() { - _selectedCategory = val; - if (val != null) { - if (val.defaultHsn != null && val.defaultHsn!.isNotEmpty) { - _hsnController.text = val.defaultHsn!; - } - if (val.defaultGst != null && val.defaultGst! > 0) { - _gstController.text = val.defaultGst.toString(); - } - if (_makingChargesController.text.isEmpty && val.defaultMakingCharge != null && val.defaultMakingCharge! > 0) { - _makingChargesController.text = val.defaultMakingCharge!.toString(); - } - if (val.makingChargeType != null && val.makingChargeType!.isNotEmpty) { - _makingChargesType = val.makingChargeType!; - } - if (_uomId == null && uoms.isNotEmpty) { - final match = uoms.where((u) => - u.abbreviation?.toLowerCase() == val.baseUnit.toLowerCase() || - u.name.toLowerCase() == val.baseUnit.toLowerCase() - ).firstOrNull; - if (match != null) { - _uomId = match.id; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.name, style: const TextStyle(fontWeight: FontWeight.bold)), + if (path.length > 1) + Text(path.join(' -> '), style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), + ], + ), + ); + }, + onChanged: (val) { + setState(() { + _selectedCategory = val; + if (val != null) { + if (val.defaultHsn != null && val.defaultHsn!.isNotEmpty) { + _hsnController.text = val.defaultHsn!; + } + if (val.defaultGst != null && val.defaultGst! > 0) { + _gstController.text = val.defaultGst.toString(); + } + if (_makingChargesController.text.isEmpty && val.defaultMakingCharge != null && val.defaultMakingCharge! > 0) { + _makingChargesController.text = val.defaultMakingCharge!.toString(); + } + if (val.makingChargeType != null && val.makingChargeType!.isNotEmpty) { + _makingChargesType = val.makingChargeType!; + } + if (val.defaultLengthUom.isNotEmpty) { + _dimensionUom = val.defaultLengthUom; + _packageDimensionUom = val.defaultLengthUom; + _calculateVolumetricWeight(); + } + if (val.baseUnit.isNotEmpty && uoms.isNotEmpty) { + final target = val.baseUnit.trim().toLowerCase(); + final match = uoms.where((u) => + (u.abbreviation != null && u.abbreviation!.trim().toLowerCase() == target) || + u.name.trim().toLowerCase() == target || + (target == 'pcs' && (u.abbreviation?.toLowerCase() == 'pcs' || u.name.toLowerCase().contains('piece'))) || + (target == 'unit' && (u.abbreviation?.toLowerCase() == 'unit' || u.name.toLowerCase().contains('unit'))) || + (target == 'box' && (u.abbreviation?.toLowerCase() == 'box' || u.name.toLowerCase().contains('box'))) || + (target == 'set' && (u.abbreviation?.toLowerCase() == 'set' || u.name.toLowerCase().contains('set'))) || + (target == 'pair' && (u.abbreviation?.toLowerCase() == 'pair' || u.name.toLowerCase().contains('pair'))) || + (target == 'pk' && (u.abbreviation?.toLowerCase() == 'pk' || u.name.toLowerCase().contains('pack'))) || + (target == 'g' && (u.abbreviation?.toLowerCase() == 'g' || u.name.toLowerCase().contains('gram'))) || + (target == 'kg' && (u.abbreviation?.toLowerCase() == 'kg' || u.name.toLowerCase().contains('kilogram'))) || + (target == 'm' && (u.abbreviation?.toLowerCase() == 'm' || u.name.toLowerCase().contains('meter'))) || + (target == 'l' && (u.abbreviation?.toLowerCase() == 'l' || u.name.toLowerCase().contains('liter'))) || + (target == 'ml' && (u.abbreviation?.toLowerCase() == 'ml' || u.name.toLowerCase().contains('milliliter'))) || + (target == 'doz' && (u.abbreviation?.toLowerCase() == 'doz' || u.name.toLowerCase().contains('dozen'))) + ).firstOrNull; + if (match != null) { + _uomId = match.id; + } } } - } - }); - }, - ); - }, - ), - const SizedBox(height: 16), - uomsState.when( - loading: () => const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: LinearProgressIndicator(), - ), - error: (err, stack) => Text('Error loading units: $err', style: const TextStyle(color: Colors.red)), - data: (uoms) { - final effectiveValue = uoms.any((u) => u.id == _uomId) ? _uomId : null; - return DropdownButtonFormField( - decoration: const InputDecoration( - labelText: 'Unit of Measure (Optional)', - ), - isExpanded: true, - hint: const Text('None (Optional)'), - value: effectiveValue, - items: uoms.map((u) => DropdownMenuItem( - value: u.id, - child: Text(u.displayName), - )).toList(), - onChanged: (val) => setState(() => _uomId = val), - ); - }, - ), - const SizedBox(height: 24), - - const Text('Pricing & Taxes', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: TextFormField( - controller: _hsnController, - decoration: const InputDecoration(labelText: 'HSN Code'), - ), - ), - const SizedBox(width: 16), - Expanded( - child: TextFormField( - controller: _gstController, - decoration: const InputDecoration(labelText: 'GST Rate (%)', suffixText: '%'), - keyboardType: const TextInputType.numberWithOptions(decimal: true), - ), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: TextFormField( - controller: _makingChargesController, - decoration: const InputDecoration( - labelText: 'Making Charges', - prefixIcon: Icon(LucideIcons.hammer), - ), - keyboardType: const TextInputType.numberWithOptions(decimal: true), - ), - ), - const SizedBox(width: 16), - Expanded( - child: DropdownButtonFormField( - value: _makingChargesType, - isExpanded: true, - decoration: const InputDecoration(labelText: 'Charge Type'), - items: const [ - DropdownMenuItem(value: 'PER_GRAM', child: Text('Per Gram')), - DropdownMenuItem(value: 'PER_PIECE', child: Text('Per Piece')), - DropdownMenuItem(value: 'PERCENTAGE', child: Text('Percentage %')), - ], - onChanged: (val) { - if (val != null) setState(() => _makingChargesType = val); - }, - ), - ), - ], - ), - const SizedBox(height: 24), - - const Text('Product Images', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - if (_images.isNotEmpty || _existingImageIds.isNotEmpty) - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, crossAxisSpacing: 16, mainAxisSpacing: 16, childAspectRatio: 1, - ), - itemCount: _images.length + _existingImageIds.length, - itemBuilder: (context, index) { - if (index < _existingImageIds.length) { - final imageId = _existingImageIds[index]; - final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content'; - return _buildImageThumbnail( - isNetwork: true, - url: imageUrl, - onTap: () => _previewImage(index), - onDelete: () async { - try { - setState(() => _isSaving = true); - await DioClient().dio.delete('/inventory/products/images/$imageId'); - setState(() => _existingImageIds.removeAt(index)); - ref.invalidate(productsProvider); - } catch (e) { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to delete: $e'))); - } finally { - if (mounted) setState(() => _isSaving = false); - } - } - ); - } else { - final localIndex = index - _existingImageIds.length; - return _buildImageThumbnail( - isNetwork: false, - xFile: _images[localIndex], - onTap: () => _previewImage(index), - onDelete: () => _removeImage(localIndex), - ); - } + }); + }, + ); }, ), - if ((_images.length + _existingImageIds.length) < 4) ...[ const SizedBox(height: 16), - GestureDetector( - onTap: _pickImages, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 24), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.05), - border: Border.all(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3)), - borderRadius: BorderRadius.circular(16), + uomsState.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: LinearProgressIndicator(), + ), + error: (err, stack) => Text('Error loading units: $err', style: const TextStyle(color: Colors.red)), + data: (uoms) { + final effectiveValue = uoms.any((u) => u.id == _uomId) ? _uomId : null; + return DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Unit of Measure (Optional)', + ), + isExpanded: true, + hint: const Text('None (Optional)'), + value: effectiveValue, + items: uoms.map((u) => DropdownMenuItem( + value: u.id, + child: Text(u.displayName), + )).toList(), + onChanged: (val) => setState(() => _uomId = val), + ); + }, + ), + const SizedBox(height: 24), + + const Text('Pricing & Taxes', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _hsnController, + decoration: const InputDecoration(labelText: 'HSN Code'), + ), ), - child: Column( - children: [ - Icon(LucideIcons.uploadCloud, size: 32, color: Theme.of(context).colorScheme.primary), - const SizedBox(height: 8), - const Text('Tap to Upload Images', style: TextStyle(fontWeight: FontWeight.bold)), - Text('${4 - (_images.length + _existingImageIds.length)} slots remaining', style: const TextStyle(color: Colors.grey, fontSize: 12)), - ], + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _gstController, + decoration: const InputDecoration(labelText: 'GST Rate (%)', suffixText: '%'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + ), + ], + ), + + if (isJewellery) ...[ + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _makingChargesController, + decoration: const InputDecoration( + labelText: 'Making Charges', + prefixIcon: Icon(LucideIcons.hammer), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + ), + const SizedBox(width: 16), + Expanded( + child: DropdownButtonFormField( + value: _makingChargesType, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Charge Type'), + items: const [ + DropdownMenuItem(value: 'PER_GRAM', child: Text('Per Gram')), + DropdownMenuItem(value: 'PER_PIECE', child: Text('Per Piece')), + DropdownMenuItem(value: 'PERCENTAGE', child: Text('Percentage %')), + ], + onChanged: (val) { + if (val != null) setState(() => _makingChargesType = val); + }, + ), + ), + ], + ), + ], + + if (!isJewellery) ...[ + const SizedBox(height: 24), + const Text('Product Specifications', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _brandNameController, + decoration: const InputDecoration( + labelText: 'Brand Name', + prefixIcon: Icon(LucideIcons.bookmark), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _manufacturerCodeController, + decoration: const InputDecoration( + labelText: 'Manufacturer Code', + prefixIcon: Icon(LucideIcons.factory), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _materialController, + decoration: const InputDecoration( + labelText: 'Material', + prefixIcon: Icon(LucideIcons.layers), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _colorController, + decoration: const InputDecoration( + labelText: 'Color', + prefixIcon: Icon(LucideIcons.palette), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _countryOfOriginController, + decoration: const InputDecoration( + labelText: 'Country of Origin', + prefixIcon: Icon(LucideIcons.globe), + ), + ), + ), + ], + ), + + const SizedBox(height: 24), + const Text('Item Dimensions & Weight', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _itemLengthController, + decoration: const InputDecoration(labelText: 'Length'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _itemWidthController, + decoration: const InputDecoration(labelText: 'Width'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _itemHeightController, + decoration: const InputDecoration(labelText: 'Height'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 110, + child: DropdownButtonFormField( + value: _dimensionUom, + decoration: const InputDecoration(labelText: 'UoM'), + items: const [ + DropdownMenuItem(value: 'cm', child: Text('cm')), + DropdownMenuItem(value: 'in', child: Text('in')), + DropdownMenuItem(value: 'mm', child: Text('mm')), + DropdownMenuItem(value: 'm', child: Text('m')), + ], + onChanged: (val) { + if (val != null) setState(() => _dimensionUom = val); + }, + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _weightInGController, + decoration: const InputDecoration( + labelText: 'Item Weight (in Grams)', + suffixText: 'g', + prefixIcon: Icon(LucideIcons.scale), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + + const SizedBox(height: 24), + const Text('Packaging Dimensions & Weight', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _packageLengthController, + decoration: const InputDecoration(labelText: 'Package Length'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => _calculateVolumetricWeight(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _packageWidthController, + decoration: const InputDecoration(labelText: 'Package Width'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => _calculateVolumetricWeight(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _packageHeightController, + decoration: const InputDecoration(labelText: 'Package Height'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => _calculateVolumetricWeight(), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 110, + child: DropdownButtonFormField( + value: _packageDimensionUom, + decoration: const InputDecoration(labelText: 'UoM'), + items: const [ + DropdownMenuItem(value: 'cm', child: Text('cm')), + DropdownMenuItem(value: 'in', child: Text('in')), + DropdownMenuItem(value: 'mm', child: Text('mm')), + DropdownMenuItem(value: 'm', child: Text('m')), + ], + onChanged: (val) { + if (val != null) { + setState(() => _packageDimensionUom = val); + _calculateVolumetricWeight(); + } + }, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextFormField( + controller: _packageWeightInGController, + decoration: const InputDecoration( + labelText: 'Package Weight (in Grams)', + suffixText: 'g', + prefixIcon: Icon(LucideIcons.package), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + Icon(LucideIcons.box, color: Theme.of(context).colorScheme.primary, size: 22), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Volumetric Weight', + style: TextStyle(fontSize: 12, color: Colors.grey, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 2), + Text( + _volumetricWeightInKg != null + ? '$_volumetricWeightInKg kg' + : 'L × W × H ÷ 5000', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: _volumetricWeightInKg != null + ? Theme.of(context).colorScheme.primary + : Colors.grey, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ], + + const SizedBox(height: 24), + const Text('Product Images', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + if (_images.isNotEmpty || _existingImageIds.isNotEmpty) + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, crossAxisSpacing: 16, mainAxisSpacing: 16, childAspectRatio: 1, + ), + itemCount: _images.length + _existingImageIds.length, + itemBuilder: (context, index) { + if (index < _existingImageIds.length) { + final imageId = _existingImageIds[index]; + final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content'; + return _buildImageThumbnail( + isNetwork: true, + url: imageUrl, + onTap: () => _previewImage(index), + onDelete: () async { + try { + setState(() => _isSaving = true); + await DioClient().dio.delete('/inventory/products/images/$imageId'); + setState(() => _existingImageIds.removeAt(index)); + ref.invalidate(productsProvider); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to delete: $e'))); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + ); + } else { + final localIndex = index - _existingImageIds.length; + return _buildImageThumbnail( + isNetwork: false, + xFile: _images[localIndex], + onTap: () => _previewImage(index), + onDelete: () => _removeImage(localIndex), + ); + } + }, + ), + if ((_images.length + _existingImageIds.length) < 4) ...[ + const SizedBox(height: 16), + GestureDetector( + onTap: _pickImages, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.05), + border: Border.all(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Icon(LucideIcons.uploadCloud, size: 32, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 8), + const Text('Tap to Upload Images', style: TextStyle(fontWeight: FontWeight.bold)), + Text('${4 - (_images.length + _existingImageIds.length)} slots remaining', style: const TextStyle(color: Colors.grey, fontSize: 12)), + ], + ), ), ), - ), - ] - ], + ] + ], + ), ), ), ), - ), - _buildBottomBar(), - ], + _buildBottomBar(), + ], + ), ), ), - ), ); } Widget _buildBottomBar() { - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16.0), + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, -5), + ), + ], + ), + child: SafeArea( child: SizedBox( width: double.infinity, + height: 52, child: ElevatedButton( onPressed: _isSaving ? null : _save, style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - elevation: 4, + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, ), child: _isSaving - ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) - : Text(widget.product != null ? 'Update Product' : 'Publish Product', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : Text( + widget.product != null ? 'Update Product' : 'Publish Product', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), ), ), ), @@ -473,11 +852,10 @@ class _AddProductScreenState extends ConsumerState { required bool isNetwork, String? url, XFile? xFile, - required VoidCallback onDelete, required VoidCallback onTap, + required VoidCallback onDelete, }) { return Stack( - fit: StackFit.expand, children: [ GestureDetector( onTap: onTap, @@ -488,16 +866,19 @@ class _AddProductScreenState extends ConsumerState { ), child: ClipRRect( borderRadius: BorderRadius.circular(16), - child: Container( - color: Colors.grey[200], + child: SizedBox.expand( child: isNetwork - ? (_token == null - ? const Icon(LucideIcons.image, color: Colors.grey) - : Image.network( + ? (_token != null + ? Image.network( url!, fit: BoxFit.cover, headers: {'Authorization': 'Bearer $_token'}, errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey), + ) + : Image.network( + url!, + fit: BoxFit.cover, + errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey), )) : (kIsWeb ? Image.network(xFile!.path, fit: BoxFit.cover) @@ -512,7 +893,7 @@ class _AddProductScreenState extends ConsumerState { child: GestureDetector( onTap: onDelete, child: Container( - decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.6), shape: BoxShape.circle), padding: const EdgeInsets.all(6), child: const Icon(LucideIcons.x, color: Colors.white, size: 16), ), diff --git a/kifi-app/lib/features/inventory/presentation/category_management_screen.dart b/kifi-app/lib/features/inventory/presentation/category_management_screen.dart index 3a458b9..5a76737 100644 --- a/kifi-app/lib/features/inventory/presentation/category_management_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/category_management_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../providers/product_categories_provider.dart'; -import '../../../core/theme/nature_colors.dart'; +import '../providers/uoms_provider.dart'; import '../../business/providers/business_mode_provider.dart'; import '../../business/providers/business_provider.dart'; import '../../../core/theme/app_theme.dart'; @@ -307,6 +307,7 @@ class _CategoryFormSheetState extends ConsumerState { String _commodityCode = 'XAU'; String _makingChargeType = 'PER_GRAM'; String _baseUnit = 'pcs'; + String _defaultLengthUom = 'cm'; final List> _commodities = [ {'code': 'XAU', 'label': 'XAU - Gold'}, @@ -325,6 +326,7 @@ class _CategoryFormSheetState extends ConsumerState { _baseUnit = isJewellery ? 'g' : 'pcs'; _commodityCode = isJewellery ? 'XAU' : 'NONE'; + _defaultLengthUom = 'cm'; if (widget.categoryToEdit != null) { final c = widget.categoryToEdit!; @@ -343,8 +345,9 @@ class _CategoryFormSheetState extends ConsumerState { _makingChargeType = c.makingChargeType ?? 'PER_GRAM'; if (_makingChargeType == 'PER_GM') _makingChargeType = 'PER_GRAM'; - _baseUnit = c.baseUnit ?? (isJewellery ? 'g' : 'pcs'); + _baseUnit = c.baseUnit; if (_baseUnit == 'gm') _baseUnit = 'g'; + _defaultLengthUom = c.defaultLengthUom; } } else if (widget.parent != null) { _selectedParentId = widget.parent!.id; @@ -380,6 +383,7 @@ class _CategoryFormSheetState extends ConsumerState { defaultMakingCharge: _isLeaf && isJewellery ? double.tryParse(_makingChargeController.text) : null, makingChargeType: _isLeaf && isJewellery ? _makingChargeType : null, baseUnit: _isLeaf ? _baseUnit : 'pcs', + defaultLengthUom: _isLeaf ? _defaultLengthUom : 'cm', ); if (widget.categoryToEdit != null && widget.categoryToEdit!.id != null) { @@ -398,6 +402,7 @@ class _CategoryFormSheetState extends ConsumerState { final businessProfile = ref.watch(businessProfileProvider).value; final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase(); final isJewellery = nature == 'JEWELLERY'; + final uomsState = ref.watch(uomsProvider); return Container( height: MediaQuery.of(context).size.height * 0.85, @@ -623,30 +628,68 @@ class _CategoryFormSheetState extends ConsumerState { const SizedBox(height: 16), ], - DropdownButtonFormField( - value: _baseUnit, - decoration: const InputDecoration( - labelText: 'Base Unit', + uomsState.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: LinearProgressIndicator(), ), - items: [ - if (isJewellery) ...const [ - DropdownMenuItem(value: 'g', child: Text('Grams (g)')), - DropdownMenuItem(value: 'kg', child: Text('Kilograms (kg)')), - DropdownMenuItem(value: 'pcs', child: Text('Pieces (pcs)')), - ] else ...const [ - DropdownMenuItem(value: 'pcs', child: Text('Pieces (pcs)')), - DropdownMenuItem(value: 'unit', child: Text('Units (unit)')), - DropdownMenuItem(value: 'box', child: Text('Boxes (box)')), - DropdownMenuItem(value: 'set', child: Text('Sets (set)')), - DropdownMenuItem(value: 'pair', child: Text('Pairs (pair)')), - DropdownMenuItem(value: 'g', child: Text('Grams (g)')), - DropdownMenuItem(value: 'kg', child: Text('Kilograms (kg)')), - DropdownMenuItem(value: 'm', child: Text('Meters (m)')), - DropdownMenuItem(value: 'l', child: Text('Liters (l)')), + error: (err, stack) => DropdownButtonFormField( + value: _baseUnit, + decoration: const InputDecoration(labelText: 'Base Unit'), + items: [ + DropdownMenuItem(value: _baseUnit, child: Text(_baseUnit)), ], - ], - onChanged: (val) => setState(() => _baseUnit = val!), + onChanged: (val) => setState(() => _baseUnit = val!), + ), + data: (uoms) { + final availableValues = uoms.map((u) => u.abbreviation ?? u.name).toList(); + final match = uoms.where((u) => + (u.abbreviation ?? u.name).toLowerCase() == _baseUnit.toLowerCase() || + u.name.toLowerCase() == _baseUnit.toLowerCase() + ).firstOrNull; + final effectiveValue = match != null + ? (match.abbreviation ?? match.name) + : (availableValues.contains(_baseUnit) ? _baseUnit : (availableValues.isNotEmpty ? availableValues.first : null)); + + return DropdownButtonFormField( + value: effectiveValue, + decoration: const InputDecoration( + labelText: 'Base Unit', + prefixIcon: Icon(LucideIcons.scale), + ), + isExpanded: true, + items: uoms.map((u) { + final val = u.abbreviation ?? u.name; + return DropdownMenuItem( + value: val, + child: Text(u.displayName), + ); + }).toList(), + onChanged: (val) { + if (val != null) { + setState(() => _baseUnit = val); + } + }, + ); + }, ), + if (!isJewellery) ...[ + const SizedBox(height: 16), + DropdownButtonFormField( + value: _defaultLengthUom, + decoration: const InputDecoration( + labelText: 'Default Length UoM', + prefixIcon: Icon(LucideIcons.ruler), + ), + items: const [ + DropdownMenuItem(value: 'cm', child: Text('Centimeters (cm)')), + DropdownMenuItem(value: 'in', child: Text('Inches (in)')), + DropdownMenuItem(value: 'mm', child: Text('Millimeters (mm)')), + DropdownMenuItem(value: 'm', child: Text('Meters (m)')), + ], + onChanged: (val) => setState(() => _defaultLengthUom = val ?? 'cm'), + ), + ], ], const SizedBox(height: 40), ], diff --git a/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart b/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart index fb00622..f2b8240 100644 --- a/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart @@ -1,11 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:kifi_app/features/inventory/providers/products_provider.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:kifi_app/core/network/dio_client.dart'; +import 'package:kifi_app/core/widgets/responsive_layout.dart'; +import 'package:kifi_app/features/business/providers/business_provider.dart'; import 'package:kifi_app/features/inventory/domain/product.dart'; import 'package:kifi_app/features/inventory/presentation/add_product_screen.dart'; import 'package:kifi_app/features/inventory/presentation/stock_ledger_tab.dart'; -import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart'; +import 'package:kifi_app/features/inventory/providers/product_categories_provider.dart'; +import 'package:kifi_app/features/inventory/providers/products_provider.dart'; +import 'package:kifi_app/features/inventory/providers/uoms_provider.dart'; +import 'package:kifi_app/features/transactions/presentation/widgets/attachment_gallery_screen.dart'; class ProductDetailScreen extends ConsumerWidget { final Product product; @@ -70,79 +75,333 @@ class ProductDetailScreen extends ConsumerWidget { } Widget _buildOverviewTab(BuildContext context, Product p, WidgetRef ref) { + final businessProfile = ref.watch(businessProfileProvider).value; + final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase(); + final isJewellery = nature == 'JEWELLERY'; + + final categories = ref.watch(productCategoriesProvider).value ?? []; + final category = p.categoryId != null + ? categories.where((c) => c.id == p.categoryId).firstOrNull + : null; + + final uoms = ref.watch(uomsProvider).value ?? []; + final uom = p.uomId != null + ? uoms.where((u) => u.id == p.uomId).firstOrNull + : null; + + final hasSpecs = (p.brandName != null && p.brandName!.isNotEmpty) || + (p.manufacturerCode != null && p.manufacturerCode!.isNotEmpty) || + (p.material != null && p.material!.isNotEmpty) || + (p.color != null && p.color!.isNotEmpty) || + (p.countryOfOrigin != null && p.countryOfOrigin!.isNotEmpty); + + final hasDimensions = p.itemLength != null || + p.itemWidth != null || + p.itemHeight != null || + p.weightInG != null; + + final hasPackaging = p.packageLength != null || + p.packageWidth != null || + p.packageHeight != null || + p.packageWeightInG != null || + p.volumetricWeightInKg != null; + return SingleChildScrollView( padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Image Header - if (p.imageIds.isNotEmpty) - Container( - height: 200, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.grey[200], - ), - clipBehavior: Clip.antiAlias, - child: FutureBuilder( - future: DioClient().storage.read(key: 'jwt_token'), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - final token = snapshot.data; - if (token == null) { - return const Icon( - Icons.image, - size: 50, - color: Colors.grey, - ); - } - return Image.network( - '${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content', - fit: BoxFit.cover, - headers: {'Authorization': 'Bearer $token'}, - errorBuilder: (context, error, stackTrace) => - const Icon(Icons.image, size: 50, color: Colors.grey), - ); - }, - ), - ), - const SizedBox(height: 24), + child: MaxContentWidth( + maxWidth: 900, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Images Header & Gallery + if (p.imageIds.isNotEmpty) + _buildImagesSection(context, p), + + if (p.imageIds.isNotEmpty) + const SizedBox(height: 20), - // Product Details Card - Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "Product Details", - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), - const Divider(), - _buildDetailRow("SKU / Barcode", p.sku ?? "-"), - _buildDetailRow("HSN Code", p.hsnCode ?? "-"), - _buildDetailRow( - "GST Rate", - "${p.gstRate?.toStringAsFixed(1) ?? '0'}%", - ), - if (p.makingCharges != null && p.makingCharges! > 0) - _buildDetailRow( - "Making Charges", - p.makingChargesType == 'PERCENTAGE' - ? "${p.makingCharges}%" - : (p.makingChargesType == 'PER_PIECE' - ? "₹${p.makingCharges!.toStringAsFixed(2)} / pc" - : "₹${p.makingCharges!.toStringAsFixed(2)} / g"), + // Product Details Card + Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.package, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + const Text( + "Product Details", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], ), - ], + const Divider(), + _buildDetailRow("SKU / Barcode", p.sku ?? "-"), + if (category != null) + _buildDetailRow("Category", category.name), + if (uom != null) + _buildDetailRow("Unit of Measure", uom.displayName), + _buildDetailRow("HSN Code", p.hsnCode ?? "-"), + _buildDetailRow( + "GST Rate", + "${p.gstRate?.toStringAsFixed(1) ?? '0'}%", + ), + if (isJewellery && p.makingCharges != null && p.makingCharges! > 0) + _buildDetailRow( + "Making Charges", + p.makingChargesType == 'PERCENTAGE' + ? "${p.makingCharges}%" + : (p.makingChargesType == 'PER_PIECE' + ? "₹${p.makingCharges!.toStringAsFixed(2)} / pc" + : "₹${p.makingCharges!.toStringAsFixed(2)} / g"), + ), + ], + ), + ), + ), + + // Non-jewellery Specific Sections + if (!isJewellery) ...[ + if (hasSpecs) ...[ + const SizedBox(height: 16), + Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.sliders, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + const Text( + "Product Specifications", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + const Divider(), + if (p.brandName != null && p.brandName!.isNotEmpty) + _buildDetailRow("Brand Name", p.brandName!), + if (p.manufacturerCode != null && p.manufacturerCode!.isNotEmpty) + _buildDetailRow("Manufacturer Code", p.manufacturerCode!), + if (p.material != null && p.material!.isNotEmpty) + _buildDetailRow("Material", p.material!), + if (p.color != null && p.color!.isNotEmpty) + _buildDetailRow("Color", p.color!), + if (p.countryOfOrigin != null && p.countryOfOrigin!.isNotEmpty) + _buildDetailRow("Country of Origin", p.countryOfOrigin!), + ], + ), + ), + ), + ], + + if (hasDimensions) ...[ + const SizedBox(height: 16), + Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.ruler, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + const Text( + "Item Dimensions & Weight", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + const Divider(), + if (p.itemLength != null || p.itemWidth != null || p.itemHeight != null) + _buildDetailRow( + "Dimensions (L × W × H)", + "${p.itemLength ?? '-'} × ${p.itemWidth ?? '-'} × ${p.itemHeight ?? '-'} ${p.dimensionUom ?? 'cm'}", + ), + if (p.weightInG != null) + _buildDetailRow("Item Weight", "${p.weightInG} g"), + ], + ), + ), + ), + ], + + if (hasPackaging) ...[ + const SizedBox(height: 16), + Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.box, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + const Text( + "Packaging Dimensions & Weight", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + const Divider(), + if (p.packageLength != null || p.packageWidth != null || p.packageHeight != null) + _buildDetailRow( + "Package (L × W × H)", + "${p.packageLength ?? '-'} × ${p.packageWidth ?? '-'} × ${p.packageHeight ?? '-'} ${p.packageDimensionUom ?? 'cm'}", + ), + if (p.packageWeightInG != null) + _buildDetailRow("Package Weight", "${p.packageWeightInG} g"), + if (p.volumetricWeightInKg != null) + _buildDetailRow( + "Volumetric Weight", + "${p.volumetricWeightInKg} kg", + highlight: true, + ), + ], + ), + ), + ), + ], + ], + ], + ), + ), + ); + } + + Widget _buildImagesSection(BuildContext context, Product p) { + return FutureBuilder( + future: DioClient().storage.read(key: 'jwt_token'), + builder: (context, snapshot) { + final token = snapshot.data; + if (token == null) { + return const SizedBox.shrink(); + } + + final allImages = p.imageIds.map((imageId) { + return NetworkImage( + '${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/$imageId/content', + headers: {'Authorization': 'Bearer $token'}, + ); + }).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AttachmentGalleryScreen( + images: allImages, + initialIndex: 0, + ), + ), + ); + }, + child: Container( + height: 220, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Colors.grey[200], + ), + clipBehavior: Clip.antiAlias, + child: Image.network( + '${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content', + fit: BoxFit.cover, + headers: {'Authorization': 'Bearer $token'}, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.image, size: 50, color: Colors.grey), + ), + ), + ), + if (p.imageIds.length > 1) ...[ + const SizedBox(height: 12), + SizedBox( + height: 64, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: p.imageIds.length, + separatorBuilder: (context, index) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final imageId = p.imageIds[index]; + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AttachmentGalleryScreen( + images: allImages, + initialIndex: index, + ), + ), + ); + }, + child: Container( + width: 64, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + clipBehavior: Clip.antiAlias, + child: Image.network( + '${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/$imageId/content', + fit: BoxFit.cover, + headers: {'Authorization': 'Bearer $token'}, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.image, size: 24, color: Colors.grey), + ), + ), + ); + }, + ), + ), + ], + ], + ); + }, + ); + } + + Widget _buildDetailRow(String label, String value, {bool highlight = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 14)), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: highlight ? const Color(0xFF4F46E5) : Colors.black87, ), ), ), @@ -150,17 +409,5 @@ class ProductDetailScreen extends ConsumerWidget { ), ); } - - Widget _buildDetailRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: TextStyle(color: Colors.grey[600])), - Text(value, style: const TextStyle(fontWeight: FontWeight.w500)), - ], - ), - ); - } } + 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 96e9cea..d48d6db 100644 --- a/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart +++ b/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart @@ -10,6 +10,10 @@ 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'; @@ -90,6 +94,10 @@ class _StockLedgerTabState extends ConsumerState { 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); @@ -97,20 +105,24 @@ class _StockLedgerTabState extends ConsumerState { (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 (category?.commodityCode != null && commodityRatesState.value != null) { + if (isCommodity && commodityRatesState.value != null && category.commodityCode != null) { final match = commodityRatesState.value!.where( - (r) => r.commodityCode.toUpperCase() == category!.commodityCode!.toUpperCase() + (r) => r.commodityCode.toUpperCase() == category.commodityCode!.toUpperCase() ).firstOrNull; if (match != null) { commodityRate = match.rate; } } - final double currentRate = commodityRate > 0 ? (commodityRate * purityFactor) : (category?.dailyRate ?? 0.0); + final double currentRate = isCommodity + ? (commodityRate > 0 ? (commodityRate * purityFactor) : (category.dailyRate ?? 0.0)) + : 0.0; return Column( children: [ @@ -147,6 +159,31 @@ class _StockLedgerTabState extends ConsumerState { 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! @@ -202,10 +239,13 @@ class _StockLedgerTabState extends ConsumerState { } final purchasePrice = weight * purchaseRate; - final currentPrice = weight * currentRate; - final gainLoss = currentPrice - purchasePrice; + 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), @@ -325,46 +365,91 @@ class _StockLedgerTabState extends ConsumerState { 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); + 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')), + ); + } } - 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, + }, + child: Text( + 'INV: $salesInvoiceNumber', + style: const TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + fontWeight: FontWeight.w600, + fontSize: 12, + ), ), ), - ), - if (item.huid != null && item.huid!.isNotEmpty) + ] 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: 4.0), + padding: const EdgeInsets.only(top: 2.0), child: Text('HUID: ${item.huid}', style: const TextStyle(fontWeight: FontWeight.w500)), ), ], @@ -399,94 +484,175 @@ class _StockLedgerTabState extends ConsumerState { 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, + 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), - 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, + if (isCommodity) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Icon( - isGain ? LucideIcons.trendingUp : LucideIcons.trendingDown, - color: isGain ? Colors.green : Colors.red, - size: 16, + 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)), + ], ), - 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, - ), + 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, + ), + ), + ], + ), + ); + }, + ), + ], + ], ], ), ), diff --git a/kifi-app/lib/features/inventory/providers/product_categories_provider.dart b/kifi-app/lib/features/inventory/providers/product_categories_provider.dart index 75b874a..aa4436d 100644 --- a/kifi-app/lib/features/inventory/providers/product_categories_provider.dart +++ b/kifi-app/lib/features/inventory/providers/product_categories_provider.dart @@ -16,6 +16,7 @@ class ProductCategory { final double? defaultMakingCharge; final String? makingChargeType; final String baseUnit; + final String defaultLengthUom; final bool isActive; final int sortOrder; final double? dailyRate; @@ -34,6 +35,7 @@ class ProductCategory { this.defaultMakingCharge, this.makingChargeType, this.baseUnit = 'pcs', + this.defaultLengthUom = 'cm', this.isActive = true, this.sortOrder = 0, this.dailyRate, @@ -54,6 +56,7 @@ class ProductCategory { defaultMakingCharge: (json['defaultMakingCharge'] ?? json['default_making_charge'] as num?)?.toDouble(), makingChargeType: json['makingChargeType'] ?? json['making_charge_type'], baseUnit: json['baseUnit'] ?? json['base_unit'] ?? 'pcs', + defaultLengthUom: json['defaultLengthUom'] ?? json['default_length_uom'] ?? 'cm', isActive: json['isActive'] ?? json['is_active'] ?? true, sortOrder: json['sortOrder'] ?? json['sort_order'] ?? 0, dailyRate: (json['dailyRate'] ?? json['daily_rate'] as num?)?.toDouble(), @@ -75,6 +78,7 @@ class ProductCategory { 'defaultMakingCharge': defaultMakingCharge, 'makingChargeType': makingChargeType, 'baseUnit': baseUnit, + 'defaultLengthUom': defaultLengthUom, 'isActive': isActive, 'sortOrder': sortOrder, }; diff --git a/kifi-app/lib/features/inventory/providers/uoms_provider.dart b/kifi-app/lib/features/inventory/providers/uoms_provider.dart index c6ad4ba..ab94fef 100644 --- a/kifi-app/lib/features/inventory/providers/uoms_provider.dart +++ b/kifi-app/lib/features/inventory/providers/uoms_provider.dart @@ -57,11 +57,20 @@ class UomsNotifier extends AsyncNotifier> { // If empty in database, seed standard units of measure try { final defaults = [ + {'name': 'Pieces', 'abbreviation': 'pcs'}, + {'name': 'Units', 'abbreviation': 'unit'}, + {'name': 'Boxes', 'abbreviation': 'box'}, + {'name': 'Sets', 'abbreviation': 'set'}, + {'name': 'Pairs', 'abbreviation': 'pair'}, + {'name': 'Packs', 'abbreviation': 'pk'}, {'name': 'Grams', 'abbreviation': 'g'}, {'name': 'Kilograms', 'abbreviation': 'kg'}, - {'name': 'Pieces', 'abbreviation': 'pcs'}, - {'name': 'Carats', 'abbreviation': 'ct'}, {'name': 'Milligrams', 'abbreviation': 'mg'}, + {'name': 'Carats', 'abbreviation': 'ct'}, + {'name': 'Meters', 'abbreviation': 'm'}, + {'name': 'Liters', 'abbreviation': 'l'}, + {'name': 'Milliliters', 'abbreviation': 'ml'}, + {'name': 'Dozens', 'abbreviation': 'doz'}, ]; for (final def in defaults) { await DioClient().dio.post('/inventory/uom', data: def); diff --git a/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart b/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart index 01e9417..9c51d5e 100644 --- a/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart +++ b/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart @@ -1175,6 +1175,9 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> { }) { final sku = product.sku; final qtyCtrl = _getQtyController(product.id ?? 0); + final businessProfile = ref.watch(businessProfileProvider).value; + final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase(); + final isJewellery = nature == 'JEWELLERY'; return Container( margin: const EdgeInsets.only(bottom: 12), @@ -1228,28 +1231,31 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> { _buildPOSheetBadge(category!.name, const Color(0xFFD4AF37)), if (sku != null && sku.isNotEmpty) _buildPOSheetBadge('SKU: $sku', Colors.blue), - _buildPOSheetBadge('Purity: ${formatPurity(resolvePurity(categoryPurity: category?.purityFactor, productPurity: product.purityFactor))}', Colors.teal), + if (isJewellery) + _buildPOSheetBadge('Purity: ${formatPurity(resolvePurity(categoryPurity: category?.purityFactor, productPurity: product.purityFactor))}', Colors.teal), ], ), ], ), ), - const SizedBox(width: 12), - // Small box to enter number of items - SizedBox( - width: 75, - child: TextFormField( - controller: qtyCtrl, - keyboardType: TextInputType.number, - textAlign: TextAlign.center, - decoration: InputDecoration( - labelText: 'Qty', - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + if (isJewellery) ...[ + const SizedBox(width: 12), + // Small box to enter number of items + SizedBox( + width: 75, + child: TextFormField( + controller: qtyCtrl, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + decoration: InputDecoration( + labelText: 'Qty', + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + ), ), ), - ), + ], ], ), const Divider(height: 20), @@ -1257,7 +1263,7 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> { width: double.infinity, child: ElevatedButton.icon( onPressed: () { - final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1; + final qty = isJewellery ? (int.tryParse(qtyCtrl.text.trim()) ?? 1) : 1; _addItemsFromCard(product, category, qty); }, style: ElevatedButton.styleFrom( @@ -1278,7 +1284,7 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> { } -class _POItemRow extends StatefulWidget { +class _POItemRow extends ConsumerStatefulWidget { final PurchaseOrderItem item; final String productName; final Product? product; @@ -1300,14 +1306,15 @@ class _POItemRow extends StatefulWidget { }); @override - State<_POItemRow> createState() => _POItemRowState(); + ConsumerState<_POItemRow> createState() => _POItemRowState(); } -class _POItemRowState extends State<_POItemRow> { +class _POItemRowState extends ConsumerState<_POItemRow> { late TextEditingController _weightCtrl; late TextEditingController _rateCtrl; late TextEditingController _huidCtrl; late TextEditingController _totalCtrl; + bool _isUploadingPhoto = false; @override void initState() { @@ -1339,15 +1346,13 @@ class _POItemRowState extends State<_POItemRow> { final updated = widget.item.copyWith( weight: weight, - huid: _huidCtrl.text.isEmpty ? null : _huidCtrl.text, unitPrice: rate, total: total, + huid: _huidCtrl.text.isEmpty ? null : _huidCtrl.text, ); widget.onChanged(updated); } - bool _isUploadingPhoto = false; - Future _pickPhoto() async { try { final picker = ImagePicker(); @@ -1393,6 +1398,10 @@ class _POItemRowState extends State<_POItemRow> { @override Widget build(BuildContext context) { + final businessProfile = ref.watch(businessProfileProvider).value; + final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase(); + final isJewellery = nature == 'JEWELLERY'; + return Card( margin: const EdgeInsets.only(bottom: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), @@ -1534,18 +1543,19 @@ class _POItemRowState extends State<_POItemRow> { style: TextStyle(fontSize: 10, color: Colors.grey[700]), ), ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.amber.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.amber.withValues(alpha: 0.4)), + if (isJewellery) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.amber.withValues(alpha: 0.4)), + ), + child: Text( + 'Purity: ${formatPurity(resolvePurity(categoryPurity: widget.category?.purityFactor, productPurity: widget.product?.purityFactor))}', + style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.amber.shade900), + ), ), - child: Text( - 'Purity: ${formatPurity(resolvePurity(categoryPurity: widget.category?.purityFactor, productPurity: widget.product?.purityFactor))}', - style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.amber.shade900), - ), - ), ], ), ], @@ -1558,83 +1568,174 @@ class _POItemRowState extends State<_POItemRow> { ], ), const SizedBox(height: 12), - Row( - children: [ - Expanded( - flex: 2, - child: PremiumTextField( - controller: _huidCtrl, - labelText: 'HUID', - textCapitalization: TextCapitalization.characters, - onChanged: (_) => _updateItem(), - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 2, - child: PremiumTextField( - controller: _weightCtrl, - labelText: 'Weight/Pcs', - keyboardType: const TextInputType.numberWithOptions(decimal: true), - onChanged: (_) => _updateItem(), - suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty) - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: Colors.grey.withValues(alpha: 0.1), - borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)), - border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))), + LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 600; + + if (isWide) { + return Row( + children: [ + if (isJewellery) ...[ + Expanded( + flex: 2, + child: PremiumTextField( + controller: _huidCtrl, + labelText: 'HUID', + textCapitalization: TextCapitalization.characters, + onChanged: (_) => _updateItem(), + ), + ), + const SizedBox(width: 8), + ], + Expanded( + flex: isJewellery ? 2 : 3, + child: PremiumTextField( + controller: _weightCtrl, + labelText: isJewellery ? 'Weight/Pcs' : 'Weight/Pcs/Length', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => _updateItem(), + suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty) + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.1), + borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)), + border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [Text(widget.category?.baseUnit ?? '', style: TextStyle(color: Colors.grey[700]))], + ), + ) + : null, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: isJewellery ? 2 : 3, + child: PremiumTextField( + controller: _rateCtrl, + labelText: 'Rate', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => _updateItem(), + suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty) + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.1), + borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)), + border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [Text('per ${widget.category?.baseUnit ?? ''}', style: TextStyle(color: Colors.grey[700]))], + ), + ) + : null, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: isJewellery ? 2 : 3, + child: PremiumTextField( + controller: _totalCtrl, + labelText: 'Purchase Amt', + keyboardType: TextInputType.number, + readOnly: true, + onChanged: (_) => _updateItem(), + ), + ), + ], + ); + } + + return Column( + children: [ + Row( + children: [ + if (isJewellery) ...[ + Expanded( + flex: 2, + child: PremiumTextField( + controller: _huidCtrl, + labelText: 'HUID', + textCapitalization: TextCapitalization.characters, + onChanged: (_) => _updateItem(), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [Text(widget.category?.baseUnit ?? '', style: TextStyle(color: Colors.grey[700]))], - ), - ) - : null, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - flex: 2, - child: PremiumTextField( - controller: _rateCtrl, - labelText: 'Rate', - keyboardType: const TextInputType.numberWithOptions(decimal: true), - onChanged: (_) => _updateItem(), - suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty) - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: Colors.grey.withValues(alpha: 0.1), - borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)), - border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [Text('per ${widget.category?.baseUnit ?? ''}', style: TextStyle(color: Colors.grey[700]))], - ), - ) - : null, - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 2, - child: PremiumTextField( - controller: _totalCtrl, - labelText: 'Purchase Amt', - keyboardType: TextInputType.number, - readOnly: true, - onChanged: (_) => _updateItem(), - ), - ), - ], + ), + const SizedBox(width: 8), + ], + Expanded( + flex: 2, + child: PremiumTextField( + controller: _weightCtrl, + labelText: isJewellery ? 'Weight/Pcs' : 'Weight/Pcs/Length', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => _updateItem(), + suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty) + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.1), + borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)), + border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [Text(widget.category?.baseUnit ?? '', style: TextStyle(color: Colors.grey[700]))], + ), + ) + : null, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + flex: 2, + child: PremiumTextField( + controller: _rateCtrl, + labelText: 'Rate', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => _updateItem(), + suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty) + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.1), + borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)), + border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [Text('per ${widget.category?.baseUnit ?? ''}', style: TextStyle(color: Colors.grey[700]))], + ), + ) + : null, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: PremiumTextField( + controller: _totalCtrl, + labelText: 'Purchase Amt', + keyboardType: TextInputType.number, + readOnly: true, + onChanged: (_) => _updateItem(), + ), + ), + ], + ), + ], + ); + }, ), const SizedBox(height: 16), _buildTaxAndTotal(),