Added market place specific feature

This commit is contained in:
2026-09-04 10:43:23 +05:30
parent b123a34e8f
commit 5e9b3f2122
31 changed files with 4978 additions and 214 deletions

View File

@@ -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<CreditNote> getCreditNotes(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return creditNoteService.getCreditNotes(userId);
}
@GetMapping("/credit-notes/{id}")
public Mono<CreditNote> 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<CreditNote> 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<CreditNote> createCreditNote(
@PathVariable Long invoiceId,
Authentication authentication,
@RequestBody CreditNote creditNote) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return creditNoteService.createCreditNote(userId, invoiceId, creditNote);
}
}

View File

@@ -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<MarketplaceSettlement> getSettlements(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return settlementService.getSettlements(userId);
}
@GetMapping("/marketplace-settlements/{id}")
public Mono<MarketplaceSettlement> getSettlementById(
@PathVariable Long id,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return settlementService.getSettlementById(id, userId);
}
@GetMapping("/invoices/{invoiceId}/settlements")
public Flux<MarketplaceSettlement> getSettlementsForInvoice(
@PathVariable Long invoiceId,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return settlementService.getSettlementsForInvoice(invoiceId, userId);
}
@PostMapping("/invoices/{invoiceId}/settlements")
public Mono<MarketplaceSettlement> createSettlement(
@PathVariable Long invoiceId,
Authentication authentication,
@RequestBody MarketplaceSettlement settlement) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return settlementService.createSettlement(userId, invoiceId, settlement);
}
}

View File

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

View File

@@ -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<CreditNoteItem> items;
}

View File

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

View File

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

View File

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

View File

@@ -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<CreditNoteItem, Long> {
Flux<CreditNoteItem> findByCreditNoteId(Long creditNoteId);
}

View File

@@ -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<CreditNote, Long> {
Flux<CreditNote> findByUserId(Long userId);
Flux<CreditNote> findByUserIdAndInvoiceId(Long userId, Long invoiceId);
Flux<CreditNote> findByUserIdAndCustomerId(Long userId, Long customerId);
Mono<CreditNote> findByUserIdAndCreditNoteNumber(Long userId, String creditNoteNumber);
}

View File

@@ -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<MarketplaceSettlement, Long> {
Flux<MarketplaceSettlement> findByUserId(Long userId);
Flux<MarketplaceSettlement> findByUserIdAndInvoiceId(Long userId, Long invoiceId);
Flux<MarketplaceSettlement> findByUserIdAndSalesChannelId(Long userId, Long salesChannelId);
Mono<MarketplaceSettlement> findByUserIdAndSettlementRef(Long userId, String settlementRef);
}

View File

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

View File

@@ -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<CreditNote> getCreditNotes(Long userId) {
return creditNoteRepository.findByUserId(userId)
.flatMap(this::populateItems);
}
public Flux<CreditNote> getCreditNotesForInvoice(Long invoiceId, Long userId) {
return creditNoteRepository.findByUserIdAndInvoiceId(userId, invoiceId)
.flatMap(this::populateItems);
}
public Mono<CreditNote> 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<CreditNote> populateItems(CreditNote cn) {
return creditNoteItemRepository.findByCreditNoteId(cn.getId())
.collectList()
.map(items -> {
cn.setItems(items);
return cn;
});
}
public Mono<CreditNote> 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<CreditNoteItem> 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<List<CreditNoteItem>> 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<Void> 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<Void> 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<Invoice> updateInvoiceMono = invoiceRepository.save(invoice);
return restockMono
.then(accountingMono)
.then(updateInvoiceMono)
.thenReturn(savedCn);
});
});
});
}
}

View File

@@ -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<MarketplaceSettlement> getSettlements(Long userId) {
return settlementRepository.findByUserId(userId);
}
public Flux<MarketplaceSettlement> getSettlementsForInvoice(Long invoiceId, Long userId) {
return settlementRepository.findByUserIdAndInvoiceId(userId, invoiceId);
}
public Mono<MarketplaceSettlement> 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<MarketplaceSettlement> 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<Void> 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<InvoicePayment> paymentMono = invoicePaymentRepository.save(payment);
// 3. Mark Invoice as PAID / SETTLED
invoice.setStatus("PAID");
invoice.setUpdatedAt(LocalDateTime.now());
Mono<Invoice> updateInvoiceMono = invoiceRepository.save(invoice);
return txMono
.then(paymentMono)
.then(updateInvoiceMono)
.thenReturn(savedSettlement);
});
});
}
}

View File

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

View File

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

View File

@@ -58,6 +58,11 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final _packageWeightInGController = TextEditingController();
double? _volumetricWeightInKg;
// Inventory & Reorder Thresholds
final _minStockController = TextEditingController();
final _reorderQuantityController = TextEditingController();
bool _trackInventory = true;
// Media
final List<XFile> _images = [];
final List<int> _existingImageIds = [];
@@ -102,6 +107,11 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
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<AddProductScreen> {
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<AddProductScreen> {
),
],
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<AddProductScreen> {
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,

View File

@@ -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) ...[

View File

@@ -22,6 +22,7 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
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<ProductListScreen> {
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<ProductListScreen> {
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<ProductListScreen> {
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<ProductListScreen> {
],
),
),
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<ProductListScreen> {
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Navigator.push(

View File

@@ -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<String, dynamic> 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<String, dynamic> toJson() {
final Map<String, dynamic> 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<CreditNoteItem> 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<String, dynamic> 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<String, dynamic> toJson() {
final Map<String, dynamic> 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;
}
}

View File

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

View File

@@ -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<String, dynamic> 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<String, dynamic> toJson() {
final Map<String, dynamic> 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;
}
}

View File

@@ -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<CreditNotesListScreen> createState() => _CreditNotesListScreenState();
}
class _CreditNotesListScreenState extends ConsumerState<CreditNotesListScreen> {
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<void> _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')),
),
),
],
),
);
}
}

