6 Commits

92 changed files with 17913 additions and 1408 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -0,0 +1,56 @@
package com.kifi.api.controller.barcode;
import com.kifi.api.entity.barcode.BarcodeLabelTemplate;
import com.kifi.api.service.barcode.BarcodeLabelTemplateService;
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/barcode-templates")
@RequiredArgsConstructor
public class BarcodeLabelTemplateController {
private final BarcodeLabelTemplateService templateService;
@GetMapping
public Flux<BarcodeLabelTemplate> getTemplates(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return templateService.getActiveTemplates(userId);
}
@GetMapping("/{id}")
public Mono<BarcodeLabelTemplate> getTemplateById(
Authentication authentication,
@PathVariable Long id) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return templateService.getTemplateById(userId, id);
}
@PostMapping
public Mono<BarcodeLabelTemplate> createTemplate(
Authentication authentication,
@RequestBody BarcodeLabelTemplate template) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return templateService.createTemplate(userId, template);
}
@PutMapping("/{id}")
public Mono<BarcodeLabelTemplate> updateTemplate(
Authentication authentication,
@PathVariable Long id,
@RequestBody BarcodeLabelTemplate template) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return templateService.updateTemplate(userId, id, template);
}
@DeleteMapping("/{id}")
public Mono<Void> deleteTemplate(
Authentication authentication,
@PathVariable Long id) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return templateService.deleteTemplate(userId, id);
}
}

View File

@@ -55,6 +55,7 @@ public class ProductCategoryController {
existing.setDefaultMakingCharge(category.getDefaultMakingCharge());
existing.setMakingChargeType(category.getMakingChargeType());
existing.setBaseUnit(category.getBaseUnit());
existing.setDefaultLengthUom(category.getDefaultLengthUom());
existing.setIsActive(category.getIsActive());
existing.setSortOrder(category.getSortOrder());

View File

@@ -0,0 +1,48 @@
package com.kifi.api.controller.invoice;
import com.kifi.api.entity.invoice.CreditNote;
import com.kifi.api.service.invoice.CreditNoteService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/kifi-v2")
@RequiredArgsConstructor
public class CreditNoteController {
private final CreditNoteService creditNoteService;
@GetMapping("/credit-notes")
public Flux<CreditNote> getCreditNotes(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return creditNoteService.getCreditNotes(userId);
}
@GetMapping("/credit-notes/{id}")
public Mono<CreditNote> getCreditNoteById(
@PathVariable Long id,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return creditNoteService.getCreditNoteById(id, userId);
}
@GetMapping("/invoices/{invoiceId}/credit-notes")
public Flux<CreditNote> getCreditNotesForInvoice(
@PathVariable Long invoiceId,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return creditNoteService.getCreditNotesForInvoice(invoiceId, userId);
}
@PostMapping("/invoices/{invoiceId}/credit-notes")
public Mono<CreditNote> createCreditNote(
@PathVariable Long invoiceId,
Authentication authentication,
@RequestBody CreditNote creditNote) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return creditNoteService.createCreditNote(userId, invoiceId, creditNote);
}
}

View File

@@ -0,0 +1,48 @@
package com.kifi.api.controller.invoice;
import com.kifi.api.entity.invoice.MarketplaceSettlement;
import com.kifi.api.service.invoice.MarketplaceSettlementService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/kifi-v2")
@RequiredArgsConstructor
public class MarketplaceSettlementController {
private final MarketplaceSettlementService settlementService;
@GetMapping("/marketplace-settlements")
public Flux<MarketplaceSettlement> getSettlements(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return settlementService.getSettlements(userId);
}
@GetMapping("/marketplace-settlements/{id}")
public Mono<MarketplaceSettlement> getSettlementById(
@PathVariable Long id,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return settlementService.getSettlementById(id, userId);
}
@GetMapping("/invoices/{invoiceId}/settlements")
public Flux<MarketplaceSettlement> getSettlementsForInvoice(
@PathVariable Long invoiceId,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return settlementService.getSettlementsForInvoice(invoiceId, userId);
}
@PostMapping("/invoices/{invoiceId}/settlements")
public Mono<MarketplaceSettlement> createSettlement(
@PathVariable Long invoiceId,
Authentication authentication,
@RequestBody MarketplaceSettlement settlement) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return settlementService.createSettlement(userId, invoiceId, settlement);
}
}

View File

@@ -0,0 +1,54 @@
package com.kifi.api.controller.sales;
import com.kifi.api.entity.sales.SalesChannel;
import com.kifi.api.service.sales.SalesChannelService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/kifi-v2/sales-channels")
@RequiredArgsConstructor
public class SalesChannelController {
private final SalesChannelService salesChannelService;
@GetMapping
public Flux<SalesChannel> getSalesChannels(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return salesChannelService.getActiveChannels(userId);
}
@GetMapping("/all")
public Flux<SalesChannel> getAllSalesChannels(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return salesChannelService.getAllChannels(userId);
}
@PostMapping
public Mono<SalesChannel> createSalesChannel(
Authentication authentication,
@RequestBody SalesChannel channel) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return salesChannelService.createChannel(userId, channel);
}
@PutMapping("/{id}")
public Mono<SalesChannel> updateSalesChannel(
@PathVariable Long id,
Authentication authentication,
@RequestBody SalesChannel channel) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return salesChannelService.updateChannel(userId, id, channel);
}
@DeleteMapping("/{id}")
public Mono<Void> deleteSalesChannel(
@PathVariable Long id,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return salesChannelService.deleteChannel(userId, id);
}
}

View File

@@ -0,0 +1,71 @@
package com.kifi.api.entity.barcode;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table("barcode_label_templates")
public class BarcodeLabelTemplate {
@Id
private Long id;
@Column("user_id")
private Long userId;
@Column("template_name")
private String templateName;
private String description;
@Builder.Default
private String category = "PRODUCT";
@Builder.Default
private Integer version = 1;
@Column("label_width_mm")
@Builder.Default
private Double labelWidthMm = 50.0;
@Column("label_height_mm")
@Builder.Default
private Double labelHeightMm = 25.0;
@Column("measurement_unit")
@Builder.Default
private String measurementUnit = "mm";
@Column("printer_language")
@Builder.Default
private String printerLanguage = "ZPL";
@Builder.Default
private Integer dpi = 203;
@Column("configuration_json")
@Builder.Default
private String configurationJson = "{}";
@Column("template_json")
private String templateJson;
@Column("is_active")
@Builder.Default
private Boolean isActive = true;
@Column("created_at")
private LocalDateTime createdAt;
@Column("updated_at")
private LocalDateTime updatedAt;
}

View File

@@ -33,6 +33,9 @@ public class BusinessFeature {
@Column("barcode_source")
private String barcodeSource;
@Column("enable_ecommerce_channels")
private Boolean enableEcommerceChannels;
@Column("created_at")
private LocalDateTime createdAt;
}

View File

