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.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table; import org.springframework.data.relational.core.mapping.Table;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -58,6 +59,19 @@ public class Product {
private Double makingCharges; private Double makingCharges;
private String makingChargesType; private String makingChargesType;
private Double wastagePercentage; 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 Boolean isActive;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; 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") @Column("marketplace_order_id")
private String marketplaceOrderId; 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") @Column("place_of_supply_state_id")
private Long placeOfSupplyStateId; 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.setMakingCharges(updatedProduct.getMakingCharges());
existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType()); existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType());
existingProduct.setWastagePercentage(updatedProduct.getWastagePercentage()); 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.setIsActive(updatedProduct.getIsActive());
existingProduct.setUpdatedAt(LocalDateTime.now()); existingProduct.setUpdatedAt(LocalDateTime.now());
return productRepository.save(existingProduct); 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, wastage_percentage DECIMAL(5, 2) DEFAULT 0.0,
min_stock DECIMAL(10, 2) DEFAULT 0, min_stock DECIMAL(10, 2) DEFAULT 0,
reorder_level DECIMAL(10, 2) DEFAULT 0, reorder_level DECIMAL(10, 2) DEFAULT 0,
reorder_quantity DECIMAL(10, 2) DEFAULT 0,
track_inventory BOOLEAN DEFAULT TRUE, track_inventory BOOLEAN DEFAULT TRUE,
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -495,6 +496,76 @@ CREATE TABLE IF NOT EXISTS invoice_payments (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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 -- 9. Projects & Task Management
CREATE TABLE IF NOT EXISTS projects ( CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,

View File

@@ -40,6 +40,12 @@ class Product {
final double? makingCharges; final double? makingCharges;
final String? makingChargesType; final String? makingChargesType;
final double? wastagePercentage; final double? wastagePercentage;
final double? minStock;
final double? reorderLevel;
final double? reorderQuantity;
final bool trackInventory;
final bool isActive; final bool isActive;
final List<int> imageIds; final List<int> imageIds;
final double? currentStock; final double? currentStock;
@@ -80,24 +86,37 @@ class Product {
this.makingCharges = 0.0, this.makingCharges = 0.0,
this.makingChargesType = 'FLAT', this.makingChargesType = 'FLAT',
this.wastagePercentage = 0.0, this.wastagePercentage = 0.0,
this.minStock = 0.0,
this.reorderLevel = 0.0,
this.reorderQuantity = 0.0,
this.trackInventory = true,
this.isActive = true, this.isActive = true,
this.imageIds = const [], this.imageIds = const [],
this.currentStock = 0.0, 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) { factory Product.fromJson(Map<String, dynamic> json) {
return Product( return Product(
id: json['id'], id: json['id'],
userId: json['userId'] ?? json['user_id'], userId: json['userId'] ?? json['user_id'],
categoryId: json['categoryId'] ?? json['category_id'], categoryId: json['categoryId'] ?? json['category_id'],
uomId: json['uomId'] ?? json['uom_id'], uomId: json['uomId'] ?? json['uom_id'],
name: json['name'], name: json['name'] ?? '',
hsnCode: json['hsnCode'] ?? json['hsn_code'], hsnCode: json['hsnCode'] ?? json['hsn_code'],
sku: json['sku'], sku: json['sku'],
barcode: json['barcode'], barcode: json['barcode'],
description: json['description'], description: json['description'],
sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?) sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?)?.toDouble(),
?.toDouble(),
gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(), gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(),
dimensions: json['dimensions'], dimensions: json['dimensions'],
color: json['color'], color: json['color'],
@@ -117,22 +136,16 @@ class Product {
weightInG: (json['weightInG'] ?? json['weight_in_g'] as num?)?.toDouble(), weightInG: (json['weightInG'] ?? json['weight_in_g'] as num?)?.toDouble(),
packageWeightInG: (json['packageWeightInG'] ?? json['package_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(), volumetricWeightInKg: (json['volumetricWeightInKg'] ?? json['volumetric_weight_in_kg'] as num?)?.toDouble(),
priceCalcRule: priceCalcRule: json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL',
json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL', autoCalculatePrice: json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false,
autoCalculatePrice: purityFactor: (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble(),
json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false, makingCharges: (json['makingCharges'] ?? json['making_charges'] as num?)?.toDouble() ?? 0.0,
purityFactor: makingChargesType: json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT',
(json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble(), wastagePercentage: (json['wastagePercentage'] ?? json['wastage_percentage'] as num?)?.toDouble() ?? 0.0,
makingCharges: minStock: (json['minStock'] ?? json['min_stock'] as num?)?.toDouble() ?? 0.0,
(json['makingCharges'] ?? json['making_charges'] as num?) reorderLevel: (json['reorderLevel'] ?? json['reorder_level'] as num?)?.toDouble() ?? 0.0,
?.toDouble() ?? reorderQuantity: (json['reorderQuantity'] ?? json['reorder_quantity'] as num?)?.toDouble() ?? 0.0,
0.0, trackInventory: json['trackInventory'] ?? json['track_inventory'] ?? true,
makingChargesType:
json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT',
wastagePercentage:
(json['wastagePercentage'] ?? json['wastage_percentage'] as num?)
?.toDouble() ??
0.0,
isActive: json['isActive'] ?? json['is_active'] ?? true, isActive: json['isActive'] ?? json['is_active'] ?? true,
imageIds: json['images'] != null imageIds: json['images'] != null
? (json['images'] as List).map((i) { ? (json['images'] as List).map((i) {
@@ -144,9 +157,7 @@ class Product {
return 0; return 0;
}).where((id) => id > 0).toList() }).where((id) => id > 0).toList()
: [], : [],
currentStock: currentStock: (json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ?? 0.0,
(json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ??
0.0,
); );
} }
@@ -187,6 +198,10 @@ class Product {
'makingCharges': makingCharges, 'makingCharges': makingCharges,
'makingChargesType': makingChargesType, 'makingChargesType': makingChargesType,
'wastagePercentage': wastagePercentage, 'wastagePercentage': wastagePercentage,
'minStock': minStock,
'reorderLevel': reorderLevel,
'reorderQuantity': reorderQuantity,
'trackInventory': trackInventory,
'isActive': isActive, 'isActive': isActive,
}; };
} }

View File

@@ -58,6 +58,11 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final _packageWeightInGController = TextEditingController(); final _packageWeightInGController = TextEditingController();
double? _volumetricWeightInKg; double? _volumetricWeightInKg;
// Inventory & Reorder Thresholds
final _minStockController = TextEditingController();
final _reorderQuantityController = TextEditingController();
bool _trackInventory = true;
// Media // Media
final List<XFile> _images = []; final List<XFile> _images = [];
final List<int> _existingImageIds = []; final List<int> _existingImageIds = [];
@@ -103,6 +108,11 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
_calculateVolumetricWeight(); _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) { if (p.imageIds.isNotEmpty) {
_existingImageIds.addAll(p.imageIds); _existingImageIds.addAll(p.imageIds);
} }
@@ -262,6 +272,10 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
weightInG: double.tryParse(_weightInGController.text), weightInG: double.tryParse(_weightInGController.text),
packageWeightInG: double.tryParse(_packageWeightInGController.text), packageWeightInG: double.tryParse(_packageWeightInGController.text),
volumetricWeightInKg: _volumetricWeightInKg, 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) { 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 SizedBox(height: 24),
const Text('Product Images', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const Text('Product Images', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -905,20 +984,26 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
Widget _buildPremiumTextField({ Widget _buildPremiumTextField({
required String label, required String label,
TextEditingController? controller,
String? initialValue, String? initialValue,
String? prefixText, String? prefixText,
String? suffixText, String? suffixText,
IconData? prefixIcon,
String? helperText,
TextInputType? keyboardType, TextInputType? keyboardType,
void Function(String)? onChanged, void Function(String)? onChanged,
void Function(String?)? onSaved, void Function(String?)? onSaved,
String? Function(String?)? validator, String? Function(String?)? validator,
}) { }) {
return TextFormField( return TextFormField(
initialValue: initialValue, controller: controller,
initialValue: controller == null ? initialValue : null,
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
prefixText: prefixText, prefixText: prefixText,
suffixText: suffixText, suffixText: suffixText,
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
helperText: helperText,
), ),
keyboardType: keyboardType, keyboardType: keyboardType,
onChanged: onChanged, 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 // Non-jewellery Specific Sections
if (!isJewellery) ...[ if (!isJewellery) ...[

View File

@@ -22,6 +22,7 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
Timer? _debounce; Timer? _debounce;
String? _token; String? _token;
String _stockFilter = 'ALL'; // 'ALL', 'LOW_STOCK', 'OUT_OF_STOCK'
@override @override
void initState() { void initState() {
@@ -55,66 +56,135 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
final productsState = ref.watch(productsProvider); final productsState = ref.watch(productsProvider);
final categoriesState = ref.watch(productCategoriesProvider); final categoriesState = ref.watch(productCategoriesProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[100], backgroundColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC),
appBar: AppBar( appBar: AppBar(
title: const Text( title: const Text(
'Products Catalog', 'Products Catalog',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
elevation: 0, elevation: 0,
backgroundColor: Colors.white, backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white,
foregroundColor: Colors.black, foregroundColor: isDark ? Colors.white : Colors.black,
centerTitle: true, centerTitle: true,
), ),
body: MaxContentWidth( body: Column(
maxWidth: 1000,
child: Column(
children: [ children: [
Container( Container(
color: Colors.white, color: isDark ? const Color(0xFF1E293B) : Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: TextField( child: Column(
controller: _searchController, children: [
decoration: InputDecoration( TextField(
hintText: 'Search products by name or SKU...', controller: _searchController,
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 14), hintText: 'Search products by name or SKU...',
), prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
onChanged: (val) { contentPadding: const EdgeInsets.symmetric(vertical: 12),
if (_debounce?.isActive ?? false) _debounce!.cancel(); border: OutlineInputBorder(
_debounce = Timer(const Duration(milliseconds: 500), () { borderRadius: BorderRadius.circular(12),
ref.read(productsProvider.notifier).search(val); 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( Expanded(
child: productsState.when( child: productsState.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')), error: (err, stack) => Center(child: Text('Error: $err')),
data: (products) { 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( return RefreshIndicator(
onRefresh: () => onRefresh: () =>
ref.read(productsProvider.notifier).refresh(), ref.read(productsProvider.notifier).refresh(),
child: ListView( child: ListView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
children: const [ children: [
SizedBox(height: 100), const SizedBox(height: 100),
Center( Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
LucideIcons.packageSearch, _stockFilter == 'LOW_STOCK'
? LucideIcons.checkCircle
: LucideIcons.packageSearch,
size: 64, size: 64,
color: Colors.grey, color: Colors.grey,
), ),
SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No products found.', _stockFilter == 'LOW_STOCK'
style: TextStyle( ? '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, color: Colors.grey,
fontSize: 16, fontSize: 16,
), ),
@@ -133,14 +203,14 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
child: ListView.builder( child: ListView.builder(
controller: _scrollController, controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
itemCount: products.length + 1, // +1 for loading indicator itemCount: filteredProducts.length + 1, // +1 for loading indicator
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == products.length) { if (index == filteredProducts.length) {
return const SizedBox(height: 80); return const SizedBox(height: 80);
} }
final p = products[index]; final p = filteredProducts[index];
final catName = categoriesState.value final catName = categoriesState.value
?.where((c) => c.id == p.categoryId) ?.where((c) => c.id == p.categoryId)
.firstOrNull .firstOrNull
@@ -150,11 +220,12 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.grey.withOpacity(0.08), color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
@@ -235,28 +306,84 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
], ],
), ),
), ),
Column( Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( if (!p.trackInventory) ...[
p.currentStock != null Container(
? (p.currentStock! > 0 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
? 'Stock: ${p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock}' decoration: BoxDecoration(
: 'Out of Stock') color: Colors.grey.withValues(alpha: 0.1),
: 'Untracked', borderRadius: BorderRadius.circular(6),
style: TextStyle( ),
color: p.currentStock != null child: const Text('Untracked', style: TextStyle(fontSize: 11, color: Colors.grey, fontWeight: FontWeight.bold)),
? (p.currentStock! > 0 ),
? const Color(0xFF10B981) ] else if (p.isOutOfStock) ...[
: Colors.red) Container(
: Colors.grey, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
fontSize: 12, decoration: BoxDecoration(
fontWeight: FontWeight.bold, 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( floatingActionButton: FloatingActionButton(
onPressed: () async { onPressed: () async {
await Navigator.push( 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 int? salesChannelId;
final String? salesChannel; final String? salesChannel;
final String? marketplaceOrderId; 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 int? placeOfSupplyStateId;
final String? placeOfSupply; final String? placeOfSupply;
final List<InvoiceItem> items; final List<InvoiceItem> items;
@@ -227,6 +233,12 @@ class Invoice {
this.salesChannelId, this.salesChannelId,
this.salesChannel, this.salesChannel,
this.marketplaceOrderId, this.marketplaceOrderId,
this.courierPartner,
this.trackingNumber,
this.dispatchStatus,
this.shippedAt,
this.shippingAddress,
this.shippingPincode,
this.placeOfSupplyStateId, this.placeOfSupplyStateId,
this.placeOfSupply, this.placeOfSupply,
this.items = const [], this.items = const [],
@@ -267,6 +279,14 @@ class Invoice {
salesChannelId: json['salesChannelId'] ?? json['sales_channel_id'], salesChannelId: json['salesChannelId'] ?? json['sales_channel_id'],
salesChannel: json['salesChannel'] ?? json['sales_channel'], salesChannel: json['salesChannel'] ?? json['sales_channel'],
marketplaceOrderId: json['marketplaceOrderId'] ?? json['marketplace_order_id'], 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'], placeOfSupplyStateId: json['placeOfSupplyStateId'] ?? json['place_of_supply_state_id'],
placeOfSupply: json['placeOfSupply'] ?? json['place_of_supply'], placeOfSupply: json['placeOfSupply'] ?? json['place_of_supply'],
items: json['items'] != null items: json['items'] != null
@@ -314,6 +334,14 @@ class Invoice {
if (salesChannelId != null) data['salesChannelId'] = salesChannelId; if (salesChannelId != null) data['salesChannelId'] = salesChannelId;
if (salesChannel != null) data['salesChannel'] = salesChannel; if (salesChannel != null) data['salesChannel'] = salesChannel;
if (marketplaceOrderId != null) data['marketplaceOrderId'] = marketplaceOrderId; 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 (placeOfSupplyStateId != null) data['placeOfSupplyStateId'] = placeOfSupplyStateId;
if (placeOfSupply != null) data['placeOfSupply'] = placeOfSupply; if (placeOfSupply != null) data['placeOfSupply'] = placeOfSupply;
data['items'] = items.map((i) => i.toJson()).toList(); 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 _emiAmountCtrl = TextEditingController();
final _discountCtrl = TextEditingController(text: '0.0'); final _discountCtrl = TextEditingController(text: '0.0');
final _marketplaceOrderIdCtrl = TextEditingController(); final _marketplaceOrderIdCtrl = TextEditingController();
final _courierPartnerCtrl = TextEditingController();
final _trackingNumberCtrl = TextEditingController();
final _shippingAddressCtrl = TextEditingController();
final _shippingPincodeCtrl = TextEditingController();
DateTime _invoiceDate = DateTime.now(); DateTime _invoiceDate = DateTime.now();
DateTime? _dueDate; DateTime? _dueDate;
@@ -118,6 +122,11 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
final List<InvoiceItem> _items = []; final List<InvoiceItem> _items = [];
bool _isLoading = false; bool _isLoading = false;
// Logistics & Delivery State
String _dispatchStatus = 'PENDING';
bool _sameAsCustomerAddress = true;
DateTime? _shippedAt;
XFile? _invoiceFile; XFile? _invoiceFile;
String? _invoiceUrl; String? _invoiceUrl;
@@ -143,6 +152,13 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
_selectedSalesChannelId = inv.salesChannelId; _selectedSalesChannelId = inv.salesChannelId;
_selectedSalesChannelName = inv.salesChannel; _selectedSalesChannelName = inv.salesChannel;
_marketplaceOrderIdCtrl.text = inv.marketplaceOrderId ?? ''; _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; _selectedPlaceOfSupplyStateId = inv.placeOfSupplyStateId;
_selectedPlaceOfSupplyStateName = inv.placeOfSupply; _selectedPlaceOfSupplyStateName = inv.placeOfSupply;
_notesCtrl.text = inv.notes ?? ''; _notesCtrl.text = inv.notes ?? '';
@@ -181,6 +197,10 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
_emiAmountCtrl.dispose(); _emiAmountCtrl.dispose();
_discountCtrl.dispose(); _discountCtrl.dispose();
_marketplaceOrderIdCtrl.dispose(); _marketplaceOrderIdCtrl.dispose();
_courierPartnerCtrl.dispose();
_trackingNumberCtrl.dispose();
_shippingAddressCtrl.dispose();
_shippingPincodeCtrl.dispose();
super.dispose(); super.dispose();
} }
@@ -502,6 +522,12 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
salesChannelId: _selectedSalesChannelId, salesChannelId: _selectedSalesChannelId,
salesChannel: _selectedSalesChannelName, salesChannel: _selectedSalesChannelName,
marketplaceOrderId: _marketplaceOrderIdCtrl.text.trim().isNotEmpty ? _marketplaceOrderIdCtrl.text.trim() : null, 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, placeOfSupplyStateId: effectiveSupplyStateId,
placeOfSupply: _selectedPlaceOfSupplyStateName ?? ref.read(indianStatesProvider).value?.where((s) => s.id == effectiveSupplyStateId).firstOrNull?.name, placeOfSupply: _selectedPlaceOfSupplyStateName ?? ref.read(indianStatesProvider).value?.where((s) => s.id == effectiveSupplyStateId).firstOrNull?.name,
items: processedItems, items: processedItems,
@@ -1047,6 +1073,11 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
// Shipping & Courier Logistics Section
_buildShippingLogisticsSection(isDark),
const SizedBox(height: 24),
// Notes & Remarks // Notes & Remarks
PremiumTextField( PremiumTextField(
controller: _notesCtrl, 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 { class _AddSalesItemSheet extends ConsumerStatefulWidget {

View File

@@ -18,9 +18,16 @@ import '../domain/invoice.dart';
import '../providers/invoices_provider.dart'; import '../providers/invoices_provider.dart';
import '../providers/customers_provider.dart'; import '../providers/customers_provider.dart';
import '../../business/providers/business_provider.dart'; import '../../business/providers/business_provider.dart';
import '../../business/providers/indian_states_provider.dart';
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
import '../../../core/network/dio_client.dart'; import '../../../core/network/dio_client.dart';
import 'widgets/receive_payment_sheet.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'; import 'invoice_builder_screen.dart';
bool _isCommodityProduct({ bool _isCommodityProduct({
@@ -533,19 +540,239 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
// Bottom Action: Receive Payment if balance due // Credit Notes / Returns associated with this Invoice
if (remaining > 0) if (latestInvoice.id != null) ...[
ElevatedButton.icon( ref.watch(invoiceCreditNotesProvider(latestInvoice.id!)).when(
onPressed: () => _showReceivePaymentSheet(context, ref, latestInvoice), data: (cns) {
icon: const Icon(LucideIcons.plusCircle, size: 18), if (cns.isEmpty) return const SizedBox.shrink();
label: const Text('Record Payment', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), return Column(
style: ElevatedButton.styleFrom( crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.symmetric(vertical: 16), children: [
backgroundColor: Colors.green, const Text('Returns & Credit Notes Issued', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
foregroundColor: Colors.white, const SizedBox(height: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ...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), 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) { Widget _buildDetailBadge(String text, Color color) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 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'); await Share.shareXFiles([XFile(imagePath.path)], text: 'Invoice $invoiceNumber');
} catch (e) { } 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, orElse: () => widget.invoice,
) ?? widget.invoice; ) ?? widget.invoice;
final pdf = pw.Document();
final business = ref.read(businessProfileProvider).value;
final customers = ref.read(customersProvider).value ?? []; final customers = ref.read(customersProvider).value ?? [];
final customer = customers.where((c) => c.id == latestInvoice.customerId).firstOrNull; final customer = customers.where((c) => c.id == latestInvoice.customerId).firstOrNull;
final business = ref.read(businessProfileProvider).value;
final products = ref.read(productsProvider).value ?? []; final products = ref.read(productsProvider).value ?? [];
final categories = ref.read(productCategoriesProvider).value ?? []; final categories = ref.read(productCategoriesProvider).value ?? [];
final uoms = ref.read(uomsProvider).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 formatDate = DateFormat('dd MMM yyyy');
final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2);
final font = await PdfGoogleFonts.robotoRegular(); // Group items by productId
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
final Map<int, List<InvoiceItem>> groupedItems = {}; final Map<int, List<InvoiceItem>> groupedItems = {};
final List<InvoiceItem> ungroupedItems = []; final List<InvoiceItem> ungroupedItems = [];
for (final item in latestInvoice.items) { for (var item in latestInvoice.items) {
if (item.productId != null) { if (item.productId != null) {
groupedItems.putIfAbsent(item.productId!, () => []).add(item); groupedItems.putIfAbsent(item.productId!, () => []).add(item);
} else { } else {
@@ -700,96 +943,64 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
} }
} }
final pdf = pw.Document();
pdf.addPage( pdf.addPage(
pw.MultiPage( pw.MultiPage(
pageFormat: PdfPageFormat.a4, pageFormat: PdfPageFormat.a4,
theme: pw.ThemeData.withFont( margin: const pw.EdgeInsets.all(32),
base: font, build: (context) => [
bold: boldFont, pw.Column(
), crossAxisAlignment: pw.CrossAxisAlignment.start,
margin: const pw.EdgeInsets.all(28), children: [
build: (pw.Context context) { // 1. Enterprise Invoice Header
return [ pw.Row(
// 1. Header with Business Details & TAX INVOICE title mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start,
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [
crossAxisAlignment: pw.CrossAxisAlignment.start, pw.Expanded(
children: [ child: pw.Column(
pw.Expanded( crossAxisAlignment: pw.CrossAxisAlignment.start,
child: pw.Column( children: [
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)
pw.Text( pw.Text(
business.address!, business?.businessName ?? 'KIFI ENTERPRISE',
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( style: pw.TextStyle(
fontSize: 9, fontSize: 18,
fontWeight: pw.FontWeight.bold, 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( pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end, crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [ children: [
pw.Text( pw.Text(
'TAX INVOICE', 'TAX INVOICE',
style: pw.TextStyle( style: pw.TextStyle(
fontSize: 22, fontSize: 18,
fontWeight: pw.FontWeight.bold, fontWeight: pw.FontWeight.bold,
color: PdfColors.blue900, 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( pw.Text(
'Invoice #: ${latestInvoice.invoiceNumber}', 'Invoice #: ${latestInvoice.invoiceNumber}',
style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold), style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold),
@@ -798,6 +1009,21 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
'Date: ${formatDate.format(latestInvoice.issueDate)}', 'Date: ${formatDate.format(latestInvoice.issueDate)}',
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), 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) if (latestInvoice.dueDate != null)
pw.Text( pw.Text(
'Due Date: ${formatDate.format(latestInvoice.dueDate!)}', 'Due Date: ${formatDate.format(latestInvoice.dueDate!)}',
@@ -809,7 +1035,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
), ),
pw.SizedBox(height: 18), pw.SizedBox(height: 18),
// 2. Bill To Box (Customer Details) // 2. Bill To & Ship To Box (Customer & Delivery Details)
pw.Container( pw.Container(
padding: const pw.EdgeInsets.all(10), padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration( decoration: pw.BoxDecoration(
@@ -836,7 +1062,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
pw.Text( pw.Text(
customer?.name ?? 'Walk-in Customer', customer?.name ?? 'Walk-in Customer',
style: pw.TextStyle( style: pw.TextStyle(
fontSize: 12, fontSize: 11,
fontWeight: pw.FontWeight.bold, 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(); final pdfBytes = await pdf.save();

View File

@@ -5,8 +5,11 @@ import 'package:intl/intl.dart';
import '../providers/invoices_provider.dart'; import '../providers/invoices_provider.dart';
import '../domain/invoice.dart'; import '../domain/invoice.dart';
import '../providers/customers_provider.dart'; import '../providers/customers_provider.dart';
import '../providers/sales_channels_provider.dart';
import 'invoice_builder_screen.dart'; import 'invoice_builder_screen.dart';
import 'invoice_details_screen.dart'; import 'invoice_details_screen.dart';
import 'credit_notes_list_screen.dart';
import 'marketplace_settlements_screen.dart';
class InvoicesListScreen extends ConsumerStatefulWidget { class InvoicesListScreen extends ConsumerStatefulWidget {
const InvoicesListScreen({super.key}); const InvoicesListScreen({super.key});
@@ -18,6 +21,8 @@ class InvoicesListScreen extends ConsumerStatefulWidget {
class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> { class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
String _searchQuery = ''; String _searchQuery = '';
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
String? _selectedChannelFilter;
String? _selectedDispatchStatusFilter;
String _getStatusLabel(Invoice invoice) { String _getStatusLabel(Invoice invoice) {
if (invoice.isEmi && invoice.status != 'PAID') return 'EMI'; 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 @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
@@ -57,6 +79,7 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final invoicesState = ref.watch(invoicesProvider); final invoicesState = ref.watch(invoicesProvider);
final customersState = ref.watch(customersProvider); final customersState = ref.watch(customersProvider);
final channelsState = ref.watch(salesChannelsProvider);
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final formatCurrency = NumberFormat.currency(symbol: ''); final formatCurrency = NumberFormat.currency(symbol: '');
final formatDate = DateFormat('MMM dd, yyyy'); final formatDate = DateFormat('MMM dd, yyyy');
@@ -72,38 +95,121 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white, backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white,
foregroundColor: isDark ? Colors.white : Colors.black, foregroundColor: isDark ? Colors.white : Colors.black,
centerTitle: true, 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( body: Column(
children: [ children: [
// Unified Search Header // Unified Search Header
Container( Container(
color: isDark ? const Color(0xFF1E293B) : Colors.white, color: isDark ? const Color(0xFF1E293B) : Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: TextField( child: Column(
controller: _searchController, children: [
decoration: InputDecoration( TextField(
hintText: 'Search Invoice # or Customer...', controller: _searchController,
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 14), hintText: 'Search Invoice #, Order ID, Customer, Courier...',
border: OutlineInputBorder( prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
borderRadius: BorderRadius.circular(12), contentPadding: const EdgeInsets.symmetric(vertical: 12),
borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300), 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( const SizedBox(height: 8),
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300), // 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( Expanded(
@@ -111,10 +217,22 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
data: (invoices) { data: (invoices) {
final customers = customersState.value ?? []; final customers = customersState.value ?? [];
final filtered = invoices.where((inv) { 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 customer = customers.where((c) => c.id == inv.customerId).firstOrNull;
final matchesCust = customer != null && customer.name.toLowerCase().contains(_searchQuery.toLowerCase()); final matchesCust = customer != null && customer.name.toLowerCase().contains(q);
return matchesInv || matchesCust; 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(); }).toList();
if (filtered.isEmpty) { 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 final weightStr = item.weight != null && item.weight! > 0
? '${item.weight!.toStringAsFixed(3)} g' ? '${item.weight!.toStringAsFixed(3)} g'
: '${item.quantity.toInt()} pcs'; : '${item.quantity.toInt()} pcs';
final name = item.productName ?? item.description ?? 'Jewellery Item'; final name = item.productName ?? item.description ?? 'Item';
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0), padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row( child: Row(
children: [ 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), const SizedBox(width: 6),
Expanded( Expanded(
child: Text( 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), const SizedBox(height: 10),
// Row 3: Date & Total Amount // 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) { builder: (context) {
final products = ref.watch(productsProvider).value ?? []; final products = ref.watch(productsProvider).value ?? [];
final categories = ref.watch(productCategoriesProvider).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 final vendorState = _selectedVendorId == null
? null ? null
: vendorsState.value?.where((v) => v.id == _selectedVendorId).firstOrNull?.stateId; : vendorsState.value?.where((v) => v.id == _selectedVendorId).firstOrNull?.stateId;
@@ -812,7 +815,7 @@ class _PurchaseOrderBuilderScreenState
final effectiveGstRate = (effectiveTaxable > 0 && taxTotal > 0) final effectiveGstRate = (effectiveTaxable > 0 && taxTotal > 0)
? (taxTotal / effectiveTaxable) * 100.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 cgstRate = effectiveGstRate / 2.0;
final sgstRate = effectiveGstRate / 2.0; final sgstRate = effectiveGstRate / 2.0;
final igstRate = effectiveGstRate; final igstRate = effectiveGstRate;