Tuned ui to support ecommerce product catalogue and purchases.

This commit is contained in:
2026-09-01 21:11:32 +05:30
parent 3a5e812947
commit b4772ccf19
16 changed files with 1798 additions and 600 deletions

View File

@@ -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());

View File

@@ -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";

View File

@@ -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;

View File

@@ -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;

View File

@@ -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());

View File

@@ -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");
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());
BigDecimal cost = invItem.getPurchaseCost() != null ? invItem.getPurchaseCost() : BigDecimal.ZERO;
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);
})

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -36,8 +36,27 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
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<XFile> _images = [];
@@ -58,11 +77,32 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
_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<AddProductScreen> {
_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<void> _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<AddProductScreen> {
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<AddProductScreen> {
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,
@@ -275,10 +382,28 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
if (val.makingChargeType != null && val.makingChargeType!.isNotEmpty) {
_makingChargesType = val.makingChargeType!;
}
if (_uomId == null && uoms.isNotEmpty) {
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?.toLowerCase() == val.baseUnit.toLowerCase() ||
u.name.toLowerCase() == val.baseUnit.toLowerCase()
(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;
@@ -336,6 +461,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
),
],
),
if (isJewellery) ...[
const SizedBox(height: 16),
Row(
children: [
@@ -367,8 +494,245 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
),
],
),
const SizedBox(height: 24),
],
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<String>(
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<String>(
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)
@@ -448,21 +812,36 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
}
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<AddProductScreen> {
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<AddProductScreen> {
),
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<AddProductScreen> {
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),
),

View File

@@ -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(),
),
error: (err, stack) => DropdownButtonFormField<String>(
value: _baseUnit,
decoration: const InputDecoration(labelText: 'Base Unit'),
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)')),
],
DropdownMenuItem(value: _baseUnit, child: Text(_baseUnit)),
],
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),
],

View File

@@ -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,46 +75,50 @@ 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: MaxContentWidth(
maxWidth: 900,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image Header
// Images Header & Gallery
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),
_buildImagesSection(context, p),
if (p.imageIds.isNotEmpty)
const SizedBox(height: 20),
// Product Details Card
Card(
@@ -122,18 +131,28 @@ class ProductDetailScreen extends ConsumerWidget {
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 (p.makingCharges != null && p.makingCharges! > 0)
if (isJewellery && p.makingCharges != null && p.makingCharges! > 0)
_buildDetailRow(
"Making Charges",
p.makingChargesType == 'PERCENTAGE'
@@ -146,21 +165,249 @@ class ProductDetailScreen extends ConsumerWidget {
),
),
),
// 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 _buildDetailRow(String label, String value) {
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])),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
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,
),
),
),
],
),
);
}
}

View File

@@ -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,6 +365,49 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
const SizedBox(height: 4),
if (isSold) ...[
if (salesInvoiceNumber != null && salesInvoiceNumber.isNotEmpty)
InkWell(
onTap: () async {
try {
var inv = matchingSaleInvoice;
if (inv == null) {
var invs = ref.read(invoicesProvider).value;
if (invs == null || invs.isEmpty) {
invs = await ref.read(invoicesProvider.future);
}
inv = invs?.where((i) => i.invoiceNumber == salesInvoiceNumber).firstOrNull;
}
if (inv == null) {
throw Exception('Sales invoice $salesInvoiceNumber not found');
}
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceDetailsScreen(invoice: inv!),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open sales invoice: $e')),
);
}
}
},
child: Text(
'INV: $salesInvoiceNumber',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
] else ...[
if (item.purchaseRef != null)
InkWell(
onTap: () async {
@@ -337,7 +420,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
(p) => p.poNumber == item.purchaseRef,
orElse: () => throw Exception('Purchase invoice not found'),
);
if (mounted) {
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(
@@ -346,7 +429,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
);
}
} catch (e) {
if (mounted) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open purchase invoice: $e')),
);
@@ -359,12 +442,14 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
color: Colors.blue,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
if (item.huid != null && item.huid!.isNotEmpty)
],
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,6 +484,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
color: isSold ? Colors.grey.shade600 : null,
),
),
if (isCommodity) ...[
const SizedBox(height: 3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
@@ -417,10 +503,12 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
),
),
],
],
),
],
),
const Divider(height: 24),
if (isCommodity) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -448,7 +536,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
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)),
Text('${displayedSellingRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
@@ -487,6 +575,84 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
],
),
),
] 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,
),
),
],
),
);
},
),
],
],
],
),
),

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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,12 +1231,14 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
_buildPOSheetBadge(category!.name, const Color(0xFFD4AF37)),
if (sku != null && sku.isNotEmpty)
_buildPOSheetBadge('SKU: $sku', Colors.blue),
if (isJewellery)
_buildPOSheetBadge('Purity: ${formatPurity(resolvePurity(categoryPurity: category?.purityFactor, productPurity: product.purityFactor))}', Colors.teal),
],
),
],
),
),
if (isJewellery) ...[
const SizedBox(width: 12),
// Small box to enter number of items
SizedBox(
@@ -1251,13 +1256,14 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
),
),
],
],
),
const Divider(height: 20),
SizedBox(
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,6 +1543,7 @@ class _POItemRowState extends State<_POItemRow> {
style: TextStyle(fontSize: 10, color: Colors.grey[700]),
),
),
if (isJewellery)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
@@ -1558,8 +1568,14 @@ class _POItemRowState extends State<_POItemRow> {
],
),
const SizedBox(height: 12),
Row(
LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 600;
if (isWide) {
return Row(
children: [
if (isJewellery) ...[
Expanded(
flex: 2,
child: PremiumTextField(
@@ -1570,11 +1586,92 @@ class _POItemRowState extends State<_POItemRow> {
),
),
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(),
),
),
const SizedBox(width: 8),
],
Expanded(
flex: 2,
child: PremiumTextField(
controller: _weightCtrl,
labelText: 'Weight/Pcs',
labelText: isJewellery ? 'Weight/Pcs' : 'Weight/Pcs/Length',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _updateItem(),
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
@@ -1636,6 +1733,10 @@ class _POItemRowState extends State<_POItemRow> {
),
],
),
],
);
},
),
const SizedBox(height: 16),
_buildTaxAndTotal(),
],