@@ -39,6 +39,9 @@ public class InventoryItem {
private Long vendorId;
private Long branchId;
private String purchaseRef;
private String salesRef;
private BigDecimal saleRate;
private String photoUrl;
@Builder.Default
private String status = "AVAILABLE";
private LocalDateTime createdAt;

View File

@@ -5,6 +5,7 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.math.BigDecimal;
@@ -28,16 +29,50 @@ public class Product {
private String barcode;
private String hsnCode;
private String description;
private BigDecimal mrp;
private BigDecimal sellingPrice;
private BigDecimal gstRate;
private String dimensions;
private String color;
private String size;
private Double itemLength;
private Double itemWidth;
private Double itemHeight;
private String dimensionUom;
private Double packageLength;
private Double packageWidth;
private Double packageHeight;
private String packageDimensionUom;
private String manufacturerCode;
private String material;
private String brandName;
private String countryOfOrigin;
private Double weightInG;
private Double packageWeightInG;
private Double volumetricWeightInKg;
private String priceCalcRule;
private Double purityFactor;
private Double makingCharges;
private String makingChargesType;
private Double wastagePercentage;
@Column("min_stock")
private BigDecimal minStock;
@Column("reorder_level")
private BigDecimal reorderLevel;
@Column("reorder_quantity")
private BigDecimal reorderQuantity;
@Column("track_inventory")
private Boolean trackInventory;
private Boolean isActive;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;

View File

@@ -34,6 +34,8 @@ public class ProductCategory {
private String makingChargeType;
private String baseUnit;
@Builder.Default
private String defaultLengthUom = "cm";
@Builder.Default
private Boolean isActive = true;

View File

@@ -0,0 +1,86 @@
package com.kifi.api.entity.invoice;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Transient;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table("credit_notes")
public class CreditNote {
@Id
private Long id;
@Column("user_id")
private Long userId;
@Column("invoice_id")
private Long invoiceId;
@Column("credit_note_number")
private String creditNoteNumber;
@Column("customer_id")
private Long customerId;
@Column("return_date")
private LocalDate returnDate;
@Column("reason")
private String reason; // CUSTOMER_RETURN, RTO_UNDELIVERED, DEFECTIVE, EXCHANGE, OTHER
@Column("restock_items")
@Builder.Default
private Boolean restockItems = true;
@Column("subtotal")
private BigDecimal subtotal;
@Column("tax_total")
private BigDecimal taxTotal;
@Column("cgst_total")
private BigDecimal cgstTotal;
@Column("sgst_total")
private BigDecimal sgstTotal;
@Column("igst_total")
private BigDecimal igstTotal;
@Column("discount_total")
private BigDecimal discountTotal;
@Column("total_amount")
private BigDecimal totalAmount;
@Column("refund_status")
private String refundStatus; // ADJUSTED_TO_AR, REFUNDED_TO_WALLET, STORE_CREDIT, PENDING
@Column("refund_wallet_id")
private Long refundWalletId;
@Column("notes")
private String notes;
@Column("created_at")
private LocalDateTime createdAt;
@Column("updated_at")
private LocalDateTime updatedAt;
@Transient
private List<CreditNoteItem> items;
}

View File

@@ -0,0 +1,78 @@
package com.kifi.api.entity.invoice;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table("credit_note_items")
public class CreditNoteItem {
@Id
private Long id;
@Column("credit_note_id")
private Long creditNoteId;
@Column("invoice_item_id")
private Long invoiceItemId;
@Column("product_id")
private Long productId;
@Column("inventory_item_id")
private Long inventoryItemId;
@Column("product_name")
private String productName;
@Column("sku")
private String sku;
@Column("huid")
private String huid;
@Column("hsn_code")
private String hsnCode;
@Column("quantity")
private BigDecimal quantity;
@Column("weight")
private BigDecimal weight;
@Column("unit_price")
private BigDecimal unitPrice;
@Column("tax_rate")
private BigDecimal taxRate;
@Column("cgst")
private BigDecimal cgst;
@Column("sgst")
private BigDecimal sgst;
@Column("igst")
private BigDecimal igst;
@Column("discount")
private BigDecimal discount;
@Column("making_charge")
private BigDecimal makingCharge;
@Column("other_charges")
private BigDecimal otherCharges;
@Column("total")
private BigDecimal total;
}

View File

@@ -80,6 +80,39 @@ public class Invoice {
@Column("emi_start_date")
private LocalDate emiStartDate;
@Column("sales_channel_id")
private Long salesChannelId;
@Column("sales_channel")
private String salesChannel;
@Column("marketplace_order_id")
private String marketplaceOrderId;
@Column("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;
@Column("place_of_supply")
private String placeOfSupply;
@Column("created_at")
private LocalDateTime createdAt;

View File

@@ -0,0 +1,90 @@
package com.kifi.api.entity.invoice;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table("marketplace_settlements")
public class MarketplaceSettlement {
@Id
private Long id;
@Column("user_id")
private Long userId;
@Column("invoice_id")
private Long invoiceId;
@Column("sales_channel_id")
private Long salesChannelId;
@Column("channel_name")
private String channelName;
@Column("settlement_ref")
private String settlementRef;
@Column("settlement_date")
private LocalDate settlementDate;
@Column("gross_amount")
private BigDecimal grossAmount;
@Column("commission_fee")
@Builder.Default
private BigDecimal commissionFee = BigDecimal.ZERO;
@Column("shipping_fee")
@Builder.Default
private BigDecimal shippingFee = BigDecimal.ZERO;
@Column("other_fees")
@Builder.Default
private BigDecimal otherFees = BigDecimal.ZERO;
@Column("fee_gst")
@Builder.Default
private BigDecimal feeGst = BigDecimal.ZERO;
@Column("tcs_gst_amount")
@Builder.Default
private BigDecimal tcsGstAmount = BigDecimal.ZERO;
@Column("tds_amount")
@Builder.Default
private BigDecimal tdsAmount = BigDecimal.ZERO;
@Column("total_deductions")
private BigDecimal totalDeductions;
@Column("net_payout_amount")
private BigDecimal netPayoutAmount;
@Column("payout_wallet_id")
private Long payoutWalletId;
@Column("status")
@Builder.Default
private String status = "SETTLED";
@Column("notes")
private String notes;
@Column("created_at")
private LocalDateTime createdAt;
@Column("updated_at")
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,43 @@
package com.kifi.api.entity.sales;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table("sales_channels")
public class SalesChannel {
@Id
private Long id;
@Column("user_id")
private Long userId;
private String name;
private String code;
private String icon;
@Column("default_ledger_id")
private Long defaultLedgerId;
@Column("is_active")
@Builder.Default
private Boolean isActive = true;
@Column("created_at")
private LocalDateTime createdAt;
@Column("updated_at")
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,15 @@
package com.kifi.api.repository.barcode;
import com.kifi.api.entity.barcode.BarcodeLabelTemplate;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface BarcodeLabelTemplateRepository extends R2dbcRepository<BarcodeLabelTemplate, Long> {
Flux<BarcodeLabelTemplate> findByUserIdAndIsActiveTrueOrderByUpdatedAtDesc(Long userId);
Flux<BarcodeLabelTemplate> findByUserIdOrderByUpdatedAtDesc(Long userId);
Mono<BarcodeLabelTemplate> findByIdAndUserId(Long id, Long userId);
}

View File

@@ -0,0 +1,9 @@
package com.kifi.api.repository.invoice;
import com.kifi.api.entity.invoice.CreditNoteItem;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
public interface CreditNoteItemRepository extends ReactiveCrudRepository<CreditNoteItem, Long> {
Flux<CreditNoteItem> findByCreditNoteId(Long creditNoteId);
}

View File

@@ -0,0 +1,13 @@
package com.kifi.api.repository.invoice;
import com.kifi.api.entity.invoice.CreditNote;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface CreditNoteRepository extends ReactiveCrudRepository<CreditNote, Long> {
Flux<CreditNote> findByUserId(Long userId);
Flux<CreditNote> findByUserIdAndInvoiceId(Long userId, Long invoiceId);
Flux<CreditNote> findByUserIdAndCustomerId(Long userId, Long customerId);
Mono<CreditNote> findByUserIdAndCreditNoteNumber(Long userId, String creditNoteNumber);
}

View File

@@ -0,0 +1,13 @@
package com.kifi.api.repository.invoice;
import com.kifi.api.entity.invoice.MarketplaceSettlement;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface MarketplaceSettlementRepository extends ReactiveCrudRepository<MarketplaceSettlement, Long> {
Flux<MarketplaceSettlement> findByUserId(Long userId);
Flux<MarketplaceSettlement> findByUserIdAndInvoiceId(Long userId, Long invoiceId);
Flux<MarketplaceSettlement> findByUserIdAndSalesChannelId(Long userId, Long salesChannelId);
Mono<MarketplaceSettlement> findByUserIdAndSettlementRef(Long userId, String settlementRef);
}

View File

@@ -0,0 +1,18 @@
package com.kifi.api.repository.sales;
import com.kifi.api.entity.sales.SalesChannel;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface SalesChannelRepository extends ReactiveCrudRepository<SalesChannel, Long> {
@Query("SELECT * FROM sales_channels WHERE (user_id = :userId OR user_id IS NULL) AND is_active = true ORDER BY id ASC")
Flux<SalesChannel> findActiveChannelsForUser(Long userId);
@Query("SELECT * FROM sales_channels WHERE (user_id = :userId OR user_id IS NULL) ORDER BY id ASC")
Flux<SalesChannel> findAllChannelsForUser(Long userId);
Mono<SalesChannel> findByIdAndUserId(Long id, Long userId);
}

View File

@@ -0,0 +1,73 @@
package com.kifi.api.service.barcode;
import com.kifi.api.entity.barcode.BarcodeLabelTemplate;
import com.kifi.api.repository.barcode.BarcodeLabelTemplateRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@Slf4j
public class BarcodeLabelTemplateService {
private final BarcodeLabelTemplateRepository templateRepository;
public Flux<BarcodeLabelTemplate> getActiveTemplates(Long userId) {
return templateRepository.findByUserIdAndIsActiveTrueOrderByUpdatedAtDesc(userId);
}
public Mono<BarcodeLabelTemplate> getTemplateById(Long userId, Long id) {
return templateRepository.findByIdAndUserId(id, userId);
}
public Mono<BarcodeLabelTemplate> createTemplate(Long userId, BarcodeLabelTemplate template) {
template.setId(null);
template.setUserId(userId);
template.setCreatedAt(LocalDateTime.now());
template.setUpdatedAt(LocalDateTime.now());
if (template.getIsActive() == null) {
template.setIsActive(true);
}
if (template.getVersion() == null) {
template.setVersion(1);
}
return templateRepository.save(template);
}
public Mono<BarcodeLabelTemplate> updateTemplate(Long userId, Long id, BarcodeLabelTemplate template) {
return templateRepository.findByIdAndUserId(id, userId)
.switchIfEmpty(Mono.error(new IllegalArgumentException("Template not found or access denied: " + id)))
.flatMap(existing -> {
if (template.getTemplateName() != null) existing.setTemplateName(template.getTemplateName());
if (template.getDescription() != null) existing.setDescription(template.getDescription());
if (template.getCategory() != null) existing.setCategory(template.getCategory());
if (template.getLabelWidthMm() != null) existing.setLabelWidthMm(template.getLabelWidthMm());
if (template.getLabelHeightMm() != null) existing.setLabelHeightMm(template.getLabelHeightMm());
if (template.getMeasurementUnit() != null) existing.setMeasurementUnit(template.getMeasurementUnit());
if (template.getPrinterLanguage() != null) existing.setPrinterLanguage(template.getPrinterLanguage());
if (template.getDpi() != null) existing.setDpi(template.getDpi());
if (template.getConfigurationJson() != null) existing.setConfigurationJson(template.getConfigurationJson());
if (template.getTemplateJson() != null) existing.setTemplateJson(template.getTemplateJson());
if (template.getIsActive() != null) existing.setIsActive(template.getIsActive());
existing.setVersion((existing.getVersion() != null ? existing.getVersion() : 1) + 1);
existing.setUpdatedAt(LocalDateTime.now());
return templateRepository.save(existing);
});
}
public Mono<Void> deleteTemplate(Long userId, Long id) {
return templateRepository.findByIdAndUserId(id, userId)
.flatMap(existing -> {
existing.setIsActive(false);
existing.setUpdatedAt(LocalDateTime.now());
return templateRepository.save(existing);
})
.then();
}
}

View File

@@ -37,6 +37,7 @@ public class BusinessService {
.multiLocation(false)
.bomReductionStrategy("COMPONENTS_ONLY")
.stockDeductionOnInvoice(true)
.enableEcommerceChannels(false)
.createdAt(LocalDateTime.now())
.build());
}
@@ -51,6 +52,7 @@ public class BusinessService {
if (feature.getBomReductionStrategy() != null) existing.setBomReductionStrategy(feature.getBomReductionStrategy());
if (feature.getStockDeductionOnInvoice() != null) existing.setStockDeductionOnInvoice(feature.getStockDeductionOnInvoice());
if (feature.getBarcodeSource() != null) existing.setBarcodeSource(feature.getBarcodeSource());
if (feature.getEnableEcommerceChannels() != null) existing.setEnableEcommerceChannels(feature.getEnableEcommerceChannels());
return businessFeatureRepository.save(existing);
})
.switchIfEmpty(Mono.defer(() -> {

View File

@@ -55,6 +55,7 @@ public class InventoryItemService {
if (updatedItem.getVendorId() != null) existingItem.setVendorId(updatedItem.getVendorId());
if (updatedItem.getBranchId() != null) existingItem.setBranchId(updatedItem.getBranchId());
if (updatedItem.getPurchaseRef() != null) existingItem.setPurchaseRef(updatedItem.getPurchaseRef());
if (updatedItem.getPhotoUrl() != null) existingItem.setPhotoUrl(updatedItem.getPhotoUrl());
if (updatedItem.getStatus() != null) existingItem.setStatus(updatedItem.getStatus());
existingItem.setUpdatedAt(LocalDateTime.now());
return inventoryItemRepository.save(existingItem);

View File

@@ -96,17 +96,44 @@ public class ProductService {
existingProduct.setSku(updatedProduct.getSku());
existingProduct.setCategoryId(updatedProduct.getCategoryId());
existingProduct.setUomId(updatedProduct.getUomId());
existingProduct.setMrp(updatedProduct.getMrp());
existingProduct.setSellingPrice(updatedProduct.getSellingPrice());
existingProduct.setGstRate(updatedProduct.getGstRate());
existingProduct.setHsnCode(updatedProduct.getHsnCode());
existingProduct.setColor(updatedProduct.getColor());
existingProduct.setSize(updatedProduct.getSize());
existingProduct.setDimensions(updatedProduct.getDimensions());
existingProduct.setItemLength(updatedProduct.getItemLength());
existingProduct.setItemWidth(updatedProduct.getItemWidth());
existingProduct.setItemHeight(updatedProduct.getItemHeight());
existingProduct.setDimensionUom(updatedProduct.getDimensionUom());
existingProduct.setPackageLength(updatedProduct.getPackageLength());
existingProduct.setPackageWidth(updatedProduct.getPackageWidth());
existingProduct.setPackageHeight(updatedProduct.getPackageHeight());
existingProduct.setPackageDimensionUom(updatedProduct.getPackageDimensionUom());
existingProduct.setManufacturerCode(updatedProduct.getManufacturerCode());
existingProduct.setMaterial(updatedProduct.getMaterial());
existingProduct.setBrandName(updatedProduct.getBrandName());
existingProduct.setCountryOfOrigin(updatedProduct.getCountryOfOrigin());
existingProduct.setWeightInG(updatedProduct.getWeightInG());
existingProduct.setPackageWeightInG(updatedProduct.getPackageWeightInG());
existingProduct.setVolumetricWeightInKg(updatedProduct.getVolumetricWeightInKg());
existingProduct.setPriceCalcRule(updatedProduct.getPriceCalcRule());
existingProduct.setPurityFactor(normalizePurity(updatedProduct.getPurityFactor()));
existingProduct.setMakingCharges(updatedProduct.getMakingCharges());
existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType());
existingProduct.setWastagePercentage(updatedProduct.getWastagePercentage());
existingProduct.setMinStock(updatedProduct.getMinStock());
existingProduct.setReorderLevel(updatedProduct.getReorderLevel());
existingProduct.setReorderQuantity(updatedProduct.getReorderQuantity());
existingProduct.setTrackInventory(updatedProduct.getTrackInventory());
existingProduct.setIsActive(updatedProduct.getIsActive());
existingProduct.setUpdatedAt(LocalDateTime.now());
return productRepository.save(existingProduct);

View File

@@ -0,0 +1,239 @@
package com.kifi.api.service.invoice;
import com.kifi.api.entity.Transaction;
import com.kifi.api.entity.inventory.InventoryItem;
import com.kifi.api.entity.invoice.CreditNote;
import com.kifi.api.entity.invoice.CreditNoteItem;
import com.kifi.api.entity.invoice.Invoice;
import com.kifi.api.repository.customer.CustomerRepository;
import com.kifi.api.repository.inventory.InventoryItemRepository;
import com.kifi.api.repository.invoice.CreditNoteItemRepository;
import com.kifi.api.repository.invoice.CreditNoteRepository;
import com.kifi.api.repository.invoice.InvoiceItemRepository;
import com.kifi.api.repository.invoice.InvoiceRepository;
import com.kifi.api.service.TransactionService;
import com.kifi.api.service.accounting.LedgerService;
import com.kifi.api.service.inventory.InventoryItemService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional
public class CreditNoteService {
private final CreditNoteRepository creditNoteRepository;
private final CreditNoteItemRepository creditNoteItemRepository;
private final InvoiceRepository invoiceRepository;
private final InvoiceItemRepository invoiceItemRepository;
private final InventoryItemRepository inventoryItemRepository;
private final InventoryItemService inventoryItemService;
private final LedgerService ledgerService;
private final CustomerRepository customerRepository;
private final TransactionService transactionService;
public Flux<CreditNote> getCreditNotes(Long userId) {
return creditNoteRepository.findByUserId(userId)
.flatMap(this::populateItems);
}
public Flux<CreditNote> getCreditNotesForInvoice(Long invoiceId, Long userId) {
return creditNoteRepository.findByUserIdAndInvoiceId(userId, invoiceId)
.flatMap(this::populateItems);
}
public Mono<CreditNote> getCreditNoteById(Long id, Long userId) {
return creditNoteRepository.findById(id)
.filter(cn -> cn.getUserId().equals(userId))
.switchIfEmpty(Mono.error(new RuntimeException("Credit note not found or unauthorized")))
.flatMap(this::populateItems);
}
private Mono<CreditNote> populateItems(CreditNote cn) {
return creditNoteItemRepository.findByCreditNoteId(cn.getId())
.collectList()
.map(items -> {
cn.setItems(items);
return cn;
});
}
public Mono<CreditNote> createCreditNote(Long userId, Long invoiceId, CreditNote creditNote) {
return invoiceRepository.findById(invoiceId)
.filter(inv -> inv.getUserId().equals(userId))
.switchIfEmpty(Mono.error(new RuntimeException("Invoice not found or unauthorized")))
.flatMap(invoice -> {
creditNote.setUserId(userId);
creditNote.setInvoiceId(invoiceId);
creditNote.setCustomerId(invoice.getCustomerId());
if (creditNote.getReturnDate() == null) {
creditNote.setReturnDate(LocalDate.now());
}
if (creditNote.getCreditNoteNumber() == null || creditNote.getCreditNoteNumber().trim().isEmpty()) {
creditNote.setCreditNoteNumber("CN-" + System.currentTimeMillis() % 1000000);
}
creditNote.setCreatedAt(LocalDateTime.now());
creditNote.setUpdatedAt(LocalDateTime.now());
if (creditNote.getRefundStatus() == null || creditNote.getRefundStatus().trim().isEmpty()) {
creditNote.setRefundStatus("ADJUSTED_TO_AR");
}
// Calculate totals from items if not provided
List<CreditNoteItem> items = creditNote.getItems() != null ? creditNote.getItems() : new ArrayList<>();
BigDecimal subtotal = BigDecimal.ZERO;
BigDecimal taxTotal = BigDecimal.ZERO;
BigDecimal cgstTotal = BigDecimal.ZERO;
BigDecimal sgstTotal = BigDecimal.ZERO;
BigDecimal igstTotal = BigDecimal.ZERO;
BigDecimal totalAmount = BigDecimal.ZERO;
for (CreditNoteItem itm : items) {
BigDecimal qty = itm.getWeight() != null && itm.getWeight().compareTo(BigDecimal.ZERO) > 0
? itm.getWeight()
: (itm.getQuantity() != null ? itm.getQuantity() : BigDecimal.ONE);
BigDecimal unitPrice = itm.getUnitPrice() != null ? itm.getUnitPrice() : BigDecimal.ZERO;
BigDecimal baseAmt = qty.multiply(unitPrice);
BigDecimal making = itm.getMakingCharge() != null ? itm.getMakingCharge() : BigDecimal.ZERO;
BigDecimal disc = itm.getDiscount() != null ? itm.getDiscount() : BigDecimal.ZERO;
BigDecimal other = itm.getOtherCharges() != null ? itm.getOtherCharges() : BigDecimal.ZERO;
BigDecimal cgst = itm.getCgst() != null ? itm.getCgst() : BigDecimal.ZERO;
BigDecimal sgst = itm.getSgst() != null ? itm.getSgst() : BigDecimal.ZERO;
BigDecimal igst = itm.getIgst() != null ? itm.getIgst() : BigDecimal.ZERO;
BigDecimal tax = cgst.add(sgst).add(igst);
BigDecimal lineTotal = baseAmt.add(making).add(other).subtract(disc).add(tax);
itm.setTotal(lineTotal);
subtotal = subtotal.add(baseAmt);
taxTotal = taxTotal.add(tax);
cgstTotal = cgstTotal.add(cgst);
sgstTotal = sgstTotal.add(sgst);
igstTotal = igstTotal.add(igst);
totalAmount = totalAmount.add(lineTotal);
}
if (creditNote.getSubtotal() == null) creditNote.setSubtotal(subtotal);
if (creditNote.getTaxTotal() == null) creditNote.setTaxTotal(taxTotal);
if (creditNote.getCgstTotal() == null) creditNote.setCgstTotal(cgstTotal);
if (creditNote.getSgstTotal() == null) creditNote.setSgstTotal(sgstTotal);
if (creditNote.getIgstTotal() == null) creditNote.setIgstTotal(igstTotal);
if (creditNote.getTotalAmount() == null) creditNote.setTotalAmount(totalAmount);
return creditNoteRepository.save(creditNote)
.flatMap(savedCn -> {
Mono<List<CreditNoteItem>> itemsMono = Flux.fromIterable(items)
.flatMap(item -> {
item.setCreditNoteId(savedCn.getId());
return creditNoteItemRepository.save(item);
})
.collectList();
return itemsMono.flatMap(savedItems -> {
savedCn.setItems(savedItems);
// 1. Stock Restoration (if restockItems is true)
Mono<Void> restockMono = Mono.empty();
if (Boolean.TRUE.equals(savedCn.getRestockItems())) {
restockMono = Flux.fromIterable(savedItems)
.flatMap(item -> {
// Inward restock back into inventory
if (item.getInventoryItemId() != null) {
return inventoryItemRepository.findById(item.getInventoryItemId())
.flatMap(invItem -> {
invItem.setStatus("AVAILABLE");
invItem.setSalesRef(null);
invItem.setUpdatedAt(LocalDateTime.now());
return inventoryItemRepository.save(invItem).then();
});
} else if (item.getProductId() != null) {
BigDecimal restockQty = item.getWeight() != null && item.getWeight().compareTo(BigDecimal.ZERO) > 0
? item.getWeight()
: (item.getQuantity() != null ? item.getQuantity() : BigDecimal.ONE);
InventoryItem restockRecord = InventoryItem.builder()
.userId(userId)
.productId(item.getProductId())
.sku(item.getSku())
.huid(item.getHuid())
.grossWeight(restockQty)
.netWeight(restockQty)
.purchaseCost(item.getUnitPrice())
.purchaseRef("RETURN-" + savedCn.getCreditNoteNumber())
.status("AVAILABLE")
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
return inventoryItemRepository.save(restockRecord).then();
}
return Mono.empty();
})
.then();
}
// 2. Financial / Ledger Accounting Adjustment
Mono<Void> accountingMono = Mono.empty();
if ("REFUNDED_TO_WALLET".equalsIgnoreCase(savedCn.getRefundStatus()) && savedCn.getRefundWalletId() != null) {
Transaction refundTx = Transaction.builder()
.userId(userId)
.fromWalletId(savedCn.getRefundWalletId()) // Cash/Bank decreases
.type("EXPENSE")
.amount(savedCn.getTotalAmount())
.date(savedCn.getReturnDate())
.description("Refund for Return #" + savedCn.getCreditNoteNumber() + " (Invoice #" + invoice.getInvoiceNumber() + ")")
.createdAt(LocalDateTime.now())
.build();
accountingMono = transactionService.addTransaction(userId, refundTx).then();
} else {
// Customer AR adjustment
if (invoice.getCustomerId() != null) {
accountingMono = customerRepository.findById(invoice.getCustomerId())
.flatMap(customer -> Mono.zip(
ledgerService.getCustomerLedger(userId, customer.getId(), customer.getName()),
ledgerService.getSalesRevenueLedger(userId)
).flatMap(ledgers -> {
Transaction returnTx = Transaction.builder()
.userId(userId)
.fromWalletId(ledgers.getT1().getId()) // Customer AR (Asset decreases)
.toWalletId(ledgers.getT2().getId()) // Sales Revenue (Income decreases)
.type("SALES_RETURN")
.amount(savedCn.getTotalAmount())
.date(savedCn.getReturnDate())
.description("Credit Note #" + savedCn.getCreditNoteNumber() + " for Invoice #" + invoice.getInvoiceNumber())
.createdAt(LocalDateTime.now())
.build();
return transactionService.addTransaction(userId, returnTx).then();
})).onErrorResume(err -> {
System.err.println("AR adjustment error: " + err.getMessage());
return Mono.empty();
});
}
}
// 3. Update Invoice Status
invoice.setStatus(savedCn.getTotalAmount().compareTo(invoice.getTotalAmount()) >= 0 ? "RETURNED" : "PARTIALLY_RETURNED");
if ("RTO_UNDELIVERED".equalsIgnoreCase(savedCn.getReason())) {
invoice.setDispatchStatus("RTO");
}
invoice.setUpdatedAt(LocalDateTime.now());
Mono<Invoice> updateInvoiceMono = invoiceRepository.save(invoice);
return restockMono
.then(accountingMono)
.then(updateInvoiceMono)
.thenReturn(savedCn);
});
});
});
}
}

View File

@@ -146,15 +146,64 @@ public class InvoiceService {
if (shouldDeduct && invoice.getItems() != null && !invoice.getItems().isEmpty()) {
return Flux.fromIterable(invoice.getItems())
.concatMap(item -> {
BigDecimal soldQty = (item.getWeight() != null && item.getWeight().compareTo(BigDecimal.ZERO) > 0)
? item.getWeight()
: (item.getQuantity() != null && item.getQuantity().compareTo(BigDecimal.ZERO) > 0 ? item.getQuantity() : BigDecimal.ONE);
BigDecimal soldRate = item.getUnitPrice() != null ? item.getUnitPrice() : BigDecimal.ZERO;
if (item.getInventoryItemId() != null) {
return inventoryItemRepository.findById(item.getInventoryItemId())
.flatMap(invItem -> {
invItem.setStatus("SOLD");
BigDecimal currentWeight = (invItem.getGrossWeight() != null && invItem.getGrossWeight().compareTo(BigDecimal.ZERO) > 0)
? invItem.getGrossWeight()
: ((invItem.getNetWeight() != null && invItem.getNetWeight().compareTo(BigDecimal.ZERO) > 0)
? invItem.getNetWeight()
: BigDecimal.ONE);
BigDecimal unitCost = invItem.getPurchaseCost() != null ? invItem.getPurchaseCost() : BigDecimal.ZERO;
BigDecimal cost = unitCost.multiply(soldQty);
if (currentWeight.compareTo(soldQty) > 0) {
// Partial sale: Reduce existing available item weight
BigDecimal remaining = currentWeight.subtract(soldQty);
invItem.setGrossWeight(remaining);
invItem.setNetWeight(remaining);
invItem.setUpdatedAt(LocalDateTime.now());
// Create new SOLD item record
com.kifi.api.entity.inventory.InventoryItem soldItem = com.kifi.api.entity.inventory.InventoryItem.builder()
.userId(userId)
.productId(invItem.getProductId())
.vendorId(invItem.getVendorId())
.purchaseRef(invItem.getPurchaseRef())
.salesRef(invoice.getInvoiceNumber())
.purchaseCost(invItem.getPurchaseCost())
.saleRate(soldRate)
.makingCharges(invItem.getMakingCharges())
.sku(invItem.getSku())
.huid(invItem.getHuid())
.grossWeight(soldQty)
.netWeight(soldQty)
.photoUrl(invItem.getPhotoUrl())
.status("SOLD")
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
return inventoryItemRepository.save(invItem)
.then(inventoryItemRepository.save(soldItem))
.thenReturn(cost);
} else {
// Full sale
invItem.setStatus("SOLD");
invItem.setSalesRef(invoice.getInvoiceNumber());
invItem.setSaleRate(soldRate);
invItem.setUpdatedAt(LocalDateTime.now());
BigDecimal cost = invItem.getPurchaseCost() != null ? invItem.getPurchaseCost() : BigDecimal.ZERO;
return inventoryItemRepository.save(invItem).thenReturn(cost);
}
}).defaultIfEmpty(BigDecimal.ZERO);
} else if (item.getProductId() != null) {
// Product sale without explicit inventory item: adjust product stock & FIFO deduct inventory items
InventoryMovement movement = InventoryMovement.builder()
.userId(userId)
.type("REDUCTION")
@@ -167,8 +216,82 @@ public class InvoiceService {
movementItem.setQuantity(item.getQuantity());
movement.setItems(java.util.Collections.singletonList(movementItem));
return productService.adjustStock(userId, item.getProductId(), movement)
.thenReturn(BigDecimal.ZERO);
Mono<Void> adjustMono = productService.adjustStock(userId, item.getProductId(), movement).then();
Mono<BigDecimal> fifoMono = inventoryItemRepository.findByUserIdAndProductId(userId, item.getProductId())
.filter(i -> "AVAILABLE".equalsIgnoreCase(i.getStatus()))
.collectList()
.flatMap(availItems -> {
if (availItems.isEmpty()) {
return Mono.just(BigDecimal.ZERO);
}
// Strict FIFO: Sort by purchase/creation date & time in ASCENDING order
availItems.sort((a, b) -> {
if (a.getCreatedAt() == null && b.getCreatedAt() == null) {
return Long.compare(a.getId() != null ? a.getId() : 0L, b.getId() != null ? b.getId() : 0L);
}
if (a.getCreatedAt() == null) return 1;
if (b.getCreatedAt() == null) return -1;
int cmp = a.getCreatedAt().compareTo(b.getCreatedAt());
return cmp != 0 ? cmp : Long.compare(a.getId() != null ? a.getId() : 0L, b.getId() != null ? b.getId() : 0L);
});
BigDecimal needed = soldQty;
java.util.List<Mono<Void>> saves = new java.util.ArrayList<>();
BigDecimal totalCost = BigDecimal.ZERO;
for (com.kifi.api.entity.inventory.InventoryItem invItem : availItems) {
if (needed.compareTo(BigDecimal.ZERO) <= 0) break;
BigDecimal itemWeight = (invItem.getGrossWeight() != null && invItem.getGrossWeight().compareTo(BigDecimal.ZERO) > 0)
? invItem.getGrossWeight()
: ((invItem.getNetWeight() != null && invItem.getNetWeight().compareTo(BigDecimal.ZERO) > 0)
? invItem.getNetWeight()
: BigDecimal.ONE);
BigDecimal take = needed.min(itemWeight);
needed = needed.subtract(take);
BigDecimal unitPurchase = invItem.getPurchaseCost() != null ? invItem.getPurchaseCost() : BigDecimal.ZERO;
totalCost = totalCost.add(unitPurchase.multiply(take));
if (itemWeight.compareTo(take) > 0) {
BigDecimal remaining = itemWeight.subtract(take);
invItem.setGrossWeight(remaining);
invItem.setNetWeight(remaining);
invItem.setUpdatedAt(LocalDateTime.now());
saves.add(inventoryItemRepository.save(invItem).then());
com.kifi.api.entity.inventory.InventoryItem soldRecord = com.kifi.api.entity.inventory.InventoryItem.builder()
.userId(userId)
.productId(invItem.getProductId())
.vendorId(invItem.getVendorId())
.purchaseRef(invItem.getPurchaseRef())
.salesRef(invoice.getInvoiceNumber())
.purchaseCost(invItem.getPurchaseCost())
.saleRate(soldRate)
.makingCharges(invItem.getMakingCharges())
.sku(invItem.getSku())
.huid(invItem.getHuid())
.grossWeight(take)
.netWeight(take)
.photoUrl(invItem.getPhotoUrl())
.status("SOLD")
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
saves.add(inventoryItemRepository.save(soldRecord).then());
} else {
invItem.setStatus("SOLD");
invItem.setSalesRef(invoice.getInvoiceNumber());
invItem.setSaleRate(soldRate);
invItem.setUpdatedAt(LocalDateTime.now());
saves.add(inventoryItemRepository.save(invItem).then());
}
}
BigDecimal finalCost = totalCost;
return Flux.concat(saves).then(Mono.just(finalCost));
});
return adjustMono.then(fifoMono);
}
return Mono.just(BigDecimal.ZERO);
})

View File

@@ -0,0 +1,128 @@
package com.kifi.api.service.invoice;
import com.kifi.api.entity.Transaction;
import com.kifi.api.entity.invoice.Invoice;
import com.kifi.api.entity.invoice.InvoicePayment;
import com.kifi.api.entity.invoice.MarketplaceSettlement;
import com.kifi.api.repository.customer.CustomerRepository;
import com.kifi.api.repository.invoice.InvoicePaymentRepository;
import com.kifi.api.repository.invoice.InvoiceRepository;
import com.kifi.api.repository.invoice.MarketplaceSettlementRepository;
import com.kifi.api.service.TransactionService;
import com.kifi.api.service.accounting.LedgerService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@Transactional
public class MarketplaceSettlementService {
private final MarketplaceSettlementRepository settlementRepository;
private final InvoiceRepository invoiceRepository;
private final InvoicePaymentRepository invoicePaymentRepository;
private final CustomerRepository customerRepository;
private final LedgerService ledgerService;
private final TransactionService transactionService;
public Flux<MarketplaceSettlement> getSettlements(Long userId) {
return settlementRepository.findByUserId(userId);
}
public Flux<MarketplaceSettlement> getSettlementsForInvoice(Long invoiceId, Long userId) {
return settlementRepository.findByUserIdAndInvoiceId(userId, invoiceId);
}
public Mono<MarketplaceSettlement> getSettlementById(Long id, Long userId) {
return settlementRepository.findById(id)
.filter(s -> s.getUserId().equals(userId))
.switchIfEmpty(Mono.error(new RuntimeException("Settlement not found or unauthorized")));
}
public Mono<MarketplaceSettlement> createSettlement(Long userId, Long invoiceId, MarketplaceSettlement settlement) {
return invoiceRepository.findById(invoiceId)
.filter(inv -> inv.getUserId().equals(userId))
.switchIfEmpty(Mono.error(new RuntimeException("Invoice not found or unauthorized")))
.flatMap(invoice -> {
settlement.setUserId(userId);
settlement.setInvoiceId(invoiceId);
if (settlement.getSalesChannelId() == null) {
settlement.setSalesChannelId(invoice.getSalesChannelId());
}
if (settlement.getChannelName() == null || settlement.getChannelName().trim().isEmpty()) {
settlement.setChannelName(invoice.getSalesChannel() != null ? invoice.getSalesChannel() : "Marketplace");
}
if (settlement.getSettlementDate() == null) {
settlement.setSettlementDate(LocalDate.now());
}
if (settlement.getGrossAmount() == null) {
settlement.setGrossAmount(invoice.getTotalAmount());
}
if (settlement.getCommissionFee() == null) settlement.setCommissionFee(BigDecimal.ZERO);
if (settlement.getShippingFee() == null) settlement.setShippingFee(BigDecimal.ZERO);
if (settlement.getOtherFees() == null) settlement.setOtherFees(BigDecimal.ZERO);
if (settlement.getFeeGst() == null) settlement.setFeeGst(BigDecimal.ZERO);
if (settlement.getTcsGstAmount() == null) settlement.setTcsGstAmount(BigDecimal.ZERO);
if (settlement.getTdsAmount() == null) settlement.setTdsAmount(BigDecimal.ZERO);
BigDecimal totalDeductions = settlement.getCommissionFee()
.add(settlement.getShippingFee())
.add(settlement.getOtherFees())
.add(settlement.getFeeGst())
.add(settlement.getTcsGstAmount())
.add(settlement.getTdsAmount());
settlement.setTotalDeductions(totalDeductions);
BigDecimal netPayout = settlement.getGrossAmount().subtract(totalDeductions);
settlement.setNetPayoutAmount(netPayout);
settlement.setStatus("SETTLED");
settlement.setCreatedAt(LocalDateTime.now());
settlement.setUpdatedAt(LocalDateTime.now());
return settlementRepository.save(settlement)
.flatMap(savedSettlement -> {
// 1. Record Bank Inflow Transaction
Mono<Void> txMono = Mono.empty();
if (savedSettlement.getPayoutWalletId() != null && netPayout.compareTo(BigDecimal.ZERO) > 0) {
Transaction netDepositTx = Transaction.builder()
.userId(userId)
.toWalletId(savedSettlement.getPayoutWalletId()) // Cash/Bank increases
.type("INCOME")
.amount(netPayout)
.date(savedSettlement.getSettlementDate())
.description("Payout for " + savedSettlement.getChannelName() + " Order #" + (invoice.getMarketplaceOrderId() != null ? invoice.getMarketplaceOrderId() : invoice.getInvoiceNumber()) + " (Ref: " + (savedSettlement.getSettlementRef() != null ? savedSettlement.getSettlementRef() : "Settlement") + ")")
.createdAt(LocalDateTime.now())
.build();
txMono = transactionService.addTransaction(userId, netDepositTx).then();
}
// 2. Record Invoice Payment (Clears Invoice Receivables)
InvoicePayment payment = InvoicePayment.builder()
.invoiceId(invoiceId)
.amount(invoice.getTotalAmount())
.paymentDate(savedSettlement.getSettlementDate())
.createdAt(LocalDateTime.now())
.build();
Mono<InvoicePayment> paymentMono = invoicePaymentRepository.save(payment);
// 3. Mark Invoice as PAID / SETTLED
invoice.setStatus("PAID");
invoice.setUpdatedAt(LocalDateTime.now());
Mono<Invoice> updateInvoiceMono = invoiceRepository.save(invoice);
return txMono
.then(paymentMono)
.then(updateInvoiceMono)
.thenReturn(savedSettlement);
});
});
}
}

View File

@@ -0,0 +1,55 @@
package com.kifi.api.service.sales;
import com.kifi.api.entity.sales.SalesChannel;
import com.kifi.api.repository.sales.SalesChannelRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@Transactional
public class SalesChannelService {
private final SalesChannelRepository salesChannelRepository;
public Flux<SalesChannel> getActiveChannels(Long userId) {
return salesChannelRepository.findActiveChannelsForUser(userId);
}
public Flux<SalesChannel> getAllChannels(Long userId) {
return salesChannelRepository.findAllChannelsForUser(userId);
}
public Mono<SalesChannel> createChannel(Long userId, SalesChannel channel) {
channel.setUserId(userId);
channel.setCreatedAt(LocalDateTime.now());
channel.setUpdatedAt(LocalDateTime.now());
if (channel.getIsActive() == null) {
channel.setIsActive(true);
}
return salesChannelRepository.save(channel);
}
public Mono<SalesChannel> updateChannel(Long userId, Long id, SalesChannel updated) {
return salesChannelRepository.findByIdAndUserId(id, userId)
.flatMap(existing -> {
if (updated.getName() != null) existing.setName(updated.getName());
if (updated.getCode() != null) existing.setCode(updated.getCode());
if (updated.getIcon() != null) existing.setIcon(updated.getIcon());
if (updated.getDefaultLedgerId() != null) existing.setDefaultLedgerId(updated.getDefaultLedgerId());
if (updated.getIsActive() != null) existing.setIsActive(updated.getIsActive());
existing.setUpdatedAt(LocalDateTime.now());
return salesChannelRepository.save(existing);
});
}
public Mono<Void> deleteChannel(Long userId, Long id) {
return salesChannelRepository.findByIdAndUserId(id, userId)
.flatMap(salesChannelRepository::delete);
}
}

View File

@@ -195,6 +195,7 @@ public class PurchaseOrderService {
.huid(item.getHuid())
.grossWeight(item.getWeight())
.netWeight(item.getWeight())
.photoUrl(item.getPhotoUrl())
.status("AVAILABLE")
.build();

View File

@@ -208,6 +208,7 @@ CREATE TABLE IF NOT EXISTS product_categories (
commodity_code VARCHAR(10),
purity_factor DECIMAL(5, 4) DEFAULT 1.0,
base_unit VARCHAR(20) DEFAULT 'pcs',
default_length_uom VARCHAR(20) DEFAULT 'cm',
is_active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
@@ -243,12 +244,28 @@ CREATE TABLE IF NOT EXISTS products (
barcode VARCHAR(100),
description TEXT,
purchase_price DECIMAL(15, 2),
mrp DECIMAL(15, 2),
selling_price DECIMAL(15, 2),
gst_rate DECIMAL(5, 2),
dimensions VARCHAR(100),
weight DECIMAL(10, 3),
color VARCHAR(50),
size VARCHAR(50),
item_length DECIMAL(10, 2),
item_width DECIMAL(10, 2),
item_height DECIMAL(10, 2),
dimension_uom VARCHAR(20) DEFAULT 'cm',
package_length DECIMAL(10, 2),
package_width DECIMAL(10, 2),
package_height DECIMAL(10, 2),
package_dimension_uom VARCHAR(20) DEFAULT 'cm',
manufacturer_code VARCHAR(100),
material VARCHAR(100),
brand_name VARCHAR(100),
country_of_origin VARCHAR(100),
weight_in_g DECIMAL(10, 3),
package_weight_in_g DECIMAL(10, 3),
volumetric_weight_in_kg DECIMAL(10, 3),
price_calc_rule VARCHAR(50) DEFAULT 'MANUAL',
purity_factor DECIMAL(5, 4),
making_charges DECIMAL(15, 2) DEFAULT 0.0,
@@ -256,6 +273,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,
@@ -383,6 +401,9 @@ CREATE TABLE IF NOT EXISTS inventory_items (
vendor_id INTEGER REFERENCES vendors(id) ON DELETE SET NULL,
branch_id INTEGER,
purchase_ref VARCHAR(100),
sales_ref VARCHAR(100),
sale_rate DECIMAL(15, 2),
photo_url VARCHAR(1024),
status VARCHAR(50) DEFAULT 'AVAILABLE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
@@ -476,6 +497,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,
@@ -510,3 +601,25 @@ CREATE TABLE IF NOT EXISTS project_task_comments (
images TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS barcode_label_templates (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
template_name VARCHAR(150) NOT NULL,
description TEXT,
category VARCHAR(50) DEFAULT 'PRODUCT',
version INTEGER DEFAULT 1,
label_width_mm DOUBLE PRECISION NOT NULL DEFAULT 50.0,
label_height_mm DOUBLE PRECISION NOT NULL DEFAULT 25.0,
measurement_unit VARCHAR(10) DEFAULT 'mm',
printer_language VARCHAR(20) DEFAULT 'ZPL',
dpi INTEGER DEFAULT 203,
configuration_json TEXT NOT NULL DEFAULT '{}',
template_json TEXT NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_barcode_templates_user ON barcode_label_templates(user_id);

View File

@@ -1,4 +1,7 @@
flutter_icons:
android: true
ios: true
web:
generate: true
image_path: "assets/icon.png"
image_path: "assets/icon.png"

View File

@@ -26,6 +26,8 @@ class DesktopSidebar extends ConsumerWidget {
final isDark = theme.brightness == Brightness.dark;
final isBusinessMode = ref.watch(businessModeProvider);
final businessProfile = ref.watch(businessProfileProvider).asData?.value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final ratesAsync = ref.watch(commodityRatesProvider);
return Container(
@@ -51,22 +53,23 @@ class DesktopSidebar extends ConsumerWidget {
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFD4AF37), Color(0xFFAA771C)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: const Color(0xFFD4AF37).withValues(alpha: 0.3),
blurRadius: 10,
offset: const Offset(0, 4),
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Center(
child: Icon(LucideIcons.gem, color: Colors.white, size: 22),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.asset(
'assets/icon.png',
width: 40,
height: 40,
fit: BoxFit.cover,
),
),
),
const SizedBox(width: 12),
@@ -276,7 +279,8 @@ class DesktopSidebar extends ConsumerWidget {
),
),
// 4. Live Metal Rate Widget in Sidebar
// 4. Live Metal Rate Widget in Sidebar (Jewellery only)
if (isJewellery)
ratesAsync.when(
data: (rates) {
if (rates.isEmpty) return const SizedBox.shrink();

View File

@@ -63,10 +63,13 @@ class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
_filterItems(_controller.text);
}
if (widget.value != oldWidget.value) {
if (widget.value != null) {
_controller.text = widget.itemAsString(widget.value as T);
} else {
_controller.text = '';
final newText = widget.value != null ? widget.itemAsString(widget.value as T) : '';
if (_controller.text != newText) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _controller.text != newText) {
_controller.text = newText;
}
});
}
}
}

View File

@@ -131,19 +131,28 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
Center(
child: Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: Colors.transparent,
shape: BoxShape.circle,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 16,
offset: const Offset(0, 4),
),
child: Center(
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/logo.png',
'assets/icon.png',
width: 80,
height: 80,
fit: BoxFit.contain,
fit: BoxFit.cover,
),
),
),
),

View File

@@ -12,8 +12,7 @@ class SetupWizardScreen extends StatefulWidget {
State<SetupWizardScreen> createState() => _SetupWizardScreenState();
}
class _SetupWizardScreenState extends State<SetupWizardScreen>
with SingleTickerProviderStateMixin {
class _SetupWizardScreenState extends State<SetupWizardScreen> {
bool _isLoading = false;
int _currentStep = 0;
String _accountType = 'INDIVIDUAL';
@@ -40,6 +39,19 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
int get _totalSteps => _accountType == 'BUSINESS' ? 4 : 3;
@override
void dispose() {
_nameController.dispose();
_usernameController.dispose();
_businessNameController.dispose();
_addressController.dispose();
_emailController.dispose();
_gstController.dispose();
_panController.dispose();
_msmeController.dispose();
super.dispose();
}
Future<void> _checkUsername(String username) async {
if (username.length < 3) return;
setState(() => _checkingUsername = true);
@@ -111,6 +123,7 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
}
void _nextStep() {
FocusScope.of(context).unfocus();
if (_currentStep == 1) {
if (_nameController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -150,6 +163,7 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
}
void _prevStep() {
FocusScope.of(context).unfocus();
if (_currentStep > 0) {
setState(() => _currentStep--);
}
@@ -209,7 +223,6 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
Widget _buildStep0AccountType() {
return Column(
key: const ValueKey(0),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
@@ -324,7 +337,6 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
Widget _buildStep1Personal() {
return Column(
key: const ValueKey(1),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
@@ -354,6 +366,7 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
duration: const Duration(milliseconds: 200),
child: _checkingUsername
? const SizedBox(
key: ValueKey('user_check_spinner'),
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
@@ -362,7 +375,7 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
_isUsernameAvailable
? LucideIcons.checkCircle2
: LucideIcons.xCircle,
key: ValueKey(_isUsernameAvailable),
key: ValueKey('user_avail_${_isUsernameAvailable}'),
color: _usernameController.text.isEmpty
? Colors.transparent
: (_isUsernameAvailable
@@ -388,7 +401,6 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
Widget _buildStep2Business() {
return Column(
key: const ValueKey(2),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
@@ -410,6 +422,10 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
value: 'JEWELLERY',
child: Text('Jewellery', style: TextStyle(color: Colors.black87)),
),
DropdownMenuItem(
value: 'ECOMMERCE',
child: Text('E-Commerce Seller', style: TextStyle(color: Colors.black87)),
),
DropdownMenuItem(
value: 'PROJECT_MANAGEMENT',
child: Text(
@@ -474,7 +490,6 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
Widget _buildStep3Consent() {
return Column(
key: const ValueKey(3),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
@@ -556,12 +571,11 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
);
}
Widget _buildCurrentStep() {
if (_currentStep == 0) return _buildStep0AccountType();
if (_currentStep == 1) return _buildStep1Personal();
if (_accountType == 'BUSINESS' && _currentStep == 2)
return _buildStep2Business();
return _buildStep3Consent(); // Step 2 (Individual) or Step 3 (Business)
int get _stackIndex {
if (_accountType == 'INDIVIDUAL' && _currentStep == 2) {
return 3;
}
return _currentStep;
}
@override
@@ -587,28 +601,38 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.05, 0),
end: Offset.zero,
).animate(animation),
child: FadeTransition(opacity: animation, child: child),
);
},
child: SingleChildScrollView(
key: ValueKey(_currentStep),
physics: const BouncingScrollPhysics(),
child: IndexedStack(
index: _stackIndex,
children: [
SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 24.0,
vertical: 16.0,
),
child: _buildCurrentStep(),
child: _buildStep0AccountType(),
),
SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 24.0,
vertical: 16.0,
),
child: _buildStep1Personal(),
),
SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 24.0,
vertical: 16.0,
),
child: _buildStep2Business(),
),
SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 24.0,
vertical: 16.0,
),
child: _buildStep3Consent(),
),
],
),
),
),

View File

@@ -0,0 +1,122 @@
import 'dart:convert';
import 'label_document.dart';
class BarcodeTemplate {
final int? id;
final int? userId;
final String templateName;
final String? description;
final String category;
final int version;
final double labelWidthMm;
final double labelHeightMm;
final String measurementUnit;
final String printerLanguage;
final int dpi;
final String configurationJson;
final String templateJson;
final bool isActive;
final DateTime? createdAt;
final DateTime? updatedAt;
BarcodeTemplate({
this.id,
this.userId,
required this.templateName,
this.description,
this.category = 'PRODUCT',
this.version = 1,
this.labelWidthMm = 50.0,
this.labelHeightMm = 25.0,
this.measurementUnit = 'mm',
this.printerLanguage = 'ZPL',
this.dpi = 203,
this.configurationJson = '{}',
required this.templateJson,
this.isActive = true,
this.createdAt,
this.updatedAt,
});
factory BarcodeTemplate.fromJson(Map<String, dynamic> json) {
return BarcodeTemplate(
id: json['id'] as int?,
userId: json['userId'] as int?,
templateName: json['templateName'] as String? ?? 'Untitled Template',
description: json['description'] as String?,
category: json['category'] as String? ?? 'PRODUCT',
version: json['version'] as int? ?? 1,
labelWidthMm: (json['labelWidthMm'] as num?)?.toDouble() ?? 50.0,
labelHeightMm: (json['labelHeightMm'] as num?)?.toDouble() ?? 25.0,
measurementUnit: json['measurementUnit'] as String? ?? 'mm',
printerLanguage: json['printerLanguage'] as String? ?? 'ZPL',
dpi: json['dpi'] as int? ?? 203,
configurationJson: json['configurationJson'] as String? ?? '{}',
templateJson: json['templateJson'] as String? ?? '{}',
isActive: json['isActive'] as bool? ?? true,
createdAt: json['createdAt'] != null ? DateTime.tryParse(json['createdAt'].toString()) : null,
updatedAt: json['updatedAt'] != null ? DateTime.tryParse(json['updatedAt'].toString()) : null,
);
}
Map<String, dynamic> toJson() => {
if (id != null) 'id': id,
if (userId != null) 'userId': userId,
'templateName': templateName,
'description': description,
'category': category,
'version': version,
'labelWidthMm': labelWidthMm,
'labelHeightMm': labelHeightMm,
'measurementUnit': measurementUnit,
'printerLanguage': printerLanguage,
'dpi': dpi,
'configurationJson': configurationJson,
'templateJson': templateJson,
'isActive': isActive,
};
LabelDocument toLabelDocument() {
try {
final parsed = jsonDecode(templateJson) as Map<String, dynamic>;
return LabelDocument.fromJson(
parsed,
id: id?.toString(),
name: templateName,
description: description,
);
} catch (_) {
return LabelDocument(
id: id?.toString() ?? 'temp',
name: templateName,
description: description,
version: version,
config: LabelConfiguration(
widthMm: labelWidthMm,
heightMm: labelHeightMm,
dpi: dpi,
printerLanguage: printerLanguage,
),
);
}
}
static BarcodeTemplate fromLabelDocument({
int? id,
required LabelDocument doc,
}) {
return BarcodeTemplate(
id: id,
templateName: doc.name,
description: doc.description,
version: doc.version,
labelWidthMm: doc.config.widthMm,
labelHeightMm: doc.config.heightMm,
measurementUnit: 'mm',
printerLanguage: doc.config.printerLanguage,
dpi: doc.config.dpi,
configurationJson: jsonEncode(doc.config.toJson()),
templateJson: doc.toJsonString(),
);
}
}

View File

@@ -0,0 +1,152 @@
class DynamicFieldDefinition {
final String source; // e.g. "product"
final String fieldKey; // e.g. "name", "sellingPrice", "barcode"
final String displayName; // e.g. "Product Name"
final String category; // e.g. "Identification", "Pricing", "Specifications"
final String dataType; // "string", "currency", "weight", "number"
final bool isBarcodeCompatible;
final String? defaultPrefix;
final String? defaultSuffix;
const DynamicFieldDefinition({
required this.source,
required this.fieldKey,
required this.displayName,
required this.category,
this.dataType = 'string',
this.isBarcodeCompatible = false,
this.defaultPrefix,
this.defaultSuffix,
});
String get placeholder => '{{$source.$fieldKey}}';
static const List<DynamicFieldDefinition> productFields = [
DynamicFieldDefinition(
source: 'product',
fieldKey: 'name',
displayName: 'Product Name',
category: 'General',
dataType: 'string',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'sku',
displayName: 'SKU',
category: 'Identification',
dataType: 'string',
isBarcodeCompatible: true,
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'barcode',
displayName: 'Barcode',
category: 'Identification',
dataType: 'string',
isBarcodeCompatible: true,
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'sellingPrice',
displayName: 'Selling Price',
category: 'Pricing',
dataType: 'currency',
defaultPrefix: '',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'mrp',
displayName: 'MRP',
category: 'Pricing',
dataType: 'currency',
defaultPrefix: 'MRP ₹',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'weightInG',
displayName: 'Weight',
category: 'Measurements',
dataType: 'weight',
defaultSuffix: ' g',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'hsnCode',
displayName: 'HSN Code',
category: 'Tax & Compliance',
dataType: 'string',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'gstRate',
displayName: 'GST Rate',
category: 'Tax & Compliance',
dataType: 'number',
defaultSuffix: '%',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'brandName',
displayName: 'Brand',
category: 'General',
dataType: 'string',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'material',
displayName: 'Material / Metal',
category: 'Specifications',
dataType: 'string',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'size',
displayName: 'Size',
category: 'Specifications',
dataType: 'string',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'color',
displayName: 'Color',
category: 'Specifications',
dataType: 'string',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'manufacturerCode',
displayName: 'Tag / Serial / Code',
category: 'Identification',
dataType: 'string',
isBarcodeCompatible: true,
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'purityFactor',
displayName: 'Purity Factor',
category: 'Specifications',
dataType: 'number',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'description',
displayName: 'Description',
category: 'General',
dataType: 'string',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'currentStock',
displayName: 'Current Stock',
category: 'Inventory',
dataType: 'number',
),
];
static DynamicFieldDefinition? find(String source, String fieldKey) {
if (source == 'product') {
return productFields.where((f) => f.fieldKey == fieldKey).firstOrNull;
}
return null;
}
}

View File

@@ -0,0 +1,956 @@
import 'dart:convert';
import 'package:flutter/material.dart';
enum BarcodeType {
code128,
ean13,
code39,
upca,
qrCode,
}
enum LabelOrientation {
portrait,
landscape,
}
enum ElementType {
text,
dynamicText,
barcode,
qrCode,
rectangle,
line,
}
class LabelConfiguration {
final double widthMm;
final double heightMm;
final double marginTopMm;
final double marginBottomMm;
final double marginLeftMm;
final double marginRightMm;
final int dpi; // 203, 300, 600
final LabelOrientation orientation;
final String printerLanguage; // ZPL, TSPL, ESCPOS, PCL
final int columnsAcross; // 1-Up (single), 2-Up (twin track), 3-Up, 4-Up
final double horizontalGapMm; // Gap between side-by-side labels in mm
final double verticalGapMm; // Gap between consecutive labels along roll feed in mm
const LabelConfiguration({
this.widthMm = 50.0,
this.heightMm = 25.0,
this.marginTopMm = 1.0,
this.marginBottomMm = 1.0,
this.marginLeftMm = 1.0,
this.marginRightMm = 1.0,
this.dpi = 203,
this.orientation = LabelOrientation.portrait,
this.printerLanguage = 'ZPL',
this.columnsAcross = 1,
this.horizontalGapMm = 2.0,
this.verticalGapMm = 2.0,
});
/// Total roll web width including all columns and gaps between them
double get totalWebWidthMm {
final cols = columnsAcross.clamp(1, 4);
if (cols <= 1) return widthMm;
return (widthMm * cols) + (horizontalGapMm * (cols - 1));
}
int get totalWebWidthDots => mmToDots(totalWebWidthMm);
LabelConfiguration copyWith({
double? widthMm,
double? heightMm,
double? marginTopMm,
double? marginBottomMm,
double? marginLeftMm,
double? marginRightMm,
int? dpi,
LabelOrientation? orientation,
String? printerLanguage,
int? columnsAcross,
double? horizontalGapMm,
double? verticalGapMm,
}) {
return LabelConfiguration(
widthMm: widthMm ?? this.widthMm,
heightMm: heightMm ?? this.heightMm,
marginTopMm: marginTopMm ?? this.marginTopMm,
marginBottomMm: marginBottomMm ?? this.marginBottomMm,
marginLeftMm: marginLeftMm ?? this.marginLeftMm,
marginRightMm: marginRightMm ?? this.marginRightMm,
dpi: dpi ?? this.dpi,
orientation: orientation ?? this.orientation,
printerLanguage: printerLanguage ?? this.printerLanguage,
columnsAcross: columnsAcross ?? this.columnsAcross,
horizontalGapMm: horizontalGapMm ?? this.horizontalGapMm,
verticalGapMm: verticalGapMm ?? this.verticalGapMm,
);
}
Map<String, dynamic> toJson() => {
'widthMm': widthMm,
'heightMm': heightMm,
'marginTopMm': marginTopMm,
'marginBottomMm': marginBottomMm,
'marginLeftMm': marginLeftMm,
'marginRightMm': marginRightMm,
'dpi': dpi,
'orientation': orientation.name,
'printerLanguage': printerLanguage,
'columnsAcross': columnsAcross,
'horizontalGapMm': horizontalGapMm,
'verticalGapMm': verticalGapMm,
};
factory LabelConfiguration.fromJson(Map<String, dynamic> json) {
return LabelConfiguration(
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 50.0,
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 25.0,
marginTopMm: (json['marginTopMm'] as num?)?.toDouble() ?? 1.0,
marginBottomMm: (json['marginBottomMm'] as num?)?.toDouble() ?? 1.0,
marginLeftMm: (json['marginLeftMm'] as num?)?.toDouble() ?? 1.0,
marginRightMm: (json['marginRightMm'] as num?)?.toDouble() ?? 1.0,
dpi: json['dpi'] as int? ?? 203,
orientation: json['orientation'] == 'landscape'
? LabelOrientation.landscape
: LabelOrientation.portrait,
printerLanguage: json['printerLanguage'] as String? ?? 'ZPL',
columnsAcross: (json['columnsAcross'] as num?)?.toInt() ?? 1,
horizontalGapMm: (json['horizontalGapMm'] as num?)?.toDouble() ?? 2.0,
verticalGapMm: (json['verticalGapMm'] as num?)?.toDouble() ?? 2.0,
);
}
int mmToDots(double mm) => ((mm / 25.4) * dpi).round();
int get widthDots => mmToDots(widthMm);
int get heightDots => mmToDots(heightMm);
}
abstract class LabelElement {
final String id;
final ElementType type;
final double xMm;
final double yMm;
final double widthMm;
final double heightMm;
final double rotation; // 0, 90, 180, 270
final bool isLocked;
final int zIndex;
const LabelElement({
required this.id,
required this.type,
required this.xMm,
required this.yMm,
required this.widthMm,
required this.heightMm,
this.rotation = 0.0,
this.isLocked = false,
this.zIndex = 0,
});
LabelElement copyWithPosition({
double? xMm,
double? yMm,
double? widthMm,
double? heightMm,
double? rotation,
bool? isLocked,
int? zIndex,
});
double get borderWidthMm => 0.0;
Map<String, dynamic> toJson();
static LabelElement fromJson(Map<String, dynamic> json) {
final typeStr = json['type'] as String? ?? 'text';
switch (typeStr) {
case 'dynamicText':
return DynamicTextElement.fromJson(json);
case 'barcode':
return BarcodeElement.fromJson(json);
case 'qrCode':
return QrCodeElement.fromJson(json);
case 'rectangle':
return RectangleElement.fromJson(json);
case 'line':
return LineElement.fromJson(json);
case 'text':
default:
return TextElement.fromJson(json);
}
}
}
class TextElement extends LabelElement {
final String text;
final String fontFamily;
final double fontSizePt;
final bool isBold;
final bool isItalic;
final TextAlign alignment;
@override
final double borderWidthMm;
final double paddingMm;
const TextElement({
required super.id,
required super.xMm,
required super.yMm,
required super.widthMm,
required super.heightMm,
super.rotation = 0.0,
super.isLocked = false,
super.zIndex = 0,
this.text = 'kifi',
this.fontFamily = 'Roboto',
this.fontSizePt = 9.0,
this.isBold = false,
this.isItalic = false,
this.alignment = TextAlign.left,
this.borderWidthMm = 0.0,
this.paddingMm = 0.0,
}) : super(type: ElementType.text);
@override
TextElement copyWithPosition({
double? xMm,
double? yMm,
double? widthMm,
double? heightMm,
double? rotation,
bool? isLocked,
int? zIndex,
String? text,
String? fontFamily,
double? fontSizePt,
bool? isBold,
bool? isItalic,
TextAlign? alignment,
double? borderWidthMm,
double? paddingMm,
}) {
return TextElement(
id: id,
xMm: xMm ?? this.xMm,
yMm: yMm ?? this.yMm,
widthMm: widthMm ?? this.widthMm,
heightMm: heightMm ?? this.heightMm,
rotation: rotation ?? this.rotation,
isLocked: isLocked ?? this.isLocked,
zIndex: zIndex ?? this.zIndex,
text: text ?? this.text,
fontFamily: fontFamily ?? this.fontFamily,
fontSizePt: fontSizePt ?? this.fontSizePt,
isBold: isBold ?? this.isBold,
isItalic: isItalic ?? this.isItalic,
alignment: alignment ?? this.alignment,
borderWidthMm: borderWidthMm ?? this.borderWidthMm,
paddingMm: paddingMm ?? this.paddingMm,
);
}
@override
Map<String, dynamic> toJson() => {
'id': id,
'type': 'text',
'xMm': xMm,
'yMm': yMm,
'widthMm': widthMm,
'heightMm': heightMm,
'rotation': rotation,
'isLocked': isLocked,
'zIndex': zIndex,
'text': text,
'fontFamily': fontFamily,
'fontSizePt': fontSizePt,
'isBold': isBold,
'isItalic': isItalic,
'alignment': alignment.name,
'borderWidthMm': borderWidthMm,
'paddingMm': paddingMm,
};
factory TextElement.fromJson(Map<String, dynamic> json) {
return TextElement(
id: json['id'] as String,
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 20.0,
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 5.0,
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
isLocked: json['isLocked'] as bool? ?? false,
zIndex: json['zIndex'] as int? ?? 0,
text: json['text'] as String? ?? json['value'] as String? ?? 'kifi',
fontFamily: json['fontFamily'] as String? ?? 'Roboto',
fontSizePt: (json['fontSizePt'] as num?)?.toDouble() ?? 9.0,
isBold: json['isBold'] as bool? ?? false,
isItalic: json['isItalic'] as bool? ?? false,
alignment: TextAlign.values.where((a) => a.name == json['alignment']).firstOrNull ?? TextAlign.left,
borderWidthMm: (json['borderWidthMm'] as num?)?.toDouble() ?? 0.0,
paddingMm: (json['paddingMm'] as num?)?.toDouble() ?? 0.0,
);
}
}
class DynamicTextElement extends LabelElement {
final String source; // "product"
final String fieldKey; // "name", "sellingPrice", etc.
final String? prefix;
final String? suffix;
final String fallbackValue;
final String fontFamily;
final double fontSizePt;
final bool isBold;
final bool isItalic;
final TextAlign alignment;
@override
final double borderWidthMm;
final double paddingMm;
final bool autoFit;
final int maxLines;
const DynamicTextElement({
required super.id,
required super.xMm,
required super.yMm,
required super.widthMm,
required super.heightMm,
super.rotation = 0.0,
super.isLocked = false,
super.zIndex = 0,
this.source = 'product',
this.fieldKey = '',
this.prefix,
this.suffix,
this.fallbackValue = 'N/A',
this.fontFamily = 'Roboto',
this.fontSizePt = 9.0,
this.isBold = false,
this.isItalic = false,
this.alignment = TextAlign.left,
this.borderWidthMm = 0.0,
this.paddingMm = 0.0,
this.autoFit = true,
this.maxLines = 2,
}) : super(type: ElementType.dynamicText);
String get placeholder => fieldKey.isNotEmpty ? '{{$source.$fieldKey}}' : '{{select_field}}';
String formatValue(String? resolvedRaw) {
if (resolvedRaw == null || resolvedRaw.trim().isEmpty) {
return fallbackValue;
}
final p = prefix ?? '';
final s = suffix ?? '';
return '$p$resolvedRaw$s';
}
@override
DynamicTextElement copyWithPosition({
double? xMm,
double? yMm,
double? widthMm,
double? heightMm,
double? rotation,
bool? isLocked,
int? zIndex,
String? source,
String? fieldKey,
String? prefix,
String? suffix,
String? fallbackValue,
String? fontFamily,
double? fontSizePt,
bool? isBold,
bool? isItalic,
TextAlign? alignment,
double? borderWidthMm,
double? paddingMm,
bool? autoFit,
int? maxLines,
}) {
return DynamicTextElement(
id: id,
xMm: xMm ?? this.xMm,
yMm: yMm ?? this.yMm,
widthMm: widthMm ?? this.widthMm,
heightMm: heightMm ?? this.heightMm,
rotation: rotation ?? this.rotation,
isLocked: isLocked ?? this.isLocked,
zIndex: zIndex ?? this.zIndex,
source: source ?? this.source,
fieldKey: fieldKey ?? this.fieldKey,
prefix: prefix ?? this.prefix,
suffix: suffix ?? this.suffix,
fallbackValue: fallbackValue ?? this.fallbackValue,
fontFamily: fontFamily ?? this.fontFamily,
fontSizePt: fontSizePt ?? this.fontSizePt,
isBold: isBold ?? this.isBold,
isItalic: isItalic ?? this.isItalic,
alignment: alignment ?? this.alignment,
borderWidthMm: borderWidthMm ?? this.borderWidthMm,
paddingMm: paddingMm ?? this.paddingMm,
autoFit: autoFit ?? this.autoFit,
maxLines: maxLines ?? this.maxLines,
);
}
@override
Map<String, dynamic> toJson() => {
'id': id,
'type': 'dynamicText',
'xMm': xMm,
'yMm': yMm,
'widthMm': widthMm,
'heightMm': heightMm,
'rotation': rotation,
'isLocked': isLocked,
'zIndex': zIndex,
'source': source,
'fieldKey': fieldKey,
'placeholder': placeholder,
'prefix': prefix,
'suffix': suffix,
'fallbackValue': fallbackValue,
'fontFamily': fontFamily,
'fontSizePt': fontSizePt,
'isBold': isBold,
'isItalic': isItalic,
'alignment': alignment.name,
'borderWidthMm': borderWidthMm,
'paddingMm': paddingMm,
'autoFit': autoFit,
'maxLines': maxLines,
};
factory DynamicTextElement.fromJson(Map<String, dynamic> json) {
return DynamicTextElement(
id: json['id'] as String,
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 30.0,
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 5.0,
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
isLocked: json['isLocked'] as bool? ?? false,
zIndex: json['zIndex'] as int? ?? 0,
source: json['source'] as String? ?? 'product',
fieldKey: json['fieldKey'] as String? ?? json['field'] as String? ?? '',
prefix: json['prefix'] as String?,
suffix: json['suffix'] as String?,
fallbackValue: json['fallbackValue'] as String? ?? json['fallback'] as String? ?? 'N/A',
fontFamily: json['fontFamily'] as String? ?? 'Roboto',
fontSizePt: (json['fontSizePt'] as num?)?.toDouble() ?? 9.0,
isBold: json['isBold'] as bool? ?? false,
isItalic: json['isItalic'] as bool? ?? false,
alignment: TextAlign.values.where((a) => a.name == json['alignment']).firstOrNull ?? TextAlign.left,
borderWidthMm: (json['borderWidthMm'] as num?)?.toDouble() ?? 0.0,
paddingMm: (json['paddingMm'] as num?)?.toDouble() ?? 0.0,
autoFit: json['autoFit'] as bool? ?? true,
maxLines: json['maxLines'] as int? ?? 2,
);
}
}
class BarcodeElement extends LabelElement {
final String valueType; // "static" or "dynamic"
final String staticValue;
final String source; // "product"
final String fieldKey; // "barcode" or "sku"
final BarcodeType barcodeType;
final bool showText;
@override
final double borderWidthMm;
final double paddingMm;
const BarcodeElement({
required super.id,
required super.xMm,
required super.yMm,
required super.widthMm,
required super.heightMm,
super.rotation = 0.0,
super.isLocked = false,
super.zIndex = 0,
this.valueType = 'dynamic',
this.staticValue = '123456789012',
this.source = 'product',
this.fieldKey = 'barcode',
this.barcodeType = BarcodeType.code128,
this.showText = true,
this.borderWidthMm = 0.0,
this.paddingMm = 0.5,
}) : super(type: ElementType.barcode);
String get placeholder => valueType == 'dynamic' ? '{{$source.$fieldKey}}' : staticValue;
@override
BarcodeElement copyWithPosition({
double? xMm,
double? yMm,
double? widthMm,
double? heightMm,
double? rotation,
bool? isLocked,
int? zIndex,
String? valueType,
String? staticValue,
String? source,
String? fieldKey,
BarcodeType? barcodeType,
bool? showText,
double? borderWidthMm,
double? paddingMm,
}) {
return BarcodeElement(
id: id,
xMm: xMm ?? this.xMm,
yMm: yMm ?? this.yMm,
widthMm: widthMm ?? this.widthMm,
heightMm: heightMm ?? this.heightMm,
rotation: rotation ?? this.rotation,
isLocked: isLocked ?? this.isLocked,
zIndex: zIndex ?? this.zIndex,
valueType: valueType ?? this.valueType,
staticValue: staticValue ?? this.staticValue,
source: source ?? this.source,
fieldKey: fieldKey ?? this.fieldKey,
barcodeType: barcodeType ?? this.barcodeType,
showText: showText ?? this.showText,
borderWidthMm: borderWidthMm ?? this.borderWidthMm,
paddingMm: paddingMm ?? this.paddingMm,
);
}
@override
Map<String, dynamic> toJson() => {
'id': id,
'type': 'barcode',
'xMm': xMm,
'yMm': yMm,
'widthMm': widthMm,
'heightMm': heightMm,
'rotation': rotation,
'isLocked': isLocked,
'zIndex': zIndex,
'valueType': valueType,
'staticValue': staticValue,
'source': source,
'fieldKey': fieldKey,
'placeholder': placeholder,
'barcodeType': barcodeType.name,
'showText': showText,
'borderWidthMm': borderWidthMm,
'paddingMm': paddingMm,
};
factory BarcodeElement.fromJson(Map<String, dynamic> json) {
return BarcodeElement(
id: json['id'] as String,
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 40.0,
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 10.0,
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
isLocked: json['isLocked'] as bool? ?? false,
zIndex: json['zIndex'] as int? ?? 0,
valueType: json['valueType'] as String? ?? 'dynamic',
staticValue: json['staticValue'] as String? ?? '123456789012',
source: json['source'] as String? ?? 'product',
fieldKey: json['fieldKey'] as String? ?? json['field'] as String? ?? 'barcode',
barcodeType: BarcodeType.values.where((b) => b.name.toLowerCase() == (json['barcodeType'] as String? ?? '').toLowerCase()).firstOrNull ?? BarcodeType.code128,
showText: json['showText'] as bool? ?? true,
borderWidthMm: (json['borderWidthMm'] as num?)?.toDouble() ?? 0.0,
paddingMm: (json['paddingMm'] as num?)?.toDouble() ?? 0.5,
);
}
}
class QrCodeElement extends LabelElement {
final String valueType; // "static" or "dynamic"
final String staticValue;
final String source; // "product"
final String fieldKey;
const QrCodeElement({
required super.id,
required super.xMm,
required super.yMm,
required super.widthMm,
required super.heightMm,
super.rotation = 0.0,
super.isLocked = false,
super.zIndex = 0,
this.valueType = 'dynamic',
this.staticValue = 'https://kifi.app',
this.source = 'product',
this.fieldKey = 'barcode',
}) : super(type: ElementType.qrCode);
String get placeholder => valueType == 'dynamic' ? '{{$source.$fieldKey}}' : staticValue;
@override
QrCodeElement copyWithPosition({
double? xMm,
double? yMm,
double? widthMm,
double? heightMm,
double? rotation,
bool? isLocked,
int? zIndex,
String? valueType,
String? staticValue,
String? source,
String? fieldKey,
}) {
return QrCodeElement(
id: id,
xMm: xMm ?? this.xMm,
yMm: yMm ?? this.yMm,
widthMm: widthMm ?? this.widthMm,
heightMm: heightMm ?? this.heightMm,
rotation: rotation ?? this.rotation,
isLocked: isLocked ?? this.isLocked,
zIndex: zIndex ?? this.zIndex,
valueType: valueType ?? this.valueType,
staticValue: staticValue ?? this.staticValue,
source: source ?? this.source,
fieldKey: fieldKey ?? this.fieldKey,
);
}
@override
Map<String, dynamic> toJson() => {
'id': id,
'type': 'qrCode',
'xMm': xMm,
'yMm': yMm,
'widthMm': widthMm,
'heightMm': heightMm,
'rotation': rotation,
'isLocked': isLocked,
'zIndex': zIndex,
'valueType': valueType,
'staticValue': staticValue,
'source': source,
'fieldKey': fieldKey,
'placeholder': placeholder,
};
factory QrCodeElement.fromJson(Map<String, dynamic> json) {
return QrCodeElement(
id: json['id'] as String,
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 12.0,
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 12.0,
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
isLocked: json['isLocked'] as bool? ?? false,
zIndex: json['zIndex'] as int? ?? 0,
valueType: json['valueType'] as String? ?? 'dynamic',
staticValue: json['staticValue'] as String? ?? 'https://kifi.app',
source: json['source'] as String? ?? 'product',
fieldKey: json['fieldKey'] as String? ?? json['field'] as String? ?? 'barcode',
);
}
}
class RectangleElement extends LabelElement {
@override
final double borderWidthMm;
final bool isFilled;
final double cornerRadiusMm;
const RectangleElement({
required super.id,
required super.xMm,
required super.yMm,
required super.widthMm,
required super.heightMm,
super.rotation = 0.0,
super.isLocked = false,
super.zIndex = 0,
this.borderWidthMm = 0.3,
this.isFilled = false,
this.cornerRadiusMm = 0.0,
}) : super(type: ElementType.rectangle);
@override
RectangleElement copyWithPosition({
double? xMm,
double? yMm,
double? widthMm,
double? heightMm,
double? rotation,
bool? isLocked,
int? zIndex,
double? borderWidthMm,
bool? isFilled,
double? cornerRadiusMm,
}) {
return RectangleElement(
id: id,
xMm: xMm ?? this.xMm,
yMm: yMm ?? this.yMm,
widthMm: widthMm ?? this.widthMm,
heightMm: heightMm ?? this.heightMm,
rotation: rotation ?? this.rotation,
isLocked: isLocked ?? this.isLocked,
zIndex: zIndex ?? this.zIndex,
borderWidthMm: borderWidthMm ?? this.borderWidthMm,
isFilled: isFilled ?? this.isFilled,
cornerRadiusMm: cornerRadiusMm ?? this.cornerRadiusMm,
);
}
@override
Map<String, dynamic> toJson() => {
'id': id,
'type': 'rectangle',
'xMm': xMm,
'yMm': yMm,
'widthMm': widthMm,
'heightMm': heightMm,
'rotation': rotation,
'isLocked': isLocked,
'zIndex': zIndex,
'borderWidthMm': borderWidthMm,
'isFilled': isFilled,
'cornerRadiusMm': cornerRadiusMm,
};
factory RectangleElement.fromJson(Map<String, dynamic> json) {
return RectangleElement(
id: json['id'] as String,
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 20.0,
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 10.0,
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
isLocked: json['isLocked'] as bool? ?? false,
zIndex: json['zIndex'] as int? ?? 0,
borderWidthMm: (json['borderWidthMm'] as num?)?.toDouble() ?? 0.3,
isFilled: json['isFilled'] as bool? ?? false,
cornerRadiusMm: (json['cornerRadiusMm'] as num?)?.toDouble() ?? 0.0,
);
}
}
class LineElement extends LabelElement {
final double thicknessMm;
final bool isVertical;
const LineElement({
required super.id,
required super.xMm,
required super.yMm,
required super.widthMm,
required super.heightMm,
super.rotation = 0.0,
super.isLocked = false,
super.zIndex = 0,
this.thicknessMm = 0.3,
this.isVertical = false,
}) : super(type: ElementType.line);
@override
LineElement copyWithPosition({
double? xMm,
double? yMm,
double? widthMm,
double? heightMm,
double? rotation,
bool? isLocked,
int? zIndex,
double? thicknessMm,
bool? isVertical,
}) {
return LineElement(
id: id,
xMm: xMm ?? this.xMm,
yMm: yMm ?? this.yMm,
widthMm: widthMm ?? this.widthMm,
heightMm: heightMm ?? this.heightMm,
rotation: rotation ?? this.rotation,
isLocked: isLocked ?? this.isLocked,
zIndex: zIndex ?? this.zIndex,
thicknessMm: thicknessMm ?? this.thicknessMm,
isVertical: isVertical ?? this.isVertical,
);
}
@override
Map<String, dynamic> toJson() => {
'id': id,
'type': 'line',
'xMm': xMm,
'yMm': yMm,
'widthMm': widthMm,
'heightMm': heightMm,
'rotation': rotation,
'isLocked': isLocked,
'zIndex': zIndex,
'thicknessMm': thicknessMm,
'isVertical': isVertical,
};
factory LineElement.fromJson(Map<String, dynamic> json) {
return LineElement(
id: json['id'] as String,
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 20.0,
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 1.0,
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
isLocked: json['isLocked'] as bool? ?? false,
zIndex: json['zIndex'] as int? ?? 0,
thicknessMm: (json['thicknessMm'] as num?)?.toDouble() ?? 0.3,
isVertical: json['isVertical'] as bool? ?? false,
);
}
}
class LabelDocument {
final String id;
final String name;
final String? description;
final int version;
final LabelConfiguration config;
final List<LabelElement> elements;
const LabelDocument({
required this.id,
required this.name,
this.description,
this.version = 1,
this.config = const LabelConfiguration(),
this.elements = const [],
});
LabelDocument copyWith({
String? id,
String? name,
String? description,
int? version,
LabelConfiguration? config,
List<LabelElement>? elements,
}) {
return LabelDocument(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
version: version ?? this.version,
config: config ?? this.config,
elements: elements ?? this.elements,
);
}
Map<String, dynamic> toJson() => {
'version': version,
'name': name,
'description': description,
'label': config.toJson(),
'elements': elements.map((e) => e.toJson()).toList(),
};
String toJsonString() => jsonEncode(toJson());
factory LabelDocument.fromJson(Map<String, dynamic> json, {String? id, String? name, String? description}) {
final labelConfig = json['label'] != null
? LabelConfiguration.fromJson(json['label'] as Map<String, dynamic>)
: const LabelConfiguration();
final rawElements = json['elements'] as List<dynamic>? ?? [];
final elementsList = rawElements
.map((e) => LabelElement.fromJson(e as Map<String, dynamic>))
.toList();
return LabelDocument(
id: id ?? (json['id'] as String? ?? 'template_1'),
name: name ?? (json['name'] as String? ?? 'Untitled Template'),
description: description ?? (json['description'] as String?),
version: json['version'] as int? ?? 1,
config: labelConfig,
elements: elementsList,
);
}
static LabelDocument createDefault() {
return const LabelDocument(
id: 'default_template',
name: '50x25 Product Barcode',
description: 'Standard jewellery and product barcode label (50x25mm)',
config: LabelConfiguration(
widthMm: 50.0,
heightMm: 25.0,
dpi: 203,
),
elements: [
TextElement(
id: 'elem_brand',
xMm: 2.0,
yMm: 1.8,
widthMm: 46.0,
heightMm: 3.5,
text: 'kifi',
fontSizePt: 8.5,
isBold: true,
alignment: TextAlign.center,
),
DynamicTextElement(
id: 'elem_product_name',
xMm: 2.0,
yMm: 5.5,
widthMm: 46.0,
heightMm: 4.0,
source: 'product',
fieldKey: 'name',
fontSizePt: 8.0,
alignment: TextAlign.center,
),
BarcodeElement(
id: 'elem_barcode',
xMm: 5.0,
yMm: 10.0,
widthMm: 40.0,
heightMm: 8.0,
valueType: 'dynamic',
source: 'product',
fieldKey: 'barcode',
showText: true,
),
DynamicTextElement(
id: 'elem_price',
xMm: 2.0,
yMm: 19.5,
widthMm: 23.0,
heightMm: 3.8,
source: 'product',
fieldKey: 'sellingPrice',
prefix: '',
fontSizePt: 8.0,
isBold: true,
alignment: TextAlign.left,
),
DynamicTextElement(
id: 'elem_weight',
xMm: 26.0,
yMm: 19.5,
widthMm: 22.0,
heightMm: 3.8,
source: 'product',
fieldKey: 'weightInG',
prefix: 'Wt: ',
suffix: ' g',
fontSizePt: 7.5,
alignment: TextAlign.right,
),
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../providers/barcode_templates_provider.dart';
import '../providers/barcode_designer_provider.dart';
import 'barcode_designer_screen.dart';
class SavedTemplatesScreen extends ConsumerWidget {
const SavedTemplatesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final templatesAsync = ref.watch(barcodeTemplatesProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text('Saved Label Templates'),
actions: [
IconButton(
icon: const Icon(LucideIcons.refreshCw),
tooltip: 'Refresh',
onPressed: () => ref.read(barcodeTemplatesProvider.notifier).refresh(),
),
],
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(LucideIcons.plus),
label: const Text('New Template'),
onPressed: () {
ref.read(barcodeDesignerProvider.notifier).resetToNew(50.0, 25.0);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const BarcodeDesignerScreen()),
);
},
),
body: templatesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.circleAlert, size: 48, color: Colors.red),
const SizedBox(height: 12),
Text('Error loading templates: $err'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => ref.read(barcodeTemplatesProvider.notifier).refresh(),
child: const Text('Retry'),
),
],
),
),
data: (templates) {
if (templates.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.barcode, size: 48, color: Colors.blue),
),
const SizedBox(height: 16),
const Text(
'No templates saved yet',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Create your first custom barcode and label template',
style: TextStyle(color: Colors.grey.shade500, fontSize: 13),
),
const SizedBox(height: 20),
ElevatedButton.icon(
icon: const Icon(LucideIcons.plus, size: 18),
label: const Text('Create New Template'),
onPressed: () {
ref.read(barcodeDesignerProvider.notifier).resetToNew(50.0, 25.0);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const BarcodeDesignerScreen()),
);
},
),
],
),
);
}
return GridView.builder(
padding: const EdgeInsets.all(20),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 360,
mainAxisExtent: 180,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: templates.length,
itemBuilder: (context, index) {
final item = templates[index];
return Container(
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.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: () {
final doc = item.toLabelDocument();
ref.read(barcodeDesignerProvider.notifier).loadDocument(doc, item.id);
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => BarcodeDesignerScreen(initialTemplate: item),
),
);
},
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(LucideIcons.barcode, color: Colors.blue, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.templateName,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'${item.labelWidthMm.round()} × ${item.labelHeightMm.round()} mm • ${item.printerLanguage}',
style: TextStyle(color: Colors.grey.shade500, fontSize: 11),
),
],
),
),
PopupMenuButton<String>(
icon: const Icon(LucideIcons.ellipsisVertical, size: 18),
onSelected: (val) async {
if (val == 'delete' && item.id != null) {
final confirm = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete Template'),
content: Text('Are you sure you want to delete "${item.templateName}"?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete', style: TextStyle(color: Colors.white)),
),
],
),
);
if (confirm == true) {
ref.read(barcodeTemplatesProvider.notifier).deleteTemplate(item.id!);
}
}
},
itemBuilder: (_) => [
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(LucideIcons.trash2, color: Colors.red, size: 16),
SizedBox(width: 8),
Text('Delete', style: TextStyle(color: Colors.red)),
],
),
),
],
),
],
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isDark ? Colors.black26 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'DPI: ${item.dpi}',
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
),
Text(
'v${item.version}',
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
),
],
),
),
],
),
),
),
),
);
},
);
},
),
);
}
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
class CanvasRuler extends StatelessWidget {
final double lengthMm;
final double scale; // pixels per mm
final bool isHorizontal;
final double thickness;
const CanvasRuler({
super.key,
required this.lengthMm,
required this.scale,
this.isHorizontal = true,
this.thickness = 20.0,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return CustomPaint(
size: isHorizontal
? Size(lengthMm * scale, thickness)
: Size(thickness, lengthMm * scale),
painter: _RulerPainter(
lengthMm: lengthMm,
scale: scale,
isHorizontal: isHorizontal,
isDark: isDark,
),
);
}
}
class _RulerPainter extends CustomPainter {
final double lengthMm;
final double scale;
final bool isHorizontal;
final bool isDark;
_RulerPainter({
required this.lengthMm,
required this.scale,
required this.isHorizontal,
required this.isDark,
});
@override
void paint(Canvas canvas, Size size) {
final bgPaint = Paint()
..color = isDark ? const Color(0xFF1E293B) : const Color(0xFFF1F5F9);
canvas.drawRect(Offset.zero & size, bgPaint);
final linePaint = Paint()
..color = isDark ? Colors.white30 : Colors.black26
..strokeWidth = 1.0;
final textStyle = TextStyle(
color: isDark ? Colors.white60 : Colors.black54,
fontSize: 8,
fontWeight: FontWeight.w500,
);
final textPainter = TextPainter(
textDirection: TextDirection.ltr,
);
// Draw ticks every 1mm, with labels every 5mm or 10mm
for (int mm = 0; mm <= lengthMm.ceil(); mm++) {
final pos = mm * scale;
final isTen = mm % 10 == 0;
final isFive = mm % 5 == 0;
final tickLength = isTen ? 12.0 : (isFive ? 7.0 : 4.0);
if (isHorizontal) {
canvas.drawLine(
Offset(pos, size.height - tickLength),
Offset(pos, size.height),
linePaint,
);
if (isTen && mm > 0 && mm < lengthMm) {
textPainter.text = TextSpan(text: '$mm', style: textStyle);
textPainter.layout();
textPainter.paint(canvas, Offset(pos - (textPainter.width / 2), 2));
}
} else {
canvas.drawLine(
Offset(size.width - tickLength, pos),
Offset(size.width, pos),
linePaint,
);
if (isTen && mm > 0 && mm < lengthMm) {
textPainter.text = TextSpan(text: '$mm', style: textStyle);
textPainter.layout();
textPainter.paint(canvas, Offset(2, pos - (textPainter.height / 2)));
}
}
}
}
@override
bool shouldRepaint(covariant _RulerPainter oldDelegate) {
return oldDelegate.lengthMm != lengthMm ||
oldDelegate.scale != scale ||
oldDelegate.isHorizontal != isHorizontal ||
oldDelegate.isDark != isDark;
}
}

View File

@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../domain/label_document.dart';
class ToolboxItemData {
final ElementType type;
final String label;
final IconData icon;
final Color color;
final String description;
const ToolboxItemData({
required this.type,
required this.label,
required this.icon,
required this.color,
required this.description,
});
}
class DesignerToolbox extends StatelessWidget {
final Function(ElementType type) onAddElement;
const DesignerToolbox({
super.key,
required this.onAddElement,
});
static const List<ToolboxItemData> items = [
ToolboxItemData(
type: ElementType.text,
label: 'Text',
icon: LucideIcons.type,
color: Colors.blue,
description: 'Static label text (default: kifi)',
),
ToolboxItemData(
type: ElementType.dynamicText,
label: 'Dynamic Field',
icon: LucideIcons.variable,
color: Colors.purple,
description: 'Bound product placeholder',
),
ToolboxItemData(
type: ElementType.barcode,
label: 'Barcode',
icon: LucideIcons.barcode,
color: Colors.indigo,
description: 'Code 128, EAN-13, SKU barcode',
),
ToolboxItemData(
type: ElementType.qrCode,
label: 'QR Code',
icon: LucideIcons.qrCode,
color: Colors.teal,
description: '2D matrix barcode or URL',
),
ToolboxItemData(
type: ElementType.rectangle,
label: 'Rectangle',
icon: LucideIcons.square,
color: Colors.amber,
description: 'Bordered or filled box',
),
ToolboxItemData(
type: ElementType.line,
label: 'Line Divider',
icon: LucideIcons.minus,
color: Colors.blueGrey,
description: 'Horizontal or vertical line',
),
];
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Text(
'TOOLBOX',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
color: Colors.grey.shade500,
),
),
),
...items.map((item) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Draggable<ElementType>(
data: item.type,
feedback: Material(
elevation: 6,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: item.color,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(item.icon, color: Colors.white, size: 18),
const SizedBox(width: 8),
Text(
item.label,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
),
childWhenDragging: Opacity(
opacity: 0.4,
child: _buildItemCard(context, item, isDark),
),
child: InkWell(
onTap: () => onAddElement(item.type),
borderRadius: BorderRadius.circular(12),
child: _buildItemCard(context, item, isDark),
),
),
);
}),
],
);
}
Widget _buildItemCard(BuildContext context, ToolboxItemData item, bool isDark) {
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: item.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Icon(item.icon, color: item.color, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.label,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
Text(
item.description,
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 10.5,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(LucideIcons.gripVertical, size: 14, color: Colors.grey.shade400),
],
),
);
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
class PrinterCommandPreview extends StatefulWidget {
final String commands;
final String language;
const PrinterCommandPreview({
super.key,
required this.commands,
this.language = 'ZPL',
});
@override
State<PrinterCommandPreview> createState() => _PrinterCommandPreviewState();
}
class _PrinterCommandPreviewState extends State<PrinterCommandPreview> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF0F172A) : const Color(0xFF1E293B),
border: Border(
top: BorderSide(
color: isDark ? Colors.white12 : Colors.black12,
),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header Bar
InkWell(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(
LucideIcons.code,
size: 16,
color: Colors.cyanAccent.shade400,
),
const SizedBox(width: 8),
Text(
'Live Printer Commands (${widget.language})',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: const Icon(LucideIcons.copy, size: 14, color: Colors.white70),
tooltip: 'Copy Commands',
visualDensity: VisualDensity.compact,
onPressed: () {
Clipboard.setData(ClipboardData(text: widget.commands));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Printer commands copied to clipboard!'),
duration: Duration(seconds: 2),
),
);
},
),
Icon(
_isExpanded ? LucideIcons.chevronDown : LucideIcons.chevronUp,
size: 16,
color: Colors.white70,
),
],
),
),
),
if (_isExpanded)
Container(
height: 140,
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: Colors.black26,
child: SingleChildScrollView(
child: SelectableText(
widget.commands.isEmpty ? '// No elements to generate commands' : widget.commands,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: Color(0xFF38BDF8), // Sky blue terminal text
height: 1.4,
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,396 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../inventory/domain/product.dart';
import '../../../inventory/domain/inventory_item.dart';
import '../../../inventory/providers/products_provider.dart';
import '../../../inventory/providers/product_categories_provider.dart';
import '../../../inventory/providers/inventory_items_provider.dart';
import '../../../business/providers/business_provider.dart';
class ProductSelectionResult {
final Product product;
final InventoryItem? inventoryItem;
const ProductSelectionResult({
required this.product,
this.inventoryItem,
});
}
class ProductPickerDialog extends ConsumerStatefulWidget {
const ProductPickerDialog({super.key});
static Future<ProductSelectionResult?> show(BuildContext context) {
return showModalBottomSheet<ProductSelectionResult>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const ProductPickerDialog(),
);
}
@override
ConsumerState<ProductPickerDialog> createState() => _ProductPickerDialogState();
}
class _ProductPickerDialogState extends ConsumerState<ProductPickerDialog> {
final TextEditingController _searchCtrl = TextEditingController();
int? _selectedCategoryId;
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final products = ref.watch(productsProvider).value ?? [];
final categories = ref.watch(productCategoriesProvider).value ?? [];
final inventoryItems = ref.watch(inventoryItemsProvider).value ?? [];
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final query = _searchCtrl.text.trim().toLowerCase();
// Filter products
final filteredProducts = products.where((p) {
if (_selectedCategoryId != null && p.categoryId != _selectedCategoryId) {
return false;
}
if (query.isEmpty) return true;
final nameMatch = p.name.toLowerCase().contains(query);
final skuMatch = p.sku?.toLowerCase().contains(query) ?? false;
final barcodeMatch = p.barcode?.toLowerCase().contains(query) ?? false;
final hsnMatch = p.hsnCode?.toLowerCase().contains(query) ?? false;
return nameMatch || skuMatch || barcodeMatch || hsnMatch;
}).toList();
return DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scrollController) {
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF0F172A) : Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 16,
offset: const Offset(0, -4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Top drag pill
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 44,
height: 5,
decoration: BoxDecoration(
color: isDark ? Colors.white24 : Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
),
),
// Title bar
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 16, 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(LucideIcons.packageSearch, color: Colors.blue, size: 20),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Select Sample Product',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
Text(
'Product values will be used for live preview and test printing',
style: TextStyle(color: Colors.grey.shade500, fontSize: 12),
),
],
),
],
),
Row(
children: [
IconButton(
tooltip: 'Refresh Products',
icon: const Icon(LucideIcons.refreshCw, size: 18),
onPressed: () => ref.read(productsProvider.notifier).refresh(),
),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
],
),
),
const Divider(height: 1),
// Search bar
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 10),
child: TextField(
controller: _searchCtrl,
autofocus: false,
decoration: InputDecoration(
hintText: isJewellery
? 'Search by name, SKU, barcode, or tag...'
: 'Search by product name, SKU, or barcode...',
prefixIcon: const Icon(LucideIcons.search, size: 18),
suffixIcon: _searchCtrl.text.isNotEmpty
? IconButton(
icon: const Icon(LucideIcons.x, size: 16),
onPressed: () => setState(() => _searchCtrl.clear()),
)
: null,
filled: true,
fillColor: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
onChanged: (_) => setState(() {}),
),
),
// Category filters
if (categories.isNotEmpty)
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
separatorBuilder: (_, index) => const SizedBox(width: 8),
itemCount: categories.length + 1,
itemBuilder: (context, idx) {
if (idx == 0) {
final isSelected = _selectedCategoryId == null;
return ChoiceChip(
label: const Text('All Categories', style: TextStyle(fontSize: 12)),
selected: isSelected,
onSelected: (_) => setState(() => _selectedCategoryId = null),
);
}
final cat = categories[idx - 1];
final isSelected = _selectedCategoryId == cat.id;
return ChoiceChip(
label: Text(cat.name, style: const TextStyle(fontSize: 12)),
selected: isSelected,
onSelected: (_) => setState(() => _selectedCategoryId = cat.id),
);
},
),
),
const SizedBox(height: 8),
// Products list
Expanded(
child: filteredProducts.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.packageOpen, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(
query.isEmpty
? 'No products available in catalog'
: 'No matching products found',
style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
),
],
),
)
: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
itemCount: filteredProducts.length,
itemBuilder: (context, index) {
final product = filteredProducts[index];
final category = categories
.where((c) => c.id == product.categoryId)
.firstOrNull;
// Find matching inventory items for jewelry tags
final relatedItems = inventoryItems
.where((i) => i.productId == product.id)
.toList();
final firstItem = relatedItems.isNotEmpty ? relatedItems.first : null;
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: () {
Navigator.pop(
context,
ProductSelectionResult(
product: product,
inventoryItem: firstItem,
),
);
},
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
LucideIcons.package,
color: Colors.blue,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
if (product.sku != null && product.sku!.isNotEmpty)
Text(
'SKU: ${product.sku}',
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 11,
),
),
if (product.barcode != null && product.barcode!.isNotEmpty)
Text(
'Barcode: ${product.barcode}',
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 11,
),
),
if (category != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
category.name,
style: const TextStyle(fontSize: 10),
),
),
],
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (product.mrp != null)
Text(
'MRP ₹${product.mrp!.toStringAsFixed(0)}',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue,
fontSize: 13,
),
),
if (product.sellingPrice != null)
Text(
'${product.sellingPrice!.toStringAsFixed(0)}',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.green,
fontSize: 13,
),
),
if (product.weightInG != null)
Text(
'${product.weightInG!.toStringAsFixed(2)} g',
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 11,
),
),
const SizedBox(height: 4),
const Icon(
LucideIcons.chevronRight,
size: 16,
color: Colors.grey,
),
],
),
],
),
),
),
),
);
},
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../domain/dynamic_field_definition.dart';
class ProductVariablesPanel extends StatelessWidget {
final Function(DynamicFieldDefinition fieldDef) onAddField;
const ProductVariablesPanel({
super.key,
required this.onAddField,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
// Group fields by category
final Map<String, List<DynamicFieldDefinition>> categories = {};
for (final field in DynamicFieldDefinition.productFields) {
categories.putIfAbsent(field.category, () => []).add(field);
}
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'PRODUCT DATA FIELDS',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
color: Colors.grey.shade500,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'DRAGGABLE',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
],
),
),
...categories.entries.map((entry) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(4, 10, 4, 4),
child: Text(
entry.key.toUpperCase(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: Colors.grey.shade600,
),
),
),
...entry.value.map((field) {
return Padding(
padding: const EdgeInsets.only(bottom: 6.0),
child: Draggable<DynamicFieldDefinition>(
data: field,
feedback: Material(
elevation: 6,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.purple.shade700,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.variable, color: Colors.white, size: 16),
const SizedBox(width: 8),
Text(
field.displayName,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
),
),
),
childWhenDragging: Opacity(
opacity: 0.4,
child: _buildVariableCard(context, field, isDark),
),
child: InkWell(
onTap: () => onAddField(field),
borderRadius: BorderRadius.circular(10),
child: _buildVariableCard(context, field, isDark),
),
),
);
}),
],
);
}),
],
);
}
Widget _buildVariableCard(BuildContext context, DynamicFieldDefinition field, bool isDark) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.purple.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
field.isBarcodeCompatible ? LucideIcons.barcode : LucideIcons.variable,
color: Colors.purple,
size: 15,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
field.displayName,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
Text(
'{{${field.source}.${field.fieldKey}}}',
style: TextStyle(
color: Colors.purple.shade300,
fontFamily: 'monospace',
fontSize: 10,
),
),
],
),
),
Icon(LucideIcons.gripVertical, size: 14, color: Colors.grey.shade400),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,531 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../domain/label_document.dart';
import '../domain/dynamic_field_definition.dart';
import '../services/template_data_resolver.dart';
import '../services/zpl_compiler.dart';
import '../../inventory/domain/product.dart';
import '../../inventory/domain/inventory_item.dart';
class BarcodeDesignerState {
final LabelDocument document;
final String? selectedElementId;
final Product? sampleProduct;
final InventoryItem? sampleInventoryItem;
final bool isPreviewMode;
final double zoom;
final bool showGrid;
final bool snapToGrid;
final double gridStepMm;
final int? savedTemplateId;
final List<LabelDocument> undoStack;
final List<LabelDocument> redoStack;
final String liveCommands;
const BarcodeDesignerState({
required this.document,
this.selectedElementId,
this.sampleProduct,
this.sampleInventoryItem,
this.isPreviewMode = false,
this.zoom = 2.4, // Good default zoom for 50x25mm labels on desktop/mobile
this.showGrid = true,
this.snapToGrid = true,
this.gridStepMm = 1.0,
this.savedTemplateId,
this.undoStack = const [],
this.redoStack = const [],
this.liveCommands = '',
});
LabelElement? get selectedElement {
if (selectedElementId == null) return null;
return document.elements.where((e) => e.id == selectedElementId).firstOrNull;
}
LabelDocument get resolvedDocument {
if (sampleProduct == null && sampleInventoryItem == null) {
return document;
}
return TemplateDataResolver.resolveDocument(
document: document,
product: sampleProduct,
inventoryItem: sampleInventoryItem,
);
}
BarcodeDesignerState copyWith({
LabelDocument? document,
String? selectedElementId,
bool clearSelectedElement = false,
Product? sampleProduct,
bool clearSampleProduct = false,
InventoryItem? sampleInventoryItem,
bool clearSampleInventoryItem = false,
bool? isPreviewMode,
double? zoom,
bool? showGrid,
bool? snapToGrid,
double? gridStepMm,
int? savedTemplateId,
List<LabelDocument>? undoStack,
List<LabelDocument>? redoStack,
String? liveCommands,
}) {
return BarcodeDesignerState(
document: document ?? this.document,
selectedElementId: clearSelectedElement
? null
: (selectedElementId ?? this.selectedElementId),
sampleProduct: clearSampleProduct ? null : (sampleProduct ?? this.sampleProduct),
sampleInventoryItem: clearSampleInventoryItem
? null
: (sampleInventoryItem ?? this.sampleInventoryItem),
isPreviewMode: isPreviewMode ?? this.isPreviewMode,
zoom: zoom ?? this.zoom,
showGrid: showGrid ?? this.showGrid,
snapToGrid: snapToGrid ?? this.snapToGrid,
gridStepMm: gridStepMm ?? this.gridStepMm,
savedTemplateId: savedTemplateId ?? this.savedTemplateId,
undoStack: undoStack ?? this.undoStack,
redoStack: redoStack ?? this.redoStack,
liveCommands: liveCommands ?? this.liveCommands,
);
}
}
class BarcodeDesignerNotifier extends Notifier<BarcodeDesignerState> {
@override
BarcodeDesignerState build() {
final defaultDoc = LabelDocument.createDefault();
final cmds = PrintCompiler.compile(document: defaultDoc);
return BarcodeDesignerState(
document: defaultDoc,
liveCommands: cmds,
);
}
void _pushUndo(LabelDocument oldDoc) {
final newUndo = List<LabelDocument>.from(state.undoStack);
if (newUndo.length >= 30) newUndo.removeAt(0);
newUndo.add(oldDoc);
state = state.copyWith(undoStack: newUndo, redoStack: []);
}
void undo() {
if (state.undoStack.isEmpty) return;
final newUndo = List<LabelDocument>.from(state.undoStack);
final previousDoc = newUndo.removeLast();
final newRedo = List<LabelDocument>.from(state.redoStack)..add(state.document);
state = state.copyWith(
document: previousDoc,
undoStack: newUndo,
redoStack: newRedo,
);
_updateLiveCommands();
}
void redo() {
if (state.redoStack.isEmpty) return;
final newRedo = List<LabelDocument>.from(state.redoStack);
final nextDoc = newRedo.removeLast();
final newUndo = List<LabelDocument>.from(state.undoStack)..add(state.document);
state = state.copyWith(
document: nextDoc,
undoStack: newUndo,
redoStack: newRedo,
);
_updateLiveCommands();
}
void loadDocument(LabelDocument doc, [int? templateId]) {
final cmds = PrintCompiler.compile(document: doc);
state = BarcodeDesignerState(
document: doc,
savedTemplateId: templateId,
sampleProduct: state.sampleProduct,
sampleInventoryItem: state.sampleInventoryItem,
isPreviewMode: state.isPreviewMode,
zoom: state.zoom,
showGrid: state.showGrid,
snapToGrid: state.snapToGrid,
liveCommands: cmds,
);
}
void resetToNew([double widthMm = 50.0, double heightMm = 25.0]) {
final newDoc = LabelDocument(
id: 'template_${DateTime.now().millisecondsSinceEpoch}',
name: '${widthMm.round()}x${heightMm.round()} Barcode Template',
config: LabelConfiguration(
widthMm: widthMm,
heightMm: heightMm,
),
elements: [],
);
loadDocument(newDoc, null);
}
void updateTemplateName(String name) {
state = state.copyWith(
document: state.document.copyWith(name: name),
);
}
void updateTemplateDescription(String desc) {
state = state.copyWith(
document: state.document.copyWith(description: desc),
);
}
void setZoom(double z) {
state = state.copyWith(zoom: z.clamp(0.8, 6.0));
}
void toggleGrid() {
state = state.copyWith(showGrid: !state.showGrid);
}
void toggleSnap() {
state = state.copyWith(snapToGrid: !state.snapToGrid);
}
void togglePreviewMode([bool? force]) {
final nextMode = force ?? !state.isPreviewMode;
state = state.copyWith(isPreviewMode: nextMode);
_updateLiveCommands();
}
void setSampleProduct(Product? product, [InventoryItem? item]) {
state = state.copyWith(
sampleProduct: product,
clearSampleProduct: product == null,
sampleInventoryItem: item,
clearSampleInventoryItem: item == null,
);
_updateLiveCommands();
}
void selectElement(String? id) {
state = state.copyWith(
selectedElementId: id,
clearSelectedElement: id == null,
);
}
void updateLabelConfig(LabelConfiguration newConfig) {
_pushUndo(state.document);
state = state.copyWith(
document: state.document.copyWith(config: newConfig),
);
_updateLiveCommands();
}
double _snap(double valueMm) {
if (!state.snapToGrid) return valueMm;
final step = state.gridStepMm;
return (valueMm / step).round() * step;
}
void addElement(LabelElement element) {
_pushUndo(state.document);
final elements = List<LabelElement>.from(state.document.elements)..add(element);
state = state.copyWith(
document: state.document.copyWith(elements: elements),
selectedElementId: element.id,
);
_updateLiveCommands();
}
void updateElement(LabelElement element) {
_pushUndo(state.document);
final elements = state.document.elements.map((e) {
return e.id == element.id ? element : e;
}).toList();
state = state.copyWith(
document: state.document.copyWith(elements: elements),
);
_updateLiveCommands();
}
void recordUndo() {
_pushUndo(state.document);
}
void setElementPosition(String id, double xMm, double yMm) {
final elem = state.document.elements.where((e) => e.id == id).firstOrNull;
if (elem == null || elem.isLocked) return;
double newX = xMm;
double newY = yMm;
if (state.snapToGrid) {
newX = _snap(newX);
newY = _snap(newY);
}
newX = newX.clamp(0.0, state.document.config.widthMm - 1.0);
newY = newY.clamp(0.0, state.document.config.heightMm - 1.0);
final updated = elem.copyWithPosition(xMm: newX, yMm: newY);
final elements = state.document.elements.map((e) => e.id == id ? updated : e).toList();
state = state.copyWith(
document: state.document.copyWith(elements: elements),
);
_updateLiveCommands();
}
void setElementSize(String id, double widthMm, double heightMm) {
final elem = state.document.elements.where((e) => e.id == id).firstOrNull;
if (elem == null || elem.isLocked) return;
double w = widthMm;
double h = heightMm;
if (state.snapToGrid) {
w = _snap(w);
h = _snap(h);
}
w = w.clamp(2.0, state.document.config.widthMm);
h = h.clamp(1.0, state.document.config.heightMm);
final updated = elem.copyWithPosition(widthMm: w, heightMm: h);
final elements = state.document.elements.map((e) => e.id == id ? updated : e).toList();
state = state.copyWith(
document: state.document.copyWith(elements: elements),
);
_updateLiveCommands();
}
void moveElement(String id, double deltaXMm, double deltaYMm) {
final elem = state.document.elements.where((e) => e.id == id).firstOrNull;
if (elem == null || elem.isLocked) return;
double newX = (elem.xMm + deltaXMm);
double newY = (elem.yMm + deltaYMm);
if (state.snapToGrid) {
newX = _snap(newX);
newY = _snap(newY);
}
newX = newX.clamp(0.0, state.document.config.widthMm - 1.0);
newY = newY.clamp(0.0, state.document.config.heightMm - 1.0);
final updated = elem.copyWithPosition(xMm: newX, yMm: newY);
final elements = state.document.elements.map((e) => e.id == id ? updated : e).toList();
state = state.copyWith(
document: state.document.copyWith(elements: elements),
);
_updateLiveCommands();
}
void resizeElement(String id, double newWidthMm, double newHeightMm) {
final elem = state.document.elements.where((e) => e.id == id).firstOrNull;
if (elem == null || elem.isLocked) return;
double w = newWidthMm;
double h = newHeightMm;
if (state.snapToGrid) {
w = _snap(w);
h = _snap(h);
}
w = w.clamp(2.0, state.document.config.widthMm);
h = h.clamp(1.0, state.document.config.heightMm);
final updated = elem.copyWithPosition(widthMm: w, heightMm: h);
final elements = state.document.elements.map((e) => e.id == id ? updated : e).toList();
state = state.copyWith(
document: state.document.copyWith(elements: elements),
);
_updateLiveCommands();
}
void deleteElement(String id) {
_pushUndo(state.document);
final elements = state.document.elements.where((e) => e.id != id).toList();
state = state.copyWith(
document: state.document.copyWith(elements: elements),
clearSelectedElement: state.selectedElementId == id,
);
_updateLiveCommands();
}
void duplicateElement(String id) {
final elem = state.document.elements.where((e) => e.id == id).firstOrNull;
if (elem == null) return;
_pushUndo(state.document);
final newId = 'elem_${DateTime.now().millisecondsSinceEpoch}';
final duplicated = elem.copyWithPosition(
xMm: (elem.xMm + 2.0).clamp(0.0, state.document.config.widthMm - 2.0),
yMm: (elem.yMm + 2.0).clamp(0.0, state.document.config.heightMm - 2.0),
);
final json = duplicated.toJson();
json['id'] = newId;
final finalElem = LabelElement.fromJson(json);
final elements = List<LabelElement>.from(state.document.elements)..add(finalElem);
state = state.copyWith(
document: state.document.copyWith(elements: elements),
selectedElementId: newId,
);
_updateLiveCommands();
}
void bringToFront(String id) {
final index = state.document.elements.indexWhere((e) => e.id == id);
if (index == -1 || index == state.document.elements.length - 1) return;
_pushUndo(state.document);
final elements = List<LabelElement>.from(state.document.elements);
final elem = elements.removeAt(index);
elements.add(elem);
state = state.copyWith(
document: state.document.copyWith(elements: elements),
);
_updateLiveCommands();
}
void sendToBack(String id) {
final index = state.document.elements.indexWhere((e) => e.id == id);
if (index == -1 || index == 0) return;
_pushUndo(state.document);
final elements = List<LabelElement>.from(state.document.elements);
final elem = elements.removeAt(index);
elements.insert(0, elem);
state = state.copyWith(
document: state.document.copyWith(elements: elements),
);
_updateLiveCommands();
}
void bringForward(String id) {
final index = state.document.elements.indexWhere((e) => e.id == id);
if (index == -1 || index >= state.document.elements.length - 1) return;
_pushUndo(state.document);
final elements = List<LabelElement>.from(state.document.elements);
final elem = elements.removeAt(index);
elements.insert(index + 1, elem);
state = state.copyWith(
document: state.document.copyWith(elements: elements),
);
_updateLiveCommands();
}
void sendBackward(String id) {
final index = state.document.elements.indexWhere((e) => e.id == id);
if (index <= 0) return;
_pushUndo(state.document);
final elements = List<LabelElement>.from(state.document.elements);
final elem = elements.removeAt(index);
elements.insert(index - 1, elem);
state = state.copyWith(
document: state.document.copyWith(elements: elements),
);
_updateLiveCommands();
}
/// Binds a product field directly to an existing DynamicTextElement or BarcodeElement
void bindFieldToElement(String elementId, String source, String fieldKey) {
final elem = state.document.elements.where((e) => e.id == elementId).firstOrNull;
if (elem == null) return;
_pushUndo(state.document);
final fieldDef = DynamicFieldDefinition.find(source, fieldKey);
LabelElement updated;
if (elem is DynamicTextElement) {
updated = elem.copyWithPosition(
source: source,
fieldKey: fieldKey,
prefix: fieldDef?.defaultPrefix ?? elem.prefix,
suffix: fieldDef?.defaultSuffix ?? elem.suffix,
);
} else if (elem is BarcodeElement) {
updated = elem.copyWithPosition(
valueType: 'dynamic',
source: source,
fieldKey: fieldKey,
);
} else if (elem is QrCodeElement) {
updated = elem.copyWithPosition(
valueType: 'dynamic',
source: source,
fieldKey: fieldKey,
);
} else {
return;
}
final elements = state.document.elements.map((e) => e.id == elementId ? updated : e).toList();
state = state.copyWith(
document: state.document.copyWith(elements: elements),
);
_updateLiveCommands();
}
/// Adds a new element directly bound from a dragged DynamicFieldDefinition
void addBoundFieldElement({
required DynamicFieldDefinition fieldDef,
required double dropXMm,
required double dropYMm,
}) {
_pushUndo(state.document);
final id = 'elem_${DateTime.now().millisecondsSinceEpoch}';
LabelElement newElem;
if (fieldDef.fieldKey == 'barcode' || (fieldDef.isBarcodeCompatible && fieldDef.fieldKey == 'sku')) {
newElem = BarcodeElement(
id: id,
xMm: dropXMm.clamp(0.0, state.document.config.widthMm - 30.0),
yMm: dropYMm.clamp(0.0, state.document.config.heightMm - 8.0),
widthMm: 38.0,
heightMm: 8.0,
valueType: 'dynamic',
source: fieldDef.source,
fieldKey: fieldDef.fieldKey,
showText: true,
);
} else {
newElem = DynamicTextElement(
id: id,
xMm: dropXMm.clamp(0.0, state.document.config.widthMm - 20.0),
yMm: dropYMm.clamp(0.0, state.document.config.heightMm - 4.0),
widthMm: 28.0,
heightMm: 4.0,
source: fieldDef.source,
fieldKey: fieldDef.fieldKey,
prefix: fieldDef.defaultPrefix,
suffix: fieldDef.defaultSuffix,
fontSizePt: 8.0,
);
}
addElement(newElem);
}
void _updateLiveCommands() {
try {
final docToCompile = state.isPreviewMode ? state.resolvedDocument : state.document;
final cmds = PrintCompiler.compile(document: docToCompile);
state = state.copyWith(liveCommands: cmds);
} catch (_) {
// Ignore intermediate compilation errors while typing
}
}
}
final barcodeDesignerProvider =
NotifierProvider<BarcodeDesignerNotifier, BarcodeDesignerState>(() {
return BarcodeDesignerNotifier();
});

