From b123a34e8f101dfc405de7608f897972a8df4d80 Mon Sep 17 00:00:00 2001 From: Narayanan Madaswamy Date: Thu, 3 Sep 2026 08:34:23 +0530 Subject: [PATCH] Fixed sales invoice issues in Ecommerce --- .../sales/SalesChannelController.java | 54 + .../api/entity/business/BusinessFeature.java | 3 + .../com/kifi/api/entity/invoice/Invoice.java | 15 + .../kifi/api/entity/sales/SalesChannel.java | 43 + .../sales/SalesChannelRepository.java | 18 + .../api/service/business/BusinessService.java | 2 + .../api/service/invoice/InvoiceService.java | 10 + .../service/sales/SalesChannelService.java | 55 + .../business/domain/business_feature.dart | 7 + .../settings/business_settings_screen.dart | 53 + .../lib/features/sales/domain/invoice.dart | 20 + .../features/sales/domain/sales_channel.dart | 49 + .../presentation/invoice_builder_screen.dart | 1642 +++++++++++++---- .../presentation/invoice_details_screen.dart | 346 +++- .../presentation/invoices_list_screen.dart | 34 +- .../presentation/sales_channels_screen.dart | 278 +++ .../providers/sales_channels_provider.dart | 74 + .../purchase_order_builder_screen.dart | 25 +- 18 files changed, 2275 insertions(+), 453 deletions(-) create mode 100644 kifi-api/src/main/java/com/kifi/api/controller/sales/SalesChannelController.java create mode 100644 kifi-api/src/main/java/com/kifi/api/entity/sales/SalesChannel.java create mode 100644 kifi-api/src/main/java/com/kifi/api/repository/sales/SalesChannelRepository.java create mode 100644 kifi-api/src/main/java/com/kifi/api/service/sales/SalesChannelService.java create mode 100644 kifi-app/lib/features/sales/domain/sales_channel.dart create mode 100644 kifi-app/lib/features/sales/presentation/sales_channels_screen.dart create mode 100644 kifi-app/lib/features/sales/providers/sales_channels_provider.dart diff --git a/kifi-api/src/main/java/com/kifi/api/controller/sales/SalesChannelController.java b/kifi-api/src/main/java/com/kifi/api/controller/sales/SalesChannelController.java new file mode 100644 index 0000000..f040e7a --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/sales/SalesChannelController.java @@ -0,0 +1,54 @@ +package com.kifi.api.controller.sales; + +import com.kifi.api.entity.sales.SalesChannel; +import com.kifi.api.service.sales.SalesChannelService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/kifi-v2/sales-channels") +@RequiredArgsConstructor +public class SalesChannelController { + + private final SalesChannelService salesChannelService; + + @GetMapping + public Flux getSalesChannels(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return salesChannelService.getActiveChannels(userId); + } + + @GetMapping("/all") + public Flux getAllSalesChannels(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return salesChannelService.getAllChannels(userId); + } + + @PostMapping + public Mono createSalesChannel( + Authentication authentication, + @RequestBody SalesChannel channel) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return salesChannelService.createChannel(userId, channel); + } + + @PutMapping("/{id}") + public Mono updateSalesChannel( + @PathVariable Long id, + Authentication authentication, + @RequestBody SalesChannel channel) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return salesChannelService.updateChannel(userId, id, channel); + } + + @DeleteMapping("/{id}") + public Mono deleteSalesChannel( + @PathVariable Long id, + Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return salesChannelService.deleteChannel(userId, id); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessFeature.java b/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessFeature.java index 7be663e..f5438f6 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessFeature.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessFeature.java @@ -33,6 +33,9 @@ public class BusinessFeature { @Column("barcode_source") private String barcodeSource; + @Column("enable_ecommerce_channels") + private Boolean enableEcommerceChannels; + @Column("created_at") private LocalDateTime createdAt; } diff --git a/kifi-api/src/main/java/com/kifi/api/entity/invoice/Invoice.java b/kifi-api/src/main/java/com/kifi/api/entity/invoice/Invoice.java index 0dd027c..b425e6f 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/invoice/Invoice.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/invoice/Invoice.java @@ -80,6 +80,21 @@ public class Invoice { @Column("emi_start_date") private LocalDate emiStartDate; + @Column("sales_channel_id") + private Long salesChannelId; + + @Column("sales_channel") + private String salesChannel; + + @Column("marketplace_order_id") + private String marketplaceOrderId; + + @Column("place_of_supply_state_id") + private Long placeOfSupplyStateId; + + @Column("place_of_supply") + private String placeOfSupply; + @Column("created_at") private LocalDateTime createdAt; diff --git a/kifi-api/src/main/java/com/kifi/api/entity/sales/SalesChannel.java b/kifi-api/src/main/java/com/kifi/api/entity/sales/SalesChannel.java new file mode 100644 index 0000000..c0bf289 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/sales/SalesChannel.java @@ -0,0 +1,43 @@ +package com.kifi.api.entity.sales; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("sales_channels") +public class SalesChannel { + @Id + private Long id; + + @Column("user_id") + private Long userId; + + private String name; + + private String code; + + private String icon; + + @Column("default_ledger_id") + private Long defaultLedgerId; + + @Column("is_active") + @Builder.Default + private Boolean isActive = true; + + @Column("created_at") + private LocalDateTime createdAt; + + @Column("updated_at") + private LocalDateTime updatedAt; +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/sales/SalesChannelRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/sales/SalesChannelRepository.java new file mode 100644 index 0000000..d08d114 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/sales/SalesChannelRepository.java @@ -0,0 +1,18 @@ +package com.kifi.api.repository.sales; + +import com.kifi.api.entity.sales.SalesChannel; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface SalesChannelRepository extends ReactiveCrudRepository { + + @Query("SELECT * FROM sales_channels WHERE (user_id = :userId OR user_id IS NULL) AND is_active = true ORDER BY id ASC") + Flux findActiveChannelsForUser(Long userId); + + @Query("SELECT * FROM sales_channels WHERE (user_id = :userId OR user_id IS NULL) ORDER BY id ASC") + Flux findAllChannelsForUser(Long userId); + + Mono findByIdAndUserId(Long id, Long userId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java b/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java index c6511f5..09ab4ff 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java @@ -37,6 +37,7 @@ public class BusinessService { .multiLocation(false) .bomReductionStrategy("COMPONENTS_ONLY") .stockDeductionOnInvoice(true) + .enableEcommerceChannels(false) .createdAt(LocalDateTime.now()) .build()); } @@ -51,6 +52,7 @@ public class BusinessService { if (feature.getBomReductionStrategy() != null) existing.setBomReductionStrategy(feature.getBomReductionStrategy()); if (feature.getStockDeductionOnInvoice() != null) existing.setStockDeductionOnInvoice(feature.getStockDeductionOnInvoice()); if (feature.getBarcodeSource() != null) existing.setBarcodeSource(feature.getBarcodeSource()); + if (feature.getEnableEcommerceChannels() != null) existing.setEnableEcommerceChannels(feature.getEnableEcommerceChannels()); return businessFeatureRepository.save(existing); }) .switchIfEmpty(Mono.defer(() -> { diff --git a/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java b/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java index b3f164c..c4be603 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java @@ -225,6 +225,16 @@ public class InvoiceService { if (availItems.isEmpty()) { return Mono.just(BigDecimal.ZERO); } + // Strict FIFO: Sort by purchase/creation date & time in ASCENDING order + availItems.sort((a, b) -> { + if (a.getCreatedAt() == null && b.getCreatedAt() == null) { + return Long.compare(a.getId() != null ? a.getId() : 0L, b.getId() != null ? b.getId() : 0L); + } + if (a.getCreatedAt() == null) return 1; + if (b.getCreatedAt() == null) return -1; + int cmp = a.getCreatedAt().compareTo(b.getCreatedAt()); + return cmp != 0 ? cmp : Long.compare(a.getId() != null ? a.getId() : 0L, b.getId() != null ? b.getId() : 0L); + }); BigDecimal needed = soldQty; java.util.List> saves = new java.util.ArrayList<>(); BigDecimal totalCost = BigDecimal.ZERO; diff --git a/kifi-api/src/main/java/com/kifi/api/service/sales/SalesChannelService.java b/kifi-api/src/main/java/com/kifi/api/service/sales/SalesChannelService.java new file mode 100644 index 0000000..76e2a59 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/sales/SalesChannelService.java @@ -0,0 +1,55 @@ +package com.kifi.api.service.sales; + +import com.kifi.api.entity.sales.SalesChannel; +import com.kifi.api.repository.sales.SalesChannelRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Transactional +public class SalesChannelService { + + private final SalesChannelRepository salesChannelRepository; + + public Flux getActiveChannels(Long userId) { + return salesChannelRepository.findActiveChannelsForUser(userId); + } + + public Flux getAllChannels(Long userId) { + return salesChannelRepository.findAllChannelsForUser(userId); + } + + public Mono createChannel(Long userId, SalesChannel channel) { + channel.setUserId(userId); + channel.setCreatedAt(LocalDateTime.now()); + channel.setUpdatedAt(LocalDateTime.now()); + if (channel.getIsActive() == null) { + channel.setIsActive(true); + } + return salesChannelRepository.save(channel); + } + + public Mono updateChannel(Long userId, Long id, SalesChannel updated) { + return salesChannelRepository.findByIdAndUserId(id, userId) + .flatMap(existing -> { + if (updated.getName() != null) existing.setName(updated.getName()); + if (updated.getCode() != null) existing.setCode(updated.getCode()); + if (updated.getIcon() != null) existing.setIcon(updated.getIcon()); + if (updated.getDefaultLedgerId() != null) existing.setDefaultLedgerId(updated.getDefaultLedgerId()); + if (updated.getIsActive() != null) existing.setIsActive(updated.getIsActive()); + existing.setUpdatedAt(LocalDateTime.now()); + return salesChannelRepository.save(existing); + }); + } + + public Mono deleteChannel(Long userId, Long id) { + return salesChannelRepository.findByIdAndUserId(id, userId) + .flatMap(salesChannelRepository::delete); + } +} diff --git a/kifi-app/lib/features/business/domain/business_feature.dart b/kifi-app/lib/features/business/domain/business_feature.dart index a07c6c9..4be769c 100644 --- a/kifi-app/lib/features/business/domain/business_feature.dart +++ b/kifi-app/lib/features/business/domain/business_feature.dart @@ -8,6 +8,7 @@ class BusinessFeature { final String bomReductionStrategy; final bool stockDeductionOnInvoice; final String barcodeSource; + final bool enableEcommerceChannels; final DateTime? createdAt; BusinessFeature({ @@ -20,6 +21,7 @@ class BusinessFeature { this.bomReductionStrategy = 'COMPONENTS_ONLY', this.stockDeductionOnInvoice = true, this.barcodeSource = 'SKU', + this.enableEcommerceChannels = false, this.createdAt, }); @@ -34,6 +36,7 @@ class BusinessFeature { bomReductionStrategy: json['bomReductionStrategy'] ?? 'COMPONENTS_ONLY', stockDeductionOnInvoice: json['stockDeductionOnInvoice'] ?? true, barcodeSource: json['barcodeSource'] ?? 'SKU', + enableEcommerceChannels: json['enableEcommerceChannels'] ?? json['enable_ecommerce_channels'] ?? false, createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, @@ -51,6 +54,7 @@ class BusinessFeature { 'bomReductionStrategy': bomReductionStrategy, 'stockDeductionOnInvoice': stockDeductionOnInvoice, 'barcodeSource': barcodeSource, + 'enableEcommerceChannels': enableEcommerceChannels, }; } @@ -64,6 +68,7 @@ class BusinessFeature { String? bomReductionStrategy, bool? stockDeductionOnInvoice, String? barcodeSource, + bool? enableEcommerceChannels, }) { return BusinessFeature( id: id ?? this.id, @@ -76,6 +81,8 @@ class BusinessFeature { stockDeductionOnInvoice: stockDeductionOnInvoice ?? this.stockDeductionOnInvoice, barcodeSource: barcodeSource ?? this.barcodeSource, + enableEcommerceChannels: + enableEcommerceChannels ?? this.enableEcommerceChannels, createdAt: createdAt, ); } diff --git a/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart b/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart index 09085c0..834475b 100644 --- a/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart +++ b/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../providers/business_provider.dart'; +import '../../../sales/presentation/sales_channels_screen.dart'; class BusinessSettingsScreen extends ConsumerStatefulWidget { const BusinessSettingsScreen({super.key}); @@ -176,6 +177,58 @@ class _BusinessSettingsScreenState } }, ), + const SizedBox(height: 16), + Builder( + builder: (context) { + final businessProfile = ref.watch(businessProfileProvider).value; + final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase(); + final isJewellery = nature == 'JEWELLERY'; + final isChannelEnabled = featureState.value?.enableEcommerceChannels ?? (!isJewellery); + + return SwitchListTile( + title: const Text('Enable E-Commerce & Marketplace Channels'), + subtitle: const Text( + 'Track multi-channel sales across Amazon, Flipkart, Shopify, etc. in Invoices.', + ), + value: isChannelEnabled, + secondary: const Icon(LucideIcons.shoppingBag), + onChanged: (val) { + if (featureState.value != null) { + final updated = featureState.value!.copyWith( + enableEcommerceChannels: val, + ); + ref + .read(businessFeatureProvider.notifier) + .updateFeatures(updated); + } + }, + ); + }, + ), + Consumer( + builder: (context, ref, child) { + final featureState = ref.watch(businessFeatureProvider); + final businessProfile = ref.watch(businessProfileProvider).value; + final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase(); + final isJewellery = nature == 'JEWELLERY'; + final isChannelEnabled = featureState.value?.enableEcommerceChannels ?? (!isJewellery); + + if (!isChannelEnabled) return const SizedBox.shrink(); + + return ListTile( + leading: const Icon(LucideIcons.store, color: Colors.blue), + title: const Text('Manage Sales Channels', style: TextStyle(fontWeight: FontWeight.w600)), + subtitle: const Text('Configure marketplace channels and custom platforms'), + trailing: const Icon(LucideIcons.chevronRight, size: 18), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SalesChannelsScreen()), + ); + }, + ); + }, + ), ], ); }, diff --git a/kifi-app/lib/features/sales/domain/invoice.dart b/kifi-app/lib/features/sales/domain/invoice.dart index 828c48e..cc9ecc2 100644 --- a/kifi-app/lib/features/sales/domain/invoice.dart +++ b/kifi-app/lib/features/sales/domain/invoice.dart @@ -192,6 +192,11 @@ class Invoice { final double? emiAmount; final String? emiCycle; final DateTime? emiStartDate; + final int? salesChannelId; + final String? salesChannel; + final String? marketplaceOrderId; + final int? placeOfSupplyStateId; + final String? placeOfSupply; final List items; final List? payments; @@ -219,6 +224,11 @@ class Invoice { this.emiAmount, this.emiCycle, this.emiStartDate, + this.salesChannelId, + this.salesChannel, + this.marketplaceOrderId, + this.placeOfSupplyStateId, + this.placeOfSupply, this.items = const [], this.payments, }); @@ -254,6 +264,11 @@ class Invoice { emiStartDate: json['emiStartDate'] != null ? DateTime.parse(json['emiStartDate']) : (json['emi_start_date'] != null ? DateTime.parse(json['emi_start_date']) : null), + salesChannelId: json['salesChannelId'] ?? json['sales_channel_id'], + salesChannel: json['salesChannel'] ?? json['sales_channel'], + marketplaceOrderId: json['marketplaceOrderId'] ?? json['marketplace_order_id'], + placeOfSupplyStateId: json['placeOfSupplyStateId'] ?? json['place_of_supply_state_id'], + placeOfSupply: json['placeOfSupply'] ?? json['place_of_supply'], items: json['items'] != null ? (json['items'] as List).map((i) => InvoiceItem.fromJson(i)).toList() : [], @@ -296,6 +311,11 @@ class Invoice { if (emiStartDate != null) { data['emiStartDate'] = emiStartDate!.toIso8601String().split('T')[0]; } + if (salesChannelId != null) data['salesChannelId'] = salesChannelId; + if (salesChannel != null) data['salesChannel'] = salesChannel; + if (marketplaceOrderId != null) data['marketplaceOrderId'] = marketplaceOrderId; + if (placeOfSupplyStateId != null) data['placeOfSupplyStateId'] = placeOfSupplyStateId; + if (placeOfSupply != null) data['placeOfSupply'] = placeOfSupply; data['items'] = items.map((i) => i.toJson()).toList(); if (payments != null) { data['payments'] = payments!.map((i) => i.toJson()).toList(); diff --git a/kifi-app/lib/features/sales/domain/sales_channel.dart b/kifi-app/lib/features/sales/domain/sales_channel.dart new file mode 100644 index 0000000..ea5a949 --- /dev/null +++ b/kifi-app/lib/features/sales/domain/sales_channel.dart @@ -0,0 +1,49 @@ +class SalesChannel { + final int? id; + final int? userId; + final String name; + final String? code; + final String? icon; + final int? defaultLedgerId; + final bool isActive; + final DateTime? createdAt; + final DateTime? updatedAt; + + SalesChannel({ + this.id, + this.userId, + required this.name, + this.code, + this.icon, + this.defaultLedgerId, + this.isActive = true, + this.createdAt, + this.updatedAt, + }); + + factory SalesChannel.fromJson(Map json) { + return SalesChannel( + id: json['id'], + userId: json['userId'] ?? json['user_id'], + name: json['name'] ?? '', + code: json['code'], + icon: json['icon'], + defaultLedgerId: json['defaultLedgerId'] ?? json['default_ledger_id'], + isActive: json['isActive'] ?? json['is_active'] ?? true, + createdAt: json['createdAt'] != null ? DateTime.tryParse(json['createdAt']) : null, + updatedAt: json['updatedAt'] != null ? DateTime.tryParse(json['updatedAt']) : null, + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + if (userId != null) 'userId': userId, + 'name': name, + if (code != null) 'code': code, + if (icon != null) 'icon': icon, + if (defaultLedgerId != null) 'defaultLedgerId': defaultLedgerId, + 'isActive': isActive, + }; + } +} diff --git a/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart b/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart index 3a262ea..8e6d53f 100644 --- a/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart @@ -14,19 +14,63 @@ import '../../inventory/providers/products_provider.dart'; import '../../inventory/providers/product_categories_provider.dart'; import '../../inventory/providers/commodity_rates_provider.dart'; import '../../inventory/providers/inventory_items_provider.dart'; +import '../../inventory/providers/uoms_provider.dart'; import '../../inventory/domain/product.dart'; import '../../inventory/domain/inventory_item.dart'; import '../providers/customers_provider.dart'; import '../providers/invoices_provider.dart'; import '../domain/customer.dart'; import '../domain/invoice.dart'; +import '../providers/sales_channels_provider.dart'; import '../../business/providers/business_provider.dart'; +import '../../business/providers/indian_states_provider.dart'; import '../../transactions/providers/providers.dart'; import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; import 'widgets/quick_add_customer_sheet.dart'; import '../../../core/utils/purity_utils.dart'; import 'invoice_details_screen.dart'; +bool _isCommodityProduct({ + ProductCategory? category, + Product? product, + InventoryItem? inventoryItem, + InvoiceItem? invoiceItem, + String? commodityCode, + String? huid, +}) { + final h = huid ?? invoiceItem?.huid ?? inventoryItem?.huid; + if (h != null && h.trim().isNotEmpty) return true; + + final code = commodityCode ?? + category?.commodityCode ?? + invoiceItem?.commodityCode; + if (code != null && code.trim().isNotEmpty && code.trim().toUpperCase() != 'NONE') { + return true; + } + + return false; +} + +String _resolveProductUnit({ + required Product? product, + required ProductCategory? category, + required List uoms, + bool isCommodity = false, +}) { + if (isCommodity) { + return (category != null && category.baseUnit.isNotEmpty) ? category.baseUnit : 'g'; + } + if (product?.uomId != null) { + final uom = uoms.where((u) => u.id == product!.uomId).firstOrNull; + if (uom != null && uom.abbreviation != null && uom.abbreviation!.isNotEmpty) return uom.abbreviation!; + if (uom != null && uom.name.isNotEmpty) return uom.name; + } + if (category != null && category.baseUnit.isNotEmpty) { + return category.baseUnit; + } + return 'pcs'; +} + String _resolveImageUrl(String path) { if (path.startsWith('http://') || path.startsWith('https://')) { return path; @@ -62,10 +106,15 @@ class _InvoiceBuilderScreenState extends ConsumerState { final _amountPaidCtrl = TextEditingController(); final _emiAmountCtrl = TextEditingController(); final _discountCtrl = TextEditingController(text: '0.0'); + final _marketplaceOrderIdCtrl = TextEditingController(); DateTime _invoiceDate = DateTime.now(); DateTime? _dueDate; int? _selectedCustomerId; + int? _selectedSalesChannelId; + String? _selectedSalesChannelName; + int? _selectedPlaceOfSupplyStateId; + String? _selectedPlaceOfSupplyStateName; final List _items = []; bool _isLoading = false; @@ -91,6 +140,11 @@ class _InvoiceBuilderScreenState extends ConsumerState { _invoiceDate = inv.issueDate; _dueDate = inv.dueDate; _selectedCustomerId = inv.customerId; + _selectedSalesChannelId = inv.salesChannelId; + _selectedSalesChannelName = inv.salesChannel; + _marketplaceOrderIdCtrl.text = inv.marketplaceOrderId ?? ''; + _selectedPlaceOfSupplyStateId = inv.placeOfSupplyStateId; + _selectedPlaceOfSupplyStateName = inv.placeOfSupply; _notesCtrl.text = inv.notes ?? ''; _items.addAll(inv.items); _invoiceUrl = inv.invoiceUrl; @@ -126,6 +180,7 @@ class _InvoiceBuilderScreenState extends ConsumerState { _amountPaidCtrl.dispose(); _emiAmountCtrl.dispose(); _discountCtrl.dispose(); + _marketplaceOrderIdCtrl.dispose(); super.dispose(); } @@ -328,13 +383,15 @@ class _InvoiceBuilderScreenState extends ConsumerState { } final products = ref.read(productsProvider).value ?? []; + final categories = ref.read(productCategoriesProvider).value ?? []; final businessStateId = ref.read(businessProfileProvider).value?.stateId; final customers = ref.read(customersProvider).value ?? []; final customer = _selectedCustomerId == null ? null : customers.where((c) => c.id == _selectedCustomerId).firstOrNull; - final isSameState = businessStateId != null && customer?.stateId != null && businessStateId == customer!.stateId; + final effectiveSupplyStateId = _selectedPlaceOfSupplyStateId ?? customer?.stateId ?? businessStateId; + final isSameState = businessStateId != null && effectiveSupplyStateId != null && businessStateId == effectiveSupplyStateId; double subtotal = 0; double totalCgst = 0; @@ -349,21 +406,26 @@ class _InvoiceBuilderScreenState extends ConsumerState { for (var item in _items) { final product = products.where((p) => p.id == item.productId).firstOrNull; + final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull; final gstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? 0.0); - final weight = item.weight ?? item.quantity; - final metalAmount = weight * item.unitPrice; + final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0); + final baseItemAmt = qtyOrWeight * item.unitPrice; + + final isItemCommodity = _isCommodityProduct(category: category, product: product, invoiceItem: item); double makingChargeAmt = 0.0; - if (item.makingChargesType == 'PERCENTAGE') { - makingChargeAmt = metalAmount * (item.makingCharge / 100.0); - } else if (item.makingChargesType == 'PER_PIECE') { - makingChargeAmt = item.makingCharge; - } else { - makingChargeAmt = weight * item.makingCharge; + if (isItemCommodity) { + if (item.makingChargesType == 'PERCENTAGE') { + makingChargeAmt = baseItemAmt * (item.makingCharge / 100.0); + } else if (item.makingChargesType == 'PER_PIECE') { + makingChargeAmt = item.makingCharge; + } else { + makingChargeAmt = qtyOrWeight * item.makingCharge; + } } - final taxableAmount = metalAmount + makingChargeAmt + item.otherCharges - item.discount; + final taxableAmount = baseItemAmt + makingChargeAmt + item.otherCharges - item.discount; final taxAmount = (taxableAmount * gstRate) / 100.0; double cgst = 0; @@ -382,11 +444,13 @@ class _InvoiceBuilderScreenState extends ConsumerState { final lineTotal = taxableAmount + taxAmount; - subtotal += metalAmount; + subtotal += baseItemAmt; totalMaking += makingChargeAmt; totalTax += taxAmount; processedItems.add(item.copyWith( + quantity: item.quantity > 0 ? item.quantity : qtyOrWeight, + weight: item.weight, taxRate: gstRate, cgst: cgst, sgst: sgst, @@ -435,6 +499,11 @@ class _InvoiceBuilderScreenState extends ConsumerState { emiAmount: _isEmi && _emiAmountCtrl.text.isNotEmpty ? double.tryParse(_emiAmountCtrl.text) : null, emiCycle: _isEmi ? _emiCycle : null, emiStartDate: _isEmi ? (_emiStartDate ?? DateTime.now().add(const Duration(days: 30))) : null, + salesChannelId: _selectedSalesChannelId, + salesChannel: _selectedSalesChannelName, + marketplaceOrderId: _marketplaceOrderIdCtrl.text.trim().isNotEmpty ? _marketplaceOrderIdCtrl.text.trim() : null, + placeOfSupplyStateId: effectiveSupplyStateId, + placeOfSupply: _selectedPlaceOfSupplyStateName ?? ref.read(indianStatesProvider).value?.where((s) => s.id == effectiveSupplyStateId).firstOrNull?.name, items: processedItems, ); @@ -523,6 +592,139 @@ class _InvoiceBuilderScreenState extends ConsumerState { keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.all(16.0), children: [ + // E-Commerce / Sales Channels Selector (If enabled) + Builder( + builder: (context) { + final featureState = ref.watch(businessFeatureProvider); + final businessProfile = ref.watch(businessProfileProvider).value; + final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase(); + final isJewellery = nature == 'JEWELLERY'; + final isChannelsEnabled = featureState.value?.enableEcommerceChannels ?? (!isJewellery); + + if (!isChannelsEnabled) return const SizedBox.shrink(); + + final salesChannelsState = ref.watch(salesChannelsProvider); + + return salesChannelsState.when( + data: (channels) { + final activeChannels = channels.where((c) => c.isActive).toList(); + if (activeChannels.isEmpty) return const SizedBox.shrink(); + + final currentSelectedId = _selectedSalesChannelId; + final selectedChannel = activeChannels.where((c) => + (currentSelectedId != null && c.id == currentSelectedId) || + (currentSelectedId == null && c.name.toUpperCase() == (_selectedSalesChannelName ?? '').toUpperCase()) + ).firstOrNull ?? activeChannels.where((c) => c.code == 'DIRECT' || c.name.toLowerCase().contains('direct')).firstOrNull ?? activeChannels.first; + + final isMarketplace = (selectedChannel.code != 'DIRECT' && + !selectedChannel.name.toLowerCase().contains('direct') && + !selectedChannel.name.toLowerCase().contains('store')); + + return Container( + margin: const EdgeInsets.only(bottom: 16.0), + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.withValues(alpha: 0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.shoppingBag, size: 16, color: Colors.blue.shade700), + const SizedBox(width: 6), + const Text( + 'Sales Channel / Marketplace', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + ], + ), + const SizedBox(height: 10), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: activeChannels.map((channel) { + final isSelected = (currentSelectedId != null && currentSelectedId == channel.id) || + (currentSelectedId == null && selectedChannel.id == channel.id); + + IconData iconData = LucideIcons.store; + if (channel.code == 'AMAZON' || channel.name.toLowerCase().contains('amazon')) { + iconData = LucideIcons.shoppingCart; + } else if (channel.code == 'FLIPKART' || channel.name.toLowerCase().contains('flipkart')) { + iconData = LucideIcons.package; + } else if (channel.code == 'SHOPIFY' || channel.name.toLowerCase().contains('shopify')) { + iconData = LucideIcons.globe; + } else if (channel.code == 'MEESHO' || channel.name.toLowerCase().contains('meesho')) { + iconData = LucideIcons.tag; + } else if (channel.code == 'QUICK_COMMERCE' || channel.name.toLowerCase().contains('quick')) { + iconData = LucideIcons.zap; + } + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ChoiceChip( + selected: isSelected, + avatar: Icon( + iconData, + size: 15, + color: isSelected ? Colors.white : Colors.grey.shade700, + ), + label: Text( + channel.name, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? Colors.white : Colors.grey.shade800, + ), + ), + selectedColor: Colors.blue.shade700, + onSelected: (selected) { + if (selected) { + setState(() { + _selectedSalesChannelId = channel.id; + _selectedSalesChannelName = channel.name; + + // If customer is not yet selected, attempt matching default channel customer (e.g. Amazon) + if (_selectedCustomerId == null) { + final customersList = ref.read(customersProvider).value ?? []; + final match = customersList.where((c) => + c.name.toLowerCase().contains(channel.name.toLowerCase()) || + (channel.code != null && c.name.toLowerCase().contains(channel.code!.toLowerCase())) + ).firstOrNull; + if (match != null) { + _selectedCustomerId = match.id; + if (match.stateId != null) { + _selectedPlaceOfSupplyStateId = match.stateId; + } + } + } + }); + } + }, + ), + ); + }).toList(), + ), + ), + if (isMarketplace) ...[ + const SizedBox(height: 12), + PremiumTextField( + controller: _marketplaceOrderIdCtrl, + labelText: '${selectedChannel.name} Order ID (e.g. 408-1234567-8901234)', + prefixIcon: const Icon(LucideIcons.hash, size: 16), + ), + ], + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (err, stack) => const SizedBox.shrink(), + ); + }, + ), // Customer Selection Row + Quick Add Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -589,6 +791,9 @@ class _InvoiceBuilderScreenState extends ConsumerState { onChanged: (val) { setState(() { _selectedCustomerId = val?.id; + if (val?.stateId != null) { + _selectedPlaceOfSupplyStateId = val!.stateId; + } }); }, ), @@ -607,6 +812,121 @@ class _InvoiceBuilderScreenState extends ConsumerState { ), ], ), + const SizedBox(height: 12), + + // Place of Supply / Destination Delivery State + Consumer( + builder: (context, ref, child) { + final statesState = ref.watch(indianStatesProvider); + final businessStateId = ref.watch(businessProfileProvider).value?.stateId; + final customers = ref.watch(customersProvider).value ?? []; + final customer = _selectedCustomerId == null + ? null + : customers.where((c) => c.id == _selectedCustomerId).firstOrNull; + + final effectiveStateId = _selectedPlaceOfSupplyStateId ?? customer?.stateId ?? businessStateId; + final isSameState = businessStateId != null && effectiveStateId != null && businessStateId == effectiveStateId; + + return statesState.when( + data: (states) { + final selectedState = states.where((s) => s.id == effectiveStateId).firstOrNull; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: isSameState + ? Colors.green.withValues(alpha: 0.05) + : Colors.purple.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSameState + ? Colors.green.withValues(alpha: 0.25) + : Colors.purple.withValues(alpha: 0.25), + ), + ), + child: Row( + children: [ + Icon( + LucideIcons.mapPin, + size: 18, + color: isSameState ? Colors.green.shade700 : Colors.purple.shade700, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Place of Supply (Delivery State)', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: Colors.grey.shade600, + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5), + decoration: BoxDecoration( + color: isSameState ? Colors.green.shade100 : Colors.purple.shade100, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + isSameState ? 'Intra-state (CGST + SGST)' : 'Inter-state (IGST)', + style: TextStyle( + fontSize: 9.5, + fontWeight: FontWeight.bold, + color: isSameState ? Colors.green.shade900 : Colors.purple.shade900, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + DropdownButtonHideUnderline( + child: DropdownButton( + value: selectedState?.id, + isDense: true, + isExpanded: true, + hint: const Text('Select State of Supply', style: TextStyle(fontSize: 13)), + items: states.map((s) { + final isHome = s.id == businessStateId; + return DropdownMenuItem( + value: s.id, + child: Text( + '${s.name} (${s.gstCode})${isHome ? " - Home State" : ""}', + style: TextStyle( + fontSize: 13, + fontWeight: isHome ? FontWeight.bold : FontWeight.normal, + ), + ), + ); + }).toList(), + onChanged: (val) { + if (val != null) { + final picked = states.where((s) => s.id == val).firstOrNull; + setState(() { + _selectedPlaceOfSupplyStateId = val; + _selectedPlaceOfSupplyStateName = picked?.name; + }); + } + }, + ), + ), + ], + ), + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (err, stack) => const SizedBox.shrink(), + ); + }, + ), const SizedBox(height: 16), PremiumTextField( @@ -672,6 +992,7 @@ class _InvoiceBuilderScreenState extends ConsumerState { // Items List if (_items.isEmpty) Container( + width: double.infinity, padding: const EdgeInsets.all(32), decoration: BoxDecoration( color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100, @@ -679,8 +1000,10 @@ class _InvoiceBuilderScreenState extends ConsumerState { border: Border.all(color: Colors.grey.withValues(alpha: 0.2)), ), child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(LucideIcons.shoppingBag, size: 48, color: Colors.grey.shade400), + Icon(LucideIcons.packageOpen, size: 48, color: Colors.grey.shade400), const SizedBox(height: 12), Text( 'No items added yet', @@ -688,7 +1011,7 @@ class _InvoiceBuilderScreenState extends ConsumerState { ), const SizedBox(height: 4), Text( - 'Tap "Add Item" or scan a barcode/HUID to add jewellery', + 'Tap "Add Item" or scan a barcode/SKU to add sales items', style: TextStyle(fontSize: 12, color: Colors.grey.shade500), ), ], @@ -800,19 +1123,36 @@ class _InvoiceBuilderScreenState extends ConsumerState { } Widget _buildSalesItemCard(InvoiceItem item, int index, bool isDark) { - final weight = item.weight ?? item.quantity; - final metalAmount = weight * item.unitPrice; + final uoms = ref.watch(uomsProvider).value ?? []; + final products = ref.watch(productsProvider).value ?? []; + final categories = ref.watch(productCategoriesProvider).value ?? []; + final product = products.where((p) => p.id == item.productId).firstOrNull; + final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull; + + final isItemCommodity = _isCommodityProduct( + category: category, + product: product, + invoiceItem: item, + ); + + final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0); + final baseAmount = qtyOrWeight * item.unitPrice; double makingChargeAmt = 0.0; - if (item.makingChargesType == 'PERCENTAGE') { - makingChargeAmt = metalAmount * (item.makingCharge / 100.0); - } else if (item.makingChargesType == 'PER_PIECE') { - makingChargeAmt = item.makingCharge; - } else { - makingChargeAmt = weight * item.makingCharge; + if (isItemCommodity) { + if (item.makingChargesType == 'PERCENTAGE') { + makingChargeAmt = baseAmount * (item.makingCharge / 100.0); + } else if (item.makingChargesType == 'PER_PIECE') { + makingChargeAmt = item.makingCharge; + } else { + makingChargeAmt = qtyOrWeight * item.makingCharge; + } } - final calculatedTotal = item.total > 0 ? item.total : (metalAmount + makingChargeAmt + item.otherCharges - item.discount); + final taxableAmount = baseAmount + makingChargeAmt + item.otherCharges - item.discount; + final taxAmount = (taxableAmount * item.taxRate) / 100.0; + final calculatedTotal = item.total > 0 ? item.total : (taxableAmount + taxAmount); + final unit = _resolveProductUnit(product: product, category: category, uoms: uoms, isCommodity: isItemCommodity); return Container( margin: const EdgeInsets.only(bottom: 12), @@ -865,10 +1205,12 @@ class _InvoiceBuilderScreenState extends ConsumerState { _buildSmallBadge(item.categoryName!, const Color(0xFFD4AF37)), if (item.sku != null && item.sku!.isNotEmpty) _buildSmallBadge('SKU: ${item.sku}', Colors.blue), - if (item.huid != null && item.huid!.isNotEmpty) + if (isItemCommodity && item.huid != null && item.huid!.isNotEmpty) _buildSmallBadge('HUID: ${item.huid}', Colors.purple), if (item.hsnCode != null && item.hsnCode!.isNotEmpty) _buildSmallBadge('HSN: ${item.hsnCode}', Colors.grey), + if (!isItemCommodity) + _buildSmallBadge('Unit: $unit', Colors.teal), ], ), ], @@ -889,33 +1231,61 @@ class _InvoiceBuilderScreenState extends ConsumerState { ], ), const Divider(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Metal: ${weight.toStringAsFixed(3)}g × ₹${item.unitPrice.toStringAsFixed(2)}', - style: TextStyle(fontSize: 13, color: Colors.grey.shade600), - ), - Text( - '₹${metalAmount.toStringAsFixed(2)}', - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + if (isItemCommodity) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Metal: ${qtyOrWeight.toStringAsFixed(3)}$unit × ₹${item.unitPrice.toStringAsFixed(2)}', + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ), + Text( + '₹${baseAmount.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + ), + ], + ), + if (makingChargeAmt > 0) ...[ + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Making Charges (${item.makingChargesType == 'PERCENTAGE' ? '${item.makingCharge}%' : (item.makingChargesType == 'PER_PIECE' ? '₹${item.makingCharge}/pc' : '₹${item.makingCharge}/g')}):', + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ), + Text( + '₹${makingChargeAmt.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, color: Colors.indigo), + ), + ], ), ], - ), - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Making Charges (${item.makingChargesType == 'PERCENTAGE' ? '${item.makingCharge}%' : (item.makingChargesType == 'PER_PIECE' ? '₹${item.makingCharge}/pc' : '₹${item.makingCharge}/g')}):', - style: TextStyle(fontSize: 13, color: Colors.grey.shade600), - ), - Text( - '₹${makingChargeAmt.toStringAsFixed(2)}', - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, color: Colors.indigo), - ), - ], - ), + ] else ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Rate: ₹${item.unitPrice.toStringAsFixed(2)} / $unit • Qty: ${qtyOrWeight.toStringAsFixed(qtyOrWeight.truncateToDouble() == qtyOrWeight ? 0 : 2)} $unit', + style: TextStyle(fontSize: 13, color: Colors.grey.shade600, fontWeight: FontWeight.w500), + ), + Text( + '₹${baseAmount.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + ), + ], + ), + ], + if (item.discount > 0) ...[ + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Discount:', style: TextStyle(fontSize: 12, color: Colors.red)), + Text('- ₹${item.discount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.red)), + ], + ), + ], if (item.taxRate > 0) ...[ const SizedBox(height: 4), Row( @@ -926,7 +1296,7 @@ class _InvoiceBuilderScreenState extends ConsumerState { style: TextStyle(fontSize: 12, color: Colors.grey.shade500), ), Text( - '+ ₹${((metalAmount + makingChargeAmt) * (item.taxRate / 100.0)).toStringAsFixed(2)}', + '+ ₹${taxAmount.toStringAsFixed(2)}', style: TextStyle(fontSize: 12, color: Colors.grey.shade600), ), ], @@ -964,48 +1334,82 @@ class _InvoiceBuilderScreenState extends ConsumerState { } Widget _buildTotalsSummaryCard(bool isDark) { + final businessProfile = ref.watch(businessProfileProvider).value; + final businessStateId = businessProfile?.stateId; final products = ref.watch(productsProvider).value ?? []; - final businessStateId = ref.watch(businessProfileProvider).value?.stateId; + final categories = ref.watch(productCategoriesProvider).value ?? []; + final customers = ref.watch(customersProvider).value ?? []; final customer = _selectedCustomerId == null ? null : customers.where((c) => c.id == _selectedCustomerId).firstOrNull; final discountAmount = double.tryParse(_discountCtrl.text) ?? 0.0; - final isSameState = businessStateId != null && customer?.stateId != null && businessStateId == customer!.stateId; + final effectiveSupplyStateId = _selectedPlaceOfSupplyStateId ?? customer?.stateId ?? businessStateId; + final isSameState = businessStateId != null && effectiveSupplyStateId != null && businessStateId == effectiveSupplyStateId; - double metalSubtotal = 0; + final hasCommodityItems = _items.any((item) { + final product = products.where((p) => p.id == item.productId).firstOrNull; + final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull; + return _isCommodityProduct(category: category, product: product, invoiceItem: item); + }); + + double itemsSubtotal = 0; double makingChargesTotal = 0; double rawTaxableTotal = 0; double taxTotal = 0; for (var item in _items) { final product = products.where((p) => p.id == item.productId).firstOrNull; - final gstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? 3.0); - final weight = item.weight ?? item.quantity; - final metalAmt = weight * item.unitPrice; + final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull; + final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0); + final baseAmt = qtyOrWeight * item.unitPrice; + + final isItemCommodity = _isCommodityProduct(category: category, product: product, invoiceItem: item); double makingAmt = 0.0; - if (item.makingChargesType == 'PERCENTAGE') { - makingAmt = metalAmt * (item.makingCharge / 100.0); - } else if (item.makingChargesType == 'PER_PIECE') { - makingAmt = item.makingCharge; - } else { - makingAmt = weight * item.makingCharge; + if (isItemCommodity) { + if (item.makingChargesType == 'PERCENTAGE') { + makingAmt = baseAmt * (item.makingCharge / 100.0); + } else if (item.makingChargesType == 'PER_PIECE') { + makingAmt = item.makingCharge; + } else { + makingAmt = qtyOrWeight * item.makingCharge; + } } - metalSubtotal += metalAmt; + final itemTaxable = baseAmt + makingAmt + item.otherCharges - item.discount; + final itemGstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? (category?.defaultGst ?? (isItemCommodity ? 3.0 : 18.0))); + final itemTax = (itemTaxable.clamp(0.0, double.infinity) * itemGstRate) / 100.0; + + itemsSubtotal += baseAmt; makingChargesTotal += makingAmt; - rawTaxableTotal += (metalAmt + makingAmt + item.otherCharges); + rawTaxableTotal += (baseAmt + makingAmt + item.otherCharges); + taxTotal += itemTax; } final effectiveTaxable = (rawTaxableTotal - discountAmount).clamp(0.0, double.infinity); - final avgGstRate = _items.isNotEmpty - ? (_items.map((i) => i.taxRate > 0 ? i.taxRate : 3.0).reduce((a, b) => a + b) / _items.length) - : 3.0; - taxTotal = (effectiveTaxable * avgGstRate) / 100.0; + if (rawTaxableTotal > 0 && discountAmount > 0) { + taxTotal = taxTotal * (effectiveTaxable / rawTaxableTotal); + } final grandTotal = effectiveTaxable + taxTotal; + final effectiveGstRate = (effectiveTaxable > 0 && taxTotal > 0) + ? (taxTotal / effectiveTaxable) * 100.0 + : (_items.isNotEmpty ? _items.first.taxRate : (hasCommodityItems ? 3.0 : 18.0)); + final cgstRate = effectiveGstRate / 2.0; + final sgstRate = effectiveGstRate / 2.0; + final igstRate = effectiveGstRate; + + String formatRate(double rate) { + if (rate <= 0) return '0%'; + final rounded = (rate * 100).round() / 100; + if (rounded.truncateToDouble() == rounded) { + return '${rounded.toInt()}%'; + } + return '${rounded.toStringAsFixed(1)}%'; + } + return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -1025,21 +1429,33 @@ class _InvoiceBuilderScreenState extends ConsumerState { children: [ const Text('INVOICE SUMMARY', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 0.8, color: Colors.grey)), const SizedBox(height: 14), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Metal Subtotal:'), - Text('₹${metalSubtotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)), + if (hasCommodityItems) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Metal Subtotal:'), + Text('₹${itemsSubtotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)), + ], + ), + if (makingChargesTotal > 0) ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Making Charges:'), + Text('₹${makingChargesTotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)), + ], + ), ], - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Making Charges:'), - Text('₹${makingChargesTotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)), - ], - ), + ] else ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Items Subtotal (Taxable):'), + Text('₹${itemsSubtotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)), + ], + ), + ], if (discountAmount > 0) ...[ const SizedBox(height: 8), Row( @@ -1055,7 +1471,7 @@ class _InvoiceBuilderScreenState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('CGST (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)), + Text('CGST (${formatRate(cgstRate)}):', style: TextStyle(color: Colors.grey.shade600)), Text('₹${(taxTotal / 2.0).toStringAsFixed(2)}'), ], ), @@ -1063,7 +1479,7 @@ class _InvoiceBuilderScreenState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('SGST (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)), + Text('SGST (${formatRate(sgstRate)}):', style: TextStyle(color: Colors.grey.shade600)), Text('₹${(taxTotal / 2.0).toStringAsFixed(2)}'), ], ), @@ -1071,7 +1487,7 @@ class _InvoiceBuilderScreenState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('IGST (${(taxTotal > 0 ? '3.0%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)), + Text('IGST (${formatRate(igstRate)}):', style: TextStyle(color: Colors.grey.shade600)), Text('₹${taxTotal.toStringAsFixed(2)}'), ], ), @@ -1097,27 +1513,39 @@ class _InvoiceBuilderScreenState extends ConsumerState { final activeWallets = wallets.where((w) => w.nature == 'CASH' || w.nature == 'SAVINGS').toList(); // Compute grand total for live balance due - final products = ref.watch(productsProvider).value ?? []; final discountAmount = double.tryParse(_discountCtrl.text) ?? 0.0; + final products = ref.watch(productsProvider).value ?? []; + final categories = ref.watch(productCategoriesProvider).value ?? []; double rawTaxable = 0; + double taxTotal = 0; for (var item in _items) { - final weight = item.weight ?? item.quantity; - final metalAmt = weight * item.unitPrice; + final product = products.where((p) => p.id == item.productId).firstOrNull; + final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull; + final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0); + final baseAmt = qtyOrWeight * item.unitPrice; + final isItemCommodity = _isCommodityProduct(category: category, product: product, invoiceItem: item); double makingAmt = 0.0; - if (item.makingChargesType == 'PERCENTAGE') { - makingAmt = metalAmt * (item.makingCharge / 100.0); - } else if (item.makingChargesType == 'PER_PIECE') { - makingAmt = item.makingCharge; - } else { - makingAmt = weight * item.makingCharge; + if (isItemCommodity) { + if (item.makingChargesType == 'PERCENTAGE') { + makingAmt = baseAmt * (item.makingCharge / 100.0); + } else if (item.makingChargesType == 'PER_PIECE') { + makingAmt = item.makingCharge; + } else { + makingAmt = qtyOrWeight * item.makingCharge; + } } - rawTaxable += (metalAmt + makingAmt + item.otherCharges); + final itemTaxable = baseAmt + makingAmt + item.otherCharges - item.discount; + final itemGstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? (category?.defaultGst ?? (isItemCommodity ? 3.0 : 18.0))); + final itemTax = (itemTaxable.clamp(0.0, double.infinity) * itemGstRate) / 100.0; + + rawTaxable += (baseAmt + makingAmt + item.otherCharges); + taxTotal += itemTax; } final effectiveTaxable = (rawTaxable - discountAmount).clamp(0.0, double.infinity); - final avgGst = _items.isNotEmpty - ? (_items.map((i) => i.taxRate > 0 ? i.taxRate : 3.0).reduce((a, b) => a + b) / _items.length) - : 3.0; - final grandTotal = effectiveTaxable + (effectiveTaxable * avgGst / 100.0); + if (rawTaxable > 0 && discountAmount > 0) { + taxTotal = taxTotal * (effectiveTaxable / rawTaxable); + } + final grandTotal = effectiveTaxable + taxTotal; // If user has not manually changed amount paid and grandTotal > 0, auto-track grand total if (!_isAmountPaidEdited && grandTotal > 0 && _amountPaidCtrl.text != grandTotal.toStringAsFixed(2)) { @@ -1378,7 +1806,11 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { final _searchCtrl = TextEditingController(); Product? _selectedProduct; InventoryItem? _selectedInventoryItem; + double _selectedProductAvailableStock = 0.0; + final _unitPriceCtrl = TextEditingController(); + final _quantityCtrl = TextEditingController(text: '1'); + final _weightCtrl = TextEditingController(text: '1.000'); final _makingChargeCtrl = TextEditingController(text: '0.0'); String _makingChargeType = 'PER_GRAM'; final _discountCtrl = TextEditingController(text: '0.0'); @@ -1392,6 +1824,9 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { _makingChargeCtrl.text = it.makingCharge.toStringAsFixed(2); _makingChargeType = it.makingChargesType ?? 'PER_GRAM'; _discountCtrl.text = it.discount.toStringAsFixed(2); + _unitPriceCtrl.text = it.unitPrice.toStringAsFixed(2); + _quantityCtrl.text = (it.quantity > 0 ? it.quantity : 1.0).toStringAsFixed(it.quantity.truncateToDouble() == it.quantity ? 0 : 2); + _weightCtrl.text = (it.weight ?? 1.0).toStringAsFixed(3); WidgetsBinding.instance.addPostFrameCallback((_) { final products = ref.read(productsProvider).value ?? []; @@ -1404,6 +1839,7 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { sku: it.sku, hsnCode: it.hsnCode, gstRate: it.taxRate, + sellingPrice: it.unitPrice, makingCharges: it.makingCharge, makingChargesType: it.makingChargesType, ); @@ -1418,15 +1854,32 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { @override void dispose() { _searchCtrl.dispose(); + _unitPriceCtrl.dispose(); + _quantityCtrl.dispose(); + _weightCtrl.dispose(); _makingChargeCtrl.dispose(); _discountCtrl.dispose(); super.dispose(); } - void _onProductSelected(Product product, ProductCategory? category, double liveCommodityRate) { + void _onProductSelected( + Product product, + ProductCategory? category, + double liveCommodityRate, + bool isCommodity, { + double? avgPrice, + double? availableStock, + }) { setState(() { _selectedProduct = product; _selectedInventoryItem = null; + _selectedProductAvailableStock = availableStock ?? product.currentStock ?? 0.0; + final resolvedPrice = (avgPrice != null && avgPrice > 0) + ? avgPrice + : (product.sellingPrice ?? 0.0); + _unitPriceCtrl.text = resolvedPrice > 0 ? resolvedPrice.toStringAsFixed(2) : ''; + _quantityCtrl.text = '1'; + _weightCtrl.text = '1.000'; if (product.makingCharges != null && product.makingCharges! > 0) { _makingChargeCtrl.text = product.makingCharges!.toStringAsFixed(2); @@ -1441,10 +1894,15 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { }); } - void _onInventoryItemSelected(InventoryItem item, Product product, ProductCategory? category) { + void _onInventoryItemSelected(InventoryItem item, Product product, ProductCategory? category, bool isCommodity) { setState(() { _selectedProduct = product; _selectedInventoryItem = item; + _selectedProductAvailableStock = item.grossWeight ?? item.netWeight ?? 1.0; + final resolvedPrice = item.saleRate ?? product.sellingPrice ?? 0.0; + _unitPriceCtrl.text = resolvedPrice > 0 ? resolvedPrice.toStringAsFixed(2) : ''; + _quantityCtrl.text = '1'; + _weightCtrl.text = (item.grossWeight ?? 1.0).toStringAsFixed(3); if (item.makingCharges != null && item.makingCharges! > 0) { _makingChargeCtrl.text = item.makingCharges!.toStringAsFixed(2); @@ -1473,43 +1931,68 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { } } - void _submitItem(ProductCategory? category, double unitRate) { + void _submitItem(ProductCategory? category, double unitRate, bool isCommodity, String uomUnit) { if (_selectedProduct == null) return; - final weight = _selectedInventoryItem?.grossWeight ?? 1.0; - final makingCharge = double.tryParse(_makingChargeCtrl.text) ?? 0.0; final discount = double.tryParse(_discountCtrl.text) ?? 0.0; + final gstRate = _selectedProduct?.gstRate ?? (category?.defaultGst ?? (isCommodity ? 3.0 : 18.0)); - final metalAmount = weight * unitRate; - double makingAmt = 0.0; - if (_makingChargeType == 'PERCENTAGE') { - makingAmt = metalAmount * (makingCharge / 100.0); - } else if (_makingChargeType == 'PER_PIECE') { - makingAmt = makingCharge; + double effectivePrice = unitRate; + double qty = 1.0; + double? weight; + double makingCharge = 0.0; + String makingType = 'PER_PIECE'; + double lineTaxable = 0.0; + double taxAmount = 0.0; + double total = 0.0; + + if (isCommodity) { + weight = double.tryParse(_weightCtrl.text) ?? (_selectedInventoryItem?.grossWeight ?? 1.0); + qty = 1.0; + effectivePrice = unitRate; + makingCharge = double.tryParse(_makingChargeCtrl.text) ?? 0.0; + makingType = _makingChargeType; + + final metalAmount = weight * effectivePrice; + double makingAmt = 0.0; + if (makingType == 'PERCENTAGE') { + makingAmt = metalAmount * (makingCharge / 100.0); + } else if (makingType == 'PER_PIECE') { + makingAmt = makingCharge; + } else { + makingAmt = weight * makingCharge; + } + + lineTaxable = metalAmount + makingAmt - discount; + taxAmount = (lineTaxable.clamp(0.0, double.infinity) * gstRate) / 100.0; + total = lineTaxable.clamp(0.0, double.infinity) + taxAmount; } else { - makingAmt = weight * makingCharge; - } + effectivePrice = double.tryParse(_unitPriceCtrl.text) ?? (_selectedProduct?.sellingPrice ?? unitRate); + qty = double.tryParse(_quantityCtrl.text) ?? 1.0; + weight = null; + makingCharge = 0.0; + makingType = 'PER_PIECE'; - final taxable = metalAmount + makingAmt - discount; - final gstRate = _selectedProduct?.gstRate ?? (category?.defaultGst ?? 3.0); - final taxAmount = (taxable * gstRate) / 100.0; - final total = taxable + taxAmount; + lineTaxable = (qty * effectivePrice) - discount; + taxAmount = (lineTaxable.clamp(0.0, double.infinity) * gstRate) / 100.0; + total = lineTaxable.clamp(0.0, double.infinity) + taxAmount; + } final item = InvoiceItem( productId: _selectedProduct!.id, inventoryItemId: _selectedInventoryItem?.id, productName: _selectedProduct!.name, categoryName: category?.name, - commodityCode: category?.commodityCode, + commodityCode: isCommodity ? category?.commodityCode : null, hsnCode: category?.defaultHsn ?? _selectedProduct?.hsnCode, sku: _selectedInventoryItem?.sku ?? _selectedProduct?.sku, - huid: _selectedInventoryItem?.huid, + huid: isCommodity ? _selectedInventoryItem?.huid : null, description: _selectedProduct!.name, - quantity: 1.0, + quantity: qty, weight: weight, - unitPrice: unitRate, + unitPrice: effectivePrice, makingCharge: makingCharge, - makingChargesType: _makingChargeType, + makingChargesType: makingType, taxRate: gstRate, discount: discount, photoUrl: _selectedInventoryItem?.tagNumber, @@ -1526,6 +2009,11 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { final categories = ref.watch(productCategoriesProvider).value ?? []; final commodityRates = ref.watch(commodityRatesProvider).value ?? []; final inventoryItems = ref.watch(inventoryItemsProvider).value ?? []; + final uoms = ref.watch(uomsProvider).value ?? []; + + final businessProfile = ref.watch(businessProfileProvider).value; + final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase(); + final isJewellery = nature == 'JEWELLERY'; final isDark = Theme.of(context).brightness == Brightness.dark; @@ -1534,6 +2022,13 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { category = categories.where((c) => c.id == _selectedProduct!.categoryId).firstOrNull; } + final isCommodity = _isCommodityProduct( + category: category, + product: _selectedProduct, + inventoryItem: _selectedInventoryItem, + ); + final uomUnit = _resolveProductUnit(product: _selectedProduct, category: category, uoms: uoms, isCommodity: isCommodity); + double commodityRate = 0.0; if (category?.commodityCode != null) { final match = commodityRates.where((r) => r.commodityCode.toUpperCase() == category!.commodityCode!.toUpperCase()).firstOrNull; @@ -1542,24 +2037,35 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { } } final purityFactor = resolvePurity(categoryPurity: category?.purityFactor, productPurity: _selectedProduct?.purityFactor); - final effectiveUnitRate = commodityRate > 0 ? (commodityRate * purityFactor) : (_selectedProduct?.sellingPrice ?? 0.0); + final effectiveUnitRate = isCommodity + ? (commodityRate > 0 ? (commodityRate * purityFactor) : (_selectedProduct?.sellingPrice ?? 0.0)) + : (double.tryParse(_unitPriceCtrl.text) ?? (_selectedProduct?.sellingPrice ?? 0.0)); - final weight = _selectedInventoryItem?.grossWeight ?? 1.0; - final metalAmount = weight * effectiveUnitRate; - final makingCharge = double.tryParse(_makingChargeCtrl.text) ?? 0.0; - + final weight = double.tryParse(_weightCtrl.text) ?? (_selectedInventoryItem?.grossWeight ?? 1.0); + final quantity = double.tryParse(_quantityCtrl.text) ?? 1.0; + final discount = double.tryParse(_discountCtrl.text) ?? 0.0; + final gstRate = _selectedProduct?.gstRate ?? (category?.defaultGst ?? (isCommodity ? 3.0 : 18.0)); + + double taxable = 0.0; double makingAmt = 0.0; - if (_makingChargeType == 'PERCENTAGE') { - makingAmt = metalAmount * (makingCharge / 100.0); - } else if (_makingChargeType == 'PER_PIECE') { - makingAmt = makingCharge; + double metalAmount = 0.0; + + if (isCommodity) { + metalAmount = weight * effectiveUnitRate; + final makingCharge = double.tryParse(_makingChargeCtrl.text) ?? 0.0; + if (_makingChargeType == 'PERCENTAGE') { + makingAmt = metalAmount * (makingCharge / 100.0); + } else if (_makingChargeType == 'PER_PIECE') { + makingAmt = makingCharge; + } else { + makingAmt = weight * makingCharge; + } + taxable = (metalAmount + makingAmt - discount).clamp(0.0, double.infinity); } else { - makingAmt = weight * makingCharge; + final subtotal = quantity * effectiveUnitRate; + taxable = (subtotal - discount).clamp(0.0, double.infinity); } - final discount = double.tryParse(_discountCtrl.text) ?? 0.0; - final taxable = metalAmount + makingAmt - discount; - final gstRate = _selectedProduct?.gstRate ?? (category?.defaultGst ?? 3.0); final taxAmount = (taxable * gstRate) / 100.0; final total = taxable + taxAmount; @@ -1595,6 +2101,27 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { }).take(20).toList(); } + final matchingProductIdsInInv = displayInventoryItems.map((i) => i.productId).toSet(); + final directProducts = products.where((p) { + if (matchingProductIdsInInv.contains(p.id)) return false; + if (query.isEmpty) return true; + final q = query.toLowerCase(); + final cat = categories.where((c) => c.id == p.categoryId).firstOrNull; + return p.name.toLowerCase().contains(q) || + (p.sku != null && p.sku!.toLowerCase().contains(q)) || + (p.barcode != null && p.barcode!.toLowerCase().contains(q)) || + (cat != null && cat.name.toLowerCase().contains(q)); + }).take(20).toList(); + + final matchedProducts = products.where((p) { + if (query.isEmpty) return true; + final cat = categories.where((c) => c.id == p.categoryId).firstOrNull; + return p.name.toLowerCase().contains(query) || + (p.sku != null && p.sku!.toLowerCase().contains(query)) || + (p.barcode != null && p.barcode!.toLowerCase().contains(query)) || + (cat != null && cat.name.toLowerCase().contains(query)); + }).toList(); + final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; return AnimatedPadding( @@ -1690,7 +2217,9 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { TextField( controller: _searchCtrl, decoration: InputDecoration( - labelText: 'Search Product by HUID / Name / SKU / Barcode', + labelText: isJewellery + ? 'Search Product by HUID / Name / SKU / Barcode' + : 'Search Product by Name / SKU / Barcode', prefixIcon: const Icon(LucideIcons.search), suffixIcon: Row( mainAxisSize: MainAxisSize.min, @@ -1718,65 +2247,159 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - query.isEmpty - ? 'Available Stock in Inventory (${displayInventoryItems.length}):' - : 'Matched Inventory Items (${displayInventoryItems.length}):', + !isJewellery + ? (query.isEmpty ? 'Available Products in Catalog (${matchedProducts.length}):' : 'Matched Products (${matchedProducts.length}):') + : (query.isEmpty ? 'Available Stock in Inventory (${displayInventoryItems.length + directProducts.length}):' : 'Matched Items (${displayInventoryItems.length + directProducts.length}):'), style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: isDark ? Colors.grey.shade400 : Colors.grey.shade700, ), ), - if (query.isEmpty && availableItems.length > 20) - Text( - 'Showing 20 newest', - style: TextStyle(fontSize: 11, color: Colors.grey.shade500, fontStyle: FontStyle.italic), - ), ], ), const SizedBox(height: 10), - if (displayInventoryItems.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 40.0), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(LucideIcons.packageOpen, size: 40, color: Colors.grey.shade400), - const SizedBox(height: 10), - Text( - query.isEmpty - ? 'No items available in stock' - : 'No matching inventory items found', - style: TextStyle(color: Colors.grey.shade500, fontSize: 14), - ), - ], + if (!isJewellery) ...[ + // NON-JEWELLERY MODULE: Show List of Unique Products with Weighted Avg Price & Aggregated Available Stock + if (matchedProducts.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 40.0), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.packageOpen, size: 40, color: Colors.grey.shade400), + const SizedBox(height: 10), + Text( + query.isEmpty + ? 'No products available in catalog' + : 'No matching products found', + style: TextStyle(color: Colors.grey.shade500, fontSize: 14), + ), + ], + ), ), - ), - ) - else - ...displayInventoryItems.map((item) { - final p = products.where((pr) => pr.id == item.productId).firstOrNull ?? - Product( - id: item.productId, - name: item.sku ?? 'Inventory Item', - sku: item.sku, - ); - final cat = categories.where((c) => c.id == p.categoryId).firstOrNull; - double commRate = 0.0; - if (cat?.commodityCode != null) { - final m = commodityRates.where((r) => r.commodityCode.toUpperCase() == cat!.commodityCode!.toUpperCase()).firstOrNull; - if (m != null) commRate = m.rate; - } - return _buildSearchItemCard( - product: p, - category: cat, - inventoryItem: item, - commodityRate: commRate, - isDark: isDark, - onTap: () => _onInventoryItemSelected(item, p, cat), - ); - }), + ) + else ...[ + ...matchedProducts.map((p) { + final cat = categories.where((c) => c.id == p.categoryId).firstOrNull; + final itemUom = _resolveProductUnit(product: p, category: cat, uoms: uoms, isCommodity: false); + + // Find all available batches for this product in inventory + final pBatches = availableItems.where((i) => i.productId == p.id).toList(); + + double totalAvailableStock = 0.0; + double totalCostOrPrice = 0.0; + for (var batch in pBatches) { + final qty = (batch.grossWeight != null && batch.grossWeight! > 0) + ? batch.grossWeight! + : ((batch.netWeight != null && batch.netWeight! > 0) ? batch.netWeight! : 1.0); + final rate = (batch.saleRate != null && batch.saleRate! > 0) + ? batch.saleRate! + : (batch.purchaseCost != null && batch.purchaseCost! > 0 ? batch.purchaseCost! : (p.sellingPrice ?? 0.0)); + totalAvailableStock += qty; + totalCostOrPrice += (qty * rate); + } + + if (pBatches.isEmpty && (p.currentStock != null && p.currentStock! > 0)) { + totalAvailableStock = p.currentStock!; + totalCostOrPrice = totalAvailableStock * (p.sellingPrice ?? 0.0); + } + + final avgPrice = totalAvailableStock > 0 + ? (totalCostOrPrice / totalAvailableStock) + : (p.sellingPrice ?? 0.0); + + return _buildProductSearchCard( + product: p, + category: cat, + availableStock: totalAvailableStock, + avgPrice: avgPrice, + uomUnit: itemUom, + isDark: isDark, + onTap: () => _onProductSelected( + p, + cat, + 0.0, + false, + avgPrice: avgPrice, + availableStock: totalAvailableStock, + ), + ); + }), + ], + ] else ...[ + // JEWELLERY MODULE: Show Serialized Inventory Items (HUID, Tag No, Purity, Gross Weight) + if (displayInventoryItems.isEmpty && directProducts.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 40.0), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.packageOpen, size: 40, color: Colors.grey.shade400), + const SizedBox(height: 10), + Text( + query.isEmpty + ? 'No items available in stock' + : 'No matching items found', + style: TextStyle(color: Colors.grey.shade500, fontSize: 14), + ), + ], + ), + ), + ) + else ...[ + ...displayInventoryItems.map((item) { + final p = products.where((pr) => pr.id == item.productId).firstOrNull ?? + Product( + id: item.productId, + name: item.sku ?? 'Inventory Item', + sku: item.sku, + ); + final cat = categories.where((c) => c.id == p.categoryId).firstOrNull; + final itemIsCommodity = _isCommodityProduct(category: cat, product: p, inventoryItem: item); + final itemUom = _resolveProductUnit(product: p, category: cat, uoms: uoms, isCommodity: itemIsCommodity); + + double commRate = 0.0; + if (cat?.commodityCode != null) { + final m = commodityRates.where((r) => r.commodityCode.toUpperCase() == cat!.commodityCode!.toUpperCase()).firstOrNull; + if (m != null) commRate = m.rate; + } + return _buildSearchItemCard( + product: p, + category: cat, + inventoryItem: item, + commodityRate: commRate, + isCommodity: itemIsCommodity, + uomUnit: itemUom, + isDark: isDark, + onTap: () => _onInventoryItemSelected(item, p, cat, itemIsCommodity), + ); + }), + ...directProducts.map((p) { + final cat = categories.where((c) => c.id == p.categoryId).firstOrNull; + final itemIsCommodity = _isCommodityProduct(category: cat, product: p); + final itemUom = _resolveProductUnit(product: p, category: cat, uoms: uoms, isCommodity: itemIsCommodity); + + double commRate = 0.0; + if (cat?.commodityCode != null) { + final m = commodityRates.where((r) => r.commodityCode.toUpperCase() == cat!.commodityCode!.toUpperCase()).firstOrNull; + if (m != null) commRate = m.rate; + } + return _buildSearchItemCard( + product: p, + category: cat, + inventoryItem: null, + commodityRate: commRate, + isCommodity: itemIsCommodity, + uomUnit: itemUom, + isDark: isDark, + onTap: () => _onProductSelected(p, cat, commRate, itemIsCommodity), + ); + }), + ], + ], ] else ...[ // SELECTED PRODUCT DETAILS CARD Container( @@ -1816,11 +2439,17 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { _buildSheetBadge(category!.name, const Color(0xFFD4AF37)), if (_selectedProduct!.sku != null) _buildSheetBadge('SKU: ${_selectedProduct!.sku}', Colors.blue), - if (_selectedInventoryItem?.huid != null) + if (isCommodity && _selectedInventoryItem?.huid != null) _buildSheetBadge('HUID: ${_selectedInventoryItem!.huid}', Colors.purple), - if (category?.commodityCode != null) + if (isCommodity && category?.commodityCode != null) _buildSheetBadge('${category!.commodityCode} (${formatPurity(purityFactor)})', Colors.teal), - _buildSheetBadge('Purity: ${formatPurity(purityFactor)}', Colors.amber.shade800), + if (isCommodity) + _buildSheetBadge('Purity: ${formatPurity(purityFactor)}', Colors.amber.shade800), + if (!isCommodity) ...[ + _buildSheetBadge('Unit: $uomUnit', Colors.teal), + if (_selectedProductAvailableStock > 0) + _buildSheetBadge('Available Stock: ${_selectedProductAvailableStock.toStringAsFixed(_selectedProductAvailableStock.truncateToDouble() == _selectedProductAvailableStock ? 0 : 2)} $uomUnit', Colors.green.shade800), + ], ], ), ], @@ -1829,128 +2458,201 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { const SizedBox(height: 16), - // DISABLED FIELDS: Rounded 12px borders - Row( - children: [ - Expanded( - child: TextFormField( - initialValue: category?.defaultHsn ?? _selectedProduct?.hsnCode ?? '7113', - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), - enabled: false, - decoration: InputDecoration( - labelText: 'HSN Code', - prefixIcon: const Icon(LucideIcons.hash, size: 18), - contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: TextFormField( - initialValue: '${weight.toStringAsFixed(3)} g', - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), - enabled: false, - decoration: InputDecoration( - labelText: 'Weight', - prefixIcon: const Icon(LucideIcons.scale, size: 18), - contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - ), - ], - ), - const SizedBox(height: 12), - - Row( - children: [ - Expanded( - child: TextFormField( - key: ValueKey(effectiveUnitRate), - initialValue: '₹${effectiveUnitRate.toStringAsFixed(2)}/g', - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), - enabled: false, - decoration: InputDecoration( - labelText: 'Live Rate', - prefixIcon: const Icon(LucideIcons.trendingUp, size: 18), - contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: TextFormField( - key: ValueKey(metalAmount), - initialValue: '₹${metalAmount.toStringAsFixed(2)}', - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), - enabled: false, - decoration: InputDecoration( - labelText: 'Metal Amount', - prefixIcon: const Icon(LucideIcons.coins, size: 18), - contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - ), - ], - ), - - const SizedBox(height: 16), - - // ENABLED FIELDS: Making Charges & Type - Row( - children: [ - Expanded( - child: PremiumTextField( - controller: _makingChargeCtrl, - labelText: 'Making Charges', - keyboardType: const TextInputType.numberWithOptions(decimal: true), - prefixIcon: const Icon(LucideIcons.hammer), - onChanged: (_) => setState(() {}), - ), - ), - const SizedBox(width: 12), - Expanded( - child: DropdownButtonFormField( - value: _makingChargeType, - decoration: InputDecoration( - labelText: 'Type', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - ), - items: const [ - DropdownMenuItem(value: 'PER_GRAM', child: Text('Per Gram')), - DropdownMenuItem(value: 'PER_PIECE', child: Text('Per Piece')), - DropdownMenuItem(value: 'PERCENTAGE', child: Text('Percentage %')), - ], - onChanged: (val) => setState(() => _makingChargeType = val!), - ), - ), - ], - ), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.indigo.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.indigo.withValues(alpha: 0.2)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + if (isCommodity) ...[ + // COMMODITY / JEWELLERY FORM + Row( children: [ - const Text( - 'Calculated Making Charge:', - style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + Expanded( + child: TextFormField( + initialValue: category?.defaultHsn ?? _selectedProduct?.hsnCode ?? '7113', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + enabled: false, + decoration: InputDecoration( + labelText: 'HSN Code', + prefixIcon: const Icon(LucideIcons.hash, size: 18), + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), ), - Text( - '₹${makingAmt.toStringAsFixed(2)}', - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.indigo), + const SizedBox(width: 12), + Expanded( + child: PremiumTextField( + controller: _weightCtrl, + labelText: 'Weight ($uomUnit)', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + prefixIcon: const Icon(LucideIcons.scale, size: 18), + onChanged: (_) => setState(() {}), + ), ), ], ), - ), + const SizedBox(height: 12), + + Row( + children: [ + Expanded( + child: TextFormField( + key: ValueKey(effectiveUnitRate), + initialValue: '₹${effectiveUnitRate.toStringAsFixed(2)}/$uomUnit', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + enabled: false, + decoration: InputDecoration( + labelText: 'Live Rate', + prefixIcon: const Icon(LucideIcons.trendingUp, size: 18), + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + key: ValueKey(metalAmount), + initialValue: '₹${metalAmount.toStringAsFixed(2)}', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + enabled: false, + decoration: InputDecoration( + labelText: 'Metal Amount', + prefixIcon: const Icon(LucideIcons.coins, size: 18), + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + Row( + children: [ + Expanded( + child: PremiumTextField( + controller: _makingChargeCtrl, + labelText: 'Making Charges', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + prefixIcon: const Icon(LucideIcons.hammer), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + value: _makingChargeType, + decoration: InputDecoration( + labelText: 'Type', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + items: const [ + DropdownMenuItem(value: 'PER_GRAM', child: Text('Per Gram')), + DropdownMenuItem(value: 'PER_PIECE', child: Text('Per Piece')), + DropdownMenuItem(value: 'PERCENTAGE', child: Text('Percentage %')), + ], + onChanged: (val) => setState(() => _makingChargeType = val!), + ), + ), + ], + ), + if (makingAmt > 0) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.indigo.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.indigo.withValues(alpha: 0.2)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Calculated Making Charge:', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), + Text( + '₹${makingAmt.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.indigo), + ), + ], + ), + ), + ], + const SizedBox(height: 12), + PremiumTextField( + controller: _discountCtrl, + labelText: 'Item Discount (₹)', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + prefixIcon: const Icon(LucideIcons.tag), + onChanged: (_) => setState(() {}), + ), + ] else ...[ + // E-COMMERCE / GENERAL / NON-COMMODITY FORM + Row( + children: [ + Expanded( + child: PremiumTextField( + controller: _unitPriceCtrl, + labelText: 'Unit Price (₹ / $uomUnit)', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + prefixIcon: const Icon(LucideIcons.indianRupee), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PremiumTextField( + controller: _quantityCtrl, + labelText: 'Quantity ($uomUnit)', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + prefixIcon: const Icon(LucideIcons.package), + onChanged: (_) => setState(() {}), + ), + if (_selectedProductAvailableStock > 0 && + (double.tryParse(_quantityCtrl.text) ?? 0.0) > _selectedProductAvailableStock) ...[ + const SizedBox(height: 4), + Text( + 'Exceeds available stock (${_selectedProductAvailableStock.toStringAsFixed(_selectedProductAvailableStock.truncateToDouble() == _selectedProductAvailableStock ? 0 : 2)} $uomUnit)', + style: const TextStyle(fontSize: 11, color: Colors.orange, fontWeight: FontWeight.w600), + ), + ], + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: PremiumTextField( + controller: _discountCtrl, + labelText: 'Item Discount (₹)', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + prefixIcon: const Icon(LucideIcons.tag), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + initialValue: category?.defaultHsn ?? _selectedProduct?.hsnCode ?? '-', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + enabled: false, + decoration: InputDecoration( + labelText: 'HSN Code', + prefixIcon: const Icon(LucideIcons.hash, size: 18), + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ], + ), + ], const SizedBox(height: 16), @@ -1993,7 +2695,7 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { const SizedBox(height: 24), ElevatedButton( - onPressed: () => _submitItem(category, effectiveUnitRate), + onPressed: () => _submitItem(category, effectiveUnitRate, isCommodity, uomUnit), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), backgroundColor: Colors.blue, @@ -2028,18 +2730,177 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { ); } + Widget _buildProductSearchCard({ + required Product product, + ProductCategory? category, + required double availableStock, + required double avgPrice, + required String uomUnit, + required bool isDark, + required VoidCallback onTap, + }) { + final gstRate = product.gstRate ?? (category?.defaultGst ?? 18.0); + final unitWithGst = avgPrice > 0 ? avgPrice * (1 + gstRate / 100.0) : 0.0; + final totalStockValue = availableStock * avgPrice; + final sku = product.sku; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + LucideIcons.package, + size: 20, + color: Colors.blue, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + ), + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + if (category?.name != null) + _buildSheetBadge(category!.name, const Color(0xFFD4AF37)), + if (sku != null && sku.isNotEmpty) + _buildSheetBadge('SKU: $sku', Colors.blue), + _buildSheetBadge('Unit: $uomUnit', Colors.teal), + ], + ), + ], + ), + ), + ], + ), + const Divider(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + avgPrice > 0 + ? 'Avg Price: ₹${avgPrice.toStringAsFixed(2)} / $uomUnit' + : 'Price: Not Set', + style: TextStyle( + fontSize: 13, + color: avgPrice > 0 ? (isDark ? Colors.white70 : Colors.grey.shade800) : Colors.orange.shade700, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 3), + Text( + 'Available Stock: ${availableStock.toStringAsFixed(availableStock.truncateToDouble() == availableStock ? 0 : 2)} $uomUnit', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: availableStock > 0 ? Colors.green.shade700 : Colors.red.shade600, + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (avgPrice > 0 && availableStock > 0) ...[ + Text( + '₹${totalStockValue.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.green), + ), + Text( + '₹${unitWithGst.toStringAsFixed(2)}/$uomUnit (incl. ${gstRate.toStringAsFixed(1)}% GST)', + style: TextStyle(fontSize: 10, color: Colors.grey.shade500), + ), + ] else if (avgPrice > 0) ...[ + Text( + '₹${unitWithGst.toStringAsFixed(2)} / $uomUnit', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: Colors.green), + ), + Text( + 'incl. ${gstRate.toStringAsFixed(1)}% GST', + style: TextStyle(fontSize: 10, color: Colors.grey.shade500), + ), + ] else ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: const Text( + 'Tap to enter price', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.blue), + ), + ), + ], + ], + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + Widget _buildSearchItemCard({ required Product product, ProductCategory? category, InventoryItem? inventoryItem, required double commodityRate, + required bool isCommodity, + required String uomUnit, required bool isDark, required VoidCallback onTap, }) { final purity = resolvePurity(categoryPurity: category?.purityFactor, productPurity: product.purityFactor); - final unitRate = commodityRate > 0 ? (commodityRate * purity) : (product.sellingPrice ?? 0.0); + final unitRate = isCommodity + ? (commodityRate > 0 ? (commodityRate * purity) : (inventoryItem?.saleRate ?? product.sellingPrice ?? 0.0)) + : (inventoryItem?.saleRate ?? product.sellingPrice ?? 0.0); final weight = inventoryItem?.grossWeight ?? 1.0; - final metalAmount = weight * unitRate; + final baseAmount = weight * unitRate; final makingCharge = (inventoryItem?.makingCharges != null && inventoryItem!.makingCharges! > 0) ? inventoryItem.makingCharges! @@ -2053,16 +2914,18 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { : (category?.makingChargeType ?? 'PER_GRAM')); double makingAmt = 0.0; - if (makingType == 'PERCENTAGE') { - makingAmt = metalAmount * (makingCharge / 100.0); - } else if (makingType == 'PER_PIECE') { - makingAmt = makingCharge; - } else { - makingAmt = weight * makingCharge; + if (isCommodity) { + if (makingType == 'PERCENTAGE') { + makingAmt = baseAmount * (makingCharge / 100.0); + } else if (makingType == 'PER_PIECE') { + makingAmt = makingCharge; + } else { + makingAmt = weight * makingCharge; + } } - final gstRate = product.gstRate ?? (category?.defaultGst ?? 3.0); - final taxable = metalAmount + makingAmt; + final gstRate = product.gstRate ?? (category?.defaultGst ?? (isCommodity ? 3.0 : 18.0)); + final taxable = baseAmount + makingAmt; final taxAmount = (taxable * gstRate) / 100.0; final totalWithTax = taxable + taxAmount; @@ -2100,13 +2963,13 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: (huid != null ? Colors.purple : Colors.blue).withValues(alpha: 0.12), + color: (isCommodity ? (huid != null ? Colors.purple : const Color(0xFFD4AF37)) : Colors.blue).withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), ), child: Icon( - huid != null ? LucideIcons.gem : LucideIcons.tag, + isCommodity ? (huid != null ? LucideIcons.gem : LucideIcons.sparkles) : LucideIcons.tag, size: 20, - color: huid != null ? Colors.purple : Colors.blue, + color: isCommodity ? (huid != null ? Colors.purple : const Color(0xFFD4AF37)) : Colors.blue, ), ), const SizedBox(width: 10), @@ -2127,9 +2990,12 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { _buildSheetBadge(category!.name, const Color(0xFFD4AF37)), if (sku != null && sku.isNotEmpty) _buildSheetBadge('SKU: $sku', Colors.blue), - if (huid != null && huid.isNotEmpty) + if (isCommodity && huid != null && huid.isNotEmpty) _buildSheetBadge('HUID: $huid', Colors.purple), - _buildSheetBadge('Purity: ${formatPurity(purity)}', Colors.amber.shade900), + if (isCommodity) + _buildSheetBadge('Purity: ${formatPurity(purity)}', Colors.amber.shade900), + if (!isCommodity) + _buildSheetBadge('Unit: $uomUnit', Colors.teal), ], ), ], @@ -2138,50 +3004,108 @@ class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { ], ), const Divider(height: 18), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Rate: ₹${unitRate.toStringAsFixed(2)}/g • ${weight.toStringAsFixed(3)}g', - style: TextStyle(fontSize: 13, color: Colors.grey.shade600, fontWeight: FontWeight.w500), - ), - Text( - 'Metal: ₹${metalAmount.toStringAsFixed(2)}', - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), - ), - ], - ), - const SizedBox(height: 6), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.indigo.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.indigo.withValues(alpha: 0.2)), + if (isCommodity) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Rate: ₹${unitRate.toStringAsFixed(2)}/$uomUnit • ${weight.toStringAsFixed(3)}$uomUnit', + style: TextStyle(fontSize: 13, color: Colors.grey.shade600, fontWeight: FontWeight.w500), ), - child: Text( - 'Making: ${makingType == 'PERCENTAGE' ? '$makingCharge%' : (makingType == 'PER_PIECE' ? '₹$makingCharge/pc' : '₹$makingCharge/g')} (₹${makingAmt.toStringAsFixed(2)})', - style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.indigo), + Text( + 'Metal: ₹${baseAmount.toStringAsFixed(2)}', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '₹${totalWithTax.toStringAsFixed(2)}', - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.green), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.indigo.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.indigo.withValues(alpha: 0.2)), ), - Text( - 'incl. ${gstRate.toStringAsFixed(1)}% GST', - style: TextStyle(fontSize: 10, color: Colors.grey.shade500), + child: Text( + 'Making: ${makingType == 'PERCENTAGE' ? '$makingCharge%' : (makingType == 'PER_PIECE' ? '₹$makingCharge/pc' : '₹$makingCharge/g')} (₹${makingAmt.toStringAsFixed(2)})', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.indigo), ), - ], - ), - ], - ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '₹${totalWithTax.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.green), + ), + Text( + 'incl. ${gstRate.toStringAsFixed(1)}% GST', + style: TextStyle(fontSize: 10, color: Colors.grey.shade500), + ), + ], + ), + ], + ), + ] else ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + unitRate > 0 + ? 'Price: ₹${unitRate.toStringAsFixed(2)} / $uomUnit' + : 'Price: Not Set', + style: TextStyle( + fontSize: 13, + color: unitRate > 0 ? (isDark ? Colors.white70 : Colors.grey.shade800) : Colors.orange.shade700, + fontWeight: FontWeight.w600, + ), + ), + if (inventoryItem?.grossWeight != null && inventoryItem!.grossWeight! > 0) ...[ + const SizedBox(height: 2), + Text( + 'In Stock: ${(inventoryItem!.grossWeight!).toStringAsFixed((inventoryItem!.grossWeight!).truncateToDouble() == inventoryItem!.grossWeight! ? 0 : 2)} $uomUnit', + style: TextStyle(fontSize: 11, color: isDark ? Colors.white54 : Colors.grey.shade600), + ), + ], + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (unitRate > 0) ...[ + Text( + '₹${totalWithTax.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.green), + ), + Text( + 'incl. ${gstRate.toStringAsFixed(1)}% GST', + style: TextStyle(fontSize: 10, color: Colors.grey.shade500), + ), + ] else ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: const Text( + 'Tap to enter price', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.blue), + ), + ), + ], + ], + ), + ], + ), + ], ], ), ), diff --git a/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart b/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart index 983bb79..b96e0f1 100644 --- a/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart @@ -12,6 +12,8 @@ import 'package:pdf/widgets.dart' as pw; import 'package:printing/printing.dart'; import '../../inventory/providers/products_provider.dart'; import '../../inventory/providers/product_categories_provider.dart'; +import '../../inventory/providers/uoms_provider.dart'; +import '../../inventory/domain/product.dart'; import '../domain/invoice.dart'; import '../providers/invoices_provider.dart'; import '../providers/customers_provider.dart'; @@ -21,6 +23,46 @@ import '../../../core/network/dio_client.dart'; import 'widgets/receive_payment_sheet.dart'; import 'invoice_builder_screen.dart'; +bool _isCommodityProduct({ + ProductCategory? category, + Product? product, + InvoiceItem? invoiceItem, + String? commodityCode, + String? huid, +}) { + final h = huid ?? invoiceItem?.huid; + if (h != null && h.trim().isNotEmpty) return true; + + final code = commodityCode ?? + category?.commodityCode ?? + invoiceItem?.commodityCode; + if (code != null && code.trim().isNotEmpty && code.trim().toUpperCase() != 'NONE') { + return true; + } + + return false; +} + +String _resolveProductUnit({ + required Product? product, + required ProductCategory? category, + required List uoms, + bool isCommodity = false, +}) { + if (isCommodity) { + return (category != null && category.baseUnit.isNotEmpty) ? category.baseUnit : 'g'; + } + if (product?.uomId != null) { + final uom = uoms.where((u) => u.id == product!.uomId).firstOrNull; + if (uom != null && uom.abbreviation != null && uom.abbreviation!.isNotEmpty) return uom.abbreviation!; + if (uom != null && uom.name.isNotEmpty) return uom.name; + } + if (category != null && category.baseUnit.isNotEmpty) { + return category.baseUnit; + } + return 'pcs'; +} + String _resolveImageUrl(String path) { if (path.startsWith('http://') || path.startsWith('https://')) { return path; @@ -185,10 +227,45 @@ class _InvoiceDetailsScreenState extends ConsumerState { const Text('INVOICE NO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)), const SizedBox(height: 2), Text(latestInvoice.invoiceNumber, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.blue)), + if (latestInvoice.salesChannel != null && latestInvoice.salesChannel!.isNotEmpty) ...[ + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.blue.withValues(alpha: 0.25)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.shoppingBag, size: 10, color: Colors.blue), + const SizedBox(width: 4), + Text( + latestInvoice.salesChannel!, + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.blue), + ), + ], + ), + ), + ], + if (latestInvoice.marketplaceOrderId != null && latestInvoice.marketplaceOrderId!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Ref: ${latestInvoice.marketplaceOrderId}', + style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: Colors.grey.shade700), + ), + ], const SizedBox(height: 10), const Text('INVOICE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)), const SizedBox(height: 2), Text(formatDate.format(latestInvoice.issueDate), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + if (latestInvoice.placeOfSupply != null && latestInvoice.placeOfSupply!.isNotEmpty) ...[ + const SizedBox(height: 8), + const Text('PLACE OF SUPPLY', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)), + const SizedBox(height: 2), + Text(latestInvoice.placeOfSupply!, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)), + ], ], ), ), @@ -233,19 +310,33 @@ class _InvoiceDetailsScreenState extends ConsumerState { // Item Cards ...(latestInvoice.items).map((item) { final product = products.where((p) => p.id == item.productId).firstOrNull; - final category = categories.where((c) => c.id == product?.categoryId).firstOrNull; - final weight = item.weight ?? item.quantity; - final metalAmount = weight * item.unitPrice; + final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull; + final uoms = ref.watch(uomsProvider).value ?? []; + + final isItemCommodity = _isCommodityProduct( + category: category, + product: product, + invoiceItem: item, + ); + + final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0); + final baseAmount = qtyOrWeight * item.unitPrice; + final unit = _resolveProductUnit(product: product, category: category, uoms: uoms, isCommodity: isItemCommodity); double makingChargeAmt = 0.0; - if (item.makingChargesType == 'PERCENTAGE') { - makingChargeAmt = metalAmount * (item.makingCharge / 100.0); - } else if (item.makingChargesType == 'PER_PIECE') { - makingChargeAmt = item.makingCharge; - } else { - makingChargeAmt = weight * item.makingCharge; + if (isItemCommodity) { + if (item.makingChargesType == 'PERCENTAGE') { + makingChargeAmt = baseAmount * (item.makingCharge / 100.0); + } else if (item.makingChargesType == 'PER_PIECE') { + makingChargeAmt = item.makingCharge; + } else { + makingChargeAmt = qtyOrWeight * item.makingCharge; + } } + final taxableAmount = baseAmount + makingChargeAmt + item.otherCharges - item.discount; + final taxAmount = (taxableAmount * item.taxRate) / 100.0; + return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(16), @@ -278,7 +369,7 @@ class _InvoiceDetailsScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - item.productName ?? product?.name ?? item.description ?? 'Jewellery Item', + item.productName ?? product?.name ?? item.description ?? 'Item', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), ), const SizedBox(height: 4), @@ -290,10 +381,12 @@ class _InvoiceDetailsScreenState extends ConsumerState { _buildDetailBadge(item.categoryName ?? category!.name, const Color(0xFFD4AF37)), if (item.sku != null || product?.sku != null) _buildDetailBadge('SKU: ${item.sku ?? product!.sku}', Colors.blue), - if (item.huid != null) + if (isItemCommodity && item.huid != null && item.huid!.isNotEmpty) _buildDetailBadge('HUID: ${item.huid}', Colors.purple), - if (item.hsnCode != null) + if (item.hsnCode != null && item.hsnCode!.isNotEmpty) _buildDetailBadge('HSN: ${item.hsnCode}', Colors.grey), + if (!isItemCommodity) + _buildDetailBadge('Unit: $unit', Colors.teal), ], ), ], @@ -306,27 +399,52 @@ class _InvoiceDetailsScreenState extends ConsumerState { ], ), const Divider(height: 18), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Metal (${weight.toStringAsFixed(3)}g @ ₹${item.unitPrice.toStringAsFixed(2)}/g):', - style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + if (isItemCommodity) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Metal (${qtyOrWeight.toStringAsFixed(3)}$unit @ ₹${item.unitPrice.toStringAsFixed(2)}/$unit):', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + Text(formatCurrency.format(baseAmount), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + if (makingChargeAmt > 0) ...[ + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Making Charges (${item.makingChargesType == 'PERCENTAGE' ? '${item.makingCharge}%' : (item.makingChargesType == 'PER_PIECE' ? '₹${item.makingCharge}/pc' : '₹${item.makingCharge}/g')}):', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + Text(formatCurrency.format(makingChargeAmt), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12, color: Colors.indigo)), + ], ), - Text(formatCurrency.format(metalAmount), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), ], - ), - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Making Charges (${item.makingChargesType == 'PERCENTAGE' ? '${item.makingCharge}%' : (item.makingChargesType == 'PER_PIECE' ? '₹${item.makingCharge}/pc' : '₹${item.makingCharge}/g')}):', - style: TextStyle(fontSize: 12, color: Colors.grey.shade600), - ), - Text(formatCurrency.format(makingChargeAmt), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12, color: Colors.indigo)), - ], - ), + ] else ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Rate: ₹${item.unitPrice.toStringAsFixed(2)} / $unit • Qty: ${qtyOrWeight.toStringAsFixed(qtyOrWeight.truncateToDouble() == qtyOrWeight ? 0 : 2)} $unit', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w500), + ), + Text(formatCurrency.format(baseAmount), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + ], + if (item.discount > 0) ...[ + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Discount:', style: TextStyle(fontSize: 11, color: Colors.red)), + Text('- ${formatCurrency.format(item.discount)}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.red)), + ], + ), + ], if (item.taxRate > 0) ...[ const SizedBox(height: 4), Row( @@ -334,7 +452,7 @@ class _InvoiceDetailsScreenState extends ConsumerState { children: [ Text('GST (${item.taxRate.toStringAsFixed(1)}%):', style: TextStyle(fontSize: 11, color: Colors.grey.shade500)), Text( - '+ ${formatCurrency.format(((metalAmount + makingChargeAmt) * (item.taxRate / 100.0)))}', + '+ ${formatCurrency.format(taxAmount)}', style: TextStyle(fontSize: 11, color: Colors.grey.shade600), ), ], @@ -556,6 +674,7 @@ class _InvoiceDetailsScreenState extends ConsumerState { final business = ref.read(businessProfileProvider).value; final products = ref.read(productsProvider).value ?? []; final categories = ref.read(productCategoriesProvider).value ?? []; + final uoms = ref.read(uomsProvider).value ?? []; final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2); final formatDate = DateFormat('dd MMM yyyy'); @@ -776,6 +895,19 @@ class _InvoiceDetailsScreenState extends ConsumerState { final groupTotal = group.fold(0.0, (sum, i) => sum + i.total); final gstRate = group.first.taxRate > 0 ? group.first.taxRate : (product?.gstRate ?? (category?.defaultGst ?? 0.0)); + final isGroupCommodity = group.any((item) => _isCommodityProduct( + category: category, + product: product, + invoiceItem: item, + )); + + final groupUnit = _resolveProductUnit( + product: product, + category: category, + uoms: uoms, + isCommodity: isGroupCommodity, + ); + return pw.Container( margin: const pw.EdgeInsets.only(bottom: 14), decoration: pw.BoxDecoration( @@ -839,22 +971,40 @@ class _InvoiceDetailsScreenState extends ConsumerState { style: const pw.TextStyle(fontSize: 7.5, color: PdfColors.grey800), ), ), - pw.Container( - padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: pw.BoxDecoration( - color: PdfColors.amber50, - border: pw.Border.all(color: PdfColors.amber600, width: 0.5), - borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)), - ), - child: pw.Text( - 'Purity: $purityStr', - style: pw.TextStyle( - fontSize: 7.5, - fontWeight: pw.FontWeight.bold, - color: PdfColors.brown800, + if (isGroupCommodity) + pw.Container( + padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: pw.BoxDecoration( + color: PdfColors.amber50, + border: pw.Border.all(color: PdfColors.amber600, width: 0.5), + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)), + ), + child: pw.Text( + 'Purity: $purityStr', + style: pw.TextStyle( + fontSize: 7.5, + fontWeight: pw.FontWeight.bold, + color: PdfColors.brown800, + ), + ), + ), + if (!isGroupCommodity) + pw.Container( + padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: pw.BoxDecoration( + color: PdfColors.teal50, + border: pw.Border.all(color: PdfColors.teal400, width: 0.5), + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)), + ), + child: pw.Text( + 'Unit: $groupUnit', + style: pw.TextStyle( + fontSize: 7.5, + fontWeight: pw.FontWeight.bold, + color: PdfColors.teal900, + ), ), ), - ), if (hsnStr != '-') pw.Container( padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2), @@ -900,49 +1050,71 @@ class _InvoiceDetailsScreenState extends ConsumerState { 4: pw.Alignment.centerRight, 5: pw.Alignment.centerRight, }, - headers: [ - 'Item / HUID', - 'Weight / Qty', - 'Rate', - 'Making Chg', - 'Other Chg', - 'Total Amt', - ], - data: group.map((item) { - String particular = ''; - if (item.huid != null && item.huid!.isNotEmpty) { - particular = 'HUID: ${item.huid}'; - } else if (item.sku != null && item.sku!.isNotEmpty) { - particular = 'SKU: ${item.sku}'; - } else { - particular = 'Standard Item'; - } + headers: isGroupCommodity + ? [ + 'Item / HUID', + 'Weight / Qty', + 'Rate', + 'Making Chg', + 'Other Chg', + 'Total Amt', + ] + : [ + 'Item / SKU', + 'Qty', + 'Unit Price', + 'Discount', + 'GST %', + 'Total Amt', + ], + data: isGroupCommodity + ? group.map((item) { + String particular = ''; + if (item.huid != null && item.huid!.isNotEmpty) { + particular = 'HUID: ${item.huid}'; + } else if (item.sku != null && item.sku!.isNotEmpty) { + particular = 'SKU: ${item.sku}'; + } else { + particular = 'Standard Item'; + } - final weight = item.weight ?? item.quantity; - final weightQty = item.weight != null && item.weight! > 0 - ? '${item.weight!.toStringAsFixed(item.weight!.truncateToDouble() == item.weight ? 0 : 3)} g' - : '${item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 0)} pcs'; + final weight = item.weight ?? item.quantity; + final weightQty = item.weight != null && item.weight! > 0 + ? '${item.weight!.toStringAsFixed(item.weight!.truncateToDouble() == item.weight ? 0 : 3)} $groupUnit' + : '${item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 0)} pcs'; - final rateUnit = item.weight != null && item.weight! > 0 ? '/ g' : '/ pc'; + final rateUnit = item.weight != null && item.weight! > 0 ? '/ $groupUnit' : '/ pc'; - double makingAmt = 0.0; - if (item.makingChargesType == 'PERCENTAGE') { - makingAmt = (weight * item.unitPrice) * (item.makingCharge / 100.0); - } else if (item.makingChargesType == 'PER_PIECE') { - makingAmt = item.makingCharge; - } else { - makingAmt = weight * item.makingCharge; - } + double makingAmt = 0.0; + if (item.makingChargesType == 'PERCENTAGE') { + makingAmt = (weight * item.unitPrice) * (item.makingCharge / 100.0); + } else if (item.makingChargesType == 'PER_PIECE') { + makingAmt = item.makingCharge; + } else { + makingAmt = weight * item.makingCharge; + } - return [ - particular, - weightQty, - '${formatCurrency.format(item.unitPrice)} $rateUnit', - makingAmt > 0 ? formatCurrency.format(makingAmt) : '-', - item.otherCharges > 0 ? formatCurrency.format(item.otherCharges) : '-', - formatCurrency.format(item.total), - ]; - }).toList(), + return [ + particular, + weightQty, + '${formatCurrency.format(item.unitPrice)} $rateUnit', + makingAmt > 0 ? formatCurrency.format(makingAmt) : '-', + item.otherCharges > 0 ? formatCurrency.format(item.otherCharges) : '-', + formatCurrency.format(item.total), + ]; + }).toList() + : group.map((item) { + final qty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0); + final qtyStr = '${qty.toStringAsFixed(qty.truncateToDouble() == qty ? 0 : 2)} $groupUnit'; + return [ + item.sku ?? skuStr ?? 'Item', + qtyStr, + formatCurrency.format(item.unitPrice), + item.discount > 0 ? '-${formatCurrency.format(item.discount)}' : '-', + '${item.taxRate.toStringAsFixed(1)}%', + formatCurrency.format(item.total), + ]; + }).toList(), ), // Group Subtotal & GST Calculation Box @@ -959,7 +1131,9 @@ class _InvoiceDetailsScreenState extends ConsumerState { mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text( - 'Metal Subtotal: ${formatCurrency.format(groupSubtotal)} | Making: ${formatCurrency.format(groupMaking)}', + isGroupCommodity + ? 'Metal Subtotal: ${formatCurrency.format(groupSubtotal)} | Making: ${formatCurrency.format(groupMaking)}' + : 'Items Subtotal: ${formatCurrency.format(groupSubtotal)}', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), ), pw.Text( diff --git a/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart b/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart index aa5e3d3..80e55b8 100644 --- a/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart @@ -198,11 +198,35 @@ class _InvoicesListScreenState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - invoice.invoiceNumber, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + invoice.invoiceNumber, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (invoice.salesChannel != null && invoice.salesChannel!.isNotEmpty) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.blue.withValues(alpha: 0.25)), + ), + child: Text( + invoice.salesChannel!, + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.blue), + ), + ), + ], + ], ), ), Container( diff --git a/kifi-app/lib/features/sales/presentation/sales_channels_screen.dart b/kifi-app/lib/features/sales/presentation/sales_channels_screen.dart new file mode 100644 index 0000000..01823c3 --- /dev/null +++ b/kifi-app/lib/features/sales/presentation/sales_channels_screen.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../domain/sales_channel.dart'; +import '../providers/sales_channels_provider.dart'; + +class SalesChannelsScreen extends ConsumerStatefulWidget { + const SalesChannelsScreen({super.key}); + + @override + ConsumerState createState() => _SalesChannelsScreenState(); +} + +class _SalesChannelsScreenState extends ConsumerState { + void _showAddEditChannelSheet([SalesChannel? channel]) { + final nameCtrl = TextEditingController(text: channel?.name ?? ''); + final codeCtrl = TextEditingController(text: channel?.code ?? ''); + final isEditing = channel != null; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, + left: 20, + right: 20, + top: 20, + ), + decoration: BoxDecoration( + color: Theme.of(ctx).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + isEditing ? 'Edit Sales Channel' : 'Add Sales Channel', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: nameCtrl, + decoration: const InputDecoration( + labelText: 'Channel Name *', + hintText: 'e.g. Nykaa, Myntra, Tata CLiQ, Etsy', + border: OutlineInputBorder(), + prefixIcon: Icon(LucideIcons.store), + ), + ), + const SizedBox(height: 16), + TextField( + controller: codeCtrl, + decoration: const InputDecoration( + labelText: 'Channel Code', + hintText: 'e.g. NYKAA, MYNTRA, ETSY', + border: OutlineInputBorder(), + prefixIcon: Icon(LucideIcons.tag), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 48, + child: FilledButton( + onPressed: () async { + final name = nameCtrl.text.trim(); + if (name.isEmpty) return; + + Navigator.pop(ctx); + try { + if (isEditing) { + await ref.read(salesChannelsProvider.notifier).updateChannel( + channel.id!, + SalesChannel( + id: channel.id, + name: name, + code: codeCtrl.text.trim().isNotEmpty + ? codeCtrl.text.trim().toUpperCase() + : null, + icon: channel.icon, + isActive: channel.isActive, + ), + ); + } else { + await ref.read(salesChannelsProvider.notifier).createChannel( + SalesChannel( + name: name, + code: codeCtrl.text.trim().isNotEmpty + ? codeCtrl.text.trim().toUpperCase() + : null, + icon: 'shopping-bag', + isActive: true, + ), + ); + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(isEditing + ? 'Channel updated successfully' + : 'Channel created successfully'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), + ); + } + } + }, + child: Text(isEditing ? 'Save Changes' : 'Add Channel'), + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final channelsState = ref.watch(salesChannelsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Sales Channels & Marketplaces'), + actions: [ + IconButton( + icon: const Icon(LucideIcons.plus), + tooltip: 'Add Sales Channel', + onPressed: () => _showAddEditChannelSheet(), + ), + ], + ), + body: channelsState.when( + data: (channels) { + if (channels.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(LucideIcons.shoppingBag, size: 48, color: Colors.grey), + const SizedBox(height: 16), + const Text( + 'No sales channels found', + style: TextStyle(color: Colors.grey, fontSize: 16), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: () => _showAddEditChannelSheet(), + icon: const Icon(LucideIcons.plus), + label: const Text('Add First Channel'), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: channels.length, + itemBuilder: (context, index) { + final channel = channels[index]; + + IconData iconData = LucideIcons.store; + if (channel.code == 'AMAZON' || channel.name.toLowerCase().contains('amazon')) { + iconData = LucideIcons.shoppingCart; + } else if (channel.code == 'FLIPKART' || channel.name.toLowerCase().contains('flipkart')) { + iconData = LucideIcons.package; + } else if (channel.code == 'SHOPIFY' || channel.name.toLowerCase().contains('shopify')) { + iconData = LucideIcons.globe; + } else if (channel.code == 'MEESHO' || channel.name.toLowerCase().contains('meesho')) { + iconData = LucideIcons.tag; + } else if (channel.code == 'QUICK_COMMERCE' || channel.name.toLowerCase().contains('quick')) { + iconData = LucideIcons.zap; + } + + final isSystemDefault = channel.userId == null; + + return Card( + elevation: 0, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.grey.withValues(alpha: 0.2)), + ), + child: ListTile( + leading: CircleAvatar( + backgroundColor: Colors.blue.withValues(alpha: 0.1), + child: Icon(iconData, color: Colors.blue, size: 20), + ), + title: Row( + children: [ + Text( + channel.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + if (isSystemDefault) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'System', + style: TextStyle(fontSize: 10, color: Colors.grey, fontWeight: FontWeight.bold), + ), + ), + ], + ], + ), + subtitle: channel.code != null + ? Text('Code: ${channel.code}', style: const TextStyle(fontSize: 12)) + : null, + trailing: isSystemDefault + ? const Icon(LucideIcons.lock, size: 16, color: Colors.grey) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(LucideIcons.edit2, size: 18), + onPressed: () => _showAddEditChannelSheet(channel), + ), + IconButton( + icon: const Icon(LucideIcons.trash2, size: 18, color: Colors.red), + onPressed: () async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete Channel?'), + content: Text('Are you sure you want to delete ${channel.name}?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Delete', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + if (confirm == true) { + await ref.read(salesChannelsProvider.notifier).deleteChannel(channel.id!); + } + }, + ), + ], + ), + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + ), + ); + } +} diff --git a/kifi-app/lib/features/sales/providers/sales_channels_provider.dart b/kifi-app/lib/features/sales/providers/sales_channels_provider.dart new file mode 100644 index 0000000..2df65a5 --- /dev/null +++ b/kifi-app/lib/features/sales/providers/sales_channels_provider.dart @@ -0,0 +1,74 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/network/dio_client.dart'; +import '../domain/sales_channel.dart'; + +class SalesChannelsNotifier extends AsyncNotifier> { + @override + FutureOr> build() async { + return _fetchChannels(); + } + + Future> _fetchChannels() async { + try { + final response = await DioClient().dio.get('/sales-channels'); + if (response.statusCode == 200) { + final List data = response.data; + return data.map((e) => SalesChannel.fromJson(e)).toList(); + } + return []; + } catch (e) { + print('Error fetching sales channels: $e'); + return []; + } + } + + Future refresh() async { + state = const AsyncValue.loading(); + try { + final channels = await _fetchChannels(); + state = AsyncValue.data(channels); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + } + } + + Future createChannel(SalesChannel channel) async { + try { + final response = await DioClient().dio.post('/sales-channels', data: channel.toJson()); + await refresh(); + if (response.data != null) { + return SalesChannel.fromJson(response.data); + } + return null; + } catch (e) { + throw Exception('Failed to create sales channel: $e'); + } + } + + Future updateChannel(int id, SalesChannel channel) async { + try { + final response = await DioClient().dio.put('/sales-channels/$id', data: channel.toJson()); + await refresh(); + if (response.data != null) { + return SalesChannel.fromJson(response.data); + } + return null; + } catch (e) { + throw Exception('Failed to update sales channel: $e'); + } + } + + Future deleteChannel(int id) async { + try { + await DioClient().dio.delete('/sales-channels/$id'); + await refresh(); + } catch (e) { + throw Exception('Failed to delete sales channel: $e'); + } + } +} + +final salesChannelsProvider = AsyncNotifierProvider>(() { + return SalesChannelsNotifier(); +}); diff --git a/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart b/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart index 9c51d5e..1f13ad3 100644 --- a/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart +++ b/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart @@ -601,6 +601,7 @@ class _PurchaseOrderBuilderScreenState const SizedBox(height: 12), if (_items.isEmpty) Container( + width: double.infinity, padding: const EdgeInsets.all(32), decoration: BoxDecoration( color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100, @@ -608,6 +609,8 @@ class _PurchaseOrderBuilderScreenState border: Border.all(color: Colors.grey.withValues(alpha: 0.2)), ), child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon(LucideIcons.packageOpen, size: 48, color: Colors.grey.shade400), const SizedBox(height: 12), @@ -807,6 +810,22 @@ class _PurchaseOrderBuilderScreenState final effectiveTaxable = (rawSubtotal - discountAmount).clamp(0.0, double.infinity); final grandTotal = effectiveTaxable + taxTotal; + final effectiveGstRate = (effectiveTaxable > 0 && taxTotal > 0) + ? (taxTotal / effectiveTaxable) * 100.0 + : (_items.isNotEmpty ? _items.first.gstRate : (isJewellery ? 3.0 : 18.0)); + final cgstRate = effectiveGstRate / 2.0; + final sgstRate = effectiveGstRate / 2.0; + final igstRate = effectiveGstRate; + + String formatRate(double rate) { + if (rate <= 0) return '0%'; + final rounded = (rate * 100).round() / 100; + if (rounded.truncateToDouble() == rounded) { + return '${rounded.toInt()}%'; + } + return '${rounded.toStringAsFixed(1)}%'; + } + return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -848,7 +867,7 @@ class _PurchaseOrderBuilderScreenState Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('CGST (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)), + Text('CGST (${formatRate(cgstRate)}):', style: TextStyle(color: Colors.grey.shade600)), Text('₹${(taxTotal / 2.0).toStringAsFixed(2)}'), ], ), @@ -856,7 +875,7 @@ class _PurchaseOrderBuilderScreenState Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('SGST (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)), + Text('SGST (${formatRate(sgstRate)}):', style: TextStyle(color: Colors.grey.shade600)), Text('₹${(taxTotal / 2.0).toStringAsFixed(2)}'), ], ), @@ -864,7 +883,7 @@ class _PurchaseOrderBuilderScreenState Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('IGST (${(taxTotal > 0 ? '3.0%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)), + Text('IGST (${formatRate(igstRate)}):', style: TextStyle(color: Colors.grey.shade600)), Text('₹${taxTotal.toStringAsFixed(2)}'), ], ),