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.setDefaultMakingCharge(category.getDefaultMakingCharge());
existing.setMakingChargeType(category.getMakingChargeType()); existing.setMakingChargeType(category.getMakingChargeType());
existing.setBaseUnit(category.getBaseUnit()); existing.setBaseUnit(category.getBaseUnit());
existing.setDefaultLengthUom(category.getDefaultLengthUom());
existing.setIsActive(category.getIsActive()); existing.setIsActive(category.getIsActive());
existing.setSortOrder(category.getSortOrder()); 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") @Column("barcode_source")
private String barcodeSource; private String barcodeSource;
@Column("enable_ecommerce_channels")
private Boolean enableEcommerceChannels;
@Column("created_at") @Column("created_at")
private LocalDateTime createdAt; private LocalDateTime createdAt;
} }

View File

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

View File

@@ -5,6 +5,7 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table; import org.springframework.data.relational.core.mapping.Table;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -28,16 +29,50 @@ public class Product {
private String barcode; private String barcode;
private String hsnCode; private String hsnCode;
private String description; private String description;
private BigDecimal mrp;
private BigDecimal sellingPrice; private BigDecimal sellingPrice;
private BigDecimal gstRate; private BigDecimal gstRate;
private String dimensions; private String dimensions;
private String color; private String color;
private String size; 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 String priceCalcRule;
private Double purityFactor; private Double purityFactor;
private Double makingCharges; private Double makingCharges;
private String makingChargesType; private String makingChargesType;
private Double wastagePercentage; private Double wastagePercentage;
@Column("min_stock")
private BigDecimal minStock;
@Column("reorder_level")
private BigDecimal reorderLevel;
@Column("reorder_quantity")
private BigDecimal reorderQuantity;
@Column("track_inventory")
private Boolean trackInventory;
private Boolean isActive; private Boolean isActive;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;

View File

@@ -34,6 +34,8 @@ public class ProductCategory {
private String makingChargeType; private String makingChargeType;
private String baseUnit; private String baseUnit;
@Builder.Default
private String defaultLengthUom = "cm";
@Builder.Default @Builder.Default
private Boolean isActive = true; 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") @Column("emi_start_date")
private LocalDate emiStartDate; 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") @Column("created_at")
private LocalDateTime createdAt; 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) .multiLocation(false)
.bomReductionStrategy("COMPONENTS_ONLY") .bomReductionStrategy("COMPONENTS_ONLY")
.stockDeductionOnInvoice(true) .stockDeductionOnInvoice(true)
.enableEcommerceChannels(false)
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.build()); .build());
} }
@@ -51,6 +52,7 @@ public class BusinessService {
if (feature.getBomReductionStrategy() != null) existing.setBomReductionStrategy(feature.getBomReductionStrategy()); if (feature.getBomReductionStrategy() != null) existing.setBomReductionStrategy(feature.getBomReductionStrategy());
if (feature.getStockDeductionOnInvoice() != null) existing.setStockDeductionOnInvoice(feature.getStockDeductionOnInvoice()); if (feature.getStockDeductionOnInvoice() != null) existing.setStockDeductionOnInvoice(feature.getStockDeductionOnInvoice());
if (feature.getBarcodeSource() != null) existing.setBarcodeSource(feature.getBarcodeSource()); if (feature.getBarcodeSource() != null) existing.setBarcodeSource(feature.getBarcodeSource());
if (feature.getEnableEcommerceChannels() != null) existing.setEnableEcommerceChannels(feature.getEnableEcommerceChannels());
return businessFeatureRepository.save(existing); return businessFeatureRepository.save(existing);
}) })
.switchIfEmpty(Mono.defer(() -> { .switchIfEmpty(Mono.defer(() -> {

View File

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

View File

@@ -96,17 +96,44 @@ public class ProductService {
existingProduct.setSku(updatedProduct.getSku()); existingProduct.setSku(updatedProduct.getSku());
existingProduct.setCategoryId(updatedProduct.getCategoryId()); existingProduct.setCategoryId(updatedProduct.getCategoryId());
existingProduct.setUomId(updatedProduct.getUomId()); existingProduct.setUomId(updatedProduct.getUomId());
existingProduct.setMrp(updatedProduct.getMrp());
existingProduct.setSellingPrice(updatedProduct.getSellingPrice()); existingProduct.setSellingPrice(updatedProduct.getSellingPrice());
existingProduct.setGstRate(updatedProduct.getGstRate()); existingProduct.setGstRate(updatedProduct.getGstRate());
existingProduct.setHsnCode(updatedProduct.getHsnCode()); existingProduct.setHsnCode(updatedProduct.getHsnCode());
existingProduct.setColor(updatedProduct.getColor()); existingProduct.setColor(updatedProduct.getColor());
existingProduct.setSize(updatedProduct.getSize()); existingProduct.setSize(updatedProduct.getSize());
existingProduct.setDimensions(updatedProduct.getDimensions()); 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.setPriceCalcRule(updatedProduct.getPriceCalcRule());
existingProduct.setPurityFactor(normalizePurity(updatedProduct.getPurityFactor())); existingProduct.setPurityFactor(normalizePurity(updatedProduct.getPurityFactor()));
existingProduct.setMakingCharges(updatedProduct.getMakingCharges()); existingProduct.setMakingCharges(updatedProduct.getMakingCharges());
existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType()); existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType());
existingProduct.setWastagePercentage(updatedProduct.getWastagePercentage()); existingProduct.setWastagePercentage(updatedProduct.getWastagePercentage());
existingProduct.setMinStock(updatedProduct.getMinStock());
existingProduct.setReorderLevel(updatedProduct.getReorderLevel());
existingProduct.setReorderQuantity(updatedProduct.getReorderQuantity());
existingProduct.setTrackInventory(updatedProduct.getTrackInventory());
existingProduct.setIsActive(updatedProduct.getIsActive()); existingProduct.setIsActive(updatedProduct.getIsActive());
existingProduct.setUpdatedAt(LocalDateTime.now()); existingProduct.setUpdatedAt(LocalDateTime.now());
return productRepository.save(existingProduct); return productRepository.save(existingProduct);

View File

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

View File

@@ -146,15 +146,64 @@ public class InvoiceService {
if (shouldDeduct && invoice.getItems() != null && !invoice.getItems().isEmpty()) { if (shouldDeduct && invoice.getItems() != null && !invoice.getItems().isEmpty()) {
return Flux.fromIterable(invoice.getItems()) return Flux.fromIterable(invoice.getItems())
.concatMap(item -> { .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) { if (item.getInventoryItemId() != null) {
return inventoryItemRepository.findById(item.getInventoryItemId()) return inventoryItemRepository.findById(item.getInventoryItemId())
.flatMap(invItem -> { .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()); invItem.setUpdatedAt(LocalDateTime.now());
BigDecimal cost = invItem.getPurchaseCost() != null ? invItem.getPurchaseCost() : BigDecimal.ZERO;
return inventoryItemRepository.save(invItem).thenReturn(cost); return inventoryItemRepository.save(invItem).thenReturn(cost);
}
}).defaultIfEmpty(BigDecimal.ZERO); }).defaultIfEmpty(BigDecimal.ZERO);
} else if (item.getProductId() != null) { } else if (item.getProductId() != null) {
// Product sale without explicit inventory item: adjust product stock & FIFO deduct inventory items
InventoryMovement movement = InventoryMovement.builder() InventoryMovement movement = InventoryMovement.builder()
.userId(userId) .userId(userId)
.type("REDUCTION") .type("REDUCTION")
@@ -167,8 +216,82 @@ public class InvoiceService {
movementItem.setQuantity(item.getQuantity()); movementItem.setQuantity(item.getQuantity());
movement.setItems(java.util.Collections.singletonList(movementItem)); movement.setItems(java.util.Collections.singletonList(movementItem));
return productService.adjustStock(userId, item.getProductId(), movement) Mono<Void> adjustMono = productService.adjustStock(userId, item.getProductId(), movement).then();
.thenReturn(BigDecimal.ZERO);
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); 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()) .huid(item.getHuid())
.grossWeight(item.getWeight()) .grossWeight(item.getWeight())
.netWeight(item.getWeight()) .netWeight(item.getWeight())
.photoUrl(item.getPhotoUrl())
.status("AVAILABLE") .status("AVAILABLE")
.build(); .build();

View File

@@ -208,6 +208,7 @@ CREATE TABLE IF NOT EXISTS product_categories (
commodity_code VARCHAR(10), commodity_code VARCHAR(10),
purity_factor DECIMAL(5, 4) DEFAULT 1.0, purity_factor DECIMAL(5, 4) DEFAULT 1.0,
base_unit VARCHAR(20) DEFAULT 'pcs', base_unit VARCHAR(20) DEFAULT 'pcs',
default_length_uom VARCHAR(20) DEFAULT 'cm',
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
@@ -243,12 +244,28 @@ CREATE TABLE IF NOT EXISTS products (
barcode VARCHAR(100), barcode VARCHAR(100),
description TEXT, description TEXT,
purchase_price DECIMAL(15, 2), purchase_price DECIMAL(15, 2),
mrp DECIMAL(15, 2),
selling_price DECIMAL(15, 2), selling_price DECIMAL(15, 2),
gst_rate DECIMAL(5, 2), gst_rate DECIMAL(5, 2),
dimensions VARCHAR(100), dimensions VARCHAR(100),
weight DECIMAL(10, 3), weight DECIMAL(10, 3),
color VARCHAR(50), color VARCHAR(50),
size 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', price_calc_rule VARCHAR(50) DEFAULT 'MANUAL',
purity_factor DECIMAL(5, 4), purity_factor DECIMAL(5, 4),
making_charges DECIMAL(15, 2) DEFAULT 0.0, 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, wastage_percentage DECIMAL(5, 2) DEFAULT 0.0,
min_stock DECIMAL(10, 2) DEFAULT 0, min_stock DECIMAL(10, 2) DEFAULT 0,
reorder_level DECIMAL(10, 2) DEFAULT 0, reorder_level DECIMAL(10, 2) DEFAULT 0,
reorder_quantity DECIMAL(10, 2) DEFAULT 0,
track_inventory BOOLEAN DEFAULT TRUE, track_inventory BOOLEAN DEFAULT TRUE,
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -383,6 +401,9 @@ CREATE TABLE IF NOT EXISTS inventory_items (
vendor_id INTEGER REFERENCES vendors(id) ON DELETE SET NULL, vendor_id INTEGER REFERENCES vendors(id) ON DELETE SET NULL,
branch_id INTEGER, branch_id INTEGER,
purchase_ref VARCHAR(100), purchase_ref VARCHAR(100),
sales_ref VARCHAR(100),
sale_rate DECIMAL(15, 2),
photo_url VARCHAR(1024),
status VARCHAR(50) DEFAULT 'AVAILABLE', status VARCHAR(50) DEFAULT 'AVAILABLE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_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 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS credit_notes (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,
credit_note_number VARCHAR(100) NOT NULL,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
return_date DATE NOT NULL,
reason VARCHAR(100) NOT NULL,
restock_items BOOLEAN DEFAULT TRUE,
subtotal DECIMAL(15, 2) NOT NULL,
tax_total DECIMAL(15, 2) DEFAULT 0.0,
cgst_total DECIMAL(15, 2) DEFAULT 0.0,
sgst_total DECIMAL(15, 2) DEFAULT 0.0,
igst_total DECIMAL(15, 2) DEFAULT 0.0,
discount_total DECIMAL(15, 2) DEFAULT 0.0,
total_amount DECIMAL(15, 2) NOT NULL,
refund_status VARCHAR(50) DEFAULT 'ADJUSTED_TO_AR',
refund_wallet_id INTEGER REFERENCES wallets(id) ON DELETE SET NULL,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS credit_note_items (
id SERIAL PRIMARY KEY,
credit_note_id INTEGER REFERENCES credit_notes(id) ON DELETE CASCADE,
invoice_item_id INTEGER REFERENCES invoice_items(id) ON DELETE SET NULL,
product_id INTEGER REFERENCES products(id) ON DELETE SET NULL,
inventory_item_id INTEGER REFERENCES inventory_items(id) ON DELETE SET NULL,
product_name VARCHAR(255),
sku VARCHAR(100),
huid VARCHAR(50),
hsn_code VARCHAR(50),
quantity DECIMAL(10, 3) NOT NULL,
weight DECIMAL(10, 3),
unit_price DECIMAL(15, 2) NOT NULL,
tax_rate DECIMAL(5, 2) DEFAULT 0.0,
cgst DECIMAL(15, 2) DEFAULT 0.0,
sgst DECIMAL(15, 2) DEFAULT 0.0,
igst DECIMAL(15, 2) DEFAULT 0.0,
discount DECIMAL(15, 2) DEFAULT 0.0,
making_charge DECIMAL(15, 2) DEFAULT 0.0,
other_charges DECIMAL(15, 2) DEFAULT 0.0,
total DECIMAL(15, 2) NOT NULL
);
CREATE TABLE IF NOT EXISTS marketplace_settlements (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,
sales_channel_id INTEGER REFERENCES sales_channels(id) ON DELETE SET NULL,
channel_name VARCHAR(100),
settlement_ref VARCHAR(100),
settlement_date DATE NOT NULL,
gross_amount DECIMAL(15, 2) NOT NULL,
commission_fee DECIMAL(15, 2) DEFAULT 0.0,
shipping_fee DECIMAL(15, 2) DEFAULT 0.0,
other_fees DECIMAL(15, 2) DEFAULT 0.0,
fee_gst DECIMAL(15, 2) DEFAULT 0.0,
tcs_gst_amount DECIMAL(15, 2) DEFAULT 0.0,
tds_amount DECIMAL(15, 2) DEFAULT 0.0,
total_deductions DECIMAL(15, 2) NOT NULL,
net_payout_amount DECIMAL(15, 2) NOT NULL,
payout_wallet_id INTEGER REFERENCES wallets(id) ON DELETE SET NULL,
status VARCHAR(50) DEFAULT 'SETTLED',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 9. Projects & Task Management -- 9. Projects & Task Management
CREATE TABLE IF NOT EXISTS projects ( CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -510,3 +601,25 @@ CREATE TABLE IF NOT EXISTS project_task_comments (
images TEXT, images TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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: flutter_icons:
android: true android: true
ios: true ios: true
web:
generate: true
image_path: "assets/icon.png"
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 isDark = theme.brightness == Brightness.dark;
final isBusinessMode = ref.watch(businessModeProvider); final isBusinessMode = ref.watch(businessModeProvider);
final businessProfile = ref.watch(businessProfileProvider).asData?.value; final businessProfile = ref.watch(businessProfileProvider).asData?.value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final ratesAsync = ref.watch(commodityRatesProvider); final ratesAsync = ref.watch(commodityRatesProvider);
return Container( return Container(
@@ -51,22 +53,23 @@ class DesktopSidebar extends ConsumerWidget {
width: 40, width: 40,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFD4AF37), Color(0xFFAA771C)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: const Color(0xFFD4AF37).withValues(alpha: 0.3), color: Colors.black.withValues(alpha: 0.08),
blurRadius: 10, blurRadius: 8,
offset: const Offset(0, 4), offset: const Offset(0, 2),
), ),
], ],
), ),
child: const Center( child: ClipRRect(
child: Icon(LucideIcons.gem, color: Colors.white, size: 22), borderRadius: BorderRadius.circular(10),
child: Image.asset(
'assets/icon.png',
width: 40,
height: 40,
fit: BoxFit.cover,
),
), ),
), ),
const SizedBox(width: 12), 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( ratesAsync.when(
data: (rates) { data: (rates) {
if (rates.isEmpty) return const SizedBox.shrink(); if (rates.isEmpty) return const SizedBox.shrink();

View File

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

View File

@@ -131,19 +131,28 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Container( Center(
child: Container(
width: 80, width: 80,
height: 80, height: 80,
decoration: const BoxDecoration( decoration: BoxDecoration(
color: Colors.transparent, borderRadius: BorderRadius.circular(20),
shape: BoxShape.circle, 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( child: Image.asset(
'assets/logo.png', 'assets/icon.png',
width: 80, width: 80,
height: 80, height: 80,
fit: BoxFit.contain, fit: BoxFit.cover,
),
), ),
), ),
), ),

View File

@@ -12,8 +12,7 @@ class SetupWizardScreen extends StatefulWidget {
State<SetupWizardScreen> createState() => _SetupWizardScreenState(); State<SetupWizardScreen> createState() => _SetupWizardScreenState();
} }
class _SetupWizardScreenState extends State<SetupWizardScreen> class _SetupWizardScreenState extends State<SetupWizardScreen> {
with SingleTickerProviderStateMixin {
bool _isLoading = false; bool _isLoading = false;
int _currentStep = 0; int _currentStep = 0;
String _accountType = 'INDIVIDUAL'; String _accountType = 'INDIVIDUAL';
@@ -40,6 +39,19 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
int get _totalSteps => _accountType == 'BUSINESS' ? 4 : 3; 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 { Future<void> _checkUsername(String username) async {
if (username.length < 3) return; if (username.length < 3) return;
setState(() => _checkingUsername = true); setState(() => _checkingUsername = true);
@@ -111,6 +123,7 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
} }
void _nextStep() { void _nextStep() {
FocusScope.of(context).unfocus();
if (_currentStep == 1) { if (_currentStep == 1) {
if (_nameController.text.trim().isEmpty) { if (_nameController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -150,6 +163,7 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
} }
void _prevStep() { void _prevStep() {
FocusScope.of(context).unfocus();
if (_currentStep > 0) { if (_currentStep > 0) {
setState(() => _currentStep--); setState(() => _currentStep--);
} }
@@ -209,7 +223,6 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
Widget _buildStep0AccountType() { Widget _buildStep0AccountType() {
return Column( return Column(
key: const ValueKey(0),
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text( const Text(
@@ -324,7 +337,6 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
Widget _buildStep1Personal() { Widget _buildStep1Personal() {
return Column( return Column(
key: const ValueKey(1),
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text( const Text(
@@ -354,6 +366,7 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
child: _checkingUsername child: _checkingUsername
? const SizedBox( ? const SizedBox(
key: ValueKey('user_check_spinner'),
width: 20, width: 20,
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
@@ -362,7 +375,7 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
_isUsernameAvailable _isUsernameAvailable
? LucideIcons.checkCircle2 ? LucideIcons.checkCircle2
: LucideIcons.xCircle, : LucideIcons.xCircle,
key: ValueKey(_isUsernameAvailable), key: ValueKey('user_avail_${_isUsernameAvailable}'),
color: _usernameController.text.isEmpty color: _usernameController.text.isEmpty
? Colors.transparent ? Colors.transparent
: (_isUsernameAvailable : (_isUsernameAvailable
@@ -388,7 +401,6 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
Widget _buildStep2Business() { Widget _buildStep2Business() {
return Column( return Column(
key: const ValueKey(2),
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text( const Text(
@@ -410,6 +422,10 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
value: 'JEWELLERY', value: 'JEWELLERY',
child: Text('Jewellery', style: TextStyle(color: Colors.black87)), child: Text('Jewellery', style: TextStyle(color: Colors.black87)),
), ),
DropdownMenuItem(
value: 'ECOMMERCE',
child: Text('E-Commerce Seller', style: TextStyle(color: Colors.black87)),
),
DropdownMenuItem( DropdownMenuItem(
value: 'PROJECT_MANAGEMENT', value: 'PROJECT_MANAGEMENT',
child: Text( child: Text(
@@ -474,7 +490,6 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
Widget _buildStep3Consent() { Widget _buildStep3Consent() {
return Column( return Column(
key: const ValueKey(3),
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text( const Text(
@@ -556,12 +571,11 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
); );
} }
Widget _buildCurrentStep() { int get _stackIndex {
if (_currentStep == 0) return _buildStep0AccountType(); if (_accountType == 'INDIVIDUAL' && _currentStep == 2) {
if (_currentStep == 1) return _buildStep1Personal(); return 3;
if (_accountType == 'BUSINESS' && _currentStep == 2) }
return _buildStep2Business(); return _currentStep;
return _buildStep3Consent(); // Step 2 (Individual) or Step 3 (Business)
} }
@override @override
@@ -587,28 +601,38 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
child: Center( child: Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600), constraints: const BoxConstraints(maxWidth: 600),
child: AnimatedSwitcher( child: IndexedStack(
duration: const Duration(milliseconds: 300), index: _stackIndex,
switchInCurve: Curves.easeOutCubic, children: [
switchOutCurve: Curves.easeInCubic, SingleChildScrollView(
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(),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 24.0, horizontal: 24.0,
vertical: 16.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 String bomReductionStrategy;
final bool stockDeductionOnInvoice; final bool stockDeductionOnInvoice;
final String barcodeSource; final String barcodeSource;
final bool enableEcommerceChannels;
final DateTime? createdAt; final DateTime? createdAt;
BusinessFeature({ BusinessFeature({
@@ -20,6 +21,7 @@ class BusinessFeature {
this.bomReductionStrategy = 'COMPONENTS_ONLY', this.bomReductionStrategy = 'COMPONENTS_ONLY',
this.stockDeductionOnInvoice = true, this.stockDeductionOnInvoice = true,
this.barcodeSource = 'SKU', this.barcodeSource = 'SKU',
this.enableEcommerceChannels = false,
this.createdAt, this.createdAt,
}); });
@@ -34,6 +36,7 @@ class BusinessFeature {
bomReductionStrategy: json['bomReductionStrategy'] ?? 'COMPONENTS_ONLY', bomReductionStrategy: json['bomReductionStrategy'] ?? 'COMPONENTS_ONLY',
stockDeductionOnInvoice: json['stockDeductionOnInvoice'] ?? true, stockDeductionOnInvoice: json['stockDeductionOnInvoice'] ?? true,
barcodeSource: json['barcodeSource'] ?? 'SKU', barcodeSource: json['barcodeSource'] ?? 'SKU',
enableEcommerceChannels: json['enableEcommerceChannels'] ?? json['enable_ecommerce_channels'] ?? false,
createdAt: json['createdAt'] != null createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt']) ? DateTime.parse(json['createdAt'])
: null, : null,
@@ -51,6 +54,7 @@ class BusinessFeature {
'bomReductionStrategy': bomReductionStrategy, 'bomReductionStrategy': bomReductionStrategy,
'stockDeductionOnInvoice': stockDeductionOnInvoice, 'stockDeductionOnInvoice': stockDeductionOnInvoice,
'barcodeSource': barcodeSource, 'barcodeSource': barcodeSource,
'enableEcommerceChannels': enableEcommerceChannels,
}; };
} }
@@ -64,6 +68,7 @@ class BusinessFeature {
String? bomReductionStrategy, String? bomReductionStrategy,
bool? stockDeductionOnInvoice, bool? stockDeductionOnInvoice,
String? barcodeSource, String? barcodeSource,
bool? enableEcommerceChannels,
}) { }) {
return BusinessFeature( return BusinessFeature(
id: id ?? this.id, id: id ?? this.id,
@@ -76,6 +81,8 @@ class BusinessFeature {
stockDeductionOnInvoice: stockDeductionOnInvoice:
stockDeductionOnInvoice ?? this.stockDeductionOnInvoice, stockDeductionOnInvoice ?? this.stockDeductionOnInvoice,
barcodeSource: barcodeSource ?? this.barcodeSource, barcodeSource: barcodeSource ?? this.barcodeSource,
enableEcommerceChannels:
enableEcommerceChannels ?? this.enableEcommerceChannels,
createdAt: createdAt, createdAt: createdAt,
); );
} }

View File

@@ -13,6 +13,8 @@ class BusinessProfile {
final String? emailId; final String? emailId;
final String? panNumber; final String? panNumber;
final String? gstin; final String? gstin;
final String? natureOfBusiness;
final String? msmeNumber;
BusinessProfile({ BusinessProfile({
this.id, this.id,
@@ -29,13 +31,15 @@ class BusinessProfile {
this.emailId, this.emailId,
this.panNumber, this.panNumber,
this.gstin, this.gstin,
this.natureOfBusiness,
this.msmeNumber,
}); });
factory BusinessProfile.fromJson(Map<String, dynamic> json) { factory BusinessProfile.fromJson(Map<String, dynamic> json) {
return BusinessProfile( return BusinessProfile(
id: json['id'], id: json['id'],
userId: json['userId'], userId: json['userId'],
businessName: json['businessName'], businessName: json['businessName'] ?? '',
industry: json['industry'], industry: json['industry'],
taxNumber: json['taxNumber'], taxNumber: json['taxNumber'],
currency: json['currency'], currency: json['currency'],
@@ -47,6 +51,8 @@ class BusinessProfile {
emailId: json['emailId'], emailId: json['emailId'],
panNumber: json['panNumber'], panNumber: json['panNumber'],
gstin: json['gstin'], gstin: json['gstin'],
natureOfBusiness: json['natureOfBusiness'] ?? json['industry'],
msmeNumber: json['msmeNumber'],
); );
} }
@@ -66,6 +72,8 @@ class BusinessProfile {
'emailId': emailId, 'emailId': emailId,
'panNumber': panNumber, 'panNumber': panNumber,
'gstin': gstin, 'gstin': gstin,
'natureOfBusiness': natureOfBusiness,
'msmeNumber': msmeNumber,
}; };
} }
@@ -82,6 +90,8 @@ class BusinessProfile {
String? emailId, String? emailId,
String? panNumber, String? panNumber,
String? gstin, String? gstin,
String? natureOfBusiness,
String? msmeNumber,
}) { }) {
return BusinessProfile( return BusinessProfile(
id: id, id: id,
@@ -98,6 +108,8 @@ class BusinessProfile {
emailId: emailId ?? this.emailId, emailId: emailId ?? this.emailId,
panNumber: panNumber ?? this.panNumber, panNumber: panNumber ?? this.panNumber,
gstin: gstin ?? this.gstin, 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 '../../../inventory/providers/products_provider.dart';
import '../../../vendor/presentation/vendors_list_screen.dart'; import '../../../vendor/presentation/vendors_list_screen.dart';
import '../../../vendor/presentation/purchase_orders_list_screen.dart'; import '../../../vendor/presentation/purchase_orders_list_screen.dart';
import '../widgets/utilities_section.dart';
class BusinessHubScreen extends ConsumerWidget { class BusinessHubScreen extends ConsumerWidget {
const BusinessHubScreen({super.key}); const BusinessHubScreen({super.key});
@@ -34,6 +35,8 @@ class BusinessHubScreen extends ConsumerWidget {
final bool showInventory = featureState?.inventoryManagement ?? false; final bool showInventory = featureState?.inventoryManagement ?? false;
final bool showSales = featureState?.salesManagement ?? false; final bool showSales = featureState?.salesManagement ?? false;
final bool showPurchase = featureState?.purchaseManagement ?? false; final bool showPurchase = featureState?.purchaseManagement ?? false;
final nature = (profile?.natureOfBusiness ?? profile?.industry ?? 'JEWELLERY').toUpperCase();
final bool isJewellery = nature == 'JEWELLERY';
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
@@ -200,7 +203,7 @@ class BusinessHubScreen extends ConsumerWidget {
), ),
isDesktop: isDesktop, isDesktop: isDesktop,
), ),
if (showInventory) if (showInventory && isJewellery)
_buildActionCard( _buildActionCard(
context, context,
'Daily Rates', 'Daily Rates',
@@ -231,6 +234,8 @@ class BusinessHubScreen extends ConsumerWidget {
); );
}, },
), ),
const SizedBox(height: 32),
const UtilitiesSection(),
if (showInventory) ...[ if (showInventory) ...[
const SizedBox(height: 32), const SizedBox(height: 32),
Text( Text(

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../providers/business_provider.dart'; import '../../providers/business_provider.dart';
import '../../../sales/presentation/sales_channels_screen.dart';
class BusinessSettingsScreen extends ConsumerStatefulWidget { class BusinessSettingsScreen extends ConsumerStatefulWidget {
const BusinessSettingsScreen({super.key}); 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? vendorId;
final int? branchId; final int? branchId;
final String? purchaseRef; final String? purchaseRef;
final String? salesRef;
final double? saleRate;
final String? photoUrl;
final String? status; final String? status;
final DateTime? createdAt; final DateTime? createdAt;
@@ -47,6 +50,9 @@ class InventoryItem {
this.vendorId, this.vendorId,
this.branchId, this.branchId,
this.purchaseRef, this.purchaseRef,
this.salesRef,
this.saleRate,
this.photoUrl,
this.status, this.status,
this.createdAt, this.createdAt,
}); });
@@ -75,6 +81,9 @@ class InventoryItem {
vendorId: json['vendorId'], vendorId: json['vendorId'],
branchId: json['branchId'], branchId: json['branchId'],
purchaseRef: json['purchaseRef'], 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'], status: json['status'],
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
); );

View File

@@ -9,17 +9,44 @@ class Product {
final String? sku; final String? sku;
final String? barcode; final String? barcode;
final String? description; final String? description;
final double? mrp;
final double? sellingPrice; final double? sellingPrice;
final double? gstRate; final double? gstRate;
final String? dimensions; final String? dimensions;
final String? color; final String? color;
final String? size; 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 String priceCalcRule;
final bool autoCalculatePrice; final bool autoCalculatePrice;
final double? purityFactor; final double? purityFactor;
final double? makingCharges; final double? makingCharges;
final String? makingChargesType; final String? makingChargesType;
final double? wastagePercentage; final double? wastagePercentage;
final double? minStock;
final double? reorderLevel;
final double? reorderQuantity;
final bool trackInventory;
final bool isActive; final bool isActive;
final List<int> imageIds; final List<int> imageIds;
final double? currentStock; final double? currentStock;
@@ -34,55 +61,98 @@ class Product {
this.sku, this.sku,
this.barcode, this.barcode,
this.description, this.description,
this.mrp,
this.sellingPrice, this.sellingPrice,
this.gstRate, this.gstRate,
this.dimensions, this.dimensions,
this.color, this.color,
this.size, 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.priceCalcRule = 'MANUAL',
this.autoCalculatePrice = false, this.autoCalculatePrice = false,
this.purityFactor, this.purityFactor,
this.makingCharges = 0.0, this.makingCharges = 0.0,
this.makingChargesType = 'FLAT', this.makingChargesType = 'FLAT',
this.wastagePercentage = 0.0, this.wastagePercentage = 0.0,
this.minStock = 0.0,
this.reorderLevel = 0.0,
this.reorderQuantity = 0.0,
this.trackInventory = true,
this.isActive = true, this.isActive = true,
this.imageIds = const [], this.imageIds = const [],
this.currentStock = 0.0, this.currentStock = 0.0,
}); });
bool get isOutOfStock => trackInventory && (currentStock == null || currentStock! <= 0);
bool get isLowStock {
final alertThreshold = (reorderLevel != null && reorderLevel! > 0)
? reorderLevel!
: (minStock != null && minStock! > 0 ? minStock! : 0.0);
if (!trackInventory || alertThreshold <= 0) return false;
return currentStock != null && currentStock! > 0 && currentStock! <= alertThreshold;
}
factory Product.fromJson(Map<String, dynamic> json) { factory Product.fromJson(Map<String, dynamic> json) {
return Product( return Product(
id: json['id'], id: json['id'],
userId: json['userId'] ?? json['user_id'], userId: json['userId'] ?? json['user_id'],
categoryId: json['categoryId'] ?? json['category_id'], categoryId: json['categoryId'] ?? json['category_id'],
uomId: json['uomId'] ?? json['uom_id'], uomId: json['uomId'] ?? json['uom_id'],
name: json['name'], name: json['name'] ?? '',
hsnCode: json['hsnCode'] ?? json['hsn_code'], hsnCode: json['hsnCode'] ?? json['hsn_code'],
sku: json['sku'], sku: json['sku'],
barcode: json['barcode'], barcode: json['barcode'],
description: json['description'], description: json['description'],
sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?) mrp: json['mrp'] != null
?.toDouble(), ? 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(), gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(),
dimensions: json['dimensions'], dimensions: json['dimensions'],
color: json['color'], color: json['color'],
size: json['size'], size: json['size'],
priceCalcRule: itemLength: (json['itemLength'] ?? json['item_length'] as num?)?.toDouble(),
json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL', itemWidth: (json['itemWidth'] ?? json['item_width'] as num?)?.toDouble(),
autoCalculatePrice: itemHeight: (json['itemHeight'] ?? json['item_height'] as num?)?.toDouble(),
json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false, dimensionUom: json['dimensionUom'] ?? json['dimension_uom'] ?? 'cm',
purityFactor: packageLength: (json['packageLength'] ?? json['package_length'] as num?)?.toDouble(),
(json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble(), packageWidth: (json['packageWidth'] ?? json['package_width'] as num?)?.toDouble(),
makingCharges: packageHeight: (json['packageHeight'] ?? json['package_height'] as num?)?.toDouble(),
(json['makingCharges'] ?? json['making_charges'] as num?) packageDimensionUom: json['packageDimensionUom'] ?? json['package_dimension_uom'] ?? 'cm',
?.toDouble() ?? manufacturerCode: json['manufacturerCode'] ?? json['manufacturer_code'],
0.0, material: json['material'],
makingChargesType: brandName: json['brandName'] ?? json['brand_name'],
json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT', countryOfOrigin: json['countryOfOrigin'] ?? json['country_of_origin'],
wastagePercentage: weightInG: (json['weightInG'] ?? json['weight_in_g'] as num?)?.toDouble(),
(json['wastagePercentage'] ?? json['wastage_percentage'] as num?) packageWeightInG: (json['packageWeightInG'] ?? json['package_weight_in_g'] as num?)?.toDouble(),
?.toDouble() ?? volumetricWeightInKg: (json['volumetricWeightInKg'] ?? json['volumetric_weight_in_kg'] as num?)?.toDouble(),
0.0, priceCalcRule: json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL',
autoCalculatePrice: json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false,
purityFactor: (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble(),
makingCharges: (json['makingCharges'] ?? json['making_charges'] as num?)?.toDouble() ?? 0.0,
makingChargesType: json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT',
wastagePercentage: (json['wastagePercentage'] ?? json['wastage_percentage'] as num?)?.toDouble() ?? 0.0,
minStock: (json['minStock'] ?? json['min_stock'] as num?)?.toDouble() ?? 0.0,
reorderLevel: (json['reorderLevel'] ?? json['reorder_level'] as num?)?.toDouble() ?? 0.0,
reorderQuantity: (json['reorderQuantity'] ?? json['reorder_quantity'] as num?)?.toDouble() ?? 0.0,
trackInventory: json['trackInventory'] ?? json['track_inventory'] ?? true,
isActive: json['isActive'] ?? json['is_active'] ?? true, isActive: json['isActive'] ?? json['is_active'] ?? true,
imageIds: json['images'] != null imageIds: json['images'] != null
? (json['images'] as List).map((i) { ? (json['images'] as List).map((i) {
@@ -94,9 +164,7 @@ class Product {
return 0; return 0;
}).where((id) => id > 0).toList() }).where((id) => id > 0).toList()
: [], : [],
currentStock: currentStock: (json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ?? 0.0,
(json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ??
0.0,
); );
} }
@@ -111,17 +179,37 @@ class Product {
'sku': sku, 'sku': sku,
'barcode': barcode, 'barcode': barcode,
'description': description, 'description': description,
'mrp': mrp,
'sellingPrice': sellingPrice, 'sellingPrice': sellingPrice,
'gstRate': gstRate, 'gstRate': gstRate,
'dimensions': dimensions, 'dimensions': dimensions,
'color': color, 'color': color,
'size': size, '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, 'priceCalcRule': priceCalcRule,
'autoCalculatePrice': autoCalculatePrice, 'autoCalculatePrice': autoCalculatePrice,
'purityFactor': purityFactor, 'purityFactor': purityFactor,
'makingCharges': makingCharges, 'makingCharges': makingCharges,
'makingChargesType': makingChargesType, 'makingChargesType': makingChargesType,
'wastagePercentage': wastagePercentage, 'wastagePercentage': wastagePercentage,
'minStock': minStock,
'reorderLevel': reorderLevel,
'reorderQuantity': reorderQuantity,
'trackInventory': trackInventory,
'isActive': isActive, 'isActive': isActive,
}; };
} }

View File

@@ -27,6 +27,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _hsnController = TextEditingController(); final _hsnController = TextEditingController();
final _gstController = TextEditingController(); final _gstController = TextEditingController();
final _mrpController = TextEditingController();
final _sellingPriceController = TextEditingController();
final _makingChargesController = TextEditingController(); final _makingChargesController = TextEditingController();
String _makingChargesType = 'PER_GRAM'; String _makingChargesType = 'PER_GRAM';
@@ -36,8 +38,32 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
ProductCategory? _selectedCategory; ProductCategory? _selectedCategory;
int? _uomId; int? _uomId;
// Properties // Specifications
String _color = ''; 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 // Media
final List<XFile> _images = []; final List<XFile> _images = [];
@@ -58,11 +84,39 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
_sku = p.sku ?? ''; _sku = p.sku ?? '';
_hsnController.text = p.hsnCode ?? ''; _hsnController.text = p.hsnCode ?? '';
_uomId = p.uomId; _uomId = p.uomId;
_color = p.color ?? '';
_gstController.text = p.gstRate != null && p.gstRate! > 0 ? p.gstRate.toString() : ''; _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() : ''; _makingChargesController.text = p.makingCharges != null && p.makingCharges! > 0 ? p.makingCharges!.toString() : '';
_makingChargesType = p.makingChargesType ?? 'PER_GRAM'; _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) { if (p.imageIds.isNotEmpty) {
_existingImageIds.addAll(p.imageIds); _existingImageIds.addAll(p.imageIds);
} }
@@ -82,10 +136,61 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
void dispose() { void dispose() {
_hsnController.dispose(); _hsnController.dispose();
_gstController.dispose(); _gstController.dispose();
_mrpController.dispose();
_sellingPriceController.dispose();
_makingChargesController.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(); 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 { Future<void> _pickImages() async {
if ((_images.length + _existingImageIds.length) >= 4) { if ((_images.length + _existingImageIds.length) >= 4) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed'))); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed')));
@@ -148,16 +253,37 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final product = Product( final product = Product(
id: widget.product?.id, id: widget.product?.id,
name: _name, name: _name,
hsnCode: _hsnController.text.isNotEmpty ? _hsnController.text : null, hsnCode: _hsnController.text.isNotEmpty ? _hsnController.text.trim() : null,
sku: _sku.isNotEmpty ? _sku : null, sku: _sku.isNotEmpty ? _sku.trim() : null,
categoryId: _selectedCategory?.id, categoryId: _selectedCategory?.id,
uomId: _uomId, 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, gstRate: double.tryParse(_gstController.text) ?? 0,
makingCharges: double.tryParse(_makingChargesController.text) ?? 0.0, makingCharges: double.tryParse(_makingChargesController.text) ?? 0.0,
makingChargesType: _makingChargesType, makingChargesType: _makingChargesType,
priceCalcRule: 'MANUAL', priceCalcRule: 'MANUAL',
autoCalculatePrice: false, 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) { if (widget.product != null) {
@@ -177,6 +303,9 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final categoriesState = ref.watch(productCategoriesProvider); final categoriesState = ref.watch(productCategoriesProvider);
final uomsState = ref.watch(uomsProvider); 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( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
@@ -275,10 +404,28 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
if (val.makingChargeType != null && val.makingChargeType!.isNotEmpty) { if (val.makingChargeType != null && val.makingChargeType!.isNotEmpty) {
_makingChargesType = val.makingChargeType!; _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) => final match = uoms.where((u) =>
u.abbreviation?.toLowerCase() == val.baseUnit.toLowerCase() || (u.abbreviation != null && u.abbreviation!.trim().toLowerCase() == target) ||
u.name.toLowerCase() == val.baseUnit.toLowerCase() 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; ).firstOrNull;
if (match != null) { if (match != null) {
_uomId = match.id; _uomId = match.id;
@@ -318,6 +465,34 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
const Text('Pricing & Taxes', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const Text('Pricing & Taxes', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16), 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( Row(
children: [ children: [
Expanded( Expanded(
@@ -336,6 +511,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
), ),
], ],
), ),
if (isJewellery) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
children: [ 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 Text('Product Images', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16), const SizedBox(height: 16),
if (_images.isNotEmpty || _existingImageIds.isNotEmpty) if (_images.isNotEmpty || _existingImageIds.isNotEmpty)
@@ -448,21 +927,36 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
} }
Widget _buildBottomBar() { Widget _buildBottomBar() {
return SafeArea( return Container(
child: Padding( padding: const EdgeInsets.all(24),
padding: const EdgeInsets.all(16.0), 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( child: SizedBox(
width: double.infinity, width: double.infinity,
height: 52,
child: ElevatedButton( child: ElevatedButton(
onPressed: _isSaving ? null : _save, onPressed: _isSaving ? null : _save,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16), backgroundColor: Theme.of(context).colorScheme.primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), foregroundColor: Colors.white,
elevation: 4, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0,
), ),
child: _isSaving child: _isSaving
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) ? 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)), : 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, required bool isNetwork,
String? url, String? url,
XFile? xFile, XFile? xFile,
required VoidCallback onDelete,
required VoidCallback onTap, required VoidCallback onTap,
required VoidCallback onDelete,
}) { }) {
return Stack( return Stack(
fit: StackFit.expand,
children: [ children: [
GestureDetector( GestureDetector(
onTap: onTap, onTap: onTap,
@@ -488,16 +981,19 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Container( child: SizedBox.expand(
color: Colors.grey[200],
child: isNetwork child: isNetwork
? (_token == null ? (_token != null
? const Icon(LucideIcons.image, color: Colors.grey) ? Image.network(
: Image.network(
url!, url!,
fit: BoxFit.cover, fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $_token'}, headers: {'Authorization': 'Bearer $_token'},
errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey), 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 : (kIsWeb
? Image.network(xFile!.path, fit: BoxFit.cover) ? Image.network(xFile!.path, fit: BoxFit.cover)
@@ -512,7 +1008,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
child: GestureDetector( child: GestureDetector(
onTap: onDelete, onTap: onDelete,
child: Container( 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), padding: const EdgeInsets.all(6),
child: const Icon(LucideIcons.x, color: Colors.white, size: 16), child: const Icon(LucideIcons.x, color: Colors.white, size: 16),
), ),
@@ -524,20 +1020,26 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
Widget _buildPremiumTextField({ Widget _buildPremiumTextField({
required String label, required String label,
TextEditingController? controller,
String? initialValue, String? initialValue,
String? prefixText, String? prefixText,
String? suffixText, String? suffixText,
IconData? prefixIcon,
String? helperText,
TextInputType? keyboardType, TextInputType? keyboardType,
void Function(String)? onChanged, void Function(String)? onChanged,
void Function(String?)? onSaved, void Function(String?)? onSaved,
String? Function(String?)? validator, String? Function(String?)? validator,
}) { }) {
return TextFormField( return TextFormField(
initialValue: initialValue, controller: controller,
initialValue: controller == null ? initialValue : null,
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
prefixText: prefixText, prefixText: prefixText,
suffixText: suffixText, suffixText: suffixText,
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
helperText: helperText,
), ),
keyboardType: keyboardType, keyboardType: keyboardType,
onChanged: onChanged, onChanged: onChanged,

View File

@@ -1,8 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/product_categories_provider.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_mode_provider.dart';
import '../../business/providers/business_provider.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'dart:ui'; import 'dart:ui';
@@ -99,6 +100,18 @@ class _CategoryManagementScreenState extends ConsumerState<CategoryManagementScr
final children = allCategories.where((c) => c.parentCategoryId == category.id).toList(); final children = allCategories.where((c) => c.parentCategoryId == category.id).toList();
final isLeaf = children.isEmpty && !category.hasChild; 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 // A nice frosted glass card
return Container( return Container(
margin: EdgeInsets.only(left: depth * 16.0, bottom: 8), 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), child: const Icon(LucideIcons.tag, size: 20, color: Colors.blue),
), ),
title: Text(category.name, style: const TextStyle(fontWeight: FontWeight.w600)), title: Text(category.name, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: category.commodityCode != null subtitle: subtitleText != null
? Text('${category.commodityCode} • HSN: ${category.defaultHsn ?? "N/A"}', ? Text(subtitleText,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600)) style: TextStyle(fontSize: 12, color: Colors.grey.shade600))
: null, : null,
trailing: Row( trailing: Row(
@@ -186,35 +199,77 @@ class _CategoryManagementScreenState extends ConsumerState<CategoryManagementScr
Widget _buildCategoryPopupMenu(ProductCategory category, bool isLeaf) { Widget _buildCategoryPopupMenu(ProductCategory category, bool isLeaf) {
return PopupMenuButton<String>( return PopupMenuButton<String>(
icon: const Icon(LucideIcons.moreVertical, color: Colors.grey), icon: const Icon(LucideIcons.ellipsisVertical, size: 18, color: Colors.grey),
onSelected: (value) { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
if (value == 'edit') { onSelected: (val) {
if (val == 'edit') {
_showAddCategorySheet(context, category, ref, isEdit: true); _showAddCategorySheet(context, category, ref, isEdit: true);
} else if (value == 'add_sub') { } else if (val == 'addSub') {
_showAddCategorySheet(context, category, ref, isEdit: false); _showAddCategorySheet(context, category, ref, isEdit: false);
} else if (value == 'delete') { } else if (val == 'delete') {
if (category.id != null) { _confirmDelete(category);
ref.read(productCategoriesProvider.notifier).deleteCategory(category.id!);
}
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: Row(children: [Icon(LucideIcons.edit, size: 18), SizedBox(width: 8), Text('Edit')]),
),
if (!isLeaf) if (!isLeaf)
const PopupMenuItem( const PopupMenuItem(
value: 'add_sub', value: 'addSub',
child: Row(children: [Icon(LucideIcons.plus, size: 18), SizedBox(width: 8), Text('Add Subcategory')]), 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( const PopupMenuItem(
value: 'delete', 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}) { void _showAddCategorySheet(BuildContext context, ProductCategory? parentOrCategory, WidgetRef ref, {bool isEdit = false}) {
@@ -251,7 +306,8 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
bool _huidRequired = false; bool _huidRequired = false;
String _commodityCode = 'XAU'; String _commodityCode = 'XAU';
String _makingChargeType = 'PER_GRAM'; String _makingChargeType = 'PER_GRAM';
String _baseUnit = 'g'; String _baseUnit = 'pcs';
String _defaultLengthUom = 'cm';
final List<Map<String, String>> _commodities = [ final List<Map<String, String>> _commodities = [
{'code': 'XAU', 'label': 'XAU - Gold'}, {'code': 'XAU', 'label': 'XAU - Gold'},
@@ -264,6 +320,14 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
@override @override
void initState() { void initState() {
super.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) { if (widget.categoryToEdit != null) {
final c = widget.categoryToEdit!; final c = widget.categoryToEdit!;
_selectedParentId = c.parentCategoryId; _selectedParentId = c.parentCategoryId;
@@ -275,15 +339,15 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
_makingChargeController.text = c.defaultMakingCharge?.toString() ?? ''; _makingChargeController.text = c.defaultMakingCharge?.toString() ?? '';
_purityFactorController.text = formatPurity(c.purityFactor); _purityFactorController.text = formatPurity(c.purityFactor);
_huidRequired = c.huidRequired; _huidRequired = c.huidRequired;
_commodityCode = c.commodityCode ?? 'XAU'; _commodityCode = c.commodityCode ?? (isJewellery ? 'XAU' : 'NONE');
// Handle legacy or varying enum string values // Handle legacy or varying enum string values
_makingChargeType = c.makingChargeType ?? 'PER_GRAM'; _makingChargeType = c.makingChargeType ?? 'PER_GRAM';
if (_makingChargeType == 'PER_GM') _makingChargeType = 'PER_GRAM'; if (_makingChargeType == 'PER_GM') _makingChargeType = 'PER_GRAM';
_baseUnit = c.baseUnit;
_baseUnit = c.baseUnit ?? 'g';
if (_baseUnit == 'gm') _baseUnit = 'g'; if (_baseUnit == 'gm') _baseUnit = 'g';
_defaultLengthUom = c.defaultLengthUom;
} }
} else if (widget.parent != null) { } else if (widget.parent != null) {
_selectedParentId = widget.parent!.id; _selectedParentId = widget.parent!.id;
@@ -302,19 +366,24 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
void _submit() { void _submit() {
if (_formKey.currentState!.validate()) { 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( final newCategory = ProductCategory(
id: widget.categoryToEdit?.id, id: widget.categoryToEdit?.id,
name: _nameController.text.trim(), name: _nameController.text.trim(),
parentCategoryId: _selectedParentId, parentCategoryId: _selectedParentId,
hasChild: !_isLeaf, hasChild: !_isLeaf,
commodityCode: _isLeaf ? _commodityCode : null, commodityCode: _isLeaf ? (isJewellery ? _commodityCode : 'NONE') : null,
purityFactor: _isLeaf ? normalizePurity(double.tryParse(_purityFactorController.text)) : 1.0, purityFactor: _isLeaf ? (isJewellery ? normalizePurity(double.tryParse(_purityFactorController.text)) : 1.0) : 1.0,
defaultHsn: _isLeaf ? _hsnController.text.trim() : null, defaultHsn: _isLeaf ? _hsnController.text.trim() : null,
defaultGst: _isLeaf ? double.tryParse(_gstController.text) : null, defaultGst: _isLeaf ? double.tryParse(_gstController.text) : null,
huidRequired: _isLeaf ? _huidRequired : false, huidRequired: _isLeaf ? (isJewellery ? _huidRequired : false) : false,
defaultMakingCharge: _isLeaf ? double.tryParse(_makingChargeController.text) : null, defaultMakingCharge: _isLeaf && isJewellery ? double.tryParse(_makingChargeController.text) : null,
makingChargeType: _isLeaf ? _makingChargeType : null, makingChargeType: _isLeaf && isJewellery ? _makingChargeType : null,
baseUnit: _isLeaf ? _baseUnit : 'pcs', baseUnit: _isLeaf ? _baseUnit : 'pcs',
defaultLengthUom: _isLeaf ? _defaultLengthUom : 'cm',
); );
if (widget.categoryToEdit != null && widget.categoryToEdit!.id != null) { if (widget.categoryToEdit != null && widget.categoryToEdit!.id != null) {
@@ -330,6 +399,10 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final isBusinessMode = ref.watch(businessModeProvider); 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( return Container(
height: MediaQuery.of(context).size.height * 0.85, 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 Text('Product Defaults', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16), const SizedBox(height: 16),
if (isBusinessMode) ...[ if (isJewellery && isBusinessMode) ...[
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -516,6 +589,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (isJewellery) ...[
SwitchListTile( SwitchListTile(
title: const Text('HUID Required'), title: const Text('HUID Required'),
subtitle: const Text('Is Hallmark Unique Identification mandatory?'), subtitle: const Text('Is Hallmark Unique Identification mandatory?'),
@@ -530,7 +604,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
Expanded( Expanded(
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
value: _makingChargeType, value: _makingChargeType,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: 'Making Charge Type', labelText: 'Making Charge Type',
), ),
items: const [ items: const [
@@ -552,18 +626,70 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( ],
value: _baseUnit,
decoration: InputDecoration( uomsState.when(
labelText: 'Base Unit', loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: LinearProgressIndicator(),
), ),
items: const [ error: (err, stack) => DropdownButtonFormField<String>(
DropdownMenuItem(value: 'g', child: Text('Grams (g)')), value: _baseUnit,
DropdownMenuItem(value: 'kg', child: Text('Kilograms (kg)')), decoration: const InputDecoration(labelText: 'Base Unit'),
DropdownMenuItem(value: 'pcs', child: Text('Pieces (pcs)')), items: [
DropdownMenuItem(value: _baseUnit, child: Text(_baseUnit)),
], ],
onChanged: (val) => setState(() => _baseUnit = val!), 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), const SizedBox(height: 40),
], ],

View File

@@ -1,11 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/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/domain/product.dart';
import 'package:kifi_app/features/inventory/presentation/add_product_screen.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/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 { class ProductDetailScreen extends ConsumerWidget {
final Product product; final Product product;
@@ -70,46 +75,50 @@ class ProductDetailScreen extends ConsumerWidget {
} }
Widget _buildOverviewTab(BuildContext context, Product p, WidgetRef ref) { 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( return SingleChildScrollView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: MaxContentWidth(
maxWidth: 900,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Image Header // Images Header & Gallery
if (p.imageIds.isNotEmpty) if (p.imageIds.isNotEmpty)
Container( _buildImagesSection(context, p),
height: 200,
width: double.infinity, if (p.imageIds.isNotEmpty)
decoration: BoxDecoration( const SizedBox(height: 20),
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),
// Product Details Card // Product Details Card
Card( Card(
@@ -122,18 +131,32 @@ class ProductDetailScreen extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row(
children: [
Icon(LucideIcons.package, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
const Text( const Text(
"Product Details", "Product Details",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
), ),
],
),
const Divider(), const Divider(),
_buildDetailRow("SKU / Barcode", p.sku ?? "-"), _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 ?? "-"), _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( _buildDetailRow(
"GST Rate", "GST Rate",
"${p.gstRate?.toStringAsFixed(1) ?? '0'}%", "${p.gstRate?.toStringAsFixed(1) ?? '0'}%",
), ),
if (p.makingCharges != null && p.makingCharges! > 0) if (isJewellery && p.makingCharges != null && p.makingCharges! > 0)
_buildDetailRow( _buildDetailRow(
"Making Charges", "Making Charges",
p.makingChargesType == 'PERCENTAGE' 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( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(label, style: TextStyle(color: Colors.grey[600])), Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 14)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)), 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(); final ScrollController _scrollController = ScrollController();
Timer? _debounce; Timer? _debounce;
String? _token; String? _token;
String _stockFilter = 'ALL'; // 'ALL', 'LOW_STOCK', 'OUT_OF_STOCK'
@override @override
void initState() { void initState() {
@@ -55,31 +56,41 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
final productsState = ref.watch(productsProvider); final productsState = ref.watch(productsProvider);
final categoriesState = ref.watch(productCategoriesProvider); final categoriesState = ref.watch(productCategoriesProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[100], backgroundColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC),
appBar: AppBar( appBar: AppBar(
title: const Text( title: const Text(
'Products Catalog', 'Products Catalog',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
elevation: 0, elevation: 0,
backgroundColor: Colors.white, backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white,
foregroundColor: Colors.black, foregroundColor: isDark ? Colors.white : Colors.black,
centerTitle: true, centerTitle: true,
), ),
body: MaxContentWidth( body: Column(
maxWidth: 1000,
child: Column(
children: [ children: [
Container( Container(
color: Colors.white, color: isDark ? const Color(0xFF1E293B) : Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: TextField( child: Column(
children: [
TextField(
controller: _searchController, controller: _searchController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search products by name or SKU...', hintText: 'Search products by name or SKU...',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), 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) { onChanged: (val) {
if (_debounce?.isActive ?? false) _debounce!.cancel(); 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( Expanded(
child: productsState.when( child: productsState.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')), error: (err, stack) => Center(child: Text('Error: $err')),
data: (products) { data: (products) {
if (products.isEmpty) { final filteredProducts = products.where((p) {
if (_stockFilter == 'LOW_STOCK') {
return p.isLowStock;
} else if (_stockFilter == 'OUT_OF_STOCK') {
return p.isOutOfStock;
}
return true;
}).toList();
if (filteredProducts.isEmpty) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => onRefresh: () =>
ref.read(productsProvider.notifier).refresh(), ref.read(productsProvider.notifier).refresh(),
child: ListView( child: ListView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
children: const [ children: [
SizedBox(height: 100), const SizedBox(height: 100),
Center( Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
LucideIcons.packageSearch, _stockFilter == 'LOW_STOCK'
? LucideIcons.checkCircle
: LucideIcons.packageSearch,
size: 64, size: 64,
color: Colors.grey, color: Colors.grey,
), ),
SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No products found.', _stockFilter == 'LOW_STOCK'
style: TextStyle( ? 'No low stock products! All inventory healthy.'
: (_stockFilter == 'OUT_OF_STOCK'
? 'No out-of-stock products.'
: 'No products found.'),
style: const TextStyle(
color: Colors.grey, color: Colors.grey,
fontSize: 16, fontSize: 16,
), ),
@@ -133,14 +203,14 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
child: ListView.builder( child: ListView.builder(
controller: _scrollController, controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
itemCount: products.length + 1, // +1 for loading indicator itemCount: filteredProducts.length + 1, // +1 for loading indicator
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == products.length) { if (index == filteredProducts.length) {
return const SizedBox(height: 80); return const SizedBox(height: 80);
} }
final p = products[index]; final p = filteredProducts[index];
final catName = categoriesState.value final catName = categoriesState.value
?.where((c) => c.id == p.categoryId) ?.where((c) => c.id == p.categoryId)
.firstOrNull .firstOrNull
@@ -150,11 +220,12 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.grey.withOpacity(0.08), color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
@@ -239,22 +310,78 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ 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( Text(
p.currentStock != null 'Low: ${p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock}',
? (p.currentStock! > 0 style: const TextStyle(fontSize: 11, color: Colors.orange, fontWeight: FontWeight.bold),
? 'Stock: ${p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock}' ),
: 'Out of Stock') ],
: 'Untracked', ),
style: TextStyle( Text(
color: p.currentStock != null 'Alert <= ${((p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock) ?? 0}',
? (p.currentStock! > 0 style: TextStyle(fontSize: 9, color: Colors.orange.shade800),
? const Color(0xFF10B981) ),
: Colors.red) ],
: Colors.grey, ),
),
] 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, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
),
],
], ],
), ),
], ],
@@ -271,7 +398,6 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
), ),
], ],
), ),
),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () async { onPressed: () async {
await Navigator.push( await Navigator.push(

View File

@@ -10,7 +10,12 @@ import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.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/presentation/purchase_order_details_screen.dart';
import 'package:kifi_app/features/vendor/providers/purchase_orders_provider.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 'package:kifi_app/core/utils/purity_utils.dart';
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
class StockLedgerTab extends ConsumerStatefulWidget { class StockLedgerTab extends ConsumerStatefulWidget {
final Product product; final Product product;
@@ -26,6 +31,18 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
bool _isLoading = true; bool _isLoading = true;
String _selectedFilter = 'IN_STOCK'; // 'IN_STOCK', 'SOLD', 'ALL' 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -77,6 +94,10 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
displayItems = _items!; 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 categoriesState = ref.watch(productCategoriesProvider);
final commodityRatesState = ref.watch(commodityRatesProvider); final commodityRatesState = ref.watch(commodityRatesProvider);
@@ -84,20 +105,24 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
(c) => c.id == widget.product.categoryId, (c) => c.id == widget.product.categoryId,
).firstOrNull; ).firstOrNull;
final bool isCommodity = isJewellery && category?.commodityCode != null && category!.commodityCode!.isNotEmpty;
final String unit = category?.baseUnit ?? 'g'; final String unit = category?.baseUnit ?? 'g';
final double purityFactor = resolvePurity(categoryPurity: category?.purityFactor, productPurity: widget.product.purityFactor); final double purityFactor = resolvePurity(categoryPurity: category?.purityFactor, productPurity: widget.product.purityFactor);
double commodityRate = 0.0; double commodityRate = 0.0;
if (category?.commodityCode != null && commodityRatesState.value != null) { if (isCommodity && commodityRatesState.value != null && category.commodityCode != null) {
final match = commodityRatesState.value!.where( final match = commodityRatesState.value!.where(
(r) => r.commodityCode.toUpperCase() == category!.commodityCode!.toUpperCase() (r) => r.commodityCode.toUpperCase() == category.commodityCode!.toUpperCase()
).firstOrNull; ).firstOrNull;
if (match != null) { if (match != null) {
commodityRate = match.rate; 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( return Column(
children: [ children: [
@@ -134,6 +159,31 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
final isSold = item.status == 'SOLD'; final isSold = item.status == 'SOLD';
final purchaseOrdersState = ref.watch(purchaseOrdersProvider); final purchaseOrdersState = ref.watch(purchaseOrdersProvider);
final purchaseOrders = purchaseOrdersState.value ?? []; 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) double weight = (item.grossWeight != null && item.grossWeight! > 0)
? item.grossWeight! ? 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 purchasePrice = weight * purchaseRate;
final currentPrice = weight * currentRate; final double displayedSellingRate = effectiveSaleRate > 0 ? effectiveSaleRate : (isCommodity ? currentRate : purchaseRate);
final gainLoss = currentPrice - purchasePrice; final currentPrice = weight * (isSold ? displayedSellingRate : currentRate);
final gainLoss = isSold ? ((weight * displayedSellingRate) - purchasePrice) : (currentPrice - purchasePrice);
final isGain = gainLoss >= 0; final isGain = gainLoss >= 0;
final String? salesInvoiceNumber = matchingSaleInvoice?.invoiceNumber ?? item.salesRef;
return Card( return Card(
elevation: 0, elevation: 0,
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
@@ -188,30 +266,93 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( FutureBuilder<String?>(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: FutureBuilder<String?>(
future: DioClient().storage.read(key: 'jwt_token'), future: DioClient().storage.read(key: 'jwt_token'),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData && widget.product.imageIds.isNotEmpty) { final hasImage = finalDisplayUrl != null;
return ClipRRect( return GestureDetector(
borderRadius: BorderRadius.circular(8), onTap: hasImage
child: Image.network( ? () {
'${DioClient().dio.options.baseUrl}/inventory/products/${widget.product.id}/images/${widget.product.imageIds.first}/content', final token = snapshot.data;
fit: BoxFit.cover, final headers = (token != null && isProductFallback)
headers: {'Authorization': 'Bearer ${snapshot.data}'}, ? {'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), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
@@ -224,6 +365,49 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
style: TextStyle(color: Colors.grey[600], fontSize: 12), style: TextStyle(color: Colors.grey[600], fontSize: 12),
), ),
const SizedBox(height: 4), 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) if (item.purchaseRef != null)
InkWell( InkWell(
onTap: () async { onTap: () async {
@@ -236,7 +420,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
(p) => p.poNumber == item.purchaseRef, (p) => p.poNumber == item.purchaseRef,
orElse: () => throw Exception('Purchase invoice not found'), orElse: () => throw Exception('Purchase invoice not found'),
); );
if (mounted) { if (context.mounted) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -245,7 +429,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
); );
} }
} catch (e) { } catch (e) {
if (mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open purchase invoice: $e')), SnackBar(content: Text('Could not open purchase invoice: $e')),
); );
@@ -258,12 +442,14 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
color: Colors.blue, color: Colors.blue,
decoration: TextDecoration.underline, decoration: TextDecoration.underline,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 12,
), ),
), ),
), ),
if (item.huid != null && item.huid!.isNotEmpty) ],
if (isJewellery && item.huid != null && item.huid!.isNotEmpty)
Padding( 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)), 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, color: isSold ? Colors.grey.shade600 : null,
), ),
), ),
if (isCommodity) ...[
const SizedBox(height: 3), const SizedBox(height: 3),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
@@ -316,10 +503,12 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
), ),
), ),
], ],
],
), ),
], ],
), ),
const Divider(height: 24), const Divider(height: 24),
if (isCommodity) ...[
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -347,7 +536,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(isSold ? 'Selling Rate' : 'Current Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)), 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( 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 double? defaultMakingCharge;
final String? makingChargeType; final String? makingChargeType;
final String baseUnit; final String baseUnit;
final String defaultLengthUom;
final bool isActive; final bool isActive;
final int sortOrder; final int sortOrder;
final double? dailyRate; final double? dailyRate;
@@ -34,6 +35,7 @@ class ProductCategory {
this.defaultMakingCharge, this.defaultMakingCharge,
this.makingChargeType, this.makingChargeType,
this.baseUnit = 'pcs', this.baseUnit = 'pcs',
this.defaultLengthUom = 'cm',
this.isActive = true, this.isActive = true,
this.sortOrder = 0, this.sortOrder = 0,
this.dailyRate, this.dailyRate,
@@ -54,6 +56,7 @@ class ProductCategory {
defaultMakingCharge: (json['defaultMakingCharge'] ?? json['default_making_charge'] as num?)?.toDouble(), defaultMakingCharge: (json['defaultMakingCharge'] ?? json['default_making_charge'] as num?)?.toDouble(),
makingChargeType: json['makingChargeType'] ?? json['making_charge_type'], makingChargeType: json['makingChargeType'] ?? json['making_charge_type'],
baseUnit: json['baseUnit'] ?? json['base_unit'] ?? 'pcs', baseUnit: json['baseUnit'] ?? json['base_unit'] ?? 'pcs',
defaultLengthUom: json['defaultLengthUom'] ?? json['default_length_uom'] ?? 'cm',
isActive: json['isActive'] ?? json['is_active'] ?? true, isActive: json['isActive'] ?? json['is_active'] ?? true,
sortOrder: json['sortOrder'] ?? json['sort_order'] ?? 0, sortOrder: json['sortOrder'] ?? json['sort_order'] ?? 0,
dailyRate: (json['dailyRate'] ?? json['daily_rate'] as num?)?.toDouble(), dailyRate: (json['dailyRate'] ?? json['daily_rate'] as num?)?.toDouble(),
@@ -75,6 +78,7 @@ class ProductCategory {
'defaultMakingCharge': defaultMakingCharge, 'defaultMakingCharge': defaultMakingCharge,
'makingChargeType': makingChargeType, 'makingChargeType': makingChargeType,
'baseUnit': baseUnit, 'baseUnit': baseUnit,
'defaultLengthUom': defaultLengthUom,
'isActive': isActive, 'isActive': isActive,
'sortOrder': sortOrder, 'sortOrder': sortOrder,
}; };

View File

@@ -57,11 +57,20 @@ class UomsNotifier extends AsyncNotifier<List<UnitOfMeasure>> {
// If empty in database, seed standard units of measure // If empty in database, seed standard units of measure
try { try {
final defaults = [ 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': 'Grams', 'abbreviation': 'g'},
{'name': 'Kilograms', 'abbreviation': 'kg'}, {'name': 'Kilograms', 'abbreviation': 'kg'},
{'name': 'Pieces', 'abbreviation': 'pcs'},
{'name': 'Carats', 'abbreviation': 'ct'},
{'name': 'Milligrams', 'abbreviation': 'mg'}, {'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) { for (final def in defaults) {
await DioClient().dio.post('/inventory/uom', data: def); 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 double? emiAmount;
final String? emiCycle; final String? emiCycle;
final DateTime? emiStartDate; 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<InvoiceItem> items;
final List<InvoicePayment>? payments; final List<InvoicePayment>? payments;
@@ -219,6 +230,17 @@ class Invoice {
this.emiAmount, this.emiAmount,
this.emiCycle, this.emiCycle,
this.emiStartDate, 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.items = const [],
this.payments, this.payments,
}); });
@@ -254,6 +276,19 @@ class Invoice {
emiStartDate: json['emiStartDate'] != null emiStartDate: json['emiStartDate'] != null
? DateTime.parse(json['emiStartDate']) ? DateTime.parse(json['emiStartDate'])
: (json['emi_start_date'] != null ? DateTime.parse(json['emi_start_date']) : null), : (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 items: json['items'] != null
? (json['items'] as List).map((i) => InvoiceItem.fromJson(i)).toList() ? (json['items'] as List).map((i) => InvoiceItem.fromJson(i)).toList()
: [], : [],
@@ -296,6 +331,19 @@ class Invoice {
if (emiStartDate != null) { if (emiStartDate != null) {
data['emiStartDate'] = emiStartDate!.toIso8601String().split('T')[0]; 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(); data['items'] = items.map((i) => i.toJson()).toList();
if (payments != null) { if (payments != null) {
data['payments'] = payments!.map((i) => i.toJson()).toList(); 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 'package:printing/printing.dart';
import '../../inventory/providers/products_provider.dart'; import '../../inventory/providers/products_provider.dart';
import '../../inventory/providers/product_categories_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 '../domain/invoice.dart';
import '../providers/invoices_provider.dart'; import '../providers/invoices_provider.dart';
import '../providers/customers_provider.dart'; import '../providers/customers_provider.dart';
import '../../business/providers/business_provider.dart'; import '../../business/providers/business_provider.dart';
import '../../business/providers/indian_states_provider.dart';
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
import '../../../core/network/dio_client.dart'; import '../../../core/network/dio_client.dart';
import 'widgets/receive_payment_sheet.dart'; import 'widgets/receive_payment_sheet.dart';
import 'widgets/create_credit_note_sheet.dart';
import 'widgets/settle_marketplace_order_sheet.dart';
import 'credit_notes_list_screen.dart';
import 'marketplace_settlements_screen.dart';
import '../providers/credit_notes_provider.dart';
import '../providers/marketplace_settlements_provider.dart';
import 'invoice_builder_screen.dart'; import 'invoice_builder_screen.dart';
bool _isCommodityProduct({
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) { String _resolveImageUrl(String path) {
if (path.startsWith('http://') || path.startsWith('https://')) { if (path.startsWith('http://') || path.startsWith('https://')) {
return path; 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 Text('INVOICE NO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(latestInvoice.invoiceNumber, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.blue)), 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 SizedBox(height: 10),
const Text('INVOICE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)), const Text('INVOICE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(formatDate.format(latestInvoice.issueDate), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), 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 // Item Cards
...(latestInvoice.items).map((item) { ...(latestInvoice.items).map((item) {
final product = products.where((p) => p.id == item.productId).firstOrNull; final product = products.where((p) => p.id == item.productId).firstOrNull;
final category = categories.where((c) => c.id == product?.categoryId).firstOrNull; final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull;
final weight = item.weight ?? item.quantity; final uoms = ref.watch(uomsProvider).value ?? [];
final metalAmount = weight * item.unitPrice;
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; double makingChargeAmt = 0.0;
if (isItemCommodity) {
if (item.makingChargesType == 'PERCENTAGE') { if (item.makingChargesType == 'PERCENTAGE') {
makingChargeAmt = metalAmount * (item.makingCharge / 100.0); makingChargeAmt = baseAmount * (item.makingCharge / 100.0);
} else if (item.makingChargesType == 'PER_PIECE') { } else if (item.makingChargesType == 'PER_PIECE') {
makingChargeAmt = item.makingCharge; makingChargeAmt = item.makingCharge;
} else { } 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( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
@@ -278,7 +376,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
item.productName ?? product?.name ?? item.description ?? 'Jewellery Item', item.productName ?? product?.name ?? item.description ?? 'Item',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -290,10 +388,12 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
_buildDetailBadge(item.categoryName ?? category!.name, const Color(0xFFD4AF37)), _buildDetailBadge(item.categoryName ?? category!.name, const Color(0xFFD4AF37)),
if (item.sku != null || product?.sku != null) if (item.sku != null || product?.sku != null)
_buildDetailBadge('SKU: ${item.sku ?? product!.sku}', Colors.blue), _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), _buildDetailBadge('HUID: ${item.huid}', Colors.purple),
if (item.hsnCode != null) if (item.hsnCode != null && item.hsnCode!.isNotEmpty)
_buildDetailBadge('HSN: ${item.hsnCode}', Colors.grey), _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), const Divider(height: 18),
if (isItemCommodity) ...[
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( 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), 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), const SizedBox(height: 4),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, 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)), 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) ...[ if (item.taxRate > 0) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Row( Row(
@@ -334,7 +459,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
children: [ children: [
Text('GST (${item.taxRate.toStringAsFixed(1)}%):', style: TextStyle(fontSize: 11, color: Colors.grey.shade500)), Text('GST (${item.taxRate.toStringAsFixed(1)}%):', style: TextStyle(fontSize: 11, color: Colors.grey.shade500)),
Text( Text(
'+ ${formatCurrency.format(((metalAmount + makingChargeAmt) * (item.taxRate / 100.0)))}', '+ ${formatCurrency.format(taxAmount)}',
style: TextStyle(fontSize: 11, color: Colors.grey.shade600), style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
), ),
], ],
@@ -415,19 +540,239 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
// Bottom Action: Receive Payment if balance due // Credit Notes / Returns associated with this Invoice
if (remaining > 0) if (latestInvoice.id != null) ...[
ElevatedButton.icon( ref.watch(invoiceCreditNotesProvider(latestInvoice.id!)).when(
onPressed: () => _showReceivePaymentSheet(context, ref, latestInvoice), data: (cns) {
icon: const Icon(LucideIcons.plusCircle, size: 18), if (cns.isEmpty) return const SizedBox.shrink();
label: const Text('Record Payment', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), return Column(
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( 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, backgroundColor: Colors.green,
foregroundColor: Colors.white, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
), ),
), ),
),
],
],
],
),
const SizedBox(height: 30), 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) { Widget _buildDetailBadge(String text, Color color) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
@@ -538,7 +900,9 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
await Share.shareXFiles([XFile(imagePath.path)], text: 'Invoice $invoiceNumber'); await Share.shareXFiles([XFile(imagePath.path)], text: 'Invoice $invoiceNumber');
} catch (e) { } catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing image: $e'))); if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing image: $e')));
}
} }
} }
@@ -550,30 +914,28 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
orElse: () => widget.invoice, orElse: () => widget.invoice,
) ?? widget.invoice; ) ?? widget.invoice;
final pdf = pw.Document();
final business = ref.read(businessProfileProvider).value;
final customers = ref.read(customersProvider).value ?? []; final customers = ref.read(customersProvider).value ?? [];
final customer = customers.where((c) => c.id == latestInvoice.customerId).firstOrNull; final customer = customers.where((c) => c.id == latestInvoice.customerId).firstOrNull;
final business = ref.read(businessProfileProvider).value;
final products = ref.read(productsProvider).value ?? []; final products = ref.read(productsProvider).value ?? [];
final categories = ref.read(productCategoriesProvider).value ?? []; final categories = ref.read(productCategoriesProvider).value ?? [];
final uoms = ref.read(uomsProvider).value ?? [];
final indianStates = ref.read(indianStatesProvider).value ?? [];
final businessState = indianStates.where((s) => s.id == business?.stateId).firstOrNull;
final customerState = indianStates.where((s) => s.id == (latestInvoice.placeOfSupplyStateId ?? customer?.stateId)).firstOrNull;
final isSameState = businessState != null && customerState != null && businessState.id == customerState.id;
final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2);
final formatDate = DateFormat('dd MMM yyyy'); final formatDate = DateFormat('dd MMM yyyy');
final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2);
final font = await PdfGoogleFonts.robotoRegular(); // Group items by productId
final boldFont = await PdfGoogleFonts.robotoBold();
final businessStateId = business?.stateId;
final customerStateId = customer?.stateId;
final isSameState = businessStateId != null && customerStateId != null
? (businessStateId == customerStateId)
: true;
// Group items by product ID if available, or itemize
final Map<int, List<InvoiceItem>> groupedItems = {}; final Map<int, List<InvoiceItem>> groupedItems = {};
final List<InvoiceItem> ungroupedItems = []; final List<InvoiceItem> ungroupedItems = [];
for (final item in latestInvoice.items) { for (var item in latestInvoice.items) {
if (item.productId != null) { if (item.productId != null) {
groupedItems.putIfAbsent(item.productId!, () => []).add(item); groupedItems.putIfAbsent(item.productId!, () => []).add(item);
} else { } else {
@@ -581,19 +943,15 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
} }
} }
final pdf = pw.Document();
pdf.addPage( pdf.addPage(
pw.MultiPage( pw.MultiPage(
pageFormat: PdfPageFormat.a4, pageFormat: PdfPageFormat.a4,
theme: pw.ThemeData.withFont( margin: const pw.EdgeInsets.all(32),
base: font, build: (context) => [
bold: boldFont, pw.Column(
), crossAxisAlignment: pw.CrossAxisAlignment.start,
margin: const pw.EdgeInsets.all(28), children: [
build: (pw.Context context) { // 1. Enterprise Invoice Header
return [
// 1. Header with Business Details & TAX INVOICE title
pw.Row( pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.start, crossAxisAlignment: pw.CrossAxisAlignment.start,
@@ -603,14 +961,13 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
crossAxisAlignment: pw.CrossAxisAlignment.start, crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [ children: [
pw.Text( pw.Text(
business?.businessName ?? 'KIFI JEWELLERS', business?.businessName ?? 'KIFI ENTERPRISE',
style: pw.TextStyle( style: pw.TextStyle(
fontSize: 22, fontSize: 18,
fontWeight: pw.FontWeight.bold, fontWeight: pw.FontWeight.bold,
color: PdfColors.blue900, color: PdfColors.blue900,
), ),
), ),
pw.SizedBox(height: 4),
if (business?.address != null && business!.address!.isNotEmpty) if (business?.address != null && business!.address!.isNotEmpty)
pw.Text( pw.Text(
business.address!, business.address!,
@@ -639,38 +996,11 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
pw.Text( pw.Text(
'TAX INVOICE', 'TAX INVOICE',
style: pw.TextStyle( style: pw.TextStyle(
fontSize: 22, fontSize: 18,
fontWeight: pw.FontWeight.bold, fontWeight: pw.FontWeight.bold,
color: PdfColors.blue900, color: PdfColors.blue900,
), ),
), ),
pw.SizedBox(height: 6),
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: pw.BoxDecoration(
color: _getEffectiveStatus(latestInvoice) == 'PAID'
? PdfColors.green100
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange100 : PdfColors.grey200),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4)),
border: pw.Border.all(
color: _getEffectiveStatus(latestInvoice) == 'PAID'
? PdfColors.green700
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange700 : PdfColors.grey500),
width: 0.5,
),
),
child: pw.Text(
_getEffectiveStatus(latestInvoice),
style: pw.TextStyle(
fontSize: 9,
fontWeight: pw.FontWeight.bold,
color: _getEffectiveStatus(latestInvoice) == 'PAID'
? PdfColors.green900
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange900 : PdfColors.grey800),
),
),
),
pw.SizedBox(height: 6),
pw.Text( pw.Text(
'Invoice #: ${latestInvoice.invoiceNumber}', 'Invoice #: ${latestInvoice.invoiceNumber}',
style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold), style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold),
@@ -679,6 +1009,21 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
'Date: ${formatDate.format(latestInvoice.issueDate)}', 'Date: ${formatDate.format(latestInvoice.issueDate)}',
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
), ),
if (latestInvoice.salesChannel != null && latestInvoice.salesChannel!.isNotEmpty)
pw.Text(
'Channel: ${latestInvoice.salesChannel!}',
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900),
),
if (latestInvoice.marketplaceOrderId != null && latestInvoice.marketplaceOrderId!.isNotEmpty)
pw.Text(
'Order Ref: #${latestInvoice.marketplaceOrderId!}',
style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700),
),
if (latestInvoice.courierPartner != null || latestInvoice.trackingNumber != null)
pw.Text(
'Courier: ${latestInvoice.courierPartner ?? ""}${latestInvoice.trackingNumber != null ? " (AWB: ${latestInvoice.trackingNumber})" : ""}',
style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700),
),
if (latestInvoice.dueDate != null) if (latestInvoice.dueDate != null)
pw.Text( pw.Text(
'Due Date: ${formatDate.format(latestInvoice.dueDate!)}', 'Due Date: ${formatDate.format(latestInvoice.dueDate!)}',
@@ -690,7 +1035,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
), ),
pw.SizedBox(height: 18), pw.SizedBox(height: 18),
// 2. Bill To Box (Customer Details) // 2. Bill To & Ship To Box (Customer & Delivery Details)
pw.Container( pw.Container(
padding: const pw.EdgeInsets.all(10), padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration( decoration: pw.BoxDecoration(
@@ -717,7 +1062,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
pw.Text( pw.Text(
customer?.name ?? 'Walk-in Customer', customer?.name ?? 'Walk-in Customer',
style: pw.TextStyle( style: pw.TextStyle(
fontSize: 12, fontSize: 11,
fontWeight: pw.FontWeight.bold, fontWeight: pw.FontWeight.bold,
), ),
), ),
@@ -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 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 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( return pw.Container(
margin: const pw.EdgeInsets.only(bottom: 14), margin: const pw.EdgeInsets.only(bottom: 14),
decoration: pw.BoxDecoration( decoration: pw.BoxDecoration(
@@ -839,6 +1229,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
style: const pw.TextStyle(fontSize: 7.5, color: PdfColors.grey800), style: const pw.TextStyle(fontSize: 7.5, color: PdfColors.grey800),
), ),
), ),
if (isGroupCommodity)
pw.Container( pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: pw.BoxDecoration( 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 != '-') if (hsnStr != '-')
pw.Container( pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
@@ -900,15 +1308,25 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
4: pw.Alignment.centerRight, 4: pw.Alignment.centerRight,
5: pw.Alignment.centerRight, 5: pw.Alignment.centerRight,
}, },
headers: [ headers: isGroupCommodity
? [
'Item / HUID', 'Item / HUID',
'Weight / Qty', 'Weight / Qty',
'Rate', 'Rate',
'Making Chg', 'Making Chg',
'Other Chg', 'Other Chg',
'Total Amt', 'Total Amt',
]
: [
'Item / SKU',
'Qty',
'Unit Price',
'Discount',
'GST %',
'Total Amt',
], ],
data: group.map((item) { data: isGroupCommodity
? group.map((item) {
String particular = ''; String particular = '';
if (item.huid != null && item.huid!.isNotEmpty) { if (item.huid != null && item.huid!.isNotEmpty) {
particular = 'HUID: ${item.huid}'; particular = 'HUID: ${item.huid}';
@@ -920,10 +1338,10 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
final weight = item.weight ?? item.quantity; final weight = item.weight ?? item.quantity;
final weightQty = item.weight != null && item.weight! > 0 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'; : '${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; double makingAmt = 0.0;
if (item.makingChargesType == 'PERCENTAGE') { if (item.makingChargesType == 'PERCENTAGE') {
@@ -942,6 +1360,18 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
item.otherCharges > 0 ? formatCurrency.format(item.otherCharges) : '-', item.otherCharges > 0 ? formatCurrency.format(item.otherCharges) : '-',
formatCurrency.format(item.total), 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(), }).toList(),
), ),
@@ -959,7 +1389,9 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Text( 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), style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
), ),
pw.Text( 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 '../providers/invoices_provider.dart';
import '../domain/invoice.dart'; import '../domain/invoice.dart';
import '../providers/customers_provider.dart'; import '../providers/customers_provider.dart';
import '../providers/sales_channels_provider.dart';
import 'invoice_builder_screen.dart'; import 'invoice_builder_screen.dart';
import 'invoice_details_screen.dart'; import 'invoice_details_screen.dart';
import 'credit_notes_list_screen.dart';
import 'marketplace_settlements_screen.dart';
class InvoicesListScreen extends ConsumerStatefulWidget { class InvoicesListScreen extends ConsumerStatefulWidget {
const InvoicesListScreen({super.key}); const InvoicesListScreen({super.key});
@@ -18,6 +21,8 @@ class InvoicesListScreen extends ConsumerStatefulWidget {
class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> { class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
String _searchQuery = ''; String _searchQuery = '';
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
String? _selectedChannelFilter;
String? _selectedDispatchStatusFilter;
String _getStatusLabel(Invoice invoice) { String _getStatusLabel(Invoice invoice) {
if (invoice.isEmi && invoice.status != 'PAID') return 'EMI'; if (invoice.isEmi && invoice.status != 'PAID') return 'EMI';
@@ -47,6 +52,23 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
} }
} }
Color _getDispatchStatusColor(String? status) {
switch (status?.toUpperCase()) {
case 'DELIVERED':
return Colors.green;
case 'SHIPPED':
case 'IN_TRANSIT':
return Colors.indigo;
case 'PACKED':
return Colors.blue;
case 'RTO':
return Colors.red;
case 'PENDING':
default:
return Colors.grey;
}
}
@override @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
@@ -57,6 +79,7 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final invoicesState = ref.watch(invoicesProvider); final invoicesState = ref.watch(invoicesProvider);
final customersState = ref.watch(customersProvider); final customersState = ref.watch(customersProvider);
final channelsState = ref.watch(salesChannelsProvider);
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final formatCurrency = NumberFormat.currency(symbol: ''); final formatCurrency = NumberFormat.currency(symbol: '');
final formatDate = DateFormat('MMM dd, yyyy'); final formatDate = DateFormat('MMM dd, yyyy');
@@ -72,19 +95,43 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white, backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white,
foregroundColor: isDark ? Colors.white : Colors.black, foregroundColor: isDark ? Colors.white : Colors.black,
centerTitle: true, centerTitle: true,
actions: [
IconButton(
icon: const Icon(LucideIcons.landmark),
tooltip: 'Marketplace Settlements & Fees',
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const MarketplaceSettlementsScreen()),
);
},
),
IconButton(
icon: const Icon(LucideIcons.undo2),
tooltip: 'Sales Returns & Credit Notes',
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const CreditNotesListScreen()),
);
},
),
],
), ),
body: Column( body: Column(
children: [ children: [
// Unified Search Header // Unified Search Header
Container( Container(
color: isDark ? const Color(0xFF1E293B) : Colors.white, color: isDark ? const Color(0xFF1E293B) : Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: TextField( child: Column(
children: [
TextField(
controller: _searchController, controller: _searchController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search Invoice # or Customer...', hintText: 'Search Invoice #, Order ID, Customer, Courier...',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
contentPadding: const EdgeInsets.symmetric(vertical: 14), contentPadding: const EdgeInsets.symmetric(vertical: 12),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300), borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300),
@@ -105,16 +152,87 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
), ),
onChanged: (val) => setState(() => _searchQuery = val.trim()), 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( Expanded(
child: invoicesState.when( child: invoicesState.when(
data: (invoices) { data: (invoices) {
final customers = customersState.value ?? []; final customers = customersState.value ?? [];
final filtered = invoices.where((inv) { final filtered = invoices.where((inv) {
final matchesInv = inv.invoiceNumber.toLowerCase().contains(_searchQuery.toLowerCase()); final q = _searchQuery.toLowerCase();
final matchesInv = inv.invoiceNumber.toLowerCase().contains(q);
final matchesOrder = inv.marketplaceOrderId != null && inv.marketplaceOrderId!.toLowerCase().contains(q);
final matchesCourier = inv.courierPartner != null && inv.courierPartner!.toLowerCase().contains(q);
final matchesAwb = inv.trackingNumber != null && inv.trackingNumber!.toLowerCase().contains(q);
final customer = customers.where((c) => c.id == inv.customerId).firstOrNull; final customer = customers.where((c) => c.id == inv.customerId).firstOrNull;
final matchesCust = customer != null && customer.name.toLowerCase().contains(_searchQuery.toLowerCase()); final matchesCust = customer != null && customer.name.toLowerCase().contains(q);
return matchesInv || matchesCust; final matchesSearch = q.isEmpty || matchesInv || matchesOrder || matchesCust || matchesCourier || matchesAwb;
final matchesChannel = _selectedChannelFilter == null ||
(inv.salesChannel != null && inv.salesChannel!.toLowerCase() == _selectedChannelFilter!.toLowerCase());
final matchesDispatch = _selectedDispatchStatusFilter == null ||
(inv.dispatchStatus != null && inv.dispatchStatus!.toUpperCase() == _selectedDispatchStatusFilter!.toUpperCase());
return matchesSearch && matchesChannel && matchesDispatch;
}).toList(); }).toList();
if (filtered.isEmpty) { if (filtered.isEmpty) {
@@ -198,12 +316,51 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Expanded(
child: Row(
children: [
Flexible(
child: Text(
invoice.invoiceNumber, invoice.invoiceNumber,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, 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( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), 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 final weightStr = item.weight != null && item.weight! > 0
? '${item.weight!.toStringAsFixed(3)} g' ? '${item.weight!.toStringAsFixed(3)} g'
: '${item.quantity.toInt()} pcs'; : '${item.quantity.toInt()} pcs';
final name = item.productName ?? item.description ?? 'Jewellery Item'; final name = item.productName ?? item.description ?? 'Item';
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0), padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row( child: Row(
children: [ children: [
Icon(LucideIcons.sparkles, size: 12, color: Colors.amber.shade700), Icon(
item.weight != null && item.weight! > 0 ? LucideIcons.sparkles : LucideIcons.package,
size: 12,
color: item.weight != null && item.weight! > 0 ? Colors.amber.shade700 : Colors.blue.shade700,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: Text( child: Text(
@@ -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), const SizedBox(height: 10),
// Row 3: Date & Total Amount // Row 3: Date & Total Amount

View File

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

View File

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

View File

@@ -65,6 +65,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.9" 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: bidi:
dependency: transitive dependency: transitive
description: description:

View File

@@ -60,6 +60,7 @@ dependencies:
intl_phone_field: ^3.2.0 intl_phone_field: ^3.2.0
url_launcher: ^6.3.2 url_launcher: ^6.3.2
lucide_icons_flutter: ^3.1.17 lucide_icons_flutter: ^3.1.17
barcode_widget: ^2.0.4
dev_dependencies: dev_dependencies:
flutter_test: 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 charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <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="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 --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
@@ -16,7 +16,7 @@
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/> <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"> <link rel="manifest" href="manifest.json">
<style> <style>
body { body {
@@ -85,9 +85,9 @@
</head> </head>
<body> <body>
<div class="splash-container"> <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-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 class="spinner"></div>
</div> </div>
<script src="flutter_bootstrap.js" async></script> <script src="flutter_bootstrap.js" async></script>