View File

@@ -0,0 +1,79 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart';
import '../domain/barcode_template.dart';
class BarcodeTemplatesNotifier extends AsyncNotifier<List<BarcodeTemplate>> {
@override
FutureOr<List<BarcodeTemplate>> build() async {
return _fetchTemplates();
}
Future<List<BarcodeTemplate>> _fetchTemplates() async {
try {
final response = await DioClient().dio.get('/barcode-templates');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((e) => BarcodeTemplate.fromJson(e)).toList();
}
return [];
} catch (e) {
print('Error fetching barcode templates: $e');
return [];
}
}
Future<void> refresh() async {
state = const AsyncValue.loading();
try {
final templates = await _fetchTemplates();
state = AsyncValue.data(templates);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<BarcodeTemplate?> saveTemplate(BarcodeTemplate template) async {
try {
if (template.id != null) {
final response = await DioClient().dio.put(
'/barcode-templates/${template.id}',
data: template.toJson(),
);
await refresh();
if (response.data != null) {
return BarcodeTemplate.fromJson(response.data);
}
} else {
final response = await DioClient().dio.post(
'/barcode-templates',
data: template.toJson(),
);
await refresh();
if (response.data != null) {
return BarcodeTemplate.fromJson(response.data);
}
}
return null;
} catch (e) {
print('Error saving barcode template: $e');
rethrow;
}
}
Future<bool> deleteTemplate(int id) async {
try {
final response = await DioClient().dio.delete('/barcode-templates/$id');
await refresh();
return response.statusCode == 200 || response.statusCode == 204;
} catch (e) {
print('Error deleting barcode template: $e');
return false;
}
}
}
final barcodeTemplatesProvider =
AsyncNotifierProvider<BarcodeTemplatesNotifier, List<BarcodeTemplate>>(() {
return BarcodeTemplatesNotifier();
});

View File

@@ -0,0 +1,310 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:printing/printing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../domain/label_document.dart';
import '../../inventory/domain/product.dart';
class PrinterDevice {
final String id;
final String name;
final String ipAddress;
final int port;
final String language; // ZPL, TSPL, ESCPOS
final bool isOnline;
final String connectionType; // 'wifi_auto', 'manual_ip'
final String? model;
final String? rawUrl;
const PrinterDevice({
required this.id,
required this.name,
required this.ipAddress,
this.port = 9100,
this.language = 'ZPL',
this.isOnline = true,
this.connectionType = 'wifi_auto',
this.model,
this.rawUrl,
});
PrinterDevice copyWith({
String? id,
String? name,
String? ipAddress,
int? port,
String? language,
bool? isOnline,
String? connectionType,
String? model,
String? rawUrl,
}) {
return PrinterDevice(
id: id ?? this.id,
name: name ?? this.name,
ipAddress: ipAddress ?? this.ipAddress,
port: port ?? this.port,
language: language ?? this.language,
isOnline: isOnline ?? this.isOnline,
connectionType: connectionType ?? this.connectionType,
model: model ?? this.model,
rawUrl: rawUrl ?? this.rawUrl,
);
}
}
class ValidationResult {
final bool isValid;
final List<String> errors;
final List<String> warnings;
const ValidationResult({
required this.isValid,
this.errors = const [],
this.warnings = const [],
});
bool get hasErrors => errors.isNotEmpty;
bool get hasWarnings => warnings.isNotEmpty;
}
class PrinterService {
static const String _prefKeyIp = 'kifi_barcode_printer_ip';
static const String _prefKeyPort = 'kifi_barcode_printer_port';
static const String _prefKeyLang = 'kifi_barcode_printer_lang';
static const String _prefKeyMode = 'kifi_barcode_printer_mode';
static const String _prefKeyName = 'kifi_barcode_printer_name';
static const String _prefKeyDpi = 'kifi_barcode_printer_dpi';
/// Saves user printer preferences
static Future<void> savePrinterPreferences({
required String mode,
required String ip,
required int port,
required String language,
int? dpi,
String? name,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefKeyMode, mode);
await prefs.setString(_prefKeyIp, ip);
await prefs.setInt(_prefKeyPort, port);
await prefs.setString(_prefKeyLang, language);
if (dpi != null) {
await prefs.setInt(_prefKeyDpi, dpi);
}
if (name != null) {
await prefs.setString(_prefKeyName, name);
}
} catch (_) {}
}
/// Loads saved printer preferences
static Future<Map<String, dynamic>> loadPrinterPreferences() async {
try {
final prefs = await SharedPreferences.getInstance();
return {
'mode': prefs.getString(_prefKeyMode) ?? 'auto',
'ip': prefs.getString(_prefKeyIp) ?? '192.168.1.100',
'port': prefs.getInt(_prefKeyPort) ?? 9100,
'language': prefs.getString(_prefKeyLang) ?? 'ZPL',
'dpi': prefs.getInt(_prefKeyDpi) ?? 300,
'name': prefs.getString(_prefKeyName),
};
} catch (_) {
return {
'mode': 'auto',
'ip': '192.168.1.100',
'port': 9100,
'language': 'ZPL',
'dpi': 300,
'name': null,
};
}
}
/// Auto-detects printers connected to the local Wi-Fi network (AirPrint, Bonjour, mDNS)
static Future<List<PrinterDevice>> discoverWifiPrinters() async {
final discovered = <PrinterDevice>[];
if (kIsWeb) {
// Browsers do not allow arbitrary local subnet broadcast / enumeration
return discovered;
}
try {
final nativePrinters = await Printing.listPrinters();
for (final p in nativePrinters) {
String extractedIp = '';
int extractedPort = 9100;
final uri = Uri.tryParse(p.url);
if (uri != null && uri.host.isNotEmpty) {
extractedIp = uri.host;
if (uri.port != 0 && uri.port != 631) {
extractedPort = uri.port;
}
}
// Infer printer language from name/model
String lang = 'ZPL';
final nameLower = '${p.name} ${p.model ?? ''}'.toLowerCase();
if (nameLower.contains('tsc') ||
nameLower.contains('argox') ||
nameLower.contains('gprinter') ||
nameLower.contains('xprinter') ||
nameLower.contains('dymo')) {
lang = 'TSPL';
} else if (nameLower.contains('pos') ||
nameLower.contains('epson') ||
nameLower.contains('receipt') ||
nameLower.contains('star')) {
lang = 'ESCPOS';
}
discovered.add(
PrinterDevice(
id: p.url.isNotEmpty ? p.url : p.name,
name: p.name.isNotEmpty ? p.name : (p.model ?? 'Wi-Fi Label Printer'),
ipAddress: extractedIp,
port: extractedPort,
language: lang,
connectionType: 'wifi_auto',
model: p.model,
rawUrl: p.url,
isOnline: p.isAvailable,
),
);
}
} catch (e) {
debugPrint('Printer discovery error: $e');
}
return discovered;
}
/// Validates a label document before test printing or saving
static ValidationResult validateDocument({
required LabelDocument document,
Product? sampleProduct,
bool isPrinting = false,
}) {
final errors = <String>[];
final warnings = <String>[];
if (document.elements.isEmpty) {
errors.add('The label has no elements. Add at least one element before printing.');
return ValidationResult(isValid: false, errors: errors, warnings: warnings);
}
final cfg = document.config;
bool hasDynamic = false;
for (final elem in document.elements) {
// 1. Boundary check
if (elem.xMm + elem.widthMm > cfg.widthMm + 0.1) {
warnings.add('Element "${elem.id}" exceeds right label margin.');
}
if (elem.yMm + elem.heightMm > cfg.heightMm + 0.1) {
warnings.add('Element "${elem.id}" exceeds bottom label margin.');
}
if (elem.xMm < 0 || elem.yMm < 0) {
warnings.add('Element "${elem.id}" is placed outside the printable area.');
}
// 2. Dynamic check
if (elem is DynamicTextElement) {
hasDynamic = true;
if (elem.fieldKey.isEmpty) {
warnings.add('Dynamic field has no product property selected.');
}
} else if (elem is BarcodeElement && elem.valueType == 'dynamic') {
hasDynamic = true;
} else if (elem is QrCodeElement && elem.valueType == 'dynamic') {
hasDynamic = true;
}
// 3. Barcode check
if (elem is BarcodeElement) {
if (elem.widthMm < 15.0) {
warnings.add('Barcode width (${elem.widthMm}mm) is very small and may fail to scan.');
}
if (elem.heightMm < 6.0) {
warnings.add('Barcode height (${elem.heightMm}mm) is low. Recommended >= 8mm.');
}
// Quiet zone check
if (elem.xMm < 1.5 || (elem.xMm + elem.widthMm > cfg.widthMm - 1.5)) {
warnings.add('Barcode quiet zone: Keep at least 2mm clearance from label edges.');
}
}
}
// Dynamic field check when printing
if (isPrinting && hasDynamic && sampleProduct == null) {
errors.add(
'The label contains dynamic fields (e.g. {{product.name}}), but no sample product has been selected. Please select a product to test print.',
);
}
return ValidationResult(
isValid: errors.isEmpty,
errors: errors,
warnings: warnings,
);
}
/// Dispatches print commands to a printer (or Web simulation bridge)
static Future<Map<String, dynamic>> sendToPrinter({
required String printerCommands,
required PrinterDevice printer,
}) async {
if (kIsWeb) {
// On Flutter Web, raw socket connections to arbitrary LAN IPs are blocked by browser sandboxing.
// We simulate a successful dispatch and provide full command data for local agent / PDF print.
await Future.delayed(const Duration(milliseconds: 600));
return {
'success': true,
'message': 'Command dispatched successfully to ${printer.name} (${printer.ipAddress.isNotEmpty ? printer.ipAddress : "Wi-Fi"})',
'commands': printerCommands,
'isWeb': true,
};
} else {
// On native platforms (macOS / Windows / Android / iOS)
if (printer.ipAddress.isNotEmpty) {
try {
final socket = await Socket.connect(
printer.ipAddress,
printer.port,
timeout: const Duration(seconds: 4),
);
socket.write(printerCommands);
await socket.flush();
await socket.close();
return {
'success': true,
'message': 'Printed 1 label on ${printer.name} (${printer.ipAddress}:${printer.port})',
'commands': printerCommands,
'isWeb': false,
};
} catch (e) {
return {
'success': false,
'message': 'Could not connect to ${printer.name} at ${printer.ipAddress}:${printer.port}. Ensure your phone is connected to the same Wi-Fi as the printer.',
'commands': printerCommands,
'isWeb': false,
};
}
} else {
return {
'success': true,
'message': 'Dispatched label to Wi-Fi printer: ${printer.name}',
'commands': printerCommands,
'isWeb': false,
};
}
}
}
}

