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.setDefaultMakingCharge(category.getDefaultMakingCharge());
existing.setMakingChargeType(category.getMakingChargeType()); existing.setMakingChargeType(category.getMakingChargeType());
existing.setBaseUnit(category.getBaseUnit()); existing.setBaseUnit(category.getBaseUnit());
existing.setDefaultLengthUom(category.getDefaultLengthUom());
existing.setIsActive(category.getIsActive()); existing.setIsActive(category.getIsActive());
existing.setSortOrder(category.getSortOrder()); existing.setSortOrder(category.getSortOrder());

View File

@@ -39,6 +39,8 @@ public class InventoryItem {
private Long vendorId; private Long vendorId;
private Long branchId; private Long branchId;
private String purchaseRef; private String purchaseRef;
private String salesRef;
private BigDecimal saleRate;
private String photoUrl; private String photoUrl;
@Builder.Default @Builder.Default
private String status = "AVAILABLE"; private String status = "AVAILABLE";

View File

@@ -33,6 +33,26 @@ public class Product {
private String dimensions; private String dimensions;
private String color; private String color;
private String size; 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 String priceCalcRule;
private Double purityFactor; private Double purityFactor;
private Double makingCharges; private Double makingCharges;

View File

@@ -34,6 +34,8 @@ public class ProductCategory {
private String makingChargeType; private String makingChargeType;
private String baseUnit; private String baseUnit;
@Builder.Default
private String defaultLengthUom = "cm";
@Builder.Default @Builder.Default
private Boolean isActive = true; private Boolean isActive = true;

View File

@@ -102,6 +102,26 @@ public class ProductService {
existingProduct.setColor(updatedProduct.getColor()); existingProduct.setColor(updatedProduct.getColor());
existingProduct.setSize(updatedProduct.getSize()); existingProduct.setSize(updatedProduct.getSize());
existingProduct.setDimensions(updatedProduct.getDimensions()); 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.setPriceCalcRule(updatedProduct.getPriceCalcRule());
existingProduct.setPurityFactor(normalizePurity(updatedProduct.getPurityFactor())); existingProduct.setPurityFactor(normalizePurity(updatedProduct.getPurityFactor()));
existingProduct.setMakingCharges(updatedProduct.getMakingCharges()); existingProduct.setMakingCharges(updatedProduct.getMakingCharges());

View File

@@ -146,15 +146,64 @@ public class InvoiceService {
if (shouldDeduct && invoice.getItems() != null && !invoice.getItems().isEmpty()) { if (shouldDeduct && invoice.getItems() != null && !invoice.getItems().isEmpty()) {
return Flux.fromIterable(invoice.getItems()) return Flux.fromIterable(invoice.getItems())
.concatMap(item -> { .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) { if (item.getInventoryItemId() != null) {
return inventoryItemRepository.findById(item.getInventoryItemId()) return inventoryItemRepository.findById(item.getInventoryItemId())
.flatMap(invItem -> { .flatMap(invItem -> {
invItem.setStatus("SOLD"); BigDecimal currentWeight = (invItem.getGrossWeight() != null && invItem.getGrossWeight().compareTo(BigDecimal.ZERO) > 0)
invItem.setUpdatedAt(LocalDateTime.now()); ? invItem.getGrossWeight()
BigDecimal cost = invItem.getPurchaseCost() != null ? invItem.getPurchaseCost() : BigDecimal.ZERO; : ((invItem.getNetWeight() != null && invItem.getNetWeight().compareTo(BigDecimal.ZERO) > 0)
return inventoryItemRepository.save(invItem).thenReturn(cost); ? 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); }).defaultIfEmpty(BigDecimal.ZERO);
} else if (item.getProductId() != null) { } else if (item.getProductId() != null) {
// Product sale without explicit inventory item: adjust product stock & FIFO deduct inventory items
InventoryMovement movement = InventoryMovement.builder() InventoryMovement movement = InventoryMovement.builder()
.userId(userId) .userId(userId)
.type("REDUCTION") .type("REDUCTION")
@@ -167,8 +216,72 @@ public class InvoiceService {
movementItem.setQuantity(item.getQuantity()); movementItem.setQuantity(item.getQuantity());
movement.setItems(java.util.Collections.singletonList(movementItem)); movement.setItems(java.util.Collections.singletonList(movementItem));
return productService.adjustStock(userId, item.getProductId(), movement) Mono<Void> adjustMono = productService.adjustStock(userId, item.getProductId(), movement).then();
.thenReturn(BigDecimal.ZERO);
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); return Mono.just(BigDecimal.ZERO);
}) })

View File

@@ -208,6 +208,7 @@ CREATE TABLE IF NOT EXISTS product_categories (
commodity_code VARCHAR(10), commodity_code VARCHAR(10),
purity_factor DECIMAL(5, 4) DEFAULT 1.0, purity_factor DECIMAL(5, 4) DEFAULT 1.0,
base_unit VARCHAR(20) DEFAULT 'pcs', base_unit VARCHAR(20) DEFAULT 'pcs',
default_length_uom VARCHAR(20) DEFAULT 'cm',
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
@@ -249,6 +250,21 @@ CREATE TABLE IF NOT EXISTS products (
weight DECIMAL(10, 3), weight DECIMAL(10, 3),
color VARCHAR(50), color VARCHAR(50),
size 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', price_calc_rule VARCHAR(50) DEFAULT 'MANUAL',
purity_factor DECIMAL(5, 4), purity_factor DECIMAL(5, 4),
making_charges DECIMAL(15, 2) DEFAULT 0.0, 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, vendor_id INTEGER REFERENCES vendors(id) ON DELETE SET NULL,
branch_id INTEGER, branch_id INTEGER,
purchase_ref VARCHAR(100), purchase_ref VARCHAR(100),
sales_ref VARCHAR(100),
sale_rate DECIMAL(15, 2),
photo_url VARCHAR(1024), photo_url VARCHAR(1024),
status VARCHAR(50) DEFAULT 'AVAILABLE', status VARCHAR(50) DEFAULT 'AVAILABLE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

View File

@@ -21,6 +21,8 @@ class InventoryItem {
final int? vendorId; final int? vendorId;
final int? branchId; final int? branchId;
final String? purchaseRef; final String? purchaseRef;
final String? salesRef;
final double? saleRate;
final String? photoUrl; final String? photoUrl;
final String? status; final String? status;
final DateTime? createdAt; final DateTime? createdAt;
@@ -48,6 +50,8 @@ class InventoryItem {
this.vendorId, this.vendorId,
this.branchId, this.branchId,
this.purchaseRef, this.purchaseRef,
this.salesRef,
this.saleRate,
this.photoUrl, this.photoUrl,
this.status, this.status,
this.createdAt, this.createdAt,
@@ -77,6 +81,8 @@ class InventoryItem {
vendorId: json['vendorId'], vendorId: json['vendorId'],
branchId: json['branchId'], branchId: json['branchId'],
purchaseRef: json['purchaseRef'], purchaseRef: json['purchaseRef'],
salesRef: json['salesRef'] ?? json['sales_ref'],
saleRate: (json['saleRate'] ?? json['sale_rate'])?.toDouble(),
photoUrl: json['photoUrl'] ?? json['photo_url'], photoUrl: json['photoUrl'] ?? json['photo_url'],
status: json['status'], status: json['status'],
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,

View File

@@ -14,6 +14,26 @@ class Product {
final String? dimensions; final String? dimensions;
final String? color; final String? color;
final String? size; 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 String priceCalcRule;
final bool autoCalculatePrice; final bool autoCalculatePrice;
final double? purityFactor; final double? purityFactor;
@@ -39,6 +59,21 @@ class Product {
this.dimensions, this.dimensions,
this.color, this.color,
this.size, 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.priceCalcRule = 'MANUAL',
this.autoCalculatePrice = false, this.autoCalculatePrice = false,
this.purityFactor, this.purityFactor,
@@ -67,6 +102,21 @@ class Product {
dimensions: json['dimensions'], dimensions: json['dimensions'],
color: json['color'], color: json['color'],
size: json['size'], 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: priceCalcRule:
json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL', json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL',
autoCalculatePrice: autoCalculatePrice:
@@ -116,6 +166,21 @@ class Product {
'dimensions': dimensions, 'dimensions': dimensions,
'color': color, 'color': color,
'size': size, '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, 'priceCalcRule': priceCalcRule,
'autoCalculatePrice': autoCalculatePrice, 'autoCalculatePrice': autoCalculatePrice,
'purityFactor': purityFactor, 'purityFactor': purityFactor,

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/product_categories_provider.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_mode_provider.dart';
import '../../business/providers/business_provider.dart'; import '../../business/providers/business_provider.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
@@ -307,6 +307,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
String _commodityCode = 'XAU'; String _commodityCode = 'XAU';
String _makingChargeType = 'PER_GRAM'; String _makingChargeType = 'PER_GRAM';
String _baseUnit = 'pcs'; String _baseUnit = 'pcs';
String _defaultLengthUom = 'cm';
final List<Map<String, String>> _commodities = [ final List<Map<String, String>> _commodities = [
{'code': 'XAU', 'label': 'XAU - Gold'}, {'code': 'XAU', 'label': 'XAU - Gold'},
@@ -325,6 +326,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
_baseUnit = isJewellery ? 'g' : 'pcs'; _baseUnit = isJewellery ? 'g' : 'pcs';
_commodityCode = isJewellery ? 'XAU' : 'NONE'; _commodityCode = isJewellery ? 'XAU' : 'NONE';
_defaultLengthUom = 'cm';
if (widget.categoryToEdit != null) { if (widget.categoryToEdit != null) {
final c = widget.categoryToEdit!; final c = widget.categoryToEdit!;
@@ -343,8 +345,9 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
_makingChargeType = c.makingChargeType ?? 'PER_GRAM'; _makingChargeType = c.makingChargeType ?? 'PER_GRAM';
if (_makingChargeType == 'PER_GM') _makingChargeType = 'PER_GRAM'; if (_makingChargeType == 'PER_GM') _makingChargeType = 'PER_GRAM';
_baseUnit = c.baseUnit ?? (isJewellery ? 'g' : 'pcs'); _baseUnit = c.baseUnit;
if (_baseUnit == 'gm') _baseUnit = 'g'; if (_baseUnit == 'gm') _baseUnit = 'g';
_defaultLengthUom = c.defaultLengthUom;
} }
} else if (widget.parent != null) { } else if (widget.parent != null) {
_selectedParentId = widget.parent!.id; _selectedParentId = widget.parent!.id;
@@ -380,6 +383,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
defaultMakingCharge: _isLeaf && isJewellery ? double.tryParse(_makingChargeController.text) : null, defaultMakingCharge: _isLeaf && isJewellery ? double.tryParse(_makingChargeController.text) : null,
makingChargeType: _isLeaf && isJewellery ? _makingChargeType : null, makingChargeType: _isLeaf && isJewellery ? _makingChargeType : null,
baseUnit: _isLeaf ? _baseUnit : 'pcs', baseUnit: _isLeaf ? _baseUnit : 'pcs',
defaultLengthUom: _isLeaf ? _defaultLengthUom : 'cm',
); );
if (widget.categoryToEdit != null && widget.categoryToEdit!.id != null) { if (widget.categoryToEdit != null && widget.categoryToEdit!.id != null) {
@@ -398,6 +402,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
final businessProfile = ref.watch(businessProfileProvider).value; final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase(); final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY'; final isJewellery = nature == 'JEWELLERY';
final uomsState = ref.watch(uomsProvider);
return Container( return Container(
height: MediaQuery.of(context).size.height * 0.85, height: MediaQuery.of(context).size.height * 0.85,
@@ -623,30 +628,68 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
DropdownButtonFormField<String>( uomsState.when(
value: _baseUnit, loading: () => const Padding(
decoration: const InputDecoration( padding: EdgeInsets.symmetric(vertical: 8),
labelText: 'Base Unit', child: LinearProgressIndicator(),
), ),
items: [ error: (err, stack) => DropdownButtonFormField<String>(
if (isJewellery) ...const [ value: _baseUnit,
DropdownMenuItem(value: 'g', child: Text('Grams (g)')), decoration: const InputDecoration(labelText: 'Base Unit'),
DropdownMenuItem(value: 'kg', child: Text('Kilograms (kg)')), items: [
DropdownMenuItem(value: 'pcs', child: Text('Pieces (pcs)')), DropdownMenuItem(value: _baseUnit, child: Text(_baseUnit)),
] 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)')),
], ],
], 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), const SizedBox(height: 40),
], ],

View File

@@ -1,11 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/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/domain/product.dart';
import 'package:kifi_app/features/inventory/presentation/add_product_screen.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/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 { class ProductDetailScreen extends ConsumerWidget {
final Product product; final Product product;
@@ -70,79 +75,333 @@ class ProductDetailScreen extends ConsumerWidget {
} }
Widget _buildOverviewTab(BuildContext context, Product p, WidgetRef ref) { 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( return SingleChildScrollView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: MaxContentWidth(
crossAxisAlignment: CrossAxisAlignment.start, maxWidth: 900,
children: [ child: Column(
// Image Header crossAxisAlignment: CrossAxisAlignment.start,
if (p.imageIds.isNotEmpty) children: [
Container( // Images Header & Gallery
height: 200, if (p.imageIds.isNotEmpty)
width: double.infinity, _buildImagesSection(context, p),
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),
// Product Details Card if (p.imageIds.isNotEmpty)
Card( const SizedBox(height: 20),
elevation: 0,
shape: RoundedRectangleBorder( // Product Details Card
borderRadius: BorderRadius.circular(12), Card(
), elevation: 0,
child: Padding( shape: RoundedRectangleBorder(
padding: const EdgeInsets.all(16.0), borderRadius: BorderRadius.circular(12),
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, child: Padding(
children: [ padding: const EdgeInsets.all(16.0),
const Text( child: Column(
"Product Details", crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), children: [
), Row(
const Divider(), children: [
_buildDetailRow("SKU / Barcode", p.sku ?? "-"), Icon(LucideIcons.package, size: 20, color: Theme.of(context).colorScheme.primary),
_buildDetailRow("HSN Code", p.hsnCode ?? "-"), const SizedBox(width: 8),
_buildDetailRow( const Text(
"GST Rate", "Product Details",
"${p.gstRate?.toStringAsFixed(1) ?? '0'}%", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
), ),
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"),
), ),
], 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)),
],
),
);
}
} }

View File

@@ -10,6 +10,10 @@ import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.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/presentation/purchase_order_details_screen.dart';
import 'package:kifi_app/features/vendor/providers/purchase_orders_provider.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 'package:kifi_app/core/utils/purity_utils.dart';
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
@@ -90,6 +94,10 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
displayItems = _items!; 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 categoriesState = ref.watch(productCategoriesProvider);
final commodityRatesState = ref.watch(commodityRatesProvider); final commodityRatesState = ref.watch(commodityRatesProvider);
@@ -97,20 +105,24 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
(c) => c.id == widget.product.categoryId, (c) => c.id == widget.product.categoryId,
).firstOrNull; ).firstOrNull;
final bool isCommodity = isJewellery && category?.commodityCode != null && category!.commodityCode!.isNotEmpty;
final String unit = category?.baseUnit ?? 'g'; final String unit = category?.baseUnit ?? 'g';
final double purityFactor = resolvePurity(categoryPurity: category?.purityFactor, productPurity: widget.product.purityFactor); final double purityFactor = resolvePurity(categoryPurity: category?.purityFactor, productPurity: widget.product.purityFactor);
double commodityRate = 0.0; double commodityRate = 0.0;
if (category?.commodityCode != null && commodityRatesState.value != null) { if (isCommodity && commodityRatesState.value != null && category.commodityCode != null) {
final match = commodityRatesState.value!.where( final match = commodityRatesState.value!.where(
(r) => r.commodityCode.toUpperCase() == category!.commodityCode!.toUpperCase() (r) => r.commodityCode.toUpperCase() == category.commodityCode!.toUpperCase()
).firstOrNull; ).firstOrNull;
if (match != null) { if (match != null) {
commodityRate = match.rate; 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( return Column(
children: [ children: [
@@ -147,6 +159,31 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
final isSold = item.status == 'SOLD'; final isSold = item.status == 'SOLD';
final purchaseOrdersState = ref.watch(purchaseOrdersProvider); final purchaseOrdersState = ref.watch(purchaseOrdersProvider);
final purchaseOrders = purchaseOrdersState.value ?? []; 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) double weight = (item.grossWeight != null && item.grossWeight! > 0)
? item.grossWeight! ? item.grossWeight!
@@ -202,10 +239,13 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
} }
final purchasePrice = weight * purchaseRate; final purchasePrice = weight * purchaseRate;
final currentPrice = weight * currentRate; final double displayedSellingRate = effectiveSaleRate > 0 ? effectiveSaleRate : (isCommodity ? currentRate : purchaseRate);
final gainLoss = currentPrice - purchasePrice; final currentPrice = weight * (isSold ? displayedSellingRate : currentRate);
final gainLoss = isSold ? ((weight * displayedSellingRate) - purchasePrice) : (currentPrice - purchasePrice);
final isGain = gainLoss >= 0; final isGain = gainLoss >= 0;
final String? salesInvoiceNumber = matchingSaleInvoice?.invoiceNumber ?? item.salesRef;
return Card( return Card(
elevation: 0, elevation: 0,
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
@@ -325,46 +365,91 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
style: TextStyle(color: Colors.grey[600], fontSize: 12), style: TextStyle(color: Colors.grey[600], fontSize: 12),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
if (item.purchaseRef != null) if (isSold) ...[
InkWell( if (salesInvoiceNumber != null && salesInvoiceNumber.isNotEmpty)
onTap: () async { InkWell(
try { onTap: () async {
var pos = ref.read(purchaseOrdersProvider).value; try {
if (pos == null || pos.isEmpty) { var inv = matchingSaleInvoice;
pos = await ref.read(purchaseOrdersProvider.future); 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, child: Text(
orElse: () => throw Exception('Purchase invoice not found'), 'INV: $salesInvoiceNumber',
); style: const TextStyle(
if (mounted) { color: Colors.blue,
Navigator.push( decoration: TextDecoration.underline,
context, fontWeight: FontWeight.w600,
MaterialPageRoute( fontSize: 12,
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,
), ),
), ),
), ] else ...[
if (item.huid != null && item.huid!.isNotEmpty) 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(
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)), 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, color: isSold ? Colors.grey.shade600 : null,
), ),
), ),
const SizedBox(height: 3), if (isCommodity) ...[
Container( const SizedBox(height: 3),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), Container(
decoration: BoxDecoration( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
color: Colors.amber.withValues(alpha: 0.12), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4), color: Colors.amber.withValues(alpha: 0.12),
border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), borderRadius: BorderRadius.circular(4),
), border: Border.all(color: Colors.amber.withValues(alpha: 0.3)),
child: Text( ),
'Purity: ${formatPurity(double.tryParse(item.purity ?? '') ?? purityFactor)}', child: Text(
style: TextStyle( 'Purity: ${formatPurity(double.tryParse(item.purity ?? '') ?? purityFactor)}',
fontSize: 10, style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 10,
color: Colors.amber.shade900, fontWeight: FontWeight.bold,
color: Colors.amber.shade900,
),
), ),
), ),
), ],
], ],
), ),
], ],
), ),
const Divider(height: 24), const Divider(height: 24),
Row( if (isCommodity) ...[
mainAxisAlignment: MainAxisAlignment.spaceBetween, Row(
children: [ mainAxisAlignment: MainAxisAlignment.spaceBetween,
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,
children: [ children: [
Icon( Column(
isGain ? LucideIcons.trendingUp : LucideIcons.trendingDown, crossAxisAlignment: CrossAxisAlignment.start,
color: isGain ? Colors.green : Colors.red, children: [
size: 16, 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), Column(
Text( crossAxisAlignment: CrossAxisAlignment.end,
'${isSold ? "Realized " : ""}${isGain ? 'Gain' : 'Loss'}: ₹${gainLoss.abs().toStringAsFixed(2)}', children: [
style: TextStyle( Text('Purchase Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
color: isGain ? Colors.green : Colors.red, Text('${purchasePrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
fontWeight: FontWeight.bold, ],
fontSize: 14,
),
), ),
], ],
), ),
), 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,
),
),
],
),
);
},
),
],
],
], ],
), ),
), ),

View File

@@ -16,6 +16,7 @@ class ProductCategory {
final double? defaultMakingCharge; final double? defaultMakingCharge;
final String? makingChargeType; final String? makingChargeType;
final String baseUnit; final String baseUnit;
final String defaultLengthUom;
final bool isActive; final bool isActive;
final int sortOrder; final int sortOrder;
final double? dailyRate; final double? dailyRate;
@@ -34,6 +35,7 @@ class ProductCategory {
this.defaultMakingCharge, this.defaultMakingCharge,
this.makingChargeType, this.makingChargeType,
this.baseUnit = 'pcs', this.baseUnit = 'pcs',
this.defaultLengthUom = 'cm',
this.isActive = true, this.isActive = true,
this.sortOrder = 0, this.sortOrder = 0,
this.dailyRate, this.dailyRate,
@@ -54,6 +56,7 @@ class ProductCategory {
defaultMakingCharge: (json['defaultMakingCharge'] ?? json['default_making_charge'] as num?)?.toDouble(), defaultMakingCharge: (json['defaultMakingCharge'] ?? json['default_making_charge'] as num?)?.toDouble(),
makingChargeType: json['makingChargeType'] ?? json['making_charge_type'], makingChargeType: json['makingChargeType'] ?? json['making_charge_type'],
baseUnit: json['baseUnit'] ?? json['base_unit'] ?? 'pcs', baseUnit: json['baseUnit'] ?? json['base_unit'] ?? 'pcs',
defaultLengthUom: json['defaultLengthUom'] ?? json['default_length_uom'] ?? 'cm',
isActive: json['isActive'] ?? json['is_active'] ?? true, isActive: json['isActive'] ?? json['is_active'] ?? true,
sortOrder: json['sortOrder'] ?? json['sort_order'] ?? 0, sortOrder: json['sortOrder'] ?? json['sort_order'] ?? 0,
dailyRate: (json['dailyRate'] ?? json['daily_rate'] as num?)?.toDouble(), dailyRate: (json['dailyRate'] ?? json['daily_rate'] as num?)?.toDouble(),
@@ -75,6 +78,7 @@ class ProductCategory {
'defaultMakingCharge': defaultMakingCharge, 'defaultMakingCharge': defaultMakingCharge,
'makingChargeType': makingChargeType, 'makingChargeType': makingChargeType,
'baseUnit': baseUnit, 'baseUnit': baseUnit,
'defaultLengthUom': defaultLengthUom,
'isActive': isActive, 'isActive': isActive,
'sortOrder': sortOrder, 'sortOrder': sortOrder,
}; };

View File

@@ -57,11 +57,20 @@ class UomsNotifier extends AsyncNotifier<List<UnitOfMeasure>> {
// If empty in database, seed standard units of measure // If empty in database, seed standard units of measure
try { try {
final defaults = [ 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': 'Grams', 'abbreviation': 'g'},
{'name': 'Kilograms', 'abbreviation': 'kg'}, {'name': 'Kilograms', 'abbreviation': 'kg'},
{'name': 'Pieces', 'abbreviation': 'pcs'},
{'name': 'Carats', 'abbreviation': 'ct'},
{'name': 'Milligrams', 'abbreviation': 'mg'}, {'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) { for (final def in defaults) {
await DioClient().dio.post('/inventory/uom', data: def); await DioClient().dio.post('/inventory/uom', data: def);

View File

@@ -1175,6 +1175,9 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
}) { }) {
final sku = product.sku; final sku = product.sku;
final qtyCtrl = _getQtyController(product.id ?? 0); 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( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
@@ -1228,28 +1231,31 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
_buildPOSheetBadge(category!.name, const Color(0xFFD4AF37)), _buildPOSheetBadge(category!.name, const Color(0xFFD4AF37)),
if (sku != null && sku.isNotEmpty) if (sku != null && sku.isNotEmpty)
_buildPOSheetBadge('SKU: $sku', Colors.blue), _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), if (isJewellery) ...[
// Small box to enter number of items const SizedBox(width: 12),
SizedBox( // Small box to enter number of items
width: 75, SizedBox(
child: TextFormField( width: 75,
controller: qtyCtrl, child: TextFormField(
keyboardType: TextInputType.number, controller: qtyCtrl,
textAlign: TextAlign.center, keyboardType: TextInputType.number,
decoration: InputDecoration( textAlign: TextAlign.center,
labelText: 'Qty', decoration: InputDecoration(
isDense: true, labelText: 'Qty',
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), isDense: true,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
),
), ),
), ),
), ],
], ],
), ),
const Divider(height: 20), const Divider(height: 20),
@@ -1257,7 +1263,7 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () { onPressed: () {
final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1; final qty = isJewellery ? (int.tryParse(qtyCtrl.text.trim()) ?? 1) : 1;
_addItemsFromCard(product, category, qty); _addItemsFromCard(product, category, qty);
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -1278,7 +1284,7 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
} }
class _POItemRow extends StatefulWidget { class _POItemRow extends ConsumerStatefulWidget {
final PurchaseOrderItem item; final PurchaseOrderItem item;
final String productName; final String productName;
final Product? product; final Product? product;
@@ -1300,14 +1306,15 @@ class _POItemRow extends StatefulWidget {
}); });
@override @override
State<_POItemRow> createState() => _POItemRowState(); ConsumerState<_POItemRow> createState() => _POItemRowState();
} }
class _POItemRowState extends State<_POItemRow> { class _POItemRowState extends ConsumerState<_POItemRow> {
late TextEditingController _weightCtrl; late TextEditingController _weightCtrl;
late TextEditingController _rateCtrl; late TextEditingController _rateCtrl;
late TextEditingController _huidCtrl; late TextEditingController _huidCtrl;
late TextEditingController _totalCtrl; late TextEditingController _totalCtrl;
bool _isUploadingPhoto = false;
@override @override
void initState() { void initState() {
@@ -1339,15 +1346,13 @@ class _POItemRowState extends State<_POItemRow> {
final updated = widget.item.copyWith( final updated = widget.item.copyWith(
weight: weight, weight: weight,
huid: _huidCtrl.text.isEmpty ? null : _huidCtrl.text,
unitPrice: rate, unitPrice: rate,
total: total, total: total,
huid: _huidCtrl.text.isEmpty ? null : _huidCtrl.text,
); );
widget.onChanged(updated); widget.onChanged(updated);
} }
bool _isUploadingPhoto = false;
Future<void> _pickPhoto() async { Future<void> _pickPhoto() async {
try { try {
final picker = ImagePicker(); final picker = ImagePicker();
@@ -1393,6 +1398,10 @@ class _POItemRowState extends State<_POItemRow> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
return Card( return Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
@@ -1534,18 +1543,19 @@ class _POItemRowState extends State<_POItemRow> {
style: TextStyle(fontSize: 10, color: Colors.grey[700]), style: TextStyle(fontSize: 10, color: Colors.grey[700]),
), ),
), ),
Container( if (isJewellery)
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), Container(
decoration: BoxDecoration( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
color: Colors.amber.withValues(alpha: 0.15), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4), color: Colors.amber.withValues(alpha: 0.15),
border: Border.all(color: Colors.amber.withValues(alpha: 0.4)), 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), const SizedBox(height: 12),
Row( LayoutBuilder(
children: [ builder: (context, constraints) {
Expanded( final isWide = constraints.maxWidth >= 600;
flex: 2,
child: PremiumTextField( if (isWide) {
controller: _huidCtrl, return Row(
labelText: 'HUID', children: [
textCapitalization: TextCapitalization.characters, if (isJewellery) ...[
onChanged: (_) => _updateItem(), Expanded(
), flex: 2,
), child: PremiumTextField(
const SizedBox(width: 8), controller: _huidCtrl,
Expanded( labelText: 'HUID',
flex: 2, textCapitalization: TextCapitalization.characters,
child: PremiumTextField( onChanged: (_) => _updateItem(),
controller: _weightCtrl, ),
labelText: 'Weight/Pcs', ),
keyboardType: const TextInputType.numberWithOptions(decimal: true), const SizedBox(width: 8),
onChanged: (_) => _updateItem(), ],
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty) Expanded(
? Container( flex: isJewellery ? 2 : 3,
padding: const EdgeInsets.symmetric(horizontal: 12), child: PremiumTextField(
decoration: BoxDecoration( controller: _weightCtrl,
color: Colors.grey.withValues(alpha: 0.1), labelText: isJewellery ? 'Weight/Pcs' : 'Weight/Pcs/Length',
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)), keyboardType: const TextInputType.numberWithOptions(decimal: true),
border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))), 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, const SizedBox(width: 8),
mainAxisSize: MainAxisSize.min, ],
children: [Text(widget.category?.baseUnit ?? '', style: TextStyle(color: Colors.grey[700]))], Expanded(
), flex: 2,
) child: PremiumTextField(
: null, 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)
const SizedBox(height: 12), ? Container(
Row( padding: const EdgeInsets.symmetric(horizontal: 12),
children: [ decoration: BoxDecoration(
Expanded( color: Colors.grey.withValues(alpha: 0.1),
flex: 2, borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
child: PremiumTextField( border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))),
controller: _rateCtrl, ),
labelText: 'Rate', child: Column(
keyboardType: const TextInputType.numberWithOptions(decimal: true), mainAxisAlignment: MainAxisAlignment.center,
onChanged: (_) => _updateItem(), mainAxisSize: MainAxisSize.min,
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty) children: [Text(widget.category?.baseUnit ?? '', style: TextStyle(color: Colors.grey[700]))],
? Container( ),
padding: const EdgeInsets.symmetric(horizontal: 12), )
decoration: BoxDecoration( : null,
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( const SizedBox(height: 12),
mainAxisAlignment: MainAxisAlignment.center, Row(
mainAxisSize: MainAxisSize.min, children: [
children: [Text('per ${widget.category?.baseUnit ?? ''}', style: TextStyle(color: Colors.grey[700]))], Expanded(
), flex: 2,
) child: PremiumTextField(
: null, controller: _rateCtrl,
), labelText: 'Rate',
), keyboardType: const TextInputType.numberWithOptions(decimal: true),
const SizedBox(width: 8), onChanged: (_) => _updateItem(),
Expanded( suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
flex: 2, ? Container(
child: PremiumTextField( padding: const EdgeInsets.symmetric(horizontal: 12),
controller: _totalCtrl, decoration: BoxDecoration(
labelText: 'Purchase Amt', color: Colors.grey.withValues(alpha: 0.1),
keyboardType: TextInputType.number, borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
readOnly: true, border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))),
onChanged: (_) => _updateItem(), ),
), 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), const SizedBox(height: 16),
_buildTaxAndTotal(), _buildTaxAndTotal(),