Tuned ui to support ecommerce product catalogue and purchases.
This commit is contained in:
@@ -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());
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<Void> adjustMono = productService.adjustStock(userId, item.getProductId(), movement).then();
|
||||
|
||||
Mono<BigDecimal> 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<Mono<Void>> 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);
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<CategoryFormSheet> {
|
||||
String _commodityCode = 'XAU';
|
||||
String _makingChargeType = 'PER_GRAM';
|
||||
String _baseUnit = 'pcs';
|
||||
String _defaultLengthUom = 'cm';
|
||||
|
||||
final List<Map<String, String>> _commodities = [
|
||||
{'code': 'XAU', 'label': 'XAU - Gold'},
|
||||
@@ -325,6 +326,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
|
||||
|
||||
_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<CategoryFormSheet> {
|
||||
_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<CategoryFormSheet> {
|
||||
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<CategoryFormSheet> {
|
||||
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<CategoryFormSheet> {
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
DropdownButtonFormField<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
value: val,
|
||||
child: Text(u.displayName),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() => _baseUnit = val);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!isJewellery) ...[
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
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),
|
||||
],
|
||||
|
||||
@@ -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<String?>(
|
||||
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<String?>(
|
||||
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<ImageProvider>((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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<StockLedgerTab> {
|
||||
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<StockLedgerTab> {
|
||||
(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<StockLedgerTab> {
|
||||
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<StockLedgerTab> {
|
||||
}
|
||||
|
||||
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<StockLedgerTab> {
|
||||
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<StockLedgerTab> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -57,11 +57,20 @@ class UomsNotifier extends AsyncNotifier<List<UnitOfMeasure>> {
|
||||
// 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);
|
||||
|
||||
@@ -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<void> _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(),
|
||||
|
||||
Reference in New Issue
Block a user