View File

@@ -0,0 +1,174 @@
import 'package:intl/intl.dart';
import '../../inventory/domain/product.dart';
import '../../inventory/domain/inventory_item.dart';
import '../domain/label_document.dart';
class TemplateDataResolver {
static final NumberFormat _currencyFormatter = NumberFormat('#,##,##0.##');
/// Resolves a single field value from a Product (and optional InventoryItem)
static String? resolveFieldValue({
required String source,
required String fieldKey,
Product? product,
InventoryItem? inventoryItem,
}) {
if (product == null && inventoryItem == null) return null;
if (source == 'product' && product != null) {
switch (fieldKey) {
case 'name':
return product.name;
case 'sku':
return product.sku ?? (inventoryItem?.tagNumber ?? 'SKU-001');
case 'barcode':
return (product.barcode != null && product.barcode!.isNotEmpty)
? product.barcode
: (inventoryItem?.tagNumber ?? product.sku ?? '8901234567890');
case 'sellingPrice':
final price = product.sellingPrice ?? product.mrp;
return price != null ? _currencyFormatter.format(price) : null;
case 'mrp':
final price = product.mrp ?? product.sellingPrice;
return price != null ? _currencyFormatter.format(price) : null;
case 'weightInG':
final wt = inventoryItem?.grossWeight ?? product.weightInG;
return wt?.toStringAsFixed(2);
case 'hsnCode':
return product.hsnCode;
case 'gstRate':
return product.gstRate != null ? product.gstRate!.toStringAsFixed(0) : '3';
case 'brandName':
return product.brandName;
case 'material':
return product.material;
case 'size':
return product.size;
case 'color':
return product.color;
case 'manufacturerCode':
return inventoryItem?.huid ?? product.manufacturerCode;
case 'purityFactor':
if (inventoryItem?.purity != null) return inventoryItem!.purity;
return product.purityFactor != null
? '${(product.purityFactor! * 100).toStringAsFixed(1)}%'
: null;
case 'currentStock':
return product.currentStock?.toString();
case 'description':
return product.description;
default:
return null;
}
}
if (source == 'inventory' && inventoryItem != null) {
switch (fieldKey) {
case 'tagNumber':
return inventoryItem.tagNumber;
case 'huid':
return inventoryItem.huid;
case 'grossWeight':
return inventoryItem.grossWeight?.toStringAsFixed(3);
case 'netWeight':
return inventoryItem.netWeight?.toStringAsFixed(3);
case 'purity':
return inventoryItem.purity;
default:
return null;
}
}
return null;
}
/// Resolves an entire LabelDocument against sample data, returning a resolved copy
static LabelDocument resolveDocument({
required LabelDocument document,
Product? product,
InventoryItem? inventoryItem,
}) {
if (product == null && inventoryItem == null) {
return document;
}
final resolvedElements = <LabelElement>[];
for (final elem in document.elements) {
if (elem is DynamicTextElement) {
final rawVal = resolveFieldValue(
source: elem.source,
fieldKey: elem.fieldKey,
product: product,
inventoryItem: inventoryItem,
);
final formattedText = elem.formatValue(rawVal);
resolvedElements.add(
TextElement(
id: elem.id,
xMm: elem.xMm,
yMm: elem.yMm,
widthMm: elem.widthMm,
heightMm: elem.heightMm,
rotation: elem.rotation,
isLocked: elem.isLocked,
zIndex: elem.zIndex,
text: formattedText,
fontFamily: elem.fontFamily,
fontSizePt: elem.fontSizePt,
isBold: elem.isBold,
isItalic: elem.isItalic,
alignment: elem.alignment,
borderWidthMm: elem.borderWidthMm,
paddingMm: elem.paddingMm,
),
);
} else if (elem is BarcodeElement) {
if (elem.valueType == 'dynamic') {
final resolvedVal = resolveFieldValue(
source: elem.source,
fieldKey: elem.fieldKey,
product: product,
inventoryItem: inventoryItem,
) ?? elem.staticValue;
final effectiveBarcode = (resolvedVal.isNotEmpty ? resolvedVal : (product?.sku ?? '8901234567890'))
.replaceAll(RegExp(r'[^a-zA-Z0-9\-]'), '');
resolvedElements.add(
elem.copyWithPosition(
valueType: 'static',
staticValue: effectiveBarcode.isNotEmpty ? effectiveBarcode : '8901234567890',
),
);
} else {
resolvedElements.add(elem);
}
} else if (elem is QrCodeElement) {
if (elem.valueType == 'dynamic') {
final resolvedVal = resolveFieldValue(
source: elem.source,
fieldKey: elem.fieldKey,
product: product,
inventoryItem: inventoryItem,
) ?? elem.staticValue;
final effectiveQr = resolvedVal.isNotEmpty ? resolvedVal : (product?.sku ?? '8901234567890');
resolvedElements.add(
elem.copyWithPosition(
valueType: 'static',
staticValue: effectiveQr,
),
);
} else {
resolvedElements.add(elem);
}
} else {
resolvedElements.add(elem);
}
}
return document.copyWith(elements: resolvedElements);
}
}

View File

@@ -0,0 +1,245 @@
import 'package:flutter/material.dart';
import '../domain/label_document.dart';
class PrintCompiler {
/// Compiles a LabelDocument into printer commands according to the chosen language
static String compile({
required LabelDocument document,
String? overrideLanguage,
int copies = 1,
}) {
final language = (overrideLanguage ?? document.config.printerLanguage).toUpperCase();
switch (language) {
case 'TSPL':
return TsplCompiler.compile(document, copies: copies);
case 'ZPL':
default:
return ZplCompiler.compile(document, copies: copies);
}
}
}
class ZplCompiler {
static String compile(LabelDocument doc, {int copies = 1}) {
final buffer = StringBuffer();
final cfg = doc.config;
// Convert total web width (accounting for 1-Up, 2-Up, etc.) to printer dots
final totalWebWidthDots = cfg.totalWebWidthDots;
final heightDots = cfg.heightDots;
buffer.writeln('^XA'); // Start of Label
buffer.writeln('^PW$totalWebWidthDots'); // Print Width across entire roll
buffer.writeln('^LL$heightDots'); // Label Length
buffer.writeln('^LS0'); // Label Shift
buffer.writeln('^LH0,0'); // Label Home
buffer.writeln('^CI28'); // UTF-8 Encoding
// Sort elements by zIndex
final sortedElements = List<LabelElement>.from(doc.elements)
..sort((a, b) => a.zIndex.compareTo(b.zIndex));
// Iterate across each column in the multi-up row (1-Up, 2-Up, 3-Up)
final cols = cfg.columnsAcross.clamp(1, 4);
for (int col = 0; col < cols; col++) {
final colOffsetMm = col * (cfg.widthMm + cfg.horizontalGapMm);
final colOffsetDots = cfg.mmToDots(colOffsetMm);
for (final elem in sortedElements) {
final xDots = cfg.mmToDots(elem.xMm) + colOffsetDots;
final yDots = cfg.mmToDots(elem.yMm);
final wDots = cfg.mmToDots(elem.widthMm);
final hDots = cfg.mmToDots(elem.heightMm);
if (elem is TextElement) {
_compileText(buffer, elem, cfg, xDots, yDots, wDots);
} else if (elem is DynamicTextElement) {
// In design mode or unresolved templates, output the placeholder
final textElem = TextElement(
id: elem.id,
xMm: elem.xMm,
yMm: elem.yMm,
widthMm: elem.widthMm,
heightMm: elem.heightMm,
text: elem.placeholder,
fontFamily: elem.fontFamily,
fontSizePt: elem.fontSizePt,
isBold: elem.isBold,
isItalic: elem.isItalic,
alignment: elem.alignment,
);
_compileText(buffer, textElem, cfg, xDots, yDots, wDots);
} else if (elem is BarcodeElement) {
_compileBarcode(buffer, elem, cfg, xDots, yDots, wDots, hDots);
} else if (elem is QrCodeElement) {
_compileQrCode(buffer, elem, cfg, xDots, yDots, wDots);
} else if (elem is RectangleElement) {
_compileRectangle(buffer, elem, cfg, xDots, yDots, wDots, hDots);
} else if (elem is LineElement) {
_compileLine(buffer, elem, cfg, xDots, yDots, wDots, hDots);
}
}
}
if (copies > 1) {
buffer.writeln('^PQ$copies');
}
buffer.writeln('^XZ'); // End of Label
return buffer.toString();
}
static void _compileText(
StringBuffer buf,
TextElement elem,
LabelConfiguration cfg,
int xDots,
int yDots,
int wDots,
) {
// Standard font height calculation based on point size & DPI
// 1 pt = 1/72 inch. dots = pt / 72 * DPI
final fontHeightDots = ((elem.fontSizePt / 72.0) * cfg.dpi * 1.33).round().clamp(14, 300);
final fontWidthDots = (fontHeightDots * 0.85).round();
final alignCode = switch (elem.alignment) {
TextAlign.center => 'C',
TextAlign.right => 'R',
TextAlign.justify => 'J',
_ => 'L',
};
// Replace unicode Rupee sign with 'Rs. ' as printer internal font 0 does not have U+20B9
final printableText = elem.text.replaceAll('', 'Rs. ');
// Text block with field formatting
buf.writeln('^FO$xDots,$yDots');
buf.writeln('^A0N,$fontHeightDots,$fontWidthDots');
buf.writeln('^FB$wDots,2,0,$alignCode,0');
buf.writeln('^FD$printableText^FS');
}
static void _compileBarcode(
StringBuffer buf,
BarcodeElement elem,
LabelConfiguration cfg,
int xDots,
int yDots,
int wDots,
int hDots,
) {
final barcodeVal = (elem.valueType == 'dynamic' && elem.staticValue.isEmpty)
? elem.placeholder
: elem.staticValue;
// Module width: 2 dots for 203 DPI, 3 dots for 300 DPI (Citizen CL-E331) for readable scanning
final moduleWidth = (cfg.dpi >= 300) ? 3 : 2;
final barHeight = (hDots * (elem.showText ? 0.82 : 0.95)).round().clamp(24, 600);
final showTextChar = elem.showText ? 'Y' : 'N';
buf.writeln('^FO$xDots,$yDots');
buf.writeln('^BY$moduleWidth,3,$barHeight');
switch (elem.barcodeType) {
case BarcodeType.ean13:
buf.writeln('^BEN,$barHeight,$showTextChar,N');
break;
case BarcodeType.code39:
buf.writeln('^B3N,N,$barHeight,$showTextChar,N');
break;
case BarcodeType.upca:
buf.writeln('^BUN,$barHeight,$showTextChar,N,N');
break;
case BarcodeType.code128:
default:
buf.writeln('^BCN,$barHeight,$showTextChar,N,N');
break;
}
buf.writeln('^FD$barcodeVal^FS');
}
static void _compileQrCode(
StringBuffer buf,
QrCodeElement elem,
LabelConfiguration cfg,
int xDots,
int yDots,
int wDots,
) {
final qrVal = (elem.valueType == 'dynamic' && elem.staticValue.isEmpty)
? elem.placeholder
: elem.staticValue;
// Magnification factor (1 to 10)
final mag = (wDots / 30).round().clamp(2, 8);
buf.writeln('^FO$xDots,$yDots');
buf.writeln('^BQN,2,$mag');
buf.writeln('^FDQA,$qrVal^FS');
}
static void _compileRectangle(
StringBuffer buf,
RectangleElement elem,
LabelConfiguration cfg,
int xDots,
int yDots,
int wDots,
int hDots,
) {
final borderDots = cfg.mmToDots(elem.borderWidthMm).clamp(1, 40);
final cornerDots = cfg.mmToDots(elem.cornerRadiusMm).clamp(0, 8);
buf.writeln('^FO$xDots,$yDots^GB$wDots,$hDots,$borderDots,B,$cornerDots^FS');
}
static void _compileLine(
StringBuffer buf,
LineElement elem,
LabelConfiguration cfg,
int xDots,
int yDots,
int wDots,
int hDots,
) {
final thickDots = cfg.mmToDots(elem.thicknessMm).clamp(1, 30);
if (elem.isVertical) {
buf.writeln('^FO$xDots,$yDots^GB$thickDots,$hDots,$thickDots^FS');
} else {
buf.writeln('^FO$xDots,$yDots^GB$wDots,$thickDots,$thickDots^FS');
}
}
}
class TsplCompiler {
static String compile(LabelDocument doc, {int copies = 1}) {
final buffer = StringBuffer();
final cfg = doc.config;
final cols = cfg.columnsAcross.clamp(1, 4);
buffer.writeln('SIZE ${cfg.totalWebWidthMm} mm, ${cfg.heightMm} mm');
buffer.writeln('GAP ${cfg.verticalGapMm.toStringAsFixed(cfg.verticalGapMm % 1 == 0 ? 0 : 1)} mm, 0 mm');
buffer.writeln('DIRECTION 1');
buffer.writeln('CLS');
for (int col = 0; col < cols; col++) {
final colOffsetMm = col * (cfg.widthMm + cfg.horizontalGapMm);
final colOffsetDots = cfg.mmToDots(colOffsetMm);
for (final elem in doc.elements) {
final xDots = cfg.mmToDots(elem.xMm) + colOffsetDots;
final yDots = cfg.mmToDots(elem.yMm);
if (elem is TextElement) {
buffer.writeln('TEXT $xDots,$yDots,"3",0,1,1,"${elem.text}"');
} else if (elem is BarcodeElement) {
final val = elem.valueType == 'dynamic' ? elem.placeholder : elem.staticValue;
final hDots = cfg.mmToDots(elem.heightMm);
buffer.writeln('BARCODE $xDots,$yDots,"128",$hDots,${elem.showText ? 1 : 0},0,2,2,"$val"');
}
}
}
buffer.writeln('PRINT $copies,1');
return buffer.toString();
}
}

View File

@@ -8,6 +8,7 @@ class BusinessFeature {
final String bomReductionStrategy;
final bool stockDeductionOnInvoice;
final String barcodeSource;
final bool enableEcommerceChannels;
final DateTime? createdAt;
BusinessFeature({
@@ -20,6 +21,7 @@ class BusinessFeature {
this.bomReductionStrategy = 'COMPONENTS_ONLY',
this.stockDeductionOnInvoice = true,
this.barcodeSource = 'SKU',
this.enableEcommerceChannels = false,
this.createdAt,
});
@@ -34,6 +36,7 @@ class BusinessFeature {
bomReductionStrategy: json['bomReductionStrategy'] ?? 'COMPONENTS_ONLY',
stockDeductionOnInvoice: json['stockDeductionOnInvoice'] ?? true,
barcodeSource: json['barcodeSource'] ?? 'SKU',
enableEcommerceChannels: json['enableEcommerceChannels'] ?? json['enable_ecommerce_channels'] ?? false,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: null,
@@ -51,6 +54,7 @@ class BusinessFeature {
'bomReductionStrategy': bomReductionStrategy,
'stockDeductionOnInvoice': stockDeductionOnInvoice,
'barcodeSource': barcodeSource,
'enableEcommerceChannels': enableEcommerceChannels,
};
}
@@ -64,6 +68,7 @@ class BusinessFeature {
String? bomReductionStrategy,
bool? stockDeductionOnInvoice,
String? barcodeSource,
bool? enableEcommerceChannels,
}) {
return BusinessFeature(
id: id ?? this.id,
@@ -76,6 +81,8 @@ class BusinessFeature {
stockDeductionOnInvoice:
stockDeductionOnInvoice ?? this.stockDeductionOnInvoice,
barcodeSource: barcodeSource ?? this.barcodeSource,
enableEcommerceChannels:
enableEcommerceChannels ?? this.enableEcommerceChannels,
createdAt: createdAt,
);
}

View File

@@ -13,6 +13,8 @@ class BusinessProfile {
final String? emailId;
final String? panNumber;
final String? gstin;
final String? natureOfBusiness;
final String? msmeNumber;
BusinessProfile({
this.id,
@@ -29,13 +31,15 @@ class BusinessProfile {
this.emailId,
this.panNumber,
this.gstin,
this.natureOfBusiness,
this.msmeNumber,
});
factory BusinessProfile.fromJson(Map<String, dynamic> json) {
return BusinessProfile(
id: json['id'],
userId: json['userId'],
businessName: json['businessName'],
businessName: json['businessName'] ?? '',
industry: json['industry'],
taxNumber: json['taxNumber'],
currency: json['currency'],
@@ -47,6 +51,8 @@ class BusinessProfile {
emailId: json['emailId'],
panNumber: json['panNumber'],
gstin: json['gstin'],
natureOfBusiness: json['natureOfBusiness'] ?? json['industry'],
msmeNumber: json['msmeNumber'],
);
}
@@ -66,6 +72,8 @@ class BusinessProfile {
'emailId': emailId,
'panNumber': panNumber,
'gstin': gstin,
'natureOfBusiness': natureOfBusiness,
'msmeNumber': msmeNumber,
};
}
@@ -82,6 +90,8 @@ class BusinessProfile {
String? emailId,
String? panNumber,
String? gstin,
String? natureOfBusiness,
String? msmeNumber,
}) {
return BusinessProfile(
id: id,
@@ -98,6 +108,8 @@ class BusinessProfile {
emailId: emailId ?? this.emailId,
panNumber: panNumber ?? this.panNumber,
gstin: gstin ?? this.gstin,
natureOfBusiness: natureOfBusiness ?? this.natureOfBusiness,
msmeNumber: msmeNumber ?? this.msmeNumber,
);
}
}

View File

@@ -13,6 +13,7 @@ import '../widgets/business_profile_form_sheet.dart';
import '../../../inventory/providers/products_provider.dart';
import '../../../vendor/presentation/vendors_list_screen.dart';
import '../../../vendor/presentation/purchase_orders_list_screen.dart';
import '../widgets/utilities_section.dart';
class BusinessHubScreen extends ConsumerWidget {
const BusinessHubScreen({super.key});
@@ -34,6 +35,8 @@ class BusinessHubScreen extends ConsumerWidget {
final bool showInventory = featureState?.inventoryManagement ?? false;
final bool showSales = featureState?.salesManagement ?? false;
final bool showPurchase = featureState?.purchaseManagement ?? false;
final nature = (profile?.natureOfBusiness ?? profile?.industry ?? 'JEWELLERY').toUpperCase();
final bool isJewellery = nature == 'JEWELLERY';
return RefreshIndicator(
onRefresh: () async {
@@ -200,7 +203,7 @@ class BusinessHubScreen extends ConsumerWidget {
),
isDesktop: isDesktop,
),
if (showInventory)
if (showInventory && isJewellery)
_buildActionCard(
context,
'Daily Rates',
@@ -231,6 +234,8 @@ class BusinessHubScreen extends ConsumerWidget {
);
},
),
const SizedBox(height: 32),
const UtilitiesSection(),
if (showInventory) ...[
const SizedBox(height: 32),
Text(

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../providers/business_provider.dart';
import '../../../sales/presentation/sales_channels_screen.dart';
class BusinessSettingsScreen extends ConsumerStatefulWidget {
const BusinessSettingsScreen({super.key});
@@ -176,6 +177,58 @@ class _BusinessSettingsScreenState
}
},
),
const SizedBox(height: 16),
Builder(
builder: (context) {
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final isChannelEnabled = featureState.value?.enableEcommerceChannels ?? (!isJewellery);
return SwitchListTile(
title: const Text('Enable E-Commerce & Marketplace Channels'),
subtitle: const Text(
'Track multi-channel sales across Amazon, Flipkart, Shopify, etc. in Invoices.',
),
value: isChannelEnabled,
secondary: const Icon(LucideIcons.shoppingBag),
onChanged: (val) {
if (featureState.value != null) {
final updated = featureState.value!.copyWith(
enableEcommerceChannels: val,
);
ref
.read(businessFeatureProvider.notifier)
.updateFeatures(updated);
}
},
);
},
),
Consumer(
builder: (context, ref, child) {
final featureState = ref.watch(businessFeatureProvider);
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final isChannelEnabled = featureState.value?.enableEcommerceChannels ?? (!isJewellery);
if (!isChannelEnabled) return const SizedBox.shrink();
return ListTile(
leading: const Icon(LucideIcons.store, color: Colors.blue),
title: const Text('Manage Sales Channels', style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text('Configure marketplace channels and custom platforms'),
trailing: const Icon(LucideIcons.chevronRight, size: 18),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SalesChannelsScreen()),
);
},
);
},
),
],
);
},

