diff --git a/kifi-api/src/main/java/com/kifi/api/controller/invoice/CreditNoteController.java b/kifi-api/src/main/java/com/kifi/api/controller/invoice/CreditNoteController.java new file mode 100644 index 0000000..8be925c --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/invoice/CreditNoteController.java @@ -0,0 +1,48 @@ +package com.kifi.api.controller.invoice; + +import com.kifi.api.entity.invoice.CreditNote; +import com.kifi.api.service.invoice.CreditNoteService; +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") +@RequiredArgsConstructor +public class CreditNoteController { + + private final CreditNoteService creditNoteService; + + @GetMapping("/credit-notes") + public Flux getCreditNotes(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return creditNoteService.getCreditNotes(userId); + } + + @GetMapping("/credit-notes/{id}") + public Mono getCreditNoteById( + @PathVariable Long id, + Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return creditNoteService.getCreditNoteById(id, userId); + } + + @GetMapping("/invoices/{invoiceId}/credit-notes") + public Flux getCreditNotesForInvoice( + @PathVariable Long invoiceId, + Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return creditNoteService.getCreditNotesForInvoice(invoiceId, userId); + } + + @PostMapping("/invoices/{invoiceId}/credit-notes") + public Mono createCreditNote( + @PathVariable Long invoiceId, + Authentication authentication, + @RequestBody CreditNote creditNote) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return creditNoteService.createCreditNote(userId, invoiceId, creditNote); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/invoice/MarketplaceSettlementController.java b/kifi-api/src/main/java/com/kifi/api/controller/invoice/MarketplaceSettlementController.java new file mode 100644 index 0000000..39e3a3f --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/invoice/MarketplaceSettlementController.java @@ -0,0 +1,48 @@ +package com.kifi.api.controller.invoice; + +import com.kifi.api.entity.invoice.MarketplaceSettlement; +import com.kifi.api.service.invoice.MarketplaceSettlementService; +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") +@RequiredArgsConstructor +public class MarketplaceSettlementController { + + private final MarketplaceSettlementService settlementService; + + @GetMapping("/marketplace-settlements") + public Flux getSettlements(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return settlementService.getSettlements(userId); + } + + @GetMapping("/marketplace-settlements/{id}") + public Mono getSettlementById( + @PathVariable Long id, + Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return settlementService.getSettlementById(id, userId); + } + + @GetMapping("/invoices/{invoiceId}/settlements") + public Flux getSettlementsForInvoice( + @PathVariable Long invoiceId, + Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return settlementService.getSettlementsForInvoice(invoiceId, userId); + } + + @PostMapping("/invoices/{invoiceId}/settlements") + public Mono createSettlement( + @PathVariable Long invoiceId, + Authentication authentication, + @RequestBody MarketplaceSettlement settlement) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return settlementService.createSettlement(userId, invoiceId, settlement); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java b/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java index 2c1ba8c..fdc5e59 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java @@ -5,6 +5,7 @@ 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.math.BigDecimal; @@ -58,6 +59,19 @@ public class Product { private Double makingCharges; private String makingChargesType; private Double wastagePercentage; + + @Column("min_stock") + private BigDecimal minStock; + + @Column("reorder_level") + private BigDecimal reorderLevel; + + @Column("reorder_quantity") + private BigDecimal reorderQuantity; + + @Column("track_inventory") + private Boolean trackInventory; + private Boolean isActive; private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/kifi-api/src/main/java/com/kifi/api/entity/invoice/CreditNote.java b/kifi-api/src/main/java/com/kifi/api/entity/invoice/CreditNote.java new file mode 100644 index 0000000..fd246c2 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/invoice/CreditNote.java @@ -0,0 +1,86 @@ +package com.kifi.api.entity.invoice; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Transient; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("credit_notes") +public class CreditNote { + @Id + private Long id; + + @Column("user_id") + private Long userId; + + @Column("invoice_id") + private Long invoiceId; + + @Column("credit_note_number") + private String creditNoteNumber; + + @Column("customer_id") + private Long customerId; + + @Column("return_date") + private LocalDate returnDate; + + @Column("reason") + private String reason; // CUSTOMER_RETURN, RTO_UNDELIVERED, DEFECTIVE, EXCHANGE, OTHER + + @Column("restock_items") + @Builder.Default + private Boolean restockItems = true; + + @Column("subtotal") + private BigDecimal subtotal; + + @Column("tax_total") + private BigDecimal taxTotal; + + @Column("cgst_total") + private BigDecimal cgstTotal; + + @Column("sgst_total") + private BigDecimal sgstTotal; + + @Column("igst_total") + private BigDecimal igstTotal; + + @Column("discount_total") + private BigDecimal discountTotal; + + @Column("total_amount") + private BigDecimal totalAmount; + + @Column("refund_status") + private String refundStatus; // ADJUSTED_TO_AR, REFUNDED_TO_WALLET, STORE_CREDIT, PENDING + + @Column("refund_wallet_id") + private Long refundWalletId; + + @Column("notes") + private String notes; + + @Column("created_at") + private LocalDateTime createdAt; + + @Column("updated_at") + private LocalDateTime updatedAt; + + @Transient + private List items; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/invoice/CreditNoteItem.java b/kifi-api/src/main/java/com/kifi/api/entity/invoice/CreditNoteItem.java new file mode 100644 index 0000000..b691baa --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/invoice/CreditNoteItem.java @@ -0,0 +1,78 @@ +package com.kifi.api.entity.invoice; + +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.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("credit_note_items") +public class CreditNoteItem { + @Id + private Long id; + + @Column("credit_note_id") + private Long creditNoteId; + + @Column("invoice_item_id") + private Long invoiceItemId; + + @Column("product_id") + private Long productId; + + @Column("inventory_item_id") + private Long inventoryItemId; + + @Column("product_name") + private String productName; + + @Column("sku") + private String sku; + + @Column("huid") + private String huid; + + @Column("hsn_code") + private String hsnCode; + + @Column("quantity") + private BigDecimal quantity; + + @Column("weight") + private BigDecimal weight; + + @Column("unit_price") + private BigDecimal unitPrice; + + @Column("tax_rate") + private BigDecimal taxRate; + + @Column("cgst") + private BigDecimal cgst; + + @Column("sgst") + private BigDecimal sgst; + + @Column("igst") + private BigDecimal igst; + + @Column("discount") + private BigDecimal discount; + + @Column("making_charge") + private BigDecimal makingCharge; + + @Column("other_charges") + private BigDecimal otherCharges; + + @Column("total") + private BigDecimal total; +} 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 b425e6f..26c4785 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 @@ -89,6 +89,24 @@ public class Invoice { @Column("marketplace_order_id") private String marketplaceOrderId; + @Column("courier_partner") + private String courierPartner; + + @Column("tracking_number") + private String trackingNumber; + + @Column("dispatch_status") + private String dispatchStatus; // PENDING, PACKED, SHIPPED, IN_TRANSIT, DELIVERED, RTO + + @Column("shipped_at") + private LocalDateTime shippedAt; + + @Column("shipping_address") + private String shippingAddress; + + @Column("shipping_pincode") + private String shippingPincode; + @Column("place_of_supply_state_id") private Long placeOfSupplyStateId; diff --git a/kifi-api/src/main/java/com/kifi/api/entity/invoice/MarketplaceSettlement.java b/kifi-api/src/main/java/com/kifi/api/entity/invoice/MarketplaceSettlement.java new file mode 100644 index 0000000..d44ba7b --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/invoice/MarketplaceSettlement.java @@ -0,0 +1,90 @@ +package com.kifi.api.entity.invoice; + +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.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("marketplace_settlements") +public class MarketplaceSettlement { + @Id + private Long id; + + @Column("user_id") + private Long userId; + + @Column("invoice_id") + private Long invoiceId; + + @Column("sales_channel_id") + private Long salesChannelId; + + @Column("channel_name") + private String channelName; + + @Column("settlement_ref") + private String settlementRef; + + @Column("settlement_date") + private LocalDate settlementDate; + + @Column("gross_amount") + private BigDecimal grossAmount; + + @Column("commission_fee") + @Builder.Default + private BigDecimal commissionFee = BigDecimal.ZERO; + + @Column("shipping_fee") + @Builder.Default + private BigDecimal shippingFee = BigDecimal.ZERO; + + @Column("other_fees") + @Builder.Default + private BigDecimal otherFees = BigDecimal.ZERO; + + @Column("fee_gst") + @Builder.Default + private BigDecimal feeGst = BigDecimal.ZERO; + + @Column("tcs_gst_amount") + @Builder.Default + private BigDecimal tcsGstAmount = BigDecimal.ZERO; + + @Column("tds_amount") + @Builder.Default + private BigDecimal tdsAmount = BigDecimal.ZERO; + + @Column("total_deductions") + private BigDecimal totalDeductions; + + @Column("net_payout_amount") + private BigDecimal netPayoutAmount; + + @Column("payout_wallet_id") + private Long payoutWalletId; + + @Column("status") + @Builder.Default + private String status = "SETTLED"; + + @Column("notes") + private String notes; + + @Column("created_at") + private LocalDateTime createdAt; + + @Column("updated_at") + private LocalDateTime updatedAt; +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/invoice/CreditNoteItemRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/invoice/CreditNoteItemRepository.java new file mode 100644 index 0000000..129912b --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/invoice/CreditNoteItemRepository.java @@ -0,0 +1,9 @@ +package com.kifi.api.repository.invoice; + +import com.kifi.api.entity.invoice.CreditNoteItem; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; + +public interface CreditNoteItemRepository extends ReactiveCrudRepository { + Flux findByCreditNoteId(Long creditNoteId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/invoice/CreditNoteRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/invoice/CreditNoteRepository.java new file mode 100644 index 0000000..1806aea --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/invoice/CreditNoteRepository.java @@ -0,0 +1,13 @@ +package com.kifi.api.repository.invoice; + +import com.kifi.api.entity.invoice.CreditNote; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface CreditNoteRepository extends ReactiveCrudRepository { + Flux findByUserId(Long userId); + Flux findByUserIdAndInvoiceId(Long userId, Long invoiceId); + Flux findByUserIdAndCustomerId(Long userId, Long customerId); + Mono findByUserIdAndCreditNoteNumber(Long userId, String creditNoteNumber); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/invoice/MarketplaceSettlementRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/invoice/MarketplaceSettlementRepository.java new file mode 100644 index 0000000..f25e3a1 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/invoice/MarketplaceSettlementRepository.java @@ -0,0 +1,13 @@ +package com.kifi.api.repository.invoice; + +import com.kifi.api.entity.invoice.MarketplaceSettlement; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface MarketplaceSettlementRepository extends ReactiveCrudRepository { + Flux findByUserId(Long userId); + Flux findByUserIdAndInvoiceId(Long userId, Long invoiceId); + Flux findByUserIdAndSalesChannelId(Long userId, Long salesChannelId); + Mono findByUserIdAndSettlementRef(Long userId, String settlementRef); +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java b/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java index 64391d3..24919d8 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java @@ -127,6 +127,12 @@ public class ProductService { existingProduct.setMakingCharges(updatedProduct.getMakingCharges()); existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType()); existingProduct.setWastagePercentage(updatedProduct.getWastagePercentage()); + + existingProduct.setMinStock(updatedProduct.getMinStock()); + existingProduct.setReorderLevel(updatedProduct.getReorderLevel()); + existingProduct.setReorderQuantity(updatedProduct.getReorderQuantity()); + existingProduct.setTrackInventory(updatedProduct.getTrackInventory()); + existingProduct.setIsActive(updatedProduct.getIsActive()); existingProduct.setUpdatedAt(LocalDateTime.now()); return productRepository.save(existingProduct); diff --git a/kifi-api/src/main/java/com/kifi/api/service/invoice/CreditNoteService.java b/kifi-api/src/main/java/com/kifi/api/service/invoice/CreditNoteService.java new file mode 100644 index 0000000..ff1c8b4 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/invoice/CreditNoteService.java @@ -0,0 +1,239 @@ +package com.kifi.api.service.invoice; + +import com.kifi.api.entity.Transaction; +import com.kifi.api.entity.inventory.InventoryItem; +import com.kifi.api.entity.invoice.CreditNote; +import com.kifi.api.entity.invoice.CreditNoteItem; +import com.kifi.api.entity.invoice.Invoice; +import com.kifi.api.repository.customer.CustomerRepository; +import com.kifi.api.repository.inventory.InventoryItemRepository; +import com.kifi.api.repository.invoice.CreditNoteItemRepository; +import com.kifi.api.repository.invoice.CreditNoteRepository; +import com.kifi.api.repository.invoice.InvoiceItemRepository; +import com.kifi.api.repository.invoice.InvoiceRepository; +import com.kifi.api.service.TransactionService; +import com.kifi.api.service.accounting.LedgerService; +import com.kifi.api.service.inventory.InventoryItemService; +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.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class CreditNoteService { + + private final CreditNoteRepository creditNoteRepository; + private final CreditNoteItemRepository creditNoteItemRepository; + private final InvoiceRepository invoiceRepository; + private final InvoiceItemRepository invoiceItemRepository; + private final InventoryItemRepository inventoryItemRepository; + private final InventoryItemService inventoryItemService; + private final LedgerService ledgerService; + private final CustomerRepository customerRepository; + private final TransactionService transactionService; + + public Flux getCreditNotes(Long userId) { + return creditNoteRepository.findByUserId(userId) + .flatMap(this::populateItems); + } + + public Flux getCreditNotesForInvoice(Long invoiceId, Long userId) { + return creditNoteRepository.findByUserIdAndInvoiceId(userId, invoiceId) + .flatMap(this::populateItems); + } + + public Mono getCreditNoteById(Long id, Long userId) { + return creditNoteRepository.findById(id) + .filter(cn -> cn.getUserId().equals(userId)) + .switchIfEmpty(Mono.error(new RuntimeException("Credit note not found or unauthorized"))) + .flatMap(this::populateItems); + } + + private Mono populateItems(CreditNote cn) { + return creditNoteItemRepository.findByCreditNoteId(cn.getId()) + .collectList() + .map(items -> { + cn.setItems(items); + return cn; + }); + } + + public Mono createCreditNote(Long userId, Long invoiceId, CreditNote creditNote) { + return invoiceRepository.findById(invoiceId) + .filter(inv -> inv.getUserId().equals(userId)) + .switchIfEmpty(Mono.error(new RuntimeException("Invoice not found or unauthorized"))) + .flatMap(invoice -> { + creditNote.setUserId(userId); + creditNote.setInvoiceId(invoiceId); + creditNote.setCustomerId(invoice.getCustomerId()); + + if (creditNote.getReturnDate() == null) { + creditNote.setReturnDate(LocalDate.now()); + } + if (creditNote.getCreditNoteNumber() == null || creditNote.getCreditNoteNumber().trim().isEmpty()) { + creditNote.setCreditNoteNumber("CN-" + System.currentTimeMillis() % 1000000); + } + creditNote.setCreatedAt(LocalDateTime.now()); + creditNote.setUpdatedAt(LocalDateTime.now()); + + if (creditNote.getRefundStatus() == null || creditNote.getRefundStatus().trim().isEmpty()) { + creditNote.setRefundStatus("ADJUSTED_TO_AR"); + } + + // Calculate totals from items if not provided + List items = creditNote.getItems() != null ? creditNote.getItems() : new ArrayList<>(); + BigDecimal subtotal = BigDecimal.ZERO; + BigDecimal taxTotal = BigDecimal.ZERO; + BigDecimal cgstTotal = BigDecimal.ZERO; + BigDecimal sgstTotal = BigDecimal.ZERO; + BigDecimal igstTotal = BigDecimal.ZERO; + BigDecimal totalAmount = BigDecimal.ZERO; + + for (CreditNoteItem itm : items) { + BigDecimal qty = itm.getWeight() != null && itm.getWeight().compareTo(BigDecimal.ZERO) > 0 + ? itm.getWeight() + : (itm.getQuantity() != null ? itm.getQuantity() : BigDecimal.ONE); + BigDecimal unitPrice = itm.getUnitPrice() != null ? itm.getUnitPrice() : BigDecimal.ZERO; + BigDecimal baseAmt = qty.multiply(unitPrice); + BigDecimal making = itm.getMakingCharge() != null ? itm.getMakingCharge() : BigDecimal.ZERO; + BigDecimal disc = itm.getDiscount() != null ? itm.getDiscount() : BigDecimal.ZERO; + BigDecimal other = itm.getOtherCharges() != null ? itm.getOtherCharges() : BigDecimal.ZERO; + + BigDecimal cgst = itm.getCgst() != null ? itm.getCgst() : BigDecimal.ZERO; + BigDecimal sgst = itm.getSgst() != null ? itm.getSgst() : BigDecimal.ZERO; + BigDecimal igst = itm.getIgst() != null ? itm.getIgst() : BigDecimal.ZERO; + BigDecimal tax = cgst.add(sgst).add(igst); + + BigDecimal lineTotal = baseAmt.add(making).add(other).subtract(disc).add(tax); + itm.setTotal(lineTotal); + + subtotal = subtotal.add(baseAmt); + taxTotal = taxTotal.add(tax); + cgstTotal = cgstTotal.add(cgst); + sgstTotal = sgstTotal.add(sgst); + igstTotal = igstTotal.add(igst); + totalAmount = totalAmount.add(lineTotal); + } + + if (creditNote.getSubtotal() == null) creditNote.setSubtotal(subtotal); + if (creditNote.getTaxTotal() == null) creditNote.setTaxTotal(taxTotal); + if (creditNote.getCgstTotal() == null) creditNote.setCgstTotal(cgstTotal); + if (creditNote.getSgstTotal() == null) creditNote.setSgstTotal(sgstTotal); + if (creditNote.getIgstTotal() == null) creditNote.setIgstTotal(igstTotal); + if (creditNote.getTotalAmount() == null) creditNote.setTotalAmount(totalAmount); + + return creditNoteRepository.save(creditNote) + .flatMap(savedCn -> { + Mono> itemsMono = Flux.fromIterable(items) + .flatMap(item -> { + item.setCreditNoteId(savedCn.getId()); + return creditNoteItemRepository.save(item); + }) + .collectList(); + + return itemsMono.flatMap(savedItems -> { + savedCn.setItems(savedItems); + + // 1. Stock Restoration (if restockItems is true) + Mono restockMono = Mono.empty(); + if (Boolean.TRUE.equals(savedCn.getRestockItems())) { + restockMono = Flux.fromIterable(savedItems) + .flatMap(item -> { + // Inward restock back into inventory + if (item.getInventoryItemId() != null) { + return inventoryItemRepository.findById(item.getInventoryItemId()) + .flatMap(invItem -> { + invItem.setStatus("AVAILABLE"); + invItem.setSalesRef(null); + invItem.setUpdatedAt(LocalDateTime.now()); + return inventoryItemRepository.save(invItem).then(); + }); + } else if (item.getProductId() != null) { + BigDecimal restockQty = item.getWeight() != null && item.getWeight().compareTo(BigDecimal.ZERO) > 0 + ? item.getWeight() + : (item.getQuantity() != null ? item.getQuantity() : BigDecimal.ONE); + InventoryItem restockRecord = InventoryItem.builder() + .userId(userId) + .productId(item.getProductId()) + .sku(item.getSku()) + .huid(item.getHuid()) + .grossWeight(restockQty) + .netWeight(restockQty) + .purchaseCost(item.getUnitPrice()) + .purchaseRef("RETURN-" + savedCn.getCreditNoteNumber()) + .status("AVAILABLE") + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + return inventoryItemRepository.save(restockRecord).then(); + } + return Mono.empty(); + }) + .then(); + } + + // 2. Financial / Ledger Accounting Adjustment + Mono accountingMono = Mono.empty(); + if ("REFUNDED_TO_WALLET".equalsIgnoreCase(savedCn.getRefundStatus()) && savedCn.getRefundWalletId() != null) { + Transaction refundTx = Transaction.builder() + .userId(userId) + .fromWalletId(savedCn.getRefundWalletId()) // Cash/Bank decreases + .type("EXPENSE") + .amount(savedCn.getTotalAmount()) + .date(savedCn.getReturnDate()) + .description("Refund for Return #" + savedCn.getCreditNoteNumber() + " (Invoice #" + invoice.getInvoiceNumber() + ")") + .createdAt(LocalDateTime.now()) + .build(); + accountingMono = transactionService.addTransaction(userId, refundTx).then(); + } else { + // Customer AR adjustment + if (invoice.getCustomerId() != null) { + accountingMono = customerRepository.findById(invoice.getCustomerId()) + .flatMap(customer -> Mono.zip( + ledgerService.getCustomerLedger(userId, customer.getId(), customer.getName()), + ledgerService.getSalesRevenueLedger(userId) + ).flatMap(ledgers -> { + Transaction returnTx = Transaction.builder() + .userId(userId) + .fromWalletId(ledgers.getT1().getId()) // Customer AR (Asset decreases) + .toWalletId(ledgers.getT2().getId()) // Sales Revenue (Income decreases) + .type("SALES_RETURN") + .amount(savedCn.getTotalAmount()) + .date(savedCn.getReturnDate()) + .description("Credit Note #" + savedCn.getCreditNoteNumber() + " for Invoice #" + invoice.getInvoiceNumber()) + .createdAt(LocalDateTime.now()) + .build(); + return transactionService.addTransaction(userId, returnTx).then(); + })).onErrorResume(err -> { + System.err.println("AR adjustment error: " + err.getMessage()); + return Mono.empty(); + }); + } + } + + // 3. Update Invoice Status + invoice.setStatus(savedCn.getTotalAmount().compareTo(invoice.getTotalAmount()) >= 0 ? "RETURNED" : "PARTIALLY_RETURNED"); + if ("RTO_UNDELIVERED".equalsIgnoreCase(savedCn.getReason())) { + invoice.setDispatchStatus("RTO"); + } + invoice.setUpdatedAt(LocalDateTime.now()); + Mono updateInvoiceMono = invoiceRepository.save(invoice); + + return restockMono + .then(accountingMono) + .then(updateInvoiceMono) + .thenReturn(savedCn); + }); + }); + }); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/invoice/MarketplaceSettlementService.java b/kifi-api/src/main/java/com/kifi/api/service/invoice/MarketplaceSettlementService.java new file mode 100644 index 0000000..f6f7fa3 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/invoice/MarketplaceSettlementService.java @@ -0,0 +1,128 @@ +package com.kifi.api.service.invoice; + +import com.kifi.api.entity.Transaction; +import com.kifi.api.entity.invoice.Invoice; +import com.kifi.api.entity.invoice.InvoicePayment; +import com.kifi.api.entity.invoice.MarketplaceSettlement; +import com.kifi.api.repository.customer.CustomerRepository; +import com.kifi.api.repository.invoice.InvoicePaymentRepository; +import com.kifi.api.repository.invoice.InvoiceRepository; +import com.kifi.api.repository.invoice.MarketplaceSettlementRepository; +import com.kifi.api.service.TransactionService; +import com.kifi.api.service.accounting.LedgerService; +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.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Transactional +public class MarketplaceSettlementService { + + private final MarketplaceSettlementRepository settlementRepository; + private final InvoiceRepository invoiceRepository; + private final InvoicePaymentRepository invoicePaymentRepository; + private final CustomerRepository customerRepository; + private final LedgerService ledgerService; + private final TransactionService transactionService; + + public Flux getSettlements(Long userId) { + return settlementRepository.findByUserId(userId); + } + + public Flux getSettlementsForInvoice(Long invoiceId, Long userId) { + return settlementRepository.findByUserIdAndInvoiceId(userId, invoiceId); + } + + public Mono getSettlementById(Long id, Long userId) { + return settlementRepository.findById(id) + .filter(s -> s.getUserId().equals(userId)) + .switchIfEmpty(Mono.error(new RuntimeException("Settlement not found or unauthorized"))); + } + + public Mono createSettlement(Long userId, Long invoiceId, MarketplaceSettlement settlement) { + return invoiceRepository.findById(invoiceId) + .filter(inv -> inv.getUserId().equals(userId)) + .switchIfEmpty(Mono.error(new RuntimeException("Invoice not found or unauthorized"))) + .flatMap(invoice -> { + settlement.setUserId(userId); + settlement.setInvoiceId(invoiceId); + if (settlement.getSalesChannelId() == null) { + settlement.setSalesChannelId(invoice.getSalesChannelId()); + } + if (settlement.getChannelName() == null || settlement.getChannelName().trim().isEmpty()) { + settlement.setChannelName(invoice.getSalesChannel() != null ? invoice.getSalesChannel() : "Marketplace"); + } + if (settlement.getSettlementDate() == null) { + settlement.setSettlementDate(LocalDate.now()); + } + if (settlement.getGrossAmount() == null) { + settlement.setGrossAmount(invoice.getTotalAmount()); + } + if (settlement.getCommissionFee() == null) settlement.setCommissionFee(BigDecimal.ZERO); + if (settlement.getShippingFee() == null) settlement.setShippingFee(BigDecimal.ZERO); + if (settlement.getOtherFees() == null) settlement.setOtherFees(BigDecimal.ZERO); + if (settlement.getFeeGst() == null) settlement.setFeeGst(BigDecimal.ZERO); + if (settlement.getTcsGstAmount() == null) settlement.setTcsGstAmount(BigDecimal.ZERO); + if (settlement.getTdsAmount() == null) settlement.setTdsAmount(BigDecimal.ZERO); + + BigDecimal totalDeductions = settlement.getCommissionFee() + .add(settlement.getShippingFee()) + .add(settlement.getOtherFees()) + .add(settlement.getFeeGst()) + .add(settlement.getTcsGstAmount()) + .add(settlement.getTdsAmount()); + settlement.setTotalDeductions(totalDeductions); + + BigDecimal netPayout = settlement.getGrossAmount().subtract(totalDeductions); + settlement.setNetPayoutAmount(netPayout); + + settlement.setStatus("SETTLED"); + settlement.setCreatedAt(LocalDateTime.now()); + settlement.setUpdatedAt(LocalDateTime.now()); + + return settlementRepository.save(settlement) + .flatMap(savedSettlement -> { + // 1. Record Bank Inflow Transaction + Mono txMono = Mono.empty(); + if (savedSettlement.getPayoutWalletId() != null && netPayout.compareTo(BigDecimal.ZERO) > 0) { + Transaction netDepositTx = Transaction.builder() + .userId(userId) + .toWalletId(savedSettlement.getPayoutWalletId()) // Cash/Bank increases + .type("INCOME") + .amount(netPayout) + .date(savedSettlement.getSettlementDate()) + .description("Payout for " + savedSettlement.getChannelName() + " Order #" + (invoice.getMarketplaceOrderId() != null ? invoice.getMarketplaceOrderId() : invoice.getInvoiceNumber()) + " (Ref: " + (savedSettlement.getSettlementRef() != null ? savedSettlement.getSettlementRef() : "Settlement") + ")") + .createdAt(LocalDateTime.now()) + .build(); + txMono = transactionService.addTransaction(userId, netDepositTx).then(); + } + + // 2. Record Invoice Payment (Clears Invoice Receivables) + InvoicePayment payment = InvoicePayment.builder() + .invoiceId(invoiceId) + .amount(invoice.getTotalAmount()) + .paymentDate(savedSettlement.getSettlementDate()) + .createdAt(LocalDateTime.now()) + .build(); + Mono paymentMono = invoicePaymentRepository.save(payment); + + // 3. Mark Invoice as PAID / SETTLED + invoice.setStatus("PAID"); + invoice.setUpdatedAt(LocalDateTime.now()); + Mono updateInvoiceMono = invoiceRepository.save(invoice); + + return txMono + .then(paymentMono) + .then(updateInvoiceMono) + .thenReturn(savedSettlement); + }); + }); + } +} diff --git a/kifi-api/src/main/resources/schema.sql b/kifi-api/src/main/resources/schema.sql index 3673112..985bd09 100644 --- a/kifi-api/src/main/resources/schema.sql +++ b/kifi-api/src/main/resources/schema.sql @@ -272,6 +272,7 @@ CREATE TABLE IF NOT EXISTS products ( wastage_percentage DECIMAL(5, 2) DEFAULT 0.0, min_stock DECIMAL(10, 2) DEFAULT 0, reorder_level DECIMAL(10, 2) DEFAULT 0, + reorder_quantity DECIMAL(10, 2) DEFAULT 0, track_inventory BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -495,6 +496,76 @@ CREATE TABLE IF NOT EXISTS invoice_payments ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS credit_notes ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE, + credit_note_number VARCHAR(100) NOT NULL, + customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL, + return_date DATE NOT NULL, + reason VARCHAR(100) NOT NULL, + restock_items BOOLEAN DEFAULT TRUE, + subtotal DECIMAL(15, 2) NOT NULL, + tax_total DECIMAL(15, 2) DEFAULT 0.0, + cgst_total DECIMAL(15, 2) DEFAULT 0.0, + sgst_total DECIMAL(15, 2) DEFAULT 0.0, + igst_total DECIMAL(15, 2) DEFAULT 0.0, + discount_total DECIMAL(15, 2) DEFAULT 0.0, + total_amount DECIMAL(15, 2) NOT NULL, + refund_status VARCHAR(50) DEFAULT 'ADJUSTED_TO_AR', + refund_wallet_id INTEGER REFERENCES wallets(id) ON DELETE SET NULL, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS credit_note_items ( + id SERIAL PRIMARY KEY, + credit_note_id INTEGER REFERENCES credit_notes(id) ON DELETE CASCADE, + invoice_item_id INTEGER REFERENCES invoice_items(id) ON DELETE SET NULL, + product_id INTEGER REFERENCES products(id) ON DELETE SET NULL, + inventory_item_id INTEGER REFERENCES inventory_items(id) ON DELETE SET NULL, + product_name VARCHAR(255), + sku VARCHAR(100), + huid VARCHAR(50), + hsn_code VARCHAR(50), + quantity DECIMAL(10, 3) NOT NULL, + weight DECIMAL(10, 3), + unit_price DECIMAL(15, 2) NOT NULL, + tax_rate DECIMAL(5, 2) DEFAULT 0.0, + cgst DECIMAL(15, 2) DEFAULT 0.0, + sgst DECIMAL(15, 2) DEFAULT 0.0, + igst DECIMAL(15, 2) DEFAULT 0.0, + discount DECIMAL(15, 2) DEFAULT 0.0, + making_charge DECIMAL(15, 2) DEFAULT 0.0, + other_charges DECIMAL(15, 2) DEFAULT 0.0, + total DECIMAL(15, 2) NOT NULL +); + +CREATE TABLE IF NOT EXISTS marketplace_settlements ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE, + sales_channel_id INTEGER REFERENCES sales_channels(id) ON DELETE SET NULL, + channel_name VARCHAR(100), + settlement_ref VARCHAR(100), + settlement_date DATE NOT NULL, + gross_amount DECIMAL(15, 2) NOT NULL, + commission_fee DECIMAL(15, 2) DEFAULT 0.0, + shipping_fee DECIMAL(15, 2) DEFAULT 0.0, + other_fees DECIMAL(15, 2) DEFAULT 0.0, + fee_gst DECIMAL(15, 2) DEFAULT 0.0, + tcs_gst_amount DECIMAL(15, 2) DEFAULT 0.0, + tds_amount DECIMAL(15, 2) DEFAULT 0.0, + total_deductions DECIMAL(15, 2) NOT NULL, + net_payout_amount DECIMAL(15, 2) NOT NULL, + payout_wallet_id INTEGER REFERENCES wallets(id) ON DELETE SET NULL, + status VARCHAR(50) DEFAULT 'SETTLED', + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + -- 9. Projects & Task Management CREATE TABLE IF NOT EXISTS projects ( id SERIAL PRIMARY KEY, diff --git a/kifi-app/lib/features/inventory/domain/product.dart b/kifi-app/lib/features/inventory/domain/product.dart index 4a3aa27..46351b8 100644 --- a/kifi-app/lib/features/inventory/domain/product.dart +++ b/kifi-app/lib/features/inventory/domain/product.dart @@ -40,6 +40,12 @@ class Product { final double? makingCharges; final String? makingChargesType; final double? wastagePercentage; + + final double? minStock; + final double? reorderLevel; + final double? reorderQuantity; + final bool trackInventory; + final bool isActive; final List imageIds; final double? currentStock; @@ -80,24 +86,37 @@ class Product { this.makingCharges = 0.0, this.makingChargesType = 'FLAT', this.wastagePercentage = 0.0, + this.minStock = 0.0, + this.reorderLevel = 0.0, + this.reorderQuantity = 0.0, + this.trackInventory = true, this.isActive = true, this.imageIds = const [], this.currentStock = 0.0, }); + bool get isOutOfStock => trackInventory && (currentStock == null || currentStock! <= 0); + + bool get isLowStock { + final alertThreshold = (reorderLevel != null && reorderLevel! > 0) + ? reorderLevel! + : (minStock != null && minStock! > 0 ? minStock! : 0.0); + if (!trackInventory || alertThreshold <= 0) return false; + return currentStock != null && currentStock! > 0 && currentStock! <= alertThreshold; + } + factory Product.fromJson(Map json) { return Product( id: json['id'], userId: json['userId'] ?? json['user_id'], categoryId: json['categoryId'] ?? json['category_id'], uomId: json['uomId'] ?? json['uom_id'], - name: json['name'], + name: json['name'] ?? '', hsnCode: json['hsnCode'] ?? json['hsn_code'], sku: json['sku'], barcode: json['barcode'], description: json['description'], - sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?) - ?.toDouble(), + sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?)?.toDouble(), gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(), dimensions: json['dimensions'], color: json['color'], @@ -117,22 +136,16 @@ class Product { weightInG: (json['weightInG'] ?? json['weight_in_g'] as num?)?.toDouble(), packageWeightInG: (json['packageWeightInG'] ?? json['package_weight_in_g'] as num?)?.toDouble(), volumetricWeightInKg: (json['volumetricWeightInKg'] ?? json['volumetric_weight_in_kg'] as num?)?.toDouble(), - priceCalcRule: - json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL', - autoCalculatePrice: - json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false, - purityFactor: - (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble(), - makingCharges: - (json['makingCharges'] ?? json['making_charges'] as num?) - ?.toDouble() ?? - 0.0, - makingChargesType: - json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT', - wastagePercentage: - (json['wastagePercentage'] ?? json['wastage_percentage'] as num?) - ?.toDouble() ?? - 0.0, + priceCalcRule: json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL', + autoCalculatePrice: json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false, + purityFactor: (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble(), + makingCharges: (json['makingCharges'] ?? json['making_charges'] as num?)?.toDouble() ?? 0.0, + makingChargesType: json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT', + wastagePercentage: (json['wastagePercentage'] ?? json['wastage_percentage'] as num?)?.toDouble() ?? 0.0, + minStock: (json['minStock'] ?? json['min_stock'] as num?)?.toDouble() ?? 0.0, + reorderLevel: (json['reorderLevel'] ?? json['reorder_level'] as num?)?.toDouble() ?? 0.0, + reorderQuantity: (json['reorderQuantity'] ?? json['reorder_quantity'] as num?)?.toDouble() ?? 0.0, + trackInventory: json['trackInventory'] ?? json['track_inventory'] ?? true, isActive: json['isActive'] ?? json['is_active'] ?? true, imageIds: json['images'] != null ? (json['images'] as List).map((i) { @@ -144,9 +157,7 @@ class Product { return 0; }).where((id) => id > 0).toList() : [], - currentStock: - (json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ?? - 0.0, + currentStock: (json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ?? 0.0, ); } @@ -187,6 +198,10 @@ class Product { 'makingCharges': makingCharges, 'makingChargesType': makingChargesType, 'wastagePercentage': wastagePercentage, + 'minStock': minStock, + 'reorderLevel': reorderLevel, + 'reorderQuantity': reorderQuantity, + 'trackInventory': trackInventory, 'isActive': isActive, }; } diff --git a/kifi-app/lib/features/inventory/presentation/add_product_screen.dart b/kifi-app/lib/features/inventory/presentation/add_product_screen.dart index d0e88d5..4d7a3c5 100644 --- a/kifi-app/lib/features/inventory/presentation/add_product_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/add_product_screen.dart @@ -58,6 +58,11 @@ class _AddProductScreenState extends ConsumerState { final _packageWeightInGController = TextEditingController(); double? _volumetricWeightInKg; + // Inventory & Reorder Thresholds + final _minStockController = TextEditingController(); + final _reorderQuantityController = TextEditingController(); + bool _trackInventory = true; + // Media final List _images = []; final List _existingImageIds = []; @@ -102,6 +107,11 @@ class _AddProductScreenState extends ConsumerState { if (_volumetricWeightInKg == null) { _calculateVolumetricWeight(); } + + final alertQty = (p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock; + _minStockController.text = alertQty != null && alertQty > 0 ? alertQty.toString() : ''; + _reorderQuantityController.text = p.reorderQuantity != null && p.reorderQuantity! > 0 ? p.reorderQuantity.toString() : ''; + _trackInventory = p.trackInventory; if (p.imageIds.isNotEmpty) { _existingImageIds.addAll(p.imageIds); @@ -262,6 +272,10 @@ class _AddProductScreenState extends ConsumerState { weightInG: double.tryParse(_weightInGController.text), packageWeightInG: double.tryParse(_packageWeightInGController.text), volumetricWeightInKg: _volumetricWeightInKg, + minStock: double.tryParse(_minStockController.text) ?? 0.0, + reorderLevel: double.tryParse(_minStockController.text) ?? 0.0, + reorderQuantity: double.tryParse(_reorderQuantityController.text) ?? 0.0, + trackInventory: _trackInventory, ); if (widget.product != null) { @@ -732,6 +746,71 @@ class _AddProductScreenState extends ConsumerState { ), ], + const SizedBox(height: 24), + // Inventory & Reorder Point Alert Thresholds + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(LucideIcons.bellRing, color: Colors.orange, size: 20), + ), + const SizedBox(width: 10), + const Text('Inventory & Reorder Alerts', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.withValues(alpha: 0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + activeColor: Theme.of(context).colorScheme.primary, + title: const Text('Track Inventory & Stock Alerts', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + subtitle: const Text('Notify when product stock drops below critical threshold', style: TextStyle(fontSize: 12)), + value: _trackInventory, + onChanged: (val) => setState(() => _trackInventory = val), + ), + if (_trackInventory) ...[ + const Divider(height: 20), + Row( + children: [ + Expanded( + child: _buildPremiumTextField( + label: 'Reorder Alert Level / Min Stock', + controller: _minStockController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + prefixIcon: LucideIcons.alertTriangle, + helperText: 'Alert when stock falls below this qty / weight', + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildPremiumTextField( + label: 'Standard Reorder Batch Qty', + controller: _reorderQuantityController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + prefixIcon: LucideIcons.shoppingBag, + helperText: 'Suggested replenishment batch size', + ), + ), + ], + ), + ], + ], + ), + ), + const SizedBox(height: 24), const Text('Product Images', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 16), @@ -905,20 +984,26 @@ class _AddProductScreenState extends ConsumerState { Widget _buildPremiumTextField({ required String label, + TextEditingController? controller, String? initialValue, String? prefixText, String? suffixText, + IconData? prefixIcon, + String? helperText, TextInputType? keyboardType, void Function(String)? onChanged, void Function(String?)? onSaved, String? Function(String?)? validator, }) { return TextFormField( - initialValue: initialValue, + controller: controller, + initialValue: controller == null ? initialValue : null, decoration: InputDecoration( labelText: label, prefixText: prefixText, suffixText: suffixText, + prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null, + helperText: helperText, ), keyboardType: keyboardType, onChanged: onChanged, diff --git a/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart b/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart index f2b8240..5208d2d 100644 --- a/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart @@ -165,6 +165,81 @@ class ProductDetailScreen extends ConsumerWidget { ), ), ), + const SizedBox(height: 16), + + // Inventory & Reorder Alerts Card + Card( + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(LucideIcons.bellRing, size: 20, color: Colors.orange.shade700), + const SizedBox(width: 8), + const Text( + "Inventory & Reorder Status", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + if (!p.trackInventory) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: Colors.grey.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(6)), + child: const Text('Untracked', style: TextStyle(fontSize: 11, color: Colors.grey, fontWeight: FontWeight.bold)), + ) + else if (p.isOutOfStock) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: Colors.red.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(6)), + child: const Text('Out of Stock', style: TextStyle(fontSize: 11, color: Colors.red, fontWeight: FontWeight.bold)), + ) + else if (p.isLowStock) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: Colors.orange.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6)), + child: const Text('Low Stock Alert', style: TextStyle(fontSize: 11, color: Colors.orange, fontWeight: FontWeight.bold)), + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: const Color(0xFF10B981).withValues(alpha: 0.12), borderRadius: BorderRadius.circular(6)), + child: const Text('Healthy Stock', style: TextStyle(fontSize: 11, color: Color(0xFF10B981), fontWeight: FontWeight.bold)), + ), + ], + ), + const Divider(), + _buildDetailRow( + "Current Available Stock", + "${p.currentStock != null ? (p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock) : '0'} ${uom?.abbreviation ?? 'pcs'}", + ), + _buildDetailRow( + "Reorder Alert Level / Min Stock", + ((p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock) != null && ((p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock)! > 0 + ? "${((p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock)} ${uom?.abbreviation ?? 'pcs'}" + : "Not configured", + ), + _buildDetailRow( + "Standard Reorder Batch Qty", + p.reorderQuantity != null && p.reorderQuantity! > 0 + ? "${p.reorderQuantity} ${uom?.abbreviation ?? 'pcs'}" + : "Not configured", + ), + _buildDetailRow( + "Inventory Tracking", + p.trackInventory ? "Active" : "Disabled", + ), + ], + ), + ), + ), // Non-jewellery Specific Sections if (!isJewellery) ...[ diff --git a/kifi-app/lib/features/inventory/presentation/product_list_screen.dart b/kifi-app/lib/features/inventory/presentation/product_list_screen.dart index 0bd678e..8e16625 100644 --- a/kifi-app/lib/features/inventory/presentation/product_list_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/product_list_screen.dart @@ -22,6 +22,7 @@ class _ProductListScreenState extends ConsumerState { final ScrollController _scrollController = ScrollController(); Timer? _debounce; String? _token; + String _stockFilter = 'ALL'; // 'ALL', 'LOW_STOCK', 'OUT_OF_STOCK' @override void initState() { @@ -55,66 +56,135 @@ class _ProductListScreenState extends ConsumerState { final productsState = ref.watch(productsProvider); final categoriesState = ref.watch(productCategoriesProvider); + final isDark = Theme.of(context).brightness == Brightness.dark; + return Scaffold( - backgroundColor: Colors.grey[100], + backgroundColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC), appBar: AppBar( title: const Text( 'Products Catalog', style: TextStyle(fontWeight: FontWeight.bold), ), elevation: 0, - backgroundColor: Colors.white, - foregroundColor: Colors.black, + backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white, + foregroundColor: isDark ? Colors.white : Colors.black, centerTitle: true, ), - body: MaxContentWidth( - maxWidth: 1000, - child: Column( + body: Column( children: [ Container( - color: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Search products by name or SKU...', - prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), - contentPadding: const EdgeInsets.symmetric(vertical: 14), - ), - onChanged: (val) { - if (_debounce?.isActive ?? false) _debounce!.cancel(); - _debounce = Timer(const Duration(milliseconds: 500), () { - ref.read(productsProvider.notifier).search(val); - }); - }, + color: isDark ? const Color(0xFF1E293B) : Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Column( + children: [ + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search products by name or SKU...', + prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), + contentPadding: const EdgeInsets.symmetric(vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300), + ), + ), + onChanged: (val) { + if (_debounce?.isActive ?? false) _debounce!.cancel(); + _debounce = Timer(const Duration(milliseconds: 500), () { + ref.read(productsProvider.notifier).search(val); + }); + }, + ), + const SizedBox(height: 8), + + // Stock Status Filter Bar + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 6.0), + child: FilterChip( + label: const Text('All Products', style: TextStyle(fontSize: 11)), + selected: _stockFilter == 'ALL', + onSelected: (val) => setState(() => _stockFilter = 'ALL'), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 6.0), + child: FilterChip( + avatar: const Icon(LucideIcons.alertTriangle, size: 14, color: Colors.orange), + label: const Text('Low Stock Alerts', style: TextStyle(fontSize: 11)), + selected: _stockFilter == 'LOW_STOCK', + selectedColor: Colors.orange.withValues(alpha: 0.15), + checkmarkColor: Colors.orange, + onSelected: (val) => setState(() => _stockFilter = val ? 'LOW_STOCK' : 'ALL'), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 6.0), + child: FilterChip( + avatar: const Icon(LucideIcons.alertCircle, size: 14, color: Colors.red), + label: const Text('Out of Stock', style: TextStyle(fontSize: 11)), + selected: _stockFilter == 'OUT_OF_STOCK', + selectedColor: Colors.red.withValues(alpha: 0.15), + checkmarkColor: Colors.red, + onSelected: (val) => setState(() => _stockFilter = val ? 'OUT_OF_STOCK' : 'ALL'), + ), + ), + ], + ), + ), + ], ), ), + const Divider(height: 1), + Expanded( child: productsState.when( loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Center(child: Text('Error: $err')), data: (products) { - if (products.isEmpty) { + final filteredProducts = products.where((p) { + if (_stockFilter == 'LOW_STOCK') { + return p.isLowStock; + } else if (_stockFilter == 'OUT_OF_STOCK') { + return p.isOutOfStock; + } + return true; + }).toList(); + + if (filteredProducts.isEmpty) { return RefreshIndicator( onRefresh: () => ref.read(productsProvider.notifier).refresh(), child: ListView( physics: const AlwaysScrollableScrollPhysics(), - children: const [ - SizedBox(height: 100), + children: [ + const SizedBox(height: 100), Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - LucideIcons.packageSearch, + _stockFilter == 'LOW_STOCK' + ? LucideIcons.checkCircle + : LucideIcons.packageSearch, size: 64, color: Colors.grey, ), - SizedBox(height: 16), + const SizedBox(height: 16), Text( - 'No products found.', - style: TextStyle( + _stockFilter == 'LOW_STOCK' + ? 'No low stock products! All inventory healthy.' + : (_stockFilter == 'OUT_OF_STOCK' + ? 'No out-of-stock products.' + : 'No products found.'), + style: const TextStyle( color: Colors.grey, fontSize: 16, ), @@ -133,14 +203,14 @@ class _ProductListScreenState extends ConsumerState { child: ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), - itemCount: products.length + 1, // +1 for loading indicator + itemCount: filteredProducts.length + 1, // +1 for loading indicator padding: const EdgeInsets.all(16), itemBuilder: (context, index) { - if (index == products.length) { + if (index == filteredProducts.length) { return const SizedBox(height: 80); } - final p = products[index]; + final p = filteredProducts[index]; final catName = categoriesState.value ?.where((c) => c.id == p.categoryId) .firstOrNull @@ -150,11 +220,12 @@ class _ProductListScreenState extends ConsumerState { return Container( margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: Colors.white, + color: isDark ? const Color(0xFF1E293B) : Colors.white, borderRadius: BorderRadius.circular(20), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), boxShadow: [ BoxShadow( - color: Colors.grey.withOpacity(0.08), + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04), blurRadius: 10, offset: const Offset(0, 4), ), @@ -235,28 +306,84 @@ class _ProductListScreenState extends ConsumerState { ], ), ), - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - p.currentStock != null - ? (p.currentStock! > 0 - ? 'Stock: ${p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock}' - : 'Out of Stock') - : 'Untracked', - style: TextStyle( - color: p.currentStock != null - ? (p.currentStock! > 0 - ? const Color(0xFF10B981) - : Colors.red) - : Colors.grey, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], - ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!p.trackInventory) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: const Text('Untracked', style: TextStyle(fontSize: 11, color: Colors.grey, fontWeight: FontWeight.bold)), + ), + ] else if (p.isOutOfStock) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.red.withValues(alpha: 0.3)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.alertCircle, size: 12, color: Colors.red), + SizedBox(width: 4), + Text('Out of Stock', style: TextStyle(fontSize: 11, color: Colors.red, fontWeight: FontWeight.bold)), + ], + ), + ), + ] else if (p.isLowStock) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.orange.withValues(alpha: 0.35)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.alertTriangle, size: 12, color: Colors.orange), + const SizedBox(width: 4), + Text( + 'Low: ${p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock}', + style: const TextStyle(fontSize: 11, color: Colors.orange, fontWeight: FontWeight.bold), + ), + ], + ), + Text( + 'Alert <= ${((p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock) ?? 0}', + style: TextStyle(fontSize: 9, color: Colors.orange.shade800), + ), + ], + ), + ), + ] else ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFF10B981).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Stock: ${p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock}', + style: const TextStyle( + color: Color(0xFF10B981), + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), ], ), ), @@ -271,7 +398,6 @@ class _ProductListScreenState extends ConsumerState { ), ], ), - ), floatingActionButton: FloatingActionButton( onPressed: () async { await Navigator.push( diff --git a/kifi-app/lib/features/sales/domain/credit_note.dart b/kifi-app/lib/features/sales/domain/credit_note.dart new file mode 100644 index 0000000..c8ed06c --- /dev/null +++ b/kifi-app/lib/features/sales/domain/credit_note.dart @@ -0,0 +1,201 @@ +class CreditNoteItem { + final int? id; + final int? creditNoteId; + final int? invoiceItemId; + final int? productId; + final int? inventoryItemId; + final String? productName; + final String? sku; + final String? huid; + final String? hsnCode; + final double quantity; + final double? weight; + final double unitPrice; + final double taxRate; + final double cgst; + final double sgst; + final double igst; + final double discount; + final double makingCharge; + final double otherCharges; + final double total; + + CreditNoteItem({ + this.id, + this.creditNoteId, + this.invoiceItemId, + this.productId, + this.inventoryItemId, + this.productName, + this.sku, + this.huid, + this.hsnCode, + required this.quantity, + this.weight, + required this.unitPrice, + this.taxRate = 0.0, + this.cgst = 0.0, + this.sgst = 0.0, + this.igst = 0.0, + this.discount = 0.0, + this.makingCharge = 0.0, + this.otherCharges = 0.0, + required this.total, + }); + + factory CreditNoteItem.fromJson(Map json) { + return CreditNoteItem( + id: json['id'], + creditNoteId: json['creditNoteId'] ?? json['credit_note_id'], + invoiceItemId: json['invoiceItemId'] ?? json['invoice_item_id'], + productId: json['productId'] ?? json['product_id'], + inventoryItemId: json['inventoryItemId'] ?? json['inventory_item_id'], + productName: json['productName'] ?? json['product_name'], + sku: json['sku'], + huid: json['huid'], + hsnCode: json['hsnCode'] ?? json['hsn_code'], + quantity: (json['quantity'] as num?)?.toDouble() ?? 1.0, + weight: (json['weight'] as num?)?.toDouble(), + unitPrice: (json['unitPrice'] ?? json['unit_price'] as num?)?.toDouble() ?? 0.0, + taxRate: (json['taxRate'] ?? json['tax_rate'] as num?)?.toDouble() ?? 0.0, + cgst: (json['cgst'] as num?)?.toDouble() ?? 0.0, + sgst: (json['sgst'] as num?)?.toDouble() ?? 0.0, + igst: (json['igst'] as num?)?.toDouble() ?? 0.0, + discount: (json['discount'] as num?)?.toDouble() ?? 0.0, + makingCharge: (json['makingCharge'] ?? json['making_charge'] as num?)?.toDouble() ?? 0.0, + otherCharges: (json['otherCharges'] ?? json['other_charges'] as num?)?.toDouble() ?? 0.0, + total: (json['total'] as num?)?.toDouble() ?? 0.0, + ); + } + + Map toJson() { + final Map data = {}; + if (id != null) data['id'] = id; + if (creditNoteId != null) data['creditNoteId'] = creditNoteId; + if (invoiceItemId != null) data['invoiceItemId'] = invoiceItemId; + if (productId != null) data['productId'] = productId; + if (inventoryItemId != null) data['inventoryItemId'] = inventoryItemId; + if (productName != null) data['productName'] = productName; + if (sku != null) data['sku'] = sku; + if (huid != null) data['huid'] = huid; + if (hsnCode != null) data['hsnCode'] = hsnCode; + data['quantity'] = quantity; + if (weight != null) data['weight'] = weight; + data['unitPrice'] = unitPrice; + data['taxRate'] = taxRate; + data['cgst'] = cgst; + data['sgst'] = sgst; + data['igst'] = igst; + data['discount'] = discount; + data['makingCharge'] = makingCharge; + data['otherCharges'] = otherCharges; + data['total'] = total; + return data; + } +} + +class CreditNote { + final int? id; + final int? userId; + final int invoiceId; + final String creditNoteNumber; + final int? customerId; + final DateTime returnDate; + final String reason; // CUSTOMER_RETURN, RTO_UNDELIVERED, DEFECTIVE, EXCHANGE, OTHER + final bool restockItems; + final double subtotal; + final double taxTotal; + final double cgstTotal; + final double sgstTotal; + final double igstTotal; + final double discountTotal; + final double totalAmount; + final String refundStatus; // ADJUSTED_TO_AR, REFUNDED_TO_WALLET, STORE_CREDIT, PENDING + final int? refundWalletId; + final String? notes; + final DateTime? createdAt; + final DateTime? updatedAt; + final List items; + + CreditNote({ + this.id, + this.userId, + required this.invoiceId, + required this.creditNoteNumber, + this.customerId, + required this.returnDate, + this.reason = 'CUSTOMER_RETURN', + this.restockItems = true, + required this.subtotal, + this.taxTotal = 0.0, + this.cgstTotal = 0.0, + this.sgstTotal = 0.0, + this.igstTotal = 0.0, + this.discountTotal = 0.0, + required this.totalAmount, + this.refundStatus = 'ADJUSTED_TO_AR', + this.refundWalletId, + this.notes, + this.createdAt, + this.updatedAt, + this.items = const [], + }); + + factory CreditNote.fromJson(Map json) { + return CreditNote( + id: json['id'], + userId: json['userId'] ?? json['user_id'], + invoiceId: json['invoiceId'] ?? json['invoice_id'] ?? 0, + creditNoteNumber: json['creditNoteNumber'] ?? json['credit_note_number'] ?? '', + customerId: json['customerId'] ?? json['customer_id'], + returnDate: json['returnDate'] != null + ? DateTime.parse(json['returnDate']) + : (json['return_date'] != null ? DateTime.parse(json['return_date']) : DateTime.now()), + reason: json['reason'] ?? 'CUSTOMER_RETURN', + restockItems: json['restockItems'] ?? json['restock_items'] ?? true, + subtotal: (json['subtotal'] as num?)?.toDouble() ?? 0.0, + taxTotal: (json['taxTotal'] ?? json['tax_total'] as num?)?.toDouble() ?? 0.0, + cgstTotal: (json['cgstTotal'] ?? json['cgst_total'] as num?)?.toDouble() ?? 0.0, + sgstTotal: (json['sgstTotal'] ?? json['sgst_total'] as num?)?.toDouble() ?? 0.0, + igstTotal: (json['igstTotal'] ?? json['igst_total'] as num?)?.toDouble() ?? 0.0, + discountTotal: (json['discountTotal'] ?? json['discount_total'] as num?)?.toDouble() ?? 0.0, + totalAmount: (json['totalAmount'] ?? json['total_amount'] as num?)?.toDouble() ?? 0.0, + refundStatus: json['refundStatus'] ?? json['refund_status'] ?? 'ADJUSTED_TO_AR', + refundWalletId: json['refundWalletId'] ?? json['refund_wallet_id'], + notes: json['notes'], + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt']) + : (json['created_at'] != null ? DateTime.parse(json['created_at']) : null), + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt']) + : (json['updated_at'] != null ? DateTime.parse(json['updated_at']) : null), + items: json['items'] != null + ? (json['items'] as List).map((i) => CreditNoteItem.fromJson(i)).toList() + : [], + ); + } + + Map toJson() { + final Map data = {}; + if (id != null) data['id'] = id; + if (userId != null) data['userId'] = userId; + data['invoiceId'] = invoiceId; + data['creditNoteNumber'] = creditNoteNumber; + if (customerId != null) data['customerId'] = customerId; + data['returnDate'] = returnDate.toIso8601String().split('T')[0]; + data['reason'] = reason; + data['restockItems'] = restockItems; + data['subtotal'] = subtotal; + data['taxTotal'] = taxTotal; + data['cgstTotal'] = cgstTotal; + data['sgstTotal'] = sgstTotal; + data['igstTotal'] = igstTotal; + data['discountTotal'] = discountTotal; + data['totalAmount'] = totalAmount; + data['refundStatus'] = refundStatus; + if (refundWalletId != null) data['refundWalletId'] = refundWalletId; + if (notes != null) data['notes'] = notes; + data['items'] = items.map((i) => i.toJson()).toList(); + return data; + } +} diff --git a/kifi-app/lib/features/sales/domain/invoice.dart b/kifi-app/lib/features/sales/domain/invoice.dart index cc9ecc2..78b5c81 100644 --- a/kifi-app/lib/features/sales/domain/invoice.dart +++ b/kifi-app/lib/features/sales/domain/invoice.dart @@ -195,6 +195,12 @@ class Invoice { final int? salesChannelId; final String? salesChannel; final String? marketplaceOrderId; + final String? courierPartner; + final String? trackingNumber; + final String? dispatchStatus; // PENDING, PACKED, SHIPPED, IN_TRANSIT, DELIVERED, RTO + final DateTime? shippedAt; + final String? shippingAddress; + final String? shippingPincode; final int? placeOfSupplyStateId; final String? placeOfSupply; final List items; @@ -227,6 +233,12 @@ class Invoice { this.salesChannelId, this.salesChannel, this.marketplaceOrderId, + this.courierPartner, + this.trackingNumber, + this.dispatchStatus, + this.shippedAt, + this.shippingAddress, + this.shippingPincode, this.placeOfSupplyStateId, this.placeOfSupply, this.items = const [], @@ -267,6 +279,14 @@ class Invoice { salesChannelId: json['salesChannelId'] ?? json['sales_channel_id'], salesChannel: json['salesChannel'] ?? json['sales_channel'], marketplaceOrderId: json['marketplaceOrderId'] ?? json['marketplace_order_id'], + courierPartner: json['courierPartner'] ?? json['courier_partner'], + trackingNumber: json['trackingNumber'] ?? json['tracking_number'], + dispatchStatus: json['dispatchStatus'] ?? json['dispatch_status'] ?? 'PENDING', + shippedAt: json['shippedAt'] != null + ? DateTime.parse(json['shippedAt']) + : (json['shipped_at'] != null ? DateTime.parse(json['shipped_at']) : null), + shippingAddress: json['shippingAddress'] ?? json['shipping_address'], + shippingPincode: json['shippingPincode'] ?? json['shipping_pincode'], placeOfSupplyStateId: json['placeOfSupplyStateId'] ?? json['place_of_supply_state_id'], placeOfSupply: json['placeOfSupply'] ?? json['place_of_supply'], items: json['items'] != null @@ -314,6 +334,14 @@ class Invoice { if (salesChannelId != null) data['salesChannelId'] = salesChannelId; if (salesChannel != null) data['salesChannel'] = salesChannel; if (marketplaceOrderId != null) data['marketplaceOrderId'] = marketplaceOrderId; + if (courierPartner != null) data['courierPartner'] = courierPartner; + if (trackingNumber != null) data['trackingNumber'] = trackingNumber; + if (dispatchStatus != null) data['dispatchStatus'] = dispatchStatus; + if (shippedAt != null) { + data['shippedAt'] = shippedAt!.toIso8601String(); + } + if (shippingAddress != null) data['shippingAddress'] = shippingAddress; + if (shippingPincode != null) data['shippingPincode'] = shippingPincode; if (placeOfSupplyStateId != null) data['placeOfSupplyStateId'] = placeOfSupplyStateId; if (placeOfSupply != null) data['placeOfSupply'] = placeOfSupply; data['items'] = items.map((i) => i.toJson()).toList(); diff --git a/kifi-app/lib/features/sales/domain/marketplace_settlement.dart b/kifi-app/lib/features/sales/domain/marketplace_settlement.dart new file mode 100644 index 0000000..b7e6c87 --- /dev/null +++ b/kifi-app/lib/features/sales/domain/marketplace_settlement.dart @@ -0,0 +1,103 @@ +class MarketplaceSettlement { + final int? id; + final int? userId; + final int invoiceId; + final int? salesChannelId; + final String? channelName; + final String? settlementRef; + final DateTime settlementDate; + final double grossAmount; + final double commissionFee; + final double shippingFee; + final double otherFees; + final double feeGst; + final double tcsGstAmount; + final double tdsAmount; + final double totalDeductions; + final double netPayoutAmount; + final int? payoutWalletId; + final String status; + final String? notes; + final DateTime? createdAt; + final DateTime? updatedAt; + + MarketplaceSettlement({ + this.id, + this.userId, + required this.invoiceId, + this.salesChannelId, + this.channelName, + this.settlementRef, + required this.settlementDate, + required this.grossAmount, + this.commissionFee = 0.0, + this.shippingFee = 0.0, + this.otherFees = 0.0, + this.feeGst = 0.0, + this.tcsGstAmount = 0.0, + this.tdsAmount = 0.0, + required this.totalDeductions, + required this.netPayoutAmount, + this.payoutWalletId, + this.status = 'SETTLED', + this.notes, + this.createdAt, + this.updatedAt, + }); + + factory MarketplaceSettlement.fromJson(Map json) { + return MarketplaceSettlement( + id: json['id'], + userId: json['userId'] ?? json['user_id'], + invoiceId: json['invoiceId'] ?? json['invoice_id'] ?? 0, + salesChannelId: json['salesChannelId'] ?? json['sales_channel_id'], + channelName: json['channelName'] ?? json['channel_name'], + settlementRef: json['settlementRef'] ?? json['settlement_ref'], + settlementDate: json['settlementDate'] != null + ? DateTime.parse(json['settlementDate']) + : (json['settlement_date'] != null ? DateTime.parse(json['settlement_date']) : DateTime.now()), + grossAmount: (json['grossAmount'] ?? json['gross_amount'] as num?)?.toDouble() ?? 0.0, + commissionFee: (json['commissionFee'] ?? json['commission_fee'] as num?)?.toDouble() ?? 0.0, + shippingFee: (json['shippingFee'] ?? json['shipping_fee'] as num?)?.toDouble() ?? 0.0, + otherFees: (json['otherFees'] ?? json['other_fees'] as num?)?.toDouble() ?? 0.0, + feeGst: (json['feeGst'] ?? json['fee_gst'] as num?)?.toDouble() ?? 0.0, + tcsGstAmount: (json['tcsGstAmount'] ?? json['tcs_gst_amount'] as num?)?.toDouble() ?? 0.0, + tdsAmount: (json['tdsAmount'] ?? json['tds_amount'] as num?)?.toDouble() ?? 0.0, + totalDeductions: (json['totalDeductions'] ?? json['total_deductions'] as num?)?.toDouble() ?? 0.0, + netPayoutAmount: (json['netPayoutAmount'] ?? json['net_payout_amount'] as num?)?.toDouble() ?? 0.0, + payoutWalletId: json['payoutWalletId'] ?? json['payout_wallet_id'], + status: json['status'] ?? 'SETTLED', + notes: json['notes'], + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt']) + : (json['created_at'] != null ? DateTime.parse(json['created_at']) : null), + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt']) + : (json['updated_at'] != null ? DateTime.parse(json['updated_at']) : null), + ); + } + + Map toJson() { + final Map data = {}; + if (id != null) data['id'] = id; + if (userId != null) data['userId'] = userId; + data['invoiceId'] = invoiceId; + if (salesChannelId != null) data['salesChannelId'] = salesChannelId; + if (channelName != null) data['channelName'] = channelName; + if (settlementRef != null) data['settlementRef'] = settlementRef; + data['settlementDate'] = settlementDate.toIso8601String().split('T')[0]; + data['grossAmount'] = grossAmount; + data['commissionFee'] = commissionFee; + data['shippingFee'] = shippingFee; + data['otherFees'] = otherFees; + data['feeGst'] = feeGst; + data['tcsGstAmount'] = tcsGstAmount; + data['tdsAmount'] = tdsAmount; + data['totalDeductions'] = totalDeductions; + data['netPayoutAmount'] = netPayoutAmount; + if (payoutWalletId != null) data['payoutWalletId'] = payoutWalletId; + data['status'] = status; + if (notes != null) data['notes'] = notes; + return data; + } +} diff --git a/kifi-app/lib/features/sales/presentation/credit_notes_list_screen.dart b/kifi-app/lib/features/sales/presentation/credit_notes_list_screen.dart new file mode 100644 index 0000000..0af1b79 --- /dev/null +++ b/kifi-app/lib/features/sales/presentation/credit_notes_list_screen.dart @@ -0,0 +1,451 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import '../domain/credit_note.dart'; +import '../providers/credit_notes_provider.dart'; +import '../providers/customers_provider.dart'; +import '../providers/invoices_provider.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; +import '../../business/providers/business_provider.dart'; + +class CreditNotesListScreen extends ConsumerStatefulWidget { + const CreditNotesListScreen({super.key}); + + @override + ConsumerState createState() => _CreditNotesListScreenState(); +} + +class _CreditNotesListScreenState extends ConsumerState { + String _searchQuery = ''; + final TextEditingController _searchController = TextEditingController(); + String? _selectedReasonFilter; + + final formatCurrency = NumberFormat.currency(symbol: '₹'); + final formatDate = DateFormat('MMM dd, yyyy'); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _shareCreditNotePdf(CreditNote cn) async { + try { + final pdf = pw.Document(); + final business = ref.read(businessProfileProvider).value; + final customers = ref.read(customersProvider).value ?? []; + final customer = customers.where((c) => c.id == cn.customerId).firstOrNull; + final invoices = ref.read(invoicesProvider).value ?? []; + final invoice = invoices.where((i) => i.id == cn.invoiceId).firstOrNull; + + final pdfFormatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2); + final pdfFormatDate = DateFormat('dd MMM yyyy'); + + pdf.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(32), + build: (context) => [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + // Header + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + business?.businessName ?? 'KIFI ENTERPRISE', + style: pw.TextStyle(fontSize: 18, fontWeight: pw.FontWeight.bold, color: PdfColors.red900), + ), + if (business?.address != null && business!.address!.isNotEmpty) + pw.Text(business.address!, style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700)), + if (business?.gstin != null && business!.gstin!.isNotEmpty) + pw.Text('GSTIN: ${business.gstin}', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.blue800)), + ], + ), + ), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text( + 'CREDIT NOTE', + style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold, color: PdfColors.red900), + ), + pw.SizedBox(height: 4), + pw.Text('CN #: ${cn.creditNoteNumber}', style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold)), + pw.Text('Date: ${pdfFormatDate.format(cn.returnDate)}', style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700)), + if (invoice != null) + pw.Text('Original Inv #: ${invoice.invoiceNumber}', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900)), + pw.Text('Reason: ${cn.reason}', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)), + ], + ), + ], + ), + pw.SizedBox(height: 16), + + // Bill To + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + color: PdfColors.grey100, + border: pw.Border.all(color: PdfColors.grey400, width: 0.5), + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)), + ), + child: pw.Row( + children: [ + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('CREDITED TO (CUSTOMER):', style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)), + pw.SizedBox(height: 2), + pw.Text(customer?.name ?? 'Valued Customer', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)), + if (customer?.phone != null) pw.Text('Phone: ${customer!.phone}', style: const pw.TextStyle(fontSize: 9)), + if (customer?.gstin != null) pw.Text('GSTIN: ${customer!.gstin}', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold)), + ], + ), + ), + ], + ), + ), + pw.SizedBox(height: 16), + + // Table of Returned Items + pw.TableHelper.fromTextArray( + context: context, + border: const pw.TableBorder( + bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5), + horizontalInside: pw.BorderSide(color: PdfColors.grey200, width: 0.5), + ), + headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.white, fontSize: 8), + headerDecoration: const pw.BoxDecoration(color: PdfColors.red900), + cellStyle: const pw.TextStyle(fontSize: 8), + headers: ['Returned Particulars', 'Qty / Weight', 'Unit Rate', 'GST Rate', 'Tax Amount', 'Credit Amount'], + data: cn.items.map((item) { + final qtyStr = item.weight != null && item.weight! > 0 + ? '${item.weight!.toStringAsFixed(3)} g' + : '${item.quantity.toInt()} pcs'; + final taxAmt = item.cgst + item.sgst + item.igst; + return [ + item.productName ?? 'Item', + qtyStr, + pdfFormatCurrency.format(item.unitPrice), + '${item.taxRate.toStringAsFixed(1)}%', + taxAmt > 0 ? pdfFormatCurrency.format(taxAmt) : '-', + pdfFormatCurrency.format(item.total), + ]; + }).toList(), + ), + pw.SizedBox(height: 14), + + // Grand Total + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.end, + children: [ + pw.Container( + width: 200, + padding: const pw.EdgeInsets.all(8), + decoration: const pw.BoxDecoration( + color: PdfColors.red50, + borderRadius: pw.BorderRadius.all(pw.Radius.circular(6)), + ), + child: pw.Column( + children: [ + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('Total Credit Value:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 11, color: PdfColors.red900)), + pw.Text(pdfFormatCurrency.format(cn.totalAmount), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 12, color: PdfColors.red900)), + ], + ), + ], + ), + ), + ], + ), + pw.SizedBox(height: 24), + + // Signatures + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.SizedBox(height: 25), + pw.Text('Customer\'s Acknowledgement', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700)), + ], + ), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.SizedBox(height: 25), + pw.Text('For ${business?.businessName ?? "KIFI ENTERPRISE"} (Authorized Signatory)', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700)), + ], + ), + ], + ), + ], + ), + ], + ), + ); + + final pdfBytes = await pdf.save(); + await Printing.sharePdf(bytes: pdfBytes, filename: 'CreditNote_${cn.creditNoteNumber}.pdf'); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error generating Credit Note PDF: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final creditNotesState = ref.watch(creditNotesProvider); + final customers = ref.watch(customersProvider).value ?? []; + final invoices = ref.watch(invoicesProvider).value ?? []; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + backgroundColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC), + appBar: AppBar( + title: const Text('Sales Returns & Credit Notes', style: TextStyle(fontWeight: FontWeight.bold)), + elevation: 0, + backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white, + foregroundColor: isDark ? Colors.white : Colors.black, + centerTitle: true, + ), + body: Column( + children: [ + // Search & Filter Header + Container( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Column( + children: [ + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search Credit Note #, Invoice # or Customer...', + prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), + contentPadding: const EdgeInsets.symmetric(vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + isDense: true, + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(LucideIcons.xCircle, size: 18), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + ), + onChanged: (val) => setState(() => _searchQuery = val.trim()), + ), + const SizedBox(height: 8), + + // Reason Filter Chips + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 6.0), + child: FilterChip( + label: const Text('All Returns', style: TextStyle(fontSize: 11)), + selected: _selectedReasonFilter == null, + onSelected: (selected) { + if (selected) setState(() => _selectedReasonFilter = null); + }, + ), + ), + ...['CUSTOMER_RETURN', 'RTO_UNDELIVERED', 'DEFECTIVE', 'EXCHANGE'].map((code) { + final label = code == 'CUSTOMER_RETURN' + ? 'Customer Returns' + : (code == 'RTO_UNDELIVERED' ? 'RTO Undelivered' : (code == 'DEFECTIVE' ? 'Defective' : 'Exchange')); + final isSelected = _selectedReasonFilter == code; + return Padding( + padding: const EdgeInsets.only(right: 6.0), + child: FilterChip( + label: Text(label, style: const TextStyle(fontSize: 11)), + selected: isSelected, + onSelected: (selected) { + setState(() => _selectedReasonFilter = selected ? code : null); + }, + ), + ); + }), + ], + ), + ), + ], + ), + ), + + // List + Expanded( + child: creditNotesState.when( + data: (creditNotes) { + final filtered = creditNotes.where((cn) { + final q = _searchQuery.toLowerCase(); + final invoice = invoices.where((i) => i.id == cn.invoiceId).firstOrNull; + final customer = customers.where((c) => c.id == cn.customerId).firstOrNull; + + final matchesNumber = cn.creditNoteNumber.toLowerCase().contains(q); + final matchesInvoice = invoice != null && invoice.invoiceNumber.toLowerCase().contains(q); + final matchesCustomer = customer != null && customer.name.toLowerCase().contains(q); + final matchesSearch = q.isEmpty || matchesNumber || matchesInvoice || matchesCustomer; + + final matchesReason = _selectedReasonFilter == null || cn.reason == _selectedReasonFilter; + + return matchesSearch && matchesReason; + }).toList(); + + if (filtered.isEmpty) { + return RefreshIndicator( + onRefresh: () => ref.refresh(creditNotesProvider.future), + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox(height: 120), + Center( + child: Column( + children: [ + Icon(LucideIcons.undo2, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text('No credit notes or returns recorded.', style: TextStyle(color: Colors.grey, fontSize: 16)), + ], + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () => ref.refresh(creditNotesProvider.future), + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: filtered.length, + itemBuilder: (context, index) { + final cn = filtered[index]; + final invoice = invoices.where((i) => i.id == cn.invoiceId).firstOrNull; + final customer = customers.where((c) => c.id == cn.customerId).firstOrNull; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(LucideIcons.undo2, color: Colors.red, size: 16), + ), + const SizedBox(width: 8), + Text( + cn.creditNoteNumber, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.red.withValues(alpha: 0.3)), + ), + child: Text( + cn.reason.replaceAll('_', ' '), + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.red), + ), + ), + ], + ), + const SizedBox(height: 10), + + Row( + children: [ + const Icon(LucideIcons.user, size: 14, color: Colors.grey), + const SizedBox(width: 6), + Text( + customer?.name ?? 'Walk-in Customer', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + ), + const Spacer(), + if (invoice != null) ...[ + Text( + 'Inv #${invoice.invoiceNumber}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ], + ), + const SizedBox(height: 10), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + formatDate.format(cn.returnDate), + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + Row( + children: [ + Text( + formatCurrency.format(cn.totalAmount), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.red), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(LucideIcons.share2, size: 18, color: Colors.blue), + tooltip: 'Share Credit Note PDF', + onPressed: () => _shareCreditNotePdf(cn), + ), + ], + ), + ], + ), + ], + ), + ); + }, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, stack) => Center(child: Text('Error: $e')), + ), + ), + ], + ), + ); + } +} 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 8e6d53f..7bb6778 100644 --- a/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart @@ -107,6 +107,10 @@ class _InvoiceBuilderScreenState extends ConsumerState { final _emiAmountCtrl = TextEditingController(); final _discountCtrl = TextEditingController(text: '0.0'); final _marketplaceOrderIdCtrl = TextEditingController(); + final _courierPartnerCtrl = TextEditingController(); + final _trackingNumberCtrl = TextEditingController(); + final _shippingAddressCtrl = TextEditingController(); + final _shippingPincodeCtrl = TextEditingController(); DateTime _invoiceDate = DateTime.now(); DateTime? _dueDate; @@ -118,6 +122,11 @@ class _InvoiceBuilderScreenState extends ConsumerState { final List _items = []; bool _isLoading = false; + // Logistics & Delivery State + String _dispatchStatus = 'PENDING'; + bool _sameAsCustomerAddress = true; + DateTime? _shippedAt; + XFile? _invoiceFile; String? _invoiceUrl; @@ -143,6 +152,13 @@ class _InvoiceBuilderScreenState extends ConsumerState { _selectedSalesChannelId = inv.salesChannelId; _selectedSalesChannelName = inv.salesChannel; _marketplaceOrderIdCtrl.text = inv.marketplaceOrderId ?? ''; + _courierPartnerCtrl.text = inv.courierPartner ?? ''; + _trackingNumberCtrl.text = inv.trackingNumber ?? ''; + _dispatchStatus = inv.dispatchStatus ?? 'PENDING'; + _shippedAt = inv.shippedAt; + _shippingAddressCtrl.text = inv.shippingAddress ?? ''; + _shippingPincodeCtrl.text = inv.shippingPincode ?? ''; + _sameAsCustomerAddress = (inv.shippingAddress == null || inv.shippingAddress!.trim().isEmpty); _selectedPlaceOfSupplyStateId = inv.placeOfSupplyStateId; _selectedPlaceOfSupplyStateName = inv.placeOfSupply; _notesCtrl.text = inv.notes ?? ''; @@ -181,6 +197,10 @@ class _InvoiceBuilderScreenState extends ConsumerState { _emiAmountCtrl.dispose(); _discountCtrl.dispose(); _marketplaceOrderIdCtrl.dispose(); + _courierPartnerCtrl.dispose(); + _trackingNumberCtrl.dispose(); + _shippingAddressCtrl.dispose(); + _shippingPincodeCtrl.dispose(); super.dispose(); } @@ -502,6 +522,12 @@ class _InvoiceBuilderScreenState extends ConsumerState { salesChannelId: _selectedSalesChannelId, salesChannel: _selectedSalesChannelName, marketplaceOrderId: _marketplaceOrderIdCtrl.text.trim().isNotEmpty ? _marketplaceOrderIdCtrl.text.trim() : null, + courierPartner: _courierPartnerCtrl.text.trim().isNotEmpty ? _courierPartnerCtrl.text.trim() : null, + trackingNumber: _trackingNumberCtrl.text.trim().isNotEmpty ? _trackingNumberCtrl.text.trim() : null, + dispatchStatus: _dispatchStatus, + shippedAt: _shippedAt ?? (_dispatchStatus == 'SHIPPED' || _dispatchStatus == 'IN_TRANSIT' || _dispatchStatus == 'DELIVERED' ? DateTime.now() : null), + shippingAddress: !_sameAsCustomerAddress && _shippingAddressCtrl.text.trim().isNotEmpty ? _shippingAddressCtrl.text.trim() : null, + shippingPincode: !_sameAsCustomerAddress && _shippingPincodeCtrl.text.trim().isNotEmpty ? _shippingPincodeCtrl.text.trim() : null, placeOfSupplyStateId: effectiveSupplyStateId, placeOfSupply: _selectedPlaceOfSupplyStateName ?? ref.read(indianStatesProvider).value?.where((s) => s.id == effectiveSupplyStateId).firstOrNull?.name, items: processedItems, @@ -1047,6 +1073,11 @@ class _InvoiceBuilderScreenState extends ConsumerState { const SizedBox(height: 24), + // Shipping & Courier Logistics Section + _buildShippingLogisticsSection(isDark), + + const SizedBox(height: 24), + // Notes & Remarks PremiumTextField( controller: _notesCtrl, @@ -1787,6 +1818,240 @@ class _InvoiceBuilderScreenState extends ConsumerState { ), ); } + + Future _scanTrackingBarcode() async { + final code = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()), + ); + if (code != null && code.isNotEmpty) { + setState(() { + _trackingNumberCtrl.text = code.trim(); + if (_dispatchStatus == 'PENDING') { + _dispatchStatus = 'SHIPPED'; + _shippedAt = DateTime.now(); + } + }); + } + } + + Widget _buildShippingLogisticsSection(bool isDark) { + final customers = ref.watch(customersProvider).value ?? []; + final customer = _selectedCustomerId == null + ? null + : customers.where((c) => c.id == _selectedCustomerId).firstOrNull; + + final courierSuggestions = [ + 'Delhivery', + 'BlueDart', + 'Shiprocket', + 'DTDC', + 'India Post', + 'Amazon Shipping', + 'Ekart', + 'Shadowfax', + 'Direct / Hand Delivery', + ]; + + final dispatchStatuses = [ + {'status': 'PENDING', 'label': 'Pending', 'color': Colors.grey}, + {'status': 'PACKED', 'label': 'Packed', 'color': Colors.blue}, + {'status': 'SHIPPED', 'label': 'Shipped', 'color': Colors.indigo}, + {'status': 'IN_TRANSIT', 'label': 'In Transit', 'color': Colors.amber.shade800}, + {'status': 'DELIVERED', 'label': 'Delivered', 'color': Colors.green}, + {'status': 'RTO', 'label': 'RTO / Return', 'color': Colors.red}, + ]; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.indigo.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(LucideIcons.truck, size: 16, color: Colors.indigo), + ), + const SizedBox(width: 8), + const Text( + 'SHIPPING & COURIER LOGISTICS', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 0.8, color: Colors.grey), + ), + ], + ), + 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.3)), + ), + child: Text( + _dispatchStatus, + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.indigo), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Dispatch Status Selector + const Text('Dispatch Status:', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.grey)), + const SizedBox(height: 6), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: dispatchStatuses.map((st) { + final statusKey = st['status'] as String; + final statusLabel = st['label'] as String; + final statusColor = st['color'] as Color; + final isSelected = _dispatchStatus == statusKey; + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ChoiceChip( + label: Text( + statusLabel, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? Colors.white : (isDark ? Colors.white70 : Colors.black87), + ), + ), + selected: isSelected, + selectedColor: statusColor, + backgroundColor: isDark ? const Color(0xFF0F172A) : Colors.grey.shade100, + onSelected: (selected) { + if (selected) { + setState(() { + _dispatchStatus = statusKey; + if (statusKey == 'SHIPPED' && _shippedAt == null) { + _shippedAt = DateTime.now(); + } + }); + } + }, + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 14), + + // Courier Partner & Quick Selection Chips + Row( + children: [ + Expanded( + child: PremiumTextField( + controller: _courierPartnerCtrl, + labelText: 'Courier Partner (e.g. Delhivery, BlueDart)', + prefixIcon: const Icon(LucideIcons.packageCheck), + ), + ), + ], + ), + const SizedBox(height: 6), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: courierSuggestions.map((name) { + final isCurrent = _courierPartnerCtrl.text.trim().toLowerCase() == name.toLowerCase(); + return Padding( + padding: const EdgeInsets.only(right: 6.0), + child: ActionChip( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), + label: Text(name, style: TextStyle(fontSize: 11, color: isCurrent ? Colors.blue : null)), + backgroundColor: isCurrent ? Colors.blue.withValues(alpha: 0.15) : null, + onPressed: () { + setState(() { + _courierPartnerCtrl.text = name; + }); + }, + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 14), + + // AWB / Tracking Number with Scanner Button + Row( + children: [ + Expanded( + child: PremiumTextField( + controller: _trackingNumberCtrl, + labelText: 'AWB / Tracking Number', + prefixIcon: const Icon(LucideIcons.barcode), + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + icon: const Icon(LucideIcons.scanLine), + tooltip: 'Scan AWB Barcode', + onPressed: _scanTrackingBarcode, + ), + ], + ), + const SizedBox(height: 14), + + // Delivery Address Toggle & Form + CheckboxListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: const Text('Deliver to customer billing address', style: TextStyle(fontWeight: FontWeight.w600)), + subtitle: Text( + customer?.address != null && customer!.address!.isNotEmpty + ? customer.address! + : 'Customer address on file', + style: TextStyle(fontSize: 11, color: Colors.grey.shade600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + value: _sameAsCustomerAddress, + onChanged: (val) { + setState(() => _sameAsCustomerAddress = val ?? true); + }, + ), + if (!_sameAsCustomerAddress) ...[ + const SizedBox(height: 8), + PremiumTextField( + controller: _shippingAddressCtrl, + labelText: 'Shipping / Delivery Address', + maxLines: 2, + prefixIcon: const Icon(LucideIcons.mapPin), + ), + const SizedBox(height: 10), + PremiumTextField( + controller: _shippingPincodeCtrl, + labelText: 'Shipping Pincode', + keyboardType: TextInputType.number, + prefixIcon: const Icon(LucideIcons.hash), + ), + ], + ], + ), + ); + } } class _AddSalesItemSheet extends ConsumerStatefulWidget { 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 b96e0f1..fe6da06 100644 --- a/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart @@ -18,9 +18,16 @@ import '../domain/invoice.dart'; import '../providers/invoices_provider.dart'; import '../providers/customers_provider.dart'; import '../../business/providers/business_provider.dart'; +import '../../business/providers/indian_states_provider.dart'; import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; import '../../../core/network/dio_client.dart'; import 'widgets/receive_payment_sheet.dart'; +import 'widgets/create_credit_note_sheet.dart'; +import 'widgets/settle_marketplace_order_sheet.dart'; +import 'credit_notes_list_screen.dart'; +import 'marketplace_settlements_screen.dart'; +import '../providers/credit_notes_provider.dart'; +import '../providers/marketplace_settlements_provider.dart'; import 'invoice_builder_screen.dart'; bool _isCommodityProduct({ @@ -533,19 +540,239 @@ class _InvoiceDetailsScreenState extends ConsumerState { const SizedBox(height: 20), ], - // Bottom Action: Receive Payment if balance due - if (remaining > 0) - ElevatedButton.icon( - onPressed: () => _showReceivePaymentSheet(context, ref, latestInvoice), - icon: const Icon(LucideIcons.plusCircle, size: 18), - label: const Text('Record Payment', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - backgroundColor: Colors.green, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), + // Credit Notes / Returns associated with this Invoice + if (latestInvoice.id != null) ...[ + ref.watch(invoiceCreditNotesProvider(latestInvoice.id!)).when( + data: (cns) { + if (cns.isEmpty) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Returns & Credit Notes Issued', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + ...cns.map((cn) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: isDark ? 0.1 : 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.red.withValues(alpha: 0.25)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(LucideIcons.undo2, color: Colors.red, size: 16), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Credit Note #${cn.creditNoteNumber}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + Text( + '${cn.reason.replaceAll("_", " ")} • ${formatDate.format(cn.returnDate)}', + style: TextStyle(fontSize: 11, color: Colors.grey.shade600), + ), + ], + ), + ], + ), + Text( + formatCurrency.format(cn.totalAmount), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.red), + ), + ], + ), + ); + }), + const SizedBox(height: 16), + ], + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), ), + ], + + // Marketplace Settlement Breakdown Card (if settled) + if (latestInvoice.id != null) ...[ + ref.watch(invoiceSettlementProvider(latestInvoice.id!)).when( + data: (settlement) { + if (settlement == null) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Marketplace Settlement & Payout', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFF10B981).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: const Row( + children: [ + Icon(LucideIcons.checkCheck, size: 12, color: Color(0xFF10B981)), + SizedBox(width: 4), + Text('Reconciled', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Color(0xFF10B981))), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('${settlement.channelName ?? "Marketplace"} Remittance', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text(formatDate.format(settlement.settlementDate), style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), + ], + ), + if (settlement.settlementRef != null) ...[ + const SizedBox(height: 4), + Row( + children: [ + const Icon(LucideIcons.hash, size: 12, color: Colors.grey), + const SizedBox(width: 4), + Text('Bank UTR: ${settlement.settlementRef}', style: TextStyle(fontSize: 11, color: Colors.grey.shade600)), + ], + ), + ], + const Divider(height: 16), + _buildSummaryRow('Gross Sale Value', formatCurrency.format(settlement.grossAmount)), + if (settlement.commissionFee > 0) + _buildSummaryRow('Referral / Commission Fee', '- ${formatCurrency.format(settlement.commissionFee)}', color: Colors.red), + if (settlement.shippingFee > 0) + _buildSummaryRow('Logistics / Shipping Fee', '- ${formatCurrency.format(settlement.shippingFee)}', color: Colors.red), + if (settlement.otherFees > 0) + _buildSummaryRow('Closing / Fixed Fees', '- ${formatCurrency.format(settlement.otherFees)}', color: Colors.red), + if (settlement.feeGst > 0) + _buildSummaryRow('GST on Marketplace Fees (18%)', '- ${formatCurrency.format(settlement.feeGst)}', color: Colors.red), + if (settlement.tcsGstAmount > 0) + _buildSummaryRow('GST TCS Withheld (1%)', '- ${formatCurrency.format(settlement.tcsGstAmount)}', color: Colors.red), + if (settlement.tdsAmount > 0) + _buildSummaryRow('TDS u/s 194O Withheld (0.1%)', '- ${formatCurrency.format(settlement.tdsAmount)}', color: Colors.red), + const Divider(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Net Bank Remittance Payout:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text( + formatCurrency.format(settlement.netPayoutAmount), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Color(0xFF10B981)), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ), + ], + + // Action Buttons Row: Settle Marketplace Payout / Record Payment & Process Return + Row( + children: [ + if (latestInvoice.status != 'RETURNED') ...[ + Expanded( + child: OutlinedButton.icon( + onPressed: () async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) => CreateCreditNoteSheet(invoice: latestInvoice), + ); + if (result != null) { + ref.refresh(invoicesProvider); + if (latestInvoice.id != null) { + ref.refresh(invoiceCreditNotesProvider(latestInvoice.id!)); + } + } + }, + icon: const Icon(LucideIcons.undo2, size: 16, color: Colors.red), + label: const Text('Return / Credit Note', style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold, fontSize: 13)), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: BorderSide(color: Colors.red.shade300), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + const SizedBox(width: 10), + ], + if (remaining > 0) ...[ + if (latestInvoice.salesChannel != null && + latestInvoice.salesChannel!.toUpperCase() != 'DIRECT_POS' && + latestInvoice.salesChannel!.toUpperCase() != 'STORE') ...[ + Expanded( + child: ElevatedButton.icon( + onPressed: () async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) => SettleMarketplaceOrderSheet(invoice: latestInvoice), + ); + if (result != null) { + ref.refresh(invoicesProvider); + if (latestInvoice.id != null) { + ref.refresh(invoiceSettlementProvider(latestInvoice.id!)); + } + } + }, + icon: const Icon(LucideIcons.landmark, size: 16), + label: const Text('Settle Payout', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ] else ...[ + Expanded( + child: ElevatedButton.icon( + onPressed: () => _showReceivePaymentSheet(context, ref, latestInvoice), + icon: const Icon(LucideIcons.plusCircle, size: 16), + label: const Text('Record Payment', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: Colors.green, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ], + ], + ], + ), const SizedBox(height: 30), ], ), @@ -555,6 +782,23 @@ class _InvoiceDetailsScreenState extends ConsumerState { ); } + Color _getDispatchColor(String? status) { + switch (status?.toUpperCase()) { + case 'DELIVERED': + return Colors.green; + case 'SHIPPED': + case 'IN_TRANSIT': + return Colors.indigo; + case 'PACKED': + return Colors.blue; + case 'RTO': + return Colors.red; + case 'PENDING': + default: + return Colors.grey; + } + } + Widget _buildDetailBadge(String text, Color color) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), @@ -656,7 +900,9 @@ class _InvoiceDetailsScreenState extends ConsumerState { await Share.shareXFiles([XFile(imagePath.path)], text: 'Invoice $invoiceNumber'); } catch (e) { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing image: $e'))); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing image: $e'))); + } } } @@ -668,31 +914,28 @@ class _InvoiceDetailsScreenState extends ConsumerState { orElse: () => widget.invoice, ) ?? widget.invoice; + final pdf = pw.Document(); + final business = ref.read(businessProfileProvider).value; final customers = ref.read(customersProvider).value ?? []; final customer = customers.where((c) => c.id == latestInvoice.customerId).firstOrNull; - - 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 indianStates = ref.read(indianStatesProvider).value ?? []; + + final businessState = indianStates.where((s) => s.id == business?.stateId).firstOrNull; + final customerState = indianStates.where((s) => s.id == (latestInvoice.placeOfSupplyStateId ?? customer?.stateId)).firstOrNull; + + final isSameState = businessState != null && customerState != null && businessState.id == customerState.id; - final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2); final formatDate = DateFormat('dd MMM yyyy'); + final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2); - final font = await PdfGoogleFonts.robotoRegular(); - final boldFont = await PdfGoogleFonts.robotoBold(); - - final businessStateId = business?.stateId; - final customerStateId = customer?.stateId; - final isSameState = businessStateId != null && customerStateId != null - ? (businessStateId == customerStateId) - : true; - - // Group items by product ID if available, or itemize + // Group items by productId final Map> groupedItems = {}; final List ungroupedItems = []; - for (final item in latestInvoice.items) { + for (var item in latestInvoice.items) { if (item.productId != null) { groupedItems.putIfAbsent(item.productId!, () => []).add(item); } else { @@ -700,96 +943,64 @@ class _InvoiceDetailsScreenState extends ConsumerState { } } - final pdf = pw.Document(); - pdf.addPage( pw.MultiPage( pageFormat: PdfPageFormat.a4, - theme: pw.ThemeData.withFont( - base: font, - bold: boldFont, - ), - margin: const pw.EdgeInsets.all(28), - build: (pw.Context context) { - return [ - // 1. Header with Business Details & TAX INVOICE title - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Expanded( - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - business?.businessName ?? 'KIFI JEWELLERS', - style: pw.TextStyle( - fontSize: 22, - fontWeight: pw.FontWeight.bold, - color: PdfColors.blue900, - ), - ), - pw.SizedBox(height: 4), - if (business?.address != null && business!.address!.isNotEmpty) + margin: const pw.EdgeInsets.all(32), + build: (context) => [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + // 1. Enterprise Invoice Header + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ pw.Text( - business.address!, - style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), - ), - if (business?.contactNumber != null && business!.contactNumber!.isNotEmpty) - pw.Text( - 'Phone: ${business.contactNumber}', - style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), - ), - if (business?.gstin != null && business!.gstin!.isNotEmpty) - pw.Text( - 'GSTIN: ${business.gstin}', + business?.businessName ?? 'KIFI ENTERPRISE', style: pw.TextStyle( - fontSize: 9, + fontSize: 18, fontWeight: pw.FontWeight.bold, - color: PdfColors.blue800, + color: PdfColors.blue900, ), ), - ], + if (business?.address != null && business!.address!.isNotEmpty) + pw.Text( + business.address!, + style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), + ), + if (business?.contactNumber != null && business!.contactNumber!.isNotEmpty) + pw.Text( + 'Phone: ${business.contactNumber}', + style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), + ), + if (business?.gstin != null && business!.gstin!.isNotEmpty) + pw.Text( + 'GSTIN: ${business.gstin}', + style: pw.TextStyle( + fontSize: 9, + fontWeight: pw.FontWeight.bold, + color: PdfColors.blue800, + ), + ), + ], + ), ), - ), pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ pw.Text( 'TAX INVOICE', style: pw.TextStyle( - fontSize: 22, + fontSize: 18, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900, ), ), - pw.SizedBox(height: 6), - pw.Container( - padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: pw.BoxDecoration( - color: _getEffectiveStatus(latestInvoice) == 'PAID' - ? PdfColors.green100 - : (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange100 : PdfColors.grey200), - borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4)), - border: pw.Border.all( - color: _getEffectiveStatus(latestInvoice) == 'PAID' - ? PdfColors.green700 - : (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange700 : PdfColors.grey500), - width: 0.5, - ), - ), - child: pw.Text( - _getEffectiveStatus(latestInvoice), - style: pw.TextStyle( - fontSize: 9, - fontWeight: pw.FontWeight.bold, - color: _getEffectiveStatus(latestInvoice) == 'PAID' - ? PdfColors.green900 - : (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange900 : PdfColors.grey800), - ), - ), - ), - pw.SizedBox(height: 6), pw.Text( 'Invoice #: ${latestInvoice.invoiceNumber}', style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold), @@ -798,6 +1009,21 @@ class _InvoiceDetailsScreenState extends ConsumerState { 'Date: ${formatDate.format(latestInvoice.issueDate)}', style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), ), + if (latestInvoice.salesChannel != null && latestInvoice.salesChannel!.isNotEmpty) + pw.Text( + 'Channel: ${latestInvoice.salesChannel!}', + style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900), + ), + if (latestInvoice.marketplaceOrderId != null && latestInvoice.marketplaceOrderId!.isNotEmpty) + pw.Text( + 'Order Ref: #${latestInvoice.marketplaceOrderId!}', + style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700), + ), + if (latestInvoice.courierPartner != null || latestInvoice.trackingNumber != null) + pw.Text( + 'Courier: ${latestInvoice.courierPartner ?? ""}${latestInvoice.trackingNumber != null ? " (AWB: ${latestInvoice.trackingNumber})" : ""}', + style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700), + ), if (latestInvoice.dueDate != null) pw.Text( 'Due Date: ${formatDate.format(latestInvoice.dueDate!)}', @@ -809,7 +1035,7 @@ class _InvoiceDetailsScreenState extends ConsumerState { ), pw.SizedBox(height: 18), - // 2. Bill To Box (Customer Details) + // 2. Bill To & Ship To Box (Customer & Delivery Details) pw.Container( padding: const pw.EdgeInsets.all(10), decoration: pw.BoxDecoration( @@ -836,7 +1062,7 @@ class _InvoiceDetailsScreenState extends ConsumerState { pw.Text( customer?.name ?? 'Walk-in Customer', style: pw.TextStyle( - fontSize: 12, + fontSize: 11, fontWeight: pw.FontWeight.bold, ), ), @@ -852,6 +1078,38 @@ class _InvoiceDetailsScreenState extends ConsumerState { ], ), ), + if (latestInvoice.shippingAddress != null && latestInvoice.shippingAddress!.isNotEmpty) ...[ + pw.SizedBox(width: 14), + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'SHIP TO (DELIVERY):', + style: pw.TextStyle( + fontSize: 8.5, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey700, + ), + ), + pw.SizedBox(height: 2), + pw.Text( + customer?.name ?? 'Recipient', + style: pw.TextStyle( + fontSize: 11, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Text( + latestInvoice.shippingAddress! + (latestInvoice.shippingPincode != null ? ' - ${latestInvoice.shippingPincode}' : ''), + style: const pw.TextStyle(fontSize: 9), + ), + if (latestInvoice.courierPartner != null) + pw.Text('Via: ${latestInvoice.courierPartner}', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)), + ], + ), + ), + ], ], ), ), @@ -1421,10 +1679,11 @@ class _InvoiceDetailsScreenState extends ConsumerState { ), ], ), - ]; - }, - ), - ); + ], + ), + ], + ), + ); final pdfBytes = await pdf.save(); 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 80e55b8..e75e258 100644 --- a/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart @@ -5,8 +5,11 @@ import 'package:intl/intl.dart'; import '../providers/invoices_provider.dart'; import '../domain/invoice.dart'; import '../providers/customers_provider.dart'; +import '../providers/sales_channels_provider.dart'; import 'invoice_builder_screen.dart'; import 'invoice_details_screen.dart'; +import 'credit_notes_list_screen.dart'; +import 'marketplace_settlements_screen.dart'; class InvoicesListScreen extends ConsumerStatefulWidget { const InvoicesListScreen({super.key}); @@ -18,6 +21,8 @@ class InvoicesListScreen extends ConsumerStatefulWidget { class _InvoicesListScreenState extends ConsumerState { String _searchQuery = ''; final TextEditingController _searchController = TextEditingController(); + String? _selectedChannelFilter; + String? _selectedDispatchStatusFilter; String _getStatusLabel(Invoice invoice) { if (invoice.isEmi && invoice.status != 'PAID') return 'EMI'; @@ -47,6 +52,23 @@ class _InvoicesListScreenState extends ConsumerState { } } + Color _getDispatchStatusColor(String? status) { + switch (status?.toUpperCase()) { + case 'DELIVERED': + return Colors.green; + case 'SHIPPED': + case 'IN_TRANSIT': + return Colors.indigo; + case 'PACKED': + return Colors.blue; + case 'RTO': + return Colors.red; + case 'PENDING': + default: + return Colors.grey; + } + } + @override void dispose() { _searchController.dispose(); @@ -57,6 +79,7 @@ class _InvoicesListScreenState extends ConsumerState { Widget build(BuildContext context) { final invoicesState = ref.watch(invoicesProvider); final customersState = ref.watch(customersProvider); + final channelsState = ref.watch(salesChannelsProvider); final isDark = Theme.of(context).brightness == Brightness.dark; final formatCurrency = NumberFormat.currency(symbol: '₹'); final formatDate = DateFormat('MMM dd, yyyy'); @@ -72,38 +95,121 @@ class _InvoicesListScreenState extends ConsumerState { backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white, foregroundColor: isDark ? Colors.white : Colors.black, centerTitle: true, + actions: [ + IconButton( + icon: const Icon(LucideIcons.landmark), + tooltip: 'Marketplace Settlements & Fees', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const MarketplaceSettlementsScreen()), + ); + }, + ), + IconButton( + icon: const Icon(LucideIcons.undo2), + tooltip: 'Sales Returns & Credit Notes', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const CreditNotesListScreen()), + ); + }, + ), + ], ), body: Column( children: [ // Unified Search Header Container( color: isDark ? const Color(0xFF1E293B) : Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Search Invoice # or Customer...', - prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), - contentPadding: const EdgeInsets.symmetric(vertical: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Column( + children: [ + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search Invoice #, Order ID, Customer, Courier...', + prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), + contentPadding: const EdgeInsets.symmetric(vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300), + ), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(LucideIcons.xCircle, size: 20), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + ), + onChanged: (val) => setState(() => _searchQuery = val.trim()), ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300), + const SizedBox(height: 8), + + // Channel Filter Bar + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 6.0), + child: FilterChip( + label: const Text('All Channels', style: TextStyle(fontSize: 11)), + selected: _selectedChannelFilter == null, + onSelected: (selected) { + if (selected) setState(() => _selectedChannelFilter = null); + }, + ), + ), + ...(channelsState.value ?? []).map((channel) { + final isSelected = _selectedChannelFilter?.toLowerCase() == channel.name.toLowerCase(); + return Padding( + padding: const EdgeInsets.only(right: 6.0), + child: FilterChip( + label: Text(channel.name, style: const TextStyle(fontSize: 11)), + selected: isSelected, + onSelected: (selected) { + setState(() => _selectedChannelFilter = selected ? channel.name : null); + }, + ), + ); + }), + const SizedBox(width: 8), + // Quick Dispatch Status filters + Padding( + padding: const EdgeInsets.only(right: 6.0), + child: FilterChip( + label: const Text('Shipped / In Transit', style: TextStyle(fontSize: 11)), + selected: _selectedDispatchStatusFilter == 'SHIPPED', + selectedColor: Colors.indigo.withValues(alpha: 0.2), + onSelected: (selected) { + setState(() => _selectedDispatchStatusFilter = selected ? 'SHIPPED' : null); + }, + ), + ), + Padding( + padding: const EdgeInsets.only(right: 6.0), + child: FilterChip( + label: const Text('Delivered', style: TextStyle(fontSize: 11)), + selected: _selectedDispatchStatusFilter == 'DELIVERED', + selectedColor: Colors.green.withValues(alpha: 0.2), + onSelected: (selected) { + setState(() => _selectedDispatchStatusFilter = selected ? 'DELIVERED' : null); + }, + ), + ), + ], + ), ), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(LucideIcons.xCircle, size: 20), - onPressed: () { - _searchController.clear(); - setState(() => _searchQuery = ''); - }, - ) - : null, - ), - onChanged: (val) => setState(() => _searchQuery = val.trim()), + ], ), ), Expanded( @@ -111,10 +217,22 @@ class _InvoicesListScreenState extends ConsumerState { data: (invoices) { final customers = customersState.value ?? []; final filtered = invoices.where((inv) { - final matchesInv = inv.invoiceNumber.toLowerCase().contains(_searchQuery.toLowerCase()); + final q = _searchQuery.toLowerCase(); + final matchesInv = inv.invoiceNumber.toLowerCase().contains(q); + final matchesOrder = inv.marketplaceOrderId != null && inv.marketplaceOrderId!.toLowerCase().contains(q); + final matchesCourier = inv.courierPartner != null && inv.courierPartner!.toLowerCase().contains(q); + final matchesAwb = inv.trackingNumber != null && inv.trackingNumber!.toLowerCase().contains(q); final customer = customers.where((c) => c.id == inv.customerId).firstOrNull; - final matchesCust = customer != null && customer.name.toLowerCase().contains(_searchQuery.toLowerCase()); - return matchesInv || matchesCust; + final matchesCust = customer != null && customer.name.toLowerCase().contains(q); + final matchesSearch = q.isEmpty || matchesInv || matchesOrder || matchesCust || matchesCourier || matchesAwb; + + final matchesChannel = _selectedChannelFilter == null || + (inv.salesChannel != null && inv.salesChannel!.toLowerCase() == _selectedChannelFilter!.toLowerCase()); + + final matchesDispatch = _selectedDispatchStatusFilter == null || + (inv.dispatchStatus != null && inv.dispatchStatus!.toUpperCase() == _selectedDispatchStatusFilter!.toUpperCase()); + + return matchesSearch && matchesChannel && matchesDispatch; }).toList(); if (filtered.isEmpty) { @@ -226,6 +344,21 @@ class _InvoicesListScreenState extends ConsumerState { ), ), ], + if (invoice.marketplaceOrderId != null && invoice.marketplaceOrderId!.isNotEmpty) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.purple.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.purple.withValues(alpha: 0.25)), + ), + child: Text( + '#${invoice.marketplaceOrderId!}', + style: TextStyle(fontSize: 10.5, fontWeight: FontWeight.bold, color: Colors.purple.shade700), + ), + ), + ], ], ), ), @@ -294,12 +427,16 @@ class _InvoicesListScreenState extends ConsumerState { final weightStr = item.weight != null && item.weight! > 0 ? '${item.weight!.toStringAsFixed(3)} g' : '${item.quantity.toInt()} pcs'; - final name = item.productName ?? item.description ?? 'Jewellery Item'; + final name = item.productName ?? item.description ?? 'Item'; return Padding( padding: const EdgeInsets.symmetric(vertical: 2.0), child: Row( children: [ - Icon(LucideIcons.sparkles, size: 12, color: Colors.amber.shade700), + Icon( + item.weight != null && item.weight! > 0 ? LucideIcons.sparkles : LucideIcons.package, + size: 12, + color: item.weight != null && item.weight! > 0 ? Colors.amber.shade700 : Colors.blue.shade700, + ), const SizedBox(width: 6), Expanded( child: Text( @@ -329,6 +466,41 @@ class _InvoicesListScreenState extends ConsumerState { ), ), ], + + // Logistics / Shipping Tracker Row (if set) + if (invoice.courierPartner != null || invoice.trackingNumber != null || (invoice.dispatchStatus != null && invoice.dispatchStatus != 'PENDING')) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon(LucideIcons.truck, size: 14, color: Colors.indigo.shade600), + const SizedBox(width: 6), + Expanded( + child: Text( + '${invoice.courierPartner ?? "Courier"}${invoice.trackingNumber != null ? " • AWB: ${invoice.trackingNumber}" : ""}', + style: TextStyle(fontSize: 11.5, color: Colors.grey.shade700, fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5), + decoration: BoxDecoration( + color: _getDispatchStatusColor(invoice.dispatchStatus).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: _getDispatchStatusColor(invoice.dispatchStatus).withValues(alpha: 0.3)), + ), + child: Text( + invoice.dispatchStatus ?? 'PENDING', + style: TextStyle( + fontSize: 9.5, + fontWeight: FontWeight.bold, + color: _getDispatchStatusColor(invoice.dispatchStatus), + ), + ), + ), + ], + ), + ], const SizedBox(height: 10), // Row 3: Date & Total Amount diff --git a/kifi-app/lib/features/sales/presentation/marketplace_settlements_screen.dart b/kifi-app/lib/features/sales/presentation/marketplace_settlements_screen.dart new file mode 100644 index 0000000..30a69a2 --- /dev/null +++ b/kifi-app/lib/features/sales/presentation/marketplace_settlements_screen.dart @@ -0,0 +1,865 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import '../domain/marketplace_settlement.dart'; +import '../domain/invoice.dart'; +import '../providers/marketplace_settlements_provider.dart'; +import '../providers/invoices_provider.dart'; +import 'invoice_details_screen.dart'; +import 'widgets/settle_marketplace_order_sheet.dart'; + +class MarketplaceSettlementsScreen extends ConsumerStatefulWidget { + const MarketplaceSettlementsScreen({super.key}); + + @override + ConsumerState createState() => _MarketplaceSettlementsScreenState(); +} + +class _MarketplaceSettlementsScreenState extends ConsumerState with SingleTickerProviderStateMixin { + String _searchQuery = ''; + final TextEditingController _searchController = TextEditingController(); + String? _selectedChannelFilter; + late TabController _tabController; + + final formatCurrency = NumberFormat.currency(symbol: '₹'); + final formatDate = DateFormat('MMM dd, yyyy'); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _searchController.dispose(); + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final settlementsState = ref.watch(marketplaceSettlementsProvider); + final invoices = ref.watch(invoicesProvider).value ?? []; + final isDark = Theme.of(context).brightness == Brightness.dark; + + // Filter pending channel invoices (invoices that have a sales channel and are not fully settled yet) + final pendingChannelInvoices = invoices.where((inv) { + final isChannelOrder = inv.salesChannel != null && + inv.salesChannel!.isNotEmpty && + inv.salesChannel!.toUpperCase() != 'DIRECT_POS' && + inv.salesChannel!.toUpperCase() != 'STORE'; + final isUnsettled = inv.status != 'PAID' && inv.status != 'SETTLED' && inv.status != 'RETURNED'; + return isChannelOrder && isUnsettled; + }).toList(); + + return Scaffold( + backgroundColor: isDark ? const Color(0xFF090D16) : const Color(0xFFF4F6F9), + appBar: AppBar( + title: const Text('Marketplace Settlements & Fees', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), + elevation: 0, + backgroundColor: isDark ? const Color(0xFF131B2E) : Colors.white, + foregroundColor: isDark ? Colors.white : Colors.black87, + centerTitle: false, + actions: [ + IconButton( + icon: const Icon(LucideIcons.refreshCw, size: 18), + tooltip: 'Refresh Settlements', + onPressed: () { + ref.refresh(marketplaceSettlementsProvider); + ref.refresh(invoicesProvider); + }, + ), + const SizedBox(width: 8), + ], + ), + body: settlementsState.when( + data: (settlements) { + final filteredSettlements = settlements.where((s) { + final q = _searchQuery.toLowerCase(); + final invoice = invoices.where((i) => i.id == s.invoiceId).firstOrNull; + + final matchesRef = s.settlementRef?.toLowerCase().contains(q) ?? false; + final matchesChannel = s.channelName?.toLowerCase().contains(q) ?? false; + final matchesInvoice = invoice != null && + (invoice.invoiceNumber.toLowerCase().contains(q) || + (invoice.marketplaceOrderId?.toLowerCase().contains(q) ?? false)); + + final matchesSearch = q.isEmpty || matchesRef || matchesChannel || matchesInvoice; + final matchesFilter = _selectedChannelFilter == null || s.channelName == _selectedChannelFilter; + + return matchesSearch && matchesFilter; + }).toList(); + + // Aggregate Metrics + double totalGross = 0.0; + double totalNetPayout = 0.0; + double totalDeductions = 0.0; + double totalTcsTds = 0.0; + + for (var s in settlements) { + totalGross += s.grossAmount; + totalNetPayout += s.netPayoutAmount; + totalDeductions += s.totalDeductions; + totalTcsTds += (s.tcsGstAmount + s.tdsAmount); + } + + final uniqueChannels = settlements + .map((s) => s.channelName) + .whereType() + .toSet() + .toList(); + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1040), + child: CustomScrollView( + slivers: [ + // 1. Overview Header & Explanation Card + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: isDark + ? [const Color(0xFF1E293B), const Color(0xFF0F172A)] + : [Colors.white, const Color(0xFFF1F5F9)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.03), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.indigo.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(LucideIcons.landmark, color: Colors.indigo, size: 28), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Marketplace Payout & Tax Reconciliation Hub', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 4), + Text( + 'Reconcile actual bank remittances against marketplace orders (Amazon, Flipkart, Shopify). Automatically track referral commissions, shipping deductions, 18% fee GST, and claimable 1% TCS / 0.1% TDS tax credits.', + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.grey.shade400 : Colors.grey.shade600, + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + + // 2. Metric KPI Cards (2x2 Grid) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth > 700; + return isWide + ? Row( + children: [ + Expanded( + child: _buildMetricCard( + title: 'Gross Channel Sales', + value: formatCurrency.format(totalGross), + subtitle: 'Total invoiced order value', + icon: LucideIcons.shoppingBag, + color: Colors.blue, + isDark: isDark, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildMetricCard( + title: 'Net Bank Remittances', + value: formatCurrency.format(totalNetPayout), + subtitle: 'Deposited into bank accounts', + icon: LucideIcons.landmark, + color: const Color(0xFF10B981), + isDark: isDark, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildMetricCard( + title: 'Fee Deductions', + value: formatCurrency.format(totalDeductions), + subtitle: 'Commissions, shipping & 18% GST', + icon: LucideIcons.scissors, + color: Colors.orange, + isDark: isDark, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildMetricCard( + title: 'TCS & TDS Tax Credits', + value: formatCurrency.format(totalTcsTds), + subtitle: '1% GST TCS + 0.1% TDS 194O', + icon: LucideIcons.shieldCheck, + color: Colors.purple, + isDark: isDark, + ), + ), + ], + ) + : Column( + children: [ + Row( + children: [ + Expanded( + child: _buildMetricCard( + title: 'Gross Sales', + value: formatCurrency.format(totalGross), + subtitle: 'Invoiced value', + icon: LucideIcons.shoppingBag, + color: Colors.blue, + isDark: isDark, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildMetricCard( + title: 'Net Remitted', + value: formatCurrency.format(totalNetPayout), + subtitle: 'Bank deposits', + icon: LucideIcons.landmark, + color: const Color(0xFF10B981), + isDark: isDark, + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _buildMetricCard( + title: 'Fee Deductions', + value: formatCurrency.format(totalDeductions), + subtitle: 'Platform fees', + icon: LucideIcons.scissors, + color: Colors.orange, + isDark: isDark, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildMetricCard( + title: 'TCS / TDS Credits', + value: formatCurrency.format(totalTcsTds), + subtitle: 'Tax withholdings', + icon: LucideIcons.shieldCheck, + color: Colors.purple, + isDark: isDark, + ), + ), + ], + ), + ], + ); + }, + ), + ), + ), + + // 3. Tab Bar (Reconciled vs Unsettled Orders) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: TabBar( + controller: _tabController, + labelColor: Colors.blue, + unselectedLabelColor: isDark ? Colors.grey.shade400 : Colors.grey.shade600, + indicatorColor: Colors.blue, + indicatorWeight: 3, + tabs: [ + Tab( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(LucideIcons.checkCheck, size: 16), + const SizedBox(width: 8), + Text('Reconciled Settlements (${settlements.length})', style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ), + ), + Tab( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(LucideIcons.clock, size: 16), + const SizedBox(width: 8), + Text('Unsettled Channel Orders (${pendingChannelInvoices.length})', style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ), + ), + ], + onTap: (_) => setState(() {}), + ), + ), + ), + ), + + // 4. Tab Content View + _tabController.index == 0 + ? _buildReconciledTab( + context, + filteredSettlements, + invoices, + uniqueChannels, + isDark, + onSwitchToPending: () { + _tabController.animateTo(1); + setState(() {}); + }, + ) + : _buildUnsettledOrdersTab( + context, + pendingChannelInvoices, + isDark, + ), + ], + ), + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, stack) => Center(child: Text('Error: $e')), + ), + ); + } + + Widget _buildReconciledTab( + BuildContext context, + List settlements, + List invoices, + List uniqueChannels, + bool isDark, { + required VoidCallback onSwitchToPending, + }) { + if (settlements.isEmpty) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: const Icon(LucideIcons.fileSpreadsheet, size: 40, color: Colors.blue), + ), + const SizedBox(height: 16), + const Text( + 'No Reconciled Settlements Yet', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + const SizedBox(height: 8), + Text( + 'When you receive platform payout reports from Amazon, Flipkart, or Shopify, reconcile them here to record fee deductions, claim TCS/TDS, and update your bank balance.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.grey.shade400 : Colors.grey.shade600, + ), + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: onSwitchToPending, + icon: const Icon(LucideIcons.arrowRight, size: 16), + label: const Text('View Unsettled Channel Orders', style: TextStyle(fontWeight: FontWeight.bold)), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ], + ), + ), + ), + ); + } + + return SliverList( + delegate: SliverChildListDelegate([ + // Search & Channel Filter Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + children: [ + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search by Order ID, Bank UTR, or Channel...', + prefixIcon: const Icon(LucideIcons.search, size: 18, color: Colors.grey), + contentPadding: const EdgeInsets.symmetric(vertical: 8), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + filled: true, + fillColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF1F5F9), + isDense: true, + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(LucideIcons.xCircle, size: 16), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + ), + onChanged: (val) => setState(() => _searchQuery = val.trim()), + ), + if (uniqueChannels.isNotEmpty) ...[ + const SizedBox(height: 8), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + FilterChip( + label: const Text('All Channels', style: TextStyle(fontSize: 12)), + selected: _selectedChannelFilter == null, + onSelected: (selected) { + if (selected) setState(() => _selectedChannelFilter = null); + }, + ), + const SizedBox(width: 8), + ...uniqueChannels.map((ch) { + final isSelected = _selectedChannelFilter == ch; + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: FilterChip( + label: Text(ch, style: const TextStyle(fontSize: 12)), + selected: isSelected, + onSelected: (selected) { + setState(() => _selectedChannelFilter = selected ? ch : null); + }, + ), + ); + }), + ], + ), + ), + ], + ], + ), + ), + ), + + // Settlements List Cards + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Column( + children: settlements.map((s) { + final invoice = invoices.where((i) => i.id == s.invoiceId).firstOrNull; + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.02), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + s.channelName ?? 'Marketplace', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.blue), + ), + ), + if (s.settlementRef != null) ...[ + const SizedBox(width: 10), + Text( + 'UTR: ${s.settlementRef}', + style: TextStyle(fontSize: 12, color: isDark ? Colors.grey.shade400 : Colors.grey.shade600), + ), + ], + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF10B981).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + Icon(LucideIcons.checkCheck, size: 12, color: Color(0xFF10B981)), + SizedBox(width: 4), + Text('Reconciled', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Color(0xFF10B981))), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + + if (invoice != null) ...[ + InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => InvoiceDetailsScreen(invoice: invoice)), + ); + }, + child: Row( + children: [ + const Icon(LucideIcons.fileText, size: 15, color: Colors.blue), + const SizedBox(width: 6), + Text( + 'Invoice #${invoice.invoiceNumber} • Order #${invoice.marketplaceOrderId ?? "-"}', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.blue), + ), + const Spacer(), + const Icon(LucideIcons.chevronRight, size: 16, color: Colors.blue), + ], + ), + ), + const SizedBox(height: 12), + ], + + // Deductions breakdown strip + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildDetailStat('Gross Value', formatCurrency.format(s.grossAmount)), + _buildDetailStat('Deductions', '- ${formatCurrency.format(s.totalDeductions)}', isRed: true), + _buildDetailStat('Net Remitted', formatCurrency.format(s.netPayoutAmount), isGreen: true), + ], + ), + ), + const SizedBox(height: 8), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Settled on ${formatDate.format(s.settlementDate)}', + style: TextStyle(fontSize: 12, color: isDark ? Colors.grey.shade400 : Colors.grey.shade600), + ), + if (s.tcsGstAmount > 0 || s.tdsAmount > 0) + Text( + 'TCS/TDS Claimable: ${formatCurrency.format(s.tcsGstAmount + s.tdsAmount)}', + style: TextStyle(fontSize: 12, color: Colors.purple.shade700, fontWeight: FontWeight.bold), + ), + ], + ), + ], + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 40), + ]), + ); + } + + Widget _buildUnsettledOrdersTab( + BuildContext context, + List pendingInvoices, + bool isDark, + ) { + if (pendingInvoices.isEmpty) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF10B981).withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: const Icon(LucideIcons.checkCheck, size: 40, color: Color(0xFF10B981)), + ), + const SizedBox(height: 16), + const Text( + 'All Marketplace Orders Reconciled!', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + const SizedBox(height: 8), + Text( + 'There are currently no outstanding marketplace invoices waiting for bank payout reconciliation.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.grey.shade400 : Colors.grey.shade600, + ), + ), + ], + ), + ), + ), + ); + } + + return SliverList( + delegate: SliverChildListDelegate([ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + 'The following invoices are attributed to online channels and have not been reconciled with a bank payout:', + style: TextStyle(fontSize: 13, color: isDark ? Colors.grey.shade400 : Colors.grey.shade600), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: pendingInvoices.map((inv) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + inv.salesChannel ?? 'Marketplace', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.blue), + ), + ), + const SizedBox(width: 8), + Text( + 'Order #${inv.marketplaceOrderId ?? "-"}', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Invoice #${inv.invoiceNumber}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + ), + const SizedBox(height: 4), + Text( + 'Invoice Date: ${formatDate.format(inv.issueDate)} • Gross Amount: ${formatCurrency.format(inv.totalAmount)}', + style: TextStyle(fontSize: 12, color: isDark ? Colors.grey.shade400 : Colors.grey.shade600), + ), + ], + ), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: () async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) => SettleMarketplaceOrderSheet(invoice: inv), + ); + if (result != null) { + ref.refresh(marketplaceSettlementsProvider); + ref.refresh(invoicesProvider); + } + }, + icon: const Icon(LucideIcons.landmark, size: 14), + label: const Text('Settle Payout', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 40), + ]), + ); + } + + Widget _buildMetricCard({ + required String title, + required String value, + required String subtitle, + required IconData icon, + required Color color, + required bool isDark, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: color.withValues(alpha: 0.2)), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: isDark ? 0.1 : 0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: color), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isDark ? Colors.grey.shade400 : Colors.grey.shade700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + value, + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle(fontSize: 11, color: isDark ? Colors.grey.shade500 : Colors.grey.shade500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } + + Widget _buildDetailStat(String label, String value, {bool isGreen = false, bool isRed = false}) { + Color valColor = Colors.black87; + if (isGreen) valColor = const Color(0xFF10B981); + if (isRed) valColor = Colors.red; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 11, color: Colors.grey)), + const SizedBox(height: 3), + Text( + value, + style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: valColor), + ), + ], + ); + } +} diff --git a/kifi-app/lib/features/sales/presentation/widgets/create_credit_note_sheet.dart b/kifi-app/lib/features/sales/presentation/widgets/create_credit_note_sheet.dart new file mode 100644 index 0000000..ed91ac4 --- /dev/null +++ b/kifi-app/lib/features/sales/presentation/widgets/create_credit_note_sheet.dart @@ -0,0 +1,594 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import '../../domain/invoice.dart'; +import '../../domain/credit_note.dart'; +import '../../../transactions/providers/providers.dart'; +import '../../providers/credit_notes_provider.dart'; + +class CreateCreditNoteSheet extends ConsumerStatefulWidget { + final Invoice invoice; + + const CreateCreditNoteSheet({ + super.key, + required this.invoice, + }); + + @override + ConsumerState createState() => _CreateCreditNoteSheetState(); +} + +class _CreateCreditNoteSheetState extends ConsumerState { + final _creditNoteNumberCtrl = TextEditingController(); + final _notesCtrl = TextEditingController(); + + DateTime _returnDate = DateTime.now(); + String _reason = 'CUSTOMER_RETURN'; + bool _restockItems = true; + String _refundStatus = 'ADJUSTED_TO_AR'; + int? _selectedWalletId; + bool _isLoading = false; + + // Selected item IDs and their return quantities + final Set _selectedItemIndices = {}; + final Map _returnQuantities = {}; + + final formatCurrency = NumberFormat.currency(symbol: '₹'); + final formatDate = DateFormat('MMM dd, yyyy'); + + @override + void initState() { + super.initState(); + _creditNoteNumberCtrl.text = 'CN-${DateTime.now().millisecondsSinceEpoch.toString().substring(6)}'; + + // Select all items by default for convenience + for (int i = 0; i < widget.invoice.items.length; i++) { + _selectedItemIndices.add(i); + final item = widget.invoice.items[i]; + final qty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0); + _returnQuantities[i] = qty; + } + } + + @override + void dispose() { + _creditNoteNumberCtrl.dispose(); + _notesCtrl.dispose(); + super.dispose(); + } + + double _calculateReturnSubtotal() { + double subtotal = 0.0; + for (var idx in _selectedItemIndices) { + if (idx < widget.invoice.items.length) { + final item = widget.invoice.items[idx]; + final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0); + final retQty = _returnQuantities[idx] ?? maxQty; + final ratio = maxQty > 0 ? (retQty / maxQty).clamp(0.0, 1.0) : 1.0; + subtotal += (item.unitPrice * retQty) + (item.makingCharge * ratio); + } + } + return subtotal; + } + + double _calculateReturnTax() { + double tax = 0.0; + for (var idx in _selectedItemIndices) { + if (idx < widget.invoice.items.length) { + final item = widget.invoice.items[idx]; + final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0); + final retQty = _returnQuantities[idx] ?? maxQty; + final ratio = maxQty > 0 ? (retQty / maxQty).clamp(0.0, 1.0) : 1.0; + final taxable = (item.unitPrice * retQty) + (item.makingCharge * ratio) + (item.otherCharges * ratio) - (item.discount * ratio); + tax += (taxable * item.taxRate) / 100.0; + } + } + return tax; + } + + double _calculateReturnTotal() { + double total = 0.0; + for (var idx in _selectedItemIndices) { + if (idx < widget.invoice.items.length) { + final item = widget.invoice.items[idx]; + final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0); + final retQty = _returnQuantities[idx] ?? maxQty; + final ratio = maxQty > 0 ? (retQty / maxQty).clamp(0.0, 1.0) : 1.0; + total += item.total * ratio; + } + } + return total; + } + + Future _submitCreditNote() async { + if (_selectedItemIndices.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please select at least one item to return.')), + ); + return; + } + + setState(() => _isLoading = true); + + try { + final List creditNoteItems = []; + double subtotal = 0.0; + double cgstTotal = 0.0; + double sgstTotal = 0.0; + double igstTotal = 0.0; + double taxTotal = 0.0; + double discountTotal = 0.0; + double totalAmount = 0.0; + + for (var idx in _selectedItemIndices) { + final item = widget.invoice.items[idx]; + final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0); + final retQty = _returnQuantities[idx] ?? maxQty; + final ratio = maxQty > 0 ? (retQty / maxQty).clamp(0.0, 1.0) : 1.0; + + final lineTotal = item.total * ratio; + final lineCgst = item.cgst * ratio; + final lineSgst = item.sgst * ratio; + final lineIgst = item.igst * ratio; + final lineDiscount = item.discount * ratio; + + subtotal += (item.unitPrice * retQty); + cgstTotal += lineCgst; + sgstTotal += lineSgst; + igstTotal += lineIgst; + taxTotal += (lineCgst + lineSgst + lineIgst); + discountTotal += lineDiscount; + totalAmount += lineTotal; + + creditNoteItems.add(CreditNoteItem( + invoiceItemId: item.id, + productId: item.productId, + inventoryItemId: item.inventoryItemId, + productName: item.productName ?? item.description, + sku: item.sku, + huid: item.huid, + hsnCode: item.hsnCode, + quantity: item.weight != null && item.weight! > 0 ? 1.0 : retQty, + weight: item.weight != null && item.weight! > 0 ? retQty : null, + unitPrice: item.unitPrice, + taxRate: item.taxRate, + cgst: lineCgst, + sgst: lineSgst, + igst: lineIgst, + discount: lineDiscount, + makingCharge: item.makingCharge * ratio, + otherCharges: item.otherCharges * ratio, + total: lineTotal, + )); + } + + final creditNote = CreditNote( + invoiceId: widget.invoice.id!, + creditNoteNumber: _creditNoteNumberCtrl.text.trim().isNotEmpty + ? _creditNoteNumberCtrl.text.trim() + : 'CN-${DateTime.now().millisecondsSinceEpoch.toString().substring(6)}', + customerId: widget.invoice.customerId, + returnDate: _returnDate, + reason: _reason, + restockItems: _restockItems, + subtotal: double.parse(subtotal.toStringAsFixed(2)), + taxTotal: double.parse(taxTotal.toStringAsFixed(2)), + cgstTotal: double.parse(cgstTotal.toStringAsFixed(2)), + sgstTotal: double.parse(sgstTotal.toStringAsFixed(2)), + igstTotal: double.parse(igstTotal.toStringAsFixed(2)), + discountTotal: double.parse(discountTotal.toStringAsFixed(2)), + totalAmount: double.parse(totalAmount.toStringAsFixed(2)), + refundStatus: _refundStatus, + refundWalletId: _refundStatus == 'REFUNDED_TO_WALLET' ? _selectedWalletId : null, + notes: _notesCtrl.text.trim().isNotEmpty ? _notesCtrl.text.trim() : null, + items: creditNoteItems, + ); + + final result = await ref + .read(creditNotesProvider.notifier) + .createCreditNote(widget.invoice.id!, creditNote); + + if (mounted) { + Navigator.pop(context, result); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Credit Note #${creditNote.creditNoteNumber} issued successfully!'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error creating credit note: $e'), backgroundColor: Colors.red), + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final wallets = ref.watch(walletProvider).value ?? []; + final returnTotal = _calculateReturnTotal(); + + final returnReasons = [ + {'code': 'CUSTOMER_RETURN', 'label': 'Customer Return', 'icon': LucideIcons.rotateCcw}, + {'code': 'RTO_UNDELIVERED', 'label': 'RTO / Courier Undelivered', 'icon': LucideIcons.truck}, + {'code': 'DEFECTIVE', 'label': 'Defective / Damaged', 'icon': LucideIcons.shieldAlert}, + {'code': 'EXCHANGE', 'label': 'Exchange / Replacement', 'icon': LucideIcons.refreshCw}, + {'code': 'OTHER', 'label': 'Other', 'icon': LucideIcons.fileText}, + ]; + + return Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF0F172A) : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + left: 20, + right: 20, + top: 20, + ), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.9, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Bar + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(LucideIcons.undo2, color: Colors.red, size: 20), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Sales Return & Credit Note', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + ), + Text( + 'Invoice #${widget.invoice.invoiceNumber}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + ], + ), + IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 16), + + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Return Meta Card (Credit Note #, Return Date) + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade50, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + controller: _creditNoteNumberCtrl, + decoration: InputDecoration( + labelText: 'Credit Note #', + prefixIcon: const Icon(LucideIcons.hash, size: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: _returnDate, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 30)), + ); + if (picked != null) setState(() => _returnDate = picked); + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Return Date', + prefixIcon: const Icon(LucideIcons.calendar, size: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + ), + child: Text(formatDate.format(_returnDate), style: const TextStyle(fontSize: 13)), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + // Reason Selector + DropdownButtonFormField( + value: _reason, + decoration: InputDecoration( + labelText: 'Return Reason', + prefixIcon: const Icon(LucideIcons.helpCircle, size: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + ), + items: returnReasons.map((r) { + return DropdownMenuItem( + value: r['code'] as String, + child: Row( + children: [ + Icon(r['icon'] as IconData, size: 15, color: Colors.red.shade700), + const SizedBox(width: 8), + Text(r['label'] as String, style: const TextStyle(fontSize: 13)), + ], + ), + ); + }).toList(), + onChanged: (val) { + if (val != null) setState(() => _reason = val); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + + // Items Selection + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Select Items to Return', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + TextButton.icon( + onPressed: () { + setState(() { + if (_selectedItemIndices.length == widget.invoice.items.length) { + _selectedItemIndices.clear(); + } else { + _selectedItemIndices.addAll(List.generate(widget.invoice.items.length, (i) => i)); + } + }); + }, + icon: Icon( + _selectedItemIndices.length == widget.invoice.items.length ? LucideIcons.checkSquare : LucideIcons.square, + size: 14, + ), + label: Text( + _selectedItemIndices.length == widget.invoice.items.length ? 'Deselect All' : 'Select All', + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: 6), + + // Item list tiles + ...widget.invoice.items.asMap().entries.map((entry) { + final idx = entry.key; + final item = entry.value; + final isSelected = _selectedItemIndices.contains(idx); + final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0); + final isWeight = item.weight != null && item.weight! > 0; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: isSelected + ? Colors.red.withValues(alpha: isDark ? 0.1 : 0.04) + : (isDark ? const Color(0xFF1E293B) : Colors.white), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? Colors.red.withValues(alpha: 0.4) : (isDark ? Colors.white10 : Colors.grey.shade200), + ), + ), + child: Row( + children: [ + Checkbox( + value: isSelected, + activeColor: Colors.red, + onChanged: (val) { + setState(() { + if (val == true) { + _selectedItemIndices.add(idx); + } else { + _selectedItemIndices.remove(idx); + } + }); + }, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.productName ?? item.description ?? 'Product Item', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + 'Sold: ${isWeight ? "${item.weight!.toStringAsFixed(3)} g" : "${item.quantity.toInt()} pcs"} • Rate: ${formatCurrency.format(item.unitPrice)}', + style: TextStyle(fontSize: 11, color: Colors.grey.shade600), + ), + ], + ), + ), + Text( + formatCurrency.format(item.total), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + ], + ), + ); + }), + const SizedBox(height: 14), + + // Restock Switch + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + activeColor: Colors.green, + title: const Text('Restock returned items to inventory', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)), + subtitle: const Text('Re-inward weights & items back to available stock', style: TextStyle(fontSize: 11)), + value: _restockItems, + onChanged: (val) => setState(() => _restockItems = val), + ), + ), + const SizedBox(height: 14), + + // Settlement / Refund Method + const Text('Settlement / Refund Method', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 6), + DropdownButtonFormField( + value: _refundStatus, + decoration: InputDecoration( + prefixIcon: const Icon(LucideIcons.wallet, size: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + ), + items: const [ + DropdownMenuItem( + value: 'ADJUSTED_TO_AR', + child: Text('Adjust Customer Outstanding (Credit Note)', style: TextStyle(fontSize: 13)), + ), + DropdownMenuItem( + value: 'REFUNDED_TO_WALLET', + child: Text('Refund to Cash / Bank Account', style: TextStyle(fontSize: 13)), + ), + DropdownMenuItem( + value: 'STORE_CREDIT', + child: Text('Issue Store Credit', style: TextStyle(fontSize: 13)), + ), + ], + onChanged: (val) { + if (val != null) setState(() => _refundStatus = val); + }, + ), + if (_refundStatus == 'REFUNDED_TO_WALLET') ...[ + const SizedBox(height: 10), + DropdownButtonFormField( + value: _selectedWalletId ?? (wallets.isNotEmpty ? wallets.first.id : null), + decoration: InputDecoration( + labelText: 'Payout From Account', + prefixIcon: const Icon(LucideIcons.arrowUpRight, size: 16, color: Colors.red), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + ), + items: wallets.map((w) { + return DropdownMenuItem( + value: w.id, + child: Text('${w.name} (${w.nature})', style: const TextStyle(fontSize: 13)), + ); + }).toList(), + onChanged: (val) => setState(() => _selectedWalletId = val), + ), + ], + const SizedBox(height: 14), + + // Remarks + TextFormField( + controller: _notesCtrl, + maxLines: 2, + decoration: InputDecoration( + labelText: 'Return Remarks / Notes (Optional)', + prefixIcon: const Icon(LucideIcons.alignLeft, size: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + ), + ), + const SizedBox(height: 20), + + // Total Refund Summary Box + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.red.withValues(alpha: 0.25)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Credit Note Value:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + Text( + formatCurrency.format(returnTotal), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.red), + ), + ], + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + + // Submit Action Button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isLoading ? null : _submitCreditNote, + icon: _isLoading + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(LucideIcons.checkCircle2, size: 18), + label: Text( + _isLoading ? 'Processing Return...' : 'Issue Credit Note (${formatCurrency.format(returnTotal)})', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade700, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/sales/presentation/widgets/settle_marketplace_order_sheet.dart b/kifi-app/lib/features/sales/presentation/widgets/settle_marketplace_order_sheet.dart new file mode 100644 index 0000000..e6e291f --- /dev/null +++ b/kifi-app/lib/features/sales/presentation/widgets/settle_marketplace_order_sheet.dart @@ -0,0 +1,508 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import '../../domain/invoice.dart'; +import '../../domain/marketplace_settlement.dart'; +import '../../providers/marketplace_settlements_provider.dart'; +import '../../../transactions/providers/providers.dart'; + +class SettleMarketplaceOrderSheet extends ConsumerStatefulWidget { + final Invoice invoice; + + const SettleMarketplaceOrderSheet({ + super.key, + required this.invoice, + }); + + @override + ConsumerState createState() => _SettleMarketplaceOrderSheetState(); +} + +class _SettleMarketplaceOrderSheetState extends ConsumerState { + final _commissionCtrl = TextEditingController(); + final _shippingFeeCtrl = TextEditingController(); + final _otherFeesCtrl = TextEditingController(); + final _feeGstCtrl = TextEditingController(); + final _tcsGstCtrl = TextEditingController(); + final _tdsCtrl = TextEditingController(); + final _settlementRefCtrl = TextEditingController(); + final _notesCtrl = TextEditingController(); + + DateTime _settlementDate = DateTime.now(); + int? _selectedWalletId; + bool _isLoading = false; + + final formatCurrency = NumberFormat.currency(symbol: '₹'); + final formatDate = DateFormat('MMM dd, yyyy'); + + @override + void initState() { + super.initState(); + final gross = widget.invoice.totalAmount; + + // Auto-calculate suggested TCS (1% of net taxable) and TDS (0.1% of gross) + final suggestedTcs = double.parse(((gross * 0.01)).toStringAsFixed(2)); + final suggestedTds = double.parse(((gross * 0.001)).toStringAsFixed(2)); + + _tcsGstCtrl.text = suggestedTcs > 0 ? suggestedTcs.toString() : '0'; + _tdsCtrl.text = suggestedTds > 0 ? suggestedTds.toString() : '0'; + + _commissionCtrl.addListener(_autoUpdateGstOnFees); + _shippingFeeCtrl.addListener(_autoUpdateGstOnFees); + _otherFeesCtrl.addListener(_autoUpdateGstOnFees); + } + + void _autoUpdateGstOnFees() { + final comm = double.tryParse(_commissionCtrl.text) ?? 0.0; + final ship = double.tryParse(_shippingFeeCtrl.text) ?? 0.0; + final other = double.tryParse(_otherFeesCtrl.text) ?? 0.0; + final taxableFees = comm + ship + other; + if (taxableFees > 0) { + final gst = double.parse((taxableFees * 0.18).toStringAsFixed(2)); + _feeGstCtrl.text = gst.toString(); + } + setState(() {}); + } + + @override + void dispose() { + _commissionCtrl.dispose(); + _shippingFeeCtrl.dispose(); + _otherFeesCtrl.dispose(); + _feeGstCtrl.dispose(); + _tcsGstCtrl.dispose(); + _tdsCtrl.dispose(); + _settlementRefCtrl.dispose(); + _notesCtrl.dispose(); + super.dispose(); + } + + double _calculateTotalDeductions() { + final comm = double.tryParse(_commissionCtrl.text) ?? 0.0; + final ship = double.tryParse(_shippingFeeCtrl.text) ?? 0.0; + final other = double.tryParse(_otherFeesCtrl.text) ?? 0.0; + final feeGst = double.tryParse(_feeGstCtrl.text) ?? 0.0; + final tcs = double.tryParse(_tcsGstCtrl.text) ?? 0.0; + final tds = double.tryParse(_tdsCtrl.text) ?? 0.0; + return comm + ship + other + feeGst + tcs + tds; + } + + double _calculateNetPayout() { + return widget.invoice.totalAmount - _calculateTotalDeductions(); + } + + Future _submitSettlement() async { + setState(() => _isLoading = true); + + try { + final comm = double.tryParse(_commissionCtrl.text) ?? 0.0; + final ship = double.tryParse(_shippingFeeCtrl.text) ?? 0.0; + final other = double.tryParse(_otherFeesCtrl.text) ?? 0.0; + final feeGst = double.tryParse(_feeGstCtrl.text) ?? 0.0; + final tcs = double.tryParse(_tcsGstCtrl.text) ?? 0.0; + final tds = double.tryParse(_tdsCtrl.text) ?? 0.0; + final totalDeductions = _calculateTotalDeductions(); + final netPayout = _calculateNetPayout(); + + final settlement = MarketplaceSettlement( + invoiceId: widget.invoice.id!, + salesChannelId: widget.invoice.salesChannelId, + channelName: widget.invoice.salesChannel ?? 'Marketplace', + settlementRef: _settlementRefCtrl.text.trim().isNotEmpty ? _settlementRefCtrl.text.trim() : null, + settlementDate: _settlementDate, + grossAmount: widget.invoice.totalAmount, + commissionFee: comm, + shippingFee: ship, + otherFees: other, + feeGst: feeGst, + tcsGstAmount: tcs, + tdsAmount: tds, + totalDeductions: totalDeductions, + netPayoutAmount: netPayout, + payoutWalletId: _selectedWalletId, + status: 'SETTLED', + notes: _notesCtrl.text.trim().isNotEmpty ? _notesCtrl.text.trim() : null, + ); + + final result = await ref + .read(marketplaceSettlementsProvider.notifier) + .createSettlement(widget.invoice.id!, settlement); + + if (mounted) { + Navigator.pop(context, result); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Marketplace settlement for Invoice #${widget.invoice.invoiceNumber} recorded successfully!'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error recording settlement: $e'), backgroundColor: Colors.red), + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final wallets = ref.watch(walletProvider).value ?? []; + final bankWallets = wallets.where((w) => w.nature == 'SAVINGS' || w.nature == 'CURRENT' || w.nature == 'BANK' || w.nature == 'CASH').toList(); + final effectiveWallets = bankWallets.isNotEmpty ? bankWallets : wallets; + + final totalDeductions = _calculateTotalDeductions(); + final netPayout = _calculateNetPayout(); + + return Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF0F172A) : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + left: 20, + right: 20, + top: 20, + ), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.9, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Bar + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.indigo.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(LucideIcons.landmark, color: Colors.indigo, size: 20), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Marketplace Settlement', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + ), + Text( + '${widget.invoice.salesChannel ?? "Marketplace"} • Order #${widget.invoice.marketplaceOrderId ?? widget.invoice.invoiceNumber}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + ], + ), + IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 16), + + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Gross Invoice Value Banner + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade50, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Gross Invoice Value', style: TextStyle(fontSize: 12, color: Colors.grey)), + const SizedBox(height: 2), + Text( + formatCurrency.format(widget.invoice.totalAmount), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + widget.invoice.salesChannel ?? 'Marketplace', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.blue), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Fee Deductions Breakdown + const Text('Channel Fee Deductions', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 10), + + Row( + children: [ + Expanded( + child: TextFormField( + controller: _commissionCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'Referral / Commission', + prefixText: '₹ ', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextFormField( + controller: _shippingFeeCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'Logistics / Shipping', + prefixText: '₹ ', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + ), + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + const SizedBox(height: 12), + + Row( + children: [ + Expanded( + child: TextFormField( + controller: _otherFeesCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'Closing / Fixed Fees', + prefixText: '₹ ', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextFormField( + controller: _feeGstCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'GST on Fees (18%)', + prefixText: '₹ ', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + ), + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Statutory Tax Deductions (TCS & TDS) + const Text('Tax Withholdings (TCS & TDS)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 10), + + Row( + children: [ + Expanded( + child: TextFormField( + controller: _tcsGstCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'GST TCS (1%)', + prefixText: '₹ ', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + helperText: 'Tax Asset Claimable in GST', + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextFormField( + controller: _tdsCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'TDS u/s 194O (0.1%)', + prefixText: '₹ ', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + helperText: 'Claimable in Income Tax', + ), + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Net Payout Calculation Summary Box + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF10B981).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFF10B981).withValues(alpha: 0.3)), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Total Marketplace Deductions:', style: TextStyle(fontSize: 13, color: Colors.grey)), + Text( + '- ${formatCurrency.format(totalDeductions)}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.red), + ), + ], + ), + const Divider(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Net Bank Remittance Payout:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + Text( + formatCurrency.format(netPayout), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Color(0xFF10B981)), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + + // Bank & Settlement Reference Details + const Text('Payout Deposit Details', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 10), + + DropdownButtonFormField( + value: _selectedWalletId ?? (effectiveWallets.isNotEmpty ? effectiveWallets.first.id : null), + decoration: InputDecoration( + labelText: 'Bank / Payout Account', + prefixIcon: const Icon(LucideIcons.landmark, size: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + ), + items: effectiveWallets.map((w) { + return DropdownMenuItem( + value: w.id, + child: Text('${w.name} (${w.nature ?? "Bank"})', style: const TextStyle(fontSize: 13)), + ); + }).toList(), + onChanged: (val) => setState(() => _selectedWalletId = val), + ), + const SizedBox(height: 12), + + Row( + children: [ + Expanded( + child: TextFormField( + controller: _settlementRefCtrl, + decoration: InputDecoration( + labelText: 'Bank UTR / Payout Ref', + prefixIcon: const Icon(LucideIcons.hash, size: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: _settlementDate, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 30)), + ); + if (picked != null) setState(() => _settlementDate = picked); + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Payout Date', + prefixIcon: const Icon(LucideIcons.calendar, size: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + ), + child: Text(formatDate.format(_settlementDate), style: const TextStyle(fontSize: 13)), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + TextFormField( + controller: _notesCtrl, + maxLines: 2, + decoration: InputDecoration( + labelText: 'Settlement Notes / Remarks (Optional)', + prefixIcon: const Icon(LucideIcons.alignLeft, size: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + isDense: true, + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + + // Submit Button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isLoading ? null : _submitSettlement, + icon: _isLoading + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(LucideIcons.checkCheck, size: 18), + label: Text( + _isLoading ? 'Reconciling Payout...' : 'Confirm Settlement (${formatCurrency.format(netPayout)})', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/sales/providers/credit_notes_provider.dart b/kifi-app/lib/features/sales/providers/credit_notes_provider.dart new file mode 100644 index 0000000..07e4fdb --- /dev/null +++ b/kifi-app/lib/features/sales/providers/credit_notes_provider.dart @@ -0,0 +1,75 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; +import '../../../core/network/dio_client.dart'; +import '../domain/credit_note.dart'; +import 'invoices_provider.dart'; + +class CreditNotesNotifier extends AsyncNotifier> { + @override + FutureOr> build() async { + return _fetchCreditNotes(); + } + + Future> _fetchCreditNotes() async { + try { + final response = await DioClient().dio.get('/credit-notes'); + if (response.statusCode == 200) { + final List data = response.data; + return data.map((e) => CreditNote.fromJson(e)).toList(); + } + return []; + } catch (e) { + return []; + } + } + + Future refresh() async { + state = const AsyncValue.loading(); + try { + final creditNotes = await _fetchCreditNotes(); + state = AsyncValue.data(creditNotes); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + } + } + + Future createCreditNote(int invoiceId, CreditNote creditNote) async { + try { + final response = await DioClient().dio.post( + '/invoices/$invoiceId/credit-notes', + data: creditNote.toJson(), + ); + await refresh(); + ref.refresh(invoicesProvider); + if (response.data != null) { + return CreditNote.fromJson(response.data); + } + return null; + } catch (e) { + if (e is DioException) { + throw Exception( + 'Failed to process return: ${e.response?.statusCode} - ${e.response?.data}', + ); + } + throw Exception('Failed to process return: $e'); + } + } +} + +final creditNotesProvider = + AsyncNotifierProvider>(CreditNotesNotifier.new); + +final invoiceCreditNotesProvider = + FutureProvider.family, int>((ref, invoiceId) async { + try { + final response = await DioClient().dio.get('/invoices/$invoiceId/credit-notes'); + if (response.statusCode == 200) { + final List data = response.data; + return data.map((e) => CreditNote.fromJson(e)).toList(); + } + return []; + } catch (e) { + return []; + } +}); diff --git a/kifi-app/lib/features/sales/providers/marketplace_settlements_provider.dart b/kifi-app/lib/features/sales/providers/marketplace_settlements_provider.dart new file mode 100644 index 0000000..ce43ecb --- /dev/null +++ b/kifi-app/lib/features/sales/providers/marketplace_settlements_provider.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; +import '../../../core/network/dio_client.dart'; +import '../domain/marketplace_settlement.dart'; +import 'invoices_provider.dart'; + +class MarketplaceSettlementsNotifier extends AsyncNotifier> { + @override + FutureOr> build() async { + return _fetchSettlements(); + } + + Future> _fetchSettlements() async { + try { + final response = await DioClient().dio.get('/marketplace-settlements'); + if (response.statusCode == 200) { + final List data = response.data; + return data.map((e) => MarketplaceSettlement.fromJson(e)).toList(); + } + return []; + } catch (e) { + return []; + } + } + + Future refresh() async { + state = const AsyncValue.loading(); + try { + final settlements = await _fetchSettlements(); + state = AsyncValue.data(settlements); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + } + } + + Future createSettlement(int invoiceId, MarketplaceSettlement settlement) async { + try { + final response = await DioClient().dio.post( + '/invoices/$invoiceId/settlements', + data: settlement.toJson(), + ); + await refresh(); + ref.refresh(invoicesProvider); + if (response.data != null) { + return MarketplaceSettlement.fromJson(response.data); + } + return null; + } catch (e) { + if (e is DioException) { + throw Exception( + 'Failed to record settlement: ${e.response?.statusCode} - ${e.response?.data}', + ); + } + throw Exception('Failed to record settlement: $e'); + } + } +} + +final marketplaceSettlementsProvider = + AsyncNotifierProvider>( + MarketplaceSettlementsNotifier.new); + +final invoiceSettlementProvider = + FutureProvider.family((ref, invoiceId) async { + try { + final response = await DioClient().dio.get('/invoices/$invoiceId/settlements'); + if (response.statusCode == 200) { + final List data = response.data; + if (data.isNotEmpty) { + return MarketplaceSettlement.fromJson(data.first); + } + } + return null; + } catch (e) { + return null; + } +}); 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 1f13ad3..58c9980 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 @@ -788,7 +788,10 @@ class _PurchaseOrderBuilderScreenState builder: (context) { final products = ref.watch(productsProvider).value ?? []; final categories = ref.watch(productCategoriesProvider).value ?? []; - final businessState = ref.watch(businessProfileProvider).value?.stateId; + final businessProfile = ref.watch(businessProfileProvider).value; + final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase(); + final isJewellery = nature == 'JEWELLERY'; + final businessState = businessProfile?.stateId; final vendorState = _selectedVendorId == null ? null : vendorsState.value?.where((v) => v.id == _selectedVendorId).firstOrNull?.stateId; @@ -812,7 +815,7 @@ class _PurchaseOrderBuilderScreenState final effectiveGstRate = (effectiveTaxable > 0 && taxTotal > 0) ? (taxTotal / effectiveTaxable) * 100.0 - : (_items.isNotEmpty ? _items.first.gstRate : (isJewellery ? 3.0 : 18.0)); + : (_items.isNotEmpty ? _items.first.taxRate : (isJewellery ? 3.0 : 18.0)); final cgstRate = effectiveGstRate / 2.0; final sgstRate = effectiveGstRate / 2.0; final igstRate = effectiveGstRate;