Compare commits
6 Commits
dev
...
e-commerce
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c04a993f7 | |||
| 5e9b3f2122 | |||
| b123a34e8f | |||
| b4772ccf19 | |||
| 3a5e812947 | |||
| bfe0c1efbd |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ public class ProductCategoryController {
|
||||
existing.setDefaultMakingCharge(category.getDefaultMakingCharge());
|
||||
existing.setMakingChargeType(category.getMakingChargeType());
|
||||
existing.setBaseUnit(category.getBaseUnit());
|
||||
existing.setDefaultLengthUom(category.getDefaultLengthUom());
|
||||
existing.setIsActive(category.getIsActive());
|
||||
existing.setSortOrder(category.getSortOrder());
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.kifi.api.controller.invoice;
|
||||
|
||||
import com.kifi.api.entity.invoice.CreditNote;
|
||||
import com.kifi.api.service.invoice.CreditNoteService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi-v2")
|
||||
@RequiredArgsConstructor
|
||||
public class CreditNoteController {
|
||||
|
||||
private final CreditNoteService creditNoteService;
|
||||
|
||||
@GetMapping("/credit-notes")
|
||||
public Flux<CreditNote> getCreditNotes(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return creditNoteService.getCreditNotes(userId);
|
||||
}
|
||||
|
||||
@GetMapping("/credit-notes/{id}")
|
||||
public Mono<CreditNote> getCreditNoteById(
|
||||
@PathVariable Long id,
|
||||
Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return creditNoteService.getCreditNoteById(id, userId);
|
||||
}
|
||||
|
||||
@GetMapping("/invoices/{invoiceId}/credit-notes")
|
||||
public Flux<CreditNote> getCreditNotesForInvoice(
|
||||
@PathVariable Long invoiceId,
|
||||
Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return creditNoteService.getCreditNotesForInvoice(invoiceId, userId);
|
||||
}
|
||||
|
||||
@PostMapping("/invoices/{invoiceId}/credit-notes")
|
||||
public Mono<CreditNote> createCreditNote(
|
||||
@PathVariable Long invoiceId,
|
||||
Authentication authentication,
|
||||
@RequestBody CreditNote creditNote) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return creditNoteService.createCreditNote(userId, invoiceId, creditNote);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.kifi.api.controller.invoice;
|
||||
|
||||
import com.kifi.api.entity.invoice.MarketplaceSettlement;
|
||||
import com.kifi.api.service.invoice.MarketplaceSettlementService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi-v2")
|
||||
@RequiredArgsConstructor
|
||||
public class MarketplaceSettlementController {
|
||||
|
||||
private final MarketplaceSettlementService settlementService;
|
||||
|
||||
@GetMapping("/marketplace-settlements")
|
||||
public Flux<MarketplaceSettlement> getSettlements(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return settlementService.getSettlements(userId);
|
||||
}
|
||||
|
||||
@GetMapping("/marketplace-settlements/{id}")
|
||||
public Mono<MarketplaceSettlement> getSettlementById(
|
||||
@PathVariable Long id,
|
||||
Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return settlementService.getSettlementById(id, userId);
|
||||
}
|
||||
|
||||
@GetMapping("/invoices/{invoiceId}/settlements")
|
||||
public Flux<MarketplaceSettlement> getSettlementsForInvoice(
|
||||
@PathVariable Long invoiceId,
|
||||
Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return settlementService.getSettlementsForInvoice(invoiceId, userId);
|
||||
}
|
||||
|
||||
@PostMapping("/invoices/{invoiceId}/settlements")
|
||||
public Mono<MarketplaceSettlement> createSettlement(
|
||||
@PathVariable Long invoiceId,
|
||||
Authentication authentication,
|
||||
@RequestBody MarketplaceSettlement settlement) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return settlementService.createSettlement(userId, invoiceId, settlement);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -33,6 +33,9 @@ public class BusinessFeature {
|
||||
@Column("barcode_source")
|
||||
private String barcodeSource;
|
||||
|
||||
@Column("enable_ecommerce_channels")
|
||||
private Boolean enableEcommerceChannels;
|
||||
|
||||
@Column("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ public class InventoryItem {
|
||||
private Long vendorId;
|
||||
private Long branchId;
|
||||
private String purchaseRef;
|
||||
private String salesRef;
|
||||
private BigDecimal saleRate;
|
||||
private String photoUrl;
|
||||
@Builder.Default
|
||||
private String status = "AVAILABLE";
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@@ -5,6 +5,7 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -28,16 +29,50 @@ public class Product {
|
||||
private String barcode;
|
||||
private String hsnCode;
|
||||
private String description;
|
||||
private BigDecimal mrp;
|
||||
private BigDecimal sellingPrice;
|
||||
private BigDecimal gstRate;
|
||||
private String dimensions;
|
||||
private String color;
|
||||
private String size;
|
||||
|
||||
private Double itemLength;
|
||||
private Double itemWidth;
|
||||
private Double itemHeight;
|
||||
private String dimensionUom;
|
||||
|
||||
private Double packageLength;
|
||||
private Double packageWidth;
|
||||
private Double packageHeight;
|
||||
private String packageDimensionUom;
|
||||
|
||||
private String manufacturerCode;
|
||||
private String material;
|
||||
private String brandName;
|
||||
private String countryOfOrigin;
|
||||
|
||||
private Double weightInG;
|
||||
private Double packageWeightInG;
|
||||
private Double volumetricWeightInKg;
|
||||
|
||||
private String priceCalcRule;
|
||||
private Double purityFactor;
|
||||
private Double makingCharges;
|
||||
private String makingChargesType;
|
||||
private Double wastagePercentage;
|
||||
|
||||
@Column("min_stock")
|
||||
private BigDecimal minStock;
|
||||
|
||||
@Column("reorder_level")
|
||||
private BigDecimal reorderLevel;
|
||||
|
||||
@Column("reorder_quantity")
|
||||
private BigDecimal reorderQuantity;
|
||||
|
||||
@Column("track_inventory")
|
||||
private Boolean trackInventory;
|
||||
|
||||
private Boolean isActive;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@@ -34,6 +34,8 @@ public class ProductCategory {
|
||||
private String makingChargeType;
|
||||
|
||||
private String baseUnit;
|
||||
@Builder.Default
|
||||
private String defaultLengthUom = "cm";
|
||||
|
||||
@Builder.Default
|
||||
private Boolean isActive = true;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.kifi.api.entity.invoice;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.annotation.Transient;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("credit_notes")
|
||||
public class CreditNote {
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@Column("user_id")
|
||||
private Long userId;
|
||||
|
||||
@Column("invoice_id")
|
||||
private Long invoiceId;
|
||||
|
||||
@Column("credit_note_number")
|
||||
private String creditNoteNumber;
|
||||
|
||||
@Column("customer_id")
|
||||
private Long customerId;
|
||||
|
||||
@Column("return_date")
|
||||
private LocalDate returnDate;
|
||||
|
||||
@Column("reason")
|
||||
private String reason; // CUSTOMER_RETURN, RTO_UNDELIVERED, DEFECTIVE, EXCHANGE, OTHER
|
||||
|
||||
@Column("restock_items")
|
||||
@Builder.Default
|
||||
private Boolean restockItems = true;
|
||||
|
||||
@Column("subtotal")
|
||||
private BigDecimal subtotal;
|
||||
|
||||
@Column("tax_total")
|
||||
private BigDecimal taxTotal;
|
||||
|
||||
@Column("cgst_total")
|
||||
private BigDecimal cgstTotal;
|
||||
|
||||
@Column("sgst_total")
|
||||
private BigDecimal sgstTotal;
|
||||
|
||||
@Column("igst_total")
|
||||
private BigDecimal igstTotal;
|
||||
|
||||
@Column("discount_total")
|
||||
private BigDecimal discountTotal;
|
||||
|
||||
@Column("total_amount")
|
||||
private BigDecimal totalAmount;
|
||||
|
||||
@Column("refund_status")
|
||||
private String refundStatus; // ADJUSTED_TO_AR, REFUNDED_TO_WALLET, STORE_CREDIT, PENDING
|
||||
|
||||
@Column("refund_wallet_id")
|
||||
private Long refundWalletId;
|
||||
|
||||
@Column("notes")
|
||||
private String notes;
|
||||
|
||||
@Column("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Transient
|
||||
private List<CreditNoteItem> items;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.kifi.api.entity.invoice;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("credit_note_items")
|
||||
public class CreditNoteItem {
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@Column("credit_note_id")
|
||||
private Long creditNoteId;
|
||||
|
||||
@Column("invoice_item_id")
|
||||
private Long invoiceItemId;
|
||||
|
||||
@Column("product_id")
|
||||
private Long productId;
|
||||
|
||||
@Column("inventory_item_id")
|
||||
private Long inventoryItemId;
|
||||
|
||||
@Column("product_name")
|
||||
private String productName;
|
||||
|
||||
@Column("sku")
|
||||
private String sku;
|
||||
|
||||
@Column("huid")
|
||||
private String huid;
|
||||
|
||||
@Column("hsn_code")
|
||||
private String hsnCode;
|
||||
|
||||
@Column("quantity")
|
||||
private BigDecimal quantity;
|
||||
|
||||
@Column("weight")
|
||||
private BigDecimal weight;
|
||||
|
||||
@Column("unit_price")
|
||||
private BigDecimal unitPrice;
|
||||
|
||||
@Column("tax_rate")
|
||||
private BigDecimal taxRate;
|
||||
|
||||
@Column("cgst")
|
||||
private BigDecimal cgst;
|
||||
|
||||
@Column("sgst")
|
||||
private BigDecimal sgst;
|
||||
|
||||
@Column("igst")
|
||||
private BigDecimal igst;
|
||||
|
||||
@Column("discount")
|
||||
private BigDecimal discount;
|
||||
|
||||
@Column("making_charge")
|
||||
private BigDecimal makingCharge;
|
||||
|
||||
@Column("other_charges")
|
||||
private BigDecimal otherCharges;
|
||||
|
||||
@Column("total")
|
||||
private BigDecimal total;
|
||||
}
|
||||
@@ -80,6 +80,39 @@ public class Invoice {
|
||||
@Column("emi_start_date")
|
||||
private LocalDate emiStartDate;
|
||||
|
||||
@Column("sales_channel_id")
|
||||
private Long salesChannelId;
|
||||
|
||||
@Column("sales_channel")
|
||||
private String salesChannel;
|
||||
|
||||
@Column("marketplace_order_id")
|
||||
private String marketplaceOrderId;
|
||||
|
||||
@Column("courier_partner")
|
||||
private String courierPartner;
|
||||
|
||||
@Column("tracking_number")
|
||||
private String trackingNumber;
|
||||
|
||||
@Column("dispatch_status")
|
||||
private String dispatchStatus; // PENDING, PACKED, SHIPPED, IN_TRANSIT, DELIVERED, RTO
|
||||
|
||||
@Column("shipped_at")
|
||||
private LocalDateTime shippedAt;
|
||||
|
||||
@Column("shipping_address")
|
||||
private String shippingAddress;
|
||||
|
||||
@Column("shipping_pincode")
|
||||
private String shippingPincode;
|
||||
|
||||
@Column("place_of_supply_state_id")
|
||||
private Long placeOfSupplyStateId;
|
||||
|
||||
@Column("place_of_supply")
|
||||
private String placeOfSupply;
|
||||
|
||||
@Column("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.kifi.api.entity.invoice;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("marketplace_settlements")
|
||||
public class MarketplaceSettlement {
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@Column("user_id")
|
||||
private Long userId;
|
||||
|
||||
@Column("invoice_id")
|
||||
private Long invoiceId;
|
||||
|
||||
@Column("sales_channel_id")
|
||||
private Long salesChannelId;
|
||||
|
||||
@Column("channel_name")
|
||||
private String channelName;
|
||||
|
||||
@Column("settlement_ref")
|
||||
private String settlementRef;
|
||||
|
||||
@Column("settlement_date")
|
||||
private LocalDate settlementDate;
|
||||
|
||||
@Column("gross_amount")
|
||||
private BigDecimal grossAmount;
|
||||
|
||||
@Column("commission_fee")
|
||||
@Builder.Default
|
||||
private BigDecimal commissionFee = BigDecimal.ZERO;
|
||||
|
||||
@Column("shipping_fee")
|
||||
@Builder.Default
|
||||
private BigDecimal shippingFee = BigDecimal.ZERO;
|
||||
|
||||
@Column("other_fees")
|
||||
@Builder.Default
|
||||
private BigDecimal otherFees = BigDecimal.ZERO;
|
||||
|
||||
@Column("fee_gst")
|
||||
@Builder.Default
|
||||
private BigDecimal feeGst = BigDecimal.ZERO;
|
||||
|
||||
@Column("tcs_gst_amount")
|
||||
@Builder.Default
|
||||
private BigDecimal tcsGstAmount = BigDecimal.ZERO;
|
||||
|
||||
@Column("tds_amount")
|
||||
@Builder.Default
|
||||
private BigDecimal tdsAmount = BigDecimal.ZERO;
|
||||
|
||||
@Column("total_deductions")
|
||||
private BigDecimal totalDeductions;
|
||||
|
||||
@Column("net_payout_amount")
|
||||
private BigDecimal netPayoutAmount;
|
||||
|
||||
@Column("payout_wallet_id")
|
||||
private Long payoutWalletId;
|
||||
|
||||
@Column("status")
|
||||
@Builder.Default
|
||||
private String status = "SETTLED";
|
||||
|
||||
@Column("notes")
|
||||
private String notes;
|
||||
|
||||
@Column("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.kifi.api.repository.invoice;
|
||||
|
||||
import com.kifi.api.entity.invoice.CreditNoteItem;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
public interface CreditNoteItemRepository extends ReactiveCrudRepository<CreditNoteItem, Long> {
|
||||
Flux<CreditNoteItem> findByCreditNoteId(Long creditNoteId);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.kifi.api.repository.invoice;
|
||||
|
||||
import com.kifi.api.entity.invoice.CreditNote;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface CreditNoteRepository extends ReactiveCrudRepository<CreditNote, Long> {
|
||||
Flux<CreditNote> findByUserId(Long userId);
|
||||
Flux<CreditNote> findByUserIdAndInvoiceId(Long userId, Long invoiceId);
|
||||
Flux<CreditNote> findByUserIdAndCustomerId(Long userId, Long customerId);
|
||||
Mono<CreditNote> findByUserIdAndCreditNoteNumber(Long userId, String creditNoteNumber);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.kifi.api.repository.invoice;
|
||||
|
||||
import com.kifi.api.entity.invoice.MarketplaceSettlement;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface MarketplaceSettlementRepository extends ReactiveCrudRepository<MarketplaceSettlement, Long> {
|
||||
Flux<MarketplaceSettlement> findByUserId(Long userId);
|
||||
Flux<MarketplaceSettlement> findByUserIdAndInvoiceId(Long userId, Long invoiceId);
|
||||
Flux<MarketplaceSettlement> findByUserIdAndSalesChannelId(Long userId, Long salesChannelId);
|
||||
Mono<MarketplaceSettlement> findByUserIdAndSettlementRef(Long userId, String settlementRef);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ public class BusinessService {
|
||||
.multiLocation(false)
|
||||
.bomReductionStrategy("COMPONENTS_ONLY")
|
||||
.stockDeductionOnInvoice(true)
|
||||
.enableEcommerceChannels(false)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build());
|
||||
}
|
||||
@@ -51,6 +52,7 @@ public class BusinessService {
|
||||
if (feature.getBomReductionStrategy() != null) existing.setBomReductionStrategy(feature.getBomReductionStrategy());
|
||||
if (feature.getStockDeductionOnInvoice() != null) existing.setStockDeductionOnInvoice(feature.getStockDeductionOnInvoice());
|
||||
if (feature.getBarcodeSource() != null) existing.setBarcodeSource(feature.getBarcodeSource());
|
||||
if (feature.getEnableEcommerceChannels() != null) existing.setEnableEcommerceChannels(feature.getEnableEcommerceChannels());
|
||||
return businessFeatureRepository.save(existing);
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
|
||||
@@ -55,6 +55,7 @@ public class InventoryItemService {
|
||||
if (updatedItem.getVendorId() != null) existingItem.setVendorId(updatedItem.getVendorId());
|
||||
if (updatedItem.getBranchId() != null) existingItem.setBranchId(updatedItem.getBranchId());
|
||||
if (updatedItem.getPurchaseRef() != null) existingItem.setPurchaseRef(updatedItem.getPurchaseRef());
|
||||
if (updatedItem.getPhotoUrl() != null) existingItem.setPhotoUrl(updatedItem.getPhotoUrl());
|
||||
if (updatedItem.getStatus() != null) existingItem.setStatus(updatedItem.getStatus());
|
||||
existingItem.setUpdatedAt(LocalDateTime.now());
|
||||
return inventoryItemRepository.save(existingItem);
|
||||
|
||||
@@ -96,17 +96,44 @@ public class ProductService {
|
||||
existingProduct.setSku(updatedProduct.getSku());
|
||||
existingProduct.setCategoryId(updatedProduct.getCategoryId());
|
||||
existingProduct.setUomId(updatedProduct.getUomId());
|
||||
existingProduct.setMrp(updatedProduct.getMrp());
|
||||
existingProduct.setSellingPrice(updatedProduct.getSellingPrice());
|
||||
existingProduct.setGstRate(updatedProduct.getGstRate());
|
||||
existingProduct.setHsnCode(updatedProduct.getHsnCode());
|
||||
existingProduct.setColor(updatedProduct.getColor());
|
||||
existingProduct.setSize(updatedProduct.getSize());
|
||||
existingProduct.setDimensions(updatedProduct.getDimensions());
|
||||
|
||||
existingProduct.setItemLength(updatedProduct.getItemLength());
|
||||
existingProduct.setItemWidth(updatedProduct.getItemWidth());
|
||||
existingProduct.setItemHeight(updatedProduct.getItemHeight());
|
||||
existingProduct.setDimensionUom(updatedProduct.getDimensionUom());
|
||||
|
||||
existingProduct.setPackageLength(updatedProduct.getPackageLength());
|
||||
existingProduct.setPackageWidth(updatedProduct.getPackageWidth());
|
||||
existingProduct.setPackageHeight(updatedProduct.getPackageHeight());
|
||||
existingProduct.setPackageDimensionUom(updatedProduct.getPackageDimensionUom());
|
||||
|
||||
existingProduct.setManufacturerCode(updatedProduct.getManufacturerCode());
|
||||
existingProduct.setMaterial(updatedProduct.getMaterial());
|
||||
existingProduct.setBrandName(updatedProduct.getBrandName());
|
||||
existingProduct.setCountryOfOrigin(updatedProduct.getCountryOfOrigin());
|
||||
|
||||
existingProduct.setWeightInG(updatedProduct.getWeightInG());
|
||||
existingProduct.setPackageWeightInG(updatedProduct.getPackageWeightInG());
|
||||
existingProduct.setVolumetricWeightInKg(updatedProduct.getVolumetricWeightInKg());
|
||||
|
||||
existingProduct.setPriceCalcRule(updatedProduct.getPriceCalcRule());
|
||||
existingProduct.setPurityFactor(normalizePurity(updatedProduct.getPurityFactor()));
|
||||
existingProduct.setMakingCharges(updatedProduct.getMakingCharges());
|
||||
existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType());
|
||||
existingProduct.setWastagePercentage(updatedProduct.getWastagePercentage());
|
||||
|
||||
existingProduct.setMinStock(updatedProduct.getMinStock());
|
||||
existingProduct.setReorderLevel(updatedProduct.getReorderLevel());
|
||||
existingProduct.setReorderQuantity(updatedProduct.getReorderQuantity());
|
||||
existingProduct.setTrackInventory(updatedProduct.getTrackInventory());
|
||||
|
||||
existingProduct.setIsActive(updatedProduct.getIsActive());
|
||||
existingProduct.setUpdatedAt(LocalDateTime.now());
|
||||
return productRepository.save(existingProduct);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -146,15 +146,64 @@ public class InvoiceService {
|
||||
if (shouldDeduct && invoice.getItems() != null && !invoice.getItems().isEmpty()) {
|
||||
return Flux.fromIterable(invoice.getItems())
|
||||
.concatMap(item -> {
|
||||
BigDecimal soldQty = (item.getWeight() != null && item.getWeight().compareTo(BigDecimal.ZERO) > 0)
|
||||
? item.getWeight()
|
||||
: (item.getQuantity() != null && item.getQuantity().compareTo(BigDecimal.ZERO) > 0 ? item.getQuantity() : BigDecimal.ONE);
|
||||
BigDecimal soldRate = item.getUnitPrice() != null ? item.getUnitPrice() : BigDecimal.ZERO;
|
||||
|
||||
if (item.getInventoryItemId() != null) {
|
||||
return inventoryItemRepository.findById(item.getInventoryItemId())
|
||||
.flatMap(invItem -> {
|
||||
invItem.setStatus("SOLD");
|
||||
BigDecimal currentWeight = (invItem.getGrossWeight() != null && invItem.getGrossWeight().compareTo(BigDecimal.ZERO) > 0)
|
||||
? invItem.getGrossWeight()
|
||||
: ((invItem.getNetWeight() != null && invItem.getNetWeight().compareTo(BigDecimal.ZERO) > 0)
|
||||
? invItem.getNetWeight()
|
||||
: BigDecimal.ONE);
|
||||
|
||||
BigDecimal unitCost = invItem.getPurchaseCost() != null ? invItem.getPurchaseCost() : BigDecimal.ZERO;
|
||||
BigDecimal cost = unitCost.multiply(soldQty);
|
||||
|
||||
if (currentWeight.compareTo(soldQty) > 0) {
|
||||
// Partial sale: Reduce existing available item weight
|
||||
BigDecimal remaining = currentWeight.subtract(soldQty);
|
||||
invItem.setGrossWeight(remaining);
|
||||
invItem.setNetWeight(remaining);
|
||||
invItem.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
// Create new SOLD item record
|
||||
com.kifi.api.entity.inventory.InventoryItem soldItem = com.kifi.api.entity.inventory.InventoryItem.builder()
|
||||
.userId(userId)
|
||||
.productId(invItem.getProductId())
|
||||
.vendorId(invItem.getVendorId())
|
||||
.purchaseRef(invItem.getPurchaseRef())
|
||||
.salesRef(invoice.getInvoiceNumber())
|
||||
.purchaseCost(invItem.getPurchaseCost())
|
||||
.saleRate(soldRate)
|
||||
.makingCharges(invItem.getMakingCharges())
|
||||
.sku(invItem.getSku())
|
||||
.huid(invItem.getHuid())
|
||||
.grossWeight(soldQty)
|
||||
.netWeight(soldQty)
|
||||
.photoUrl(invItem.getPhotoUrl())
|
||||
.status("SOLD")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
return inventoryItemRepository.save(invItem)
|
||||
.then(inventoryItemRepository.save(soldItem))
|
||||
.thenReturn(cost);
|
||||
} else {
|
||||
// Full sale
|
||||
invItem.setStatus("SOLD");
|
||||
invItem.setSalesRef(invoice.getInvoiceNumber());
|
||||
invItem.setSaleRate(soldRate);
|
||||
invItem.setUpdatedAt(LocalDateTime.now());
|
||||
BigDecimal cost = invItem.getPurchaseCost() != null ? invItem.getPurchaseCost() : BigDecimal.ZERO;
|
||||
return inventoryItemRepository.save(invItem).thenReturn(cost);
|
||||
}
|
||||
}).defaultIfEmpty(BigDecimal.ZERO);
|
||||
} else if (item.getProductId() != null) {
|
||||
// Product sale without explicit inventory item: adjust product stock & FIFO deduct inventory items
|
||||
InventoryMovement movement = InventoryMovement.builder()
|
||||
.userId(userId)
|
||||
.type("REDUCTION")
|
||||
@@ -167,8 +216,82 @@ public class InvoiceService {
|
||||
movementItem.setQuantity(item.getQuantity());
|
||||
movement.setItems(java.util.Collections.singletonList(movementItem));
|
||||
|
||||
return productService.adjustStock(userId, item.getProductId(), movement)
|
||||
.thenReturn(BigDecimal.ZERO);
|
||||
Mono<Void> adjustMono = productService.adjustStock(userId, item.getProductId(), movement).then();
|
||||
|
||||
Mono<BigDecimal> fifoMono = inventoryItemRepository.findByUserIdAndProductId(userId, item.getProductId())
|
||||
.filter(i -> "AVAILABLE".equalsIgnoreCase(i.getStatus()))
|
||||
.collectList()
|
||||
.flatMap(availItems -> {
|
||||
if (availItems.isEmpty()) {
|
||||
return Mono.just(BigDecimal.ZERO);
|
||||
}
|
||||
// Strict FIFO: Sort by purchase/creation date & time in ASCENDING order
|
||||
availItems.sort((a, b) -> {
|
||||
if (a.getCreatedAt() == null && b.getCreatedAt() == null) {
|
||||
return Long.compare(a.getId() != null ? a.getId() : 0L, b.getId() != null ? b.getId() : 0L);
|
||||
}
|
||||
if (a.getCreatedAt() == null) return 1;
|
||||
if (b.getCreatedAt() == null) return -1;
|
||||
int cmp = a.getCreatedAt().compareTo(b.getCreatedAt());
|
||||
return cmp != 0 ? cmp : Long.compare(a.getId() != null ? a.getId() : 0L, b.getId() != null ? b.getId() : 0L);
|
||||
});
|
||||
BigDecimal needed = soldQty;
|
||||
java.util.List<Mono<Void>> saves = new java.util.ArrayList<>();
|
||||
BigDecimal totalCost = BigDecimal.ZERO;
|
||||
|
||||
for (com.kifi.api.entity.inventory.InventoryItem invItem : availItems) {
|
||||
if (needed.compareTo(BigDecimal.ZERO) <= 0) break;
|
||||
BigDecimal itemWeight = (invItem.getGrossWeight() != null && invItem.getGrossWeight().compareTo(BigDecimal.ZERO) > 0)
|
||||
? invItem.getGrossWeight()
|
||||
: ((invItem.getNetWeight() != null && invItem.getNetWeight().compareTo(BigDecimal.ZERO) > 0)
|
||||
? invItem.getNetWeight()
|
||||
: BigDecimal.ONE);
|
||||
|
||||
BigDecimal take = needed.min(itemWeight);
|
||||
needed = needed.subtract(take);
|
||||
BigDecimal unitPurchase = invItem.getPurchaseCost() != null ? invItem.getPurchaseCost() : BigDecimal.ZERO;
|
||||
totalCost = totalCost.add(unitPurchase.multiply(take));
|
||||
|
||||
if (itemWeight.compareTo(take) > 0) {
|
||||
BigDecimal remaining = itemWeight.subtract(take);
|
||||
invItem.setGrossWeight(remaining);
|
||||
invItem.setNetWeight(remaining);
|
||||
invItem.setUpdatedAt(LocalDateTime.now());
|
||||
saves.add(inventoryItemRepository.save(invItem).then());
|
||||
|
||||
com.kifi.api.entity.inventory.InventoryItem soldRecord = com.kifi.api.entity.inventory.InventoryItem.builder()
|
||||
.userId(userId)
|
||||
.productId(invItem.getProductId())
|
||||
.vendorId(invItem.getVendorId())
|
||||
.purchaseRef(invItem.getPurchaseRef())
|
||||
.salesRef(invoice.getInvoiceNumber())
|
||||
.purchaseCost(invItem.getPurchaseCost())
|
||||
.saleRate(soldRate)
|
||||
.makingCharges(invItem.getMakingCharges())
|
||||
.sku(invItem.getSku())
|
||||
.huid(invItem.getHuid())
|
||||
.grossWeight(take)
|
||||
.netWeight(take)
|
||||
.photoUrl(invItem.getPhotoUrl())
|
||||
.status("SOLD")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
saves.add(inventoryItemRepository.save(soldRecord).then());
|
||||
} else {
|
||||
invItem.setStatus("SOLD");
|
||||
invItem.setSalesRef(invoice.getInvoiceNumber());
|
||||
invItem.setSaleRate(soldRate);
|
||||
invItem.setUpdatedAt(LocalDateTime.now());
|
||||
saves.add(inventoryItemRepository.save(invItem).then());
|
||||
}
|
||||
}
|
||||
|
||||
BigDecimal finalCost = totalCost;
|
||||
return Flux.concat(saves).then(Mono.just(finalCost));
|
||||
});
|
||||
|
||||
return adjustMono.then(fifoMono);
|
||||
}
|
||||
return Mono.just(BigDecimal.ZERO);
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,7 @@ public class PurchaseOrderService {
|
||||
.huid(item.getHuid())
|
||||
.grossWeight(item.getWeight())
|
||||
.netWeight(item.getWeight())
|
||||
.photoUrl(item.getPhotoUrl())
|
||||
.status("AVAILABLE")
|
||||
.build();
|
||||
|
||||
|
||||
@@ -208,6 +208,7 @@ CREATE TABLE IF NOT EXISTS product_categories (
|
||||
commodity_code VARCHAR(10),
|
||||
purity_factor DECIMAL(5, 4) DEFAULT 1.0,
|
||||
base_unit VARCHAR(20) DEFAULT 'pcs',
|
||||
default_length_uom VARCHAR(20) DEFAULT 'cm',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -243,12 +244,28 @@ CREATE TABLE IF NOT EXISTS products (
|
||||
barcode VARCHAR(100),
|
||||
description TEXT,
|
||||
purchase_price DECIMAL(15, 2),
|
||||
mrp DECIMAL(15, 2),
|
||||
selling_price DECIMAL(15, 2),
|
||||
gst_rate DECIMAL(5, 2),
|
||||
dimensions VARCHAR(100),
|
||||
weight DECIMAL(10, 3),
|
||||
color VARCHAR(50),
|
||||
size VARCHAR(50),
|
||||
item_length DECIMAL(10, 2),
|
||||
item_width DECIMAL(10, 2),
|
||||
item_height DECIMAL(10, 2),
|
||||
dimension_uom VARCHAR(20) DEFAULT 'cm',
|
||||
package_length DECIMAL(10, 2),
|
||||
package_width DECIMAL(10, 2),
|
||||
package_height DECIMAL(10, 2),
|
||||
package_dimension_uom VARCHAR(20) DEFAULT 'cm',
|
||||
manufacturer_code VARCHAR(100),
|
||||
material VARCHAR(100),
|
||||
brand_name VARCHAR(100),
|
||||
country_of_origin VARCHAR(100),
|
||||
weight_in_g DECIMAL(10, 3),
|
||||
package_weight_in_g DECIMAL(10, 3),
|
||||
volumetric_weight_in_kg DECIMAL(10, 3),
|
||||
price_calc_rule VARCHAR(50) DEFAULT 'MANUAL',
|
||||
purity_factor DECIMAL(5, 4),
|
||||
making_charges DECIMAL(15, 2) DEFAULT 0.0,
|
||||
@@ -256,6 +273,7 @@ CREATE TABLE IF NOT EXISTS products (
|
||||
wastage_percentage DECIMAL(5, 2) DEFAULT 0.0,
|
||||
min_stock DECIMAL(10, 2) DEFAULT 0,
|
||||
reorder_level DECIMAL(10, 2) DEFAULT 0,
|
||||
reorder_quantity DECIMAL(10, 2) DEFAULT 0,
|
||||
track_inventory BOOLEAN DEFAULT TRUE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -383,6 +401,9 @@ CREATE TABLE IF NOT EXISTS inventory_items (
|
||||
vendor_id INTEGER REFERENCES vendors(id) ON DELETE SET NULL,
|
||||
branch_id INTEGER,
|
||||
purchase_ref VARCHAR(100),
|
||||
sales_ref VARCHAR(100),
|
||||
sale_rate DECIMAL(15, 2),
|
||||
photo_url VARCHAR(1024),
|
||||
status VARCHAR(50) DEFAULT 'AVAILABLE',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -476,6 +497,76 @@ CREATE TABLE IF NOT EXISTS invoice_payments (
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credit_notes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
credit_note_number VARCHAR(100) NOT NULL,
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
return_date DATE NOT NULL,
|
||||
reason VARCHAR(100) NOT NULL,
|
||||
restock_items BOOLEAN DEFAULT TRUE,
|
||||
subtotal DECIMAL(15, 2) NOT NULL,
|
||||
tax_total DECIMAL(15, 2) DEFAULT 0.0,
|
||||
cgst_total DECIMAL(15, 2) DEFAULT 0.0,
|
||||
sgst_total DECIMAL(15, 2) DEFAULT 0.0,
|
||||
igst_total DECIMAL(15, 2) DEFAULT 0.0,
|
||||
discount_total DECIMAL(15, 2) DEFAULT 0.0,
|
||||
total_amount DECIMAL(15, 2) NOT NULL,
|
||||
refund_status VARCHAR(50) DEFAULT 'ADJUSTED_TO_AR',
|
||||
refund_wallet_id INTEGER REFERENCES wallets(id) ON DELETE SET NULL,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credit_note_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
credit_note_id INTEGER REFERENCES credit_notes(id) ON DELETE CASCADE,
|
||||
invoice_item_id INTEGER REFERENCES invoice_items(id) ON DELETE SET NULL,
|
||||
product_id INTEGER REFERENCES products(id) ON DELETE SET NULL,
|
||||
inventory_item_id INTEGER REFERENCES inventory_items(id) ON DELETE SET NULL,
|
||||
product_name VARCHAR(255),
|
||||
sku VARCHAR(100),
|
||||
huid VARCHAR(50),
|
||||
hsn_code VARCHAR(50),
|
||||
quantity DECIMAL(10, 3) NOT NULL,
|
||||
weight DECIMAL(10, 3),
|
||||
unit_price DECIMAL(15, 2) NOT NULL,
|
||||
tax_rate DECIMAL(5, 2) DEFAULT 0.0,
|
||||
cgst DECIMAL(15, 2) DEFAULT 0.0,
|
||||
sgst DECIMAL(15, 2) DEFAULT 0.0,
|
||||
igst DECIMAL(15, 2) DEFAULT 0.0,
|
||||
discount DECIMAL(15, 2) DEFAULT 0.0,
|
||||
making_charge DECIMAL(15, 2) DEFAULT 0.0,
|
||||
other_charges DECIMAL(15, 2) DEFAULT 0.0,
|
||||
total DECIMAL(15, 2) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS marketplace_settlements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
sales_channel_id INTEGER REFERENCES sales_channels(id) ON DELETE SET NULL,
|
||||
channel_name VARCHAR(100),
|
||||
settlement_ref VARCHAR(100),
|
||||
settlement_date DATE NOT NULL,
|
||||
gross_amount DECIMAL(15, 2) NOT NULL,
|
||||
commission_fee DECIMAL(15, 2) DEFAULT 0.0,
|
||||
shipping_fee DECIMAL(15, 2) DEFAULT 0.0,
|
||||
other_fees DECIMAL(15, 2) DEFAULT 0.0,
|
||||
fee_gst DECIMAL(15, 2) DEFAULT 0.0,
|
||||
tcs_gst_amount DECIMAL(15, 2) DEFAULT 0.0,
|
||||
tds_amount DECIMAL(15, 2) DEFAULT 0.0,
|
||||
total_deductions DECIMAL(15, 2) NOT NULL,
|
||||
net_payout_amount DECIMAL(15, 2) NOT NULL,
|
||||
payout_wallet_id INTEGER REFERENCES wallets(id) ON DELETE SET NULL,
|
||||
status VARCHAR(50) DEFAULT 'SETTLED',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 9. Projects & Task Management
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -510,3 +601,25 @@ CREATE TABLE IF NOT EXISTS project_task_comments (
|
||||
images TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS barcode_label_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
template_name VARCHAR(150) NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(50) DEFAULT 'PRODUCT',
|
||||
version INTEGER DEFAULT 1,
|
||||
label_width_mm DOUBLE PRECISION NOT NULL DEFAULT 50.0,
|
||||
label_height_mm DOUBLE PRECISION NOT NULL DEFAULT 25.0,
|
||||
measurement_unit VARCHAR(10) DEFAULT 'mm',
|
||||
printer_language VARCHAR(20) DEFAULT 'ZPL',
|
||||
dpi INTEGER DEFAULT 203,
|
||||
configuration_json TEXT NOT NULL DEFAULT '{}',
|
||||
template_json TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_barcode_templates_user ON barcode_label_templates(user_id);
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
flutter_icons:
|
||||
android: true
|
||||
ios: true
|
||||
web:
|
||||
generate: true
|
||||
image_path: "assets/icon.png"
|
||||
image_path: "assets/icon.png"
|
||||
|
||||
@@ -26,6 +26,8 @@ class DesktopSidebar extends ConsumerWidget {
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final isBusinessMode = ref.watch(businessModeProvider);
|
||||
final businessProfile = ref.watch(businessProfileProvider).asData?.value;
|
||||
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||
final isJewellery = nature == 'JEWELLERY';
|
||||
final ratesAsync = ref.watch(commodityRatesProvider);
|
||||
|
||||
return Container(
|
||||
@@ -51,22 +53,23 @@ class DesktopSidebar extends ConsumerWidget {
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFD4AF37), Color(0xFFAA771C)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFFD4AF37).withValues(alpha: 0.3),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(LucideIcons.gem, color: Colors.white, size: 22),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.asset(
|
||||
'assets/icon.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -276,7 +279,8 @@ class DesktopSidebar extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// 4. Live Metal Rate Widget in Sidebar
|
||||
// 4. Live Metal Rate Widget in Sidebar (Jewellery only)
|
||||
if (isJewellery)
|
||||
ratesAsync.when(
|
||||
data: (rates) {
|
||||
if (rates.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
@@ -63,10 +63,13 @@ class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
|
||||
_filterItems(_controller.text);
|
||||
}
|
||||
if (widget.value != oldWidget.value) {
|
||||
if (widget.value != null) {
|
||||
_controller.text = widget.itemAsString(widget.value as T);
|
||||
} else {
|
||||
_controller.text = '';
|
||||
final newText = widget.value != null ? widget.itemAsString(widget.value as T) : '';
|
||||
if (_controller.text != newText) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _controller.text != newText) {
|
||||
_controller.text = newText;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,19 +131,28 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
Center(
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
child: Center(
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Image.asset(
|
||||
'assets/logo.png',
|
||||
'assets/icon.png',
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.contain,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -12,8 +12,7 @@ class SetupWizardScreen extends StatefulWidget {
|
||||
State<SetupWizardScreen> createState() => _SetupWizardScreenState();
|
||||
}
|
||||
|
||||
class _SetupWizardScreenState extends State<SetupWizardScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
class _SetupWizardScreenState extends State<SetupWizardScreen> {
|
||||
bool _isLoading = false;
|
||||
int _currentStep = 0;
|
||||
String _accountType = 'INDIVIDUAL';
|
||||
@@ -40,6 +39,19 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
|
||||
|
||||
int get _totalSteps => _accountType == 'BUSINESS' ? 4 : 3;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_usernameController.dispose();
|
||||
_businessNameController.dispose();
|
||||
_addressController.dispose();
|
||||
_emailController.dispose();
|
||||
_gstController.dispose();
|
||||
_panController.dispose();
|
||||
_msmeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkUsername(String username) async {
|
||||
if (username.length < 3) return;
|
||||
setState(() => _checkingUsername = true);
|
||||
@@ -111,6 +123,7 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
|
||||
}
|
||||
|
||||
void _nextStep() {
|
||||
FocusScope.of(context).unfocus();
|
||||
if (_currentStep == 1) {
|
||||
if (_nameController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -150,6 +163,7 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
|
||||
}
|
||||
|
||||
void _prevStep() {
|
||||
FocusScope.of(context).unfocus();
|
||||
if (_currentStep > 0) {
|
||||
setState(() => _currentStep--);
|
||||
}
|
||||
@@ -209,7 +223,6 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
|
||||
|
||||
Widget _buildStep0AccountType() {
|
||||
return Column(
|
||||
key: const ValueKey(0),
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
@@ -324,7 +337,6 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
|
||||
|
||||
Widget _buildStep1Personal() {
|
||||
return Column(
|
||||
key: const ValueKey(1),
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
@@ -354,6 +366,7 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: _checkingUsername
|
||||
? const SizedBox(
|
||||
key: ValueKey('user_check_spinner'),
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
@@ -362,7 +375,7 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
|
||||
_isUsernameAvailable
|
||||
? LucideIcons.checkCircle2
|
||||
: LucideIcons.xCircle,
|
||||
key: ValueKey(_isUsernameAvailable),
|
||||
key: ValueKey('user_avail_${_isUsernameAvailable}'),
|
||||
color: _usernameController.text.isEmpty
|
||||
? Colors.transparent
|
||||
: (_isUsernameAvailable
|
||||
@@ -388,7 +401,6 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
|
||||
|
||||
Widget _buildStep2Business() {
|
||||
return Column(
|
||||
key: const ValueKey(2),
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
@@ -410,6 +422,10 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
|
||||
value: 'JEWELLERY',
|
||||
child: Text('Jewellery', style: TextStyle(color: Colors.black87)),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'ECOMMERCE',
|
||||
child: Text('E-Commerce Seller', style: TextStyle(color: Colors.black87)),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'PROJECT_MANAGEMENT',
|
||||
child: Text(
|
||||
@@ -474,7 +490,6 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
|
||||
|
||||
Widget _buildStep3Consent() {
|
||||
return Column(
|
||||
key: const ValueKey(3),
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
@@ -556,12 +571,11 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentStep() {
|
||||
if (_currentStep == 0) return _buildStep0AccountType();
|
||||
if (_currentStep == 1) return _buildStep1Personal();
|
||||
if (_accountType == 'BUSINESS' && _currentStep == 2)
|
||||
return _buildStep2Business();
|
||||
return _buildStep3Consent(); // Step 2 (Individual) or Step 3 (Business)
|
||||
int get _stackIndex {
|
||||
if (_accountType == 'INDIVIDUAL' && _currentStep == 2) {
|
||||
return 3;
|
||||
}
|
||||
return _currentStep;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -587,28 +601,38 @@ class _SetupWizardScreenState extends State<SetupWizardScreen>
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.05, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: FadeTransition(opacity: animation, child: child),
|
||||
);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
key: ValueKey(_currentStep),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: IndexedStack(
|
||||
index: _stackIndex,
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24.0,
|
||||
vertical: 16.0,
|
||||
),
|
||||
child: _buildCurrentStep(),
|
||||
child: _buildStep0AccountType(),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24.0,
|
||||
vertical: 16.0,
|
||||
),
|
||||
child: _buildStep1Personal(),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24.0,
|
||||
vertical: 16.0,
|
||||
),
|
||||
child: _buildStep2Business(),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24.0,
|
||||
vertical: 16.0,
|
||||
),
|
||||
child: _buildStep3Consent(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ class BusinessFeature {
|
||||
final String bomReductionStrategy;
|
||||
final bool stockDeductionOnInvoice;
|
||||
final String barcodeSource;
|
||||
final bool enableEcommerceChannels;
|
||||
final DateTime? createdAt;
|
||||
|
||||
BusinessFeature({
|
||||
@@ -20,6 +21,7 @@ class BusinessFeature {
|
||||
this.bomReductionStrategy = 'COMPONENTS_ONLY',
|
||||
this.stockDeductionOnInvoice = true,
|
||||
this.barcodeSource = 'SKU',
|
||||
this.enableEcommerceChannels = false,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
@@ -34,6 +36,7 @@ class BusinessFeature {
|
||||
bomReductionStrategy: json['bomReductionStrategy'] ?? 'COMPONENTS_ONLY',
|
||||
stockDeductionOnInvoice: json['stockDeductionOnInvoice'] ?? true,
|
||||
barcodeSource: json['barcodeSource'] ?? 'SKU',
|
||||
enableEcommerceChannels: json['enableEcommerceChannels'] ?? json['enable_ecommerce_channels'] ?? false,
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'])
|
||||
: null,
|
||||
@@ -51,6 +54,7 @@ class BusinessFeature {
|
||||
'bomReductionStrategy': bomReductionStrategy,
|
||||
'stockDeductionOnInvoice': stockDeductionOnInvoice,
|
||||
'barcodeSource': barcodeSource,
|
||||
'enableEcommerceChannels': enableEcommerceChannels,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,6 +68,7 @@ class BusinessFeature {
|
||||
String? bomReductionStrategy,
|
||||
bool? stockDeductionOnInvoice,
|
||||
String? barcodeSource,
|
||||
bool? enableEcommerceChannels,
|
||||
}) {
|
||||
return BusinessFeature(
|
||||
id: id ?? this.id,
|
||||
@@ -76,6 +81,8 @@ class BusinessFeature {
|
||||
stockDeductionOnInvoice:
|
||||
stockDeductionOnInvoice ?? this.stockDeductionOnInvoice,
|
||||
barcodeSource: barcodeSource ?? this.barcodeSource,
|
||||
enableEcommerceChannels:
|
||||
enableEcommerceChannels ?? this.enableEcommerceChannels,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ class BusinessProfile {
|
||||
final String? emailId;
|
||||
final String? panNumber;
|
||||
final String? gstin;
|
||||
final String? natureOfBusiness;
|
||||
final String? msmeNumber;
|
||||
|
||||
BusinessProfile({
|
||||
this.id,
|
||||
@@ -29,13 +31,15 @@ class BusinessProfile {
|
||||
this.emailId,
|
||||
this.panNumber,
|
||||
this.gstin,
|
||||
this.natureOfBusiness,
|
||||
this.msmeNumber,
|
||||
});
|
||||
|
||||
factory BusinessProfile.fromJson(Map<String, dynamic> json) {
|
||||
return BusinessProfile(
|
||||
id: json['id'],
|
||||
userId: json['userId'],
|
||||
businessName: json['businessName'],
|
||||
businessName: json['businessName'] ?? '',
|
||||
industry: json['industry'],
|
||||
taxNumber: json['taxNumber'],
|
||||
currency: json['currency'],
|
||||
@@ -47,6 +51,8 @@ class BusinessProfile {
|
||||
emailId: json['emailId'],
|
||||
panNumber: json['panNumber'],
|
||||
gstin: json['gstin'],
|
||||
natureOfBusiness: json['natureOfBusiness'] ?? json['industry'],
|
||||
msmeNumber: json['msmeNumber'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +72,8 @@ class BusinessProfile {
|
||||
'emailId': emailId,
|
||||
'panNumber': panNumber,
|
||||
'gstin': gstin,
|
||||
'natureOfBusiness': natureOfBusiness,
|
||||
'msmeNumber': msmeNumber,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,6 +90,8 @@ class BusinessProfile {
|
||||
String? emailId,
|
||||
String? panNumber,
|
||||
String? gstin,
|
||||
String? natureOfBusiness,
|
||||
String? msmeNumber,
|
||||
}) {
|
||||
return BusinessProfile(
|
||||
id: id,
|
||||
@@ -98,6 +108,8 @@ class BusinessProfile {
|
||||
emailId: emailId ?? this.emailId,
|
||||
panNumber: panNumber ?? this.panNumber,
|
||||
gstin: gstin ?? this.gstin,
|
||||
natureOfBusiness: natureOfBusiness ?? this.natureOfBusiness,
|
||||
msmeNumber: msmeNumber ?? this.msmeNumber,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../widgets/business_profile_form_sheet.dart';
|
||||
import '../../../inventory/providers/products_provider.dart';
|
||||
import '../../../vendor/presentation/vendors_list_screen.dart';
|
||||
import '../../../vendor/presentation/purchase_orders_list_screen.dart';
|
||||
import '../widgets/utilities_section.dart';
|
||||
|
||||
class BusinessHubScreen extends ConsumerWidget {
|
||||
const BusinessHubScreen({super.key});
|
||||
@@ -34,6 +35,8 @@ class BusinessHubScreen extends ConsumerWidget {
|
||||
final bool showInventory = featureState?.inventoryManagement ?? false;
|
||||
final bool showSales = featureState?.salesManagement ?? false;
|
||||
final bool showPurchase = featureState?.purchaseManagement ?? false;
|
||||
final nature = (profile?.natureOfBusiness ?? profile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||
final bool isJewellery = nature == 'JEWELLERY';
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
@@ -200,7 +203,7 @@ class BusinessHubScreen extends ConsumerWidget {
|
||||
),
|
||||
isDesktop: isDesktop,
|
||||
),
|
||||
if (showInventory)
|
||||
if (showInventory && isJewellery)
|
||||
_buildActionCard(
|
||||
context,
|
||||
'Daily Rates',
|
||||
@@ -231,6 +234,8 @@ class BusinessHubScreen extends ConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const UtilitiesSection(),
|
||||
if (showInventory) ...[
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../providers/business_provider.dart';
|
||||
import '../../../sales/presentation/sales_channels_screen.dart';
|
||||
|
||||
class BusinessSettingsScreen extends ConsumerStatefulWidget {
|
||||
const BusinessSettingsScreen({super.key});
|
||||
@@ -176,6 +177,58 @@ class _BusinessSettingsScreenState
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final businessProfile = ref.watch(businessProfileProvider).value;
|
||||
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||
final isJewellery = nature == 'JEWELLERY';
|
||||
final isChannelEnabled = featureState.value?.enableEcommerceChannels ?? (!isJewellery);
|
||||
|
||||
return SwitchListTile(
|
||||
title: const Text('Enable E-Commerce & Marketplace Channels'),
|
||||
subtitle: const Text(
|
||||
'Track multi-channel sales across Amazon, Flipkart, Shopify, etc. in Invoices.',
|
||||
),
|
||||
value: isChannelEnabled,
|
||||
secondary: const Icon(LucideIcons.shoppingBag),
|
||||
onChanged: (val) {
|
||||
if (featureState.value != null) {
|
||||
final updated = featureState.value!.copyWith(
|
||||
enableEcommerceChannels: val,
|
||||
);
|
||||
ref
|
||||
.read(businessFeatureProvider.notifier)
|
||||
.updateFeatures(updated);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final featureState = ref.watch(businessFeatureProvider);
|
||||
final businessProfile = ref.watch(businessProfileProvider).value;
|
||||
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||
final isJewellery = nature == 'JEWELLERY';
|
||||
final isChannelEnabled = featureState.value?.enableEcommerceChannels ?? (!isJewellery);
|
||||
|
||||
if (!isChannelEnabled) return const SizedBox.shrink();
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(LucideIcons.store, color: Colors.blue),
|
||||
title: const Text('Manage Sales Channels', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: const Text('Configure marketplace channels and custom platforms'),
|
||||
trailing: const Icon(LucideIcons.chevronRight, size: 18),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SalesChannelsScreen()),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ class InventoryItem {
|
||||
final int? vendorId;
|
||||
final int? branchId;
|
||||
final String? purchaseRef;
|
||||
final String? salesRef;
|
||||
final double? saleRate;
|
||||
final String? photoUrl;
|
||||
final String? status;
|
||||
final DateTime? createdAt;
|
||||
|
||||
@@ -47,6 +50,9 @@ class InventoryItem {
|
||||
this.vendorId,
|
||||
this.branchId,
|
||||
this.purchaseRef,
|
||||
this.salesRef,
|
||||
this.saleRate,
|
||||
this.photoUrl,
|
||||
this.status,
|
||||
this.createdAt,
|
||||
});
|
||||
@@ -75,6 +81,9 @@ class InventoryItem {
|
||||
vendorId: json['vendorId'],
|
||||
branchId: json['branchId'],
|
||||
purchaseRef: json['purchaseRef'],
|
||||
salesRef: json['salesRef'] ?? json['sales_ref'],
|
||||
saleRate: (json['saleRate'] ?? json['sale_rate'])?.toDouble(),
|
||||
photoUrl: json['photoUrl'] ?? json['photo_url'],
|
||||
status: json['status'],
|
||||
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
|
||||
);
|
||||
|
||||
@@ -9,17 +9,44 @@ class Product {
|
||||
final String? sku;
|
||||
final String? barcode;
|
||||
final String? description;
|
||||
final double? mrp;
|
||||
final double? sellingPrice;
|
||||
final double? gstRate;
|
||||
final String? dimensions;
|
||||
final String? color;
|
||||
final String? size;
|
||||
|
||||
final double? itemLength;
|
||||
final double? itemWidth;
|
||||
final double? itemHeight;
|
||||
final String? dimensionUom;
|
||||
|
||||
final double? packageLength;
|
||||
final double? packageWidth;
|
||||
final double? packageHeight;
|
||||
final String? packageDimensionUom;
|
||||
|
||||
final String? manufacturerCode;
|
||||
final String? material;
|
||||
final String? brandName;
|
||||
final String? countryOfOrigin;
|
||||
|
||||
final double? weightInG;
|
||||
final double? packageWeightInG;
|
||||
final double? volumetricWeightInKg;
|
||||
|
||||
final String priceCalcRule;
|
||||
final bool autoCalculatePrice;
|
||||
final double? purityFactor;
|
||||
final double? makingCharges;
|
||||
final String? makingChargesType;
|
||||
final double? wastagePercentage;
|
||||
|
||||
final double? minStock;
|
||||
final double? reorderLevel;
|
||||
final double? reorderQuantity;
|
||||
final bool trackInventory;
|
||||
|
||||
final bool isActive;
|
||||
final List<int> imageIds;
|
||||
final double? currentStock;
|
||||
@@ -34,55 +61,98 @@ class Product {
|
||||
this.sku,
|
||||
this.barcode,
|
||||
this.description,
|
||||
this.mrp,
|
||||
this.sellingPrice,
|
||||
this.gstRate,
|
||||
this.dimensions,
|
||||
this.color,
|
||||
this.size,
|
||||
this.itemLength,
|
||||
this.itemWidth,
|
||||
this.itemHeight,
|
||||
this.dimensionUom = 'cm',
|
||||
this.packageLength,
|
||||
this.packageWidth,
|
||||
this.packageHeight,
|
||||
this.packageDimensionUom = 'cm',
|
||||
this.manufacturerCode,
|
||||
this.material,
|
||||
this.brandName,
|
||||
this.countryOfOrigin,
|
||||
this.weightInG,
|
||||
this.packageWeightInG,
|
||||
this.volumetricWeightInKg,
|
||||
this.priceCalcRule = 'MANUAL',
|
||||
this.autoCalculatePrice = false,
|
||||
this.purityFactor,
|
||||
this.makingCharges = 0.0,
|
||||
this.makingChargesType = 'FLAT',
|
||||
this.wastagePercentage = 0.0,
|
||||
this.minStock = 0.0,
|
||||
this.reorderLevel = 0.0,
|
||||
this.reorderQuantity = 0.0,
|
||||
this.trackInventory = true,
|
||||
this.isActive = true,
|
||||
this.imageIds = const [],
|
||||
this.currentStock = 0.0,
|
||||
});
|
||||
|
||||
bool get isOutOfStock => trackInventory && (currentStock == null || currentStock! <= 0);
|
||||
|
||||
bool get isLowStock {
|
||||
final alertThreshold = (reorderLevel != null && reorderLevel! > 0)
|
||||
? reorderLevel!
|
||||
: (minStock != null && minStock! > 0 ? minStock! : 0.0);
|
||||
if (!trackInventory || alertThreshold <= 0) return false;
|
||||
return currentStock != null && currentStock! > 0 && currentStock! <= alertThreshold;
|
||||
}
|
||||
|
||||
factory Product.fromJson(Map<String, dynamic> json) {
|
||||
return Product(
|
||||
id: json['id'],
|
||||
userId: json['userId'] ?? json['user_id'],
|
||||
categoryId: json['categoryId'] ?? json['category_id'],
|
||||
uomId: json['uomId'] ?? json['uom_id'],
|
||||
name: json['name'],
|
||||
name: json['name'] ?? '',
|
||||
hsnCode: json['hsnCode'] ?? json['hsn_code'],
|
||||
sku: json['sku'],
|
||||
barcode: json['barcode'],
|
||||
description: json['description'],
|
||||
sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?)
|
||||
?.toDouble(),
|
||||
mrp: json['mrp'] != null
|
||||
? double.tryParse(json['mrp'].toString())
|
||||
: (json['mrp_price'] != null ? double.tryParse(json['mrp_price'].toString()) : null),
|
||||
sellingPrice: json['sellingPrice'] != null
|
||||
? double.tryParse(json['sellingPrice'].toString())
|
||||
: (json['selling_price'] != null ? double.tryParse(json['selling_price'].toString()) : null),
|
||||
gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(),
|
||||
dimensions: json['dimensions'],
|
||||
color: json['color'],
|
||||
size: json['size'],
|
||||
priceCalcRule:
|
||||
json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL',
|
||||
autoCalculatePrice:
|
||||
json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false,
|
||||
purityFactor:
|
||||
(json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble(),
|
||||
makingCharges:
|
||||
(json['makingCharges'] ?? json['making_charges'] as num?)
|
||||
?.toDouble() ??
|
||||
0.0,
|
||||
makingChargesType:
|
||||
json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT',
|
||||
wastagePercentage:
|
||||
(json['wastagePercentage'] ?? json['wastage_percentage'] as num?)
|
||||
?.toDouble() ??
|
||||
0.0,
|
||||
itemLength: (json['itemLength'] ?? json['item_length'] as num?)?.toDouble(),
|
||||
itemWidth: (json['itemWidth'] ?? json['item_width'] as num?)?.toDouble(),
|
||||
itemHeight: (json['itemHeight'] ?? json['item_height'] as num?)?.toDouble(),
|
||||
dimensionUom: json['dimensionUom'] ?? json['dimension_uom'] ?? 'cm',
|
||||
packageLength: (json['packageLength'] ?? json['package_length'] as num?)?.toDouble(),
|
||||
packageWidth: (json['packageWidth'] ?? json['package_width'] as num?)?.toDouble(),
|
||||
packageHeight: (json['packageHeight'] ?? json['package_height'] as num?)?.toDouble(),
|
||||
packageDimensionUom: json['packageDimensionUom'] ?? json['package_dimension_uom'] ?? 'cm',
|
||||
manufacturerCode: json['manufacturerCode'] ?? json['manufacturer_code'],
|
||||
material: json['material'],
|
||||
brandName: json['brandName'] ?? json['brand_name'],
|
||||
countryOfOrigin: json['countryOfOrigin'] ?? json['country_of_origin'],
|
||||
weightInG: (json['weightInG'] ?? json['weight_in_g'] as num?)?.toDouble(),
|
||||
packageWeightInG: (json['packageWeightInG'] ?? json['package_weight_in_g'] as num?)?.toDouble(),
|
||||
volumetricWeightInKg: (json['volumetricWeightInKg'] ?? json['volumetric_weight_in_kg'] as num?)?.toDouble(),
|
||||
priceCalcRule: json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL',
|
||||
autoCalculatePrice: json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false,
|
||||
purityFactor: (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble(),
|
||||
makingCharges: (json['makingCharges'] ?? json['making_charges'] as num?)?.toDouble() ?? 0.0,
|
||||
makingChargesType: json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT',
|
||||
wastagePercentage: (json['wastagePercentage'] ?? json['wastage_percentage'] as num?)?.toDouble() ?? 0.0,
|
||||
minStock: (json['minStock'] ?? json['min_stock'] as num?)?.toDouble() ?? 0.0,
|
||||
reorderLevel: (json['reorderLevel'] ?? json['reorder_level'] as num?)?.toDouble() ?? 0.0,
|
||||
reorderQuantity: (json['reorderQuantity'] ?? json['reorder_quantity'] as num?)?.toDouble() ?? 0.0,
|
||||
trackInventory: json['trackInventory'] ?? json['track_inventory'] ?? true,
|
||||
isActive: json['isActive'] ?? json['is_active'] ?? true,
|
||||
imageIds: json['images'] != null
|
||||
? (json['images'] as List).map((i) {
|
||||
@@ -94,9 +164,7 @@ class Product {
|
||||
return 0;
|
||||
}).where((id) => id > 0).toList()
|
||||
: [],
|
||||
currentStock:
|
||||
(json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ??
|
||||
0.0,
|
||||
currentStock: (json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ?? 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,17 +179,37 @@ class Product {
|
||||
'sku': sku,
|
||||
'barcode': barcode,
|
||||
'description': description,
|
||||
'mrp': mrp,
|
||||
'sellingPrice': sellingPrice,
|
||||
'gstRate': gstRate,
|
||||
'dimensions': dimensions,
|
||||
'color': color,
|
||||
'size': size,
|
||||
'itemLength': itemLength,
|
||||
'itemWidth': itemWidth,
|
||||
'itemHeight': itemHeight,
|
||||
'dimensionUom': dimensionUom,
|
||||
'packageLength': packageLength,
|
||||
'packageWidth': packageWidth,
|
||||
'packageHeight': packageHeight,
|
||||
'packageDimensionUom': packageDimensionUom,
|
||||
'manufacturerCode': manufacturerCode,
|
||||
'material': material,
|
||||
'brandName': brandName,
|
||||
'countryOfOrigin': countryOfOrigin,
|
||||
'weightInG': weightInG,
|
||||
'packageWeightInG': packageWeightInG,
|
||||
'volumetricWeightInKg': volumetricWeightInKg,
|
||||
'priceCalcRule': priceCalcRule,
|
||||
'autoCalculatePrice': autoCalculatePrice,
|
||||
'purityFactor': purityFactor,
|
||||
'makingCharges': makingCharges,
|
||||
'makingChargesType': makingChargesType,
|
||||
'wastagePercentage': wastagePercentage,
|
||||
'minStock': minStock,
|
||||
'reorderLevel': reorderLevel,
|
||||
'reorderQuantity': reorderQuantity,
|
||||
'trackInventory': trackInventory,
|
||||
'isActive': isActive,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _hsnController = TextEditingController();
|
||||
final _gstController = TextEditingController();
|
||||
final _mrpController = TextEditingController();
|
||||
final _sellingPriceController = TextEditingController();
|
||||
final _makingChargesController = TextEditingController();
|
||||
String _makingChargesType = 'PER_GRAM';
|
||||
|
||||
@@ -36,8 +38,32 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
ProductCategory? _selectedCategory;
|
||||
int? _uomId;
|
||||
|
||||
// Properties
|
||||
String _color = '';
|
||||
// Specifications
|
||||
final _colorController = TextEditingController();
|
||||
final _manufacturerCodeController = TextEditingController();
|
||||
final _materialController = TextEditingController();
|
||||
final _brandNameController = TextEditingController();
|
||||
final _countryOfOriginController = TextEditingController();
|
||||
|
||||
// Item Dimensions & Weight
|
||||
final _itemLengthController = TextEditingController();
|
||||
final _itemWidthController = TextEditingController();
|
||||
final _itemHeightController = TextEditingController();
|
||||
String _dimensionUom = 'cm';
|
||||
final _weightInGController = TextEditingController();
|
||||
|
||||
// Package Dimensions & Weight
|
||||
final _packageLengthController = TextEditingController();
|
||||
final _packageWidthController = TextEditingController();
|
||||
final _packageHeightController = TextEditingController();
|
||||
String _packageDimensionUom = 'cm';
|
||||
final _packageWeightInGController = TextEditingController();
|
||||
double? _volumetricWeightInKg;
|
||||
|
||||
// Inventory & Reorder Thresholds
|
||||
final _minStockController = TextEditingController();
|
||||
final _reorderQuantityController = TextEditingController();
|
||||
bool _trackInventory = true;
|
||||
|
||||
// Media
|
||||
final List<XFile> _images = [];
|
||||
@@ -58,11 +84,39 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
_sku = p.sku ?? '';
|
||||
_hsnController.text = p.hsnCode ?? '';
|
||||
_uomId = p.uomId;
|
||||
_color = p.color ?? '';
|
||||
_gstController.text = p.gstRate != null && p.gstRate! > 0 ? p.gstRate.toString() : '';
|
||||
_mrpController.text = p.mrp != null && p.mrp! > 0 ? p.mrp.toString() : '';
|
||||
_sellingPriceController.text = p.sellingPrice != null && p.sellingPrice! > 0 ? p.sellingPrice.toString() : '';
|
||||
_makingChargesController.text = p.makingCharges != null && p.makingCharges! > 0 ? p.makingCharges!.toString() : '';
|
||||
_makingChargesType = p.makingChargesType ?? 'PER_GRAM';
|
||||
|
||||
_colorController.text = p.color ?? '';
|
||||
_manufacturerCodeController.text = p.manufacturerCode ?? '';
|
||||
_materialController.text = p.material ?? '';
|
||||
_brandNameController.text = p.brandName ?? '';
|
||||
_countryOfOriginController.text = p.countryOfOrigin ?? '';
|
||||
|
||||
_itemLengthController.text = p.itemLength != null ? p.itemLength.toString() : '';
|
||||
_itemWidthController.text = p.itemWidth != null ? p.itemWidth.toString() : '';
|
||||
_itemHeightController.text = p.itemHeight != null ? p.itemHeight.toString() : '';
|
||||
_dimensionUom = p.dimensionUom ?? 'cm';
|
||||
_weightInGController.text = p.weightInG != null ? p.weightInG.toString() : '';
|
||||
|
||||
_packageLengthController.text = p.packageLength != null ? p.packageLength.toString() : '';
|
||||
_packageWidthController.text = p.packageWidth != null ? p.packageWidth.toString() : '';
|
||||
_packageHeightController.text = p.packageHeight != null ? p.packageHeight.toString() : '';
|
||||
_packageDimensionUom = p.packageDimensionUom ?? 'cm';
|
||||
_packageWeightInGController.text = p.packageWeightInG != null ? p.packageWeightInG.toString() : '';
|
||||
_volumetricWeightInKg = p.volumetricWeightInKg;
|
||||
if (_volumetricWeightInKg == null) {
|
||||
_calculateVolumetricWeight();
|
||||
}
|
||||
|
||||
final alertQty = (p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock;
|
||||
_minStockController.text = alertQty != null && alertQty > 0 ? alertQty.toString() : '';
|
||||
_reorderQuantityController.text = p.reorderQuantity != null && p.reorderQuantity! > 0 ? p.reorderQuantity.toString() : '';
|
||||
_trackInventory = p.trackInventory;
|
||||
|
||||
if (p.imageIds.isNotEmpty) {
|
||||
_existingImageIds.addAll(p.imageIds);
|
||||
}
|
||||
@@ -82,10 +136,61 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
void dispose() {
|
||||
_hsnController.dispose();
|
||||
_gstController.dispose();
|
||||
_mrpController.dispose();
|
||||
_sellingPriceController.dispose();
|
||||
_makingChargesController.dispose();
|
||||
_colorController.dispose();
|
||||
_manufacturerCodeController.dispose();
|
||||
_materialController.dispose();
|
||||
_brandNameController.dispose();
|
||||
_countryOfOriginController.dispose();
|
||||
_itemLengthController.dispose();
|
||||
_itemWidthController.dispose();
|
||||
_itemHeightController.dispose();
|
||||
_weightInGController.dispose();
|
||||
_packageLengthController.dispose();
|
||||
_packageWidthController.dispose();
|
||||
_packageHeightController.dispose();
|
||||
_packageWeightInGController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _calculateVolumetricWeight() {
|
||||
final l = double.tryParse(_packageLengthController.text) ?? 0.0;
|
||||
final w = double.tryParse(_packageWidthController.text) ?? 0.0;
|
||||
final h = double.tryParse(_packageHeightController.text) ?? 0.0;
|
||||
|
||||
if (l > 0 && w > 0 && h > 0) {
|
||||
double factor = 1.0;
|
||||
switch (_packageDimensionUom.toLowerCase()) {
|
||||
case 'in':
|
||||
factor = 2.54;
|
||||
break;
|
||||
case 'mm':
|
||||
factor = 0.1;
|
||||
break;
|
||||
case 'm':
|
||||
factor = 100.0;
|
||||
break;
|
||||
case 'cm':
|
||||
default:
|
||||
factor = 1.0;
|
||||
break;
|
||||
}
|
||||
final lCm = l * factor;
|
||||
final wCm = w * factor;
|
||||
final hCm = h * factor;
|
||||
final volKg = (lCm * wCm * hCm) / 5000.0;
|
||||
setState(() {
|
||||
_volumetricWeightInKg = double.parse(volKg.toStringAsFixed(3));
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_volumetricWeightInKg = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickImages() async {
|
||||
if ((_images.length + _existingImageIds.length) >= 4) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed')));
|
||||
@@ -148,16 +253,37 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
final product = Product(
|
||||
id: widget.product?.id,
|
||||
name: _name,
|
||||
hsnCode: _hsnController.text.isNotEmpty ? _hsnController.text : null,
|
||||
sku: _sku.isNotEmpty ? _sku : null,
|
||||
hsnCode: _hsnController.text.isNotEmpty ? _hsnController.text.trim() : null,
|
||||
sku: _sku.isNotEmpty ? _sku.trim() : null,
|
||||
categoryId: _selectedCategory?.id,
|
||||
uomId: _uomId,
|
||||
color: _color.isNotEmpty ? _color : null,
|
||||
color: _colorController.text.isNotEmpty ? _colorController.text.trim() : null,
|
||||
mrp: double.tryParse(_mrpController.text),
|
||||
sellingPrice: double.tryParse(_sellingPriceController.text),
|
||||
gstRate: double.tryParse(_gstController.text) ?? 0,
|
||||
makingCharges: double.tryParse(_makingChargesController.text) ?? 0.0,
|
||||
makingChargesType: _makingChargesType,
|
||||
priceCalcRule: 'MANUAL',
|
||||
autoCalculatePrice: false,
|
||||
itemLength: double.tryParse(_itemLengthController.text),
|
||||
itemWidth: double.tryParse(_itemWidthController.text),
|
||||
itemHeight: double.tryParse(_itemHeightController.text),
|
||||
dimensionUom: _dimensionUom,
|
||||
packageLength: double.tryParse(_packageLengthController.text),
|
||||
packageWidth: double.tryParse(_packageWidthController.text),
|
||||
packageHeight: double.tryParse(_packageHeightController.text),
|
||||
packageDimensionUom: _packageDimensionUom,
|
||||
manufacturerCode: _manufacturerCodeController.text.isNotEmpty ? _manufacturerCodeController.text.trim() : null,
|
||||
material: _materialController.text.isNotEmpty ? _materialController.text.trim() : null,
|
||||
brandName: _brandNameController.text.isNotEmpty ? _brandNameController.text.trim() : null,
|
||||
countryOfOrigin: _countryOfOriginController.text.isNotEmpty ? _countryOfOriginController.text.trim() : null,
|
||||
weightInG: double.tryParse(_weightInGController.text),
|
||||
packageWeightInG: double.tryParse(_packageWeightInGController.text),
|
||||
volumetricWeightInKg: _volumetricWeightInKg,
|
||||
minStock: double.tryParse(_minStockController.text) ?? 0.0,
|
||||
reorderLevel: double.tryParse(_minStockController.text) ?? 0.0,
|
||||
reorderQuantity: double.tryParse(_reorderQuantityController.text) ?? 0.0,
|
||||
trackInventory: _trackInventory,
|
||||
);
|
||||
|
||||
if (widget.product != null) {
|
||||
@@ -177,6 +303,9 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final categoriesState = ref.watch(productCategoriesProvider);
|
||||
final uomsState = ref.watch(uomsProvider);
|
||||
final businessProfile = ref.watch(businessProfileProvider).value;
|
||||
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||
final isJewellery = nature == 'JEWELLERY';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
@@ -275,10 +404,28 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
if (val.makingChargeType != null && val.makingChargeType!.isNotEmpty) {
|
||||
_makingChargesType = val.makingChargeType!;
|
||||
}
|
||||
if (_uomId == null && uoms.isNotEmpty) {
|
||||
if (val.defaultLengthUom.isNotEmpty) {
|
||||
_dimensionUom = val.defaultLengthUom;
|
||||
_packageDimensionUom = val.defaultLengthUom;
|
||||
_calculateVolumetricWeight();
|
||||
}
|
||||
if (val.baseUnit.isNotEmpty && uoms.isNotEmpty) {
|
||||
final target = val.baseUnit.trim().toLowerCase();
|
||||
final match = uoms.where((u) =>
|
||||
u.abbreviation?.toLowerCase() == val.baseUnit.toLowerCase() ||
|
||||
u.name.toLowerCase() == val.baseUnit.toLowerCase()
|
||||
(u.abbreviation != null && u.abbreviation!.trim().toLowerCase() == target) ||
|
||||
u.name.trim().toLowerCase() == target ||
|
||||
(target == 'pcs' && (u.abbreviation?.toLowerCase() == 'pcs' || u.name.toLowerCase().contains('piece'))) ||
|
||||
(target == 'unit' && (u.abbreviation?.toLowerCase() == 'unit' || u.name.toLowerCase().contains('unit'))) ||
|
||||
(target == 'box' && (u.abbreviation?.toLowerCase() == 'box' || u.name.toLowerCase().contains('box'))) ||
|
||||
(target == 'set' && (u.abbreviation?.toLowerCase() == 'set' || u.name.toLowerCase().contains('set'))) ||
|
||||
(target == 'pair' && (u.abbreviation?.toLowerCase() == 'pair' || u.name.toLowerCase().contains('pair'))) ||
|
||||
(target == 'pk' && (u.abbreviation?.toLowerCase() == 'pk' || u.name.toLowerCase().contains('pack'))) ||
|
||||
(target == 'g' && (u.abbreviation?.toLowerCase() == 'g' || u.name.toLowerCase().contains('gram'))) ||
|
||||
(target == 'kg' && (u.abbreviation?.toLowerCase() == 'kg' || u.name.toLowerCase().contains('kilogram'))) ||
|
||||
(target == 'm' && (u.abbreviation?.toLowerCase() == 'm' || u.name.toLowerCase().contains('meter'))) ||
|
||||
(target == 'l' && (u.abbreviation?.toLowerCase() == 'l' || u.name.toLowerCase().contains('liter'))) ||
|
||||
(target == 'ml' && (u.abbreviation?.toLowerCase() == 'ml' || u.name.toLowerCase().contains('milliliter'))) ||
|
||||
(target == 'doz' && (u.abbreviation?.toLowerCase() == 'doz' || u.name.toLowerCase().contains('dozen')))
|
||||
).firstOrNull;
|
||||
if (match != null) {
|
||||
_uomId = match.id;
|
||||
@@ -318,6 +465,34 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
|
||||
const Text('Pricing & Taxes', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _mrpController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'MRP (₹)',
|
||||
hintText: 'e.g. 1999',
|
||||
prefixIcon: Icon(LucideIcons.tag),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _sellingPriceController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Selling Price (₹)',
|
||||
hintText: 'e.g. 1499',
|
||||
prefixIcon: Icon(LucideIcons.badgePercent),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -336,6 +511,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (isJewellery) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
@@ -367,8 +544,310 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
if (!isJewellery) ...[
|
||||
const SizedBox(height: 24),
|
||||
const Text('Product Specifications', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _brandNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Brand Name',
|
||||
prefixIcon: Icon(LucideIcons.bookmark),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _manufacturerCodeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Manufacturer Code',
|
||||
prefixIcon: Icon(LucideIcons.factory),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _materialController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Material',
|
||||
prefixIcon: Icon(LucideIcons.layers),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _colorController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Color',
|
||||
prefixIcon: Icon(LucideIcons.palette),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _countryOfOriginController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Country of Origin',
|
||||
prefixIcon: Icon(LucideIcons.globe),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
const Text('Item Dimensions & Weight', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _itemLengthController,
|
||||
decoration: const InputDecoration(labelText: 'Length'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _itemWidthController,
|
||||
decoration: const InputDecoration(labelText: 'Width'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _itemHeightController,
|
||||
decoration: const InputDecoration(labelText: 'Height'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _dimensionUom,
|
||||
decoration: const InputDecoration(labelText: 'UoM'),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'cm', child: Text('cm')),
|
||||
DropdownMenuItem(value: 'in', child: Text('in')),
|
||||
DropdownMenuItem(value: 'mm', child: Text('mm')),
|
||||
DropdownMenuItem(value: 'm', child: Text('m')),
|
||||
],
|
||||
onChanged: (val) {
|
||||
if (val != null) setState(() => _dimensionUom = val);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _weightInGController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Item Weight (in Grams)',
|
||||
suffixText: 'g',
|
||||
prefixIcon: Icon(LucideIcons.scale),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
const Text('Packaging Dimensions & Weight', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _packageLengthController,
|
||||
decoration: const InputDecoration(labelText: 'Package Length'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (_) => _calculateVolumetricWeight(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _packageWidthController,
|
||||
decoration: const InputDecoration(labelText: 'Package Width'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (_) => _calculateVolumetricWeight(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _packageHeightController,
|
||||
decoration: const InputDecoration(labelText: 'Package Height'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (_) => _calculateVolumetricWeight(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _packageDimensionUom,
|
||||
decoration: const InputDecoration(labelText: 'UoM'),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'cm', child: Text('cm')),
|
||||
DropdownMenuItem(value: 'in', child: Text('in')),
|
||||
DropdownMenuItem(value: 'mm', child: Text('mm')),
|
||||
DropdownMenuItem(value: 'm', child: Text('m')),
|
||||
],
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() => _packageDimensionUom = val);
|
||||
_calculateVolumetricWeight();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _packageWeightInGController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Package Weight (in Grams)',
|
||||
suffixText: 'g',
|
||||
prefixIcon: Icon(LucideIcons.package),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.box, color: Theme.of(context).colorScheme.primary, size: 22),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Volumetric Weight',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_volumetricWeightInKg != null
|
||||
? '$_volumetricWeightInKg kg'
|
||||
: 'L × W × H ÷ 5000',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _volumetricWeightInKg != null
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
// Inventory & Reorder Point Alert Thresholds
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(LucideIcons.bellRing, color: Colors.orange, size: 20),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text('Inventory & Reorder Alerts', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
title: const Text('Track Inventory & Stock Alerts', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
subtitle: const Text('Notify when product stock drops below critical threshold', style: TextStyle(fontSize: 12)),
|
||||
value: _trackInventory,
|
||||
onChanged: (val) => setState(() => _trackInventory = val),
|
||||
),
|
||||
if (_trackInventory) ...[
|
||||
const Divider(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildPremiumTextField(
|
||||
label: 'Reorder Alert Level / Min Stock',
|
||||
controller: _minStockController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
prefixIcon: LucideIcons.alertTriangle,
|
||||
helperText: 'Alert when stock falls below this qty / weight',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildPremiumTextField(
|
||||
label: 'Standard Reorder Batch Qty',
|
||||
controller: _reorderQuantityController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
prefixIcon: LucideIcons.shoppingBag,
|
||||
helperText: 'Suggested replenishment batch size',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
const Text('Product Images', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
if (_images.isNotEmpty || _existingImageIds.isNotEmpty)
|
||||
@@ -448,21 +927,36 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
}
|
||||
|
||||
Widget _buildBottomBar() {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
elevation: 4,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 0,
|
||||
),
|
||||
child: _isSaving
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: Text(widget.product != null ? 'Update Product' : 'Publish Product', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: Text(
|
||||
widget.product != null ? 'Update Product' : 'Publish Product',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -473,11 +967,10 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
required bool isNetwork,
|
||||
String? url,
|
||||
XFile? xFile,
|
||||
required VoidCallback onDelete,
|
||||
required VoidCallback onTap,
|
||||
required VoidCallback onDelete,
|
||||
}) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
@@ -488,16 +981,19 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
color: Colors.grey[200],
|
||||
child: SizedBox.expand(
|
||||
child: isNetwork
|
||||
? (_token == null
|
||||
? const Icon(LucideIcons.image, color: Colors.grey)
|
||||
: Image.network(
|
||||
? (_token != null
|
||||
? Image.network(
|
||||
url!,
|
||||
fit: BoxFit.cover,
|
||||
headers: {'Authorization': 'Bearer $_token'},
|
||||
errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey),
|
||||
)
|
||||
: Image.network(
|
||||
url!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey),
|
||||
))
|
||||
: (kIsWeb
|
||||
? Image.network(xFile!.path, fit: BoxFit.cover)
|
||||
@@ -512,7 +1008,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
child: GestureDetector(
|
||||
onTap: onDelete,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.6), shape: BoxShape.circle),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: const Icon(LucideIcons.x, color: Colors.white, size: 16),
|
||||
),
|
||||
@@ -524,20 +1020,26 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
|
||||
Widget _buildPremiumTextField({
|
||||
required String label,
|
||||
TextEditingController? controller,
|
||||
String? initialValue,
|
||||
String? prefixText,
|
||||
String? suffixText,
|
||||
IconData? prefixIcon,
|
||||
String? helperText,
|
||||
TextInputType? keyboardType,
|
||||
void Function(String)? onChanged,
|
||||
void Function(String?)? onSaved,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
return TextFormField(
|
||||
initialValue: initialValue,
|
||||
controller: controller,
|
||||
initialValue: controller == null ? initialValue : null,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixText: prefixText,
|
||||
suffixText: suffixText,
|
||||
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
|
||||
helperText: helperText,
|
||||
),
|
||||
keyboardType: keyboardType,
|
||||
onChanged: onChanged,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/product_categories_provider.dart';
|
||||
import '../../../core/theme/nature_colors.dart';
|
||||
import '../providers/uoms_provider.dart';
|
||||
import '../../business/providers/business_mode_provider.dart';
|
||||
import '../../business/providers/business_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'dart:ui';
|
||||
@@ -99,6 +100,18 @@ class _CategoryManagementScreenState extends ConsumerState<CategoryManagementScr
|
||||
final children = allCategories.where((c) => c.parentCategoryId == category.id).toList();
|
||||
final isLeaf = children.isEmpty && !category.hasChild;
|
||||
|
||||
final hasCommodity = category.commodityCode != null && category.commodityCode != 'NONE' && category.commodityCode!.isNotEmpty;
|
||||
final hasHsn = category.defaultHsn != null && category.defaultHsn!.isNotEmpty;
|
||||
|
||||
String? subtitleText;
|
||||
if (hasCommodity && hasHsn) {
|
||||
subtitleText = '${category.commodityCode} • HSN: ${category.defaultHsn}';
|
||||
} else if (hasCommodity) {
|
||||
subtitleText = category.commodityCode;
|
||||
} else if (hasHsn) {
|
||||
subtitleText = 'HSN: ${category.defaultHsn}';
|
||||
}
|
||||
|
||||
// A nice frosted glass card
|
||||
return Container(
|
||||
margin: EdgeInsets.only(left: depth * 16.0, bottom: 8),
|
||||
@@ -136,8 +149,8 @@ class _CategoryManagementScreenState extends ConsumerState<CategoryManagementScr
|
||||
child: const Icon(LucideIcons.tag, size: 20, color: Colors.blue),
|
||||
),
|
||||
title: Text(category.name, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: category.commodityCode != null
|
||||
? Text('${category.commodityCode} • HSN: ${category.defaultHsn ?? "N/A"}',
|
||||
subtitle: subtitleText != null
|
||||
? Text(subtitleText,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600))
|
||||
: null,
|
||||
trailing: Row(
|
||||
@@ -186,35 +199,77 @@ class _CategoryManagementScreenState extends ConsumerState<CategoryManagementScr
|
||||
|
||||
Widget _buildCategoryPopupMenu(ProductCategory category, bool isLeaf) {
|
||||
return PopupMenuButton<String>(
|
||||
icon: const Icon(LucideIcons.moreVertical, color: Colors.grey),
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
icon: const Icon(LucideIcons.ellipsisVertical, size: 18, color: Colors.grey),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onSelected: (val) {
|
||||
if (val == 'edit') {
|
||||
_showAddCategorySheet(context, category, ref, isEdit: true);
|
||||
} else if (value == 'add_sub') {
|
||||
} else if (val == 'addSub') {
|
||||
_showAddCategorySheet(context, category, ref, isEdit: false);
|
||||
} else if (value == 'delete') {
|
||||
if (category.id != null) {
|
||||
ref.read(productCategoriesProvider.notifier).deleteCategory(category.id!);
|
||||
}
|
||||
} else if (val == 'delete') {
|
||||
_confirmDelete(category);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(children: [Icon(LucideIcons.edit, size: 18), SizedBox(width: 8), Text('Edit')]),
|
||||
),
|
||||
if (!isLeaf)
|
||||
const PopupMenuItem(
|
||||
value: 'add_sub',
|
||||
child: Row(children: [Icon(LucideIcons.plus, size: 18), SizedBox(width: 8), Text('Add Subcategory')]),
|
||||
value: 'addSub',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.folderPlus, size: 18, color: Colors.blue),
|
||||
SizedBox(width: 12),
|
||||
Text('Add Subcategory'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.pencil, size: 18, color: Colors.orange),
|
||||
SizedBox(width: 12),
|
||||
Text('Edit Details'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(children: [Icon(LucideIcons.trash2, size: 18, color: Colors.red), SizedBox(width: 8), Text('Delete', style: TextStyle(color: Colors.red))]),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.trash2, size: 18, color: Colors.red),
|
||||
SizedBox(width: 12),
|
||||
Text('Delete Category', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(ProductCategory category) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Text('Delete Category?'),
|
||||
content: Text('Are you sure you want to delete "${category.name}"? If it has subcategories or products, they will also be affected.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
|
||||
onPressed: () {
|
||||
ref.read(productCategoriesProvider.notifier).deleteCategory(category.id!);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showAddCategorySheet(BuildContext context, ProductCategory? parentOrCategory, WidgetRef ref, {bool isEdit = false}) {
|
||||
@@ -251,7 +306,8 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
|
||||
bool _huidRequired = false;
|
||||
String _commodityCode = 'XAU';
|
||||
String _makingChargeType = 'PER_GRAM';
|
||||
String _baseUnit = 'g';
|
||||
String _baseUnit = 'pcs';
|
||||
String _defaultLengthUom = 'cm';
|
||||
|
||||
final List<Map<String, String>> _commodities = [
|
||||
{'code': 'XAU', 'label': 'XAU - Gold'},
|
||||
@@ -264,6 +320,14 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final profile = ref.read(businessProfileProvider).value;
|
||||
final nature = (profile?.natureOfBusiness ?? profile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||
final isJewellery = nature == 'JEWELLERY';
|
||||
|
||||
_baseUnit = isJewellery ? 'g' : 'pcs';
|
||||
_commodityCode = isJewellery ? 'XAU' : 'NONE';
|
||||
_defaultLengthUom = 'cm';
|
||||
|
||||
if (widget.categoryToEdit != null) {
|
||||
final c = widget.categoryToEdit!;
|
||||
_selectedParentId = c.parentCategoryId;
|
||||
@@ -275,15 +339,15 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
|
||||
_makingChargeController.text = c.defaultMakingCharge?.toString() ?? '';
|
||||
_purityFactorController.text = formatPurity(c.purityFactor);
|
||||
_huidRequired = c.huidRequired;
|
||||
_commodityCode = c.commodityCode ?? 'XAU';
|
||||
_commodityCode = c.commodityCode ?? (isJewellery ? 'XAU' : 'NONE');
|
||||
|
||||
// Handle legacy or varying enum string values
|
||||
_makingChargeType = c.makingChargeType ?? 'PER_GRAM';
|
||||
if (_makingChargeType == 'PER_GM') _makingChargeType = 'PER_GRAM';
|
||||
|
||||
|
||||
_baseUnit = c.baseUnit ?? 'g';
|
||||
_baseUnit = c.baseUnit;
|
||||
if (_baseUnit == 'gm') _baseUnit = 'g';
|
||||
_defaultLengthUom = c.defaultLengthUom;
|
||||
}
|
||||
} else if (widget.parent != null) {
|
||||
_selectedParentId = widget.parent!.id;
|
||||
@@ -302,19 +366,24 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
|
||||
|
||||
void _submit() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final profile = ref.read(businessProfileProvider).value;
|
||||
final nature = (profile?.natureOfBusiness ?? profile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||
final isJewellery = nature == 'JEWELLERY';
|
||||
|
||||
final newCategory = ProductCategory(
|
||||
id: widget.categoryToEdit?.id,
|
||||
name: _nameController.text.trim(),
|
||||
parentCategoryId: _selectedParentId,
|
||||
hasChild: !_isLeaf,
|
||||
commodityCode: _isLeaf ? _commodityCode : null,
|
||||
purityFactor: _isLeaf ? normalizePurity(double.tryParse(_purityFactorController.text)) : 1.0,
|
||||
commodityCode: _isLeaf ? (isJewellery ? _commodityCode : 'NONE') : null,
|
||||
purityFactor: _isLeaf ? (isJewellery ? normalizePurity(double.tryParse(_purityFactorController.text)) : 1.0) : 1.0,
|
||||
defaultHsn: _isLeaf ? _hsnController.text.trim() : null,
|
||||
defaultGst: _isLeaf ? double.tryParse(_gstController.text) : null,
|
||||
huidRequired: _isLeaf ? _huidRequired : false,
|
||||
defaultMakingCharge: _isLeaf ? double.tryParse(_makingChargeController.text) : null,
|
||||
makingChargeType: _isLeaf ? _makingChargeType : null,
|
||||
huidRequired: _isLeaf ? (isJewellery ? _huidRequired : false) : false,
|
||||
defaultMakingCharge: _isLeaf && isJewellery ? double.tryParse(_makingChargeController.text) : null,
|
||||
makingChargeType: _isLeaf && isJewellery ? _makingChargeType : null,
|
||||
baseUnit: _isLeaf ? _baseUnit : 'pcs',
|
||||
defaultLengthUom: _isLeaf ? _defaultLengthUom : 'cm',
|
||||
);
|
||||
|
||||
if (widget.categoryToEdit != null && widget.categoryToEdit!.id != null) {
|
||||
@@ -330,6 +399,10 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final isBusinessMode = ref.watch(businessModeProvider);
|
||||
final businessProfile = ref.watch(businessProfileProvider).value;
|
||||
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||
final isJewellery = nature == 'JEWELLERY';
|
||||
final uomsState = ref.watch(uomsProvider);
|
||||
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.85,
|
||||
@@ -461,7 +534,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
|
||||
const Text('Product Defaults', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (isBusinessMode) ...[
|
||||
if (isJewellery && isBusinessMode) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -516,6 +589,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (isJewellery) ...[
|
||||
SwitchListTile(
|
||||
title: const Text('HUID Required'),
|
||||
subtitle: const Text('Is Hallmark Unique Identification mandatory?'),
|
||||
@@ -530,7 +604,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _makingChargeType,
|
||||
decoration: InputDecoration(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Making Charge Type',
|
||||
),
|
||||
items: const [
|
||||
@@ -552,18 +626,70 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _baseUnit,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Base Unit',
|
||||
],
|
||||
|
||||
uomsState.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: LinearProgressIndicator(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'g', child: Text('Grams (g)')),
|
||||
DropdownMenuItem(value: 'kg', child: Text('Kilograms (kg)')),
|
||||
DropdownMenuItem(value: 'pcs', child: Text('Pieces (pcs)')),
|
||||
error: (err, stack) => DropdownButtonFormField<String>(
|
||||
value: _baseUnit,
|
||||
decoration: const InputDecoration(labelText: 'Base Unit'),
|
||||
items: [
|
||||
DropdownMenuItem(value: _baseUnit, child: Text(_baseUnit)),
|
||||
],
|
||||
onChanged: (val) => setState(() => _baseUnit = val!),
|
||||
),
|
||||
data: (uoms) {
|
||||
final availableValues = uoms.map((u) => u.abbreviation ?? u.name).toList();
|
||||
final match = uoms.where((u) =>
|
||||
(u.abbreviation ?? u.name).toLowerCase() == _baseUnit.toLowerCase() ||
|
||||
u.name.toLowerCase() == _baseUnit.toLowerCase()
|
||||
).firstOrNull;
|
||||
final effectiveValue = match != null
|
||||
? (match.abbreviation ?? match.name)
|
||||
: (availableValues.contains(_baseUnit) ? _baseUnit : (availableValues.isNotEmpty ? availableValues.first : null));
|
||||
|
||||
return DropdownButtonFormField<String>(
|
||||
value: effectiveValue,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Base Unit',
|
||||
prefixIcon: Icon(LucideIcons.scale),
|
||||
),
|
||||
isExpanded: true,
|
||||
items: uoms.map((u) {
|
||||
final val = u.abbreviation ?? u.name;
|
||||
return DropdownMenuItem<String>(
|
||||
value: val,
|
||||
child: Text(u.displayName),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() => _baseUnit = val);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!isJewellery) ...[
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _defaultLengthUom,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Default Length UoM',
|
||||
prefixIcon: Icon(LucideIcons.ruler),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'cm', child: Text('Centimeters (cm)')),
|
||||
DropdownMenuItem(value: 'in', child: Text('Inches (in)')),
|
||||
DropdownMenuItem(value: 'mm', child: Text('Millimeters (mm)')),
|
||||
DropdownMenuItem(value: 'm', child: Text('Meters (m)')),
|
||||
],
|
||||
onChanged: (val) => setState(() => _defaultLengthUom = val ?? 'cm'),
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:kifi_app/core/network/dio_client.dart';
|
||||
import 'package:kifi_app/core/widgets/responsive_layout.dart';
|
||||
import 'package:kifi_app/features/business/providers/business_provider.dart';
|
||||
import 'package:kifi_app/features/inventory/domain/product.dart';
|
||||
import 'package:kifi_app/features/inventory/presentation/add_product_screen.dart';
|
||||
import 'package:kifi_app/features/inventory/presentation/stock_ledger_tab.dart';
|
||||
import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart';
|
||||
import 'package:kifi_app/features/inventory/providers/product_categories_provider.dart';
|
||||
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
|
||||
import 'package:kifi_app/features/inventory/providers/uoms_provider.dart';
|
||||
import 'package:kifi_app/features/transactions/presentation/widgets/attachment_gallery_screen.dart';
|
||||
|
||||
class ProductDetailScreen extends ConsumerWidget {
|
||||
final Product product;
|
||||
@@ -70,46 +75,50 @@ class ProductDetailScreen extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Widget _buildOverviewTab(BuildContext context, Product p, WidgetRef ref) {
|
||||
final businessProfile = ref.watch(businessProfileProvider).value;
|
||||
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||
final isJewellery = nature == 'JEWELLERY';
|
||||
|
||||
final categories = ref.watch(productCategoriesProvider).value ?? [];
|
||||
final category = p.categoryId != null
|
||||
? categories.where((c) => c.id == p.categoryId).firstOrNull
|
||||
: null;
|
||||
|
||||
final uoms = ref.watch(uomsProvider).value ?? [];
|
||||
final uom = p.uomId != null
|
||||
? uoms.where((u) => u.id == p.uomId).firstOrNull
|
||||
: null;
|
||||
|
||||
final hasSpecs = (p.brandName != null && p.brandName!.isNotEmpty) ||
|
||||
(p.manufacturerCode != null && p.manufacturerCode!.isNotEmpty) ||
|
||||
(p.material != null && p.material!.isNotEmpty) ||
|
||||
(p.color != null && p.color!.isNotEmpty) ||
|
||||
(p.countryOfOrigin != null && p.countryOfOrigin!.isNotEmpty);
|
||||
|
||||
final hasDimensions = p.itemLength != null ||
|
||||
p.itemWidth != null ||
|
||||
p.itemHeight != null ||
|
||||
p.weightInG != null;
|
||||
|
||||
final hasPackaging = p.packageLength != null ||
|
||||
p.packageWidth != null ||
|
||||
p.packageHeight != null ||
|
||||
p.packageWeightInG != null ||
|
||||
p.volumetricWeightInKg != null;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: MaxContentWidth(
|
||||
maxWidth: 900,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Image Header
|
||||
// Images Header & Gallery
|
||||
if (p.imageIds.isNotEmpty)
|
||||
Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: FutureBuilder<String?>(
|
||||
future: DioClient().storage.read(key: 'jwt_token'),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final token = snapshot.data;
|
||||
if (token == null) {
|
||||
return const Icon(
|
||||
Icons.image,
|
||||
size: 50,
|
||||
color: Colors.grey,
|
||||
);
|
||||
}
|
||||
return Image.network(
|
||||
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content',
|
||||
fit: BoxFit.cover,
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
const Icon(Icons.image, size: 50, color: Colors.grey),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildImagesSection(context, p),
|
||||
|
||||
if (p.imageIds.isNotEmpty)
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Product Details Card
|
||||
Card(
|
||||
@@ -122,18 +131,32 @@ class ProductDetailScreen extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.package, size: 20, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
"Product Details",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildDetailRow("SKU / Barcode", p.sku ?? "-"),
|
||||
if (category != null)
|
||||
_buildDetailRow("Category", category.name),
|
||||
if (uom != null)
|
||||
_buildDetailRow("Unit of Measure", uom.displayName),
|
||||
_buildDetailRow("HSN Code", p.hsnCode ?? "-"),
|
||||
if (p.mrp != null && p.mrp! > 0)
|
||||
_buildDetailRow("MRP", "₹${p.mrp!.toStringAsFixed(2)}"),
|
||||
if (p.sellingPrice != null && p.sellingPrice! > 0)
|
||||
_buildDetailRow("Selling Price", "₹${p.sellingPrice!.toStringAsFixed(2)}"),
|
||||
_buildDetailRow(
|
||||
"GST Rate",
|
||||
"${p.gstRate?.toStringAsFixed(1) ?? '0'}%",
|
||||
),
|
||||
if (p.makingCharges != null && p.makingCharges! > 0)
|
||||
if (isJewellery && p.makingCharges != null && p.makingCharges! > 0)
|
||||
_buildDetailRow(
|
||||
"Making Charges",
|
||||
p.makingChargesType == 'PERCENTAGE'
|
||||
@@ -146,21 +169,324 @@ class ProductDetailScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Inventory & Reorder Alerts Card
|
||||
Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.bellRing, size: 20, color: Colors.orange.shade700),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
"Inventory & Reorder Status",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!p.trackInventory)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: Colors.grey.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(6)),
|
||||
child: const Text('Untracked', style: TextStyle(fontSize: 11, color: Colors.grey, fontWeight: FontWeight.bold)),
|
||||
)
|
||||
else if (p.isOutOfStock)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: Colors.red.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(6)),
|
||||
child: const Text('Out of Stock', style: TextStyle(fontSize: 11, color: Colors.red, fontWeight: FontWeight.bold)),
|
||||
)
|
||||
else if (p.isLowStock)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: Colors.orange.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6)),
|
||||
child: const Text('Low Stock Alert', style: TextStyle(fontSize: 11, color: Colors.orange, fontWeight: FontWeight.bold)),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: const Color(0xFF10B981).withValues(alpha: 0.12), borderRadius: BorderRadius.circular(6)),
|
||||
child: const Text('Healthy Stock', style: TextStyle(fontSize: 11, color: Color(0xFF10B981), fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildDetailRow(
|
||||
"Current Available Stock",
|
||||
"${p.currentStock != null ? (p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock) : '0'} ${uom?.abbreviation ?? 'pcs'}",
|
||||
),
|
||||
_buildDetailRow(
|
||||
"Reorder Alert Level / Min Stock",
|
||||
((p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock) != null && ((p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock)! > 0
|
||||
? "${((p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock)} ${uom?.abbreviation ?? 'pcs'}"
|
||||
: "Not configured",
|
||||
),
|
||||
_buildDetailRow(
|
||||
"Standard Reorder Batch Qty",
|
||||
p.reorderQuantity != null && p.reorderQuantity! > 0
|
||||
? "${p.reorderQuantity} ${uom?.abbreviation ?? 'pcs'}"
|
||||
: "Not configured",
|
||||
),
|
||||
_buildDetailRow(
|
||||
"Inventory Tracking",
|
||||
p.trackInventory ? "Active" : "Disabled",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Non-jewellery Specific Sections
|
||||
if (!isJewellery) ...[
|
||||
if (hasSpecs) ...[
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.sliders, size: 20, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
"Product Specifications",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
if (p.brandName != null && p.brandName!.isNotEmpty)
|
||||
_buildDetailRow("Brand Name", p.brandName!),
|
||||
if (p.manufacturerCode != null && p.manufacturerCode!.isNotEmpty)
|
||||
_buildDetailRow("Manufacturer Code", p.manufacturerCode!),
|
||||
if (p.material != null && p.material!.isNotEmpty)
|
||||
_buildDetailRow("Material", p.material!),
|
||||
if (p.color != null && p.color!.isNotEmpty)
|
||||
_buildDetailRow("Color", p.color!),
|
||||
if (p.countryOfOrigin != null && p.countryOfOrigin!.isNotEmpty)
|
||||
_buildDetailRow("Country of Origin", p.countryOfOrigin!),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (hasDimensions) ...[
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.ruler, size: 20, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
"Item Dimensions & Weight",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
if (p.itemLength != null || p.itemWidth != null || p.itemHeight != null)
|
||||
_buildDetailRow(
|
||||
"Dimensions (L × W × H)",
|
||||
"${p.itemLength ?? '-'} × ${p.itemWidth ?? '-'} × ${p.itemHeight ?? '-'} ${p.dimensionUom ?? 'cm'}",
|
||||
),
|
||||
if (p.weightInG != null)
|
||||
_buildDetailRow("Item Weight", "${p.weightInG} g"),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (hasPackaging) ...[
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.box, size: 20, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
"Packaging Dimensions & Weight",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
if (p.packageLength != null || p.packageWidth != null || p.packageHeight != null)
|
||||
_buildDetailRow(
|
||||
"Package (L × W × H)",
|
||||
"${p.packageLength ?? '-'} × ${p.packageWidth ?? '-'} × ${p.packageHeight ?? '-'} ${p.packageDimensionUom ?? 'cm'}",
|
||||
),
|
||||
if (p.packageWeightInG != null)
|
||||
_buildDetailRow("Package Weight", "${p.packageWeightInG} g"),
|
||||
if (p.volumetricWeightInKg != null)
|
||||
_buildDetailRow(
|
||||
"Volumetric Weight",
|
||||
"${p.volumetricWeightInKg} kg",
|
||||
highlight: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
Widget _buildImagesSection(BuildContext context, Product p) {
|
||||
return FutureBuilder<String?>(
|
||||
future: DioClient().storage.read(key: 'jwt_token'),
|
||||
builder: (context, snapshot) {
|
||||
final token = snapshot.data;
|
||||
if (token == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final allImages = p.imageIds.map<ImageProvider>((imageId) {
|
||||
return NetworkImage(
|
||||
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/$imageId/content',
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AttachmentGalleryScreen(
|
||||
images: allImages,
|
||||
initialIndex: 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: 220,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Image.network(
|
||||
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content',
|
||||
fit: BoxFit.cover,
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
const Icon(Icons.image, size: 50, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (p.imageIds.length > 1) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 64,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: p.imageIds.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final imageId = p.imageIds[index];
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AttachmentGalleryScreen(
|
||||
images: allImages,
|
||||
initialIndex: index,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 64,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Image.network(
|
||||
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/$imageId/content',
|
||||
fit: BoxFit.cover,
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
const Icon(Icons.image, size: 24, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value, {bool highlight = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 14)),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: highlight ? const Color(0xFF4F46E5) : Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
Timer? _debounce;
|
||||
String? _token;
|
||||
String _stockFilter = 'ALL'; // 'ALL', 'LOW_STOCK', 'OUT_OF_STOCK'
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -55,31 +56,41 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
final productsState = ref.watch(productsProvider);
|
||||
final categoriesState = ref.watch(productCategoriesProvider);
|
||||
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[100],
|
||||
backgroundColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC),
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Products Catalog',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
foregroundColor: isDark ? Colors.white : Colors.black,
|
||||
centerTitle: true,
|
||||
),
|
||||
body: MaxContentWidth(
|
||||
maxWidth: 1000,
|
||||
child: Column(
|
||||
body: Column(
|
||||
children: [
|
||||
Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: TextField(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search products by name or SKU...',
|
||||
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
onChanged: (val) {
|
||||
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||
@@ -88,33 +99,92 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Stock Status Filter Bar
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
label: const Text('All Products', style: TextStyle(fontSize: 11)),
|
||||
selected: _stockFilter == 'ALL',
|
||||
onSelected: (val) => setState(() => _stockFilter = 'ALL'),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
avatar: const Icon(LucideIcons.alertTriangle, size: 14, color: Colors.orange),
|
||||
label: const Text('Low Stock Alerts', style: TextStyle(fontSize: 11)),
|
||||
selected: _stockFilter == 'LOW_STOCK',
|
||||
selectedColor: Colors.orange.withValues(alpha: 0.15),
|
||||
checkmarkColor: Colors.orange,
|
||||
onSelected: (val) => setState(() => _stockFilter = val ? 'LOW_STOCK' : 'ALL'),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
avatar: const Icon(LucideIcons.alertCircle, size: 14, color: Colors.red),
|
||||
label: const Text('Out of Stock', style: TextStyle(fontSize: 11)),
|
||||
selected: _stockFilter == 'OUT_OF_STOCK',
|
||||
selectedColor: Colors.red.withValues(alpha: 0.15),
|
||||
checkmarkColor: Colors.red,
|
||||
onSelected: (val) => setState(() => _stockFilter = val ? 'OUT_OF_STOCK' : 'ALL'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
Expanded(
|
||||
child: productsState.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
data: (products) {
|
||||
if (products.isEmpty) {
|
||||
final filteredProducts = products.where((p) {
|
||||
if (_stockFilter == 'LOW_STOCK') {
|
||||
return p.isLowStock;
|
||||
} else if (_stockFilter == 'OUT_OF_STOCK') {
|
||||
return p.isOutOfStock;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
if (filteredProducts.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
ref.read(productsProvider.notifier).refresh(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(height: 100),
|
||||
children: [
|
||||
const SizedBox(height: 100),
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.packageSearch,
|
||||
_stockFilter == 'LOW_STOCK'
|
||||
? LucideIcons.checkCircle
|
||||
: LucideIcons.packageSearch,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No products found.',
|
||||
style: TextStyle(
|
||||
_stockFilter == 'LOW_STOCK'
|
||||
? 'No low stock products! All inventory healthy.'
|
||||
: (_stockFilter == 'OUT_OF_STOCK'
|
||||
? 'No out-of-stock products.'
|
||||
: 'No products found.'),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 16,
|
||||
),
|
||||
@@ -133,14 +203,14 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: products.length + 1, // +1 for loading indicator
|
||||
itemCount: filteredProducts.length + 1, // +1 for loading indicator
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == products.length) {
|
||||
if (index == filteredProducts.length) {
|
||||
return const SizedBox(height: 80);
|
||||
}
|
||||
|
||||
final p = products[index];
|
||||
final p = filteredProducts[index];
|
||||
final catName = categoriesState.value
|
||||
?.where((c) => c.id == p.categoryId)
|
||||
.firstOrNull
|
||||
@@ -150,11 +220,12 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.08),
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
@@ -239,22 +310,78 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!p.trackInventory) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text('Untracked', style: TextStyle(fontSize: 11, color: Colors.grey, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
] else if (p.isOutOfStock) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.alertCircle, size: 12, color: Colors.red),
|
||||
SizedBox(width: 4),
|
||||
Text('Out of Stock', style: TextStyle(fontSize: 11, color: Colors.red, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else if (p.isLowStock) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.orange.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(LucideIcons.alertTriangle, size: 12, color: Colors.orange),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
p.currentStock != null
|
||||
? (p.currentStock! > 0
|
||||
? 'Stock: ${p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock}'
|
||||
: 'Out of Stock')
|
||||
: 'Untracked',
|
||||
style: TextStyle(
|
||||
color: p.currentStock != null
|
||||
? (p.currentStock! > 0
|
||||
? const Color(0xFF10B981)
|
||||
: Colors.red)
|
||||
: Colors.grey,
|
||||
'Low: ${p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock}',
|
||||
style: const TextStyle(fontSize: 11, color: Colors.orange, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'Alert <= ${((p.reorderLevel != null && p.reorderLevel! > 0) ? p.reorderLevel : p.minStock) ?? 0}',
|
||||
style: TextStyle(fontSize: 9, color: Colors.orange.shade800),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF10B981).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'Stock: ${p.currentStock! % 1 == 0 ? p.currentStock!.toInt() : p.currentStock}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF10B981),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -271,7 +398,6 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () async {
|
||||
await Navigator.push(
|
||||
|
||||
@@ -10,7 +10,12 @@ import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:kifi_app/features/vendor/presentation/purchase_order_details_screen.dart';
|
||||
import 'package:kifi_app/features/vendor/providers/purchase_orders_provider.dart';
|
||||
import 'package:kifi_app/features/sales/presentation/invoice_details_screen.dart';
|
||||
import 'package:kifi_app/features/sales/providers/invoices_provider.dart';
|
||||
import 'package:kifi_app/features/sales/domain/invoice.dart';
|
||||
import 'package:kifi_app/features/business/providers/business_provider.dart';
|
||||
import 'package:kifi_app/core/utils/purity_utils.dart';
|
||||
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
|
||||
|
||||
class StockLedgerTab extends ConsumerStatefulWidget {
|
||||
final Product product;
|
||||
@@ -26,6 +31,18 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
bool _isLoading = true;
|
||||
String _selectedFilter = 'IN_STOCK'; // 'IN_STOCK', 'SOLD', 'ALL'
|
||||
|
||||
String _resolveImageUrl(String path) {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
final base = DioClient().dio.options.baseUrl;
|
||||
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
|
||||
if (cleanPath.startsWith('api/kifi-v2/upload/view') || cleanPath.startsWith('upload/view')) {
|
||||
return '$base/${cleanPath.replaceFirst('api/kifi-v2/', '')}';
|
||||
}
|
||||
return '$base/upload/view?path=${Uri.encodeComponent(cleanPath)}';
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -77,6 +94,10 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
displayItems = _items!;
|
||||
}
|
||||
|
||||
final businessProfile = ref.watch(businessProfileProvider).value;
|
||||
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||
final isJewellery = nature == 'JEWELLERY';
|
||||
|
||||
final categoriesState = ref.watch(productCategoriesProvider);
|
||||
final commodityRatesState = ref.watch(commodityRatesProvider);
|
||||
|
||||
@@ -84,20 +105,24 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
(c) => c.id == widget.product.categoryId,
|
||||
).firstOrNull;
|
||||
|
||||
final bool isCommodity = isJewellery && category?.commodityCode != null && category!.commodityCode!.isNotEmpty;
|
||||
|
||||
final String unit = category?.baseUnit ?? 'g';
|
||||
final double purityFactor = resolvePurity(categoryPurity: category?.purityFactor, productPurity: widget.product.purityFactor);
|
||||
|
||||
double commodityRate = 0.0;
|
||||
if (category?.commodityCode != null && commodityRatesState.value != null) {
|
||||
if (isCommodity && commodityRatesState.value != null && category.commodityCode != null) {
|
||||
final match = commodityRatesState.value!.where(
|
||||
(r) => r.commodityCode.toUpperCase() == category!.commodityCode!.toUpperCase()
|
||||
(r) => r.commodityCode.toUpperCase() == category.commodityCode!.toUpperCase()
|
||||
).firstOrNull;
|
||||
if (match != null) {
|
||||
commodityRate = match.rate;
|
||||
}
|
||||
}
|
||||
|
||||
final double currentRate = commodityRate > 0 ? (commodityRate * purityFactor) : (category?.dailyRate ?? 0.0);
|
||||
final double currentRate = isCommodity
|
||||
? (commodityRate > 0 ? (commodityRate * purityFactor) : (category.dailyRate ?? 0.0))
|
||||
: 0.0;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -134,6 +159,31 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
final isSold = item.status == 'SOLD';
|
||||
final purchaseOrdersState = ref.watch(purchaseOrdersProvider);
|
||||
final purchaseOrders = purchaseOrdersState.value ?? [];
|
||||
final invoicesState = ref.watch(invoicesProvider);
|
||||
final invoices = invoicesState.value ?? [];
|
||||
|
||||
// Resolve sales invoice for sold item
|
||||
Invoice? matchingSaleInvoice;
|
||||
if (isSold) {
|
||||
if (item.salesRef != null && item.salesRef!.isNotEmpty) {
|
||||
matchingSaleInvoice = invoices.where((i) => i.invoiceNumber == item.salesRef).firstOrNull;
|
||||
}
|
||||
matchingSaleInvoice ??= invoices.where((i) => i.items.any((invIt) =>
|
||||
(invIt.inventoryItemId != null && invIt.inventoryItemId == item.id) ||
|
||||
(item.huid != null && item.huid!.isNotEmpty && invIt.huid == item.huid)
|
||||
)).firstOrNull;
|
||||
}
|
||||
|
||||
double effectiveSaleRate = (item.saleRate != null && item.saleRate! > 0) ? item.saleRate! : 0.0;
|
||||
if (isSold && effectiveSaleRate == 0.0 && matchingSaleInvoice != null) {
|
||||
final matchItem = matchingSaleInvoice.items.where((invIt) =>
|
||||
(invIt.inventoryItemId != null && invIt.inventoryItemId == item.id) ||
|
||||
(item.huid != null && item.huid!.isNotEmpty && invIt.huid == item.huid)
|
||||
).firstOrNull;
|
||||
if (matchItem != null && matchItem.unitPrice > 0) {
|
||||
effectiveSaleRate = matchItem.unitPrice;
|
||||
}
|
||||
}
|
||||
|
||||
double weight = (item.grossWeight != null && item.grossWeight! > 0)
|
||||
? item.grossWeight!
|
||||
@@ -163,11 +213,39 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve purchase item thumbnail photo
|
||||
String? itemPhotoUrl = (item.photoUrl != null && item.photoUrl!.isNotEmpty) ? item.photoUrl : null;
|
||||
if (itemPhotoUrl == null && item.purchaseRef != null) {
|
||||
final po = purchaseOrders.where((p) => p.poNumber == item.purchaseRef).firstOrNull;
|
||||
if (po != null) {
|
||||
final poItem = po.items.where((i) =>
|
||||
(item.huid != null && i.huid == item.huid) ||
|
||||
(item.sku != null && i.sku == item.sku) ||
|
||||
i.productId == widget.product.id
|
||||
).firstOrNull ?? po.items.firstOrNull;
|
||||
if (poItem != null && poItem.photoUrl != null && poItem.photoUrl!.isNotEmpty) {
|
||||
itemPhotoUrl = poItem.photoUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? finalDisplayUrl;
|
||||
bool isProductFallback = false;
|
||||
if (itemPhotoUrl != null) {
|
||||
finalDisplayUrl = _resolveImageUrl(itemPhotoUrl);
|
||||
} else if (widget.product.imageIds.isNotEmpty) {
|
||||
finalDisplayUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product.id}/images/${widget.product.imageIds.first}/content';
|
||||
isProductFallback = true;
|
||||
}
|
||||
|
||||
final purchasePrice = weight * purchaseRate;
|
||||
final currentPrice = weight * currentRate;
|
||||
final gainLoss = currentPrice - purchasePrice;
|
||||
final double displayedSellingRate = effectiveSaleRate > 0 ? effectiveSaleRate : (isCommodity ? currentRate : purchaseRate);
|
||||
final currentPrice = weight * (isSold ? displayedSellingRate : currentRate);
|
||||
final gainLoss = isSold ? ((weight * displayedSellingRate) - purchasePrice) : (currentPrice - purchasePrice);
|
||||
final isGain = gainLoss >= 0;
|
||||
|
||||
final String? salesInvoiceNumber = matchingSaleInvoice?.invoiceNumber ?? item.salesRef;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
@@ -188,30 +266,93 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: FutureBuilder<String?>(
|
||||
FutureBuilder<String?>(
|
||||
future: DioClient().storage.read(key: 'jwt_token'),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && widget.product.imageIds.isNotEmpty) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
'${DioClient().dio.options.baseUrl}/inventory/products/${widget.product.id}/images/${widget.product.imageIds.first}/content',
|
||||
fit: BoxFit.cover,
|
||||
headers: {'Authorization': 'Bearer ${snapshot.data}'},
|
||||
final hasImage = finalDisplayUrl != null;
|
||||
return GestureDetector(
|
||||
onTap: hasImage
|
||||
? () {
|
||||
final token = snapshot.data;
|
||||
final headers = (token != null && isProductFallback)
|
||||
? {'Authorization': 'Bearer $token'}
|
||||
: null;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AttachmentGalleryScreen(
|
||||
images: [
|
||||
NetworkImage(
|
||||
finalDisplayUrl!,
|
||||
headers: headers,
|
||||
),
|
||||
],
|
||||
initialIndex: 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Icon(LucideIcons.image, color: Colors.grey, size: 20);
|
||||
},
|
||||
: null,
|
||||
child: MouseRegion(
|
||||
cursor: hasImage ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: hasImage
|
||||
? Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: Image.network(
|
||||
finalDisplayUrl,
|
||||
fit: BoxFit.cover,
|
||||
headers: (snapshot.hasData && isProductFallback)
|
||||
? {'Authorization': 'Bearer ${snapshot.data}'}
|
||||
: null,
|
||||
errorBuilder: (context, error, stackTrace) => const Icon(
|
||||
LucideIcons.imageOff,
|
||||
color: Colors.grey,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 2,
|
||||
right: 2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.maximize2,
|
||||
size: 8,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Icon(
|
||||
LucideIcons.image,
|
||||
color: Colors.grey,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -224,6 +365,49 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
style: TextStyle(color: Colors.grey[600], fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (isSold) ...[
|
||||
if (salesInvoiceNumber != null && salesInvoiceNumber.isNotEmpty)
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
try {
|
||||
var inv = matchingSaleInvoice;
|
||||
if (inv == null) {
|
||||
var invs = ref.read(invoicesProvider).value;
|
||||
if (invs == null || invs.isEmpty) {
|
||||
invs = await ref.read(invoicesProvider.future);
|
||||
}
|
||||
inv = invs?.where((i) => i.invoiceNumber == salesInvoiceNumber).firstOrNull;
|
||||
}
|
||||
if (inv == null) {
|
||||
throw Exception('Sales invoice $salesInvoiceNumber not found');
|
||||
}
|
||||
if (context.mounted) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => InvoiceDetailsScreen(invoice: inv!),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not open sales invoice: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
'INV: $salesInvoiceNumber',
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
if (item.purchaseRef != null)
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
@@ -236,7 +420,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
(p) => p.poNumber == item.purchaseRef,
|
||||
orElse: () => throw Exception('Purchase invoice not found'),
|
||||
);
|
||||
if (mounted) {
|
||||
if (context.mounted) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -245,7 +429,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not open purchase invoice: $e')),
|
||||
);
|
||||
@@ -258,12 +442,14 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item.huid != null && item.huid!.isNotEmpty)
|
||||
],
|
||||
if (isJewellery && item.huid != null && item.huid!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
padding: const EdgeInsets.only(top: 2.0),
|
||||
child: Text('HUID: ${item.huid}', style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
),
|
||||
],
|
||||
@@ -298,6 +484,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
color: isSold ? Colors.grey.shade600 : null,
|
||||
),
|
||||
),
|
||||
if (isCommodity) ...[
|
||||
const SizedBox(height: 3),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
|
||||
@@ -316,10 +503,12 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
if (isCommodity) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -347,7 +536,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(isSold ? 'Selling Rate' : 'Current Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
|
||||
Text('₹${currentRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text('₹${displayedSellingRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
@@ -386,6 +575,84 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
// Non-Commodity / E-Commerce Products
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Purchase Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
|
||||
Text('₹${purchaseRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text('Purchase Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
|
||||
Text('₹${purchasePrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isSold && effectiveSaleRate > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Sold Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
|
||||
Text('₹${effectiveSaleRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.purple)),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text('Sold Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
|
||||
Text('₹${(weight * effectiveSaleRate).toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.purple)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final soldAmt = weight * effectiveSaleRate;
|
||||
final realizedMargin = soldAmt - purchasePrice;
|
||||
final isProfit = realizedMargin >= 0;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isProfit ? Colors.green.withValues(alpha: 0.1) : Colors.red.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isProfit ? LucideIcons.trendingUp : LucideIcons.trendingDown,
|
||||
color: isProfit ? Colors.green : Colors.red,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Realized ${isProfit ? 'Profit' : 'Loss'}: ₹${realizedMargin.abs().toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
color: isProfit ? Colors.green : Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -16,6 +16,7 @@ class ProductCategory {
|
||||
final double? defaultMakingCharge;
|
||||
final String? makingChargeType;
|
||||
final String baseUnit;
|
||||
final String defaultLengthUom;
|
||||
final bool isActive;
|
||||
final int sortOrder;
|
||||
final double? dailyRate;
|
||||
@@ -34,6 +35,7 @@ class ProductCategory {
|
||||
this.defaultMakingCharge,
|
||||
this.makingChargeType,
|
||||
this.baseUnit = 'pcs',
|
||||
this.defaultLengthUom = 'cm',
|
||||
this.isActive = true,
|
||||
this.sortOrder = 0,
|
||||
this.dailyRate,
|
||||
@@ -54,6 +56,7 @@ class ProductCategory {
|
||||
defaultMakingCharge: (json['defaultMakingCharge'] ?? json['default_making_charge'] as num?)?.toDouble(),
|
||||
makingChargeType: json['makingChargeType'] ?? json['making_charge_type'],
|
||||
baseUnit: json['baseUnit'] ?? json['base_unit'] ?? 'pcs',
|
||||
defaultLengthUom: json['defaultLengthUom'] ?? json['default_length_uom'] ?? 'cm',
|
||||
isActive: json['isActive'] ?? json['is_active'] ?? true,
|
||||
sortOrder: json['sortOrder'] ?? json['sort_order'] ?? 0,
|
||||
dailyRate: (json['dailyRate'] ?? json['daily_rate'] as num?)?.toDouble(),
|
||||
@@ -75,6 +78,7 @@ class ProductCategory {
|
||||
'defaultMakingCharge': defaultMakingCharge,
|
||||
'makingChargeType': makingChargeType,
|
||||
'baseUnit': baseUnit,
|
||||
'defaultLengthUom': defaultLengthUom,
|
||||
'isActive': isActive,
|
||||
'sortOrder': sortOrder,
|
||||
};
|
||||
|
||||
@@ -57,11 +57,20 @@ class UomsNotifier extends AsyncNotifier<List<UnitOfMeasure>> {
|
||||
// If empty in database, seed standard units of measure
|
||||
try {
|
||||
final defaults = [
|
||||
{'name': 'Pieces', 'abbreviation': 'pcs'},
|
||||
{'name': 'Units', 'abbreviation': 'unit'},
|
||||
{'name': 'Boxes', 'abbreviation': 'box'},
|
||||
{'name': 'Sets', 'abbreviation': 'set'},
|
||||
{'name': 'Pairs', 'abbreviation': 'pair'},
|
||||
{'name': 'Packs', 'abbreviation': 'pk'},
|
||||
{'name': 'Grams', 'abbreviation': 'g'},
|
||||
{'name': 'Kilograms', 'abbreviation': 'kg'},
|
||||
{'name': 'Pieces', 'abbreviation': 'pcs'},
|
||||
{'name': 'Carats', 'abbreviation': 'ct'},
|
||||
{'name': 'Milligrams', 'abbreviation': 'mg'},
|
||||
{'name': 'Carats', 'abbreviation': 'ct'},
|
||||
{'name': 'Meters', 'abbreviation': 'm'},
|
||||
{'name': 'Liters', 'abbreviation': 'l'},
|
||||
{'name': 'Milliliters', 'abbreviation': 'ml'},
|
||||
{'name': 'Dozens', 'abbreviation': 'doz'},
|
||||
];
|
||||
for (final def in defaults) {
|
||||
await DioClient().dio.post('/inventory/uom', data: def);
|
||||
|
||||
201
kifi-app/lib/features/sales/domain/credit_note.dart
Normal file
@@ -0,0 +1,201 @@
|
||||
class CreditNoteItem {
|
||||
final int? id;
|
||||
final int? creditNoteId;
|
||||
final int? invoiceItemId;
|
||||
final int? productId;
|
||||
final int? inventoryItemId;
|
||||
final String? productName;
|
||||
final String? sku;
|
||||
final String? huid;
|
||||
final String? hsnCode;
|
||||
final double quantity;
|
||||
final double? weight;
|
||||
final double unitPrice;
|
||||
final double taxRate;
|
||||
final double cgst;
|
||||
final double sgst;
|
||||
final double igst;
|
||||
final double discount;
|
||||
final double makingCharge;
|
||||
final double otherCharges;
|
||||
final double total;
|
||||
|
||||
CreditNoteItem({
|
||||
this.id,
|
||||
this.creditNoteId,
|
||||
this.invoiceItemId,
|
||||
this.productId,
|
||||
this.inventoryItemId,
|
||||
this.productName,
|
||||
this.sku,
|
||||
this.huid,
|
||||
this.hsnCode,
|
||||
required this.quantity,
|
||||
this.weight,
|
||||
required this.unitPrice,
|
||||
this.taxRate = 0.0,
|
||||
this.cgst = 0.0,
|
||||
this.sgst = 0.0,
|
||||
this.igst = 0.0,
|
||||
this.discount = 0.0,
|
||||
this.makingCharge = 0.0,
|
||||
this.otherCharges = 0.0,
|
||||
required this.total,
|
||||
});
|
||||
|
||||
factory CreditNoteItem.fromJson(Map<String, dynamic> json) {
|
||||
return CreditNoteItem(
|
||||
id: json['id'],
|
||||
creditNoteId: json['creditNoteId'] ?? json['credit_note_id'],
|
||||
invoiceItemId: json['invoiceItemId'] ?? json['invoice_item_id'],
|
||||
productId: json['productId'] ?? json['product_id'],
|
||||
inventoryItemId: json['inventoryItemId'] ?? json['inventory_item_id'],
|
||||
productName: json['productName'] ?? json['product_name'],
|
||||
sku: json['sku'],
|
||||
huid: json['huid'],
|
||||
hsnCode: json['hsnCode'] ?? json['hsn_code'],
|
||||
quantity: (json['quantity'] as num?)?.toDouble() ?? 1.0,
|
||||
weight: (json['weight'] as num?)?.toDouble(),
|
||||
unitPrice: (json['unitPrice'] ?? json['unit_price'] as num?)?.toDouble() ?? 0.0,
|
||||
taxRate: (json['taxRate'] ?? json['tax_rate'] as num?)?.toDouble() ?? 0.0,
|
||||
cgst: (json['cgst'] as num?)?.toDouble() ?? 0.0,
|
||||
sgst: (json['sgst'] as num?)?.toDouble() ?? 0.0,
|
||||
igst: (json['igst'] as num?)?.toDouble() ?? 0.0,
|
||||
discount: (json['discount'] as num?)?.toDouble() ?? 0.0,
|
||||
makingCharge: (json['makingCharge'] ?? json['making_charge'] as num?)?.toDouble() ?? 0.0,
|
||||
otherCharges: (json['otherCharges'] ?? json['other_charges'] as num?)?.toDouble() ?? 0.0,
|
||||
total: (json['total'] as num?)?.toDouble() ?? 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = {};
|
||||
if (id != null) data['id'] = id;
|
||||
if (creditNoteId != null) data['creditNoteId'] = creditNoteId;
|
||||
if (invoiceItemId != null) data['invoiceItemId'] = invoiceItemId;
|
||||
if (productId != null) data['productId'] = productId;
|
||||
if (inventoryItemId != null) data['inventoryItemId'] = inventoryItemId;
|
||||
if (productName != null) data['productName'] = productName;
|
||||
if (sku != null) data['sku'] = sku;
|
||||
if (huid != null) data['huid'] = huid;
|
||||
if (hsnCode != null) data['hsnCode'] = hsnCode;
|
||||
data['quantity'] = quantity;
|
||||
if (weight != null) data['weight'] = weight;
|
||||
data['unitPrice'] = unitPrice;
|
||||
data['taxRate'] = taxRate;
|
||||
data['cgst'] = cgst;
|
||||
data['sgst'] = sgst;
|
||||
data['igst'] = igst;
|
||||
data['discount'] = discount;
|
||||
data['makingCharge'] = makingCharge;
|
||||
data['otherCharges'] = otherCharges;
|
||||
data['total'] = total;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
class CreditNote {
|
||||
final int? id;
|
||||
final int? userId;
|
||||
final int invoiceId;
|
||||
final String creditNoteNumber;
|
||||
final int? customerId;
|
||||
final DateTime returnDate;
|
||||
final String reason; // CUSTOMER_RETURN, RTO_UNDELIVERED, DEFECTIVE, EXCHANGE, OTHER
|
||||
final bool restockItems;
|
||||
final double subtotal;
|
||||
final double taxTotal;
|
||||
final double cgstTotal;
|
||||
final double sgstTotal;
|
||||
final double igstTotal;
|
||||
final double discountTotal;
|
||||
final double totalAmount;
|
||||
final String refundStatus; // ADJUSTED_TO_AR, REFUNDED_TO_WALLET, STORE_CREDIT, PENDING
|
||||
final int? refundWalletId;
|
||||
final String? notes;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final List<CreditNoteItem> items;
|
||||
|
||||
CreditNote({
|
||||
this.id,
|
||||
this.userId,
|
||||
required this.invoiceId,
|
||||
required this.creditNoteNumber,
|
||||
this.customerId,
|
||||
required this.returnDate,
|
||||
this.reason = 'CUSTOMER_RETURN',
|
||||
this.restockItems = true,
|
||||
required this.subtotal,
|
||||
this.taxTotal = 0.0,
|
||||
this.cgstTotal = 0.0,
|
||||
this.sgstTotal = 0.0,
|
||||
this.igstTotal = 0.0,
|
||||
this.discountTotal = 0.0,
|
||||
required this.totalAmount,
|
||||
this.refundStatus = 'ADJUSTED_TO_AR',
|
||||
this.refundWalletId,
|
||||
this.notes,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.items = const [],
|
||||
});
|
||||
|
||||
factory CreditNote.fromJson(Map<String, dynamic> json) {
|
||||
return CreditNote(
|
||||
id: json['id'],
|
||||
userId: json['userId'] ?? json['user_id'],
|
||||
invoiceId: json['invoiceId'] ?? json['invoice_id'] ?? 0,
|
||||
creditNoteNumber: json['creditNoteNumber'] ?? json['credit_note_number'] ?? '',
|
||||
customerId: json['customerId'] ?? json['customer_id'],
|
||||
returnDate: json['returnDate'] != null
|
||||
? DateTime.parse(json['returnDate'])
|
||||
: (json['return_date'] != null ? DateTime.parse(json['return_date']) : DateTime.now()),
|
||||
reason: json['reason'] ?? 'CUSTOMER_RETURN',
|
||||
restockItems: json['restockItems'] ?? json['restock_items'] ?? true,
|
||||
subtotal: (json['subtotal'] as num?)?.toDouble() ?? 0.0,
|
||||
taxTotal: (json['taxTotal'] ?? json['tax_total'] as num?)?.toDouble() ?? 0.0,
|
||||
cgstTotal: (json['cgstTotal'] ?? json['cgst_total'] as num?)?.toDouble() ?? 0.0,
|
||||
sgstTotal: (json['sgstTotal'] ?? json['sgst_total'] as num?)?.toDouble() ?? 0.0,
|
||||
igstTotal: (json['igstTotal'] ?? json['igst_total'] as num?)?.toDouble() ?? 0.0,
|
||||
discountTotal: (json['discountTotal'] ?? json['discount_total'] as num?)?.toDouble() ?? 0.0,
|
||||
totalAmount: (json['totalAmount'] ?? json['total_amount'] as num?)?.toDouble() ?? 0.0,
|
||||
refundStatus: json['refundStatus'] ?? json['refund_status'] ?? 'ADJUSTED_TO_AR',
|
||||
refundWalletId: json['refundWalletId'] ?? json['refund_wallet_id'],
|
||||
notes: json['notes'],
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'])
|
||||
: (json['created_at'] != null ? DateTime.parse(json['created_at']) : null),
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.parse(json['updatedAt'])
|
||||
: (json['updated_at'] != null ? DateTime.parse(json['updated_at']) : null),
|
||||
items: json['items'] != null
|
||||
? (json['items'] as List).map((i) => CreditNoteItem.fromJson(i)).toList()
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = {};
|
||||
if (id != null) data['id'] = id;
|
||||
if (userId != null) data['userId'] = userId;
|
||||
data['invoiceId'] = invoiceId;
|
||||
data['creditNoteNumber'] = creditNoteNumber;
|
||||
if (customerId != null) data['customerId'] = customerId;
|
||||
data['returnDate'] = returnDate.toIso8601String().split('T')[0];
|
||||
data['reason'] = reason;
|
||||
data['restockItems'] = restockItems;
|
||||
data['subtotal'] = subtotal;
|
||||
data['taxTotal'] = taxTotal;
|
||||
data['cgstTotal'] = cgstTotal;
|
||||
data['sgstTotal'] = sgstTotal;
|
||||
data['igstTotal'] = igstTotal;
|
||||
data['discountTotal'] = discountTotal;
|
||||
data['totalAmount'] = totalAmount;
|
||||
data['refundStatus'] = refundStatus;
|
||||
if (refundWalletId != null) data['refundWalletId'] = refundWalletId;
|
||||
if (notes != null) data['notes'] = notes;
|
||||
data['items'] = items.map((i) => i.toJson()).toList();
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,17 @@ class Invoice {
|
||||
final double? emiAmount;
|
||||
final String? emiCycle;
|
||||
final DateTime? emiStartDate;
|
||||
final int? salesChannelId;
|
||||
final String? salesChannel;
|
||||
final String? marketplaceOrderId;
|
||||
final String? courierPartner;
|
||||
final String? trackingNumber;
|
||||
final String? dispatchStatus; // PENDING, PACKED, SHIPPED, IN_TRANSIT, DELIVERED, RTO
|
||||
final DateTime? shippedAt;
|
||||
final String? shippingAddress;
|
||||
final String? shippingPincode;
|
||||
final int? placeOfSupplyStateId;
|
||||
final String? placeOfSupply;
|
||||
final List<InvoiceItem> items;
|
||||
final List<InvoicePayment>? payments;
|
||||
|
||||
@@ -219,6 +230,17 @@ class Invoice {
|
||||
this.emiAmount,
|
||||
this.emiCycle,
|
||||
this.emiStartDate,
|
||||
this.salesChannelId,
|
||||
this.salesChannel,
|
||||
this.marketplaceOrderId,
|
||||
this.courierPartner,
|
||||
this.trackingNumber,
|
||||
this.dispatchStatus,
|
||||
this.shippedAt,
|
||||
this.shippingAddress,
|
||||
this.shippingPincode,
|
||||
this.placeOfSupplyStateId,
|
||||
this.placeOfSupply,
|
||||
this.items = const [],
|
||||
this.payments,
|
||||
});
|
||||
@@ -254,6 +276,19 @@ class Invoice {
|
||||
emiStartDate: json['emiStartDate'] != null
|
||||
? DateTime.parse(json['emiStartDate'])
|
||||
: (json['emi_start_date'] != null ? DateTime.parse(json['emi_start_date']) : null),
|
||||
salesChannelId: json['salesChannelId'] ?? json['sales_channel_id'],
|
||||
salesChannel: json['salesChannel'] ?? json['sales_channel'],
|
||||
marketplaceOrderId: json['marketplaceOrderId'] ?? json['marketplace_order_id'],
|
||||
courierPartner: json['courierPartner'] ?? json['courier_partner'],
|
||||
trackingNumber: json['trackingNumber'] ?? json['tracking_number'],
|
||||
dispatchStatus: json['dispatchStatus'] ?? json['dispatch_status'] ?? 'PENDING',
|
||||
shippedAt: json['shippedAt'] != null
|
||||
? DateTime.parse(json['shippedAt'])
|
||||
: (json['shipped_at'] != null ? DateTime.parse(json['shipped_at']) : null),
|
||||
shippingAddress: json['shippingAddress'] ?? json['shipping_address'],
|
||||
shippingPincode: json['shippingPincode'] ?? json['shipping_pincode'],
|
||||
placeOfSupplyStateId: json['placeOfSupplyStateId'] ?? json['place_of_supply_state_id'],
|
||||
placeOfSupply: json['placeOfSupply'] ?? json['place_of_supply'],
|
||||
items: json['items'] != null
|
||||
? (json['items'] as List).map((i) => InvoiceItem.fromJson(i)).toList()
|
||||
: [],
|
||||
@@ -296,6 +331,19 @@ class Invoice {
|
||||
if (emiStartDate != null) {
|
||||
data['emiStartDate'] = emiStartDate!.toIso8601String().split('T')[0];
|
||||
}
|
||||
if (salesChannelId != null) data['salesChannelId'] = salesChannelId;
|
||||
if (salesChannel != null) data['salesChannel'] = salesChannel;
|
||||
if (marketplaceOrderId != null) data['marketplaceOrderId'] = marketplaceOrderId;
|
||||
if (courierPartner != null) data['courierPartner'] = courierPartner;
|
||||
if (trackingNumber != null) data['trackingNumber'] = trackingNumber;
|
||||
if (dispatchStatus != null) data['dispatchStatus'] = dispatchStatus;
|
||||
if (shippedAt != null) {
|
||||
data['shippedAt'] = shippedAt!.toIso8601String();
|
||||
}
|
||||
if (shippingAddress != null) data['shippingAddress'] = shippingAddress;
|
||||
if (shippingPincode != null) data['shippingPincode'] = shippingPincode;
|
||||
if (placeOfSupplyStateId != null) data['placeOfSupplyStateId'] = placeOfSupplyStateId;
|
||||
if (placeOfSupply != null) data['placeOfSupply'] = placeOfSupply;
|
||||
data['items'] = items.map((i) => i.toJson()).toList();
|
||||
if (payments != null) {
|
||||
data['payments'] = payments!.map((i) => i.toJson()).toList();
|
||||
|
||||
103
kifi-app/lib/features/sales/domain/marketplace_settlement.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
class MarketplaceSettlement {
|
||||
final int? id;
|
||||
final int? userId;
|
||||
final int invoiceId;
|
||||
final int? salesChannelId;
|
||||
final String? channelName;
|
||||
final String? settlementRef;
|
||||
final DateTime settlementDate;
|
||||
final double grossAmount;
|
||||
final double commissionFee;
|
||||
final double shippingFee;
|
||||
final double otherFees;
|
||||
final double feeGst;
|
||||
final double tcsGstAmount;
|
||||
final double tdsAmount;
|
||||
final double totalDeductions;
|
||||
final double netPayoutAmount;
|
||||
final int? payoutWalletId;
|
||||
final String status;
|
||||
final String? notes;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
MarketplaceSettlement({
|
||||
this.id,
|
||||
this.userId,
|
||||
required this.invoiceId,
|
||||
this.salesChannelId,
|
||||
this.channelName,
|
||||
this.settlementRef,
|
||||
required this.settlementDate,
|
||||
required this.grossAmount,
|
||||
this.commissionFee = 0.0,
|
||||
this.shippingFee = 0.0,
|
||||
this.otherFees = 0.0,
|
||||
this.feeGst = 0.0,
|
||||
this.tcsGstAmount = 0.0,
|
||||
this.tdsAmount = 0.0,
|
||||
required this.totalDeductions,
|
||||
required this.netPayoutAmount,
|
||||
this.payoutWalletId,
|
||||
this.status = 'SETTLED',
|
||||
this.notes,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory MarketplaceSettlement.fromJson(Map<String, dynamic> json) {
|
||||
return MarketplaceSettlement(
|
||||
id: json['id'],
|
||||
userId: json['userId'] ?? json['user_id'],
|
||||
invoiceId: json['invoiceId'] ?? json['invoice_id'] ?? 0,
|
||||
salesChannelId: json['salesChannelId'] ?? json['sales_channel_id'],
|
||||
channelName: json['channelName'] ?? json['channel_name'],
|
||||
settlementRef: json['settlementRef'] ?? json['settlement_ref'],
|
||||
settlementDate: json['settlementDate'] != null
|
||||
? DateTime.parse(json['settlementDate'])
|
||||
: (json['settlement_date'] != null ? DateTime.parse(json['settlement_date']) : DateTime.now()),
|
||||
grossAmount: (json['grossAmount'] ?? json['gross_amount'] as num?)?.toDouble() ?? 0.0,
|
||||
commissionFee: (json['commissionFee'] ?? json['commission_fee'] as num?)?.toDouble() ?? 0.0,
|
||||
shippingFee: (json['shippingFee'] ?? json['shipping_fee'] as num?)?.toDouble() ?? 0.0,
|
||||
otherFees: (json['otherFees'] ?? json['other_fees'] as num?)?.toDouble() ?? 0.0,
|
||||
feeGst: (json['feeGst'] ?? json['fee_gst'] as num?)?.toDouble() ?? 0.0,
|
||||
tcsGstAmount: (json['tcsGstAmount'] ?? json['tcs_gst_amount'] as num?)?.toDouble() ?? 0.0,
|
||||
tdsAmount: (json['tdsAmount'] ?? json['tds_amount'] as num?)?.toDouble() ?? 0.0,
|
||||
totalDeductions: (json['totalDeductions'] ?? json['total_deductions'] as num?)?.toDouble() ?? 0.0,
|
||||
netPayoutAmount: (json['netPayoutAmount'] ?? json['net_payout_amount'] as num?)?.toDouble() ?? 0.0,
|
||||
payoutWalletId: json['payoutWalletId'] ?? json['payout_wallet_id'],
|
||||
status: json['status'] ?? 'SETTLED',
|
||||
notes: json['notes'],
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'])
|
||||
: (json['created_at'] != null ? DateTime.parse(json['created_at']) : null),
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.parse(json['updatedAt'])
|
||||
: (json['updated_at'] != null ? DateTime.parse(json['updated_at']) : null),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = {};
|
||||
if (id != null) data['id'] = id;
|
||||
if (userId != null) data['userId'] = userId;
|
||||
data['invoiceId'] = invoiceId;
|
||||
if (salesChannelId != null) data['salesChannelId'] = salesChannelId;
|
||||
if (channelName != null) data['channelName'] = channelName;
|
||||
if (settlementRef != null) data['settlementRef'] = settlementRef;
|
||||
data['settlementDate'] = settlementDate.toIso8601String().split('T')[0];
|
||||
data['grossAmount'] = grossAmount;
|
||||
data['commissionFee'] = commissionFee;
|
||||
data['shippingFee'] = shippingFee;
|
||||
data['otherFees'] = otherFees;
|
||||
data['feeGst'] = feeGst;
|
||||
data['tcsGstAmount'] = tcsGstAmount;
|
||||
data['tdsAmount'] = tdsAmount;
|
||||
data['totalDeductions'] = totalDeductions;
|
||||
data['netPayoutAmount'] = netPayoutAmount;
|
||||
if (payoutWalletId != null) data['payoutWalletId'] = payoutWalletId;
|
||||
data['status'] = status;
|
||||
if (notes != null) data['notes'] = notes;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
49
kifi-app/lib/features/sales/domain/sales_channel.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,64 @@ import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
import '../../inventory/providers/products_provider.dart';
|
||||
import '../../inventory/providers/product_categories_provider.dart';
|
||||
import '../../inventory/providers/uoms_provider.dart';
|
||||
import '../../inventory/domain/product.dart';
|
||||
import '../domain/invoice.dart';
|
||||
import '../providers/invoices_provider.dart';
|
||||
import '../providers/customers_provider.dart';
|
||||
import '../../business/providers/business_provider.dart';
|
||||
import '../../business/providers/indian_states_provider.dart';
|
||||
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import 'widgets/receive_payment_sheet.dart';
|
||||
import 'widgets/create_credit_note_sheet.dart';
|
||||
import 'widgets/settle_marketplace_order_sheet.dart';
|
||||
import 'credit_notes_list_screen.dart';
|
||||
import 'marketplace_settlements_screen.dart';
|
||||
import '../providers/credit_notes_provider.dart';
|
||||
import '../providers/marketplace_settlements_provider.dart';
|
||||
import 'invoice_builder_screen.dart';
|
||||
|
||||
bool _isCommodityProduct({
|
||||
ProductCategory? category,
|
||||
Product? product,
|
||||
InvoiceItem? invoiceItem,
|
||||
String? commodityCode,
|
||||
String? huid,
|
||||
}) {
|
||||
final h = huid ?? invoiceItem?.huid;
|
||||
if (h != null && h.trim().isNotEmpty) return true;
|
||||
|
||||
final code = commodityCode ??
|
||||
category?.commodityCode ??
|
||||
invoiceItem?.commodityCode;
|
||||
if (code != null && code.trim().isNotEmpty && code.trim().toUpperCase() != 'NONE') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
String _resolveProductUnit({
|
||||
required Product? product,
|
||||
required ProductCategory? category,
|
||||
required List<UnitOfMeasure> uoms,
|
||||
bool isCommodity = false,
|
||||
}) {
|
||||
if (isCommodity) {
|
||||
return (category != null && category.baseUnit.isNotEmpty) ? category.baseUnit : 'g';
|
||||
}
|
||||
if (product?.uomId != null) {
|
||||
final uom = uoms.where((u) => u.id == product!.uomId).firstOrNull;
|
||||
if (uom != null && uom.abbreviation != null && uom.abbreviation!.isNotEmpty) return uom.abbreviation!;
|
||||
if (uom != null && uom.name.isNotEmpty) return uom.name;
|
||||
}
|
||||
if (category != null && category.baseUnit.isNotEmpty) {
|
||||
return category.baseUnit;
|
||||
}
|
||||
return 'pcs';
|
||||
}
|
||||
|
||||
String _resolveImageUrl(String path) {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
@@ -185,10 +234,45 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
const Text('INVOICE NO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||
const SizedBox(height: 2),
|
||||
Text(latestInvoice.invoiceNumber, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.blue)),
|
||||
if (latestInvoice.salesChannel != null && latestInvoice.salesChannel!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.blue.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(LucideIcons.shoppingBag, size: 10, color: Colors.blue),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
latestInvoice.salesChannel!,
|
||||
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.blue),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (latestInvoice.marketplaceOrderId != null && latestInvoice.marketplaceOrderId!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Ref: ${latestInvoice.marketplaceOrderId}',
|
||||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: Colors.grey.shade700),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
const Text('INVOICE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||
const SizedBox(height: 2),
|
||||
Text(formatDate.format(latestInvoice.issueDate), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
if (latestInvoice.placeOfSupply != null && latestInvoice.placeOfSupply!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text('PLACE OF SUPPLY', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||
const SizedBox(height: 2),
|
||||
Text(latestInvoice.placeOfSupply!, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -233,18 +317,32 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
// Item Cards
|
||||
...(latestInvoice.items).map((item) {
|
||||
final product = products.where((p) => p.id == item.productId).firstOrNull;
|
||||
final category = categories.where((c) => c.id == product?.categoryId).firstOrNull;
|
||||
final weight = item.weight ?? item.quantity;
|
||||
final metalAmount = weight * item.unitPrice;
|
||||
final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull;
|
||||
final uoms = ref.watch(uomsProvider).value ?? [];
|
||||
|
||||
final isItemCommodity = _isCommodityProduct(
|
||||
category: category,
|
||||
product: product,
|
||||
invoiceItem: item,
|
||||
);
|
||||
|
||||
final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
|
||||
final baseAmount = qtyOrWeight * item.unitPrice;
|
||||
final unit = _resolveProductUnit(product: product, category: category, uoms: uoms, isCommodity: isItemCommodity);
|
||||
|
||||
double makingChargeAmt = 0.0;
|
||||
if (isItemCommodity) {
|
||||
if (item.makingChargesType == 'PERCENTAGE') {
|
||||
makingChargeAmt = metalAmount * (item.makingCharge / 100.0);
|
||||
makingChargeAmt = baseAmount * (item.makingCharge / 100.0);
|
||||
} else if (item.makingChargesType == 'PER_PIECE') {
|
||||
makingChargeAmt = item.makingCharge;
|
||||
} else {
|
||||
makingChargeAmt = weight * item.makingCharge;
|
||||
makingChargeAmt = qtyOrWeight * item.makingCharge;
|
||||
}
|
||||
}
|
||||
|
||||
final taxableAmount = baseAmount + makingChargeAmt + item.otherCharges - item.discount;
|
||||
final taxAmount = (taxableAmount * item.taxRate) / 100.0;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
@@ -278,7 +376,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.productName ?? product?.name ?? item.description ?? 'Jewellery Item',
|
||||
item.productName ?? product?.name ?? item.description ?? 'Item',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -290,10 +388,12 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
_buildDetailBadge(item.categoryName ?? category!.name, const Color(0xFFD4AF37)),
|
||||
if (item.sku != null || product?.sku != null)
|
||||
_buildDetailBadge('SKU: ${item.sku ?? product!.sku}', Colors.blue),
|
||||
if (item.huid != null)
|
||||
if (isItemCommodity && item.huid != null && item.huid!.isNotEmpty)
|
||||
_buildDetailBadge('HUID: ${item.huid}', Colors.purple),
|
||||
if (item.hsnCode != null)
|
||||
if (item.hsnCode != null && item.hsnCode!.isNotEmpty)
|
||||
_buildDetailBadge('HSN: ${item.hsnCode}', Colors.grey),
|
||||
if (!isItemCommodity)
|
||||
_buildDetailBadge('Unit: $unit', Colors.teal),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -306,16 +406,18 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
],
|
||||
),
|
||||
const Divider(height: 18),
|
||||
if (isItemCommodity) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Metal (${weight.toStringAsFixed(3)}g @ ₹${item.unitPrice.toStringAsFixed(2)}/g):',
|
||||
'Metal (${qtyOrWeight.toStringAsFixed(3)}$unit @ ₹${item.unitPrice.toStringAsFixed(2)}/$unit):',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||
),
|
||||
Text(formatCurrency.format(metalAmount), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
Text(formatCurrency.format(baseAmount), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
if (makingChargeAmt > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
@@ -327,6 +429,29 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
Text(formatCurrency.format(makingChargeAmt), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12, color: Colors.indigo)),
|
||||
],
|
||||
),
|
||||
],
|
||||
] else ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Rate: ₹${item.unitPrice.toStringAsFixed(2)} / $unit • Qty: ${qtyOrWeight.toStringAsFixed(qtyOrWeight.truncateToDouble() == qtyOrWeight ? 0 : 2)} $unit',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w500),
|
||||
),
|
||||
Text(formatCurrency.format(baseAmount), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (item.discount > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Discount:', style: TextStyle(fontSize: 11, color: Colors.red)),
|
||||
Text('- ${formatCurrency.format(item.discount)}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.red)),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (item.taxRate > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
@@ -334,7 +459,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
children: [
|
||||
Text('GST (${item.taxRate.toStringAsFixed(1)}%):', style: TextStyle(fontSize: 11, color: Colors.grey.shade500)),
|
||||
Text(
|
||||
'+ ${formatCurrency.format(((metalAmount + makingChargeAmt) * (item.taxRate / 100.0)))}',
|
||||
'+ ${formatCurrency.format(taxAmount)}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
@@ -415,19 +540,239 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Bottom Action: Receive Payment if balance due
|
||||
if (remaining > 0)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showReceivePaymentSheet(context, ref, latestInvoice),
|
||||
icon: const Icon(LucideIcons.plusCircle, size: 18),
|
||||
label: const Text('Record Payment', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
// Credit Notes / Returns associated with this Invoice
|
||||
if (latestInvoice.id != null) ...[
|
||||
ref.watch(invoiceCreditNotesProvider(latestInvoice.id!)).when(
|
||||
data: (cns) {
|
||||
if (cns.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Returns & Credit Notes Issued', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
...cns.map((cn) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: isDark ? 0.1 : 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.red.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.undo2, color: Colors.red, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Credit Note #${cn.creditNoteNumber}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
Text(
|
||||
'${cn.reason.replaceAll("_", " ")} • ${formatDate.format(cn.returnDate)}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
formatCurrency.format(cn.totalAmount),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
|
||||
// Marketplace Settlement Breakdown Card (if settled)
|
||||
if (latestInvoice.id != null) ...[
|
||||
ref.watch(invoiceSettlementProvider(latestInvoice.id!)).when(
|
||||
data: (settlement) {
|
||||
if (settlement == null) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Marketplace Settlement & Payout', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF10B981).withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(LucideIcons.checkCheck, size: 12, color: Color(0xFF10B981)),
|
||||
SizedBox(width: 4),
|
||||
Text('Reconciled', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Color(0xFF10B981))),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('${settlement.channelName ?? "Marketplace"} Remittance', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
Text(formatDate.format(settlement.settlementDate), style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
|
||||
],
|
||||
),
|
||||
if (settlement.settlementRef != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.hash, size: 12, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
Text('Bank UTR: ${settlement.settlementRef}', style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
|
||||
],
|
||||
),
|
||||
],
|
||||
const Divider(height: 16),
|
||||
_buildSummaryRow('Gross Sale Value', formatCurrency.format(settlement.grossAmount)),
|
||||
if (settlement.commissionFee > 0)
|
||||
_buildSummaryRow('Referral / Commission Fee', '- ${formatCurrency.format(settlement.commissionFee)}', color: Colors.red),
|
||||
if (settlement.shippingFee > 0)
|
||||
_buildSummaryRow('Logistics / Shipping Fee', '- ${formatCurrency.format(settlement.shippingFee)}', color: Colors.red),
|
||||
if (settlement.otherFees > 0)
|
||||
_buildSummaryRow('Closing / Fixed Fees', '- ${formatCurrency.format(settlement.otherFees)}', color: Colors.red),
|
||||
if (settlement.feeGst > 0)
|
||||
_buildSummaryRow('GST on Marketplace Fees (18%)', '- ${formatCurrency.format(settlement.feeGst)}', color: Colors.red),
|
||||
if (settlement.tcsGstAmount > 0)
|
||||
_buildSummaryRow('GST TCS Withheld (1%)', '- ${formatCurrency.format(settlement.tcsGstAmount)}', color: Colors.red),
|
||||
if (settlement.tdsAmount > 0)
|
||||
_buildSummaryRow('TDS u/s 194O Withheld (0.1%)', '- ${formatCurrency.format(settlement.tdsAmount)}', color: Colors.red),
|
||||
const Divider(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Net Bank Remittance Payout:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
Text(
|
||||
formatCurrency.format(settlement.netPayoutAmount),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Color(0xFF10B981)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
|
||||
// Action Buttons Row: Settle Marketplace Payout / Record Payment & Process Return
|
||||
Row(
|
||||
children: [
|
||||
if (latestInvoice.status != 'RETURNED') ...[
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => CreateCreditNoteSheet(invoice: latestInvoice),
|
||||
);
|
||||
if (result != null) {
|
||||
ref.refresh(invoicesProvider);
|
||||
if (latestInvoice.id != null) {
|
||||
ref.refresh(invoiceCreditNotesProvider(latestInvoice.id!));
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(LucideIcons.undo2, size: 16, color: Colors.red),
|
||||
label: const Text('Return / Credit Note', style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
side: BorderSide(color: Colors.red.shade300),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
if (remaining > 0) ...[
|
||||
if (latestInvoice.salesChannel != null &&
|
||||
latestInvoice.salesChannel!.toUpperCase() != 'DIRECT_POS' &&
|
||||
latestInvoice.salesChannel!.toUpperCase() != 'STORE') ...[
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => SettleMarketplaceOrderSheet(invoice: latestInvoice),
|
||||
);
|
||||
if (result != null) {
|
||||
ref.refresh(invoicesProvider);
|
||||
if (latestInvoice.id != null) {
|
||||
ref.refresh(invoiceSettlementProvider(latestInvoice.id!));
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(LucideIcons.landmark, size: 16),
|
||||
label: const Text('Settle Payout', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: const Color(0xFF10B981),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _showReceivePaymentSheet(context, ref, latestInvoice),
|
||||
icon: const Icon(LucideIcons.plusCircle, size: 16),
|
||||
label: const Text('Record Payment', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
@@ -437,6 +782,23 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Color _getDispatchColor(String? status) {
|
||||
switch (status?.toUpperCase()) {
|
||||
case 'DELIVERED':
|
||||
return Colors.green;
|
||||
case 'SHIPPED':
|
||||
case 'IN_TRANSIT':
|
||||
return Colors.indigo;
|
||||
case 'PACKED':
|
||||
return Colors.blue;
|
||||
case 'RTO':
|
||||
return Colors.red;
|
||||
case 'PENDING':
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDetailBadge(String text, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
@@ -538,7 +900,9 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
|
||||
await Share.shareXFiles([XFile(imagePath.path)], text: 'Invoice $invoiceNumber');
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing image: $e')));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing image: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,30 +914,28 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
orElse: () => widget.invoice,
|
||||
) ?? widget.invoice;
|
||||
|
||||
final pdf = pw.Document();
|
||||
final business = ref.read(businessProfileProvider).value;
|
||||
final customers = ref.read(customersProvider).value ?? [];
|
||||
final customer = customers.where((c) => c.id == latestInvoice.customerId).firstOrNull;
|
||||
|
||||
final business = ref.read(businessProfileProvider).value;
|
||||
final products = ref.read(productsProvider).value ?? [];
|
||||
final categories = ref.read(productCategoriesProvider).value ?? [];
|
||||
final uoms = ref.read(uomsProvider).value ?? [];
|
||||
final indianStates = ref.read(indianStatesProvider).value ?? [];
|
||||
|
||||
final businessState = indianStates.where((s) => s.id == business?.stateId).firstOrNull;
|
||||
final customerState = indianStates.where((s) => s.id == (latestInvoice.placeOfSupplyStateId ?? customer?.stateId)).firstOrNull;
|
||||
|
||||
final isSameState = businessState != null && customerState != null && businessState.id == customerState.id;
|
||||
|
||||
final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2);
|
||||
final formatDate = DateFormat('dd MMM yyyy');
|
||||
final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2);
|
||||
|
||||
final font = await PdfGoogleFonts.robotoRegular();
|
||||
final boldFont = await PdfGoogleFonts.robotoBold();
|
||||
|
||||
final businessStateId = business?.stateId;
|
||||
final customerStateId = customer?.stateId;
|
||||
final isSameState = businessStateId != null && customerStateId != null
|
||||
? (businessStateId == customerStateId)
|
||||
: true;
|
||||
|
||||
// Group items by product ID if available, or itemize
|
||||
// Group items by productId
|
||||
final Map<int, List<InvoiceItem>> groupedItems = {};
|
||||
final List<InvoiceItem> ungroupedItems = [];
|
||||
|
||||
for (final item in latestInvoice.items) {
|
||||
for (var item in latestInvoice.items) {
|
||||
if (item.productId != null) {
|
||||
groupedItems.putIfAbsent(item.productId!, () => []).add(item);
|
||||
} else {
|
||||
@@ -581,19 +943,15 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
final pdf = pw.Document();
|
||||
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
theme: pw.ThemeData.withFont(
|
||||
base: font,
|
||||
bold: boldFont,
|
||||
),
|
||||
margin: const pw.EdgeInsets.all(28),
|
||||
build: (pw.Context context) {
|
||||
return [
|
||||
// 1. Header with Business Details & TAX INVOICE title
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
build: (context) => [
|
||||
pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 1. Enterprise Invoice Header
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
@@ -603,14 +961,13 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
business?.businessName ?? 'KIFI JEWELLERS',
|
||||
business?.businessName ?? 'KIFI ENTERPRISE',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 22,
|
||||
fontSize: 18,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.blue900,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
if (business?.address != null && business!.address!.isNotEmpty)
|
||||
pw.Text(
|
||||
business.address!,
|
||||
@@ -639,38 +996,11 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
pw.Text(
|
||||
'TAX INVOICE',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 22,
|
||||
fontSize: 18,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.blue900,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: _getEffectiveStatus(latestInvoice) == 'PAID'
|
||||
? PdfColors.green100
|
||||
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange100 : PdfColors.grey200),
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4)),
|
||||
border: pw.Border.all(
|
||||
color: _getEffectiveStatus(latestInvoice) == 'PAID'
|
||||
? PdfColors.green700
|
||||
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange700 : PdfColors.grey500),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: pw.Text(
|
||||
_getEffectiveStatus(latestInvoice),
|
||||
style: pw.TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: _getEffectiveStatus(latestInvoice) == 'PAID'
|
||||
? PdfColors.green900
|
||||
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange900 : PdfColors.grey800),
|
||||
),
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Text(
|
||||
'Invoice #: ${latestInvoice.invoiceNumber}',
|
||||
style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold),
|
||||
@@ -679,6 +1009,21 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
'Date: ${formatDate.format(latestInvoice.issueDate)}',
|
||||
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
|
||||
),
|
||||
if (latestInvoice.salesChannel != null && latestInvoice.salesChannel!.isNotEmpty)
|
||||
pw.Text(
|
||||
'Channel: ${latestInvoice.salesChannel!}',
|
||||
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900),
|
||||
),
|
||||
if (latestInvoice.marketplaceOrderId != null && latestInvoice.marketplaceOrderId!.isNotEmpty)
|
||||
pw.Text(
|
||||
'Order Ref: #${latestInvoice.marketplaceOrderId!}',
|
||||
style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700),
|
||||
),
|
||||
if (latestInvoice.courierPartner != null || latestInvoice.trackingNumber != null)
|
||||
pw.Text(
|
||||
'Courier: ${latestInvoice.courierPartner ?? ""}${latestInvoice.trackingNumber != null ? " (AWB: ${latestInvoice.trackingNumber})" : ""}',
|
||||
style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700),
|
||||
),
|
||||
if (latestInvoice.dueDate != null)
|
||||
pw.Text(
|
||||
'Due Date: ${formatDate.format(latestInvoice.dueDate!)}',
|
||||
@@ -690,7 +1035,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
),
|
||||
pw.SizedBox(height: 18),
|
||||
|
||||
// 2. Bill To Box (Customer Details)
|
||||
// 2. Bill To & Ship To Box (Customer & Delivery Details)
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.all(10),
|
||||
decoration: pw.BoxDecoration(
|
||||
@@ -717,7 +1062,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
pw.Text(
|
||||
customer?.name ?? 'Walk-in Customer',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -733,6 +1078,38 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (latestInvoice.shippingAddress != null && latestInvoice.shippingAddress!.isNotEmpty) ...[
|
||||
pw.SizedBox(width: 14),
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
'SHIP TO (DELIVERY):',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 8.5,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(
|
||||
customer?.name ?? 'Recipient',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
pw.Text(
|
||||
latestInvoice.shippingAddress! + (latestInvoice.shippingPincode != null ? ' - ${latestInvoice.shippingPincode}' : ''),
|
||||
style: const pw.TextStyle(fontSize: 9),
|
||||
),
|
||||
if (latestInvoice.courierPartner != null)
|
||||
pw.Text('Via: ${latestInvoice.courierPartner}', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -776,6 +1153,19 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
final groupTotal = group.fold(0.0, (sum, i) => sum + i.total);
|
||||
final gstRate = group.first.taxRate > 0 ? group.first.taxRate : (product?.gstRate ?? (category?.defaultGst ?? 0.0));
|
||||
|
||||
final isGroupCommodity = group.any((item) => _isCommodityProduct(
|
||||
category: category,
|
||||
product: product,
|
||||
invoiceItem: item,
|
||||
));
|
||||
|
||||
final groupUnit = _resolveProductUnit(
|
||||
product: product,
|
||||
category: category,
|
||||
uoms: uoms,
|
||||
isCommodity: isGroupCommodity,
|
||||
);
|
||||
|
||||
return pw.Container(
|
||||
margin: const pw.EdgeInsets.only(bottom: 14),
|
||||
decoration: pw.BoxDecoration(
|
||||
@@ -839,6 +1229,7 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
style: const pw.TextStyle(fontSize: 7.5, color: PdfColors.grey800),
|
||||
),
|
||||
),
|
||||
if (isGroupCommodity)
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: pw.BoxDecoration(
|
||||
@@ -855,6 +1246,23 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isGroupCommodity)
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: PdfColors.teal50,
|
||||
border: pw.Border.all(color: PdfColors.teal400, width: 0.5),
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)),
|
||||
),
|
||||
child: pw.Text(
|
||||
'Unit: $groupUnit',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 7.5,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.teal900,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hsnStr != '-')
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
@@ -900,15 +1308,25 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
4: pw.Alignment.centerRight,
|
||||
5: pw.Alignment.centerRight,
|
||||
},
|
||||
headers: [
|
||||
headers: isGroupCommodity
|
||||
? [
|
||||
'Item / HUID',
|
||||
'Weight / Qty',
|
||||
'Rate',
|
||||
'Making Chg',
|
||||
'Other Chg',
|
||||
'Total Amt',
|
||||
]
|
||||
: [
|
||||
'Item / SKU',
|
||||
'Qty',
|
||||
'Unit Price',
|
||||
'Discount',
|
||||
'GST %',
|
||||
'Total Amt',
|
||||
],
|
||||
data: group.map((item) {
|
||||
data: isGroupCommodity
|
||||
? group.map((item) {
|
||||
String particular = '';
|
||||
if (item.huid != null && item.huid!.isNotEmpty) {
|
||||
particular = 'HUID: ${item.huid}';
|
||||
@@ -920,10 +1338,10 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
|
||||
final weight = item.weight ?? item.quantity;
|
||||
final weightQty = item.weight != null && item.weight! > 0
|
||||
? '${item.weight!.toStringAsFixed(item.weight!.truncateToDouble() == item.weight ? 0 : 3)} g'
|
||||
? '${item.weight!.toStringAsFixed(item.weight!.truncateToDouble() == item.weight ? 0 : 3)} $groupUnit'
|
||||
: '${item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 0)} pcs';
|
||||
|
||||
final rateUnit = item.weight != null && item.weight! > 0 ? '/ g' : '/ pc';
|
||||
final rateUnit = item.weight != null && item.weight! > 0 ? '/ $groupUnit' : '/ pc';
|
||||
|
||||
double makingAmt = 0.0;
|
||||
if (item.makingChargesType == 'PERCENTAGE') {
|
||||
@@ -942,6 +1360,18 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
item.otherCharges > 0 ? formatCurrency.format(item.otherCharges) : '-',
|
||||
formatCurrency.format(item.total),
|
||||
];
|
||||
}).toList()
|
||||
: group.map((item) {
|
||||
final qty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
|
||||
final qtyStr = '${qty.toStringAsFixed(qty.truncateToDouble() == qty ? 0 : 2)} $groupUnit';
|
||||
return [
|
||||
item.sku ?? skuStr ?? 'Item',
|
||||
qtyStr,
|
||||
formatCurrency.format(item.unitPrice),
|
||||
item.discount > 0 ? '-${formatCurrency.format(item.discount)}' : '-',
|
||||
'${item.taxRate.toStringAsFixed(1)}%',
|
||||
formatCurrency.format(item.total),
|
||||
];
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
@@ -959,7 +1389,9 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Metal Subtotal: ${formatCurrency.format(groupSubtotal)} | Making: ${formatCurrency.format(groupMaking)}',
|
||||
isGroupCommodity
|
||||
? 'Metal Subtotal: ${formatCurrency.format(groupSubtotal)} | Making: ${formatCurrency.format(groupMaking)}'
|
||||
: 'Items Subtotal: ${formatCurrency.format(groupSubtotal)}',
|
||||
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
|
||||
),
|
||||
pw.Text(
|
||||
@@ -1247,8 +1679,9 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ import 'package:intl/intl.dart';
|
||||
import '../providers/invoices_provider.dart';
|
||||
import '../domain/invoice.dart';
|
||||
import '../providers/customers_provider.dart';
|
||||
import '../providers/sales_channels_provider.dart';
|
||||
import 'invoice_builder_screen.dart';
|
||||
import 'invoice_details_screen.dart';
|
||||
import 'credit_notes_list_screen.dart';
|
||||
import 'marketplace_settlements_screen.dart';
|
||||
|
||||
class InvoicesListScreen extends ConsumerStatefulWidget {
|
||||
const InvoicesListScreen({super.key});
|
||||
@@ -18,6 +21,8 @@ class InvoicesListScreen extends ConsumerStatefulWidget {
|
||||
class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
String _searchQuery = '';
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String? _selectedChannelFilter;
|
||||
String? _selectedDispatchStatusFilter;
|
||||
|
||||
String _getStatusLabel(Invoice invoice) {
|
||||
if (invoice.isEmi && invoice.status != 'PAID') return 'EMI';
|
||||
@@ -47,6 +52,23 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Color _getDispatchStatusColor(String? status) {
|
||||
switch (status?.toUpperCase()) {
|
||||
case 'DELIVERED':
|
||||
return Colors.green;
|
||||
case 'SHIPPED':
|
||||
case 'IN_TRANSIT':
|
||||
return Colors.indigo;
|
||||
case 'PACKED':
|
||||
return Colors.blue;
|
||||
case 'RTO':
|
||||
return Colors.red;
|
||||
case 'PENDING':
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
@@ -57,6 +79,7 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final invoicesState = ref.watch(invoicesProvider);
|
||||
final customersState = ref.watch(customersProvider);
|
||||
final channelsState = ref.watch(salesChannelsProvider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
||||
final formatDate = DateFormat('MMM dd, yyyy');
|
||||
@@ -72,19 +95,43 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
foregroundColor: isDark ? Colors.white : Colors.black,
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.landmark),
|
||||
tooltip: 'Marketplace Settlements & Fees',
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const MarketplaceSettlementsScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.undo2),
|
||||
tooltip: 'Sales Returns & Credit Notes',
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const CreditNotesListScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Unified Search Header
|
||||
Container(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: TextField(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search Invoice # or Customer...',
|
||||
hintText: 'Search Invoice #, Order ID, Customer, Courier...',
|
||||
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300),
|
||||
@@ -105,16 +152,87 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
),
|
||||
onChanged: (val) => setState(() => _searchQuery = val.trim()),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Channel Filter Bar
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
label: const Text('All Channels', style: TextStyle(fontSize: 11)),
|
||||
selected: _selectedChannelFilter == null,
|
||||
onSelected: (selected) {
|
||||
if (selected) setState(() => _selectedChannelFilter = null);
|
||||
},
|
||||
),
|
||||
),
|
||||
...(channelsState.value ?? []).map((channel) {
|
||||
final isSelected = _selectedChannelFilter?.toLowerCase() == channel.name.toLowerCase();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
label: Text(channel.name, style: const TextStyle(fontSize: 11)),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() => _selectedChannelFilter = selected ? channel.name : null);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(width: 8),
|
||||
// Quick Dispatch Status filters
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
label: const Text('Shipped / In Transit', style: TextStyle(fontSize: 11)),
|
||||
selected: _selectedDispatchStatusFilter == 'SHIPPED',
|
||||
selectedColor: Colors.indigo.withValues(alpha: 0.2),
|
||||
onSelected: (selected) {
|
||||
setState(() => _selectedDispatchStatusFilter = selected ? 'SHIPPED' : null);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
label: const Text('Delivered', style: TextStyle(fontSize: 11)),
|
||||
selected: _selectedDispatchStatusFilter == 'DELIVERED',
|
||||
selectedColor: Colors.green.withValues(alpha: 0.2),
|
||||
onSelected: (selected) {
|
||||
setState(() => _selectedDispatchStatusFilter = selected ? 'DELIVERED' : null);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: invoicesState.when(
|
||||
data: (invoices) {
|
||||
final customers = customersState.value ?? [];
|
||||
final filtered = invoices.where((inv) {
|
||||
final matchesInv = inv.invoiceNumber.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
final q = _searchQuery.toLowerCase();
|
||||
final matchesInv = inv.invoiceNumber.toLowerCase().contains(q);
|
||||
final matchesOrder = inv.marketplaceOrderId != null && inv.marketplaceOrderId!.toLowerCase().contains(q);
|
||||
final matchesCourier = inv.courierPartner != null && inv.courierPartner!.toLowerCase().contains(q);
|
||||
final matchesAwb = inv.trackingNumber != null && inv.trackingNumber!.toLowerCase().contains(q);
|
||||
final customer = customers.where((c) => c.id == inv.customerId).firstOrNull;
|
||||
final matchesCust = customer != null && customer.name.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
return matchesInv || matchesCust;
|
||||
final matchesCust = customer != null && customer.name.toLowerCase().contains(q);
|
||||
final matchesSearch = q.isEmpty || matchesInv || matchesOrder || matchesCust || matchesCourier || matchesAwb;
|
||||
|
||||
final matchesChannel = _selectedChannelFilter == null ||
|
||||
(inv.salesChannel != null && inv.salesChannel!.toLowerCase() == _selectedChannelFilter!.toLowerCase());
|
||||
|
||||
final matchesDispatch = _selectedDispatchStatusFilter == null ||
|
||||
(inv.dispatchStatus != null && inv.dispatchStatus!.toUpperCase() == _selectedDispatchStatusFilter!.toUpperCase());
|
||||
|
||||
return matchesSearch && matchesChannel && matchesDispatch;
|
||||
}).toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
@@ -198,12 +316,51 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
invoice.invoiceNumber,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (invoice.salesChannel != null && invoice.salesChannel!.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.blue.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Text(
|
||||
invoice.salesChannel!,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.blue),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (invoice.marketplaceOrderId != null && invoice.marketplaceOrderId!.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.purple.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.purple.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Text(
|
||||
'#${invoice.marketplaceOrderId!}',
|
||||
style: TextStyle(fontSize: 10.5, fontWeight: FontWeight.bold, color: Colors.purple.shade700),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
@@ -270,12 +427,16 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
final weightStr = item.weight != null && item.weight! > 0
|
||||
? '${item.weight!.toStringAsFixed(3)} g'
|
||||
: '${item.quantity.toInt()} pcs';
|
||||
final name = item.productName ?? item.description ?? 'Jewellery Item';
|
||||
final name = item.productName ?? item.description ?? 'Item';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.sparkles, size: 12, color: Colors.amber.shade700),
|
||||
Icon(
|
||||
item.weight != null && item.weight! > 0 ? LucideIcons.sparkles : LucideIcons.package,
|
||||
size: 12,
|
||||
color: item.weight != null && item.weight! > 0 ? Colors.amber.shade700 : Colors.blue.shade700,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -305,6 +466,41 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Logistics / Shipping Tracker Row (if set)
|
||||
if (invoice.courierPartner != null || invoice.trackingNumber != null || (invoice.dispatchStatus != null && invoice.dispatchStatus != 'PENDING')) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.truck, size: 14, color: Colors.indigo.shade600),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${invoice.courierPartner ?? "Courier"}${invoice.trackingNumber != null ? " • AWB: ${invoice.trackingNumber}" : ""}',
|
||||
style: TextStyle(fontSize: 11.5, color: Colors.grey.shade700, fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: _getDispatchStatusColor(invoice.dispatchStatus).withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: _getDispatchStatusColor(invoice.dispatchStatus).withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Text(
|
||||
invoice.dispatchStatus ?? 'PENDING',
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _getDispatchStatusColor(invoice.dispatchStatus),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Row 3: Date & Total Amount
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../domain/marketplace_settlement.dart';
|
||||
import '../domain/invoice.dart';
|
||||
import '../providers/marketplace_settlements_provider.dart';
|
||||
import '../providers/invoices_provider.dart';
|
||||
import 'invoice_details_screen.dart';
|
||||
import 'widgets/settle_marketplace_order_sheet.dart';
|
||||
|
||||
class MarketplaceSettlementsScreen extends ConsumerStatefulWidget {
|
||||
const MarketplaceSettlementsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MarketplaceSettlementsScreen> createState() => _MarketplaceSettlementsScreenState();
|
||||
}
|
||||
|
||||
class _MarketplaceSettlementsScreenState extends ConsumerState<MarketplaceSettlementsScreen> with SingleTickerProviderStateMixin {
|
||||
String _searchQuery = '';
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String? _selectedChannelFilter;
|
||||
late TabController _tabController;
|
||||
|
||||
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
||||
final formatDate = DateFormat('MMM dd, yyyy');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settlementsState = ref.watch(marketplaceSettlementsProvider);
|
||||
final invoices = ref.watch(invoicesProvider).value ?? [];
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// Filter pending channel invoices (invoices that have a sales channel and are not fully settled yet)
|
||||
final pendingChannelInvoices = invoices.where((inv) {
|
||||
final isChannelOrder = inv.salesChannel != null &&
|
||||
inv.salesChannel!.isNotEmpty &&
|
||||
inv.salesChannel!.toUpperCase() != 'DIRECT_POS' &&
|
||||
inv.salesChannel!.toUpperCase() != 'STORE';
|
||||
final isUnsettled = inv.status != 'PAID' && inv.status != 'SETTLED' && inv.status != 'RETURNED';
|
||||
return isChannelOrder && isUnsettled;
|
||||
}).toList();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark ? const Color(0xFF090D16) : const Color(0xFFF4F6F9),
|
||||
appBar: AppBar(
|
||||
title: const Text('Marketplace Settlements & Fees', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||
elevation: 0,
|
||||
backgroundColor: isDark ? const Color(0xFF131B2E) : Colors.white,
|
||||
foregroundColor: isDark ? Colors.white : Colors.black87,
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.refreshCw, size: 18),
|
||||
tooltip: 'Refresh Settlements',
|
||||
onPressed: () {
|
||||
ref.refresh(marketplaceSettlementsProvider);
|
||||
ref.refresh(invoicesProvider);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: settlementsState.when(
|
||||
data: (settlements) {
|
||||
final filteredSettlements = settlements.where((s) {
|
||||
final q = _searchQuery.toLowerCase();
|
||||
final invoice = invoices.where((i) => i.id == s.invoiceId).firstOrNull;
|
||||
|
||||
final matchesRef = s.settlementRef?.toLowerCase().contains(q) ?? false;
|
||||
final matchesChannel = s.channelName?.toLowerCase().contains(q) ?? false;
|
||||
final matchesInvoice = invoice != null &&
|
||||
(invoice.invoiceNumber.toLowerCase().contains(q) ||
|
||||
(invoice.marketplaceOrderId?.toLowerCase().contains(q) ?? false));
|
||||
|
||||
final matchesSearch = q.isEmpty || matchesRef || matchesChannel || matchesInvoice;
|
||||
final matchesFilter = _selectedChannelFilter == null || s.channelName == _selectedChannelFilter;
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
}).toList();
|
||||
|
||||
// Aggregate Metrics
|
||||
double totalGross = 0.0;
|
||||
double totalNetPayout = 0.0;
|
||||
double totalDeductions = 0.0;
|
||||
double totalTcsTds = 0.0;
|
||||
|
||||
for (var s in settlements) {
|
||||
totalGross += s.grossAmount;
|
||||
totalNetPayout += s.netPayoutAmount;
|
||||
totalDeductions += s.totalDeductions;
|
||||
totalTcsTds += (s.tcsGstAmount + s.tdsAmount);
|
||||
}
|
||||
|
||||
final uniqueChannels = settlements
|
||||
.map((s) => s.channelName)
|
||||
.whereType<String>()
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1040),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// 1. Overview Header & Explanation Card
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: isDark
|
||||
? [const Color(0xFF1E293B), const Color(0xFF0F172A)]
|
||||
: [Colors.white, const Color(0xFFF1F5F9)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.03),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.indigo.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(LucideIcons.landmark, color: Colors.indigo, size: 28),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Marketplace Payout & Tax Reconciliation Hub',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Reconcile actual bank remittances against marketplace orders (Amazon, Flipkart, Shopify). Automatically track referral commissions, shipping deductions, 18% fee GST, and claimable 1% TCS / 0.1% TDS tax credits.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 2. Metric KPI Cards (2x2 Grid)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth > 700;
|
||||
return isWide
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildMetricCard(
|
||||
title: 'Gross Channel Sales',
|
||||
value: formatCurrency.format(totalGross),
|
||||
subtitle: 'Total invoiced order value',
|
||||
icon: LucideIcons.shoppingBag,
|
||||
color: Colors.blue,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildMetricCard(
|
||||
title: 'Net Bank Remittances',
|
||||
value: formatCurrency.format(totalNetPayout),
|
||||
subtitle: 'Deposited into bank accounts',
|
||||
icon: LucideIcons.landmark,
|
||||
color: const Color(0xFF10B981),
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildMetricCard(
|
||||
title: 'Fee Deductions',
|
||||
value: formatCurrency.format(totalDeductions),
|
||||
subtitle: 'Commissions, shipping & 18% GST',
|
||||
icon: LucideIcons.scissors,
|
||||
color: Colors.orange,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildMetricCard(
|
||||
title: 'TCS & TDS Tax Credits',
|
||||
value: formatCurrency.format(totalTcsTds),
|
||||
subtitle: '1% GST TCS + 0.1% TDS 194O',
|
||||
icon: LucideIcons.shieldCheck,
|
||||
color: Colors.purple,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildMetricCard(
|
||||
title: 'Gross Sales',
|
||||
value: formatCurrency.format(totalGross),
|
||||
subtitle: 'Invoiced value',
|
||||
icon: LucideIcons.shoppingBag,
|
||||
color: Colors.blue,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _buildMetricCard(
|
||||
title: 'Net Remitted',
|
||||
value: formatCurrency.format(totalNetPayout),
|
||||
subtitle: 'Bank deposits',
|
||||
icon: LucideIcons.landmark,
|
||||
color: const Color(0xFF10B981),
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildMetricCard(
|
||||
title: 'Fee Deductions',
|
||||
value: formatCurrency.format(totalDeductions),
|
||||
subtitle: 'Platform fees',
|
||||
icon: LucideIcons.scissors,
|
||||
color: Colors.orange,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _buildMetricCard(
|
||||
title: 'TCS / TDS Credits',
|
||||
value: formatCurrency.format(totalTcsTds),
|
||||
subtitle: 'Tax withholdings',
|
||||
icon: LucideIcons.shieldCheck,
|
||||
color: Colors.purple,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 3. Tab Bar (Reconciled vs Unsettled Orders)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: Colors.blue,
|
||||
unselectedLabelColor: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
|
||||
indicatorColor: Colors.blue,
|
||||
indicatorWeight: 3,
|
||||
tabs: [
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(LucideIcons.checkCheck, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text('Reconciled Settlements (${settlements.length})', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(LucideIcons.clock, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text('Unsettled Channel Orders (${pendingChannelInvoices.length})', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onTap: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 4. Tab Content View
|
||||
_tabController.index == 0
|
||||
? _buildReconciledTab(
|
||||
context,
|
||||
filteredSettlements,
|
||||
invoices,
|
||||
uniqueChannels,
|
||||
isDark,
|
||||
onSwitchToPending: () {
|
||||
_tabController.animateTo(1);
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: _buildUnsettledOrdersTab(
|
||||
context,
|
||||
pendingChannelInvoices,
|
||||
isDark,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, stack) => Center(child: Text('Error: $e')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReconciledTab(
|
||||
BuildContext context,
|
||||
List<MarketplaceSettlement> settlements,
|
||||
List<Invoice> invoices,
|
||||
List<String> uniqueChannels,
|
||||
bool isDark, {
|
||||
required VoidCallback onSwitchToPending,
|
||||
}) {
|
||||
if (settlements.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(LucideIcons.fileSpreadsheet, size: 40, color: Colors.blue),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'No Reconciled Settlements Yet',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'When you receive platform payout reports from Amazon, Flipkart, or Shopify, reconcile them here to record fee deductions, claim TCS/TDS, and update your bank balance.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onSwitchToPending,
|
||||
icon: const Icon(LucideIcons.arrowRight, size: 16),
|
||||
label: const Text('View Unsettled Channel Orders', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// Search & Channel Filter Bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search by Order ID, Bank UTR, or Channel...',
|
||||
prefixIcon: const Icon(LucideIcons.search, size: 18, color: Colors.grey),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||
filled: true,
|
||||
fillColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF1F5F9),
|
||||
isDense: true,
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(LucideIcons.xCircle, size: 16),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _searchQuery = '');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (val) => setState(() => _searchQuery = val.trim()),
|
||||
),
|
||||
if (uniqueChannels.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text('All Channels', style: TextStyle(fontSize: 12)),
|
||||
selected: _selectedChannelFilter == null,
|
||||
onSelected: (selected) {
|
||||
if (selected) setState(() => _selectedChannelFilter = null);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
...uniqueChannels.map((ch) {
|
||||
final isSelected = _selectedChannelFilter == ch;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: FilterChip(
|
||||
label: Text(ch, style: const TextStyle(fontSize: 12)),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() => _selectedChannelFilter = selected ? ch : null);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Settlements List Cards
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Column(
|
||||
children: settlements.map((s) {
|
||||
final invoice = invoices.where((i) => i.id == s.invoiceId).firstOrNull;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.02),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
s.channelName ?? 'Marketplace',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.blue),
|
||||
),
|
||||
),
|
||||
if (s.settlementRef != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'UTR: ${s.settlementRef}',
|
||||
style: TextStyle(fontSize: 12, color: isDark ? Colors.grey.shade400 : Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF10B981).withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(LucideIcons.checkCheck, size: 12, color: Color(0xFF10B981)),
|
||||
SizedBox(width: 4),
|
||||
Text('Reconciled', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Color(0xFF10B981))),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
if (invoice != null) ...[
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => InvoiceDetailsScreen(invoice: invoice)),
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.fileText, size: 15, color: Colors.blue),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Invoice #${invoice.invoiceNumber} • Order #${invoice.marketplaceOrderId ?? "-"}',
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.blue),
|
||||
),
|
||||
const Spacer(),
|
||||
const Icon(LucideIcons.chevronRight, size: 16, color: Colors.blue),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// Deductions breakdown strip
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildDetailStat('Gross Value', formatCurrency.format(s.grossAmount)),
|
||||
_buildDetailStat('Deductions', '- ${formatCurrency.format(s.totalDeductions)}', isRed: true),
|
||||
_buildDetailStat('Net Remitted', formatCurrency.format(s.netPayoutAmount), isGreen: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Settled on ${formatDate.format(s.settlementDate)}',
|
||||
style: TextStyle(fontSize: 12, color: isDark ? Colors.grey.shade400 : Colors.grey.shade600),
|
||||
),
|
||||
if (s.tcsGstAmount > 0 || s.tdsAmount > 0)
|
||||
Text(
|
||||
'TCS/TDS Claimable: ${formatCurrency.format(s.tcsGstAmount + s.tdsAmount)}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.purple.shade700, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUnsettledOrdersTab(
|
||||
BuildContext context,
|
||||
List<Invoice> pendingInvoices,
|
||||
bool isDark,
|
||||
) {
|
||||
if (pendingInvoices.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF10B981).withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(LucideIcons.checkCheck, size: 40, color: Color(0xFF10B981)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'All Marketplace Orders Reconciled!',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'There are currently no outstanding marketplace invoices waiting for bank payout reconciliation.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
'The following invoices are attributed to online channels and have not been reconciled with a bank payout:',
|
||||
style: TextStyle(fontSize: 13, color: isDark ? Colors.grey.shade400 : Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: pendingInvoices.map((inv) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
inv.salesChannel ?? 'Marketplace',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.blue),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Order #${inv.marketplaceOrderId ?? "-"}',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Invoice #${inv.invoiceNumber}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Invoice Date: ${formatDate.format(inv.issueDate)} • Gross Amount: ${formatCurrency.format(inv.totalAmount)}',
|
||||
style: TextStyle(fontSize: 12, color: isDark ? Colors.grey.shade400 : Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => SettleMarketplaceOrderSheet(invoice: inv),
|
||||
);
|
||||
if (result != null) {
|
||||
ref.refresh(marketplaceSettlementsProvider);
|
||||
ref.refresh(invoicesProvider);
|
||||
}
|
||||
},
|
||||
icon: const Icon(LucideIcons.landmark, size: 14),
|
||||
label: const Text('Settle Payout', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF10B981),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricCard({
|
||||
required String title,
|
||||
required String value,
|
||||
required String subtitle,
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
required bool isDark,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: color.withValues(alpha: 0.2)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: isDark ? 0.1 : 0.04),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: color),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(fontSize: 11, color: isDark ? Colors.grey.shade500 : Colors.grey.shade500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailStat(String label, String value, {bool isGreen = false, bool isRed = false}) {
|
||||
Color valColor = Colors.black87;
|
||||
if (isGreen) valColor = const Color(0xFF10B981);
|
||||
if (isRed) valColor = Colors.red;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: Colors.grey)),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: valColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../domain/invoice.dart';
|
||||
import '../../domain/credit_note.dart';
|
||||
import '../../../transactions/providers/providers.dart';
|
||||
import '../../providers/credit_notes_provider.dart';
|
||||
|
||||
class CreateCreditNoteSheet extends ConsumerStatefulWidget {
|
||||
final Invoice invoice;
|
||||
|
||||
const CreateCreditNoteSheet({
|
||||
super.key,
|
||||
required this.invoice,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<CreateCreditNoteSheet> createState() => _CreateCreditNoteSheetState();
|
||||
}
|
||||
|
||||
class _CreateCreditNoteSheetState extends ConsumerState<CreateCreditNoteSheet> {
|
||||
final _creditNoteNumberCtrl = TextEditingController();
|
||||
final _notesCtrl = TextEditingController();
|
||||
|
||||
DateTime _returnDate = DateTime.now();
|
||||
String _reason = 'CUSTOMER_RETURN';
|
||||
bool _restockItems = true;
|
||||
String _refundStatus = 'ADJUSTED_TO_AR';
|
||||
int? _selectedWalletId;
|
||||
bool _isLoading = false;
|
||||
|
||||
// Selected item IDs and their return quantities
|
||||
final Set<int> _selectedItemIndices = {};
|
||||
final Map<int, double> _returnQuantities = {};
|
||||
|
||||
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
||||
final formatDate = DateFormat('MMM dd, yyyy');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_creditNoteNumberCtrl.text = 'CN-${DateTime.now().millisecondsSinceEpoch.toString().substring(6)}';
|
||||
|
||||
// Select all items by default for convenience
|
||||
for (int i = 0; i < widget.invoice.items.length; i++) {
|
||||
_selectedItemIndices.add(i);
|
||||
final item = widget.invoice.items[i];
|
||||
final qty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
|
||||
_returnQuantities[i] = qty;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_creditNoteNumberCtrl.dispose();
|
||||
_notesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double _calculateReturnSubtotal() {
|
||||
double subtotal = 0.0;
|
||||
for (var idx in _selectedItemIndices) {
|
||||
if (idx < widget.invoice.items.length) {
|
||||
final item = widget.invoice.items[idx];
|
||||
final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
|
||||
final retQty = _returnQuantities[idx] ?? maxQty;
|
||||
final ratio = maxQty > 0 ? (retQty / maxQty).clamp(0.0, 1.0) : 1.0;
|
||||
subtotal += (item.unitPrice * retQty) + (item.makingCharge * ratio);
|
||||
}
|
||||
}
|
||||
return subtotal;
|
||||
}
|
||||
|
||||
double _calculateReturnTax() {
|
||||
double tax = 0.0;
|
||||
for (var idx in _selectedItemIndices) {
|
||||
if (idx < widget.invoice.items.length) {
|
||||
final item = widget.invoice.items[idx];
|
||||
final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
|
||||
final retQty = _returnQuantities[idx] ?? maxQty;
|
||||
final ratio = maxQty > 0 ? (retQty / maxQty).clamp(0.0, 1.0) : 1.0;
|
||||
final taxable = (item.unitPrice * retQty) + (item.makingCharge * ratio) + (item.otherCharges * ratio) - (item.discount * ratio);
|
||||
tax += (taxable * item.taxRate) / 100.0;
|
||||
}
|
||||
}
|
||||
return tax;
|
||||
}
|
||||
|
||||
double _calculateReturnTotal() {
|
||||
double total = 0.0;
|
||||
for (var idx in _selectedItemIndices) {
|
||||
if (idx < widget.invoice.items.length) {
|
||||
final item = widget.invoice.items[idx];
|
||||
final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
|
||||
final retQty = _returnQuantities[idx] ?? maxQty;
|
||||
final ratio = maxQty > 0 ? (retQty / maxQty).clamp(0.0, 1.0) : 1.0;
|
||||
total += item.total * ratio;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
Future<void> _submitCreditNote() async {
|
||||
if (_selectedItemIndices.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please select at least one item to return.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final List<CreditNoteItem> creditNoteItems = [];
|
||||
double subtotal = 0.0;
|
||||
double cgstTotal = 0.0;
|
||||
double sgstTotal = 0.0;
|
||||
double igstTotal = 0.0;
|
||||
double taxTotal = 0.0;
|
||||
double discountTotal = 0.0;
|
||||
double totalAmount = 0.0;
|
||||
|
||||
for (var idx in _selectedItemIndices) {
|
||||
final item = widget.invoice.items[idx];
|
||||
final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
|
||||
final retQty = _returnQuantities[idx] ?? maxQty;
|
||||
final ratio = maxQty > 0 ? (retQty / maxQty).clamp(0.0, 1.0) : 1.0;
|
||||
|
||||
final lineTotal = item.total * ratio;
|
||||
final lineCgst = item.cgst * ratio;
|
||||
final lineSgst = item.sgst * ratio;
|
||||
final lineIgst = item.igst * ratio;
|
||||
final lineDiscount = item.discount * ratio;
|
||||
|
||||
subtotal += (item.unitPrice * retQty);
|
||||
cgstTotal += lineCgst;
|
||||
sgstTotal += lineSgst;
|
||||
igstTotal += lineIgst;
|
||||
taxTotal += (lineCgst + lineSgst + lineIgst);
|
||||
discountTotal += lineDiscount;
|
||||
totalAmount += lineTotal;
|
||||
|
||||
creditNoteItems.add(CreditNoteItem(
|
||||
invoiceItemId: item.id,
|
||||
productId: item.productId,
|
||||
inventoryItemId: item.inventoryItemId,
|
||||
productName: item.productName ?? item.description,
|
||||
sku: item.sku,
|
||||
huid: item.huid,
|
||||
hsnCode: item.hsnCode,
|
||||
quantity: item.weight != null && item.weight! > 0 ? 1.0 : retQty,
|
||||
weight: item.weight != null && item.weight! > 0 ? retQty : null,
|
||||
unitPrice: item.unitPrice,
|
||||
taxRate: item.taxRate,
|
||||
cgst: lineCgst,
|
||||
sgst: lineSgst,
|
||||
igst: lineIgst,
|
||||
discount: lineDiscount,
|
||||
makingCharge: item.makingCharge * ratio,
|
||||
otherCharges: item.otherCharges * ratio,
|
||||
total: lineTotal,
|
||||
));
|
||||
}
|
||||
|
||||
final creditNote = CreditNote(
|
||||
invoiceId: widget.invoice.id!,
|
||||
creditNoteNumber: _creditNoteNumberCtrl.text.trim().isNotEmpty
|
||||
? _creditNoteNumberCtrl.text.trim()
|
||||
: 'CN-${DateTime.now().millisecondsSinceEpoch.toString().substring(6)}',
|
||||
customerId: widget.invoice.customerId,
|
||||
returnDate: _returnDate,
|
||||
reason: _reason,
|
||||
restockItems: _restockItems,
|
||||
subtotal: double.parse(subtotal.toStringAsFixed(2)),
|
||||
taxTotal: double.parse(taxTotal.toStringAsFixed(2)),
|
||||
cgstTotal: double.parse(cgstTotal.toStringAsFixed(2)),
|
||||
sgstTotal: double.parse(sgstTotal.toStringAsFixed(2)),
|
||||
igstTotal: double.parse(igstTotal.toStringAsFixed(2)),
|
||||
discountTotal: double.parse(discountTotal.toStringAsFixed(2)),
|
||||
totalAmount: double.parse(totalAmount.toStringAsFixed(2)),
|
||||
refundStatus: _refundStatus,
|
||||
refundWalletId: _refundStatus == 'REFUNDED_TO_WALLET' ? _selectedWalletId : null,
|
||||
notes: _notesCtrl.text.trim().isNotEmpty ? _notesCtrl.text.trim() : null,
|
||||
items: creditNoteItems,
|
||||
);
|
||||
|
||||
final result = await ref
|
||||
.read(creditNotesProvider.notifier)
|
||||
.createCreditNote(widget.invoice.id!, creditNote);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context, result);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Credit Note #${creditNote.creditNoteNumber} issued successfully!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error creating credit note: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final wallets = ref.watch(walletProvider).value ?? [];
|
||||
final returnTotal = _calculateReturnTotal();
|
||||
|
||||
final returnReasons = [
|
||||
{'code': 'CUSTOMER_RETURN', 'label': 'Customer Return', 'icon': LucideIcons.rotateCcw},
|
||||
{'code': 'RTO_UNDELIVERED', 'label': 'RTO / Courier Undelivered', 'icon': LucideIcons.truck},
|
||||
{'code': 'DEFECTIVE', 'label': 'Defective / Damaged', 'icon': LucideIcons.shieldAlert},
|
||||
{'code': 'EXCHANGE', 'label': 'Exchange / Replacement', 'icon': LucideIcons.refreshCw},
|
||||
{'code': 'OTHER', 'label': 'Other', 'icon': LucideIcons.fileText},
|
||||
];
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF0F172A) : Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header Bar
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(LucideIcons.undo2, color: Colors.red, size: 20),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Sales Return & Credit Note',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'Invoice #${widget.invoice.invoiceNumber}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Return Meta Card (Credit Note #, Return Date)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _creditNoteNumberCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Credit Note #',
|
||||
prefixIcon: const Icon(LucideIcons.hash, size: 16),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _returnDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime.now().add(const Duration(days: 30)),
|
||||
);
|
||||
if (picked != null) setState(() => _returnDate = picked);
|
||||
},
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Return Date',
|
||||
prefixIcon: const Icon(LucideIcons.calendar, size: 16),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
),
|
||||
child: Text(formatDate.format(_returnDate), style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Reason Selector
|
||||
DropdownButtonFormField<String>(
|
||||
value: _reason,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Return Reason',
|
||||
prefixIcon: const Icon(LucideIcons.helpCircle, size: 16),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
),
|
||||
items: returnReasons.map((r) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: r['code'] as String,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(r['icon'] as IconData, size: 15, color: Colors.red.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Text(r['label'] as String, style: const TextStyle(fontSize: 13)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) setState(() => _reason = val);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Items Selection
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Select Items to Return',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_selectedItemIndices.length == widget.invoice.items.length) {
|
||||
_selectedItemIndices.clear();
|
||||
} else {
|
||||
_selectedItemIndices.addAll(List.generate(widget.invoice.items.length, (i) => i));
|
||||
}
|
||||
});
|
||||
},
|
||||
icon: Icon(
|
||||
_selectedItemIndices.length == widget.invoice.items.length ? LucideIcons.checkSquare : LucideIcons.square,
|
||||
size: 14,
|
||||
),
|
||||
label: Text(
|
||||
_selectedItemIndices.length == widget.invoice.items.length ? 'Deselect All' : 'Select All',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Item list tiles
|
||||
...widget.invoice.items.asMap().entries.map((entry) {
|
||||
final idx = entry.key;
|
||||
final item = entry.value;
|
||||
final isSelected = _selectedItemIndices.contains(idx);
|
||||
final maxQty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
|
||||
final isWeight = item.weight != null && item.weight! > 0;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Colors.red.withValues(alpha: isDark ? 0.1 : 0.04)
|
||||
: (isDark ? const Color(0xFF1E293B) : Colors.white),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected ? Colors.red.withValues(alpha: 0.4) : (isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: isSelected,
|
||||
activeColor: Colors.red,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
if (val == true) {
|
||||
_selectedItemIndices.add(idx);
|
||||
} else {
|
||||
_selectedItemIndices.remove(idx);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.productName ?? item.description ?? 'Product Item',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Sold: ${isWeight ? "${item.weight!.toStringAsFixed(3)} g" : "${item.quantity.toInt()} pcs"} • Rate: ${formatCurrency.format(item.unitPrice)}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatCurrency.format(item.total),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Restock Switch
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
activeColor: Colors.green,
|
||||
title: const Text('Restock returned items to inventory', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
subtitle: const Text('Re-inward weights & items back to available stock', style: TextStyle(fontSize: 11)),
|
||||
value: _restockItems,
|
||||
onChanged: (val) => setState(() => _restockItems = val),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Settlement / Refund Method
|
||||
const Text('Settlement / Refund Method', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
const SizedBox(height: 6),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _refundStatus,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(LucideIcons.wallet, size: 16),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'ADJUSTED_TO_AR',
|
||||
child: Text('Adjust Customer Outstanding (Credit Note)', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'REFUNDED_TO_WALLET',
|
||||
child: Text('Refund to Cash / Bank Account', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'STORE_CREDIT',
|
||||
child: Text('Issue Store Credit', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
],
|
||||
onChanged: (val) {
|
||||
if (val != null) setState(() => _refundStatus = val);
|
||||
},
|
||||
),
|
||||
if (_refundStatus == 'REFUNDED_TO_WALLET') ...[
|
||||
const SizedBox(height: 10),
|
||||
DropdownButtonFormField<int>(
|
||||
value: _selectedWalletId ?? (wallets.isNotEmpty ? wallets.first.id : null),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Payout From Account',
|
||||
prefixIcon: const Icon(LucideIcons.arrowUpRight, size: 16, color: Colors.red),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
),
|
||||
items: wallets.map((w) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: w.id,
|
||||
child: Text('${w.name} (${w.nature})', style: const TextStyle(fontSize: 13)),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (val) => setState(() => _selectedWalletId = val),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Remarks
|
||||
TextFormField(
|
||||
controller: _notesCtrl,
|
||||
maxLines: 2,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Return Remarks / Notes (Optional)',
|
||||
prefixIcon: const Icon(LucideIcons.alignLeft, size: 16),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Total Refund Summary Box
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.red.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Credit Note Value:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
formatCurrency.format(returnTotal),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Submit Action Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isLoading ? null : _submitCreditNote,
|
||||
icon: _isLoading
|
||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(LucideIcons.checkCircle2, size: 18),
|
||||
label: Text(
|
||||
_isLoading ? 'Processing Return...' : 'Issue Credit Note (${formatCurrency.format(returnTotal)})',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../domain/invoice.dart';
|
||||
import '../../domain/marketplace_settlement.dart';
|
||||
import '../../providers/marketplace_settlements_provider.dart';
|
||||
import '../../../transactions/providers/providers.dart';
|
||||
|
||||
class SettleMarketplaceOrderSheet extends ConsumerStatefulWidget {
|
||||
final Invoice invoice;
|
||||
|
||||
const SettleMarketplaceOrderSheet({
|
||||
super.key,
|
||||
required this.invoice,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<SettleMarketplaceOrderSheet> createState() => _SettleMarketplaceOrderSheetState();
|
||||
}
|
||||
|
||||
class _SettleMarketplaceOrderSheetState extends ConsumerState<SettleMarketplaceOrderSheet> {
|
||||
final _commissionCtrl = TextEditingController();
|
||||
final _shippingFeeCtrl = TextEditingController();
|
||||
final _otherFeesCtrl = TextEditingController();
|
||||
final _feeGstCtrl = TextEditingController();
|
||||
final _tcsGstCtrl = TextEditingController();
|
||||
final _tdsCtrl = TextEditingController();
|
||||
final _settlementRefCtrl = TextEditingController();
|
||||
final _notesCtrl = TextEditingController();
|
||||
|
||||
DateTime _settlementDate = DateTime.now();
|
||||
int? _selectedWalletId;
|
||||
bool _isLoading = false;
|
||||
|
||||
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
||||
final formatDate = DateFormat('MMM dd, yyyy');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final gross = widget.invoice.totalAmount;
|
||||
|
||||
// Auto-calculate suggested TCS (1% of net taxable) and TDS (0.1% of gross)
|
||||
final suggestedTcs = double.parse(((gross * 0.01)).toStringAsFixed(2));
|
||||
final suggestedTds = double.parse(((gross * 0.001)).toStringAsFixed(2));
|
||||
|
||||
_tcsGstCtrl.text = suggestedTcs > 0 ? suggestedTcs.toString() : '0';
|
||||
_tdsCtrl.text = suggestedTds > 0 ? suggestedTds.toString() : '0';
|
||||
|
||||
_commissionCtrl.addListener(_autoUpdateGstOnFees);
|
||||
_shippingFeeCtrl.addListener(_autoUpdateGstOnFees);
|
||||
_otherFeesCtrl.addListener(_autoUpdateGstOnFees);
|
||||
}
|
||||
|
||||
void _autoUpdateGstOnFees() {
|
||||
final comm = double.tryParse(_commissionCtrl.text) ?? 0.0;
|
||||
final ship = double.tryParse(_shippingFeeCtrl.text) ?? 0.0;
|
||||
final other = double.tryParse(_otherFeesCtrl.text) ?? 0.0;
|
||||
final taxableFees = comm + ship + other;
|
||||
if (taxableFees > 0) {
|
||||
final gst = double.parse((taxableFees * 0.18).toStringAsFixed(2));
|
||||
_feeGstCtrl.text = gst.toString();
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_commissionCtrl.dispose();
|
||||
_shippingFeeCtrl.dispose();
|
||||
_otherFeesCtrl.dispose();
|
||||
_feeGstCtrl.dispose();
|
||||
_tcsGstCtrl.dispose();
|
||||
_tdsCtrl.dispose();
|
||||
_settlementRefCtrl.dispose();
|
||||
_notesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double _calculateTotalDeductions() {
|
||||
final comm = double.tryParse(_commissionCtrl.text) ?? 0.0;
|
||||
final ship = double.tryParse(_shippingFeeCtrl.text) ?? 0.0;
|
||||
final other = double.tryParse(_otherFeesCtrl.text) ?? 0.0;
|
||||
final feeGst = double.tryParse(_feeGstCtrl.text) ?? 0.0;
|
||||
final tcs = double.tryParse(_tcsGstCtrl.text) ?? 0.0;
|
||||
final tds = double.tryParse(_tdsCtrl.text) ?? 0.0;
|
||||
return comm + ship + other + feeGst + tcs + tds;
|
||||
}
|
||||
|
||||
double _calculateNetPayout() {
|
||||
return widget.invoice.totalAmount - _calculateTotalDeductions();
|
||||
}
|
||||
|
||||
Future<void> _submitSettlement() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final comm = double.tryParse(_commissionCtrl.text) ?? 0.0;
|
||||
final ship = double.tryParse(_shippingFeeCtrl.text) ?? 0.0;
|
||||
final other = double.tryParse(_otherFeesCtrl.text) ?? 0.0;
|
||||
final feeGst = double.tryParse(_feeGstCtrl.text) ?? 0.0;
|
||||
final tcs = double.tryParse(_tcsGstCtrl.text) ?? 0.0;
|
||||
final tds = double.tryParse(_tdsCtrl.text) ?? 0.0;
|
||||
final totalDeductions = _calculateTotalDeductions();
|
||||
final netPayout = _calculateNetPayout();
|
||||
|
||||
final settlement = MarketplaceSettlement(
|
||||
invoiceId: widget.invoice.id!,
|
||||
salesChannelId: widget.invoice.salesChannelId,
|
||||
channelName: widget.invoice.salesChannel ?? 'Marketplace',
|
||||
settlementRef: _settlementRefCtrl.text.trim().isNotEmpty ? _settlementRefCtrl.text.trim() : null,
|
||||
settlementDate: _settlementDate,
|
||||
grossAmount: widget.invoice.totalAmount,
|
||||
commissionFee: comm,
|
||||
shippingFee: ship,
|
||||
otherFees: other,
|
||||
feeGst: feeGst,
|
||||
tcsGstAmount: tcs,
|
||||
tdsAmount: tds,
|
||||
totalDeductions: totalDeductions,
|
||||
netPayoutAmount: netPayout,
|
||||
payoutWalletId: _selectedWalletId,
|
||||
status: 'SETTLED',
|
||||
notes: _notesCtrl.text.trim().isNotEmpty ? _notesCtrl.text.trim() : null,
|
||||
);
|
||||
|
||||
final result = await ref
|
||||
.read(marketplaceSettlementsProvider.notifier)
|
||||
.createSettlement(widget.invoice.id!, settlement);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context, result);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Marketplace settlement for Invoice #${widget.invoice.invoiceNumber} recorded successfully!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error recording settlement: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final wallets = ref.watch(walletProvider).value ?? [];
|
||||
final bankWallets = wallets.where((w) => w.nature == 'SAVINGS' || w.nature == 'CURRENT' || w.nature == 'BANK' || w.nature == 'CASH').toList();
|
||||
final effectiveWallets = bankWallets.isNotEmpty ? bankWallets : wallets;
|
||||
|
||||
final totalDeductions = _calculateTotalDeductions();
|
||||
final netPayout = _calculateNetPayout();
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF0F172A) : Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header Bar
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.indigo.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(LucideIcons.landmark, color: Colors.indigo, size: 20),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Marketplace Settlement',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'${widget.invoice.salesChannel ?? "Marketplace"} • Order #${widget.invoice.marketplaceOrderId ?? widget.invoice.invoiceNumber}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Gross Invoice Value Banner
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Gross Invoice Value', style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
formatCurrency.format(widget.invoice.totalAmount),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
widget.invoice.salesChannel ?? 'Marketplace',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.blue),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Fee Deductions Breakdown
|
||||
const Text('Channel Fee Deductions', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _commissionCtrl,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Referral / Commission',
|
||||
prefixText: '₹ ',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _shippingFeeCtrl,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Logistics / Shipping',
|
||||
prefixText: '₹ ',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _otherFeesCtrl,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Closing / Fixed Fees',
|
||||
prefixText: '₹ ',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _feeGstCtrl,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'GST on Fees (18%)',
|
||||
prefixText: '₹ ',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Statutory Tax Deductions (TCS & TDS)
|
||||
const Text('Tax Withholdings (TCS & TDS)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _tcsGstCtrl,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'GST TCS (1%)',
|
||||
prefixText: '₹ ',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
helperText: 'Tax Asset Claimable in GST',
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _tdsCtrl,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'TDS u/s 194O (0.1%)',
|
||||
prefixText: '₹ ',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
helperText: 'Claimable in Income Tax',
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Net Payout Calculation Summary Box
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF10B981).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFF10B981).withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Total Marketplace Deductions:', style: TextStyle(fontSize: 13, color: Colors.grey)),
|
||||
Text(
|
||||
'- ${formatCurrency.format(totalDeductions)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 14),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Net Bank Remittance Payout:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
Text(
|
||||
formatCurrency.format(netPayout),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Color(0xFF10B981)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bank & Settlement Reference Details
|
||||
const Text('Payout Deposit Details', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
DropdownButtonFormField<int>(
|
||||
value: _selectedWalletId ?? (effectiveWallets.isNotEmpty ? effectiveWallets.first.id : null),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Bank / Payout Account',
|
||||
prefixIcon: const Icon(LucideIcons.landmark, size: 16),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
),
|
||||
items: effectiveWallets.map((w) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: w.id,
|
||||
child: Text('${w.name} (${w.nature ?? "Bank"})', style: const TextStyle(fontSize: 13)),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (val) => setState(() => _selectedWalletId = val),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _settlementRefCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Bank UTR / Payout Ref',
|
||||
prefixIcon: const Icon(LucideIcons.hash, size: 16),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _settlementDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime.now().add(const Duration(days: 30)),
|
||||
);
|
||||
if (picked != null) setState(() => _settlementDate = picked);
|
||||
},
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Payout Date',
|
||||
prefixIcon: const Icon(LucideIcons.calendar, size: 16),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
),
|
||||
child: Text(formatDate.format(_settlementDate), style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
TextFormField(
|
||||
controller: _notesCtrl,
|
||||
maxLines: 2,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Settlement Notes / Remarks (Optional)',
|
||||
prefixIcon: const Icon(LucideIcons.alignLeft, size: 16),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Submit Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isLoading ? null : _submitSettlement,
|
||||
icon: _isLoading
|
||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(LucideIcons.checkCheck, size: 18),
|
||||
label: Text(
|
||||
_isLoading ? 'Reconciling Payout...' : 'Confirm Settlement (${formatCurrency.format(netPayout)})',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF10B981),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import '../domain/credit_note.dart';
|
||||
import 'invoices_provider.dart';
|
||||
|
||||
class CreditNotesNotifier extends AsyncNotifier<List<CreditNote>> {
|
||||
@override
|
||||
FutureOr<List<CreditNote>> build() async {
|
||||
return _fetchCreditNotes();
|
||||
}
|
||||
|
||||
Future<List<CreditNote>> _fetchCreditNotes() async {
|
||||
try {
|
||||
final response = await DioClient().dio.get('/credit-notes');
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((e) => CreditNote.fromJson(e)).toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final creditNotes = await _fetchCreditNotes();
|
||||
state = AsyncValue.data(creditNotes);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<CreditNote?> createCreditNote(int invoiceId, CreditNote creditNote) async {
|
||||
try {
|
||||
final response = await DioClient().dio.post(
|
||||
'/invoices/$invoiceId/credit-notes',
|
||||
data: creditNote.toJson(),
|
||||
);
|
||||
await refresh();
|
||||
ref.refresh(invoicesProvider);
|
||||
if (response.data != null) {
|
||||
return CreditNote.fromJson(response.data);
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (e is DioException) {
|
||||
throw Exception(
|
||||
'Failed to process return: ${e.response?.statusCode} - ${e.response?.data}',
|
||||
);
|
||||
}
|
||||
throw Exception('Failed to process return: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final creditNotesProvider =
|
||||
AsyncNotifierProvider<CreditNotesNotifier, List<CreditNote>>(CreditNotesNotifier.new);
|
||||
|
||||
final invoiceCreditNotesProvider =
|
||||
FutureProvider.family<List<CreditNote>, int>((ref, invoiceId) async {
|
||||
try {
|
||||
final response = await DioClient().dio.get('/invoices/$invoiceId/credit-notes');
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((e) => CreditNote.fromJson(e)).toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import '../domain/marketplace_settlement.dart';
|
||||
import 'invoices_provider.dart';
|
||||
|
||||
class MarketplaceSettlementsNotifier extends AsyncNotifier<List<MarketplaceSettlement>> {
|
||||
@override
|
||||
FutureOr<List<MarketplaceSettlement>> build() async {
|
||||
return _fetchSettlements();
|
||||
}
|
||||
|
||||
Future<List<MarketplaceSettlement>> _fetchSettlements() async {
|
||||
try {
|
||||
final response = await DioClient().dio.get('/marketplace-settlements');
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((e) => MarketplaceSettlement.fromJson(e)).toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final settlements = await _fetchSettlements();
|
||||
state = AsyncValue.data(settlements);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<MarketplaceSettlement?> createSettlement(int invoiceId, MarketplaceSettlement settlement) async {
|
||||
try {
|
||||
final response = await DioClient().dio.post(
|
||||
'/invoices/$invoiceId/settlements',
|
||||
data: settlement.toJson(),
|
||||
);
|
||||
await refresh();
|
||||
ref.refresh(invoicesProvider);
|
||||
if (response.data != null) {
|
||||
return MarketplaceSettlement.fromJson(response.data);
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (e is DioException) {
|
||||
throw Exception(
|
||||
'Failed to record settlement: ${e.response?.statusCode} - ${e.response?.data}',
|
||||
);
|
||||
}
|
||||
throw Exception('Failed to record settlement: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final marketplaceSettlementsProvider =
|
||||
AsyncNotifierProvider<MarketplaceSettlementsNotifier, List<MarketplaceSettlement>>(
|
||||
MarketplaceSettlementsNotifier.new);
|
||||
|
||||
final invoiceSettlementProvider =
|
||||
FutureProvider.family<MarketplaceSettlement?, int>((ref, invoiceId) async {
|
||||
try {
|
||||
final response = await DioClient().dio.get('/invoices/$invoiceId/settlements');
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data;
|
||||
if (data.isNotEmpty) {
|
||||
return MarketplaceSettlement.fromJson(data.first);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -601,6 +601,7 @@ class _PurchaseOrderBuilderScreenState
|
||||
const SizedBox(height: 12),
|
||||
if (_items.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100,
|
||||
@@ -608,6 +609,8 @@ class _PurchaseOrderBuilderScreenState
|
||||
border: Border.all(color: Colors.grey.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.packageOpen, size: 48, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 12),
|
||||
@@ -785,7 +788,10 @@ class _PurchaseOrderBuilderScreenState
|
||||
builder: (context) {
|
||||
final products = ref.watch(productsProvider).value ?? [];
|
||||
final categories = ref.watch(productCategoriesProvider).value ?? [];
|
||||
final businessState = ref.watch(businessProfileProvider).value?.stateId;
|
||||
final businessProfile = ref.watch(businessProfileProvider).value;
|
||||
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||
final isJewellery = nature == 'JEWELLERY';
|
||||
final businessState = businessProfile?.stateId;
|
||||
final vendorState = _selectedVendorId == null
|
||||
? null
|
||||
: vendorsState.value?.where((v) => v.id == _selectedVendorId).firstOrNull?.stateId;
|
||||
@@ -807,6 +813,22 @@ class _PurchaseOrderBuilderScreenState
|
||||
final effectiveTaxable = (rawSubtotal - discountAmount).clamp(0.0, double.infinity);
|
||||
final grandTotal = effectiveTaxable + taxTotal;
|
||||
|
||||
final effectiveGstRate = (effectiveTaxable > 0 && taxTotal > 0)
|
||||
? (taxTotal / effectiveTaxable) * 100.0
|
||||
: (_items.isNotEmpty ? _items.first.taxRate : (isJewellery ? 3.0 : 18.0));
|
||||
final cgstRate = effectiveGstRate / 2.0;
|
||||
final sgstRate = effectiveGstRate / 2.0;
|
||||
final igstRate = effectiveGstRate;
|
||||
|
||||
String formatRate(double rate) {
|
||||
if (rate <= 0) return '0%';
|
||||
final rounded = (rate * 100).round() / 100;
|
||||
if (rounded.truncateToDouble() == rounded) {
|
||||
return '${rounded.toInt()}%';
|
||||
}
|
||||
return '${rounded.toStringAsFixed(1)}%';
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
@@ -848,7 +870,7 @@ class _PurchaseOrderBuilderScreenState
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('CGST (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)),
|
||||
Text('CGST (${formatRate(cgstRate)}):', style: TextStyle(color: Colors.grey.shade600)),
|
||||
Text('₹${(taxTotal / 2.0).toStringAsFixed(2)}'),
|
||||
],
|
||||
),
|
||||
@@ -856,7 +878,7 @@ class _PurchaseOrderBuilderScreenState
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('SGST (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)),
|
||||
Text('SGST (${formatRate(sgstRate)}):', style: TextStyle(color: Colors.grey.shade600)),
|
||||
Text('₹${(taxTotal / 2.0).toStringAsFixed(2)}'),
|
||||
],
|
||||
),
|
||||
@@ -864,7 +886,7 @@ class _PurchaseOrderBuilderScreenState
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('IGST (${(taxTotal > 0 ? '3.0%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)),
|
||||
Text('IGST (${formatRate(igstRate)}):', style: TextStyle(color: Colors.grey.shade600)),
|
||||
Text('₹${taxTotal.toStringAsFixed(2)}'),
|
||||
],
|
||||
),
|
||||
@@ -1175,6 +1197,9 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
|
||||
}) {
|
||||
final sku = product.sku;
|
||||
final qtyCtrl = _getQtyController(product.id ?? 0);
|
||||
final businessProfile = ref.watch(businessProfileProvider).value;
|
||||
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||
final isJewellery = nature == 'JEWELLERY';
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
@@ -1228,12 +1253,14 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
|
||||
_buildPOSheetBadge(category!.name, const Color(0xFFD4AF37)),
|
||||
if (sku != null && sku.isNotEmpty)
|
||||
_buildPOSheetBadge('SKU: $sku', Colors.blue),
|
||||
if (isJewellery)
|
||||
_buildPOSheetBadge('Purity: ${formatPurity(resolvePurity(categoryPurity: category?.purityFactor, productPurity: product.purityFactor))}', Colors.teal),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isJewellery) ...[
|
||||
const SizedBox(width: 12),
|
||||
// Small box to enter number of items
|
||||
SizedBox(
|
||||
@@ -1251,13 +1278,14 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const Divider(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1;
|
||||
final qty = isJewellery ? (int.tryParse(qtyCtrl.text.trim()) ?? 1) : 1;
|
||||
_addItemsFromCard(product, category, qty);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
@@ -1278,7 +1306,7 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
|
||||
}
|
||||
|
||||
|
||||
class _POItemRow extends StatefulWidget {
|
||||
class _POItemRow extends ConsumerStatefulWidget {
|
||||
final PurchaseOrderItem item;
|
||||
final String productName;
|
||||
final Product? product;
|
||||
@@ -1300,14 +1328,15 @@ class _POItemRow extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<_POItemRow> createState() => _POItemRowState();
|
||||
ConsumerState<_POItemRow> createState() => _POItemRowState();
|
||||
}
|
||||
|
||||
class _POItemRowState extends State<_POItemRow> {
|
||||
class _POItemRowState extends ConsumerState<_POItemRow> {
|
||||
late TextEditingController _weightCtrl;
|
||||
late TextEditingController _rateCtrl;
|
||||
late TextEditingController _huidCtrl;
|
||||
late TextEditingController _totalCtrl;
|
||||
bool _isUploadingPhoto = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -1339,15 +1368,13 @@ class _POItemRowState extends State<_POItemRow> {
|
||||
|
||||
final updated = widget.item.copyWith(
|
||||
weight: weight,
|
||||
huid: _huidCtrl.text.isEmpty ? null : _huidCtrl.text,
|
||||
unitPrice: rate,
|
||||
total: total,
|
||||
huid: _huidCtrl.text.isEmpty ? null : _huidCtrl.text,
|
||||
);
|
||||
widget.onChanged(updated);
|
||||
}
|
||||
|
||||
bool _isUploadingPhoto = false;
|
||||
|
||||
Future<void> _pickPhoto() async {
|
||||
try {
|
||||
final picker = ImagePicker();
|
||||
@@ -1393,6 +1420,10 @@ class _POItemRowState extends State<_POItemRow> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final businessProfile = ref.watch(businessProfileProvider).value;
|
||||
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||
final isJewellery = nature == 'JEWELLERY';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
@@ -1534,6 +1565,7 @@ class _POItemRowState extends State<_POItemRow> {
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey[700]),
|
||||
),
|
||||
),
|
||||
if (isJewellery)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
@@ -1558,8 +1590,14 @@ class _POItemRowState extends State<_POItemRow> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= 600;
|
||||
|
||||
if (isWide) {
|
||||
return Row(
|
||||
children: [
|
||||
if (isJewellery) ...[
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: PremiumTextField(
|
||||
@@ -1570,11 +1608,92 @@ class _POItemRowState extends State<_POItemRow> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
flex: isJewellery ? 2 : 3,
|
||||
child: PremiumTextField(
|
||||
controller: _weightCtrl,
|
||||
labelText: isJewellery ? 'Weight/Pcs' : 'Weight/Pcs/Length',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (_) => _updateItem(),
|
||||
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withValues(alpha: 0.1),
|
||||
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
|
||||
border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [Text(widget.category?.baseUnit ?? '', style: TextStyle(color: Colors.grey[700]))],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: isJewellery ? 2 : 3,
|
||||
child: PremiumTextField(
|
||||
controller: _rateCtrl,
|
||||
labelText: 'Rate',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (_) => _updateItem(),
|
||||
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withValues(alpha: 0.1),
|
||||
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
|
||||
border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [Text('per ${widget.category?.baseUnit ?? ''}', style: TextStyle(color: Colors.grey[700]))],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: isJewellery ? 2 : 3,
|
||||
child: PremiumTextField(
|
||||
controller: _totalCtrl,
|
||||
labelText: 'Purchase Amt',
|
||||
keyboardType: TextInputType.number,
|
||||
readOnly: true,
|
||||
onChanged: (_) => _updateItem(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (isJewellery) ...[
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: PremiumTextField(
|
||||
controller: _huidCtrl,
|
||||
labelText: 'HUID',
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
onChanged: (_) => _updateItem(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: PremiumTextField(
|
||||
controller: _weightCtrl,
|
||||
labelText: 'Weight/Pcs',
|
||||
labelText: isJewellery ? 'Weight/Pcs' : 'Weight/Pcs/Length',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (_) => _updateItem(),
|
||||
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
|
||||
@@ -1636,6 +1755,10 @@ class _POItemRowState extends State<_POItemRow> {
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTaxAndTotal(),
|
||||
],
|
||||
|
||||
@@ -65,6 +65,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.9"
|
||||
barcode_widget:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: barcode_widget
|
||||
sha256: "6f2c5b08659b1a5f4d88d183e6007133ea2f96e50e7b8bb628f03266c3931427"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
bidi:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -60,6 +60,7 @@ dependencies:
|
||||
intl_phone_field: ^3.2.0
|
||||
url_launcher: ^6.3.2
|
||||
lucide_icons_flutter: ^3.1.17
|
||||
barcode_widget: ^2.0.4
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 795 B |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 161 KiB |
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="description" content="KIFI - Modern Financial Ledger & Jewellery Business Suite">
|
||||
<meta name="description" content="KIFI - Modern Financial Ledger & Business ERP">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -16,7 +16,7 @@
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>KIFI - Financial Ledger & Jewellery ERP</title>
|
||||
<title>KIFI - Financial Ledger & Business ERP</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<style>
|
||||
body {
|
||||
@@ -85,9 +85,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="splash-container">
|
||||
<div class="splash-logo">💎</div>
|
||||
<img src="icons/Icon-192.png" alt="KIFI" style="width: 64px; height: 64px; border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); animation: pulse 2s infinite ease-in-out;">
|
||||
<div class="splash-title">KIFI</div>
|
||||
<div class="splash-sub">Financial Ledger & Jewellery ERP</div>
|
||||
<div class="splash-sub">Financial Ledger & Business ERP</div>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
|
||||