View File

@@ -0,0 +1,258 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../barcode_designer/presentation/barcode_designer_screen.dart';
import '../../../barcode_designer/presentation/saved_templates_screen.dart';
class UtilityItemData {
final String title;
final String description;
final IconData icon;
final Color color;
final VoidCallback? onTap;
final String? badgeText;
final List<UtilityQuickAction>? actions;
const UtilityItemData({
required this.title,
required this.description,
required this.icon,
required this.color,
this.onTap,
this.badgeText,
this.actions,
});
}
class UtilityQuickAction {
final String label;
final IconData icon;
final VoidCallback onTap;
const UtilityQuickAction({
required this.label,
required this.icon,
required this.onTap,
});
}
class UtilitiesSection extends StatelessWidget {
final bool isDesktop;
const UtilitiesSection({super.key, this.isDesktop = true});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final List<UtilityItemData> utilities = [
UtilityItemData(
title: 'Barcode & Label Designer',
description: 'Design custom barcode & jewellery tags with dynamic product fields, live preview & direct printer testing.',
icon: LucideIcons.barcode,
color: Colors.indigo,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const BarcodeDesignerScreen()),
);
},
badgeText: 'Active',
actions: [
UtilityQuickAction(
label: 'Open Designer',
icon: LucideIcons.draftingCompass,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const BarcodeDesignerScreen()),
);
},
),
UtilityQuickAction(
label: 'Saved Templates',
icon: LucideIcons.folderOpen,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SavedTemplatesScreen()),
);
},
),
],
),
const UtilityItemData(
title: 'Bulk Price & Rate Updater',
description: 'Automate mass price updates across inventory categories and daily commodity rate recalculations.',
icon: LucideIcons.trendingUp,
color: Colors.teal,
badgeText: 'Coming Soon',
),
const UtilityItemData(
title: 'Data Export & Compliance',
description: 'Export audit-ready GST reports, physical inventory verification sheets, and tally XML packages.',
icon: LucideIcons.fileSpreadsheet,
color: Colors.amber,
badgeText: 'Coming Soon',
),
];
return 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.sparkles, color: Colors.indigo, size: 18),
),
const SizedBox(width: 10),
Text(
'Utilities & Tools',
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
],
),
],
),
const SizedBox(height: 14),
// Responsive Cards Grid
LayoutBuilder(
builder: (context, constraints) {
final double cardWidth = constraints.maxWidth >= 900
? (constraints.maxWidth - 32) / 3
: (constraints.maxWidth >= 600 ? (constraints.maxWidth - 16) / 2 : constraints.maxWidth);
return Wrap(
spacing: 16,
runSpacing: 16,
children: utilities.map((util) {
return SizedBox(
width: cardWidth,
child: _buildUtilityCard(context, util, isDark),
);
}).toList(),
);
},
),
],
);
}
Widget _buildUtilityCard(BuildContext context, UtilityItemData util, bool isDark) {
final bool isAvailable = util.onTap != null;
return Container(
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.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: util.onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: util.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(util.icon, color: util.color, size: 22),
),
if (util.badgeText != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isAvailable
? Colors.green.withValues(alpha: 0.12)
: Colors.grey.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
util.badgeText!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isAvailable ? Colors.green : Colors.grey.shade600,
),
),
),
],
),
const SizedBox(height: 12),
Text(
util.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
const SizedBox(height: 6),
Text(
util.description,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
height: 1.35,
),
),
if (util.actions != null && util.actions!.isNotEmpty) ...[
const SizedBox(height: 14),
const Divider(height: 1),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: util.actions!.map((action) {
return Padding(
padding: const EdgeInsets.only(left: 8.0),
child: TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
textStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
icon: Icon(action.icon, size: 14),
label: Text(action.label),
onPressed: action.onTap,
),
);
}).toList(),
),
],
],
),
),
),
),
);
}
}

View File

@@ -21,6 +21,9 @@ class InventoryItem {
final int? vendorId;
final int? branchId;
final String? purchaseRef;
final String? salesRef;
final double? saleRate;
final String? photoUrl;
final String? status;
final DateTime? createdAt;
@@ -47,6 +50,9 @@ class InventoryItem {
this.vendorId,
this.branchId,
this.purchaseRef,
this.salesRef,
this.saleRate,
this.photoUrl,
this.status,
this.createdAt,
});
@@ -75,6 +81,9 @@ class InventoryItem {
vendorId: json['vendorId'],
branchId: json['branchId'],
purchaseRef: json['purchaseRef'],
salesRef: json['salesRef'] ?? json['sales_ref'],
saleRate: (json['saleRate'] ?? json['sale_rate'])?.toDouble(),
photoUrl: json['photoUrl'] ?? json['photo_url'],
status: json['status'],
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
);

View File

@@ -9,17 +9,44 @@ class Product {
final String? sku;
final String? barcode;
final String? description;
final double? mrp;
final double? sellingPrice;
final double? gstRate;
final String? dimensions;
final String? color;
final String? size;
final double? itemLength;
final double? itemWidth;
final double? itemHeight;
final String? dimensionUom;
final double? packageLength;
final double? packageWidth;
final double? packageHeight;
final String? packageDimensionUom;
final String? manufacturerCode;
final String? material;
final String? brandName;
final String? countryOfOrigin;
final double? weightInG;
final double? packageWeightInG;
final double? volumetricWeightInKg;
final String priceCalcRule;
final bool autoCalculatePrice;
final double? purityFactor;
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;
@@ -34,55 +61,98 @@ class Product {
this.sku,
this.barcode,
this.description,
this.mrp,
this.sellingPrice,
this.gstRate,
this.dimensions,
this.color,
this.size,
this.itemLength,
this.itemWidth,
this.itemHeight,
this.dimensionUom = 'cm',
this.packageLength,
this.packageWidth,
this.packageHeight,
this.packageDimensionUom = 'cm',
this.manufacturerCode,
this.material,
this.brandName,
this.countryOfOrigin,
this.weightInG,
this.packageWeightInG,
this.volumetricWeightInKg,
this.priceCalcRule = 'MANUAL',
this.autoCalculatePrice = false,
this.purityFactor,
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(),
mrp: json['mrp'] != null
? double.tryParse(json['mrp'].toString())
: (json['mrp_price'] != null ? double.tryParse(json['mrp_price'].toString()) : null),
sellingPrice: json['sellingPrice'] != null
? double.tryParse(json['sellingPrice'].toString())
: (json['selling_price'] != null ? double.tryParse(json['selling_price'].toString()) : null),
gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(),
dimensions: json['dimensions'],
color: json['color'],
size: json['size'],
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,
itemLength: (json['itemLength'] ?? json['item_length'] as num?)?.toDouble(),
itemWidth: (json['itemWidth'] ?? json['item_width'] as num?)?.toDouble(),
itemHeight: (json['itemHeight'] ?? json['item_height'] as num?)?.toDouble(),
dimensionUom: json['dimensionUom'] ?? json['dimension_uom'] ?? 'cm',
packageLength: (json['packageLength'] ?? json['package_length'] as num?)?.toDouble(),
packageWidth: (json['packageWidth'] ?? json['package_width'] as num?)?.toDouble(),
packageHeight: (json['packageHeight'] ?? json['package_height'] as num?)?.toDouble(),
packageDimensionUom: json['packageDimensionUom'] ?? json['package_dimension_uom'] ?? 'cm',
manufacturerCode: json['manufacturerCode'] ?? json['manufacturer_code'],
material: json['material'],
brandName: json['brandName'] ?? json['brand_name'],
countryOfOrigin: json['countryOfOrigin'] ?? json['country_of_origin'],
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,
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) {
@@ -94,9 +164,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,
);
}
@@ -111,17 +179,37 @@ class Product {
'sku': sku,
'barcode': barcode,
'description': description,
'mrp': mrp,
'sellingPrice': sellingPrice,
'gstRate': gstRate,
'dimensions': dimensions,
'color': color,
'size': size,
'itemLength': itemLength,
'itemWidth': itemWidth,
'itemHeight': itemHeight,
'dimensionUom': dimensionUom,
'packageLength': packageLength,
'packageWidth': packageWidth,
'packageHeight': packageHeight,
'packageDimensionUom': packageDimensionUom,
'manufacturerCode': manufacturerCode,
'material': material,
'brandName': brandName,
'countryOfOrigin': countryOfOrigin,
'weightInG': weightInG,
'packageWeightInG': packageWeightInG,
'volumetricWeightInKg': volumetricWeightInKg,
'priceCalcRule': priceCalcRule,
'autoCalculatePrice': autoCalculatePrice,
'purityFactor': purityFactor,
'makingCharges': makingCharges,
'makingChargesType': makingChargesType,
'wastagePercentage': wastagePercentage,
'minStock': minStock,
'reorderLevel': reorderLevel,
'reorderQuantity': reorderQuantity,
'trackInventory': trackInventory,
'isActive': isActive,
};
}

View File

@@ -27,6 +27,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final _formKey = GlobalKey<FormState>();
final _hsnController = TextEditingController();
final _gstController = TextEditingController();
final _mrpController = TextEditingController();
final _sellingPriceController = TextEditingController();
final _makingChargesController = TextEditingController();
String _makingChargesType = 'PER_GRAM';
@@ -36,8 +38,32 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
ProductCategory? _selectedCategory;
int? _uomId;
// Properties
String _color = '';
// Specifications
final _colorController = TextEditingController();
final _manufacturerCodeController = TextEditingController();
final _materialController = TextEditingController();
final _brandNameController = TextEditingController();
final _countryOfOriginController = TextEditingController();
// Item Dimensions & Weight
final _itemLengthController = TextEditingController();
final _itemWidthController = TextEditingController();
final _itemHeightController = TextEditingController();
String _dimensionUom = 'cm';
final _weightInGController = TextEditingController();
// Package Dimensions & Weight
final _packageLengthController = TextEditingController();
final _packageWidthController = TextEditingController();
final _packageHeightController = TextEditingController();
String _packageDimensionUom = 'cm';
final _packageWeightInGController = TextEditingController();
double? _volumetricWeightInKg;
// Inventory & Reorder Thresholds
final _minStockController = TextEditingController();
final _reorderQuantityController = TextEditingController();
bool _trackInventory = true;
// Media
final List<XFile> _images = [];
@@ -58,11 +84,39 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
_sku = p.sku ?? '';
_hsnController.text = p.hsnCode ?? '';
_uomId = p.uomId;
_color = p.color ?? '';
_gstController.text = p.gstRate != null && p.gstRate! > 0 ? p.gstRate.toString() : '';
_mrpController.text = p.mrp != null && p.mrp! > 0 ? p.mrp.toString() : '';
_sellingPriceController.text = p.sellingPrice != null && p.sellingPrice! > 0 ? p.sellingPrice.toString() : '';
_makingChargesController.text = p.makingCharges != null && p.makingCharges! > 0 ? p.makingCharges!.toString() : '';
_makingChargesType = p.makingChargesType ?? 'PER_GRAM';
_colorController.text = p.color ?? '';
_manufacturerCodeController.text = p.manufacturerCode ?? '';
_materialController.text = p.material ?? '';
_brandNameController.text = p.brandName ?? '';
_countryOfOriginController.text = p.countryOfOrigin ?? '';
_itemLengthController.text = p.itemLength != null ? p.itemLength.toString() : '';
_itemWidthController.text = p.itemWidth != null ? p.itemWidth.toString() : '';
_itemHeightController.text = p.itemHeight != null ? p.itemHeight.toString() : '';
_dimensionUom = p.dimensionUom ?? 'cm';
_weightInGController.text = p.weightInG != null ? p.weightInG.toString() : '';
_packageLengthController.text = p.packageLength != null ? p.packageLength.toString() : '';
_packageWidthController.text = p.packageWidth != null ? p.packageWidth.toString() : '';
_packageHeightController.text = p.packageHeight != null ? p.packageHeight.toString() : '';
_packageDimensionUom = p.packageDimensionUom ?? 'cm';
_packageWeightInGController.text = p.packageWeightInG != null ? p.packageWeightInG.toString() : '';
_volumetricWeightInKg = p.volumetricWeightInKg;
if (_volumetricWeightInKg == null) {
_calculateVolumetricWeight();
}
final alertQty = (p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock;
_minStockController.text = alertQty != null && alertQty > 0 ? alertQty.toString() : '';
_reorderQuantityController.text = p.reorderQuantity != null && p.reorderQuantity! > 0 ? p.reorderQuantity.toString() : '';
_trackInventory = p.trackInventory;
if (p.imageIds.isNotEmpty) {
_existingImageIds.addAll(p.imageIds);
}
@@ -82,10 +136,61 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
void dispose() {
_hsnController.dispose();
_gstController.dispose();
_mrpController.dispose();
_sellingPriceController.dispose();
_makingChargesController.dispose();
_colorController.dispose();
_manufacturerCodeController.dispose();
_materialController.dispose();
_brandNameController.dispose();
_countryOfOriginController.dispose();
_itemLengthController.dispose();
_itemWidthController.dispose();
_itemHeightController.dispose();
_weightInGController.dispose();
_packageLengthController.dispose();
_packageWidthController.dispose();
_packageHeightController.dispose();
_packageWeightInGController.dispose();
super.dispose();
}
void _calculateVolumetricWeight() {
final l = double.tryParse(_packageLengthController.text) ?? 0.0;
final w = double.tryParse(_packageWidthController.text) ?? 0.0;
final h = double.tryParse(_packageHeightController.text) ?? 0.0;
if (l > 0 && w > 0 && h > 0) {
double factor = 1.0;
switch (_packageDimensionUom.toLowerCase()) {
case 'in':
factor = 2.54;
break;
case 'mm':
factor = 0.1;
break;
case 'm':
factor = 100.0;
break;
case 'cm':
default:
factor = 1.0;
break;
}
final lCm = l * factor;
final wCm = w * factor;
final hCm = h * factor;
final volKg = (lCm * wCm * hCm) / 5000.0;
setState(() {
_volumetricWeightInKg = double.parse(volKg.toStringAsFixed(3));
});
} else {
setState(() {
_volumetricWeightInKg = null;
});
}
}
Future<void> _pickImages() async {
if ((_images.length + _existingImageIds.length) >= 4) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed')));
@@ -148,16 +253,37 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final product = Product(
id: widget.product?.id,
name: _name,
hsnCode: _hsnController.text.isNotEmpty ? _hsnController.text : null,
sku: _sku.isNotEmpty ? _sku : null,
hsnCode: _hsnController.text.isNotEmpty ? _hsnController.text.trim() : null,
sku: _sku.isNotEmpty ? _sku.trim() : null,
categoryId: _selectedCategory?.id,
uomId: _uomId,
color: _color.isNotEmpty ? _color : null,
color: _colorController.text.isNotEmpty ? _colorController.text.trim() : null,
mrp: double.tryParse(_mrpController.text),
sellingPrice: double.tryParse(_sellingPriceController.text),
gstRate: double.tryParse(_gstController.text) ?? 0,
makingCharges: double.tryParse(_makingChargesController.text) ?? 0.0,
makingChargesType: _makingChargesType,
priceCalcRule: 'MANUAL',
autoCalculatePrice: false,
itemLength: double.tryParse(_itemLengthController.text),
itemWidth: double.tryParse(_itemWidthController.text),
itemHeight: double.tryParse(_itemHeightController.text),
dimensionUom: _dimensionUom,
packageLength: double.tryParse(_packageLengthController.text),
packageWidth: double.tryParse(_packageWidthController.text),
packageHeight: double.tryParse(_packageHeightController.text),
packageDimensionUom: _packageDimensionUom,
manufacturerCode: _manufacturerCodeController.text.isNotEmpty ? _manufacturerCodeController.text.trim() : null,
material: _materialController.text.isNotEmpty ? _materialController.text.trim() : null,
brandName: _brandNameController.text.isNotEmpty ? _brandNameController.text.trim() : null,
countryOfOrigin: _countryOfOriginController.text.isNotEmpty ? _countryOfOriginController.text.trim() : null,
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) {
@@ -177,6 +303,9 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
Widget build(BuildContext context) {
final categoriesState = ref.watch(productCategoriesProvider);
final uomsState = ref.watch(uomsProvider);
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
@@ -275,10 +404,28 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
if (val.makingChargeType != null && val.makingChargeType!.isNotEmpty) {
_makingChargesType = val.makingChargeType!;
}
if (_uomId == null && uoms.isNotEmpty) {
if (val.defaultLengthUom.isNotEmpty) {
_dimensionUom = val.defaultLengthUom;
_packageDimensionUom = val.defaultLengthUom;
_calculateVolumetricWeight();
}
if (val.baseUnit.isNotEmpty && uoms.isNotEmpty) {
final target = val.baseUnit.trim().toLowerCase();
final match = uoms.where((u) =>
u.abbreviation?.toLowerCase() == val.baseUnit.toLowerCase() ||
u.name.toLowerCase() == val.baseUnit.toLowerCase()
(u.abbreviation != null && u.abbreviation!.trim().toLowerCase() == target) ||
u.name.trim().toLowerCase() == target ||
(target == 'pcs' && (u.abbreviation?.toLowerCase() == 'pcs' || u.name.toLowerCase().contains('piece'))) ||
(target == 'unit' && (u.abbreviation?.toLowerCase() == 'unit' || u.name.toLowerCase().contains('unit'))) ||
(target == 'box' && (u.abbreviation?.toLowerCase() == 'box' || u.name.toLowerCase().contains('box'))) ||
(target == 'set' && (u.abbreviation?.toLowerCase() == 'set' || u.name.toLowerCase().contains('set'))) ||
(target == 'pair' && (u.abbreviation?.toLowerCase() == 'pair' || u.name.toLowerCase().contains('pair'))) ||
(target == 'pk' && (u.abbreviation?.toLowerCase() == 'pk' || u.name.toLowerCase().contains('pack'))) ||
(target == 'g' && (u.abbreviation?.toLowerCase() == 'g' || u.name.toLowerCase().contains('gram'))) ||
(target == 'kg' && (u.abbreviation?.toLowerCase() == 'kg' || u.name.toLowerCase().contains('kilogram'))) ||
(target == 'm' && (u.abbreviation?.toLowerCase() == 'm' || u.name.toLowerCase().contains('meter'))) ||
(target == 'l' && (u.abbreviation?.toLowerCase() == 'l' || u.name.toLowerCase().contains('liter'))) ||
(target == 'ml' && (u.abbreviation?.toLowerCase() == 'ml' || u.name.toLowerCase().contains('milliliter'))) ||
(target == 'doz' && (u.abbreviation?.toLowerCase() == 'doz' || u.name.toLowerCase().contains('dozen')))
).firstOrNull;
if (match != null) {
_uomId = match.id;
@@ -318,6 +465,34 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
const Text('Pricing & Taxes', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _mrpController,
decoration: const InputDecoration(
labelText: 'MRP (₹)',
hintText: 'e.g. 1999',
prefixIcon: Icon(LucideIcons.tag),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _sellingPriceController,
decoration: const InputDecoration(
labelText: 'Selling Price (₹)',
hintText: 'e.g. 1499',
prefixIcon: Icon(LucideIcons.badgePercent),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
@@ -336,6 +511,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
),
],
),
if (isJewellery) ...[
const SizedBox(height: 16),
Row(
children: [
@@ -367,8 +544,310 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
),
],
),
const SizedBox(height: 24),
],
if (!isJewellery) ...[
const SizedBox(height: 24),
const Text('Product Specifications', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _brandNameController,
decoration: const InputDecoration(
labelText: 'Brand Name',
prefixIcon: Icon(LucideIcons.bookmark),
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _manufacturerCodeController,
decoration: const InputDecoration(
labelText: 'Manufacturer Code',
prefixIcon: Icon(LucideIcons.factory),
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _materialController,
decoration: const InputDecoration(
labelText: 'Material',
prefixIcon: Icon(LucideIcons.layers),
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _colorController,
decoration: const InputDecoration(
labelText: 'Color',
prefixIcon: Icon(LucideIcons.palette),
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _countryOfOriginController,
decoration: const InputDecoration(
labelText: 'Country of Origin',
prefixIcon: Icon(LucideIcons.globe),
),
),
),
],
),
const SizedBox(height: 24),
const Text('Item Dimensions & Weight', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _itemLengthController,
decoration: const InputDecoration(labelText: 'Length'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _itemWidthController,
decoration: const InputDecoration(labelText: 'Width'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _itemHeightController,
decoration: const InputDecoration(labelText: 'Height'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
const SizedBox(width: 12),
SizedBox(
width: 110,
child: DropdownButtonFormField<String>(
value: _dimensionUom,
decoration: const InputDecoration(labelText: 'UoM'),
items: const [
DropdownMenuItem(value: 'cm', child: Text('cm')),
DropdownMenuItem(value: 'in', child: Text('in')),
DropdownMenuItem(value: 'mm', child: Text('mm')),
DropdownMenuItem(value: 'm', child: Text('m')),
],
onChanged: (val) {
if (val != null) setState(() => _dimensionUom = val);
},
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _weightInGController,
decoration: const InputDecoration(
labelText: 'Item Weight (in Grams)',
suffixText: 'g',
prefixIcon: Icon(LucideIcons.scale),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
const SizedBox(height: 24),
const Text('Packaging Dimensions & Weight', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _packageLengthController,
decoration: const InputDecoration(labelText: 'Package Length'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _calculateVolumetricWeight(),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _packageWidthController,
decoration: const InputDecoration(labelText: 'Package Width'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _calculateVolumetricWeight(),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _packageHeightController,
decoration: const InputDecoration(labelText: 'Package Height'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _calculateVolumetricWeight(),
),
),
const SizedBox(width: 12),
SizedBox(
width: 110,
child: DropdownButtonFormField<String>(
value: _packageDimensionUom,
decoration: const InputDecoration(labelText: 'UoM'),
items: const [
DropdownMenuItem(value: 'cm', child: Text('cm')),
DropdownMenuItem(value: 'in', child: Text('in')),
DropdownMenuItem(value: 'mm', child: Text('mm')),
DropdownMenuItem(value: 'm', child: Text('m')),
],
onChanged: (val) {
if (val != null) {
setState(() => _packageDimensionUom = val);
_calculateVolumetricWeight();
}
},
),
),
],
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextFormField(
controller: _packageWeightInGController,
decoration: const InputDecoration(
labelText: 'Package Weight (in Grams)',
suffixText: 'g',
prefixIcon: Icon(LucideIcons.package),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
const SizedBox(width: 16),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Icon(LucideIcons.box, color: Theme.of(context).colorScheme.primary, size: 22),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Volumetric Weight',
style: TextStyle(fontSize: 12, color: Colors.grey, fontWeight: FontWeight.w500),
),
const SizedBox(height: 2),
Text(
_volumetricWeightInKg != null
? '$_volumetricWeightInKg kg'
: 'L × W × H ÷ 5000',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: _volumetricWeightInKg != null
? Theme.of(context).colorScheme.primary
: Colors.grey,
),
),
],
),
),
],
),
),
),
],
),
],
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),
if (_images.isNotEmpty || _existingImageIds.isNotEmpty)
@@ -448,21 +927,36 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
}
Widget _buildBottomBar() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: SafeArea(
child: SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _isSaving ? null : _save,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 4,
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
child: _isSaving
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(widget.product != null ? 'Update Product' : 'Publish Product', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(
widget.product != null ? 'Update Product' : 'Publish Product',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
),
),
@@ -473,11 +967,10 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
required bool isNetwork,
String? url,
XFile? xFile,
required VoidCallback onDelete,
required VoidCallback onTap,
required VoidCallback onDelete,
}) {
return Stack(
fit: StackFit.expand,
children: [
GestureDetector(
onTap: onTap,
@@ -488,16 +981,19 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
color: Colors.grey[200],
child: SizedBox.expand(
child: isNetwork
? (_token == null
? const Icon(LucideIcons.image, color: Colors.grey)
: Image.network(
? (_token != null
? Image.network(
url!,
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $_token'},
errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey),
)
: Image.network(
url!,
fit: BoxFit.cover,
errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey),
))
: (kIsWeb
? Image.network(xFile!.path, fit: BoxFit.cover)
@@ -512,7 +1008,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
child: GestureDetector(
onTap: onDelete,
child: Container(
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.6), shape: BoxShape.circle),
padding: const EdgeInsets.all(6),
child: const Icon(LucideIcons.x, color: Colors.white, size: 16),
),
@@ -524,20 +1020,26 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
Widget _buildPremiumTextField({
required String label,
TextEditingController? controller,
String? initialValue,
String? prefixText,
String? suffixText,
IconData? prefixIcon,
String? helperText,
TextInputType? keyboardType,
void Function(String)? onChanged,
void Function(String?)? onSaved,
String? Function(String?)? validator,
}) {
return TextFormField(
initialValue: initialValue,
controller: controller,
initialValue: controller == null ? initialValue : null,
decoration: InputDecoration(
labelText: label,
prefixText: prefixText,
suffixText: suffixText,
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
helperText: helperText,
),
keyboardType: keyboardType,
onChanged: onChanged,

View File

@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/product_categories_provider.dart';
import '../../../core/theme/nature_colors.dart';
import '../providers/uoms_provider.dart';
import '../../business/providers/business_mode_provider.dart';
import '../../business/providers/business_provider.dart';
import '../../../core/theme/app_theme.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'dart:ui';
@@ -99,6 +100,18 @@ class _CategoryManagementScreenState extends ConsumerState<CategoryManagementScr
final children = allCategories.where((c) => c.parentCategoryId == category.id).toList();
final isLeaf = children.isEmpty && !category.hasChild;
final hasCommodity = category.commodityCode != null && category.commodityCode != 'NONE' && category.commodityCode!.isNotEmpty;
final hasHsn = category.defaultHsn != null && category.defaultHsn!.isNotEmpty;
String? subtitleText;
if (hasCommodity && hasHsn) {
subtitleText = '${category.commodityCode} • HSN: ${category.defaultHsn}';
} else if (hasCommodity) {
subtitleText = category.commodityCode;
} else if (hasHsn) {
subtitleText = 'HSN: ${category.defaultHsn}';
}
// A nice frosted glass card
return Container(
margin: EdgeInsets.only(left: depth * 16.0, bottom: 8),
@@ -136,8 +149,8 @@ class _CategoryManagementScreenState extends ConsumerState<CategoryManagementScr
child: const Icon(LucideIcons.tag, size: 20, color: Colors.blue),
),
title: Text(category.name, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: category.commodityCode != null
? Text('${category.commodityCode} • HSN: ${category.defaultHsn ?? "N/A"}',
subtitle: subtitleText != null
? Text(subtitleText,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600))
: null,
trailing: Row(
@@ -186,35 +199,77 @@ class _CategoryManagementScreenState extends ConsumerState<CategoryManagementScr
Widget _buildCategoryPopupMenu(ProductCategory category, bool isLeaf) {
return PopupMenuButton<String>(
icon: const Icon(LucideIcons.moreVertical, color: Colors.grey),
onSelected: (value) {
if (value == 'edit') {
icon: const Icon(LucideIcons.ellipsisVertical, size: 18, color: Colors.grey),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (val) {
if (val == 'edit') {
_showAddCategorySheet(context, category, ref, isEdit: true);
} else if (value == 'add_sub') {
} else if (val == 'addSub') {
_showAddCategorySheet(context, category, ref, isEdit: false);
} else if (value == 'delete') {
if (category.id != null) {
ref.read(productCategoriesProvider.notifier).deleteCategory(category.id!);
}
} else if (val == 'delete') {
_confirmDelete(category);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: Row(children: [Icon(LucideIcons.edit, size: 18), SizedBox(width: 8), Text('Edit')]),
),
if (!isLeaf)
const PopupMenuItem(
value: 'add_sub',
child: Row(children: [Icon(LucideIcons.plus, size: 18), SizedBox(width: 8), Text('Add Subcategory')]),
value: 'addSub',
child: Row(
children: [
Icon(LucideIcons.folderPlus, size: 18, color: Colors.blue),
SizedBox(width: 12),
Text('Add Subcategory'),
],
),
),
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(LucideIcons.pencil, size: 18, color: Colors.orange),
SizedBox(width: 12),
Text('Edit Details'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(children: [Icon(LucideIcons.trash2, size: 18, color: Colors.red), SizedBox(width: 8), Text('Delete', style: TextStyle(color: Colors.red))]),
child: Row(
children: [
Icon(LucideIcons.trash2, size: 18, color: Colors.red),
SizedBox(width: 12),
Text('Delete Category', style: TextStyle(color: Colors.red)),
],
),
),
],
);
}
void _confirmDelete(ProductCategory category) {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Text('Delete Category?'),
content: Text('Are you sure you want to delete "${category.name}"? If it has subcategories or products, they will also be affected.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
onPressed: () {
ref.read(productCategoriesProvider.notifier).deleteCategory(category.id!);
Navigator.pop(context);
},
child: const Text('Delete'),
),
],
),
);
}
}
void _showAddCategorySheet(BuildContext context, ProductCategory? parentOrCategory, WidgetRef ref, {bool isEdit = false}) {
@@ -251,7 +306,8 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
bool _huidRequired = false;
String _commodityCode = 'XAU';
String _makingChargeType = 'PER_GRAM';
String _baseUnit = 'g';
String _baseUnit = 'pcs';
String _defaultLengthUom = 'cm';
final List<Map<String, String>> _commodities = [
{'code': 'XAU', 'label': 'XAU - Gold'},
@@ -264,6 +320,14 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
@override
void initState() {
super.initState();
final profile = ref.read(businessProfileProvider).value;
final nature = (profile?.natureOfBusiness ?? profile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
_baseUnit = isJewellery ? 'g' : 'pcs';
_commodityCode = isJewellery ? 'XAU' : 'NONE';
_defaultLengthUom = 'cm';
if (widget.categoryToEdit != null) {
final c = widget.categoryToEdit!;
_selectedParentId = c.parentCategoryId;
@@ -275,15 +339,15 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
_makingChargeController.text = c.defaultMakingCharge?.toString() ?? '';
_purityFactorController.text = formatPurity(c.purityFactor);
_huidRequired = c.huidRequired;
_commodityCode = c.commodityCode ?? 'XAU';
_commodityCode = c.commodityCode ?? (isJewellery ? 'XAU' : 'NONE');
// Handle legacy or varying enum string values
_makingChargeType = c.makingChargeType ?? 'PER_GRAM';
if (_makingChargeType == 'PER_GM') _makingChargeType = 'PER_GRAM';
_baseUnit = c.baseUnit ?? 'g';
_baseUnit = c.baseUnit;
if (_baseUnit == 'gm') _baseUnit = 'g';
_defaultLengthUom = c.defaultLengthUom;
}
} else if (widget.parent != null) {
_selectedParentId = widget.parent!.id;
@@ -302,19 +366,24 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
void _submit() {
if (_formKey.currentState!.validate()) {
final profile = ref.read(businessProfileProvider).value;
final nature = (profile?.natureOfBusiness ?? profile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final newCategory = ProductCategory(
id: widget.categoryToEdit?.id,
name: _nameController.text.trim(),
parentCategoryId: _selectedParentId,
hasChild: !_isLeaf,
commodityCode: _isLeaf ? _commodityCode : null,
purityFactor: _isLeaf ? normalizePurity(double.tryParse(_purityFactorController.text)) : 1.0,
commodityCode: _isLeaf ? (isJewellery ? _commodityCode : 'NONE') : null,
purityFactor: _isLeaf ? (isJewellery ? normalizePurity(double.tryParse(_purityFactorController.text)) : 1.0) : 1.0,
defaultHsn: _isLeaf ? _hsnController.text.trim() : null,
defaultGst: _isLeaf ? double.tryParse(_gstController.text) : null,
huidRequired: _isLeaf ? _huidRequired : false,
defaultMakingCharge: _isLeaf ? double.tryParse(_makingChargeController.text) : null,
makingChargeType: _isLeaf ? _makingChargeType : null,
huidRequired: _isLeaf ? (isJewellery ? _huidRequired : false) : false,
defaultMakingCharge: _isLeaf && isJewellery ? double.tryParse(_makingChargeController.text) : null,
makingChargeType: _isLeaf && isJewellery ? _makingChargeType : null,
baseUnit: _isLeaf ? _baseUnit : 'pcs',
defaultLengthUom: _isLeaf ? _defaultLengthUom : 'cm',
);
if (widget.categoryToEdit != null && widget.categoryToEdit!.id != null) {
@@ -330,6 +399,10 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final isBusinessMode = ref.watch(businessModeProvider);
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final uomsState = ref.watch(uomsProvider);
return Container(
height: MediaQuery.of(context).size.height * 0.85,
@@ -461,7 +534,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
const Text('Product Defaults', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
if (isBusinessMode) ...[
if (isJewellery && isBusinessMode) ...[
Row(
children: [
Expanded(
@@ -516,6 +589,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
),
const SizedBox(height: 16),
if (isJewellery) ...[
SwitchListTile(
title: const Text('HUID Required'),
subtitle: const Text('Is Hallmark Unique Identification mandatory?'),
@@ -530,7 +604,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
Expanded(
child: DropdownButtonFormField<String>(
value: _makingChargeType,
decoration: InputDecoration(
decoration: const InputDecoration(
labelText: 'Making Charge Type',
),
items: const [
@@ -552,18 +626,70 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _baseUnit,
decoration: InputDecoration(
labelText: 'Base Unit',
],
uomsState.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: LinearProgressIndicator(),
),
items: const [
DropdownMenuItem(value: 'g', child: Text('Grams (g)')),
DropdownMenuItem(value: 'kg', child: Text('Kilograms (kg)')),
DropdownMenuItem(value: 'pcs', child: Text('Pieces (pcs)')),
error: (err, stack) => DropdownButtonFormField<String>(
value: _baseUnit,
decoration: const InputDecoration(labelText: 'Base Unit'),
items: [
DropdownMenuItem(value: _baseUnit, child: Text(_baseUnit)),
],
onChanged: (val) => setState(() => _baseUnit = val!),
),
data: (uoms) {
final availableValues = uoms.map((u) => u.abbreviation ?? u.name).toList();
final match = uoms.where((u) =>
(u.abbreviation ?? u.name).toLowerCase() == _baseUnit.toLowerCase() ||
u.name.toLowerCase() == _baseUnit.toLowerCase()
).firstOrNull;
final effectiveValue = match != null
? (match.abbreviation ?? match.name)
: (availableValues.contains(_baseUnit) ? _baseUnit : (availableValues.isNotEmpty ? availableValues.first : null));
return DropdownButtonFormField<String>(
value: effectiveValue,
decoration: const InputDecoration(
labelText: 'Base Unit',
prefixIcon: Icon(LucideIcons.scale),
),
isExpanded: true,
items: uoms.map((u) {
final val = u.abbreviation ?? u.name;
return DropdownMenuItem<String>(
value: val,
child: Text(u.displayName),
);
}).toList(),
onChanged: (val) {
if (val != null) {
setState(() => _baseUnit = val);
}
},
);
},
),
if (!isJewellery) ...[
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _defaultLengthUom,
decoration: const InputDecoration(
labelText: 'Default Length UoM',
prefixIcon: Icon(LucideIcons.ruler),
),
items: const [
DropdownMenuItem(value: 'cm', child: Text('Centimeters (cm)')),
DropdownMenuItem(value: 'in', child: Text('Inches (in)')),
DropdownMenuItem(value: 'mm', child: Text('Millimeters (mm)')),
DropdownMenuItem(value: 'm', child: Text('Meters (m)')),
],
onChanged: (val) => setState(() => _defaultLengthUom = val ?? 'cm'),
),
],
],
const SizedBox(height: 40),
],

View File

@@ -1,11 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:kifi_app/core/network/dio_client.dart';
import 'package:kifi_app/core/widgets/responsive_layout.dart';
import 'package:kifi_app/features/business/providers/business_provider.dart';
import 'package:kifi_app/features/inventory/domain/product.dart';
import 'package:kifi_app/features/inventory/presentation/add_product_screen.dart';
import 'package:kifi_app/features/inventory/presentation/stock_ledger_tab.dart';
import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart';
import 'package:kifi_app/features/inventory/providers/product_categories_provider.dart';
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
import 'package:kifi_app/features/inventory/providers/uoms_provider.dart';
import 'package:kifi_app/features/transactions/presentation/widgets/attachment_gallery_screen.dart';
class ProductDetailScreen extends ConsumerWidget {
final Product product;
@@ -70,46 +75,50 @@ class ProductDetailScreen extends ConsumerWidget {
}
Widget _buildOverviewTab(BuildContext context, Product p, WidgetRef ref) {
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final categories = ref.watch(productCategoriesProvider).value ?? [];
final category = p.categoryId != null
? categories.where((c) => c.id == p.categoryId).firstOrNull
: null;
final uoms = ref.watch(uomsProvider).value ?? [];
final uom = p.uomId != null
? uoms.where((u) => u.id == p.uomId).firstOrNull
: null;
final hasSpecs = (p.brandName != null && p.brandName!.isNotEmpty) ||
(p.manufacturerCode != null && p.manufacturerCode!.isNotEmpty) ||
(p.material != null && p.material!.isNotEmpty) ||
(p.color != null && p.color!.isNotEmpty) ||
(p.countryOfOrigin != null && p.countryOfOrigin!.isNotEmpty);
final hasDimensions = p.itemLength != null ||
p.itemWidth != null ||
p.itemHeight != null ||
p.weightInG != null;
final hasPackaging = p.packageLength != null ||
p.packageWidth != null ||
p.packageHeight != null ||
p.packageWeightInG != null ||
p.volumetricWeightInKg != null;
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: MaxContentWidth(
maxWidth: 900,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image Header
// Images Header & Gallery
if (p.imageIds.isNotEmpty)
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.grey[200],
),
clipBehavior: Clip.antiAlias,
child: FutureBuilder<String?>(
future: DioClient().storage.read(key: 'jwt_token'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final token = snapshot.data;
if (token == null) {
return const Icon(
Icons.image,
size: 50,
color: Colors.grey,
);
}
return Image.network(
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content',
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $token'},
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.image, size: 50, color: Colors.grey),
);
},
),
),
const SizedBox(height: 24),
_buildImagesSection(context, p),
if (p.imageIds.isNotEmpty)
const SizedBox(height: 20),
// Product Details Card
Card(
@@ -122,18 +131,32 @@ class ProductDetailScreen extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.package, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
const Text(
"Product Details",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const Divider(),
_buildDetailRow("SKU / Barcode", p.sku ?? "-"),
if (category != null)
_buildDetailRow("Category", category.name),
if (uom != null)
_buildDetailRow("Unit of Measure", uom.displayName),
_buildDetailRow("HSN Code", p.hsnCode ?? "-"),
if (p.mrp != null && p.mrp! > 0)
_buildDetailRow("MRP", "${p.mrp!.toStringAsFixed(2)}"),
if (p.sellingPrice != null && p.sellingPrice! > 0)
_buildDetailRow("Selling Price", "${p.sellingPrice!.toStringAsFixed(2)}"),
_buildDetailRow(
"GST Rate",
"${p.gstRate?.toStringAsFixed(1) ?? '0'}%",
),
if (p.makingCharges != null && p.makingCharges! > 0)
if (isJewellery && p.makingCharges != null && p.makingCharges! > 0)
_buildDetailRow(
"Making Charges",
p.makingChargesType == 'PERCENTAGE'
@@ -146,21 +169,324 @@ 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) ...[
if (hasSpecs) ...[
const SizedBox(height: 16),
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.sliders, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
const Text(
"Product Specifications",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const Divider(),
if (p.brandName != null && p.brandName!.isNotEmpty)
_buildDetailRow("Brand Name", p.brandName!),
if (p.manufacturerCode != null && p.manufacturerCode!.isNotEmpty)
_buildDetailRow("Manufacturer Code", p.manufacturerCode!),
if (p.material != null && p.material!.isNotEmpty)
_buildDetailRow("Material", p.material!),
if (p.color != null && p.color!.isNotEmpty)
_buildDetailRow("Color", p.color!),
if (p.countryOfOrigin != null && p.countryOfOrigin!.isNotEmpty)
_buildDetailRow("Country of Origin", p.countryOfOrigin!),
],
),
),
),
],
if (hasDimensions) ...[
const SizedBox(height: 16),
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.ruler, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
const Text(
"Item Dimensions & Weight",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const Divider(),
if (p.itemLength != null || p.itemWidth != null || p.itemHeight != null)
_buildDetailRow(
"Dimensions (L × W × H)",
"${p.itemLength ?? '-'} × ${p.itemWidth ?? '-'} × ${p.itemHeight ?? '-'} ${p.dimensionUom ?? 'cm'}",
),
if (p.weightInG != null)
_buildDetailRow("Item Weight", "${p.weightInG} g"),
],
),
),
),
],
if (hasPackaging) ...[
const SizedBox(height: 16),
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.box, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
const Text(
"Packaging Dimensions & Weight",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const Divider(),
if (p.packageLength != null || p.packageWidth != null || p.packageHeight != null)
_buildDetailRow(
"Package (L × W × H)",
"${p.packageLength ?? '-'} × ${p.packageWidth ?? '-'} × ${p.packageHeight ?? '-'} ${p.packageDimensionUom ?? 'cm'}",
),
if (p.packageWeightInG != null)
_buildDetailRow("Package Weight", "${p.packageWeightInG} g"),
if (p.volumetricWeightInKg != null)
_buildDetailRow(
"Volumetric Weight",
"${p.volumetricWeightInKg} kg",
highlight: true,
),
],
),
),
),
],
],
],
),
),
);
}
Widget _buildDetailRow(String label, String value) {
Widget _buildImagesSection(BuildContext context, Product p) {
return FutureBuilder<String?>(
future: DioClient().storage.read(key: 'jwt_token'),
builder: (context, snapshot) {
final token = snapshot.data;
if (token == null) {
return const SizedBox.shrink();
}
final allImages = p.imageIds.map<ImageProvider>((imageId) {
return NetworkImage(
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/$imageId/content',
headers: {'Authorization': 'Bearer $token'},
);
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AttachmentGalleryScreen(
images: allImages,
initialIndex: 0,
),
),
);
},
child: Container(
height: 220,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.grey[200],
),
clipBehavior: Clip.antiAlias,
child: Image.network(
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content',
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $token'},
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.image, size: 50, color: Colors.grey),
),
),
),
if (p.imageIds.length > 1) ...[
const SizedBox(height: 12),
SizedBox(
height: 64,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: p.imageIds.length,
separatorBuilder: (context, index) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final imageId = p.imageIds[index];
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AttachmentGalleryScreen(
images: allImages,
initialIndex: index,
),
),
);
},
child: Container(
width: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: Image.network(
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/$imageId/content',
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $token'},
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.image, size: 24, color: Colors.grey),
),
),
);
},
),
),
],
],
);
},
);
}
Widget _buildDetailRow(String label, String value, {bool highlight = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: Colors.grey[600])),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 14)),
Flexible(
child: Text(
value,
textAlign: TextAlign.end,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: highlight ? const Color(0xFF4F46E5) : Colors.black87,
),
),
),
],
),
);
}
}

