Added market place specific feature
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -58,6 +59,19 @@ public class Product {
|
||||
private Double makingCharges;
|
||||
private String makingChargesType;
|
||||
private Double wastagePercentage;
|
||||
|
||||
@Column("min_stock")
|
||||
private BigDecimal minStock;
|
||||
|
||||
@Column("reorder_level")
|
||||
private BigDecimal reorderLevel;
|
||||
|
||||
@Column("reorder_quantity")
|
||||
private BigDecimal reorderQuantity;
|
||||
|
||||
@Column("track_inventory")
|
||||
private Boolean trackInventory;
|
||||
|
||||
private Boolean isActive;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -89,6 +89,24 @@ public class Invoice {
|
||||
@Column("marketplace_order_id")
|
||||
private String marketplaceOrderId;
|
||||
|
||||
@Column("courier_partner")
|
||||
private String courierPartner;
|
||||
|
||||
@Column("tracking_number")
|
||||
private String trackingNumber;
|
||||
|
||||
@Column("dispatch_status")
|
||||
private String dispatchStatus; // PENDING, PACKED, SHIPPED, IN_TRANSIT, DELIVERED, RTO
|
||||
|
||||
@Column("shipped_at")
|
||||
private LocalDateTime shippedAt;
|
||||
|
||||
@Column("shipping_address")
|
||||
private String shippingAddress;
|
||||
|
||||
@Column("shipping_pincode")
|
||||
private String shippingPincode;
|
||||
|
||||
@Column("place_of_supply_state_id")
|
||||
private Long placeOfSupplyStateId;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -127,6 +127,12 @@ public class ProductService {
|
||||
existingProduct.setMakingCharges(updatedProduct.getMakingCharges());
|
||||
existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType());
|
||||
existingProduct.setWastagePercentage(updatedProduct.getWastagePercentage());
|
||||
|
||||
existingProduct.setMinStock(updatedProduct.getMinStock());
|
||||
existingProduct.setReorderLevel(updatedProduct.getReorderLevel());
|
||||
existingProduct.setReorderQuantity(updatedProduct.getReorderQuantity());
|
||||
existingProduct.setTrackInventory(updatedProduct.getTrackInventory());
|
||||
|
||||
existingProduct.setIsActive(updatedProduct.getIsActive());
|
||||
existingProduct.setUpdatedAt(LocalDateTime.now());
|
||||
return productRepository.save(existingProduct);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,7 @@ CREATE TABLE IF NOT EXISTS products (
|
||||
wastage_percentage DECIMAL(5, 2) DEFAULT 0.0,
|
||||
min_stock DECIMAL(10, 2) DEFAULT 0,
|
||||
reorder_level DECIMAL(10, 2) DEFAULT 0,
|
||||
reorder_quantity DECIMAL(10, 2) DEFAULT 0,
|
||||
track_inventory BOOLEAN DEFAULT TRUE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -495,6 +496,76 @@ CREATE TABLE IF NOT EXISTS invoice_payments (
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credit_notes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
credit_note_number VARCHAR(100) NOT NULL,
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
return_date DATE NOT NULL,
|
||||
reason VARCHAR(100) NOT NULL,
|
||||
restock_items BOOLEAN DEFAULT TRUE,
|
||||
subtotal DECIMAL(15, 2) NOT NULL,
|
||||
tax_total DECIMAL(15, 2) DEFAULT 0.0,
|
||||
cgst_total DECIMAL(15, 2) DEFAULT 0.0,
|
||||
sgst_total DECIMAL(15, 2) DEFAULT 0.0,
|
||||
igst_total DECIMAL(15, 2) DEFAULT 0.0,
|
||||
discount_total DECIMAL(15, 2) DEFAULT 0.0,
|
||||
total_amount DECIMAL(15, 2) NOT NULL,
|
||||
refund_status VARCHAR(50) DEFAULT 'ADJUSTED_TO_AR',
|
||||
refund_wallet_id INTEGER REFERENCES wallets(id) ON DELETE SET NULL,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credit_note_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
credit_note_id INTEGER REFERENCES credit_notes(id) ON DELETE CASCADE,
|
||||
invoice_item_id INTEGER REFERENCES invoice_items(id) ON DELETE SET NULL,
|
||||
product_id INTEGER REFERENCES products(id) ON DELETE SET NULL,
|
||||
inventory_item_id INTEGER REFERENCES inventory_items(id) ON DELETE SET NULL,
|
||||
product_name VARCHAR(255),
|
||||
sku VARCHAR(100),
|
||||
huid VARCHAR(50),
|
||||
hsn_code VARCHAR(50),
|
||||
quantity DECIMAL(10, 3) NOT NULL,
|
||||
weight DECIMAL(10, 3),
|
||||
unit_price DECIMAL(15, 2) NOT NULL,
|
||||
tax_rate DECIMAL(5, 2) DEFAULT 0.0,
|
||||
cgst DECIMAL(15, 2) DEFAULT 0.0,
|
||||
sgst DECIMAL(15, 2) DEFAULT 0.0,
|
||||
igst DECIMAL(15, 2) DEFAULT 0.0,
|
||||
discount DECIMAL(15, 2) DEFAULT 0.0,
|
||||
making_charge DECIMAL(15, 2) DEFAULT 0.0,
|
||||
other_charges DECIMAL(15, 2) DEFAULT 0.0,
|
||||
total DECIMAL(15, 2) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS marketplace_settlements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
sales_channel_id INTEGER REFERENCES sales_channels(id) ON DELETE SET NULL,
|
||||
channel_name VARCHAR(100),
|
||||
settlement_ref VARCHAR(100),
|
||||
settlement_date DATE NOT NULL,
|
||||
gross_amount DECIMAL(15, 2) NOT NULL,
|
||||
commission_fee DECIMAL(15, 2) DEFAULT 0.0,
|
||||
shipping_fee DECIMAL(15, 2) DEFAULT 0.0,
|
||||
other_fees DECIMAL(15, 2) DEFAULT 0.0,
|
||||
fee_gst DECIMAL(15, 2) DEFAULT 0.0,
|
||||
tcs_gst_amount DECIMAL(15, 2) DEFAULT 0.0,
|
||||
tds_amount DECIMAL(15, 2) DEFAULT 0.0,
|
||||
total_deductions DECIMAL(15, 2) NOT NULL,
|
||||
net_payout_amount DECIMAL(15, 2) NOT NULL,
|
||||
payout_wallet_id INTEGER REFERENCES wallets(id) ON DELETE SET NULL,
|
||||
status VARCHAR(50) DEFAULT 'SETTLED',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 9. Projects & Task Management
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
@@ -40,6 +40,12 @@ class Product {
|
||||
final double? makingCharges;
|
||||
final String? makingChargesType;
|
||||
final double? wastagePercentage;
|
||||
|
||||
final double? minStock;
|
||||
final double? reorderLevel;
|
||||
final double? reorderQuantity;
|
||||
final bool trackInventory;
|
||||
|
||||
final bool isActive;
|
||||
final List<int> imageIds;
|
||||
final double? currentStock;
|
||||
@@ -80,24 +86,37 @@ class Product {
|
||||
this.makingCharges = 0.0,
|
||||
this.makingChargesType = 'FLAT',
|
||||
this.wastagePercentage = 0.0,
|
||||
this.minStock = 0.0,
|
||||
this.reorderLevel = 0.0,
|
||||
this.reorderQuantity = 0.0,
|
||||
this.trackInventory = true,
|
||||
this.isActive = true,
|
||||
this.imageIds = const [],
|
||||
this.currentStock = 0.0,
|
||||
});
|
||||
|
||||
bool get isOutOfStock => trackInventory && (currentStock == null || currentStock! <= 0);
|
||||
|
||||
bool get isLowStock {
|
||||
final alertThreshold = (reorderLevel != null && reorderLevel! > 0)
|
||||
? reorderLevel!
|
||||
: (minStock != null && minStock! > 0 ? minStock! : 0.0);
|
||||
if (!trackInventory || alertThreshold <= 0) return false;
|
||||
return currentStock != null && currentStock! > 0 && currentStock! <= alertThreshold;
|
||||
}
|
||||
|
||||
factory Product.fromJson(Map<String, dynamic> json) {
|
||||
return Product(
|
||||
id: json['id'],
|
||||
userId: json['userId'] ?? json['user_id'],
|
||||
categoryId: json['categoryId'] ?? json['category_id'],
|
||||
uomId: json['uomId'] ?? json['uom_id'],
|
||||
name: json['name'],
|
||||
name: json['name'] ?? '',
|
||||
hsnCode: json['hsnCode'] ?? json['hsn_code'],
|
||||
sku: json['sku'],
|
||||
barcode: json['barcode'],
|
||||
description: json['description'],
|
||||
sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?)
|
||||
?.toDouble(),
|
||||
sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?)?.toDouble(),
|
||||
gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(),
|
||||
dimensions: json['dimensions'],
|
||||
color: json['color'],
|
||||
@@ -117,22 +136,16 @@ class Product {
|
||||
weightInG: (json['weightInG'] ?? json['weight_in_g'] as num?)?.toDouble(),
|
||||
packageWeightInG: (json['packageWeightInG'] ?? json['package_weight_in_g'] as num?)?.toDouble(),
|
||||
volumetricWeightInKg: (json['volumetricWeightInKg'] ?? json['volumetric_weight_in_kg'] as num?)?.toDouble(),
|
||||
priceCalcRule:
|
||||
json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL',
|
||||
autoCalculatePrice:
|
||||
json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false,
|
||||
purityFactor:
|
||||
(json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble(),
|
||||
makingCharges:
|
||||
(json['makingCharges'] ?? json['making_charges'] as num?)
|
||||
?.toDouble() ??
|
||||
0.0,
|
||||
makingChargesType:
|
||||
json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT',
|
||||
wastagePercentage:
|
||||
(json['wastagePercentage'] ?? json['wastage_percentage'] as num?)
|
||||
?.toDouble() ??
|
||||
0.0,
|
||||
priceCalcRule: json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL',
|
||||
autoCalculatePrice: json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false,
|
||||
purityFactor: (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble(),
|
||||
makingCharges: (json['makingCharges'] ?? json['making_charges'] as num?)?.toDouble() ?? 0.0,
|
||||
makingChargesType: json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT',
|
||||
wastagePercentage: (json['wastagePercentage'] ?? json['wastage_percentage'] as num?)?.toDouble() ?? 0.0,
|
||||
minStock: (json['minStock'] ?? json['min_stock'] as num?)?.toDouble() ?? 0.0,
|
||||
reorderLevel: (json['reorderLevel'] ?? json['reorder_level'] as num?)?.toDouble() ?? 0.0,
|
||||
reorderQuantity: (json['reorderQuantity'] ?? json['reorder_quantity'] as num?)?.toDouble() ?? 0.0,
|
||||
trackInventory: json['trackInventory'] ?? json['track_inventory'] ?? true,
|
||||
isActive: json['isActive'] ?? json['is_active'] ?? true,
|
||||
imageIds: json['images'] != null
|
||||
? (json['images'] as List).map((i) {
|
||||
@@ -144,9 +157,7 @@ class Product {
|
||||
return 0;
|
||||
}).where((id) => id > 0).toList()
|
||||
: [],
|
||||
currentStock:
|
||||
(json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ??
|
||||
0.0,
|
||||
currentStock: (json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ?? 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,6 +198,10 @@ class Product {
|
||||
'makingCharges': makingCharges,
|
||||
'makingChargesType': makingChargesType,
|
||||
'wastagePercentage': wastagePercentage,
|
||||
'minStock': minStock,
|
||||
'reorderLevel': reorderLevel,
|
||||
'reorderQuantity': reorderQuantity,
|
||||
'trackInventory': trackInventory,
|
||||
'isActive': isActive,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
final _packageWeightInGController = TextEditingController();
|
||||
double? _volumetricWeightInKg;
|
||||
|
||||
// Inventory & Reorder Thresholds
|
||||
final _minStockController = TextEditingController();
|
||||
final _reorderQuantityController = TextEditingController();
|
||||
bool _trackInventory = true;
|
||||
|
||||
// Media
|
||||
final List<XFile> _images = [];
|
||||
final List<int> _existingImageIds = [];
|
||||
@@ -103,6 +108,11 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
_calculateVolumetricWeight();
|
||||
}
|
||||
|
||||
final alertQty = (p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock;
|
||||
_minStockController.text = alertQty != null && alertQty > 0 ? alertQty.toString() : '';
|
||||
_reorderQuantityController.text = p.reorderQuantity != null && p.reorderQuantity! > 0 ? p.reorderQuantity.toString() : '';
|
||||
_trackInventory = p.trackInventory;
|
||||
|
||||
if (p.imageIds.isNotEmpty) {
|
||||
_existingImageIds.addAll(p.imageIds);
|
||||
}
|
||||
@@ -262,6 +272,10 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
weightInG: double.tryParse(_weightInGController.text),
|
||||
packageWeightInG: double.tryParse(_packageWeightInGController.text),
|
||||
volumetricWeightInKg: _volumetricWeightInKg,
|
||||
minStock: double.tryParse(_minStockController.text) ?? 0.0,
|
||||
reorderLevel: double.tryParse(_minStockController.text) ?? 0.0,
|
||||
reorderQuantity: double.tryParse(_reorderQuantityController.text) ?? 0.0,
|
||||
trackInventory: _trackInventory,
|
||||
);
|
||||
|
||||
if (widget.product != null) {
|
||||
@@ -732,6 +746,71 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
// Inventory & Reorder Point Alert Thresholds
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(LucideIcons.bellRing, color: Colors.orange, size: 20),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text('Inventory & Reorder Alerts', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
title: const Text('Track Inventory & Stock Alerts', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
subtitle: const Text('Notify when product stock drops below critical threshold', style: TextStyle(fontSize: 12)),
|
||||
value: _trackInventory,
|
||||
onChanged: (val) => setState(() => _trackInventory = val),
|
||||
),
|
||||
if (_trackInventory) ...[
|
||||
const Divider(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildPremiumTextField(
|
||||
label: 'Reorder Alert Level / Min Stock',
|
||||
controller: _minStockController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
prefixIcon: LucideIcons.alertTriangle,
|
||||
helperText: 'Alert when stock falls below this qty / weight',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildPremiumTextField(
|
||||
label: 'Standard Reorder Batch Qty',
|
||||
controller: _reorderQuantityController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
prefixIcon: LucideIcons.shoppingBag,
|
||||
helperText: 'Suggested replenishment batch size',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
const Text('Product Images', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
@@ -905,20 +984,26 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
|
||||
Widget _buildPremiumTextField({
|
||||
required String label,
|
||||
TextEditingController? controller,
|
||||
String? initialValue,
|
||||
String? prefixText,
|
||||
String? suffixText,
|
||||
IconData? prefixIcon,
|
||||
String? helperText,
|
||||
TextInputType? keyboardType,
|
||||
void Function(String)? onChanged,
|
||||
void Function(String?)? onSaved,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
return TextFormField(
|
||||
initialValue: initialValue,
|
||||
controller: controller,
|
||||
initialValue: controller == null ? initialValue : null,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixText: prefixText,
|
||||
suffixText: suffixText,
|
||||
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
|
||||
helperText: helperText,
|
||||
),
|
||||
keyboardType: keyboardType,
|
||||
onChanged: onChanged,
|
||||
|
||||
@@ -165,6 +165,81 @@ class ProductDetailScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Inventory & Reorder Alerts Card
|
||||
Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.bellRing, size: 20, color: Colors.orange.shade700),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
"Inventory & Reorder Status",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!p.trackInventory)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: Colors.grey.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(6)),
|
||||
child: const Text('Untracked', style: TextStyle(fontSize: 11, color: Colors.grey, fontWeight: FontWeight.bold)),
|
||||
)
|
||||
else if (p.isOutOfStock)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: Colors.red.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(6)),
|
||||
child: const Text('Out of Stock', style: TextStyle(fontSize: 11, color: Colors.red, fontWeight: FontWeight.bold)),
|
||||
)
|
||||
else if (p.isLowStock)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: Colors.orange.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6)),
|
||||
child: const Text('Low Stock Alert', style: TextStyle(fontSize: 11, color: Colors.orange, fontWeight: FontWeight.bold)),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: const Color(0xFF10B981).withValues(alpha: 0.12), borderRadius: BorderRadius.circular(6)),
|
||||
child: const Text('Healthy Stock', style: TextStyle(fontSize: 11, color: Color(0xFF10B981), fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildDetailRow(
|
||||
"Current Available Stock",
|
||||
"${p.currentStock != null ? (p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock) : '0'} ${uom?.abbreviation ?? 'pcs'}",
|
||||
),
|
||||
_buildDetailRow(
|
||||
"Reorder Alert Level / Min Stock",
|
||||
((p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock) != null && ((p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock)! > 0
|
||||
? "${((p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock)} ${uom?.abbreviation ?? 'pcs'}"
|
||||
: "Not configured",
|
||||
),
|
||||
_buildDetailRow(
|
||||
"Standard Reorder Batch Qty",
|
||||
p.reorderQuantity != null && p.reorderQuantity! > 0
|
||||
? "${p.reorderQuantity} ${uom?.abbreviation ?? 'pcs'}"
|
||||
: "Not configured",
|
||||
),
|
||||
_buildDetailRow(
|
||||
"Inventory Tracking",
|
||||
p.trackInventory ? "Active" : "Disabled",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Non-jewellery Specific Sections
|
||||
if (!isJewellery) ...[
|
||||
|
||||
@@ -22,6 +22,7 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
Timer? _debounce;
|
||||
String? _token;
|
||||
String _stockFilter = 'ALL'; // 'ALL', 'LOW_STOCK', 'OUT_OF_STOCK'
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -55,31 +56,41 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
final productsState = ref.watch(productsProvider);
|
||||
final categoriesState = ref.watch(productCategoriesProvider);
|
||||
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[100],
|
||||
backgroundColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC),
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Products Catalog',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
foregroundColor: isDark ? Colors.white : Colors.black,
|
||||
centerTitle: true,
|
||||
),
|
||||
body: MaxContentWidth(
|
||||
maxWidth: 1000,
|
||||
child: Column(
|
||||
body: Column(
|
||||
children: [
|
||||
Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: TextField(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search products by name or SKU...',
|
||||
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
onChanged: (val) {
|
||||
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||
@@ -88,33 +99,92 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Stock Status Filter Bar
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
label: const Text('All Products', style: TextStyle(fontSize: 11)),
|
||||
selected: _stockFilter == 'ALL',
|
||||
onSelected: (val) => setState(() => _stockFilter = 'ALL'),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
avatar: const Icon(LucideIcons.alertTriangle, size: 14, color: Colors.orange),
|
||||
label: const Text('Low Stock Alerts', style: TextStyle(fontSize: 11)),
|
||||
selected: _stockFilter == 'LOW_STOCK',
|
||||
selectedColor: Colors.orange.withValues(alpha: 0.15),
|
||||
checkmarkColor: Colors.orange,
|
||||
onSelected: (val) => setState(() => _stockFilter = val ? 'LOW_STOCK' : 'ALL'),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
avatar: const Icon(LucideIcons.alertCircle, size: 14, color: Colors.red),
|
||||
label: const Text('Out of Stock', style: TextStyle(fontSize: 11)),
|
||||
selected: _stockFilter == 'OUT_OF_STOCK',
|
||||
selectedColor: Colors.red.withValues(alpha: 0.15),
|
||||
checkmarkColor: Colors.red,
|
||||
onSelected: (val) => setState(() => _stockFilter = val ? 'OUT_OF_STOCK' : 'ALL'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
Expanded(
|
||||
child: productsState.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
data: (products) {
|
||||
if (products.isEmpty) {
|
||||
final filteredProducts = products.where((p) {
|
||||
if (_stockFilter == 'LOW_STOCK') {
|
||||
return p.isLowStock;
|
||||
} else if (_stockFilter == 'OUT_OF_STOCK') {
|
||||
return p.isOutOfStock;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
if (filteredProducts.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
ref.read(productsProvider.notifier).refresh(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(height: 100),
|
||||
children: [
|
||||
const SizedBox(height: 100),
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.packageSearch,
|
||||
_stockFilter == 'LOW_STOCK'
|
||||
? LucideIcons.checkCircle
|
||||
: LucideIcons.packageSearch,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No products found.',
|
||||
style: TextStyle(
|
||||
_stockFilter == 'LOW_STOCK'
|
||||
? 'No low stock products! All inventory healthy.'
|
||||
: (_stockFilter == 'OUT_OF_STOCK'
|
||||
? 'No out-of-stock products.'
|
||||
: 'No products found.'),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 16,
|
||||
),
|
||||
@@ -133,14 +203,14 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: products.length + 1, // +1 for loading indicator
|
||||
itemCount: filteredProducts.length + 1, // +1 for loading indicator
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == products.length) {
|
||||
if (index == filteredProducts.length) {
|
||||
return const SizedBox(height: 80);
|
||||
}
|
||||
|
||||
final p = products[index];
|
||||
final p = filteredProducts[index];
|
||||
final catName = categoriesState.value
|
||||
?.where((c) => c.id == p.categoryId)
|
||||
.firstOrNull
|
||||
@@ -150,11 +220,12 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.08),
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
@@ -239,22 +310,78 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!p.trackInventory) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text('Untracked', style: TextStyle(fontSize: 11, color: Colors.grey, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
] else if (p.isOutOfStock) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.alertCircle, size: 12, color: Colors.red),
|
||||
SizedBox(width: 4),
|
||||
Text('Out of Stock', style: TextStyle(fontSize: 11, color: Colors.red, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else if (p.isLowStock) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.orange.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(LucideIcons.alertTriangle, size: 12, color: Colors.orange),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
p.currentStock != null
|
||||
? (p.currentStock! > 0
|
||||
? 'Stock: ${p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock}'
|
||||
: 'Out of Stock')
|
||||
: 'Untracked',
|
||||
style: TextStyle(
|
||||
color: p.currentStock != null
|
||||
? (p.currentStock! > 0
|
||||
? const Color(0xFF10B981)
|
||||
: Colors.red)
|
||||
: Colors.grey,
|
||||
'Low: ${p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock}',
|
||||
style: const TextStyle(fontSize: 11, color: Colors.orange, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'Alert <= ${((p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock) ?? 0}',
|
||||
style: TextStyle(fontSize: 9, color: Colors.orange.shade800),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF10B981).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'Stock: ${p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF10B981),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -271,7 +398,6 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () async {
|
||||
await Navigator.push(
|
||||
|
||||
201
kifi-app/lib/features/sales/domain/credit_note.dart
Normal file
201
kifi-app/lib/features/sales/domain/credit_note.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,12 @@ class Invoice {
|
||||
final int? salesChannelId;
|
||||
final String? salesChannel;
|
||||
final String? marketplaceOrderId;
|
||||
final String? courierPartner;
|
||||
final String? trackingNumber;
|
||||
final String? dispatchStatus; // PENDING, PACKED, SHIPPED, IN_TRANSIT, DELIVERED, RTO
|
||||
final DateTime? shippedAt;
|
||||
final String? shippingAddress;
|
||||
final String? shippingPincode;
|
||||
final int? placeOfSupplyStateId;
|
||||
final String? placeOfSupply;
|
||||
final List<InvoiceItem> items;
|
||||
@@ -227,6 +233,12 @@ class Invoice {
|
||||
this.salesChannelId,
|
||||
this.salesChannel,
|
||||
this.marketplaceOrderId,
|
||||
this.courierPartner,
|
||||
this.trackingNumber,
|
||||
this.dispatchStatus,
|
||||
this.shippedAt,
|
||||
this.shippingAddress,
|
||||
this.shippingPincode,
|
||||
this.placeOfSupplyStateId,
|
||||
this.placeOfSupply,
|
||||
this.items = const [],
|
||||
@@ -267,6 +279,14 @@ class Invoice {
|
||||
salesChannelId: json['salesChannelId'] ?? json['sales_channel_id'],
|
||||
salesChannel: json['salesChannel'] ?? json['sales_channel'],
|
||||
marketplaceOrderId: json['marketplaceOrderId'] ?? json['marketplace_order_id'],
|
||||
courierPartner: json['courierPartner'] ?? json['courier_partner'],
|
||||
trackingNumber: json['trackingNumber'] ?? json['tracking_number'],
|
||||
dispatchStatus: json['dispatchStatus'] ?? json['dispatch_status'] ?? 'PENDING',
|
||||
shippedAt: json['shippedAt'] != null
|
||||
? DateTime.parse(json['shippedAt'])
|
||||
: (json['shipped_at'] != null ? DateTime.parse(json['shipped_at']) : null),
|
||||
shippingAddress: json['shippingAddress'] ?? json['shipping_address'],
|
||||
shippingPincode: json['shippingPincode'] ?? json['shipping_pincode'],
|
||||
placeOfSupplyStateId: json['placeOfSupplyStateId'] ?? json['place_of_supply_state_id'],
|
||||
placeOfSupply: json['placeOfSupply'] ?? json['place_of_supply'],
|
||||
items: json['items'] != null
|
||||
@@ -314,6 +334,14 @@ class Invoice {
|
||||
if (salesChannelId != null) data['salesChannelId'] = salesChannelId;
|
||||
if (salesChannel != null) data['salesChannel'] = salesChannel;
|
||||
if (marketplaceOrderId != null) data['marketplaceOrderId'] = marketplaceOrderId;
|
||||
if (courierPartner != null) data['courierPartner'] = courierPartner;
|
||||
if (trackingNumber != null) data['trackingNumber'] = trackingNumber;
|
||||
if (dispatchStatus != null) data['dispatchStatus'] = dispatchStatus;
|
||||
if (shippedAt != null) {
|
||||
data['shippedAt'] = shippedAt!.toIso8601String();
|
||||
}
|
||||
if (shippingAddress != null) data['shippingAddress'] = shippingAddress;
|
||||
if (shippingPincode != null) data['shippingPincode'] = shippingPincode;
|
||||
if (placeOfSupplyStateId != null) data['placeOfSupplyStateId'] = placeOfSupplyStateId;
|
||||
if (placeOfSupply != null) data['placeOfSupply'] = placeOfSupply;
|
||||
data['items'] = items.map((i) => i.toJson()).toList();
|
||||
|
||||
103
kifi-app/lib/features/sales/domain/marketplace_settlement.dart
Normal file
103
kifi-app/lib/features/sales/domain/marketplace_settlement.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,10 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
||||
final _emiAmountCtrl = TextEditingController();
|
||||
final _discountCtrl = TextEditingController(text: '0.0');
|
||||
final _marketplaceOrderIdCtrl = TextEditingController();
|
||||
final _courierPartnerCtrl = TextEditingController();
|
||||
final _trackingNumberCtrl = TextEditingController();
|
||||
final _shippingAddressCtrl = TextEditingController();
|
||||
final _shippingPincodeCtrl = TextEditingController();
|
||||
|
||||
DateTime _invoiceDate = DateTime.now();
|
||||
DateTime? _dueDate;
|
||||
@@ -118,6 +122,11 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
||||
final List<InvoiceItem> _items = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
// Logistics & Delivery State
|
||||
String _dispatchStatus = 'PENDING';
|
||||
bool _sameAsCustomerAddress = true;
|
||||
DateTime? _shippedAt;
|
||||
|
||||
XFile? _invoiceFile;
|
||||
String? _invoiceUrl;
|
||||
|
||||
@@ -143,6 +152,13 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
||||
_selectedSalesChannelId = inv.salesChannelId;
|
||||
_selectedSalesChannelName = inv.salesChannel;
|
||||
_marketplaceOrderIdCtrl.text = inv.marketplaceOrderId ?? '';
|
||||
_courierPartnerCtrl.text = inv.courierPartner ?? '';
|
||||
_trackingNumberCtrl.text = inv.trackingNumber ?? '';
|
||||
_dispatchStatus = inv.dispatchStatus ?? 'PENDING';
|
||||
_shippedAt = inv.shippedAt;
|
||||
_shippingAddressCtrl.text = inv.shippingAddress ?? '';
|
||||
_shippingPincodeCtrl.text = inv.shippingPincode ?? '';
|
||||
_sameAsCustomerAddress = (inv.shippingAddress == null || inv.shippingAddress!.trim().isEmpty);
|
||||
_selectedPlaceOfSupplyStateId = inv.placeOfSupplyStateId;
|
||||
_selectedPlaceOfSupplyStateName = inv.placeOfSupply;
|
||||
_notesCtrl.text = inv.notes ?? '';
|
||||
@@ -181,6 +197,10 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
||||
_emiAmountCtrl.dispose();
|
||||
_discountCtrl.dispose();
|
||||
_marketplaceOrderIdCtrl.dispose();
|
||||
_courierPartnerCtrl.dispose();
|
||||
_trackingNumberCtrl.dispose();
|
||||
_shippingAddressCtrl.dispose();
|
||||
_shippingPincodeCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -502,6 +522,12 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
||||
salesChannelId: _selectedSalesChannelId,
|
||||
salesChannel: _selectedSalesChannelName,
|
||||
marketplaceOrderId: _marketplaceOrderIdCtrl.text.trim().isNotEmpty ? _marketplaceOrderIdCtrl.text.trim() : null,
|
||||
courierPartner: _courierPartnerCtrl.text.trim().isNotEmpty ? _courierPartnerCtrl.text.trim() : null,
|
||||
trackingNumber: _trackingNumberCtrl.text.trim().isNotEmpty ? _trackingNumberCtrl.text.trim() : null,
|
||||
dispatchStatus: _dispatchStatus,
|
||||
shippedAt: _shippedAt ?? (_dispatchStatus == 'SHIPPED' || _dispatchStatus == 'IN_TRANSIT' || _dispatchStatus == 'DELIVERED' ? DateTime.now() : null),
|
||||
shippingAddress: !_sameAsCustomerAddress && _shippingAddressCtrl.text.trim().isNotEmpty ? _shippingAddressCtrl.text.trim() : null,
|
||||
shippingPincode: !_sameAsCustomerAddress && _shippingPincodeCtrl.text.trim().isNotEmpty ? _shippingPincodeCtrl.text.trim() : null,
|
||||
placeOfSupplyStateId: effectiveSupplyStateId,
|
||||
placeOfSupply: _selectedPlaceOfSupplyStateName ?? ref.read(indianStatesProvider).value?.where((s) => s.id == effectiveSupplyStateId).firstOrNull?.name,
|
||||
items: processedItems,
|
||||
@@ -1047,6 +1073,11 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Shipping & Courier Logistics Section
|
||||
_buildShippingLogisticsSection(isDark),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Notes & Remarks
|
||||
PremiumTextField(
|
||||
controller: _notesCtrl,
|
||||
@@ -1787,6 +1818,240 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _scanTrackingBarcode() async {
|
||||
final code = await Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()),
|
||||
);
|
||||
if (code != null && code.isNotEmpty) {
|
||||
setState(() {
|
||||
_trackingNumberCtrl.text = code.trim();
|
||||
if (_dispatchStatus == 'PENDING') {
|
||||
_dispatchStatus = 'SHIPPED';
|
||||
_shippedAt = DateTime.now();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildShippingLogisticsSection(bool isDark) {
|
||||
final customers = ref.watch(customersProvider).value ?? [];
|
||||
final customer = _selectedCustomerId == null
|
||||
? null
|
||||
: customers.where((c) => c.id == _selectedCustomerId).firstOrNull;
|
||||
|
||||
final courierSuggestions = [
|
||||
'Delhivery',
|
||||
'BlueDart',
|
||||
'Shiprocket',
|
||||
'DTDC',
|
||||
'India Post',
|
||||
'Amazon Shipping',
|
||||
'Ekart',
|
||||
'Shadowfax',
|
||||
'Direct / Hand Delivery',
|
||||
];
|
||||
|
||||
final dispatchStatuses = [
|
||||
{'status': 'PENDING', 'label': 'Pending', 'color': Colors.grey},
|
||||
{'status': 'PACKED', 'label': 'Packed', 'color': Colors.blue},
|
||||
{'status': 'SHIPPED', 'label': 'Shipped', 'color': Colors.indigo},
|
||||
{'status': 'IN_TRANSIT', 'label': 'In Transit', 'color': Colors.amber.shade800},
|
||||
{'status': 'DELIVERED', 'label': 'Delivered', 'color': Colors.green},
|
||||
{'status': 'RTO', 'label': 'RTO / Return', 'color': Colors.red},
|
||||
];
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.05),
|
||||
blurRadius: 15,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.indigo.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(LucideIcons.truck, size: 16, color: Colors.indigo),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'SHIPPING & COURIER LOGISTICS',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 0.8, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.indigo.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.indigo.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Text(
|
||||
_dispatchStatus,
|
||||
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.indigo),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Dispatch Status Selector
|
||||
const Text('Dispatch Status:', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||
const SizedBox(height: 6),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: dispatchStatuses.map((st) {
|
||||
final statusKey = st['status'] as String;
|
||||
final statusLabel = st['label'] as String;
|
||||
final statusColor = st['color'] as Color;
|
||||
final isSelected = _dispatchStatus == statusKey;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: ChoiceChip(
|
||||
label: Text(
|
||||
statusLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected ? Colors.white : (isDark ? Colors.white70 : Colors.black87),
|
||||
),
|
||||
),
|
||||
selected: isSelected,
|
||||
selectedColor: statusColor,
|
||||
backgroundColor: isDark ? const Color(0xFF0F172A) : Colors.grey.shade100,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setState(() {
|
||||
_dispatchStatus = statusKey;
|
||||
if (statusKey == 'SHIPPED' && _shippedAt == null) {
|
||||
_shippedAt = DateTime.now();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Courier Partner & Quick Selection Chips
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PremiumTextField(
|
||||
controller: _courierPartnerCtrl,
|
||||
labelText: 'Courier Partner (e.g. Delhivery, BlueDart)',
|
||||
prefixIcon: const Icon(LucideIcons.packageCheck),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: courierSuggestions.map((name) {
|
||||
final isCurrent = _courierPartnerCtrl.text.trim().toLowerCase() == name.toLowerCase();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: ActionChip(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
|
||||
label: Text(name, style: TextStyle(fontSize: 11, color: isCurrent ? Colors.blue : null)),
|
||||
backgroundColor: isCurrent ? Colors.blue.withValues(alpha: 0.15) : null,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_courierPartnerCtrl.text = name;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// AWB / Tracking Number with Scanner Button
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PremiumTextField(
|
||||
controller: _trackingNumberCtrl,
|
||||
labelText: 'AWB / Tracking Number',
|
||||
prefixIcon: const Icon(LucideIcons.barcode),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
icon: const Icon(LucideIcons.scanLine),
|
||||
tooltip: 'Scan AWB Barcode',
|
||||
onPressed: _scanTrackingBarcode,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Delivery Address Toggle & Form
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: const Text('Deliver to customer billing address', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(
|
||||
customer?.address != null && customer!.address!.isNotEmpty
|
||||
? customer.address!
|
||||
: 'Customer address on file',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
value: _sameAsCustomerAddress,
|
||||
onChanged: (val) {
|
||||
setState(() => _sameAsCustomerAddress = val ?? true);
|
||||
},
|
||||
),
|
||||
if (!_sameAsCustomerAddress) ...[
|
||||
const SizedBox(height: 8),
|
||||
PremiumTextField(
|
||||
controller: _shippingAddressCtrl,
|
||||
labelText: 'Shipping / Delivery Address',
|
||||
maxLines: 2,
|
||||
prefixIcon: const Icon(LucideIcons.mapPin),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
PremiumTextField(
|
||||
controller: _shippingPincodeCtrl,
|
||||
labelText: 'Shipping Pincode',
|
||||
keyboardType: TextInputType.number,
|
||||
prefixIcon: const Icon(LucideIcons.hash),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddSalesItemSheet extends ConsumerStatefulWidget {
|
||||
|
||||
@@ -18,9 +18,16 @@ import '../domain/invoice.dart';
|
||||
import '../providers/invoices_provider.dart';
|
||||
import '../providers/customers_provider.dart';
|
||||
import '../../business/providers/business_provider.dart';
|
||||
import '../../business/providers/indian_states_provider.dart';
|
||||
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import 'widgets/receive_payment_sheet.dart';
|
||||
import 'widgets/create_credit_note_sheet.dart';
|
||||
import 'widgets/settle_marketplace_order_sheet.dart';
|
||||
import 'credit_notes_list_screen.dart';
|
||||
import 'marketplace_settlements_screen.dart';
|
||||
import '../providers/credit_notes_provider.dart';
|
||||
import '../providers/marketplace_settlements_provider.dart';
|
||||
import 'invoice_builder_screen.dart';
|
||||
|
||||
bool _isCommodityProduct({
|
||||
@@ -533,19 +540,239 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Bottom Action: Receive Payment if balance due
|
||||
if (remaining > 0)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showReceivePaymentSheet(context, ref, latestInvoice),
|
||||
icon: const Icon(LucideIcons.plusCircle, size: 18),
|
||||
label: const Text('Record Payment', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
// Credit Notes / Returns associated with this Invoice
|
||||
if (latestInvoice.id != null) ...[
|
||||
ref.watch(invoiceCreditNotesProvider(latestInvoice.id!)).when(
|
||||
data: (cns) {
|
||||
if (cns.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Returns & Credit Notes Issued', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
...cns.map((cn) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: isDark ? 0.1 : 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.red.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.undo2, color: Colors.red, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Credit Note #${cn.creditNoteNumber}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
Text(
|
||||
'${cn.reason.replaceAll("_", " ")} • ${formatDate.format(cn.returnDate)}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
formatCurrency.format(cn.totalAmount),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
|
||||
// Marketplace Settlement Breakdown Card (if settled)
|
||||
if (latestInvoice.id != null) ...[
|
||||
ref.watch(invoiceSettlementProvider(latestInvoice.id!)).when(
|
||||
data: (settlement) {
|
||||
if (settlement == null) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Marketplace Settlement & Payout', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF10B981).withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(LucideIcons.checkCheck, size: 12, color: Color(0xFF10B981)),
|
||||
SizedBox(width: 4),
|
||||
Text('Reconciled', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Color(0xFF10B981))),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('${settlement.channelName ?? "Marketplace"} Remittance', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
Text(formatDate.format(settlement.settlementDate), style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
|
||||
],
|
||||
),
|
||||
if (settlement.settlementRef != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.hash, size: 12, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
Text('Bank UTR: ${settlement.settlementRef}', style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
|
||||
],
|
||||
),
|
||||
],
|
||||
const Divider(height: 16),
|
||||
_buildSummaryRow('Gross Sale Value', formatCurrency.format(settlement.grossAmount)),
|
||||
if (settlement.commissionFee > 0)
|
||||
_buildSummaryRow('Referral / Commission Fee', '- ${formatCurrency.format(settlement.commissionFee)}', color: Colors.red),
|
||||
if (settlement.shippingFee > 0)
|
||||
_buildSummaryRow('Logistics / Shipping Fee', '- ${formatCurrency.format(settlement.shippingFee)}', color: Colors.red),
|
||||
if (settlement.otherFees > 0)
|
||||
_buildSummaryRow('Closing / Fixed Fees', '- ${formatCurrency.format(settlement.otherFees)}', color: Colors.red),
|
||||
if (settlement.feeGst > 0)
|
||||
_buildSummaryRow('GST on Marketplace Fees (18%)', '- ${formatCurrency.format(settlement.feeGst)}', color: Colors.red),
|
||||
if (settlement.tcsGstAmount > 0)
|
||||
_buildSummaryRow('GST TCS Withheld (1%)', '- ${formatCurrency.format(settlement.tcsGstAmount)}', color: Colors.red),
|
||||
if (settlement.tdsAmount > 0)
|
||||
_buildSummaryRow('TDS u/s 194O Withheld (0.1%)', '- ${formatCurrency.format(settlement.tdsAmount)}', color: Colors.red),
|
||||
const Divider(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Net Bank Remittance Payout:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
Text(
|
||||
formatCurrency.format(settlement.netPayoutAmount),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Color(0xFF10B981)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
|
||||
// Action Buttons Row: Settle Marketplace Payout / Record Payment & Process Return
|
||||
Row(
|
||||
children: [
|
||||
if (latestInvoice.status != 'RETURNED') ...[
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => CreateCreditNoteSheet(invoice: latestInvoice),
|
||||
);
|
||||
if (result != null) {
|
||||
ref.refresh(invoicesProvider);
|
||||
if (latestInvoice.id != null) {
|
||||
ref.refresh(invoiceCreditNotesProvider(latestInvoice.id!));
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(LucideIcons.undo2, size: 16, color: Colors.red),
|
||||
label: const Text('Return / Credit Note', style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
side: BorderSide(color: Colors.red.shade300),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
if (remaining > 0) ...[
|
||||
if (latestInvoice.salesChannel != null &&
|
||||
latestInvoice.salesChannel!.toUpperCase() != 'DIRECT_POS' &&
|
||||
latestInvoice.salesChannel!.toUpperCase() != 'STORE') ...[
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => SettleMarketplaceOrderSheet(invoice: latestInvoice),
|
||||
);
|
||||
if (result != null) {
|
||||
ref.refresh(invoicesProvider);
|
||||
if (latestInvoice.id != null) {
|
||||
ref.refresh(invoiceSettlementProvider(latestInvoice.id!));
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(LucideIcons.landmark, size: 16),
|
||||
label: const Text('Settle Payout', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: const Color(0xFF10B981),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _showReceivePaymentSheet(context, ref, latestInvoice),
|
||||
icon: const Icon(LucideIcons.plusCircle, size: 16),
|
||||
label: const Text('Record Payment', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
@@ -555,6 +782,23 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Color _getDispatchColor(String? status) {
|
||||
switch (status?.toUpperCase()) {
|
||||
case 'DELIVERED':
|
||||
return Colors.green;
|
||||
case 'SHIPPED':
|
||||
case 'IN_TRANSIT':
|
||||
return Colors.indigo;
|
||||
case 'PACKED':
|
||||
return Colors.blue;
|
||||
case 'RTO':
|
||||
return Colors.red;
|
||||
case 'PENDING':
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDetailBadge(String text, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
@@ -656,7 +900,9 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
|
||||
await Share.shareXFiles([XFile(imagePath.path)], text: 'Invoice $invoiceNumber');
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing image: $e')));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing image: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,31 +914,28 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
orElse: () => widget.invoice,
|
||||
) ?? widget.invoice;
|
||||
|
||||
final pdf = pw.Document();
|
||||
final business = ref.read(businessProfileProvider).value;
|
||||
final customers = ref.read(customersProvider).value ?? [];
|
||||
final customer = customers.where((c) => c.id == latestInvoice.customerId).firstOrNull;
|
||||
|
||||
final business = ref.read(businessProfileProvider).value;
|
||||
final products = ref.read(productsProvider).value ?? [];
|
||||
final categories = ref.read(productCategoriesProvider).value ?? [];
|
||||
final uoms = ref.read(uomsProvider).value ?? [];
|
||||
final indianStates = ref.read(indianStatesProvider).value ?? [];
|
||||
|
||||
final businessState = indianStates.where((s) => s.id == business?.stateId).firstOrNull;
|
||||
final customerState = indianStates.where((s) => s.id == (latestInvoice.placeOfSupplyStateId ?? customer?.stateId)).firstOrNull;
|
||||
|
||||
final isSameState = businessState != null && customerState != null && businessState.id == customerState.id;
|
||||
|
||||
final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2);
|
||||
final formatDate = DateFormat('dd MMM yyyy');
|
||||
final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2);
|
||||
|
||||
final font = await PdfGoogleFonts.robotoRegular();
|
||||
final boldFont = await PdfGoogleFonts.robotoBold();
|
||||
|
||||
final businessStateId = business?.stateId;
|
||||
final customerStateId = customer?.stateId;
|
||||
final isSameState = businessStateId != null && customerStateId != null
|
||||
? (businessStateId == customerStateId)
|
||||
: true;
|
||||
|
||||
// Group items by product ID if available, or itemize
|
||||
// Group items by productId
|
||||
final Map<int, List<InvoiceItem>> groupedItems = {};
|
||||
final List<InvoiceItem> ungroupedItems = [];
|
||||
|
||||
for (final item in latestInvoice.items) {
|
||||
for (var item in latestInvoice.items) {
|
||||
if (item.productId != null) {
|
||||
groupedItems.putIfAbsent(item.productId!, () => []).add(item);
|
||||
} else {
|
||||
@@ -700,19 +943,15 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
final pdf = pw.Document();
|
||||
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
theme: pw.ThemeData.withFont(
|
||||
base: font,
|
||||
bold: boldFont,
|
||||
),
|
||||
margin: const pw.EdgeInsets.all(28),
|
||||
build: (pw.Context context) {
|
||||
return [
|
||||
// 1. Header with Business Details & TAX INVOICE title
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
build: (context) => [
|
||||
pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 1. Enterprise Invoice Header
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
@@ -722,14 +961,13 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
business?.businessName ?? 'KIFI JEWELLERS',
|
||||
business?.businessName ?? 'KIFI ENTERPRISE',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 22,
|
||||
fontSize: 18,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.blue900,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
if (business?.address != null && business!.address!.isNotEmpty)
|
||||
pw.Text(
|
||||
business.address!,
|
||||
@@ -758,38 +996,11 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
pw.Text(
|
||||
'TAX INVOICE',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 22,
|
||||
fontSize: 18,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.blue900,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: _getEffectiveStatus(latestInvoice) == 'PAID'
|
||||
? PdfColors.green100
|
||||
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange100 : PdfColors.grey200),
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4)),
|
||||
border: pw.Border.all(
|
||||
color: _getEffectiveStatus(latestInvoice) == 'PAID'
|
||||
? PdfColors.green700
|
||||
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange700 : PdfColors.grey500),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: pw.Text(
|
||||
_getEffectiveStatus(latestInvoice),
|
||||
style: pw.TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: _getEffectiveStatus(latestInvoice) == 'PAID'
|
||||
? PdfColors.green900
|
||||
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange900 : PdfColors.grey800),
|
||||
),
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Text(
|
||||
'Invoice #: ${latestInvoice.invoiceNumber}',
|
||||
style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold),
|
||||
@@ -798,6 +1009,21 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
'Date: ${formatDate.format(latestInvoice.issueDate)}',
|
||||
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
|
||||
),
|
||||
if (latestInvoice.salesChannel != null && latestInvoice.salesChannel!.isNotEmpty)
|
||||
pw.Text(
|
||||
'Channel: ${latestInvoice.salesChannel!}',
|
||||
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900),
|
||||
),
|
||||
if (latestInvoice.marketplaceOrderId != null && latestInvoice.marketplaceOrderId!.isNotEmpty)
|
||||
pw.Text(
|
||||
'Order Ref: #${latestInvoice.marketplaceOrderId!}',
|
||||
style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700),
|
||||
),
|
||||
if (latestInvoice.courierPartner != null || latestInvoice.trackingNumber != null)
|
||||
pw.Text(
|
||||
'Courier: ${latestInvoice.courierPartner ?? ""}${latestInvoice.trackingNumber != null ? " (AWB: ${latestInvoice.trackingNumber})" : ""}',
|
||||
style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700),
|
||||
),
|
||||
if (latestInvoice.dueDate != null)
|
||||
pw.Text(
|
||||
'Due Date: ${formatDate.format(latestInvoice.dueDate!)}',
|
||||
@@ -809,7 +1035,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
),
|
||||
pw.SizedBox(height: 18),
|
||||
|
||||
// 2. Bill To Box (Customer Details)
|
||||
// 2. Bill To & Ship To Box (Customer & Delivery Details)
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.all(10),
|
||||
decoration: pw.BoxDecoration(
|
||||
@@ -836,7 +1062,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
pw.Text(
|
||||
customer?.name ?? 'Walk-in Customer',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -852,6 +1078,38 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (latestInvoice.shippingAddress != null && latestInvoice.shippingAddress!.isNotEmpty) ...[
|
||||
pw.SizedBox(width: 14),
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
'SHIP TO (DELIVERY):',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 8.5,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(
|
||||
customer?.name ?? 'Recipient',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
pw.Text(
|
||||
latestInvoice.shippingAddress! + (latestInvoice.shippingPincode != null ? ' - ${latestInvoice.shippingPincode}' : ''),
|
||||
style: const pw.TextStyle(fontSize: 9),
|
||||
),
|
||||
if (latestInvoice.courierPartner != null)
|
||||
pw.Text('Via: ${latestInvoice.courierPartner}', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1421,8 +1679,9 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ import 'package:intl/intl.dart';
|
||||
import '../providers/invoices_provider.dart';
|
||||
import '../domain/invoice.dart';
|
||||
import '../providers/customers_provider.dart';
|
||||
import '../providers/sales_channels_provider.dart';
|
||||
import 'invoice_builder_screen.dart';
|
||||
import 'invoice_details_screen.dart';
|
||||
import 'credit_notes_list_screen.dart';
|
||||
import 'marketplace_settlements_screen.dart';
|
||||
|
||||
class InvoicesListScreen extends ConsumerStatefulWidget {
|
||||
const InvoicesListScreen({super.key});
|
||||
@@ -18,6 +21,8 @@ class InvoicesListScreen extends ConsumerStatefulWidget {
|
||||
class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
String _searchQuery = '';
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String? _selectedChannelFilter;
|
||||
String? _selectedDispatchStatusFilter;
|
||||
|
||||
String _getStatusLabel(Invoice invoice) {
|
||||
if (invoice.isEmi && invoice.status != 'PAID') return 'EMI';
|
||||
@@ -47,6 +52,23 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Color _getDispatchStatusColor(String? status) {
|
||||
switch (status?.toUpperCase()) {
|
||||
case 'DELIVERED':
|
||||
return Colors.green;
|
||||
case 'SHIPPED':
|
||||
case 'IN_TRANSIT':
|
||||
return Colors.indigo;
|
||||
case 'PACKED':
|
||||
return Colors.blue;
|
||||
case 'RTO':
|
||||
return Colors.red;
|
||||
case 'PENDING':
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
@@ -57,6 +79,7 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final invoicesState = ref.watch(invoicesProvider);
|
||||
final customersState = ref.watch(customersProvider);
|
||||
final channelsState = ref.watch(salesChannelsProvider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
||||
final formatDate = DateFormat('MMM dd, yyyy');
|
||||
@@ -72,19 +95,43 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
foregroundColor: isDark ? Colors.white : Colors.black,
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.landmark),
|
||||
tooltip: 'Marketplace Settlements & Fees',
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const MarketplaceSettlementsScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.undo2),
|
||||
tooltip: 'Sales Returns & Credit Notes',
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const CreditNotesListScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Unified Search Header
|
||||
Container(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: TextField(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search Invoice # or Customer...',
|
||||
hintText: 'Search Invoice #, Order ID, Customer, Courier...',
|
||||
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300),
|
||||
@@ -105,16 +152,87 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
),
|
||||
onChanged: (val) => setState(() => _searchQuery = val.trim()),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Channel Filter Bar
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
label: const Text('All Channels', style: TextStyle(fontSize: 11)),
|
||||
selected: _selectedChannelFilter == null,
|
||||
onSelected: (selected) {
|
||||
if (selected) setState(() => _selectedChannelFilter = null);
|
||||
},
|
||||
),
|
||||
),
|
||||
...(channelsState.value ?? []).map((channel) {
|
||||
final isSelected = _selectedChannelFilter?.toLowerCase() == channel.name.toLowerCase();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
label: Text(channel.name, style: const TextStyle(fontSize: 11)),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() => _selectedChannelFilter = selected ? channel.name : null);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(width: 8),
|
||||
// Quick Dispatch Status filters
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
label: const Text('Shipped / In Transit', style: TextStyle(fontSize: 11)),
|
||||
selected: _selectedDispatchStatusFilter == 'SHIPPED',
|
||||
selectedColor: Colors.indigo.withValues(alpha: 0.2),
|
||||
onSelected: (selected) {
|
||||
setState(() => _selectedDispatchStatusFilter = selected ? 'SHIPPED' : null);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
label: const Text('Delivered', style: TextStyle(fontSize: 11)),
|
||||
selected: _selectedDispatchStatusFilter == 'DELIVERED',
|
||||
selectedColor: Colors.green.withValues(alpha: 0.2),
|
||||
onSelected: (selected) {
|
||||
setState(() => _selectedDispatchStatusFilter = selected ? 'DELIVERED' : null);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: invoicesState.when(
|
||||
data: (invoices) {
|
||||
final customers = customersState.value ?? [];
|
||||
final filtered = invoices.where((inv) {
|
||||
final matchesInv = inv.invoiceNumber.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
final q = _searchQuery.toLowerCase();
|
||||
final matchesInv = inv.invoiceNumber.toLowerCase().contains(q);
|
||||
final matchesOrder = inv.marketplaceOrderId != null && inv.marketplaceOrderId!.toLowerCase().contains(q);
|
||||
final matchesCourier = inv.courierPartner != null && inv.courierPartner!.toLowerCase().contains(q);
|
||||
final matchesAwb = inv.trackingNumber != null && inv.trackingNumber!.toLowerCase().contains(q);
|
||||
final customer = customers.where((c) => c.id == inv.customerId).firstOrNull;
|
||||
final matchesCust = customer != null && customer.name.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
return matchesInv || matchesCust;
|
||||
final matchesCust = customer != null && customer.name.toLowerCase().contains(q);
|
||||
final matchesSearch = q.isEmpty || matchesInv || matchesOrder || matchesCust || matchesCourier || matchesAwb;
|
||||
|
||||
final matchesChannel = _selectedChannelFilter == null ||
|
||||
(inv.salesChannel != null && inv.salesChannel!.toLowerCase() == _selectedChannelFilter!.toLowerCase());
|
||||
|
||||
final matchesDispatch = _selectedDispatchStatusFilter == null ||
|
||||
(inv.dispatchStatus != null && inv.dispatchStatus!.toUpperCase() == _selectedDispatchStatusFilter!.toUpperCase());
|
||||
|
||||
return matchesSearch && matchesChannel && matchesDispatch;
|
||||
}).toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
@@ -226,6 +344,21 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (invoice.marketplaceOrderId != null && invoice.marketplaceOrderId!.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.purple.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.purple.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Text(
|
||||
'#${invoice.marketplaceOrderId!}',
|
||||
style: TextStyle(fontSize: 10.5, fontWeight: FontWeight.bold, color: Colors.purple.shade700),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -294,12 +427,16 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
final weightStr = item.weight != null && item.weight! > 0
|
||||
? '${item.weight!.toStringAsFixed(3)} g'
|
||||
: '${item.quantity.toInt()} pcs';
|
||||
final name = item.productName ?? item.description ?? 'Jewellery Item';
|
||||
final name = item.productName ?? item.description ?? 'Item';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.sparkles, size: 12, color: Colors.amber.shade700),
|
||||
Icon(
|
||||
item.weight != null && item.weight! > 0 ? LucideIcons.sparkles : LucideIcons.package,
|
||||
size: 12,
|
||||
color: item.weight != null && item.weight! > 0 ? Colors.amber.shade700 : Colors.blue.shade700,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -329,6 +466,41 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Logistics / Shipping Tracker Row (if set)
|
||||
if (invoice.courierPartner != null || invoice.trackingNumber != null || (invoice.dispatchStatus != null && invoice.dispatchStatus != 'PENDING')) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.truck, size: 14, color: Colors.indigo.shade600),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${invoice.courierPartner ?? "Courier"}${invoice.trackingNumber != null ? " • AWB: ${invoice.trackingNumber}" : ""}',
|
||||
style: TextStyle(fontSize: 11.5, color: Colors.grey.shade700, fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: _getDispatchStatusColor(invoice.dispatchStatus).withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: _getDispatchStatusColor(invoice.dispatchStatus).withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Text(
|
||||
invoice.dispatchStatus ?? 'PENDING',
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _getDispatchStatusColor(invoice.dispatchStatus),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Row 3: Date & Total Amount
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -788,7 +788,10 @@ class _PurchaseOrderBuilderScreenState
|
||||
builder: (context) {
|
||||
final products = ref.watch(productsProvider).value ?? [];
|
||||
final categories = ref.watch(productCategoriesProvider).value ?? [];
|
||||
final businessState = ref.watch(businessProfileProvider).value?.stateId;
|
||||
final businessProfile = ref.watch(businessProfileProvider).value;
|
||||
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||
final isJewellery = nature == 'JEWELLERY';
|
||||
final businessState = businessProfile?.stateId;
|
||||
final vendorState = _selectedVendorId == null
|
||||
? null
|
||||
: vendorsState.value?.where((v) => v.id == _selectedVendorId).firstOrNull?.stateId;
|
||||
@@ -812,7 +815,7 @@ class _PurchaseOrderBuilderScreenState
|
||||
|
||||
final effectiveGstRate = (effectiveTaxable > 0 && taxTotal > 0)
|
||||
? (taxTotal / effectiveTaxable) * 100.0
|
||||
: (_items.isNotEmpty ? _items.first.gstRate : (isJewellery ? 3.0 : 18.0));
|
||||
: (_items.isNotEmpty ? _items.first.taxRate : (isJewellery ? 3.0 : 18.0));
|
||||
final cgstRate = effectiveGstRate / 2.0;
|
||||
final sgstRate = effectiveGstRate / 2.0;
|
||||
final igstRate = effectiveGstRate;
|
||||
|
||||
Reference in New Issue
Block a user