View File

@@ -107,6 +107,10 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
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<InvoiceBuilderScreen> {
final List<InvoiceItem> _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<InvoiceBuilderScreen> {
_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<InvoiceBuilderScreen> {
_emiAmountCtrl.dispose();
_discountCtrl.dispose();
_marketplaceOrderIdCtrl.dispose();
_courierPartnerCtrl.dispose();
_trackingNumberCtrl.dispose();
_shippingAddressCtrl.dispose();
_shippingPincodeCtrl.dispose();
super.dispose();
}
@@ -502,6 +522,12 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
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<InvoiceBuilderScreen> {
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<InvoiceBuilderScreen> {
),
);
}
Future<void> _scanTrackingBarcode() async {
final code = await Navigator.push<String>(
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 {

View File

@@ -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<InvoiceDetailsScreen> {
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<InvoiceDetailsScreen> {
);
}
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<InvoiceDetailsScreen> {
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<InvoiceDetailsScreen> {
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<int, List<InvoiceItem>> groupedItems = {};
final List<InvoiceItem> 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<InvoiceDetailsScreen> {
}
}
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<InvoiceDetailsScreen> {
'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<InvoiceDetailsScreen> {
),
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<InvoiceDetailsScreen> {
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<InvoiceDetailsScreen> {
],
),
),
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<InvoiceDetailsScreen> {
),
],
),
];
},
),
);
],
),
],
),
);
final pdfBytes = await pdf.save();

View File

@@ -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<InvoicesListScreen> {
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<InvoicesListScreen> {
}
}
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<InvoicesListScreen> {
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<InvoicesListScreen> {
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<InvoicesListScreen> {
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<InvoicesListScreen> {
),
),
],
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<InvoicesListScreen> {
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<InvoicesListScreen> {
),
),
],
// 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

View File

@@ -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<MarketplaceSettlementsScreen> createState() => _MarketplaceSettlementsScreenState();
}
class _MarketplaceSettlementsScreenState extends ConsumerState<MarketplaceSettlementsScreen> 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<String>()
.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<MarketplaceSettlement> settlements,
List<Invoice> invoices,
List<String> 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<Invoice> 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),
),
],
);
}
}

View File

@@ -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<CreateCreditNoteSheet> createState() => _CreateCreditNoteSheetState();
}
class _CreateCreditNoteSheetState extends ConsumerState<CreateCreditNoteSheet> {
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<int> _selectedItemIndices = {};
final Map<int, double> _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<void> _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<CreditNoteItem> 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<String>(
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<String>(
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<String>(
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<int>(
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<int>(
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)),
),
),
),
],
),
);
}
}

View File

@@ -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<SettleMarketplaceOrderSheet> createState() => _SettleMarketplaceOrderSheetState();
}
class _SettleMarketplaceOrderSheetState extends ConsumerState<SettleMarketplaceOrderSheet> {
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<void> _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<int>(
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<int>(
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)),
),
),
),
],
),
);
}
}

View File

@@ -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<List<CreditNote>> {
@override
FutureOr<List<CreditNote>> build() async {
return _fetchCreditNotes();
}
Future<List<CreditNote>> _fetchCreditNotes() async {
try {
final response = await DioClient().dio.get('/credit-notes');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((e) => CreditNote.fromJson(e)).toList();
}
return [];
} catch (e) {
return [];
}
}
Future<void> refresh() async {
state = const AsyncValue.loading();
try {
final creditNotes = await _fetchCreditNotes();
state = AsyncValue.data(creditNotes);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<CreditNote?> 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, List<CreditNote>>(CreditNotesNotifier.new);
final invoiceCreditNotesProvider =
FutureProvider.family<List<CreditNote>, int>((ref, invoiceId) async {
try {
final response = await DioClient().dio.get('/invoices/$invoiceId/credit-notes');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((e) => CreditNote.fromJson(e)).toList();
}
return [];
} catch (e) {
return [];
}
});

View File

@@ -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<List<MarketplaceSettlement>> {
@override
FutureOr<List<MarketplaceSettlement>> build() async {
return _fetchSettlements();
}
Future<List<MarketplaceSettlement>> _fetchSettlements() async {
try {
final response = await DioClient().dio.get('/marketplace-settlements');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((e) => MarketplaceSettlement.fromJson(e)).toList();
}
return [];
} catch (e) {
return [];
}
}
Future<void> refresh() async {
state = const AsyncValue.loading();
try {
final settlements = await _fetchSettlements();
state = AsyncValue.data(settlements);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<MarketplaceSettlement?> 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, List<MarketplaceSettlement>>(
MarketplaceSettlementsNotifier.new);
final invoiceSettlementProvider =
FutureProvider.family<MarketplaceSettlement?, int>((ref, invoiceId) async {
try {
final response = await DioClient().dio.get('/invoices/$invoiceId/settlements');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
if (data.isNotEmpty) {
return MarketplaceSettlement.fromJson(data.first);
}
}
return null;
} catch (e) {
return null;
}
});

View File

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