View File

@@ -22,6 +22,7 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
final ScrollController _scrollController = ScrollController();
Timer? _debounce;
String? _token;
String _stockFilter = 'ALL'; // 'ALL', 'LOW_STOCK', 'OUT_OF_STOCK'
@override
void initState() {
@@ -55,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(

View File

@@ -10,7 +10,12 @@ import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:kifi_app/features/vendor/presentation/purchase_order_details_screen.dart';
import 'package:kifi_app/features/vendor/providers/purchase_orders_provider.dart';
import 'package:kifi_app/features/sales/presentation/invoice_details_screen.dart';
import 'package:kifi_app/features/sales/providers/invoices_provider.dart';
import 'package:kifi_app/features/sales/domain/invoice.dart';
import 'package:kifi_app/features/business/providers/business_provider.dart';
import 'package:kifi_app/core/utils/purity_utils.dart';
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
class StockLedgerTab extends ConsumerStatefulWidget {
final Product product;
@@ -26,6 +31,18 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
bool _isLoading = true;
String _selectedFilter = 'IN_STOCK'; // 'IN_STOCK', 'SOLD', 'ALL'
String _resolveImageUrl(String path) {
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
final base = DioClient().dio.options.baseUrl;
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
if (cleanPath.startsWith('api/kifi-v2/upload/view') || cleanPath.startsWith('upload/view')) {
return '$base/${cleanPath.replaceFirst('api/kifi-v2/', '')}';
}
return '$base/upload/view?path=${Uri.encodeComponent(cleanPath)}';
}
@override
void initState() {
super.initState();
@@ -77,6 +94,10 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
displayItems = _items!;
}
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final categoriesState = ref.watch(productCategoriesProvider);
final commodityRatesState = ref.watch(commodityRatesProvider);
@@ -84,20 +105,24 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
(c) => c.id == widget.product.categoryId,
).firstOrNull;
final bool isCommodity = isJewellery && category?.commodityCode != null && category!.commodityCode!.isNotEmpty;
final String unit = category?.baseUnit ?? 'g';
final double purityFactor = resolvePurity(categoryPurity: category?.purityFactor, productPurity: widget.product.purityFactor);
double commodityRate = 0.0;
if (category?.commodityCode != null && commodityRatesState.value != null) {
if (isCommodity && commodityRatesState.value != null && category.commodityCode != null) {
final match = commodityRatesState.value!.where(
(r) => r.commodityCode.toUpperCase() == category!.commodityCode!.toUpperCase()
(r) => r.commodityCode.toUpperCase() == category.commodityCode!.toUpperCase()
).firstOrNull;
if (match != null) {
commodityRate = match.rate;
}
}
final double currentRate = commodityRate > 0 ? (commodityRate * purityFactor) : (category?.dailyRate ?? 0.0);
final double currentRate = isCommodity
? (commodityRate > 0 ? (commodityRate * purityFactor) : (category.dailyRate ?? 0.0))
: 0.0;
return Column(
children: [
@@ -134,6 +159,31 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
final isSold = item.status == 'SOLD';
final purchaseOrdersState = ref.watch(purchaseOrdersProvider);
final purchaseOrders = purchaseOrdersState.value ?? [];
final invoicesState = ref.watch(invoicesProvider);
final invoices = invoicesState.value ?? [];
// Resolve sales invoice for sold item
Invoice? matchingSaleInvoice;
if (isSold) {
if (item.salesRef != null && item.salesRef!.isNotEmpty) {
matchingSaleInvoice = invoices.where((i) => i.invoiceNumber == item.salesRef).firstOrNull;
}
matchingSaleInvoice ??= invoices.where((i) => i.items.any((invIt) =>
(invIt.inventoryItemId != null && invIt.inventoryItemId == item.id) ||
(item.huid != null && item.huid!.isNotEmpty && invIt.huid == item.huid)
)).firstOrNull;
}
double effectiveSaleRate = (item.saleRate != null && item.saleRate! > 0) ? item.saleRate! : 0.0;
if (isSold && effectiveSaleRate == 0.0 && matchingSaleInvoice != null) {
final matchItem = matchingSaleInvoice.items.where((invIt) =>
(invIt.inventoryItemId != null && invIt.inventoryItemId == item.id) ||
(item.huid != null && item.huid!.isNotEmpty && invIt.huid == item.huid)
).firstOrNull;
if (matchItem != null && matchItem.unitPrice > 0) {
effectiveSaleRate = matchItem.unitPrice;
}
}
double weight = (item.grossWeight != null && item.grossWeight! > 0)
? item.grossWeight!
@@ -163,11 +213,39 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
}
}
// Resolve purchase item thumbnail photo
String? itemPhotoUrl = (item.photoUrl != null && item.photoUrl!.isNotEmpty) ? item.photoUrl : null;
if (itemPhotoUrl == null && item.purchaseRef != null) {
final po = purchaseOrders.where((p) => p.poNumber == item.purchaseRef).firstOrNull;
if (po != null) {
final poItem = po.items.where((i) =>
(item.huid != null && i.huid == item.huid) ||
(item.sku != null && i.sku == item.sku) ||
i.productId == widget.product.id
).firstOrNull ?? po.items.firstOrNull;
if (poItem != null && poItem.photoUrl != null && poItem.photoUrl!.isNotEmpty) {
itemPhotoUrl = poItem.photoUrl;
}
}
}
String? finalDisplayUrl;
bool isProductFallback = false;
if (itemPhotoUrl != null) {
finalDisplayUrl = _resolveImageUrl(itemPhotoUrl);
} else if (widget.product.imageIds.isNotEmpty) {
finalDisplayUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product.id}/images/${widget.product.imageIds.first}/content';
isProductFallback = true;
}
final purchasePrice = weight * purchaseRate;
final currentPrice = weight * currentRate;
final gainLoss = currentPrice - purchasePrice;
final double displayedSellingRate = effectiveSaleRate > 0 ? effectiveSaleRate : (isCommodity ? currentRate : purchaseRate);
final currentPrice = weight * (isSold ? displayedSellingRate : currentRate);
final gainLoss = isSold ? ((weight * displayedSellingRate) - purchasePrice) : (currentPrice - purchasePrice);
final isGain = gainLoss >= 0;
final String? salesInvoiceNumber = matchingSaleInvoice?.invoiceNumber ?? item.salesRef;
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 12),
@@ -188,30 +266,93 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: FutureBuilder<String?>(
FutureBuilder<String?>(
future: DioClient().storage.read(key: 'jwt_token'),
builder: (context, snapshot) {
if (snapshot.hasData && widget.product.imageIds.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
'${DioClient().dio.options.baseUrl}/inventory/products/${widget.product.id}/images/${widget.product.imageIds.first}/content',
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer ${snapshot.data}'},
final hasImage = finalDisplayUrl != null;
return GestureDetector(
onTap: hasImage
? () {
final token = snapshot.data;
final headers = (token != null && isProductFallback)
? {'Authorization': 'Bearer $token'}
: null;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AttachmentGalleryScreen(
images: [
NetworkImage(
finalDisplayUrl!,
headers: headers,
),
],
initialIndex: 0,
),
),
);
}
return const Icon(LucideIcons.image, color: Colors.grey, size: 20);
},
: null,
child: MouseRegion(
cursor: hasImage ? SystemMouseCursors.click : SystemMouseCursors.basic,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.grey.withValues(alpha: 0.2),
),
),
child: hasImage
? Stack(
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(7),
child: Image.network(
finalDisplayUrl,
fit: BoxFit.cover,
headers: (snapshot.hasData && isProductFallback)
? {'Authorization': 'Bearer ${snapshot.data}'}
: null,
errorBuilder: (context, error, stackTrace) => const Icon(
LucideIcons.imageOff,
color: Colors.grey,
size: 20,
),
),
),
),
Positioned(
bottom: 2,
right: 2,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(4),
),
child: const Icon(
LucideIcons.maximize2,
size: 8,
color: Colors.white,
),
),
),
],
)
: const Icon(
LucideIcons.image,
color: Colors.grey,
size: 20,
),
),
),
);
},
),
const SizedBox(width: 12),
Expanded(
child: Column(
@@ -224,6 +365,49 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
const SizedBox(height: 4),
if (isSold) ...[
if (salesInvoiceNumber != null && salesInvoiceNumber.isNotEmpty)
InkWell(
onTap: () async {
try {
var inv = matchingSaleInvoice;
if (inv == null) {
var invs = ref.read(invoicesProvider).value;
if (invs == null || invs.isEmpty) {
invs = await ref.read(invoicesProvider.future);
}
inv = invs?.where((i) => i.invoiceNumber == salesInvoiceNumber).firstOrNull;
}
if (inv == null) {
throw Exception('Sales invoice $salesInvoiceNumber not found');
}
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceDetailsScreen(invoice: inv!),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open sales invoice: $e')),
);
}
}
},
child: Text(
'INV: $salesInvoiceNumber',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
] else ...[
if (item.purchaseRef != null)
InkWell(
onTap: () async {
@@ -236,7 +420,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
(p) => p.poNumber == item.purchaseRef,
orElse: () => throw Exception('Purchase invoice not found'),
);
if (mounted) {
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(
@@ -245,7 +429,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
);
}
} catch (e) {
if (mounted) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open purchase invoice: $e')),
);
@@ -258,12 +442,14 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
color: Colors.blue,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
if (item.huid != null && item.huid!.isNotEmpty)
],
if (isJewellery && item.huid != null && item.huid!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
padding: const EdgeInsets.only(top: 2.0),
child: Text('HUID: ${item.huid}', style: const TextStyle(fontWeight: FontWeight.w500)),
),
],
@@ -298,6 +484,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
color: isSold ? Colors.grey.shade600 : null,
),
),
if (isCommodity) ...[
const SizedBox(height: 3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
@@ -316,10 +503,12 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
),
),
],
],
),
],
),
const Divider(height: 24),
if (isCommodity) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -347,7 +536,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(isSold ? 'Selling Rate' : 'Current Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${currentRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
Text('${displayedSellingRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
@@ -386,6 +575,84 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
],
),
),
] else ...[
// Non-Commodity / E-Commerce Products
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Purchase Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchaseRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Purchase Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchasePrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
),
if (isSold && effectiveSaleRate > 0) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Sold Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${effectiveSaleRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.purple)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Sold Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${(weight * effectiveSaleRate).toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.purple)),
],
),
],
),
const SizedBox(height: 16),
Builder(
builder: (context) {
final soldAmt = weight * effectiveSaleRate;
final realizedMargin = soldAmt - purchasePrice;
final isProfit = realizedMargin >= 0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isProfit ? Colors.green.withValues(alpha: 0.1) : Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isProfit ? LucideIcons.trendingUp : LucideIcons.trendingDown,
color: isProfit ? Colors.green : Colors.red,
size: 16,
),
const SizedBox(width: 8),
Text(
'Realized ${isProfit ? 'Profit' : 'Loss'}: ₹${realizedMargin.abs().toStringAsFixed(2)}',
style: TextStyle(
color: isProfit ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
);
},
),
],
],
],
),
),

View File

@@ -16,6 +16,7 @@ class ProductCategory {
final double? defaultMakingCharge;
final String? makingChargeType;
final String baseUnit;
final String defaultLengthUom;
final bool isActive;
final int sortOrder;
final double? dailyRate;
@@ -34,6 +35,7 @@ class ProductCategory {
this.defaultMakingCharge,
this.makingChargeType,
this.baseUnit = 'pcs',
this.defaultLengthUom = 'cm',
this.isActive = true,
this.sortOrder = 0,
this.dailyRate,
@@ -54,6 +56,7 @@ class ProductCategory {
defaultMakingCharge: (json['defaultMakingCharge'] ?? json['default_making_charge'] as num?)?.toDouble(),
makingChargeType: json['makingChargeType'] ?? json['making_charge_type'],
baseUnit: json['baseUnit'] ?? json['base_unit'] ?? 'pcs',
defaultLengthUom: json['defaultLengthUom'] ?? json['default_length_uom'] ?? 'cm',
isActive: json['isActive'] ?? json['is_active'] ?? true,
sortOrder: json['sortOrder'] ?? json['sort_order'] ?? 0,
dailyRate: (json['dailyRate'] ?? json['daily_rate'] as num?)?.toDouble(),
@@ -75,6 +78,7 @@ class ProductCategory {
'defaultMakingCharge': defaultMakingCharge,
'makingChargeType': makingChargeType,
'baseUnit': baseUnit,
'defaultLengthUom': defaultLengthUom,
'isActive': isActive,
'sortOrder': sortOrder,
};

View File

@@ -57,11 +57,20 @@ class UomsNotifier extends AsyncNotifier<List<UnitOfMeasure>> {
// If empty in database, seed standard units of measure
try {
final defaults = [
{'name': 'Pieces', 'abbreviation': 'pcs'},
{'name': 'Units', 'abbreviation': 'unit'},
{'name': 'Boxes', 'abbreviation': 'box'},
{'name': 'Sets', 'abbreviation': 'set'},
{'name': 'Pairs', 'abbreviation': 'pair'},
{'name': 'Packs', 'abbreviation': 'pk'},
{'name': 'Grams', 'abbreviation': 'g'},
{'name': 'Kilograms', 'abbreviation': 'kg'},
{'name': 'Pieces', 'abbreviation': 'pcs'},
{'name': 'Carats', 'abbreviation': 'ct'},
{'name': 'Milligrams', 'abbreviation': 'mg'},
{'name': 'Carats', 'abbreviation': 'ct'},
{'name': 'Meters', 'abbreviation': 'm'},
{'name': 'Liters', 'abbreviation': 'l'},
{'name': 'Milliliters', 'abbreviation': 'ml'},
{'name': 'Dozens', 'abbreviation': 'doz'},
];
for (final def in defaults) {
await DioClient().dio.post('/inventory/uom', data: def);

View File

@@ -0,0 +1,201 @@
class CreditNoteItem {
final int? id;
final int? creditNoteId;
final int? invoiceItemId;
final int? productId;
final int? inventoryItemId;
final String? productName;
final String? sku;
final String? huid;
final String? hsnCode;
final double quantity;
final double? weight;
final double unitPrice;
final double taxRate;
final double cgst;
final double sgst;
final double igst;
final double discount;
final double makingCharge;
final double otherCharges;
final double total;
CreditNoteItem({
this.id,
this.creditNoteId,
this.invoiceItemId,
this.productId,
this.inventoryItemId,
this.productName,
this.sku,
this.huid,
this.hsnCode,
required this.quantity,
this.weight,
required this.unitPrice,
this.taxRate = 0.0,
this.cgst = 0.0,
this.sgst = 0.0,
this.igst = 0.0,
this.discount = 0.0,
this.makingCharge = 0.0,
this.otherCharges = 0.0,
required this.total,
});
factory CreditNoteItem.fromJson(Map<String, dynamic> json) {
return CreditNoteItem(
id: json['id'],
creditNoteId: json['creditNoteId'] ?? json['credit_note_id'],
invoiceItemId: json['invoiceItemId'] ?? json['invoice_item_id'],
productId: json['productId'] ?? json['product_id'],
inventoryItemId: json['inventoryItemId'] ?? json['inventory_item_id'],
productName: json['productName'] ?? json['product_name'],
sku: json['sku'],
huid: json['huid'],
hsnCode: json['hsnCode'] ?? json['hsn_code'],
quantity: (json['quantity'] as num?)?.toDouble() ?? 1.0,
weight: (json['weight'] as num?)?.toDouble(),
unitPrice: (json['unitPrice'] ?? json['unit_price'] as num?)?.toDouble() ?? 0.0,
taxRate: (json['taxRate'] ?? json['tax_rate'] as num?)?.toDouble() ?? 0.0,
cgst: (json['cgst'] as num?)?.toDouble() ?? 0.0,
sgst: (json['sgst'] as num?)?.toDouble() ?? 0.0,
igst: (json['igst'] as num?)?.toDouble() ?? 0.0,
discount: (json['discount'] as num?)?.toDouble() ?? 0.0,
makingCharge: (json['makingCharge'] ?? json['making_charge'] as num?)?.toDouble() ?? 0.0,
otherCharges: (json['otherCharges'] ?? json['other_charges'] as num?)?.toDouble() ?? 0.0,
total: (json['total'] as num?)?.toDouble() ?? 0.0,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
if (id != null) data['id'] = id;
if (creditNoteId != null) data['creditNoteId'] = creditNoteId;
if (invoiceItemId != null) data['invoiceItemId'] = invoiceItemId;
if (productId != null) data['productId'] = productId;
if (inventoryItemId != null) data['inventoryItemId'] = inventoryItemId;
if (productName != null) data['productName'] = productName;
if (sku != null) data['sku'] = sku;
if (huid != null) data['huid'] = huid;
if (hsnCode != null) data['hsnCode'] = hsnCode;
data['quantity'] = quantity;
if (weight != null) data['weight'] = weight;
data['unitPrice'] = unitPrice;
data['taxRate'] = taxRate;
data['cgst'] = cgst;
data['sgst'] = sgst;
data['igst'] = igst;
data['discount'] = discount;
data['makingCharge'] = makingCharge;
data['otherCharges'] = otherCharges;
data['total'] = total;
return data;
}
}
class CreditNote {
final int? id;
final int? userId;
final int invoiceId;
final String creditNoteNumber;
final int? customerId;
final DateTime returnDate;
final String reason; // CUSTOMER_RETURN, RTO_UNDELIVERED, DEFECTIVE, EXCHANGE, OTHER
final bool restockItems;
final double subtotal;
final double taxTotal;
final double cgstTotal;
final double sgstTotal;
final double igstTotal;
final double discountTotal;
final double totalAmount;
final String refundStatus; // ADJUSTED_TO_AR, REFUNDED_TO_WALLET, STORE_CREDIT, PENDING
final int? refundWalletId;
final String? notes;
final DateTime? createdAt;
final DateTime? updatedAt;
final List<CreditNoteItem> items;
CreditNote({
this.id,
this.userId,
required this.invoiceId,
required this.creditNoteNumber,
this.customerId,
required this.returnDate,
this.reason = 'CUSTOMER_RETURN',
this.restockItems = true,
required this.subtotal,
this.taxTotal = 0.0,
this.cgstTotal = 0.0,
this.sgstTotal = 0.0,
this.igstTotal = 0.0,
this.discountTotal = 0.0,
required this.totalAmount,
this.refundStatus = 'ADJUSTED_TO_AR',
this.refundWalletId,
this.notes,
this.createdAt,
this.updatedAt,
this.items = const [],
});
factory CreditNote.fromJson(Map<String, dynamic> json) {
return CreditNote(
id: json['id'],
userId: json['userId'] ?? json['user_id'],
invoiceId: json['invoiceId'] ?? json['invoice_id'] ?? 0,
creditNoteNumber: json['creditNoteNumber'] ?? json['credit_note_number'] ?? '',
customerId: json['customerId'] ?? json['customer_id'],
returnDate: json['returnDate'] != null
? DateTime.parse(json['returnDate'])
: (json['return_date'] != null ? DateTime.parse(json['return_date']) : DateTime.now()),
reason: json['reason'] ?? 'CUSTOMER_RETURN',
restockItems: json['restockItems'] ?? json['restock_items'] ?? true,
subtotal: (json['subtotal'] as num?)?.toDouble() ?? 0.0,
taxTotal: (json['taxTotal'] ?? json['tax_total'] as num?)?.toDouble() ?? 0.0,
cgstTotal: (json['cgstTotal'] ?? json['cgst_total'] as num?)?.toDouble() ?? 0.0,
sgstTotal: (json['sgstTotal'] ?? json['sgst_total'] as num?)?.toDouble() ?? 0.0,
igstTotal: (json['igstTotal'] ?? json['igst_total'] as num?)?.toDouble() ?? 0.0,
discountTotal: (json['discountTotal'] ?? json['discount_total'] as num?)?.toDouble() ?? 0.0,
totalAmount: (json['totalAmount'] ?? json['total_amount'] as num?)?.toDouble() ?? 0.0,
refundStatus: json['refundStatus'] ?? json['refund_status'] ?? 'ADJUSTED_TO_AR',
refundWalletId: json['refundWalletId'] ?? json['refund_wallet_id'],
notes: json['notes'],
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: (json['created_at'] != null ? DateTime.parse(json['created_at']) : null),
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'])
: (json['updated_at'] != null ? DateTime.parse(json['updated_at']) : null),
items: json['items'] != null
? (json['items'] as List).map((i) => CreditNoteItem.fromJson(i)).toList()
: [],
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
if (id != null) data['id'] = id;
if (userId != null) data['userId'] = userId;
data['invoiceId'] = invoiceId;
data['creditNoteNumber'] = creditNoteNumber;
if (customerId != null) data['customerId'] = customerId;
data['returnDate'] = returnDate.toIso8601String().split('T')[0];
data['reason'] = reason;
data['restockItems'] = restockItems;
data['subtotal'] = subtotal;
data['taxTotal'] = taxTotal;
data['cgstTotal'] = cgstTotal;
data['sgstTotal'] = sgstTotal;
data['igstTotal'] = igstTotal;
data['discountTotal'] = discountTotal;
data['totalAmount'] = totalAmount;
data['refundStatus'] = refundStatus;
if (refundWalletId != null) data['refundWalletId'] = refundWalletId;
if (notes != null) data['notes'] = notes;
data['items'] = items.map((i) => i.toJson()).toList();
return data;
}
}

View File

@@ -192,6 +192,17 @@ class Invoice {
final double? emiAmount;
final String? emiCycle;
final DateTime? emiStartDate;
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;
final List<InvoicePayment>? payments;
@@ -219,6 +230,17 @@ class Invoice {
this.emiAmount,
this.emiCycle,
this.emiStartDate,
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 [],
this.payments,
});
@@ -254,6 +276,19 @@ class Invoice {
emiStartDate: json['emiStartDate'] != null
? DateTime.parse(json['emiStartDate'])
: (json['emi_start_date'] != null ? DateTime.parse(json['emi_start_date']) : null),
salesChannelId: json['salesChannelId'] ?? json['sales_channel_id'],
salesChannel: json['salesChannel'] ?? json['sales_channel'],
marketplaceOrderId: json['marketplaceOrderId'] ?? json['marketplace_order_id'],
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
? (json['items'] as List).map((i) => InvoiceItem.fromJson(i)).toList()
: [],
@@ -296,6 +331,19 @@ class Invoice {
if (emiStartDate != null) {
data['emiStartDate'] = emiStartDate!.toIso8601String().split('T')[0];
}
if (salesChannelId != null) data['salesChannelId'] = salesChannelId;
if (salesChannel != null) data['salesChannel'] = salesChannel;
if (marketplaceOrderId != null) data['marketplaceOrderId'] = marketplaceOrderId;
if (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();
if (payments != null) {
data['payments'] = payments!.map((i) => i.toJson()).toList();

View File

@@ -0,0 +1,103 @@
class MarketplaceSettlement {
final int? id;
final int? userId;
final int invoiceId;
final int? salesChannelId;
final String? channelName;
final String? settlementRef;
final DateTime settlementDate;
final double grossAmount;
final double commissionFee;
final double shippingFee;
final double otherFees;
final double feeGst;
final double tcsGstAmount;
final double tdsAmount;
final double totalDeductions;
final double netPayoutAmount;
final int? payoutWalletId;
final String status;
final String? notes;
final DateTime? createdAt;
final DateTime? updatedAt;
MarketplaceSettlement({
this.id,
this.userId,
required this.invoiceId,
this.salesChannelId,
this.channelName,
this.settlementRef,
required this.settlementDate,
required this.grossAmount,
this.commissionFee = 0.0,
this.shippingFee = 0.0,
this.otherFees = 0.0,
this.feeGst = 0.0,
this.tcsGstAmount = 0.0,
this.tdsAmount = 0.0,
required this.totalDeductions,
required this.netPayoutAmount,
this.payoutWalletId,
this.status = 'SETTLED',
this.notes,
this.createdAt,
this.updatedAt,
});
factory MarketplaceSettlement.fromJson(Map<String, dynamic> json) {
return MarketplaceSettlement(
id: json['id'],
userId: json['userId'] ?? json['user_id'],
invoiceId: json['invoiceId'] ?? json['invoice_id'] ?? 0,
salesChannelId: json['salesChannelId'] ?? json['sales_channel_id'],
channelName: json['channelName'] ?? json['channel_name'],
settlementRef: json['settlementRef'] ?? json['settlement_ref'],
settlementDate: json['settlementDate'] != null
? DateTime.parse(json['settlementDate'])
: (json['settlement_date'] != null ? DateTime.parse(json['settlement_date']) : DateTime.now()),
grossAmount: (json['grossAmount'] ?? json['gross_amount'] as num?)?.toDouble() ?? 0.0,
commissionFee: (json['commissionFee'] ?? json['commission_fee'] as num?)?.toDouble() ?? 0.0,
shippingFee: (json['shippingFee'] ?? json['shipping_fee'] as num?)?.toDouble() ?? 0.0,
otherFees: (json['otherFees'] ?? json['other_fees'] as num?)?.toDouble() ?? 0.0,
feeGst: (json['feeGst'] ?? json['fee_gst'] as num?)?.toDouble() ?? 0.0,
tcsGstAmount: (json['tcsGstAmount'] ?? json['tcs_gst_amount'] as num?)?.toDouble() ?? 0.0,
tdsAmount: (json['tdsAmount'] ?? json['tds_amount'] as num?)?.toDouble() ?? 0.0,
totalDeductions: (json['totalDeductions'] ?? json['total_deductions'] as num?)?.toDouble() ?? 0.0,
netPayoutAmount: (json['netPayoutAmount'] ?? json['net_payout_amount'] as num?)?.toDouble() ?? 0.0,
payoutWalletId: json['payoutWalletId'] ?? json['payout_wallet_id'],
status: json['status'] ?? 'SETTLED',
notes: json['notes'],
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: (json['created_at'] != null ? DateTime.parse(json['created_at']) : null),
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'])
: (json['updated_at'] != null ? DateTime.parse(json['updated_at']) : null),
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
if (id != null) data['id'] = id;
if (userId != null) data['userId'] = userId;
data['invoiceId'] = invoiceId;
if (salesChannelId != null) data['salesChannelId'] = salesChannelId;
if (channelName != null) data['channelName'] = channelName;
if (settlementRef != null) data['settlementRef'] = settlementRef;
data['settlementDate'] = settlementDate.toIso8601String().split('T')[0];
data['grossAmount'] = grossAmount;
data['commissionFee'] = commissionFee;
data['shippingFee'] = shippingFee;
data['otherFees'] = otherFees;
data['feeGst'] = feeGst;
data['tcsGstAmount'] = tcsGstAmount;
data['tdsAmount'] = tdsAmount;
data['totalDeductions'] = totalDeductions;
data['netPayoutAmount'] = netPayoutAmount;
if (payoutWalletId != null) data['payoutWalletId'] = payoutWalletId;
data['status'] = status;
if (notes != null) data['notes'] = notes;
return data;
}
}

View File

@@ -0,0 +1,49 @@
class SalesChannel {
final int? id;
final int? userId;
final String name;
final String? code;
final String? icon;
final int? defaultLedgerId;
final bool isActive;
final DateTime? createdAt;
final DateTime? updatedAt;
SalesChannel({
this.id,
this.userId,
required this.name,
this.code,
this.icon,
this.defaultLedgerId,
this.isActive = true,
this.createdAt,
this.updatedAt,
});
factory SalesChannel.fromJson(Map<String, dynamic> json) {
return SalesChannel(
id: json['id'],
userId: json['userId'] ?? json['user_id'],
name: json['name'] ?? '',
code: json['code'],
icon: json['icon'],
defaultLedgerId: json['defaultLedgerId'] ?? json['default_ledger_id'],
isActive: json['isActive'] ?? json['is_active'] ?? true,
createdAt: json['createdAt'] != null ? DateTime.tryParse(json['createdAt']) : null,
updatedAt: json['updatedAt'] != null ? DateTime.tryParse(json['updatedAt']) : null,
);
}
Map<String, dynamic> toJson() {
return {
if (id != null) 'id': id,
if (userId != null) 'userId': userId,
'name': name,
if (code != null) 'code': code,
if (icon != null) 'icon': icon,
if (defaultLedgerId != null) 'defaultLedgerId': defaultLedgerId,
'isActive': isActive,
};
}
}

View File

@@ -0,0 +1,451 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../domain/credit_note.dart';
import '../providers/credit_notes_provider.dart';
import '../providers/customers_provider.dart';
import '../providers/invoices_provider.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../../business/providers/business_provider.dart';
class CreditNotesListScreen extends ConsumerStatefulWidget {
const CreditNotesListScreen({super.key});
@override
ConsumerState<CreditNotesListScreen> createState() => _CreditNotesListScreenState();
}
class _CreditNotesListScreenState extends ConsumerState<CreditNotesListScreen> {
String _searchQuery = '';
final TextEditingController _searchController = TextEditingController();
String? _selectedReasonFilter;
final formatCurrency = NumberFormat.currency(symbol: '');
final formatDate = DateFormat('MMM dd, yyyy');
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _shareCreditNotePdf(CreditNote cn) async {
try {
final pdf = pw.Document();
final business = ref.read(businessProfileProvider).value;
final customers = ref.read(customersProvider).value ?? [];
final customer = customers.where((c) => c.id == cn.customerId).firstOrNull;
final invoices = ref.read(invoicesProvider).value ?? [];
final invoice = invoices.where((i) => i.id == cn.invoiceId).firstOrNull;
final pdfFormatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2);
final pdfFormatDate = DateFormat('dd MMM yyyy');
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
build: (context) => [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
// Header
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
business?.businessName ?? 'KIFI ENTERPRISE',
style: pw.TextStyle(fontSize: 18, fontWeight: pw.FontWeight.bold, color: PdfColors.red900),
),
if (business?.address != null && business!.address!.isNotEmpty)
pw.Text(business.address!, style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700)),
if (business?.gstin != null && business!.gstin!.isNotEmpty)
pw.Text('GSTIN: ${business.gstin}', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.blue800)),
],
),
),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(
'CREDIT NOTE',
style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold, color: PdfColors.red900),
),
pw.SizedBox(height: 4),
pw.Text('CN #: ${cn.creditNoteNumber}', style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold)),
pw.Text('Date: ${pdfFormatDate.format(cn.returnDate)}', style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700)),
if (invoice != null)
pw.Text('Original Inv #: ${invoice.invoiceNumber}', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900)),
pw.Text('Reason: ${cn.reason}', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)),
],
),
],
),
pw.SizedBox(height: 16),
// Bill To
pw.Container(
padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
color: PdfColors.grey100,
border: pw.Border.all(color: PdfColors.grey400, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
),
child: pw.Row(
children: [
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('CREDITED TO (CUSTOMER):', style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)),
pw.SizedBox(height: 2),
pw.Text(customer?.name ?? 'Valued Customer', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
if (customer?.phone != null) pw.Text('Phone: ${customer!.phone}', style: const pw.TextStyle(fontSize: 9)),
if (customer?.gstin != null) pw.Text('GSTIN: ${customer!.gstin}', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold)),
],
),
),
],
),
),
pw.SizedBox(height: 16),
// Table of Returned Items
pw.TableHelper.fromTextArray(
context: context,
border: const pw.TableBorder(
bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
horizontalInside: pw.BorderSide(color: PdfColors.grey200, width: 0.5),
),
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.white, fontSize: 8),
headerDecoration: const pw.BoxDecoration(color: PdfColors.red900),
cellStyle: const pw.TextStyle(fontSize: 8),
headers: ['Returned Particulars', 'Qty / Weight', 'Unit Rate', 'GST Rate', 'Tax Amount', 'Credit Amount'],
data: cn.items.map((item) {
final qtyStr = item.weight != null && item.weight! > 0
? '${item.weight!.toStringAsFixed(3)} g'
: '${item.quantity.toInt()} pcs';
final taxAmt = item.cgst + item.sgst + item.igst;
return [
item.productName ?? 'Item',
qtyStr,
pdfFormatCurrency.format(item.unitPrice),
'${item.taxRate.toStringAsFixed(1)}%',
taxAmt > 0 ? pdfFormatCurrency.format(taxAmt) : '-',
pdfFormatCurrency.format(item.total),
];
}).toList(),
),
pw.SizedBox(height: 14),
// Grand Total
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.end,
children: [
pw.Container(
width: 200,
padding: const pw.EdgeInsets.all(8),
decoration: const pw.BoxDecoration(
color: PdfColors.red50,
borderRadius: pw.BorderRadius.all(pw.Radius.circular(6)),
),
child: pw.Column(
children: [
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Total Credit Value:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 11, color: PdfColors.red900)),
pw.Text(pdfFormatCurrency.format(cn.totalAmount), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 12, color: PdfColors.red900)),
],
),
],
),
),
],
),
pw.SizedBox(height: 24),
// Signatures
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.SizedBox(height: 25),
pw.Text('Customer\'s Acknowledgement', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700)),
],
),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.SizedBox(height: 25),
pw.Text('For ${business?.businessName ?? "KIFI ENTERPRISE"} (Authorized Signatory)', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700)),
],
),
],
),
],
),
],
),
);
final pdfBytes = await pdf.save();
await Printing.sharePdf(bytes: pdfBytes, filename: 'CreditNote_${cn.creditNoteNumber}.pdf');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error generating Credit Note PDF: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
final creditNotesState = ref.watch(creditNotesProvider);
final customers = ref.watch(customersProvider).value ?? [];
final invoices = ref.watch(invoicesProvider).value ?? [];
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC),
appBar: AppBar(
title: const Text('Sales Returns & Credit Notes', style: TextStyle(fontWeight: FontWeight.bold)),
elevation: 0,
backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white,
foregroundColor: isDark ? Colors.white : Colors.black,
centerTitle: true,
),
body: Column(
children: [
// Search & Filter Header
Container(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Column(
children: [
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search Credit Note #, Invoice # or Customer...',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
contentPadding: const EdgeInsets.symmetric(vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
isDense: true,
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(LucideIcons.xCircle, size: 18),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
),
onChanged: (val) => setState(() => _searchQuery = val.trim()),
),
const SizedBox(height: 8),
// Reason Filter Chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 6.0),
child: FilterChip(
label: const Text('All Returns', style: TextStyle(fontSize: 11)),
selected: _selectedReasonFilter == null,
onSelected: (selected) {
if (selected) setState(() => _selectedReasonFilter = null);
},
),
),
...['CUSTOMER_RETURN', 'RTO_UNDELIVERED', 'DEFECTIVE', 'EXCHANGE'].map((code) {
final label = code == 'CUSTOMER_RETURN'
? 'Customer Returns'
: (code == 'RTO_UNDELIVERED' ? 'RTO Undelivered' : (code == 'DEFECTIVE' ? 'Defective' : 'Exchange'));
final isSelected = _selectedReasonFilter == code;
return Padding(
padding: const EdgeInsets.only(right: 6.0),
child: FilterChip(
label: Text(label, style: const TextStyle(fontSize: 11)),
selected: isSelected,
onSelected: (selected) {
setState(() => _selectedReasonFilter = selected ? code : null);
},
),
);
}),
],
),
),
],
),
),
// List
Expanded(
child: creditNotesState.when(
data: (creditNotes) {
final filtered = creditNotes.where((cn) {
final q = _searchQuery.toLowerCase();
final invoice = invoices.where((i) => i.id == cn.invoiceId).firstOrNull;
final customer = customers.where((c) => c.id == cn.customerId).firstOrNull;
final matchesNumber = cn.creditNoteNumber.toLowerCase().contains(q);
final matchesInvoice = invoice != null && invoice.invoiceNumber.toLowerCase().contains(q);
final matchesCustomer = customer != null && customer.name.toLowerCase().contains(q);
final matchesSearch = q.isEmpty || matchesNumber || matchesInvoice || matchesCustomer;
final matchesReason = _selectedReasonFilter == null || cn.reason == _selectedReasonFilter;
return matchesSearch && matchesReason;
}).toList();
if (filtered.isEmpty) {
return RefreshIndicator(
onRefresh: () => ref.refresh(creditNotesProvider.future),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 120),
Center(
child: Column(
children: [
Icon(LucideIcons.undo2, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('No credit notes or returns recorded.', style: TextStyle(color: Colors.grey, fontSize: 16)),
],
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () => ref.refresh(creditNotesProvider.future),
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: filtered.length,
itemBuilder: (context, index) {
final cn = filtered[index];
final invoice = invoices.where((i) => i.id == cn.invoiceId).firstOrNull;
final customer = customers.where((c) => c.id == cn.customerId).firstOrNull;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(LucideIcons.undo2, color: Colors.red, size: 16),
),
const SizedBox(width: 8),
Text(
cn.creditNoteNumber,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
),
child: Text(
cn.reason.replaceAll('_', ' '),
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.red),
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
const Icon(LucideIcons.user, size: 14, color: Colors.grey),
const SizedBox(width: 6),
Text(
customer?.name ?? 'Walk-in Customer',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
const Spacer(),
if (invoice != null) ...[
Text(
'Inv #${invoice.invoiceNumber}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
formatDate.format(cn.returnDate),
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
Row(
children: [
Text(
formatCurrency.format(cn.totalAmount),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.red),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(LucideIcons.share2, size: 18, color: Colors.blue),
tooltip: 'Share Credit Note PDF',
onPressed: () => _shareCreditNotePdf(cn),
),
],
),
],
),
],
),
);
},
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, stack) => Center(child: Text('Error: $e')),
),
),
],
),
);
}
}

View File

@@ -12,15 +12,64 @@ import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../../inventory/providers/products_provider.dart';
import '../../inventory/providers/product_categories_provider.dart';
import '../../inventory/providers/uoms_provider.dart';
import '../../inventory/domain/product.dart';
import '../domain/invoice.dart';
import '../providers/invoices_provider.dart';
import '../providers/customers_provider.dart';
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({
ProductCategory? category,
Product? product,
InvoiceItem? invoiceItem,
String? commodityCode,
String? huid,
}) {
final h = huid ?? invoiceItem?.huid;
if (h != null && h.trim().isNotEmpty) return true;
final code = commodityCode ??
category?.commodityCode ??
invoiceItem?.commodityCode;
if (code != null && code.trim().isNotEmpty && code.trim().toUpperCase() != 'NONE') {
return true;
}
return false;
}
String _resolveProductUnit({
required Product? product,
required ProductCategory? category,
required List<UnitOfMeasure> uoms,
bool isCommodity = false,
}) {
if (isCommodity) {
return (category != null && category.baseUnit.isNotEmpty) ? category.baseUnit : 'g';
}
if (product?.uomId != null) {
final uom = uoms.where((u) => u.id == product!.uomId).firstOrNull;
if (uom != null && uom.abbreviation != null && uom.abbreviation!.isNotEmpty) return uom.abbreviation!;
if (uom != null && uom.name.isNotEmpty) return uom.name;
}
if (category != null && category.baseUnit.isNotEmpty) {
return category.baseUnit;
}
return 'pcs';
}
String _resolveImageUrl(String path) {
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
@@ -185,10 +234,45 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
const Text('INVOICE NO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)),
const SizedBox(height: 2),
Text(latestInvoice.invoiceNumber, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.blue)),
if (latestInvoice.salesChannel != null && latestInvoice.salesChannel!.isNotEmpty) ...[
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.blue.withValues(alpha: 0.25)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.shoppingBag, size: 10, color: Colors.blue),
const SizedBox(width: 4),
Text(
latestInvoice.salesChannel!,
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.blue),
),
],
),
),
],
if (latestInvoice.marketplaceOrderId != null && latestInvoice.marketplaceOrderId!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'Ref: ${latestInvoice.marketplaceOrderId}',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: Colors.grey.shade700),
),
],
const SizedBox(height: 10),
const Text('INVOICE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)),
const SizedBox(height: 2),
Text(formatDate.format(latestInvoice.issueDate), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
if (latestInvoice.placeOfSupply != null && latestInvoice.placeOfSupply!.isNotEmpty) ...[
const SizedBox(height: 8),
const Text('PLACE OF SUPPLY', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)),
const SizedBox(height: 2),
Text(latestInvoice.placeOfSupply!, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
],
],
),
),
@@ -233,18 +317,32 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
// Item Cards
...(latestInvoice.items).map((item) {
final product = products.where((p) => p.id == item.productId).firstOrNull;
final category = categories.where((c) => c.id == product?.categoryId).firstOrNull;
final weight = item.weight ?? item.quantity;
final metalAmount = weight * item.unitPrice;
final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull;
final uoms = ref.watch(uomsProvider).value ?? [];
final isItemCommodity = _isCommodityProduct(
category: category,
product: product,
invoiceItem: item,
);
final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
final baseAmount = qtyOrWeight * item.unitPrice;
final unit = _resolveProductUnit(product: product, category: category, uoms: uoms, isCommodity: isItemCommodity);
double makingChargeAmt = 0.0;
if (isItemCommodity) {
if (item.makingChargesType == 'PERCENTAGE') {
makingChargeAmt = metalAmount * (item.makingCharge / 100.0);
makingChargeAmt = baseAmount * (item.makingCharge / 100.0);
} else if (item.makingChargesType == 'PER_PIECE') {
makingChargeAmt = item.makingCharge;
} else {
makingChargeAmt = weight * item.makingCharge;
makingChargeAmt = qtyOrWeight * item.makingCharge;
}
}
final taxableAmount = baseAmount + makingChargeAmt + item.otherCharges - item.discount;
final taxAmount = (taxableAmount * item.taxRate) / 100.0;
return Container(
margin: const EdgeInsets.only(bottom: 12),
@@ -278,7 +376,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.productName ?? product?.name ?? item.description ?? 'Jewellery Item',
item.productName ?? product?.name ?? item.description ?? 'Item',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 4),
@@ -290,10 +388,12 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
_buildDetailBadge(item.categoryName ?? category!.name, const Color(0xFFD4AF37)),
if (item.sku != null || product?.sku != null)
_buildDetailBadge('SKU: ${item.sku ?? product!.sku}', Colors.blue),
if (item.huid != null)
if (isItemCommodity && item.huid != null && item.huid!.isNotEmpty)
_buildDetailBadge('HUID: ${item.huid}', Colors.purple),
if (item.hsnCode != null)
if (item.hsnCode != null && item.hsnCode!.isNotEmpty)
_buildDetailBadge('HSN: ${item.hsnCode}', Colors.grey),
if (!isItemCommodity)
_buildDetailBadge('Unit: $unit', Colors.teal),
],
),
],
@@ -306,16 +406,18 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
],
),
const Divider(height: 18),
if (isItemCommodity) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Metal (${weight.toStringAsFixed(3)}g @ ₹${item.unitPrice.toStringAsFixed(2)}/g):',
'Metal (${qtyOrWeight.toStringAsFixed(3)}$unit @ ₹${item.unitPrice.toStringAsFixed(2)}/$unit):',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
Text(formatCurrency.format(metalAmount), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)),
Text(formatCurrency.format(baseAmount), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)),
],
),
if (makingChargeAmt > 0) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -327,6 +429,29 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
Text(formatCurrency.format(makingChargeAmt), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12, color: Colors.indigo)),
],
),
],
] else ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Rate: ₹${item.unitPrice.toStringAsFixed(2)} / $unit • Qty: ${qtyOrWeight.toStringAsFixed(qtyOrWeight.truncateToDouble() == qtyOrWeight ? 0 : 2)} $unit',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w500),
),
Text(formatCurrency.format(baseAmount), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)),
],
),
],
if (item.discount > 0) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Discount:', style: TextStyle(fontSize: 11, color: Colors.red)),
Text('- ${formatCurrency.format(item.discount)}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.red)),
],
),
],
if (item.taxRate > 0) ...[
const SizedBox(height: 4),
Row(
@@ -334,7 +459,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
children: [
Text('GST (${item.taxRate.toStringAsFixed(1)}%):', style: TextStyle(fontSize: 11, color: Colors.grey.shade500)),
Text(
'+ ${formatCurrency.format(((metalAmount + makingChargeAmt) * (item.taxRate / 100.0)))}',
'+ ${formatCurrency.format(taxAmount)}',
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
),
],
@@ -415,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),
],
),
@@ -437,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),
@@ -538,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')));
}
}
}
@@ -550,30 +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 {
@@ -581,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,
@@ -603,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!,
@@ -639,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),
@@ -679,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!)}',
@@ -690,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(
@@ -717,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,
),
),
@@ -733,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)),
],
),
),
],
],
),
),
@@ -776,6 +1153,19 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
final groupTotal = group.fold(0.0, (sum, i) => sum + i.total);
final gstRate = group.first.taxRate > 0 ? group.first.taxRate : (product?.gstRate ?? (category?.defaultGst ?? 0.0));
final isGroupCommodity = group.any((item) => _isCommodityProduct(
category: category,
product: product,
invoiceItem: item,
));
final groupUnit = _resolveProductUnit(
product: product,
category: category,
uoms: uoms,
isCommodity: isGroupCommodity,
);
return pw.Container(
margin: const pw.EdgeInsets.only(bottom: 14),
decoration: pw.BoxDecoration(
@@ -839,6 +1229,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
style: const pw.TextStyle(fontSize: 7.5, color: PdfColors.grey800),
),
),
if (isGroupCommodity)
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: pw.BoxDecoration(
@@ -855,6 +1246,23 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
),
),
),
if (!isGroupCommodity)
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: pw.BoxDecoration(
color: PdfColors.teal50,
border: pw.Border.all(color: PdfColors.teal400, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)),
),
child: pw.Text(
'Unit: $groupUnit',
style: pw.TextStyle(
fontSize: 7.5,
fontWeight: pw.FontWeight.bold,
color: PdfColors.teal900,
),
),
),
if (hsnStr != '-')
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
@@ -900,15 +1308,25 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
4: pw.Alignment.centerRight,
5: pw.Alignment.centerRight,
},
headers: [
headers: isGroupCommodity
? [
'Item / HUID',
'Weight / Qty',
'Rate',
'Making Chg',
'Other Chg',
'Total Amt',
]
: [
'Item / SKU',
'Qty',
'Unit Price',
'Discount',
'GST %',
'Total Amt',
],
data: group.map((item) {
data: isGroupCommodity
? group.map((item) {
String particular = '';
if (item.huid != null && item.huid!.isNotEmpty) {
particular = 'HUID: ${item.huid}';
@@ -920,10 +1338,10 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
final weight = item.weight ?? item.quantity;
final weightQty = item.weight != null && item.weight! > 0
? '${item.weight!.toStringAsFixed(item.weight!.truncateToDouble() == item.weight ? 0 : 3)} g'
? '${item.weight!.toStringAsFixed(item.weight!.truncateToDouble() == item.weight ? 0 : 3)} $groupUnit'
: '${item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 0)} pcs';
final rateUnit = item.weight != null && item.weight! > 0 ? '/ g' : '/ pc';
final rateUnit = item.weight != null && item.weight! > 0 ? '/ $groupUnit' : '/ pc';
double makingAmt = 0.0;
if (item.makingChargesType == 'PERCENTAGE') {
@@ -942,6 +1360,18 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
item.otherCharges > 0 ? formatCurrency.format(item.otherCharges) : '-',
formatCurrency.format(item.total),
];
}).toList()
: group.map((item) {
final qty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
final qtyStr = '${qty.toStringAsFixed(qty.truncateToDouble() == qty ? 0 : 2)} $groupUnit';
return [
item.sku ?? skuStr ?? 'Item',
qtyStr,
formatCurrency.format(item.unitPrice),
item.discount > 0 ? '-${formatCurrency.format(item.discount)}' : '-',
'${item.taxRate.toStringAsFixed(1)}%',
formatCurrency.format(item.total),
];
}).toList(),
),
@@ -959,7 +1389,9 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'Metal Subtotal: ${formatCurrency.format(groupSubtotal)} | Making: ${formatCurrency.format(groupMaking)}',
isGroupCommodity
? 'Metal Subtotal: ${formatCurrency.format(groupSubtotal)} | Making: ${formatCurrency.format(groupMaking)}'
: 'Items Subtotal: ${formatCurrency.format(groupSubtotal)}',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
),
pw.Text(
@@ -1247,8 +1679,9 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
),
],
),
];
},
],
),
],
),
);

View File

@@ -5,8 +5,11 @@ import 'package:intl/intl.dart';
import '../providers/invoices_provider.dart';
import '../domain/invoice.dart';
import '../providers/customers_provider.dart';
import '../providers/sales_channels_provider.dart';
import 'invoice_builder_screen.dart';
import 'invoice_details_screen.dart';
import 'credit_notes_list_screen.dart';
import 'marketplace_settlements_screen.dart';
class InvoicesListScreen extends ConsumerStatefulWidget {
const InvoicesListScreen({super.key});
@@ -18,6 +21,8 @@ class InvoicesListScreen extends ConsumerStatefulWidget {
class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
String _searchQuery = '';
final TextEditingController _searchController = TextEditingController();
String? _selectedChannelFilter;
String? _selectedDispatchStatusFilter;
String _getStatusLabel(Invoice invoice) {
if (invoice.isEmi && invoice.status != 'PAID') return 'EMI';
@@ -47,6 +52,23 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
}
}
Color _getDispatchStatusColor(String? status) {
switch (status?.toUpperCase()) {
case 'DELIVERED':
return Colors.green;
case 'SHIPPED':
case 'IN_TRANSIT':
return Colors.indigo;
case 'PACKED':
return Colors.blue;
case 'RTO':
return Colors.red;
case 'PENDING':
default:
return Colors.grey;
}
}
@override
void dispose() {
_searchController.dispose();
@@ -57,6 +79,7 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
Widget build(BuildContext context) {
final invoicesState = ref.watch(invoicesProvider);
final customersState = ref.watch(customersProvider);
final channelsState = ref.watch(salesChannelsProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
final formatCurrency = NumberFormat.currency(symbol: '');
final formatDate = DateFormat('MMM dd, yyyy');
@@ -72,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) {
@@ -198,12 +316,51 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
Expanded(
child: Row(
children: [
Flexible(
child: Text(
invoice.invoiceNumber,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
overflow: TextOverflow.ellipsis,
),
),
if (invoice.salesChannel != null && invoice.salesChannel!.isNotEmpty) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.blue.withValues(alpha: 0.25)),
),
child: Text(
invoice.salesChannel!,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.blue),
),
),
],
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),
),
),
],
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
@@ -270,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(
@@ -305,6 +466,41 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
),
),
],
// Logistics / Shipping Tracker Row (if set)
if (invoice.courierPartner != null || invoice.trackingNumber != null || (invoice.dispatchStatus != null && invoice.dispatchStatus != 'PENDING')) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(LucideIcons.truck, size: 14, color: Colors.indigo.shade600),
const SizedBox(width: 6),
Expanded(
child: Text(
'${invoice.courierPartner ?? "Courier"}${invoice.trackingNumber != null ? " • AWB: ${invoice.trackingNumber}" : ""}',
style: TextStyle(fontSize: 11.5, color: Colors.grey.shade700, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: _getDispatchStatusColor(invoice.dispatchStatus).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: _getDispatchStatusColor(invoice.dispatchStatus).withValues(alpha: 0.3)),
),
child: Text(
invoice.dispatchStatus ?? 'PENDING',
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: _getDispatchStatusColor(invoice.dispatchStatus),
),
),
),
],
),
],
const SizedBox(height: 10),
// Row 3: Date & Total Amount

View File

@@ -0,0 +1,865 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../domain/marketplace_settlement.dart';
import '../domain/invoice.dart';
import '../providers/marketplace_settlements_provider.dart';
import '../providers/invoices_provider.dart';
import 'invoice_details_screen.dart';
import 'widgets/settle_marketplace_order_sheet.dart';
class MarketplaceSettlementsScreen extends ConsumerStatefulWidget {
const MarketplaceSettlementsScreen({super.key});
@override
ConsumerState<MarketplaceSettlementsScreen> createState() => _MarketplaceSettlementsScreenState();
}
class _MarketplaceSettlementsScreenState extends ConsumerState<MarketplaceSettlementsScreen> with SingleTickerProviderStateMixin {
String _searchQuery = '';
final TextEditingController _searchController = TextEditingController();
String? _selectedChannelFilter;
late TabController _tabController;
final formatCurrency = NumberFormat.currency(symbol: '');
final formatDate = DateFormat('MMM dd, yyyy');
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_searchController.dispose();
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final settlementsState = ref.watch(marketplaceSettlementsProvider);
final invoices = ref.watch(invoicesProvider).value ?? [];
final isDark = Theme.of(context).brightness == Brightness.dark;
// Filter pending channel invoices (invoices that have a sales channel and are not fully settled yet)
final pendingChannelInvoices = invoices.where((inv) {
final isChannelOrder = inv.salesChannel != null &&
inv.salesChannel!.isNotEmpty &&
inv.salesChannel!.toUpperCase() != 'DIRECT_POS' &&
inv.salesChannel!.toUpperCase() != 'STORE';
final isUnsettled = inv.status != 'PAID' && inv.status != 'SETTLED' && inv.status != 'RETURNED';
return isChannelOrder && isUnsettled;
}).toList();
return Scaffold(
backgroundColor: isDark ? const Color(0xFF090D16) : const Color(0xFFF4F6F9),
appBar: AppBar(
title: const Text('Marketplace Settlements & Fees', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
elevation: 0,
backgroundColor: isDark ? const Color(0xFF131B2E) : Colors.white,
foregroundColor: isDark ? Colors.white : Colors.black87,
centerTitle: false,
actions: [
IconButton(
icon: const Icon(LucideIcons.refreshCw, size: 18),
tooltip: 'Refresh Settlements',
onPressed: () {
ref.refresh(marketplaceSettlementsProvider);
ref.refresh(invoicesProvider);
},
),
const SizedBox(width: 8),
],
),
body: settlementsState.when(
data: (settlements) {
final filteredSettlements = settlements.where((s) {
final q = _searchQuery.toLowerCase();
final invoice = invoices.where((i) => i.id == s.invoiceId).firstOrNull;
final matchesRef = s.settlementRef?.toLowerCase().contains(q) ?? false;
final matchesChannel = s.channelName?.toLowerCase().contains(q) ?? false;
final matchesInvoice = invoice != null &&
(invoice.invoiceNumber.toLowerCase().contains(q) ||
(invoice.marketplaceOrderId?.toLowerCase().contains(q) ?? false));
final matchesSearch = q.isEmpty || matchesRef || matchesChannel || matchesInvoice;
final matchesFilter = _selectedChannelFilter == null || s.channelName == _selectedChannelFilter;
return matchesSearch && matchesFilter;
}).toList();
// Aggregate Metrics
double totalGross = 0.0;
double totalNetPayout = 0.0;
double totalDeductions = 0.0;
double totalTcsTds = 0.0;
for (var s in settlements) {
totalGross += s.grossAmount;
totalNetPayout += s.netPayoutAmount;
totalDeductions += s.totalDeductions;
totalTcsTds += (s.tcsGstAmount + s.tdsAmount);
}
final uniqueChannels = settlements
.map((s) => s.channelName)
.whereType<String>()
.toSet()
.toList();
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1040),
child: CustomScrollView(
slivers: [
// 1. Overview Header & Explanation Card
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isDark
? [const Color(0xFF1E293B), const Color(0xFF0F172A)]
: [Colors.white, const Color(0xFFF1F5F9)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.indigo.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(LucideIcons.landmark, color: Colors.indigo, size: 28),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Marketplace Payout & Tax Reconciliation Hub',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 4),
Text(
'Reconcile actual bank remittances against marketplace orders (Amazon, Flipkart, Shopify). Automatically track referral commissions, shipping deductions, 18% fee GST, and claimable 1% TCS / 0.1% TDS tax credits.',
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
height: 1.4,
),
),
],
),
),
],
),
),
),
),
// 2. Metric KPI Cards (2x2 Grid)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 700;
return isWide
? Row(
children: [
Expanded(
child: _buildMetricCard(
title: 'Gross Channel Sales',
value: formatCurrency.format(totalGross),
subtitle: 'Total invoiced order value',
icon: LucideIcons.shoppingBag,
color: Colors.blue,
isDark: isDark,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildMetricCard(
title: 'Net Bank Remittances',
value: formatCurrency.format(totalNetPayout),
subtitle: 'Deposited into bank accounts',
icon: LucideIcons.landmark,
color: const Color(0xFF10B981),
isDark: isDark,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildMetricCard(
title: 'Fee Deductions',
value: formatCurrency.format(totalDeductions),
subtitle: 'Commissions, shipping & 18% GST',
icon: LucideIcons.scissors,
color: Colors.orange,
isDark: isDark,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildMetricCard(
title: 'TCS & TDS Tax Credits',
value: formatCurrency.format(totalTcsTds),
subtitle: '1% GST TCS + 0.1% TDS 194O',
icon: LucideIcons.shieldCheck,
color: Colors.purple,
isDark: isDark,
),
),
],
)
: Column(
children: [
Row(
children: [
Expanded(
child: _buildMetricCard(
title: 'Gross Sales',
value: formatCurrency.format(totalGross),
subtitle: 'Invoiced value',
icon: LucideIcons.shoppingBag,
color: Colors.blue,
isDark: isDark,
),
),
const SizedBox(width: 10),
Expanded(
child: _buildMetricCard(
title: 'Net Remitted',
value: formatCurrency.format(totalNetPayout),
subtitle: 'Bank deposits',
icon: LucideIcons.landmark,
color: const Color(0xFF10B981),
isDark: isDark,
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _buildMetricCard(
title: 'Fee Deductions',
value: formatCurrency.format(totalDeductions),
subtitle: 'Platform fees',
icon: LucideIcons.scissors,
color: Colors.orange,
isDark: isDark,
),
),
const SizedBox(width: 10),
Expanded(
child: _buildMetricCard(
title: 'TCS / TDS Credits',
value: formatCurrency.format(totalTcsTds),
subtitle: 'Tax withholdings',
icon: LucideIcons.shieldCheck,
color: Colors.purple,
isDark: isDark,
),
),
],
),
],
);
},
),
),
),
// 3. Tab Bar (Reconciled vs Unsettled Orders)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: TabBar(
controller: _tabController,
labelColor: Colors.blue,
unselectedLabelColor: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
indicatorColor: Colors.blue,
indicatorWeight: 3,
tabs: [
Tab(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.checkCheck, size: 16),
const SizedBox(width: 8),
Text('Reconciled Settlements (${settlements.length})', style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
),
Tab(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.clock, size: 16),
const SizedBox(width: 8),
Text('Unsettled Channel Orders (${pendingChannelInvoices.length})', style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
),
],
onTap: (_) => setState(() {}),
),
),
),
),
// 4. Tab Content View
_tabController.index == 0
? _buildReconciledTab(
context,
filteredSettlements,
invoices,
uniqueChannels,
isDark,
onSwitchToPending: () {
_tabController.animateTo(1);
setState(() {});
},
)
: _buildUnsettledOrdersTab(
context,
pendingChannelInvoices,
isDark,
),
],
),
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, stack) => Center(child: Text('Error: $e')),
),
);
}
Widget _buildReconciledTab(
BuildContext context,
List<MarketplaceSettlement> settlements,
List<Invoice> invoices,
List<String> uniqueChannels,
bool isDark, {
required VoidCallback onSwitchToPending,
}) {
if (settlements.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.fileSpreadsheet, size: 40, color: Colors.blue),
),
const SizedBox(height: 16),
const Text(
'No Reconciled Settlements Yet',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
const SizedBox(height: 8),
Text(
'When you receive platform payout reports from Amazon, Flipkart, or Shopify, reconcile them here to record fee deductions, claim TCS/TDS, and update your bank balance.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
),
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: onSwitchToPending,
icon: const Icon(LucideIcons.arrowRight, size: 16),
label: const Text('View Unsettled Channel Orders', style: TextStyle(fontWeight: FontWeight.bold)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
],
),
),
),
);
}
return SliverList(
delegate: SliverChildListDelegate([
// Search & Channel Filter Bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(
children: [
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search by Order ID, Bank UTR, or Channel...',
prefixIcon: const Icon(LucideIcons.search, size: 18, color: Colors.grey),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
filled: true,
fillColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF1F5F9),
isDense: true,
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(LucideIcons.xCircle, size: 16),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
),
onChanged: (val) => setState(() => _searchQuery = val.trim()),
),
if (uniqueChannels.isNotEmpty) ...[
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
FilterChip(
label: const Text('All Channels', style: TextStyle(fontSize: 12)),
selected: _selectedChannelFilter == null,
onSelected: (selected) {
if (selected) setState(() => _selectedChannelFilter = null);
},
),
const SizedBox(width: 8),
...uniqueChannels.map((ch) {
final isSelected = _selectedChannelFilter == ch;
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: FilterChip(
label: Text(ch, style: const TextStyle(fontSize: 12)),
selected: isSelected,
onSelected: (selected) {
setState(() => _selectedChannelFilter = selected ? ch : null);
},
),
);
}),
],
),
),
],
],
),
),
),
// Settlements List Cards
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Column(
children: settlements.map((s) {
final invoice = invoices.where((i) => i.id == s.invoiceId).firstOrNull;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.02),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(
s.channelName ?? 'Marketplace',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.blue),
),
),
if (s.settlementRef != null) ...[
const SizedBox(width: 10),
Text(
'UTR: ${s.settlementRef}',
style: TextStyle(fontSize: 12, color: isDark ? Colors.grey.shade400 : Colors.grey.shade600),
),
],
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF10B981).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
Icon(LucideIcons.checkCheck, size: 12, color: Color(0xFF10B981)),
SizedBox(width: 4),
Text('Reconciled', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Color(0xFF10B981))),
],
),
),
],
),
const SizedBox(height: 12),
if (invoice != null) ...[
InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => InvoiceDetailsScreen(invoice: invoice)),
);
},
child: Row(
children: [
const Icon(LucideIcons.fileText, size: 15, color: Colors.blue),
const SizedBox(width: 6),
Text(
'Invoice #${invoice.invoiceNumber} • Order #${invoice.marketplaceOrderId ?? "-"}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.blue),
),
const Spacer(),
const Icon(LucideIcons.chevronRight, size: 16, color: Colors.blue),
],
),
),
const SizedBox(height: 12),
],
// Deductions breakdown strip
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildDetailStat('Gross Value', formatCurrency.format(s.grossAmount)),
_buildDetailStat('Deductions', '- ${formatCurrency.format(s.totalDeductions)}', isRed: true),
_buildDetailStat('Net Remitted', formatCurrency.format(s.netPayoutAmount), isGreen: true),
],
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Settled on ${formatDate.format(s.settlementDate)}',
style: TextStyle(fontSize: 12, color: isDark ? Colors.grey.shade400 : Colors.grey.shade600),
),
if (s.tcsGstAmount > 0 || s.tdsAmount > 0)
Text(
'TCS/TDS Claimable: ${formatCurrency.format(s.tcsGstAmount + s.tdsAmount)}',
style: TextStyle(fontSize: 12, color: Colors.purple.shade700, fontWeight: FontWeight.bold),
),
],
),
],
),
);
}).toList(),
),
),
const SizedBox(height: 40),
]),
);
}
Widget _buildUnsettledOrdersTab(
BuildContext context,
List<Invoice> pendingInvoices,
bool isDark,
) {
if (pendingInvoices.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF10B981).withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.checkCheck, size: 40, color: Color(0xFF10B981)),
),
const SizedBox(height: 16),
const Text(
'All Marketplace Orders Reconciled!',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
const SizedBox(height: 8),
Text(
'There are currently no outstanding marketplace invoices waiting for bank payout reconciliation.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
),
),
],
),
),
),
);
}
return SliverList(
delegate: SliverChildListDelegate([
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'The following invoices are attributed to online channels and have not been reconciled with a bank payout:',
style: TextStyle(fontSize: 13, color: isDark ? Colors.grey.shade400 : Colors.grey.shade600),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: pendingInvoices.map((inv) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
inv.salesChannel ?? 'Marketplace',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.blue),
),
),
const SizedBox(width: 8),
Text(
'Order #${inv.marketplaceOrderId ?? "-"}',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 8),
Text(
'Invoice #${inv.invoiceNumber}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 4),
Text(
'Invoice Date: ${formatDate.format(inv.issueDate)} • Gross Amount: ${formatCurrency.format(inv.totalAmount)}',
style: TextStyle(fontSize: 12, color: isDark ? Colors.grey.shade400 : Colors.grey.shade600),
),
],
),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: () async {
final result = await showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) => SettleMarketplaceOrderSheet(invoice: inv),
);
if (result != null) {
ref.refresh(marketplaceSettlementsProvider);
ref.refresh(invoicesProvider);
}
},
icon: const Icon(LucideIcons.landmark, size: 14),
label: const Text('Settle Payout', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
],
),
);
}).toList(),
),
),
const SizedBox(height: 40),
]),
);
}
Widget _buildMetricCard({
required String title,
required String value,
required String subtitle,
required IconData icon,
required Color color,
required bool isDark,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withValues(alpha: 0.2)),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: isDark ? 0.1 : 0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 18, color: color),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
Text(
value,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(fontSize: 11, color: isDark ? Colors.grey.shade500 : Colors.grey.shade500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildDetailStat(String label, String value, {bool isGreen = false, bool isRed = false}) {
Color valColor = Colors.black87;
if (isGreen) valColor = const Color(0xFF10B981);
if (isRed) valColor = Colors.red;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 11, color: Colors.grey)),
const SizedBox(height: 3),
Text(
value,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: valColor),
),
],
);
}
}

View File

@@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../domain/sales_channel.dart';
import '../providers/sales_channels_provider.dart';
class SalesChannelsScreen extends ConsumerStatefulWidget {
const SalesChannelsScreen({super.key});
@override
ConsumerState<SalesChannelsScreen> createState() => _SalesChannelsScreenState();
}
class _SalesChannelsScreenState extends ConsumerState<SalesChannelsScreen> {
void _showAddEditChannelSheet([SalesChannel? channel]) {
final nameCtrl = TextEditingController(text: channel?.name ?? '');
final codeCtrl = TextEditingController(text: channel?.code ?? '');
final isEditing = channel != null;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom + 20,
left: 20,
right: 20,
top: 20,
),
decoration: BoxDecoration(
color: Theme.of(ctx).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
isEditing ? 'Edit Sales Channel' : 'Add Sales Channel',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.pop(ctx),
),
],
),
const SizedBox(height: 16),
TextField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: 'Channel Name *',
hintText: 'e.g. Nykaa, Myntra, Tata CLiQ, Etsy',
border: OutlineInputBorder(),
prefixIcon: Icon(LucideIcons.store),
),
),
const SizedBox(height: 16),
TextField(
controller: codeCtrl,
decoration: const InputDecoration(
labelText: 'Channel Code',
hintText: 'e.g. NYKAA, MYNTRA, ETSY',
border: OutlineInputBorder(),
prefixIcon: Icon(LucideIcons.tag),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
onPressed: () async {
final name = nameCtrl.text.trim();
if (name.isEmpty) return;
Navigator.pop(ctx);
try {
if (isEditing) {
await ref.read(salesChannelsProvider.notifier).updateChannel(
channel.id!,
SalesChannel(
id: channel.id,
name: name,
code: codeCtrl.text.trim().isNotEmpty
? codeCtrl.text.trim().toUpperCase()
: null,
icon: channel.icon,
isActive: channel.isActive,
),
);
} else {
await ref.read(salesChannelsProvider.notifier).createChannel(
SalesChannel(
name: name,
code: codeCtrl.text.trim().isNotEmpty
? codeCtrl.text.trim().toUpperCase()
: null,
icon: 'shopping-bag',
isActive: true,
),
);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isEditing
? 'Channel updated successfully'
: 'Channel created successfully'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
);
}
}
},
child: Text(isEditing ? 'Save Changes' : 'Add Channel'),
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final channelsState = ref.watch(salesChannelsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Sales Channels & Marketplaces'),
actions: [
IconButton(
icon: const Icon(LucideIcons.plus),
tooltip: 'Add Sales Channel',
onPressed: () => _showAddEditChannelSheet(),
),
],
),
body: channelsState.when(
data: (channels) {
if (channels.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.shoppingBag, size: 48, color: Colors.grey),
const SizedBox(height: 16),
const Text(
'No sales channels found',
style: TextStyle(color: Colors.grey, fontSize: 16),
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: () => _showAddEditChannelSheet(),
icon: const Icon(LucideIcons.plus),
label: const Text('Add First Channel'),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: channels.length,
itemBuilder: (context, index) {
final channel = channels[index];
IconData iconData = LucideIcons.store;
if (channel.code == 'AMAZON' || channel.name.toLowerCase().contains('amazon')) {
iconData = LucideIcons.shoppingCart;
} else if (channel.code == 'FLIPKART' || channel.name.toLowerCase().contains('flipkart')) {
iconData = LucideIcons.package;
} else if (channel.code == 'SHOPIFY' || channel.name.toLowerCase().contains('shopify')) {
iconData = LucideIcons.globe;
} else if (channel.code == 'MEESHO' || channel.name.toLowerCase().contains('meesho')) {
iconData = LucideIcons.tag;
} else if (channel.code == 'QUICK_COMMERCE' || channel.name.toLowerCase().contains('quick')) {
iconData = LucideIcons.zap;
}
final isSystemDefault = channel.userId == null;
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.withValues(alpha: 0.2)),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blue.withValues(alpha: 0.1),
child: Icon(iconData, color: Colors.blue, size: 20),
),
title: Row(
children: [
Text(
channel.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (isSystemDefault) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'System',
style: TextStyle(fontSize: 10, color: Colors.grey, fontWeight: FontWeight.bold),
),
),
],
],
),
subtitle: channel.code != null
? Text('Code: ${channel.code}', style: const TextStyle(fontSize: 12))
: null,
trailing: isSystemDefault
? const Icon(LucideIcons.lock, size: 16, color: Colors.grey)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(LucideIcons.edit2, size: 18),
onPressed: () => _showAddEditChannelSheet(channel),
),
IconButton(
icon: const Icon(LucideIcons.trash2, size: 18, color: Colors.red),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Channel?'),
content: Text('Are you sure you want to delete ${channel.name}?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirm == true) {
await ref.read(salesChannelsProvider.notifier).deleteChannel(channel.id!);
}
},
),
],
),
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
),
);
}
}

View File

@@ -0,0 +1,594 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../domain/invoice.dart';
import '../../domain/credit_note.dart';
import '../../../transactions/providers/providers.dart';
import '../../providers/credit_notes_provider.dart';
class CreateCreditNoteSheet extends ConsumerStatefulWidget {
final Invoice invoice;
const CreateCreditNoteSheet({
super.key,
required this.invoice,
});
@override
ConsumerState<CreateCreditNoteSheet> createState() => _CreateCreditNoteSheetState();
}
class _CreateCreditNoteSheetState extends ConsumerState<CreateCreditNoteSheet> {
final _creditNoteNumberCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
DateTime _returnDate = DateTime.now();
String _reason = 'CUSTOMER_RETURN';
bool _restockItems = true;
String _refundStatus = 'ADJUSTED_TO_AR';
int? _selectedWalletId;
bool _isLoading = false;
// Selected item IDs and their return quantities
final Set<int> _selectedItemIndices = {};
final Map<int, double> _returnQuantities = {};
final formatCurrency = NumberFormat.currency(symbol: '');
final formatDate = DateFormat('MMM dd, yyyy');
@override
void initState() {
super.initState();
_creditNoteNumberCtrl.text = 'CN-${DateTime.now().millisecondsSinceEpoch.toString().substring(6)}';
// Select all items by default for convenience
for (int i = 0; i < widget.invoice.items.length; i++) {
_selectedItemIndices.add(i);
final item = widget.invoice.items[i];
final qty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
_returnQuantities[i] = qty;
}
}
@override
void dispose() {
_creditNoteNumberCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
double _calculateReturnSubtotal() {
double subtotal = 0.0;
for (var idx in _selectedItemIndices) {
if (idx < widget.invoice.items.length) {
final item = widget.invoice.items[idx];
final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
final retQty = _returnQuantities[idx] ?? maxQty;
final ratio = maxQty > 0 ? (retQty / maxQty).clamp(0.0, 1.0) : 1.0;
subtotal += (item.unitPrice * retQty) + (item.makingCharge * ratio);
}
}
return subtotal;
}
double _calculateReturnTax() {
double tax = 0.0;
for (var idx in _selectedItemIndices) {
if (idx < widget.invoice.items.length) {
final item = widget.invoice.items[idx];
final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
final retQty = _returnQuantities[idx] ?? maxQty;
final ratio = maxQty > 0 ? (retQty / maxQty).clamp(0.0, 1.0) : 1.0;
final taxable = (item.unitPrice * retQty) + (item.makingCharge * ratio) + (item.otherCharges * ratio) - (item.discount * ratio);
tax += (taxable * item.taxRate) / 100.0;
}
}
return tax;
}
double _calculateReturnTotal() {
double total = 0.0;
for (var idx in _selectedItemIndices) {
if (idx < widget.invoice.items.length) {
final item = widget.invoice.items[idx];
final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
final retQty = _returnQuantities[idx] ?? maxQty;
final ratio = maxQty > 0 ? (retQty / maxQty).clamp(0.0, 1.0) : 1.0;
total += item.total * ratio;
}
}
return total;
}
Future<void> _submitCreditNote() async {
if (_selectedItemIndices.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select at least one item to return.')),
);
return;
}
setState(() => _isLoading = true);
try {
final List<CreditNoteItem> creditNoteItems = [];
double subtotal = 0.0;
double cgstTotal = 0.0;
double sgstTotal = 0.0;
double igstTotal = 0.0;
double taxTotal = 0.0;
double discountTotal = 0.0;
double totalAmount = 0.0;
for (var idx in _selectedItemIndices) {
final item = widget.invoice.items[idx];
final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
final retQty = _returnQuantities[idx] ?? maxQty;
final ratio = maxQty > 0 ? (retQty / maxQty).clamp(0.0, 1.0) : 1.0;
final lineTotal = item.total * ratio;
final lineCgst = item.cgst * ratio;
final lineSgst = item.sgst * ratio;
final lineIgst = item.igst * ratio;
final lineDiscount = item.discount * ratio;
subtotal += (item.unitPrice * retQty);
cgstTotal += lineCgst;
sgstTotal += lineSgst;
igstTotal += lineIgst;
taxTotal += (lineCgst + lineSgst + lineIgst);
discountTotal += lineDiscount;
totalAmount += lineTotal;
creditNoteItems.add(CreditNoteItem(
invoiceItemId: item.id,
productId: item.productId,
inventoryItemId: item.inventoryItemId,
productName: item.productName ?? item.description,
sku: item.sku,
huid: item.huid,
hsnCode: item.hsnCode,
quantity: item.weight != null && item.weight! > 0 ? 1.0 : retQty,
weight: item.weight != null && item.weight! > 0 ? retQty : null,
unitPrice: item.unitPrice,
taxRate: item.taxRate,
cgst: lineCgst,
sgst: lineSgst,
igst: lineIgst,
discount: lineDiscount,
makingCharge: item.makingCharge * ratio,
otherCharges: item.otherCharges * ratio,
total: lineTotal,
));
}
final creditNote = CreditNote(
invoiceId: widget.invoice.id!,
creditNoteNumber: _creditNoteNumberCtrl.text.trim().isNotEmpty
? _creditNoteNumberCtrl.text.trim()
: 'CN-${DateTime.now().millisecondsSinceEpoch.toString().substring(6)}',
customerId: widget.invoice.customerId,
returnDate: _returnDate,
reason: _reason,
restockItems: _restockItems,
subtotal: double.parse(subtotal.toStringAsFixed(2)),
taxTotal: double.parse(taxTotal.toStringAsFixed(2)),
cgstTotal: double.parse(cgstTotal.toStringAsFixed(2)),
sgstTotal: double.parse(sgstTotal.toStringAsFixed(2)),
igstTotal: double.parse(igstTotal.toStringAsFixed(2)),
discountTotal: double.parse(discountTotal.toStringAsFixed(2)),
totalAmount: double.parse(totalAmount.toStringAsFixed(2)),
refundStatus: _refundStatus,
refundWalletId: _refundStatus == 'REFUNDED_TO_WALLET' ? _selectedWalletId : null,
notes: _notesCtrl.text.trim().isNotEmpty ? _notesCtrl.text.trim() : null,
items: creditNoteItems,
);
final result = await ref
.read(creditNotesProvider.notifier)
.createCreditNote(widget.invoice.id!, creditNote);
if (mounted) {
Navigator.pop(context, result);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Credit Note #${creditNote.creditNoteNumber} issued successfully!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error creating credit note: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final wallets = ref.watch(walletProvider).value ?? [];
final returnTotal = _calculateReturnTotal();
final returnReasons = [
{'code': 'CUSTOMER_RETURN', 'label': 'Customer Return', 'icon': LucideIcons.rotateCcw},
{'code': 'RTO_UNDELIVERED', 'label': 'RTO / Courier Undelivered', 'icon': LucideIcons.truck},
{'code': 'DEFECTIVE', 'label': 'Defective / Damaged', 'icon': LucideIcons.shieldAlert},
{'code': 'EXCHANGE', 'label': 'Exchange / Replacement', 'icon': LucideIcons.refreshCw},
{'code': 'OTHER', 'label': 'Other', 'icon': LucideIcons.fileText},
];
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF0F172A) : Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
left: 20,
right: 20,
top: 20,
),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.9,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Bar
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(LucideIcons.undo2, color: Colors.red, size: 20),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Sales Return & Credit Note',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
Text(
'Invoice #${widget.invoice.invoiceNumber}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
),
],
),
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 16),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Return Meta Card (Credit Note #, Return Date)
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade50,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: TextFormField(
controller: _creditNoteNumberCtrl,
decoration: InputDecoration(
labelText: 'Credit Note #',
prefixIcon: const Icon(LucideIcons.hash, size: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
),
),
),
const SizedBox(width: 10),
Expanded(
child: InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: _returnDate,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 30)),
);
if (picked != null) setState(() => _returnDate = picked);
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'Return Date',
prefixIcon: const Icon(LucideIcons.calendar, size: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
),
child: Text(formatDate.format(_returnDate), style: const TextStyle(fontSize: 13)),
),
),
),
],
),
const SizedBox(height: 12),
// Reason Selector
DropdownButtonFormField<String>(
value: _reason,
decoration: InputDecoration(
labelText: 'Return Reason',
prefixIcon: const Icon(LucideIcons.helpCircle, size: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
),
items: returnReasons.map((r) {
return DropdownMenuItem<String>(
value: r['code'] as String,
child: Row(
children: [
Icon(r['icon'] as IconData, size: 15, color: Colors.red.shade700),
const SizedBox(width: 8),
Text(r['label'] as String, style: const TextStyle(fontSize: 13)),
],
),
);
}).toList(),
onChanged: (val) {
if (val != null) setState(() => _reason = val);
},
),
],
),
),
const SizedBox(height: 16),
// Items Selection
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Select Items to Return',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
TextButton.icon(
onPressed: () {
setState(() {
if (_selectedItemIndices.length == widget.invoice.items.length) {
_selectedItemIndices.clear();
} else {
_selectedItemIndices.addAll(List.generate(widget.invoice.items.length, (i) => i));
}
});
},
icon: Icon(
_selectedItemIndices.length == widget.invoice.items.length ? LucideIcons.checkSquare : LucideIcons.square,
size: 14,
),
label: Text(
_selectedItemIndices.length == widget.invoice.items.length ? 'Deselect All' : 'Select All',
style: const TextStyle(fontSize: 12),
),
),
],
),
const SizedBox(height: 6),
// Item list tiles
...widget.invoice.items.asMap().entries.map((entry) {
final idx = entry.key;
final item = entry.value;
final isSelected = _selectedItemIndices.contains(idx);
final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
final isWeight = item.weight != null && item.weight! > 0;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isSelected
? Colors.red.withValues(alpha: isDark ? 0.1 : 0.04)
: (isDark ? const Color(0xFF1E293B) : Colors.white),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? Colors.red.withValues(alpha: 0.4) : (isDark ? Colors.white10 : Colors.grey.shade200),
),
),
child: Row(
children: [
Checkbox(
value: isSelected,
activeColor: Colors.red,
onChanged: (val) {
setState(() {
if (val == true) {
_selectedItemIndices.add(idx);
} else {
_selectedItemIndices.remove(idx);
}
});
},
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.productName ?? item.description ?? 'Product Item',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'Sold: ${isWeight ? "${item.weight!.toStringAsFixed(3)} g" : "${item.quantity.toInt()} pcs"} • Rate: ${formatCurrency.format(item.unitPrice)}',
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
),
],
),
),
Text(
formatCurrency.format(item.total),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
],
),
);
}),
const SizedBox(height: 14),
// Restock Switch
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
activeColor: Colors.green,
title: const Text('Restock returned items to inventory', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
subtitle: const Text('Re-inward weights & items back to available stock', style: TextStyle(fontSize: 11)),
value: _restockItems,
onChanged: (val) => setState(() => _restockItems = val),
),
),
const SizedBox(height: 14),
// Settlement / Refund Method
const Text('Settlement / Refund Method', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
value: _refundStatus,
decoration: InputDecoration(
prefixIcon: const Icon(LucideIcons.wallet, size: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
),
items: const [
DropdownMenuItem(
value: 'ADJUSTED_TO_AR',
child: Text('Adjust Customer Outstanding (Credit Note)', style: TextStyle(fontSize: 13)),
),
DropdownMenuItem(
value: 'REFUNDED_TO_WALLET',
child: Text('Refund to Cash / Bank Account', style: TextStyle(fontSize: 13)),
),
DropdownMenuItem(
value: 'STORE_CREDIT',
child: Text('Issue Store Credit', style: TextStyle(fontSize: 13)),
),
],
onChanged: (val) {
if (val != null) setState(() => _refundStatus = val);
},
),
if (_refundStatus == 'REFUNDED_TO_WALLET') ...[
const SizedBox(height: 10),
DropdownButtonFormField<int>(
value: _selectedWalletId ?? (wallets.isNotEmpty ? wallets.first.id : null),
decoration: InputDecoration(
labelText: 'Payout From Account',
prefixIcon: const Icon(LucideIcons.arrowUpRight, size: 16, color: Colors.red),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
),
items: wallets.map((w) {
return DropdownMenuItem<int>(
value: w.id,
child: Text('${w.name} (${w.nature})', style: const TextStyle(fontSize: 13)),
);
}).toList(),
onChanged: (val) => setState(() => _selectedWalletId = val),
),
],
const SizedBox(height: 14),
// Remarks
TextFormField(
controller: _notesCtrl,
maxLines: 2,
decoration: InputDecoration(
labelText: 'Return Remarks / Notes (Optional)',
prefixIcon: const Icon(LucideIcons.alignLeft, size: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
),
),
const SizedBox(height: 20),
// Total Refund Summary Box
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.withValues(alpha: 0.25)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Credit Note Value:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
formatCurrency.format(returnTotal),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.red),
),
],
),
),
const SizedBox(height: 20),
],
),
),
),
// Submit Action Button
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _submitCreditNote,
icon: _isLoading
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(LucideIcons.checkCircle2, size: 18),
label: Text(
_isLoading ? 'Processing Return...' : 'Issue Credit Note (${formatCurrency.format(returnTotal)})',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,508 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../domain/invoice.dart';
import '../../domain/marketplace_settlement.dart';
import '../../providers/marketplace_settlements_provider.dart';
import '../../../transactions/providers/providers.dart';
class SettleMarketplaceOrderSheet extends ConsumerStatefulWidget {
final Invoice invoice;
const SettleMarketplaceOrderSheet({
super.key,
required this.invoice,
});
@override
ConsumerState<SettleMarketplaceOrderSheet> createState() => _SettleMarketplaceOrderSheetState();
}
class _SettleMarketplaceOrderSheetState extends ConsumerState<SettleMarketplaceOrderSheet> {
final _commissionCtrl = TextEditingController();
final _shippingFeeCtrl = TextEditingController();
final _otherFeesCtrl = TextEditingController();
final _feeGstCtrl = TextEditingController();
final _tcsGstCtrl = TextEditingController();
final _tdsCtrl = TextEditingController();
final _settlementRefCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
DateTime _settlementDate = DateTime.now();
int? _selectedWalletId;
bool _isLoading = false;
final formatCurrency = NumberFormat.currency(symbol: '');
final formatDate = DateFormat('MMM dd, yyyy');
@override
void initState() {
super.initState();
final gross = widget.invoice.totalAmount;
// Auto-calculate suggested TCS (1% of net taxable) and TDS (0.1% of gross)
final suggestedTcs = double.parse(((gross * 0.01)).toStringAsFixed(2));
final suggestedTds = double.parse(((gross * 0.001)).toStringAsFixed(2));
_tcsGstCtrl.text = suggestedTcs > 0 ? suggestedTcs.toString() : '0';
_tdsCtrl.text = suggestedTds > 0 ? suggestedTds.toString() : '0';
_commissionCtrl.addListener(_autoUpdateGstOnFees);
_shippingFeeCtrl.addListener(_autoUpdateGstOnFees);
_otherFeesCtrl.addListener(_autoUpdateGstOnFees);
}
void _autoUpdateGstOnFees() {
final comm = double.tryParse(_commissionCtrl.text) ?? 0.0;
final ship = double.tryParse(_shippingFeeCtrl.text) ?? 0.0;
final other = double.tryParse(_otherFeesCtrl.text) ?? 0.0;
final taxableFees = comm + ship + other;
if (taxableFees > 0) {
final gst = double.parse((taxableFees * 0.18).toStringAsFixed(2));
_feeGstCtrl.text = gst.toString();
}
setState(() {});
}
@override
void dispose() {
_commissionCtrl.dispose();
_shippingFeeCtrl.dispose();
_otherFeesCtrl.dispose();
_feeGstCtrl.dispose();
_tcsGstCtrl.dispose();
_tdsCtrl.dispose();
_settlementRefCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
double _calculateTotalDeductions() {
final comm = double.tryParse(_commissionCtrl.text) ?? 0.0;
final ship = double.tryParse(_shippingFeeCtrl.text) ?? 0.0;
final other = double.tryParse(_otherFeesCtrl.text) ?? 0.0;
final feeGst = double.tryParse(_feeGstCtrl.text) ?? 0.0;
final tcs = double.tryParse(_tcsGstCtrl.text) ?? 0.0;
final tds = double.tryParse(_tdsCtrl.text) ?? 0.0;
return comm + ship + other + feeGst + tcs + tds;
}
double _calculateNetPayout() {
return widget.invoice.totalAmount - _calculateTotalDeductions();
}
Future<void> _submitSettlement() async {
setState(() => _isLoading = true);
try {
final comm = double.tryParse(_commissionCtrl.text) ?? 0.0;
final ship = double.tryParse(_shippingFeeCtrl.text) ?? 0.0;
final other = double.tryParse(_otherFeesCtrl.text) ?? 0.0;
final feeGst = double.tryParse(_feeGstCtrl.text) ?? 0.0;
final tcs = double.tryParse(_tcsGstCtrl.text) ?? 0.0;
final tds = double.tryParse(_tdsCtrl.text) ?? 0.0;
final totalDeductions = _calculateTotalDeductions();
final netPayout = _calculateNetPayout();
final settlement = MarketplaceSettlement(
invoiceId: widget.invoice.id!,
salesChannelId: widget.invoice.salesChannelId,
channelName: widget.invoice.salesChannel ?? 'Marketplace',
settlementRef: _settlementRefCtrl.text.trim().isNotEmpty ? _settlementRefCtrl.text.trim() : null,
settlementDate: _settlementDate,
grossAmount: widget.invoice.totalAmount,
commissionFee: comm,
shippingFee: ship,
otherFees: other,
feeGst: feeGst,
tcsGstAmount: tcs,
tdsAmount: tds,
totalDeductions: totalDeductions,
netPayoutAmount: netPayout,
payoutWalletId: _selectedWalletId,
status: 'SETTLED',
notes: _notesCtrl.text.trim().isNotEmpty ? _notesCtrl.text.trim() : null,
);
final result = await ref
.read(marketplaceSettlementsProvider.notifier)
.createSettlement(widget.invoice.id!, settlement);
if (mounted) {
Navigator.pop(context, result);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Marketplace settlement for Invoice #${widget.invoice.invoiceNumber} recorded successfully!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error recording settlement: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final wallets = ref.watch(walletProvider).value ?? [];
final bankWallets = wallets.where((w) => w.nature == 'SAVINGS' || w.nature == 'CURRENT' || w.nature == 'BANK' || w.nature == 'CASH').toList();
final effectiveWallets = bankWallets.isNotEmpty ? bankWallets : wallets;
final totalDeductions = _calculateTotalDeductions();
final netPayout = _calculateNetPayout();
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF0F172A) : Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
left: 20,
right: 20,
top: 20,
),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.9,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Bar
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.indigo.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(LucideIcons.landmark, color: Colors.indigo, size: 20),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Marketplace Settlement',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
Text(
'${widget.invoice.salesChannel ?? "Marketplace"} • Order #${widget.invoice.marketplaceOrderId ?? widget.invoice.invoiceNumber}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
),
],
),
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 16),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Gross Invoice Value Banner
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade50,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Gross Invoice Value', style: TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 2),
Text(
formatCurrency.format(widget.invoice.totalAmount),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
widget.invoice.salesChannel ?? 'Marketplace',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.blue),
),
),
],
),
),
const SizedBox(height: 16),
// Fee Deductions Breakdown
const Text('Channel Fee Deductions', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: TextFormField(
controller: _commissionCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'Referral / Commission',
prefixText: '',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 10),
Expanded(
child: TextFormField(
controller: _shippingFeeCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'Logistics / Shipping',
prefixText: '',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
),
onChanged: (_) => setState(() {}),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
controller: _otherFeesCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'Closing / Fixed Fees',
prefixText: '',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 10),
Expanded(
child: TextFormField(
controller: _feeGstCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'GST on Fees (18%)',
prefixText: '',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
),
onChanged: (_) => setState(() {}),
),
),
],
),
const SizedBox(height: 16),
// Statutory Tax Deductions (TCS & TDS)
const Text('Tax Withholdings (TCS & TDS)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: TextFormField(
controller: _tcsGstCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'GST TCS (1%)',
prefixText: '',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
helperText: 'Tax Asset Claimable in GST',
),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 10),
Expanded(
child: TextFormField(
controller: _tdsCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'TDS u/s 194O (0.1%)',
prefixText: '',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
helperText: 'Claimable in Income Tax',
),
onChanged: (_) => setState(() {}),
),
),
],
),
const SizedBox(height: 16),
// Net Payout Calculation Summary Box
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF10B981).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF10B981).withValues(alpha: 0.3)),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Total Marketplace Deductions:', style: TextStyle(fontSize: 13, color: Colors.grey)),
Text(
'- ${formatCurrency.format(totalDeductions)}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.red),
),
],
),
const Divider(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Net Bank Remittance Payout:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
Text(
formatCurrency.format(netPayout),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Color(0xFF10B981)),
),
],
),
],
),
),
const SizedBox(height: 16),
// Bank & Settlement Reference Details
const Text('Payout Deposit Details', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
const SizedBox(height: 10),
DropdownButtonFormField<int>(
value: _selectedWalletId ?? (effectiveWallets.isNotEmpty ? effectiveWallets.first.id : null),
decoration: InputDecoration(
labelText: 'Bank / Payout Account',
prefixIcon: const Icon(LucideIcons.landmark, size: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
),
items: effectiveWallets.map((w) {
return DropdownMenuItem<int>(
value: w.id,
child: Text('${w.name} (${w.nature ?? "Bank"})', style: const TextStyle(fontSize: 13)),
);
}).toList(),
onChanged: (val) => setState(() => _selectedWalletId = val),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
controller: _settlementRefCtrl,
decoration: InputDecoration(
labelText: 'Bank UTR / Payout Ref',
prefixIcon: const Icon(LucideIcons.hash, size: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
),
),
),
const SizedBox(width: 10),
Expanded(
child: InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: _settlementDate,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 30)),
);
if (picked != null) setState(() => _settlementDate = picked);
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'Payout Date',
prefixIcon: const Icon(LucideIcons.calendar, size: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
),
child: Text(formatDate.format(_settlementDate), style: const TextStyle(fontSize: 13)),
),
),
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: _notesCtrl,
maxLines: 2,
decoration: InputDecoration(
labelText: 'Settlement Notes / Remarks (Optional)',
prefixIcon: const Icon(LucideIcons.alignLeft, size: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
isDense: true,
),
),
const SizedBox(height: 20),
],
),
),
),
// Submit Button
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _submitSettlement,
icon: _isLoading
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(LucideIcons.checkCheck, size: 18),
label: Text(
_isLoading ? 'Reconciling Payout...' : 'Confirm Settlement (${formatCurrency.format(netPayout)})',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,75 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import '../../../core/network/dio_client.dart';
import '../domain/credit_note.dart';
import 'invoices_provider.dart';
class CreditNotesNotifier extends AsyncNotifier<List<CreditNote>> {
@override
FutureOr<List<CreditNote>> build() async {
return _fetchCreditNotes();
}
Future<List<CreditNote>> _fetchCreditNotes() async {
try {
final response = await DioClient().dio.get('/credit-notes');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((e) => CreditNote.fromJson(e)).toList();
}
return [];
} catch (e) {
return [];
}
}
Future<void> refresh() async {
state = const AsyncValue.loading();
try {
final creditNotes = await _fetchCreditNotes();
state = AsyncValue.data(creditNotes);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<CreditNote?> createCreditNote(int invoiceId, CreditNote creditNote) async {
try {
final response = await DioClient().dio.post(
'/invoices/$invoiceId/credit-notes',
data: creditNote.toJson(),
);
await refresh();
ref.refresh(invoicesProvider);
if (response.data != null) {
return CreditNote.fromJson(response.data);
}
return null;
} catch (e) {
if (e is DioException) {
throw Exception(
'Failed to process return: ${e.response?.statusCode} - ${e.response?.data}',
);
}
throw Exception('Failed to process return: $e');
}
}
}
final creditNotesProvider =
AsyncNotifierProvider<CreditNotesNotifier, List<CreditNote>>(CreditNotesNotifier.new);
final invoiceCreditNotesProvider =
FutureProvider.family<List<CreditNote>, int>((ref, invoiceId) async {
try {
final response = await DioClient().dio.get('/invoices/$invoiceId/credit-notes');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((e) => CreditNote.fromJson(e)).toList();
}
return [];
} catch (e) {
return [];
}
});

View File

@@ -0,0 +1,78 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import '../../../core/network/dio_client.dart';
import '../domain/marketplace_settlement.dart';
import 'invoices_provider.dart';
class MarketplaceSettlementsNotifier extends AsyncNotifier<List<MarketplaceSettlement>> {
@override
FutureOr<List<MarketplaceSettlement>> build() async {
return _fetchSettlements();
}
Future<List<MarketplaceSettlement>> _fetchSettlements() async {
try {
final response = await DioClient().dio.get('/marketplace-settlements');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((e) => MarketplaceSettlement.fromJson(e)).toList();
}
return [];
} catch (e) {
return [];
}
}
Future<void> refresh() async {
state = const AsyncValue.loading();
try {
final settlements = await _fetchSettlements();
state = AsyncValue.data(settlements);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<MarketplaceSettlement?> createSettlement(int invoiceId, MarketplaceSettlement settlement) async {
try {
final response = await DioClient().dio.post(
'/invoices/$invoiceId/settlements',
data: settlement.toJson(),
);
await refresh();
ref.refresh(invoicesProvider);
if (response.data != null) {
return MarketplaceSettlement.fromJson(response.data);
}
return null;
} catch (e) {
if (e is DioException) {
throw Exception(
'Failed to record settlement: ${e.response?.statusCode} - ${e.response?.data}',
);
}
throw Exception('Failed to record settlement: $e');
}
}
}
final marketplaceSettlementsProvider =
AsyncNotifierProvider<MarketplaceSettlementsNotifier, List<MarketplaceSettlement>>(
MarketplaceSettlementsNotifier.new);
final invoiceSettlementProvider =
FutureProvider.family<MarketplaceSettlement?, int>((ref, invoiceId) async {
try {
final response = await DioClient().dio.get('/invoices/$invoiceId/settlements');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
if (data.isNotEmpty) {
return MarketplaceSettlement.fromJson(data.first);
}
}
return null;
} catch (e) {
return null;
}
});

View File

@@ -0,0 +1,74 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart';
import '../domain/sales_channel.dart';
class SalesChannelsNotifier extends AsyncNotifier<List<SalesChannel>> {
@override
FutureOr<List<SalesChannel>> build() async {
return _fetchChannels();
}
Future<List<SalesChannel>> _fetchChannels() async {
try {
final response = await DioClient().dio.get('/sales-channels');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((e) => SalesChannel.fromJson(e)).toList();
}
return [];
} catch (e) {
print('Error fetching sales channels: $e');
return [];
}
}
Future<void> refresh() async {
state = const AsyncValue.loading();
try {
final channels = await _fetchChannels();
state = AsyncValue.data(channels);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<SalesChannel?> createChannel(SalesChannel channel) async {
try {
final response = await DioClient().dio.post('/sales-channels', data: channel.toJson());
await refresh();
if (response.data != null) {
return SalesChannel.fromJson(response.data);
}
return null;
} catch (e) {
throw Exception('Failed to create sales channel: $e');
}
}
Future<SalesChannel?> updateChannel(int id, SalesChannel channel) async {
try {
final response = await DioClient().dio.put('/sales-channels/$id', data: channel.toJson());
await refresh();
if (response.data != null) {
return SalesChannel.fromJson(response.data);
}
return null;
} catch (e) {
throw Exception('Failed to update sales channel: $e');
}
}
Future<void> deleteChannel(int id) async {
try {
await DioClient().dio.delete('/sales-channels/$id');
await refresh();
} catch (e) {
throw Exception('Failed to delete sales channel: $e');
}
}
}
final salesChannelsProvider = AsyncNotifierProvider<SalesChannelsNotifier, List<SalesChannel>>(() {
return SalesChannelsNotifier();
});

View File

@@ -601,6 +601,7 @@ class _PurchaseOrderBuilderScreenState
const SizedBox(height: 12),
if (_items.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100,
@@ -608,6 +609,8 @@ class _PurchaseOrderBuilderScreenState
border: Border.all(color: Colors.grey.withValues(alpha: 0.2)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(LucideIcons.packageOpen, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
@@ -785,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;
@@ -807,6 +813,22 @@ class _PurchaseOrderBuilderScreenState
final effectiveTaxable = (rawSubtotal - discountAmount).clamp(0.0, double.infinity);
final grandTotal = effectiveTaxable + taxTotal;
final effectiveGstRate = (effectiveTaxable > 0 && taxTotal > 0)
? (taxTotal / effectiveTaxable) * 100.0
: (_items.isNotEmpty ? _items.first.taxRate : (isJewellery ? 3.0 : 18.0));
final cgstRate = effectiveGstRate / 2.0;
final sgstRate = effectiveGstRate / 2.0;
final igstRate = effectiveGstRate;
String formatRate(double rate) {
if (rate <= 0) return '0%';
final rounded = (rate * 100).round() / 100;
if (rounded.truncateToDouble() == rounded) {
return '${rounded.toInt()}%';
}
return '${rounded.toStringAsFixed(1)}%';
}
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
@@ -848,7 +870,7 @@ class _PurchaseOrderBuilderScreenState
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('CGST (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)),
Text('CGST (${formatRate(cgstRate)}):', style: TextStyle(color: Colors.grey.shade600)),
Text('${(taxTotal / 2.0).toStringAsFixed(2)}'),
],
),
@@ -856,7 +878,7 @@ class _PurchaseOrderBuilderScreenState
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('SGST (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)),
Text('SGST (${formatRate(sgstRate)}):', style: TextStyle(color: Colors.grey.shade600)),
Text('${(taxTotal / 2.0).toStringAsFixed(2)}'),
],
),
@@ -864,7 +886,7 @@ class _PurchaseOrderBuilderScreenState
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('IGST (${(taxTotal > 0 ? '3.0%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)),
Text('IGST (${formatRate(igstRate)}):', style: TextStyle(color: Colors.grey.shade600)),
Text('${taxTotal.toStringAsFixed(2)}'),
],
),
@@ -1175,6 +1197,9 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
}) {
final sku = product.sku;
final qtyCtrl = _getQtyController(product.id ?? 0);
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
return Container(
margin: const EdgeInsets.only(bottom: 12),
@@ -1228,12 +1253,14 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
_buildPOSheetBadge(category!.name, const Color(0xFFD4AF37)),
if (sku != null && sku.isNotEmpty)
_buildPOSheetBadge('SKU: $sku', Colors.blue),
if (isJewellery)
_buildPOSheetBadge('Purity: ${formatPurity(resolvePurity(categoryPurity: category?.purityFactor, productPurity: product.purityFactor))}', Colors.teal),
],
),
],
),
),
if (isJewellery) ...[
const SizedBox(width: 12),
// Small box to enter number of items
SizedBox(
@@ -1251,13 +1278,14 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
),
),
],
],
),
const Divider(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1;
final qty = isJewellery ? (int.tryParse(qtyCtrl.text.trim()) ?? 1) : 1;
_addItemsFromCard(product, category, qty);
},
style: ElevatedButton.styleFrom(
@@ -1278,7 +1306,7 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
}
class _POItemRow extends StatefulWidget {
class _POItemRow extends ConsumerStatefulWidget {
final PurchaseOrderItem item;
final String productName;
final Product? product;
@@ -1300,14 +1328,15 @@ class _POItemRow extends StatefulWidget {
});
@override
State<_POItemRow> createState() => _POItemRowState();
ConsumerState<_POItemRow> createState() => _POItemRowState();
}
class _POItemRowState extends State<_POItemRow> {
class _POItemRowState extends ConsumerState<_POItemRow> {
late TextEditingController _weightCtrl;
late TextEditingController _rateCtrl;
late TextEditingController _huidCtrl;
late TextEditingController _totalCtrl;
bool _isUploadingPhoto = false;
@override
void initState() {
@@ -1339,15 +1368,13 @@ class _POItemRowState extends State<_POItemRow> {
final updated = widget.item.copyWith(
weight: weight,
huid: _huidCtrl.text.isEmpty ? null : _huidCtrl.text,
unitPrice: rate,
total: total,
huid: _huidCtrl.text.isEmpty ? null : _huidCtrl.text,
);
widget.onChanged(updated);
}
bool _isUploadingPhoto = false;
Future<void> _pickPhoto() async {
try {
final picker = ImagePicker();
@@ -1393,6 +1420,10 @@ class _POItemRowState extends State<_POItemRow> {
@override
Widget build(BuildContext context) {
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
@@ -1534,6 +1565,7 @@ class _POItemRowState extends State<_POItemRow> {
style: TextStyle(fontSize: 10, color: Colors.grey[700]),
),
),
if (isJewellery)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
@@ -1558,8 +1590,14 @@ class _POItemRowState extends State<_POItemRow> {
],
),
const SizedBox(height: 12),
Row(
LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 600;
if (isWide) {
return Row(
children: [
if (isJewellery) ...[
Expanded(
flex: 2,
child: PremiumTextField(
@@ -1570,11 +1608,92 @@ class _POItemRowState extends State<_POItemRow> {
),
),
const SizedBox(width: 8),
],
Expanded(
flex: isJewellery ? 2 : 3,
child: PremiumTextField(
controller: _weightCtrl,
labelText: isJewellery ? 'Weight/Pcs' : 'Weight/Pcs/Length',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _updateItem(),
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
? Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [Text(widget.category?.baseUnit ?? '', style: TextStyle(color: Colors.grey[700]))],
),
)
: null,
),
),
const SizedBox(width: 8),
Expanded(
flex: isJewellery ? 2 : 3,
child: PremiumTextField(
controller: _rateCtrl,
labelText: 'Rate',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _updateItem(),
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
? Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [Text('per ${widget.category?.baseUnit ?? ''}', style: TextStyle(color: Colors.grey[700]))],
),
)
: null,
),
),
const SizedBox(width: 8),
Expanded(
flex: isJewellery ? 2 : 3,
child: PremiumTextField(
controller: _totalCtrl,
labelText: 'Purchase Amt',
keyboardType: TextInputType.number,
readOnly: true,
onChanged: (_) => _updateItem(),
),
),
],
);
}
return Column(
children: [
Row(
children: [
if (isJewellery) ...[
Expanded(
flex: 2,
child: PremiumTextField(
controller: _huidCtrl,
labelText: 'HUID',
textCapitalization: TextCapitalization.characters,
onChanged: (_) => _updateItem(),
),
),
const SizedBox(width: 8),
],
Expanded(
flex: 2,
child: PremiumTextField(
controller: _weightCtrl,
labelText: 'Weight/Pcs',
labelText: isJewellery ? 'Weight/Pcs' : 'Weight/Pcs/Length',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _updateItem(),
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
@@ -1636,6 +1755,10 @@ class _POItemRowState extends State<_POItemRow> {
),
],
),
],
);
},
),
const SizedBox(height: 16),
_buildTaxAndTotal(),
],

View File

@@ -65,6 +65,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.9"
barcode_widget:
dependency: "direct main"
description:
name: barcode_widget
sha256: "6f2c5b08659b1a5f4d88d183e6007133ea2f96e50e7b8bb628f03266c3931427"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
bidi:
dependency: transitive
description:

View File

@@ -60,6 +60,7 @@ dependencies:
intl_phone_field: ^3.2.0
url_launcher: ^6.3.2
lucide_icons_flutter: ^3.1.17
barcode_widget: ^2.0.4
dev_dependencies:
flutter_test:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 795 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 161 KiB

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="description" content="KIFI - Modern Financial Ledger & Jewellery Business Suite">
<meta name="description" content="KIFI - Modern Financial Ledger & Business ERP">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
@@ -16,7 +16,7 @@
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>KIFI - Financial Ledger & Jewellery ERP</title>
<title>KIFI - Financial Ledger & Business ERP</title>
<link rel="manifest" href="manifest.json">
<style>
body {
@@ -85,9 +85,9 @@
</head>
<body>
<div class="splash-container">
<div class="splash-logo">💎</div>
<img src="icons/Icon-192.png" alt="KIFI" style="width: 64px; height: 64px; border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); animation: pulse 2s infinite ease-in-out;">
<div class="splash-title">KIFI</div>
<div class="splash-sub">Financial Ledger & Jewellery ERP</div>
<div class="splash-sub">Financial Ledger & Business ERP</div>
<div class="spinner"></div>
</div>
<script src="flutter_bootstrap.js" async></script>