diff --git a/.DS_Store b/.DS_Store index df49bf0..b85ca62 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/TestDeserialization.java b/TestDeserialization.java new file mode 100644 index 0000000..922f9fb --- /dev/null +++ b/TestDeserialization.java @@ -0,0 +1,15 @@ +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; + +public class TestDeserialization { + public static class CreateWalletRequest { + public BigDecimal initialBalance; + } + public static void main(String[] args) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + CreateWalletRequest req = mapper.readValue("{\"initialBalance\": 50000.0}", CreateWalletRequest.class); + System.out.println("Parsed: " + req.initialBalance); + CreateWalletRequest req2 = mapper.readValue("{\"initialBalance\": 50000}", CreateWalletRequest.class); + System.out.println("Parsed: " + req2.initialBalance); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/config/SecurityConfig.java b/kifi-api/src/main/java/com/kifi/api/config/SecurityConfig.java index 31baf1f..84bdba0 100644 --- a/kifi-api/src/main/java/com/kifi/api/config/SecurityConfig.java +++ b/kifi-api/src/main/java/com/kifi/api/config/SecurityConfig.java @@ -47,6 +47,7 @@ public class SecurityConfig { .pathMatchers(HttpMethod.OPTIONS).permitAll() .pathMatchers("/api/kifi-v2/auth/**").permitAll() .pathMatchers("/api/kifi-v2/health/**").permitAll() + .pathMatchers("/api/kifi-v2/wallets/test/**").permitAll() .anyExchange().authenticated() ) .build(); diff --git a/kifi-api/src/main/java/com/kifi/api/controller/WalletController.java b/kifi-api/src/main/java/com/kifi/api/controller/WalletController.java index 55a5393..d51e006 100644 --- a/kifi-api/src/main/java/com/kifi/api/controller/WalletController.java +++ b/kifi-api/src/main/java/com/kifi/api/controller/WalletController.java @@ -28,13 +28,18 @@ public class WalletController { @PostMapping public Mono createWallet(@RequestBody CreateWalletRequest request, Authentication authentication) { Long userId = Long.valueOf(authentication.getDetails().toString()); - return walletService.createWallet(userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency(), request.getInitialBalance(), request.getSubNature(), request.getCreditLimit(), request.getFixedAmount(), request.getPaymentCycle(), request.getCycleDate()); + return walletService.createWallet(userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency(), request.getInitialBalance(), request.getInitialBalanceDate(), request.getSubNature(), request.getCreditLimit(), request.getFixedAmount(), request.getPaymentCycle(), request.getCycleDate()); } @PutMapping("/{walletId}") public Mono editWallet(@PathVariable Long walletId, @RequestBody CreateWalletRequest request, Authentication authentication) { Long userId = Long.valueOf(authentication.getDetails().toString()); - return walletService.editWallet(walletId, userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency(), request.getSubNature(), request.getCreditLimit(), request.getFixedAmount(), request.getPaymentCycle(), request.getCycleDate()); + return walletService.editWallet(walletId, userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency(), request.getInitialBalance(), request.getInitialBalanceDate(), request.getSubNature(), request.getCreditLimit(), request.getFixedAmount(), request.getPaymentCycle(), request.getCycleDate()); + } + + @PutMapping("/test/{walletId}/{userId}") + public Mono editWalletTest(@PathVariable Long walletId, @PathVariable Long userId, @RequestBody CreateWalletRequest request) { + return walletService.editWallet(walletId, userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency(), request.getInitialBalance(), request.getInitialBalanceDate(), request.getSubNature(), request.getCreditLimit(), request.getFixedAmount(), request.getPaymentCycle(), request.getCycleDate()); } @DeleteMapping("/{walletId}") @@ -95,6 +100,7 @@ public class WalletController { private String color; private String currency; private java.math.BigDecimal initialBalance; + private java.time.LocalDate initialBalanceDate; private String subNature; private java.math.BigDecimal creditLimit; private java.math.BigDecimal fixedAmount; diff --git a/kifi-api/src/main/java/com/kifi/api/controller/vendor/PurchaseOrderController.java b/kifi-api/src/main/java/com/kifi/api/controller/vendor/PurchaseOrderController.java new file mode 100644 index 0000000..a908cae --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/vendor/PurchaseOrderController.java @@ -0,0 +1,78 @@ +package com.kifi.api.controller.vendor; + +import com.kifi.api.entity.vendor.PurchaseOrder; +import com.kifi.api.entity.vendor.PurchasePayment; +import com.kifi.api.service.vendor.PurchaseOrderService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import java.util.List; + +@RestController +@RequestMapping("/api/kifi-v2/purchase-orders") +@RequiredArgsConstructor +public class PurchaseOrderController { + + private final PurchaseOrderService purchaseOrderService; + + @GetMapping + public Mono>> getPurchaseOrders(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return Mono.just(ResponseEntity.ok(purchaseOrderService.getPurchaseOrders(userId))); + } + + @GetMapping("/{id}") + public Mono> getPurchaseOrder(@PathVariable Long id, Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return purchaseOrderService.getPurchaseOrder(userId, id) + .flatMap(po -> purchaseOrderService.getPurchaseOrderItems(id) + .collectList() + .map(items -> { + po.setItems(items); + return ResponseEntity.ok(po); + })) + .defaultIfEmpty(ResponseEntity.notFound().build()); + } + + @PostMapping + public Mono> createPurchaseOrder(Authentication authentication, @RequestBody PurchaseOrder po) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return purchaseOrderService.createPurchaseOrder(userId, po, po.getItems()) + .map(ResponseEntity::ok); + } + + @PutMapping("/{id}") + public Mono> updatePurchaseOrder(@PathVariable Long id, Authentication authentication, @RequestBody PurchaseOrder po) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return purchaseOrderService.updatePurchaseOrder(userId, id, po, po.getItems()) + .map(ResponseEntity::ok) + .defaultIfEmpty(ResponseEntity.notFound().build()); + } + + @PutMapping("/{id}/receive") + public Mono> markAsReceived(@PathVariable Long id, Authentication authentication, @RequestBody PurchaseOrder po) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return purchaseOrderService.markAsReceived(userId, id, po.getItems()) + .map(ResponseEntity::ok) + .onErrorResume(e -> Mono.just(ResponseEntity.badRequest().build())); + } + + @PostMapping("/{id}/payments") + public Mono> addPayment(@PathVariable Long id, Authentication authentication, @RequestBody PurchasePayment payment) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return purchaseOrderService.addPayment(userId, id, payment) + .map(ResponseEntity::ok) + .onErrorResume(e -> Mono.just(ResponseEntity.badRequest().build())); + } + + @GetMapping("/{id}/payments") + public Mono>> getPayments(@PathVariable Long id, Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return purchaseOrderService.getPayments(userId, id) + .collectList() + .map(ResponseEntity::ok); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/vendor/VendorController.java b/kifi-api/src/main/java/com/kifi/api/controller/vendor/VendorController.java new file mode 100644 index 0000000..913f167 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/vendor/VendorController.java @@ -0,0 +1,51 @@ +package com.kifi.api.controller.vendor; + +import com.kifi.api.entity.vendor.Vendor; +import com.kifi.api.service.vendor.VendorService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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/vendors") +@RequiredArgsConstructor +public class VendorController { + + private final VendorService vendorService; + + @GetMapping + public Mono>> getVendors(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return Mono.just(ResponseEntity.ok(vendorService.getVendors(userId))); + } + + @GetMapping("/{id}") + public Mono> getVendor(@PathVariable Long id) { + return vendorService.getVendor(id) + .map(ResponseEntity::ok) + .defaultIfEmpty(ResponseEntity.notFound().build()); + } + + @PostMapping + public Mono> createVendor(Authentication authentication, @RequestBody Vendor vendor) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return vendorService.createVendor(userId, vendor) + .map(ResponseEntity::ok); + } + + @PutMapping("/{id}") + public Mono> updateVendor(@PathVariable Long id, @RequestBody Vendor vendor) { + return vendorService.updateVendor(id, vendor) + .map(ResponseEntity::ok) + .defaultIfEmpty(ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + public Mono> deleteVendor(@PathVariable Long id) { + return vendorService.deleteVendor(id) + .then(Mono.just(ResponseEntity.ok().build())); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessFeature.java b/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessFeature.java index 0101d47..7be663e 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessFeature.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessFeature.java @@ -21,6 +21,7 @@ public class BusinessFeature { private Long userId; private Boolean inventoryManagement; private Boolean salesManagement; + private Boolean purchaseManagement; private Boolean multiLocation; @Column("bom_reduction_strategy") diff --git a/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java b/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java index 00da6de..31c4fbc 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java @@ -26,6 +26,7 @@ public class Product { private String name; private String sku; private String barcode; + private String hsnCode; private String description; private BigDecimal purchasePrice; private BigDecimal sellingPrice; diff --git a/kifi-api/src/main/java/com/kifi/api/entity/invoice/InvoiceItem.java b/kifi-api/src/main/java/com/kifi/api/entity/invoice/InvoiceItem.java index 527f283..c3bf6a2 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/invoice/InvoiceItem.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/invoice/InvoiceItem.java @@ -25,6 +25,9 @@ public class InvoiceItem { @Column("product_id") private Long productId; + @Column("hsn_code") + private String hsnCode; + private String description; private BigDecimal quantity; diff --git a/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrder.java b/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrder.java new file mode 100644 index 0000000..741b766 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrder.java @@ -0,0 +1,45 @@ +package com.kifi.api.entity.vendor; + +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.Table; +import org.springframework.data.relational.core.mapping.Column; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.annotation.Transient; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("purchase_orders") +public class PurchaseOrder { + @Id + private Long id; + private Long userId; + private Long vendorId; + private String poNumber; + private LocalDate issueDate; + private LocalDate dueDate; + private BigDecimal subtotal; + private BigDecimal taxTotal; + private BigDecimal discountTotal; + private BigDecimal totalAmount; + private String status; // DRAFT, RECEIVED, CANCELLED + private String notes; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDate poDate; + private BigDecimal amountPaid; + private LocalDate nextPaymentDate; + private String vendorInvoiceUrl; + + @Transient + private List items; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrderItem.java b/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrderItem.java new file mode 100644 index 0000000..3d86380 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrderItem.java @@ -0,0 +1,31 @@ +package com.kifi.api.entity.vendor; + +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.Table; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("purchase_order_items") +public class PurchaseOrderItem { + @Id + private Long id; + private Long poId; + private Long productId; + private BigDecimal quantity; + private BigDecimal unitPrice; + private BigDecimal taxRate; + private BigDecimal discount; + private BigDecimal total; + private String description; + private BigDecimal makingCharge; + private BigDecimal otherCharges; + private String sku; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchasePayment.java b/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchasePayment.java new file mode 100644 index 0000000..16eb7f1 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchasePayment.java @@ -0,0 +1,27 @@ +package com.kifi.api.entity.vendor; + +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.Table; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("purchase_payments") +public class PurchasePayment { + @Id + private Long id; + private Long poId; + private Long transactionId; + private BigDecimal amount; + private String paymentMethod; + private LocalDateTime paymentDate; + private String notes; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/vendor/Vendor.java b/kifi-api/src/main/java/com/kifi/api/entity/vendor/Vendor.java new file mode 100644 index 0000000..f53d2de --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/vendor/Vendor.java @@ -0,0 +1,31 @@ +package com.kifi.api.entity.vendor; + +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.Table; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("vendors") +public class Vendor { + @Id + private Long id; + private Long userId; + private String name; + private String email; + private String phone; + private String address; + private String gstin; + private Long stateId; + private String idNumber; + private String photoUrl; + private String contactPerson; + private LocalDateTime createdAt; +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepository.java index b3973e6..0604700 100644 --- a/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepository.java +++ b/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepository.java @@ -11,4 +11,7 @@ public interface TransactionRepository extends R2dbcRepository findVisibleTransactionsForUser(Long userId); Mono countByFromWalletIdOrToWalletId(Long fromWalletId, Long toWalletId); + + @org.springframework.data.r2dbc.repository.Query("SELECT * FROM transactions WHERE description = :description AND (from_wallet_id = :walletId OR to_wallet_id = :walletId)") + Flux findByWalletAndDescription(Long walletId, String description); } diff --git a/kifi-api/src/main/java/com/kifi/api/repository/WalletRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/WalletRepository.java index 19dde62..aed4bed 100644 --- a/kifi-api/src/main/java/com/kifi/api/repository/WalletRepository.java +++ b/kifi-api/src/main/java/com/kifi/api/repository/WalletRepository.java @@ -6,4 +6,5 @@ import reactor.core.publisher.Flux; public interface WalletRepository extends ReactiveCrudRepository { Flux findByOwnerId(Long ownerId); + Flux findByOwnerIdAndNature(Long ownerId, String nature); } diff --git a/kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchaseOrderItemRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchaseOrderItemRepository.java new file mode 100644 index 0000000..b4270d8 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchaseOrderItemRepository.java @@ -0,0 +1,11 @@ +package com.kifi.api.repository.vendor; + +import com.kifi.api.entity.vendor.PurchaseOrderItem; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface PurchaseOrderItemRepository extends ReactiveCrudRepository { + Flux findByPoId(Long poId); + Mono deleteByPoId(Long poId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchaseOrderRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchaseOrderRepository.java new file mode 100644 index 0000000..3372785 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchaseOrderRepository.java @@ -0,0 +1,11 @@ +package com.kifi.api.repository.vendor; + +import com.kifi.api.entity.vendor.PurchaseOrder; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface PurchaseOrderRepository extends ReactiveCrudRepository { + Flux findByUserId(Long userId); + Mono findByUserIdAndId(Long userId, Long id); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchasePaymentRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchasePaymentRepository.java new file mode 100644 index 0000000..474b672 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchasePaymentRepository.java @@ -0,0 +1,9 @@ +package com.kifi.api.repository.vendor; + +import com.kifi.api.entity.vendor.PurchasePayment; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; + +public interface PurchasePaymentRepository extends ReactiveCrudRepository { + Flux findByPoId(Long poId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/vendor/VendorRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/vendor/VendorRepository.java new file mode 100644 index 0000000..fc858c5 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/vendor/VendorRepository.java @@ -0,0 +1,9 @@ +package com.kifi.api.repository.vendor; + +import com.kifi.api.entity.vendor.Vendor; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; + +public interface VendorRepository extends ReactiveCrudRepository { + Flux findByUserId(Long userId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/WalletService.java b/kifi-api/src/main/java/com/kifi/api/service/WalletService.java index d76df35..d5c1a04 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/WalletService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/WalletService.java @@ -12,6 +12,8 @@ import com.kifi.api.repository.WalletRepository; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -20,6 +22,7 @@ import java.time.LocalDateTime; @Service @RequiredArgsConstructor public class WalletService { + private static final Logger log = LoggerFactory.getLogger(WalletService.class); private final WalletRepository walletRepository; private final UserWalletRepository userWalletRepository; private final WalletInvitationRepository walletInvitationRepository; @@ -29,12 +32,12 @@ public class WalletService { private final EmailService emailService; private final com.kifi.api.repository.UserRepository userRepository; - public Mono createWallet(Long ownerId, String name, String nature, String icon, String color, String currency, java.math.BigDecimal initialBalance, String subNature, java.math.BigDecimal creditLimit, java.math.BigDecimal fixedAmount, String paymentCycle, Integer cycleDate) { + public Mono createWallet(Long ownerId, String name, String nature, String icon, String color, String currency, java.math.BigDecimal initialBalance, java.time.LocalDate initialBalanceDate, String subNature, java.math.BigDecimal creditLimit, java.math.BigDecimal fixedAmount, String paymentCycle, Integer cycleDate) { Wallet wallet = new Wallet(); wallet.setOwnerId(ownerId); wallet.setName(name); wallet.setNature(nature != null ? nature : "CASH"); - wallet.setBalance(initialBalance != null ? initialBalance : java.math.BigDecimal.ZERO); + wallet.setBalance(java.math.BigDecimal.ZERO); wallet.setCurrency(currency != null ? currency : "INR"); wallet.setIcon(icon); wallet.setColor(color); @@ -53,9 +56,79 @@ public class WalletService { uw.setRole("OWNER"); uw.setJoinedAt(LocalDateTime.now()); return userWalletRepository.save(uw).thenReturn(savedWallet); + }) + .flatMap(savedWallet -> { + if (initialBalance != null && initialBalance.compareTo(java.math.BigDecimal.ZERO) != 0) { + return handleOpeningBalance(ownerId, savedWallet, initialBalance, initialBalanceDate); + } + return Mono.just(savedWallet); }); } + private Mono handleOpeningBalance(Long ownerId, Wallet newWallet, java.math.BigDecimal initialBalance, java.time.LocalDate initialBalanceDate) { + log.info("handleOpeningBalance called for wallet {} with initialBalance {}", newWallet.getId(), initialBalance); + if (initialBalance.compareTo(java.math.BigDecimal.ZERO) == 0) { + log.info("initialBalance is 0, skipping"); + return Mono.just(newWallet); + } + return walletRepository.findByOwnerIdAndNature(ownerId, "EQUITY") + .collectList() + .flatMap(equityList -> { + if (equityList.isEmpty()) { + Wallet equityWallet = new Wallet(); + equityWallet.setOwnerId(ownerId); + equityWallet.setName("Opening Balance Equity"); + equityWallet.setNature("EQUITY"); + equityWallet.setBalance(java.math.BigDecimal.ZERO); + equityWallet.setCurrency(newWallet.getCurrency()); + equityWallet.setCreatedAt(LocalDateTime.now()); + return walletRepository.save(equityWallet) + .flatMap(savedEquity -> { + UserWallet uw = new UserWallet(); + uw.setUserId(ownerId); + uw.setWalletId(savedEquity.getId()); + uw.setRole("OWNER"); + uw.setJoinedAt(LocalDateTime.now()); + return userWalletRepository.save(uw).thenReturn(savedEquity); + }); + } else { + return Mono.just(equityList.get(0)); + } + }) + .flatMap(equityWallet -> { + com.kifi.api.entity.Transaction t = new com.kifi.api.entity.Transaction(); + t.setUserId(ownerId); + java.math.BigDecimal absAmount = initialBalance.abs(); + t.setAmount(absAmount); + t.setDate(initialBalanceDate != null ? initialBalanceDate : java.time.LocalDate.now()); + t.setDescription("Opening Balance"); + t.setCreatedAt(LocalDateTime.now()); + t.setType("TRANSFER"); + + String nature = newWallet.getNature(); + boolean isLiability = "PAYABLES".equals(nature) || "CREDIT_CARD".equals(newWallet.getSubNature()); + + // If it's a liability OR initialBalance is negative, it's effectively a liability + if (isLiability || initialBalance.compareTo(java.math.BigDecimal.ZERO) < 0) { + t.setFromWalletId(newWallet.getId()); + t.setToWalletId(equityWallet.getId()); + newWallet.setBalance(newWallet.getBalance().subtract(absAmount)); + equityWallet.setBalance(equityWallet.getBalance().add(absAmount)); + } else { + t.setFromWalletId(equityWallet.getId()); + t.setToWalletId(newWallet.getId()); + equityWallet.setBalance(equityWallet.getBalance().subtract(absAmount)); + newWallet.setBalance(newWallet.getBalance().add(absAmount)); + } + + return transactionRepository.save(t) + .doOnSuccess(savedTx -> log.info("Successfully saved Opening Balance transaction: {}", savedTx.getId())) + .then(walletRepository.save(newWallet)) + .then(walletRepository.save(equityWallet)) + .thenReturn(newWallet); + }); + } + public Flux getWalletsForUser(Long userId) { return userWalletRepository.findByUserId(userId) .flatMap(uw -> walletRepository.findById(uw.getWalletId())); @@ -117,7 +190,7 @@ public class WalletService { }); } - public Mono editWallet(Long walletId, Long ownerId, String name, String nature, String icon, String color, String currency, String subNature, java.math.BigDecimal creditLimit, java.math.BigDecimal fixedAmount, String paymentCycle, Integer cycleDate) { + public Mono editWallet(Long walletId, Long ownerId, String name, String nature, String icon, String color, String currency, java.math.BigDecimal initialBalance, java.time.LocalDate initialBalanceDate, String subNature, java.math.BigDecimal creditLimit, java.math.BigDecimal fixedAmount, String paymentCycle, Integer cycleDate) { return walletRepository.findById(walletId) .filter(w -> w.getOwnerId().equals(ownerId)) .switchIfEmpty(Mono.error(new RuntimeException("Wallet not found or you are not the owner"))) @@ -132,6 +205,43 @@ public class WalletService { if (fixedAmount != null) w.setFixedAmount(fixedAmount); if (paymentCycle != null) w.setPaymentCycle(paymentCycle); if (cycleDate != null) w.setCycleDate(cycleDate); + + if (initialBalance != null) { + log.info("editWallet: initialBalance is not null: {}", initialBalance); + return transactionRepository.findByWalletAndDescription(walletId, "Opening Balance") + .collectList() + .flatMap(txList -> { + if (txList.isEmpty()) { + log.info("editWallet: No old opening balance tx found. Calling handleOpeningBalance."); + return walletRepository.save(w).flatMap(saved -> handleOpeningBalance(ownerId, saved, initialBalance, initialBalanceDate)); + } else { + com.kifi.api.entity.Transaction oldTx = txList.get(0); + log.info("editWallet: Found old opening balance tx: {}", oldTx.getId()); + // Reverse old transaction impact on w in memory + if (oldTx.getFromWalletId().equals(w.getId())) { + w.setBalance(w.getBalance().add(oldTx.getAmount())); + } else if (oldTx.getToWalletId().equals(w.getId())) { + w.setBalance(w.getBalance().subtract(oldTx.getAmount())); + } + + Long otherWalletId = oldTx.getFromWalletId().equals(w.getId()) ? oldTx.getToWalletId() : oldTx.getFromWalletId(); + + return walletRepository.findById(otherWalletId) + .flatMap(otherW -> { + if (oldTx.getFromWalletId().equals(otherW.getId())) { + otherW.setBalance(otherW.getBalance().add(oldTx.getAmount())); + } else if (oldTx.getToWalletId().equals(otherW.getId())) { + otherW.setBalance(otherW.getBalance().subtract(oldTx.getAmount())); + } + return walletRepository.save(otherW); + }) + .then(transactionRepository.delete(oldTx)) + .then(walletRepository.save(w)) + .flatMap(saved -> handleOpeningBalance(ownerId, saved, initialBalance, initialBalanceDate)); + } + }); + } + return walletRepository.save(w); }); } diff --git a/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java b/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java index e9f3e9e..9a35f7b 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java @@ -26,6 +26,7 @@ public class BusinessService { .userId(userId) .inventoryManagement(false) .salesManagement(false) + .purchaseManagement(false) .multiLocation(false) .bomReductionStrategy("COMPONENTS_ONLY") .stockDeductionOnInvoice(true) @@ -38,6 +39,7 @@ public class BusinessService { .flatMap(existing -> { if (feature.getInventoryManagement() != null) existing.setInventoryManagement(feature.getInventoryManagement()); if (feature.getSalesManagement() != null) existing.setSalesManagement(feature.getSalesManagement()); + if (feature.getPurchaseManagement() != null) existing.setPurchaseManagement(feature.getPurchaseManagement()); if (feature.getMultiLocation() != null) existing.setMultiLocation(feature.getMultiLocation()); if (feature.getBomReductionStrategy() != null) existing.setBomReductionStrategy(feature.getBomReductionStrategy()); if (feature.getStockDeductionOnInvoice() != null) existing.setStockDeductionOnInvoice(feature.getStockDeductionOnInvoice()); diff --git a/kifi-api/src/main/java/com/kifi/api/service/vendor/PurchaseOrderService.java b/kifi-api/src/main/java/com/kifi/api/service/vendor/PurchaseOrderService.java new file mode 100644 index 0000000..0929cfd --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/vendor/PurchaseOrderService.java @@ -0,0 +1,161 @@ +package com.kifi.api.service.vendor; + +import com.kifi.api.entity.vendor.PurchaseOrder; +import com.kifi.api.entity.vendor.PurchaseOrderItem; +import com.kifi.api.entity.vendor.PurchasePayment; +import com.kifi.api.repository.vendor.PurchaseOrderItemRepository; +import com.kifi.api.repository.vendor.PurchaseOrderRepository; +import com.kifi.api.repository.vendor.PurchasePaymentRepository; +import com.kifi.api.service.inventory.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PurchaseOrderService { + + private final PurchaseOrderRepository purchaseOrderRepository; + private final PurchaseOrderItemRepository purchaseOrderItemRepository; + private final PurchasePaymentRepository purchasePaymentRepository; + private final ProductService productService; + + public Flux getPurchaseOrders(Long userId) { + return purchaseOrderRepository.findByUserId(userId) + .sort((a, b) -> b.getIssueDate().compareTo(a.getIssueDate())); + } + + public Mono getPurchaseOrder(Long userId, Long id) { + return purchaseOrderRepository.findByUserIdAndId(userId, id); + } + + public Flux getPurchaseOrderItems(Long poId) { + return purchaseOrderItemRepository.findByPoId(poId); + } + + public Flux getPurchasePayments(Long poId) { + return purchasePaymentRepository.findByPoId(poId); + } + + @Transactional + public Mono createPurchaseOrder(Long userId, PurchaseOrder po, List items) { + po.setUserId(userId); + po.setCreatedAt(LocalDateTime.now()); + po.setUpdatedAt(LocalDateTime.now()); + if (po.getStatus() == null || po.getStatus().isEmpty()) { + po.setStatus("PENDING"); + } + + return purchaseOrderRepository.save(po) + .flatMap(savedPo -> { + return Flux.fromIterable(items) + .map(item -> { + item.setPoId(savedPo.getId()); + return item; + }) + .flatMap(purchaseOrderItemRepository::save) + .then(Mono.just(savedPo)); + }); + } + + @Transactional + public Mono updatePurchaseOrder(Long userId, Long id, PurchaseOrder po, List items) { + return purchaseOrderRepository.findByUserIdAndId(userId, id) + .flatMap(existingPo -> { + existingPo.setVendorId(po.getVendorId()); + existingPo.setPoNumber(po.getPoNumber()); + existingPo.setIssueDate(po.getIssueDate()); + existingPo.setDueDate(po.getDueDate()); + existingPo.setSubtotal(po.getSubtotal()); + existingPo.setTaxTotal(po.getTaxTotal()); + existingPo.setDiscountTotal(po.getDiscountTotal()); + existingPo.setTotalAmount(po.getTotalAmount()); + existingPo.setNotes(po.getNotes()); + existingPo.setVendorInvoiceUrl(po.getVendorInvoiceUrl()); + existingPo.setUpdatedAt(LocalDateTime.now()); + + return purchaseOrderRepository.save(existingPo) + .flatMap(savedPo -> purchaseOrderItemRepository.deleteByPoId(id) + .thenMany(Flux.fromIterable(items) + .map(item -> { + item.setPoId(id); + return item; + }) + .flatMap(purchaseOrderItemRepository::save)) + .then(Mono.just(savedPo))); + }); + } + + @Transactional + public Mono markAsReceived(Long userId, Long id, List receivedItems) { + return purchaseOrderRepository.findByUserIdAndId(userId, id) + .flatMap(po -> { + if ("RECEIVED".equals(po.getStatus())) { + return Mono.error(new RuntimeException("PO is already marked as received")); + } + + po.setStatus("RECEIVED"); + po.setUpdatedAt(LocalDateTime.now()); + + return purchaseOrderRepository.save(po) + .flatMap(savedPo -> purchaseOrderItemRepository.deleteByPoId(id) + .thenMany(Flux.fromIterable(receivedItems) + .map(item -> { + item.setPoId(id); + return item; + }) + .flatMap(purchaseOrderItemRepository::save)) + .collectList() + .flatMap(savedItems -> { + // Update inventory for received items + return Flux.fromIterable(savedItems) + .flatMap(item -> { + if (item.getProductId() != null) { + com.kifi.api.entity.inventory.InventoryMovement movement = com.kifi.api.entity.inventory.InventoryMovement.builder() + .type("IN") + .notes("PO Received: " + savedPo.getPoNumber()) + .items(java.util.List.of(com.kifi.api.entity.inventory.InventoryMovementItem.builder() + .productId(item.getProductId()) + .quantity(item.getQuantity()) + .build())) + .build(); + return productService.adjustStock(userId, item.getProductId(), movement); + } + return Mono.empty(); + }) + .then(Mono.just(savedPo)); + })); + }); + } + + @Transactional + public Mono addPayment(Long userId, Long poId, PurchasePayment payment) { + return purchaseOrderRepository.findByUserIdAndId(userId, poId) + .switchIfEmpty(Mono.error(new RuntimeException("PO not found"))) + .flatMap(po -> { + payment.setPoId(poId); + if (payment.getPaymentDate() == null) { + payment.setPaymentDate(LocalDateTime.now()); + } + return purchasePaymentRepository.save(payment) + .flatMap(savedPayment -> { + BigDecimal newPaid = po.getAmountPaid() != null ? po.getAmountPaid().add(savedPayment.getAmount()) : savedPayment.getAmount(); + po.setAmountPaid(newPaid); + po.setUpdatedAt(LocalDateTime.now()); + return purchaseOrderRepository.save(po).thenReturn(savedPayment); + }); + }); + } + + public Flux getPayments(Long userId, Long poId) { + return purchaseOrderRepository.findByUserIdAndId(userId, poId) + .switchIfEmpty(Mono.error(new RuntimeException("PO not found"))) + .flatMapMany(po -> purchasePaymentRepository.findByPoId(poId)); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/vendor/VendorService.java b/kifi-api/src/main/java/com/kifi/api/service/vendor/VendorService.java new file mode 100644 index 0000000..b567463 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/vendor/VendorService.java @@ -0,0 +1,52 @@ +package com.kifi.api.service.vendor; + +import com.kifi.api.entity.vendor.Vendor; +import com.kifi.api.repository.vendor.VendorRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class VendorService { + + private final VendorRepository vendorRepository; + + public Flux getVendors(Long userId) { + return vendorRepository.findByUserId(userId) + .sort((a, b) -> a.getName().compareToIgnoreCase(b.getName())); + } + + public Mono getVendor(Long id) { + return vendorRepository.findById(id); + } + + public Mono createVendor(Long userId, Vendor vendor) { + vendor.setUserId(userId); + vendor.setCreatedAt(LocalDateTime.now()); + return vendorRepository.save(vendor); + } + + public Mono updateVendor(Long id, Vendor vendor) { + return vendorRepository.findById(id) + .flatMap(existing -> { + existing.setName(vendor.getName()); + existing.setEmail(vendor.getEmail()); + existing.setPhone(vendor.getPhone()); + existing.setAddress(vendor.getAddress()); + existing.setGstin(vendor.getGstin()); + existing.setStateId(vendor.getStateId()); + existing.setIdNumber(vendor.getIdNumber()); + existing.setPhotoUrl(vendor.getPhotoUrl()); + existing.setContactPerson(vendor.getContactPerson()); + return vendorRepository.save(existing); + }); + } + + public Mono deleteVendor(Long id) { + return vendorRepository.deleteById(id); + } +} diff --git a/kifi-app/lib/core/widgets/smart_search_dropdown.dart b/kifi-app/lib/core/widgets/smart_search_dropdown.dart index 4381aab..dab9671 100644 --- a/kifi-app/lib/core/widgets/smart_search_dropdown.dart +++ b/kifi-app/lib/core/widgets/smart_search_dropdown.dart @@ -207,7 +207,7 @@ class _SmartSearchDropdownState extends State> { hintText: widget.hintText, suffixIcon: const Icon(Icons.arrow_drop_down, color: Colors.grey), filled: Theme.of(context).inputDecorationTheme.filled ?? true, - fillColor: widget.fillColor ?? (Theme.of(context).brightness == Brightness.dark ? Colors.black26 : Colors.grey[100]), + fillColor: widget.fillColor ?? Theme.of(context).inputDecorationTheme.fillColor ?? (Theme.of(context).brightness == Brightness.dark ? Colors.black26 : Colors.grey[100]), border: Theme.of(context).inputDecorationTheme.border ?? OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), enabledBorder: Theme.of(context).inputDecorationTheme.enabledBorder ?? OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), focusedBorder: Theme.of(context).inputDecorationTheme.focusedBorder ?? OutlineInputBorder( diff --git a/kifi-app/lib/features/business/domain/business_feature.dart b/kifi-app/lib/features/business/domain/business_feature.dart index e68e11c..7b0e13b 100644 --- a/kifi-app/lib/features/business/domain/business_feature.dart +++ b/kifi-app/lib/features/business/domain/business_feature.dart @@ -3,6 +3,7 @@ class BusinessFeature { final int? userId; final bool inventoryManagement; final bool salesManagement; + final bool purchaseManagement; final bool multiLocation; final String bomReductionStrategy; final bool stockDeductionOnInvoice; @@ -14,6 +15,7 @@ class BusinessFeature { this.userId, this.inventoryManagement = false, this.salesManagement = false, + this.purchaseManagement = false, this.multiLocation = false, this.bomReductionStrategy = 'COMPONENTS_ONLY', this.stockDeductionOnInvoice = true, @@ -27,6 +29,7 @@ class BusinessFeature { userId: json['userId'], inventoryManagement: json['inventoryManagement'] ?? false, salesManagement: json['salesManagement'] ?? false, + purchaseManagement: json['purchaseManagement'] ?? false, multiLocation: json['multiLocation'] ?? false, bomReductionStrategy: json['bomReductionStrategy'] ?? 'COMPONENTS_ONLY', stockDeductionOnInvoice: json['stockDeductionOnInvoice'] ?? true, @@ -41,6 +44,7 @@ class BusinessFeature { 'userId': userId, 'inventoryManagement': inventoryManagement, 'salesManagement': salesManagement, + 'purchaseManagement': purchaseManagement, 'multiLocation': multiLocation, 'bomReductionStrategy': bomReductionStrategy, 'stockDeductionOnInvoice': stockDeductionOnInvoice, @@ -49,18 +53,22 @@ class BusinessFeature { } BusinessFeature copyWith({ + int? id, + int? userId, bool? inventoryManagement, bool? salesManagement, + bool? purchaseManagement, bool? multiLocation, String? bomReductionStrategy, bool? stockDeductionOnInvoice, String? barcodeSource, }) { return BusinessFeature( - id: id, - userId: userId, + id: id ?? this.id, + userId: userId ?? this.userId, inventoryManagement: inventoryManagement ?? this.inventoryManagement, salesManagement: salesManagement ?? this.salesManagement, + purchaseManagement: purchaseManagement ?? this.purchaseManagement, multiLocation: multiLocation ?? this.multiLocation, bomReductionStrategy: bomReductionStrategy ?? this.bomReductionStrategy, stockDeductionOnInvoice: stockDeductionOnInvoice ?? this.stockDeductionOnInvoice, diff --git a/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart b/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart index 9aeba48..1cfe5b1 100644 --- a/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart +++ b/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart @@ -10,6 +10,8 @@ import '../../../sales/presentation/invoices_list_screen.dart'; import '../../providers/business_provider.dart'; 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'; class BusinessHubScreen extends ConsumerWidget { const BusinessHubScreen({super.key}); @@ -29,6 +31,7 @@ class BusinessHubScreen extends ConsumerWidget { final featureState = ref.watch(businessFeatureProvider).value; final bool showInventory = featureState?.inventoryManagement ?? false; final bool showSales = featureState?.salesManagement ?? false; + final bool showPurchase = featureState?.purchaseManagement ?? false; return SingleChildScrollView( padding: const EdgeInsets.all(24.0), @@ -122,6 +125,24 @@ class BusinessHubScreen extends ConsumerWidget { () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomersListScreen())), ), ], + if (showPurchase) ...[ + _buildActionCard( + context, + 'Vendors', + 'Manage suppliers', + LucideIcons.truck, + Colors.orange, + () => Navigator.push(context, MaterialPageRoute(builder: (_) => const VendorsListScreen())), + ), + _buildActionCard( + context, + 'Purchase Orders', + 'Manage inward stock', + LucideIcons.clipboardList, + Colors.deepOrange, + () => Navigator.push(context, MaterialPageRoute(builder: (_) => const PurchaseOrdersListScreen())), + ), + ], ], ), if (showInventory) ...[ diff --git a/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart b/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart index 4f93af6..f1ed6d5 100644 --- a/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart +++ b/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart @@ -14,6 +14,7 @@ class _BusinessSettingsScreenState extends ConsumerState bool _taxIncludedInPrice = false; bool _salesEnabled = true; bool _inventoryEnabled = true; + bool _purchaseEnabled = false; @override void initState() { @@ -30,6 +31,7 @@ class _BusinessSettingsScreenState extends ConsumerState setState(() { _inventoryEnabled = feature.inventoryManagement; _salesEnabled = feature.salesManagement; + _purchaseEnabled = feature.purchaseManagement; }); } }); @@ -68,6 +70,16 @@ class _BusinessSettingsScreenState extends ConsumerState }, secondary: const Icon(LucideIcons.shoppingCart), ), + SwitchListTile( + title: const Text('Vendor & Purchases'), + subtitle: const Text('Manage vendors, purchase orders, and inward stock'), + value: _purchaseEnabled, + onChanged: (val) { + setState(() => _purchaseEnabled = val); + _saveFeatures(); + }, + secondary: const Icon(LucideIcons.truck), + ), const SizedBox(height: 32), const Text('Inventory Strategy', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)), const SizedBox(height: 16), @@ -153,6 +165,7 @@ class _BusinessSettingsScreenState extends ConsumerState final updated = featureState.copyWith( inventoryManagement: _inventoryEnabled, salesManagement: _salesEnabled, + purchaseManagement: _purchaseEnabled, ); ref.read(businessFeatureProvider.notifier).updateFeatures(updated); } diff --git a/kifi-app/lib/features/dashboard/presentation/accounts_screen.dart b/kifi-app/lib/features/dashboard/presentation/accounts_screen.dart index 36ac877..d0cf159 100644 --- a/kifi-app/lib/features/dashboard/presentation/accounts_screen.dart +++ b/kifi-app/lib/features/dashboard/presentation/accounts_screen.dart @@ -312,7 +312,8 @@ class _AccountsScreenState extends ConsumerState { final newWallet = await ref.read(walletProvider.notifier).createWallet( name: ctrl.text, nature: selectedNature, - initialBalance: 0.0, + initialBalance: amt, + initialBalanceDate: openingDate, subNature: selectedNature == 'PAYABLES' ? selectedSubNature : null, creditLimit: cl, fixedAmount: fa, @@ -320,19 +321,6 @@ class _AccountsScreenState extends ConsumerState { cycleDate: cd, ); - if (amt != 0) { - final tx = Transaction( - id: 0, - type: amt > 0 ? 'INCOME' : 'EXPENSE', - amount: amt.abs(), - date: openingDate, - description: 'Opening Balance', - fromWalletId: amt < 0 ? newWallet.id : null, - toWalletId: amt > 0 ? newWallet.id : null, - ); - await ref.read(transactionProvider.notifier).addTransaction(tx); - } - if (context.mounted) Navigator.pop(ctx); } }, @@ -522,6 +510,7 @@ class _AccountsScreenState extends ConsumerState { error: (e, st) => Center(child: Text('Error: $e')), data: (allWallets) { final wallets = allWallets.where((w) { + if (w.nature == 'EQUITY') return false; final matchSearch = _searchQuery.isEmpty || w.name.toLowerCase().contains(_searchQuery.toLowerCase()); final matchNature = _filterNature == null || w.nature == _filterNature; @@ -601,6 +590,14 @@ class _AccountsScreenState extends ConsumerState { final editCreditLimitCtrl = TextEditingController(text: w.creditLimit?.toString() ?? ''); final editFixedAmountCtrl = TextEditingController(text: w.fixedAmount?.toString() ?? ''); final editCycleDateCtrl = TextEditingController(text: w.cycleDate?.toString() ?? ''); + + final allTxs = ref.read(transactionProvider).value ?? []; + final walletTxs = allTxs.where((t) => t.fromWalletId == w.id || t.toWalletId == w.id).toList(); + final obTxs = walletTxs.where((t) => t.description == 'Opening Balance'); + final hasRealTx = walletTxs.any((t) => t.description != 'Opening Balance'); + final editInitialBalanceCtrl = TextEditingController(text: w.balance != 0 ? w.balance.toString() : ''); + DateTime editOpeningDate = obTxs.isNotEmpty ? obTxs.first.date : DateTime.now(); + String? editPaymentCycle = w.paymentCycle; final natures = ['CASH', 'SAVINGS', 'EXPENSE', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'RECEIVABLES', 'INCOME']; @@ -663,6 +660,58 @@ class _AccountsScreenState extends ConsumerState { ), const SizedBox(height: 16), + Tooltip( + message: hasRealTx ? 'Opening balance cannot be edited after transactions are recorded' : '', + child: TextField( + controller: editInitialBalanceCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), + enabled: !hasRealTx, + decoration: InputDecoration( + hintText: 'Opening Balance (Optional)', + prefixText: 'Rs. ', + filled: true, + fillColor: hasRealTx ? Colors.grey.shade200 : Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + ), + ), + ), + const SizedBox(height: 16), + + Tooltip( + message: hasRealTx ? 'Opening balance date cannot be edited after transactions are recorded' : '', + child: InkWell( + onTap: hasRealTx ? null : () async { + final picked = await showDatePicker( + context: context, + initialDate: editOpeningDate, + firstDate: DateTime.now().subtract(const Duration(days: 365 * 10)), + lastDate: DateTime.now(), + ); + if (picked != null) { + setStateDialog(() => editOpeningDate = picked); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + color: hasRealTx ? Colors.grey.shade200 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + const Icon(LucideIcons.calendar, color: Colors.black54, size: 20), + const SizedBox(width: 12), + Text( + 'Opening Date: ${editOpeningDate.day.toString().padLeft(2, '0')}/${editOpeningDate.month.toString().padLeft(2, '0')}/${editOpeningDate.year}', + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 15, color: hasRealTx ? Colors.black38 : Colors.black87), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 16), + if (editNature == 'PAYABLES') ...[ DropdownButtonFormField( value: editSubNature, @@ -758,15 +807,26 @@ class _AccountsScreenState extends ConsumerState { ElevatedButton( onPressed: () async { if (editCtrl.text.isNotEmpty) { - final double? cl = double.tryParse(editCreditLimitCtrl.text); - final double? fa = double.tryParse(editFixedAmountCtrl.text); + final double? cl = double.tryParse(editCreditLimitCtrl.text.replaceAll(RegExp(r'[^0-9.]'), '')); + final double? fa = double.tryParse(editFixedAmountCtrl.text.replaceAll(RegExp(r'[^0-9.]'), '')); final int? cd = int.tryParse(editCycleDateCtrl.text); + // Make parsing super robust by stripping everything except digits and dot + final String rawText = editInitialBalanceCtrl.text.replaceAll(RegExp(r'[^0-9.]'), ''); + final double? ib = (!hasRealTx && rawText.isNotEmpty) ? double.tryParse(rawText) : null; + + print("DEBUG KIFI: editWallet called."); + print("DEBUG KIFI: hasRealTx = $hasRealTx"); + print("DEBUG KIFI: rawText = '$rawText'"); + print("DEBUG KIFI: parsed ib = $ib"); + try { await ref.read(walletProvider.notifier).editWallet( w.id, name: editCtrl.text.trim(), nature: editNature, + initialBalance: ib, + initialBalanceDate: ib != null ? editOpeningDate : null, subNature: editNature == 'PAYABLES' ? editSubNature : null, creditLimit: cl, fixedAmount: fa, diff --git a/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart b/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart index 489e94c..5fea9ae 100644 --- a/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart @@ -177,16 +177,17 @@ class _DashboardScreenState extends ConsumerState { backgroundColor: Colors.transparent, elevation: 0, actions: [ - IconButton( - icon: const Icon(LucideIcons.trendingUp), - tooltip: 'Daily Commodity Rates', - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const DailyRatesScreen()), - ); - }, - ), + if (isBusinessMode) + IconButton( + icon: const Icon(LucideIcons.trendingUp), + tooltip: 'Daily Commodity Rates', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const DailyRatesScreen()), + ); + }, + ), Consumer( builder: (context, ref, child) { final invitationsState = ref.watch(invitationProvider); diff --git a/kifi-app/lib/features/inventory/domain/product.dart b/kifi-app/lib/features/inventory/domain/product.dart index 5b1889f..a01984b 100644 --- a/kifi-app/lib/features/inventory/domain/product.dart +++ b/kifi-app/lib/features/inventory/domain/product.dart @@ -3,6 +3,8 @@ class Product { final int? userId; final int? categoryId; final int? uomId; + final String? hsnCode; + final String name; final String? sku; final String? barcode; @@ -33,6 +35,7 @@ class Product { this.categoryId, this.uomId, required this.name, + this.hsnCode, this.sku, this.barcode, this.description, @@ -64,6 +67,7 @@ class Product { categoryId: json['categoryId'] ?? json['category_id'], uomId: json['uomId'] ?? json['uom_id'], name: json['name'], + hsnCode: json['hsnCode'] ?? json['hsn_code'], sku: json['sku'], barcode: json['barcode'], description: json['description'], @@ -98,6 +102,7 @@ class Product { 'categoryId': categoryId, 'uomId': uomId, 'name': name, + 'hsnCode': hsnCode, 'sku': sku, 'barcode': barcode, 'description': description, diff --git a/kifi-app/lib/features/inventory/presentation/add_product_screen.dart b/kifi-app/lib/features/inventory/presentation/add_product_screen.dart index 2ec24d9..bb44c57 100644 --- a/kifi-app/lib/features/inventory/presentation/add_product_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/add_product_screen.dart @@ -32,6 +32,7 @@ class _AddProductScreenState extends ConsumerState { // Basic String _name = ''; String _sku = ''; + String _hsnCode = ''; ProductCategory? _selectedCategory; int? _uomId; @@ -71,6 +72,7 @@ class _AddProductScreenState extends ConsumerState { final p = widget.product!; _name = p.name; _sku = p.sku ?? ''; + _hsnCode = p.hsnCode ?? ''; _uomId = p.uomId; _color = p.color ?? ''; _size = p.size ?? ''; @@ -269,6 +271,7 @@ class _AddProductScreenState extends ConsumerState { final product = Product( id: widget.product?.id, name: _name, + hsnCode: _hsnCode.isNotEmpty ? _hsnCode : null, sku: _sku.isNotEmpty ? _sku : null, categoryId: _selectedCategory?.id, uomId: _uomId, @@ -701,12 +704,26 @@ class _AddProductScreenState extends ConsumerState { const SizedBox(height: 20), ], - _buildPremiumTextField( - label: 'GST Rate (%)', - initialValue: _gstRate == 0 ? '' : _gstRate.toString(), - suffixText: '%', - keyboardType: const TextInputType.numberWithOptions(decimal: true), - onChanged: (val) => _gstRate = double.tryParse(val) ?? 0, + Row( + children: [ + Expanded( + child: _buildPremiumTextField( + label: 'HSN Code', + initialValue: _hsnCode, + onChanged: (val) => _hsnCode = val, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildPremiumTextField( + label: 'GST Rate (%)', + initialValue: _gstRate == 0 ? '' : _gstRate.toString(), + suffixText: '%', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (val) => _gstRate = double.tryParse(val) ?? 0, + ), + ), + ], ), const SizedBox(height: 32), diff --git a/kifi-app/lib/features/inventory/presentation/widgets/adjust_stock_sheet.dart b/kifi-app/lib/features/inventory/presentation/widgets/adjust_stock_sheet.dart index 116930a..99d9975 100644 --- a/kifi-app/lib/features/inventory/presentation/widgets/adjust_stock_sheet.dart +++ b/kifi-app/lib/features/inventory/presentation/widgets/adjust_stock_sheet.dart @@ -60,10 +60,22 @@ class _AdjustStockSheetState extends ConsumerState { @override Widget build(BuildContext context) { + final inputDecoration = InputDecoration( + filled: true, + fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.black26 : Colors.grey[100], + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2) + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + ); + return Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom + 24, @@ -86,10 +98,7 @@ class _AdjustStockSheetState extends ConsumerState { DropdownButtonFormField( value: _selectedType, - decoration: InputDecoration( - labelText: "Movement Type", - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - ), + decoration: inputDecoration.copyWith(labelText: "Movement Type"), items: _types.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(), onChanged: (val) { if (val != null) setState(() => _selectedType = val); @@ -100,9 +109,8 @@ class _AdjustStockSheetState extends ConsumerState { TextFormField( controller: _qtyController, keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: InputDecoration( + decoration: inputDecoration.copyWith( labelText: "Quantity", - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), prefixIcon: const Icon(Icons.inventory_2), ), ), @@ -110,9 +118,8 @@ class _AdjustStockSheetState extends ConsumerState { TextFormField( controller: _notesController, - decoration: InputDecoration( + decoration: inputDecoration.copyWith( labelText: "Notes (Optional)", - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), prefixIcon: const Icon(Icons.note), ), ), diff --git a/kifi-app/lib/features/sales/domain/invoice.dart b/kifi-app/lib/features/sales/domain/invoice.dart index 4c25d7b..e587914 100644 --- a/kifi-app/lib/features/sales/domain/invoice.dart +++ b/kifi-app/lib/features/sales/domain/invoice.dart @@ -3,6 +3,8 @@ class InvoiceItem { final int? invoiceId; final int? productId; final String? sku; + final String? hsnCode; + final String? description; final double quantity; final double unitPrice; @@ -16,6 +18,7 @@ class InvoiceItem { this.id, this.invoiceId, this.productId, + this.hsnCode, this.sku, this.description, required this.quantity, @@ -31,6 +34,7 @@ class InvoiceItem { int? id, int? invoiceId, int? productId, + String? hsnCode, String? sku, String? description, double? quantity, @@ -45,6 +49,7 @@ class InvoiceItem { id: id ?? this.id, invoiceId: invoiceId ?? this.invoiceId, productId: productId ?? this.productId, + hsnCode: hsnCode ?? this.hsnCode, sku: sku ?? this.sku, description: description ?? this.description, quantity: quantity ?? this.quantity, @@ -62,6 +67,7 @@ class InvoiceItem { id: json['id'], invoiceId: json['invoiceId'], productId: json['productId'], + hsnCode: json['hsnCode'] ?? json['hsn_code'], sku: json['sku'], description: json['description'], quantity: json['quantity'].toDouble(), @@ -79,6 +85,7 @@ class InvoiceItem { if (id != null) data['id'] = id; if (invoiceId != null) data['invoiceId'] = invoiceId; if (productId != null) data['productId'] = productId; + if (hsnCode != null) data['hsnCode'] = hsnCode; if (sku != null) data['sku'] = sku; if (description != null) data['description'] = description; data['quantity'] = quantity; diff --git a/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart b/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart index e64fe1e..c7849f1 100644 --- a/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart @@ -293,6 +293,16 @@ class _InvoiceDetailsScreenState extends ConsumerState { padding: const EdgeInsets.only(top: 4), child: Text('SKU: $skuToDisplay', style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600)), ), + if (item.hsnCode != null && item.hsnCode!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text('HSN: ${item.hsnCode}', style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600)), + ) + else if (product?.hsnCode != null && product!.hsnCode!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text('HSN: ${product.hsnCode}', style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600)), + ), const SizedBox(height: 12), Container( padding: const EdgeInsets.all(12), @@ -654,8 +664,10 @@ class _InvoiceDetailsScreenState extends ConsumerState { ...(latestInvoice.items ?? []).map((item) { final product = item.productId != null ? ref.read(productsProvider).value?.where((p) => p.id == item.productId).firstOrNull : null; final skuToDisplay = item.sku ?? product?.sku; + final hsnToDisplay = item.hsnCode ?? product?.hsnCode; String itemDesc = item.description ?? 'Item'; if (skuToDisplay != null && skuToDisplay.isNotEmpty) itemDesc += '\nSKU: $skuToDisplay'; + if (hsnToDisplay != null && hsnToDisplay.isNotEmpty) itemDesc += '\nHSN: $hsnToDisplay'; if (item.makingCharge > 0) itemDesc += '\n+ Making Charges: ${formatCurrency.format(item.makingCharge)}'; if (item.otherCharges > 0) itemDesc += '\n+ Other Charges: ${formatCurrency.format(item.otherCharges)}'; if (item.discount > 0) itemDesc += '\n- Discount: ${formatCurrency.format(item.discount)}'; diff --git a/kifi-app/lib/features/transactions/data/repository.dart b/kifi-app/lib/features/transactions/data/repository.dart index c7bd721..7502f91 100644 --- a/kifi-app/lib/features/transactions/data/repository.dart +++ b/kifi-app/lib/features/transactions/data/repository.dart @@ -147,6 +147,7 @@ class ApiRepository { required String name, String nature = 'CASH', double initialBalance = 0.0, + String? initialBalanceDate, String currency = 'INR', String? icon, String? color, @@ -160,6 +161,7 @@ class ApiRepository { 'name': name, 'nature': nature, 'initialBalance': initialBalance, + if (initialBalanceDate != null) 'initialBalanceDate': initialBalanceDate, 'currency': currency, 'icon': icon, 'color': color, @@ -177,6 +179,8 @@ class ApiRepository { String? nature, String? icon, String? color, + double? initialBalance, + String? initialBalanceDate, String? subNature, double? creditLimit, double? fixedAmount, @@ -188,6 +192,8 @@ class ApiRepository { if (nature != null) 'nature': nature, if (icon != null) 'icon': icon, if (color != null) 'color': color, + if (initialBalance != null) 'initialBalance': initialBalance, + if (initialBalanceDate != null) 'initialBalanceDate': initialBalanceDate, if (subNature != null) 'subNature': subNature, if (creditLimit != null) 'creditLimit': creditLimit, if (fixedAmount != null) 'fixedAmount': fixedAmount, diff --git a/kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart b/kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart index 00e8d37..923d3c3 100644 --- a/kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart +++ b/kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart @@ -423,7 +423,8 @@ class _AddTransactionScreenState extends ConsumerState { child: walletsState.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, st) => Center(child: Text('Error: $e')), - data: (wallets) { + data: (walletsData) { + final wallets = walletsData.where((w) => w.nature != 'EQUITY').toList(); return ListView.builder( itemCount: wallets.length, itemBuilder: (context, index) { diff --git a/kifi-app/lib/features/transactions/providers/providers.dart b/kifi-app/lib/features/transactions/providers/providers.dart index 4921724..7c886a7 100644 --- a/kifi-app/lib/features/transactions/providers/providers.dart +++ b/kifi-app/lib/features/transactions/providers/providers.dart @@ -154,6 +154,7 @@ class WalletNotifier extends AsyncNotifier> { required String name, String nature = 'CASH', double initialBalance = 0.0, + DateTime? initialBalanceDate, String currency = 'INR', String? icon, String? color, @@ -167,6 +168,7 @@ class WalletNotifier extends AsyncNotifier> { name: name, nature: nature, initialBalance: initialBalance, + initialBalanceDate: initialBalanceDate != null ? "${initialBalanceDate.year}-${initialBalanceDate.month.toString().padLeft(2, '0')}-${initialBalanceDate.day.toString().padLeft(2, '0')}" : null, currency: currency, icon: icon, color: color, @@ -188,13 +190,15 @@ class WalletNotifier extends AsyncNotifier> { await ref.read(apiRepositoryProvider).inviteUserToWallet(walletId, email); } - Future editWallet(int id, {String? name, String? nature, String? icon, String? color, String? subNature, double? creditLimit, double? fixedAmount, String? paymentCycle, int? cycleDate}) async { + Future editWallet(int id, {String? name, String? nature, String? icon, String? color, double? initialBalance, DateTime? initialBalanceDate, String? subNature, double? creditLimit, double? fixedAmount, String? paymentCycle, int? cycleDate}) async { final updated = await ref.read(apiRepositoryProvider).editWallet( id: id, name: name, nature: nature, icon: icon, color: color, + initialBalance: initialBalance, + initialBalanceDate: initialBalanceDate != null ? "${initialBalanceDate.year}-${initialBalanceDate.month.toString().padLeft(2, '0')}-${initialBalanceDate.day.toString().padLeft(2, '0')}" : null, subNature: subNature, creditLimit: creditLimit, fixedAmount: fixedAmount, diff --git a/kifi-app/lib/features/vendor/domain/purchase_order.dart b/kifi-app/lib/features/vendor/domain/purchase_order.dart new file mode 100644 index 0000000..397c4bb --- /dev/null +++ b/kifi-app/lib/features/vendor/domain/purchase_order.dart @@ -0,0 +1,139 @@ +import 'purchase_order_item.dart'; +import 'vendor.dart'; + +class PurchaseOrder { + final int? id; + final int? userId; + final int? vendorId; + final String poNumber; + final DateTime issueDate; + final DateTime? dueDate; + final double subtotal; + final double taxTotal; + final double discountTotal; + final double totalAmount; + final String status; + final String? notes; + final DateTime? createdAt; + final DateTime? updatedAt; + final DateTime? poDate; + final double amountPaid; + final DateTime? nextPaymentDate; + final String? vendorInvoiceUrl; + final List items; + final Vendor? vendor; + + PurchaseOrder({ + this.id, + this.userId, + this.vendorId, + required this.poNumber, + required this.issueDate, + this.dueDate, + this.subtotal = 0.0, + this.taxTotal = 0.0, + this.discountTotal = 0.0, + this.totalAmount = 0.0, + this.status = 'PENDING', + this.notes, + this.createdAt, + this.updatedAt, + this.poDate, + this.amountPaid = 0.0, + this.nextPaymentDate, + this.vendorInvoiceUrl, + this.items = const [], + this.vendor, + }); + + factory PurchaseOrder.fromJson(Map json) { + return PurchaseOrder( + id: json['id'], + userId: json['userId'], + vendorId: json['vendorId'], + poNumber: json['poNumber'] ?? '', + issueDate: json['issueDate'] != null ? DateTime.parse(json['issueDate']) : DateTime.now(), + dueDate: json['dueDate'] != null ? DateTime.parse(json['dueDate']) : null, + subtotal: (json['subtotal'] ?? 0.0).toDouble(), + taxTotal: (json['taxTotal'] ?? 0.0).toDouble(), + discountTotal: (json['discountTotal'] ?? 0.0).toDouble(), + totalAmount: (json['totalAmount'] ?? 0.0).toDouble(), + status: json['status'] ?? 'PENDING', + notes: json['notes'], + createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, + updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null, + poDate: json['poDate'] != null ? DateTime.parse(json['poDate']) : null, + amountPaid: (json['amountPaid'] ?? 0.0).toDouble(), + nextPaymentDate: json['nextPaymentDate'] != null ? DateTime.parse(json['nextPaymentDate']) : null, + vendorInvoiceUrl: json['vendorInvoiceUrl'], + items: json['items'] != null ? (json['items'] as List).map((i) => PurchaseOrderItem.fromJson(i)).toList() : [], + vendor: json['vendor'] != null ? Vendor.fromJson(json['vendor']) : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'userId': userId, + 'vendorId': vendorId, + 'poNumber': poNumber, + 'issueDate': issueDate.toIso8601String(), + 'dueDate': dueDate?.toIso8601String(), + 'subtotal': subtotal, + 'taxTotal': taxTotal, + 'discountTotal': discountTotal, + 'totalAmount': totalAmount, + 'status': status, + 'notes': notes, + 'poDate': poDate?.toIso8601String(), + 'amountPaid': amountPaid, + 'nextPaymentDate': nextPaymentDate?.toIso8601String(), + 'vendorInvoiceUrl': vendorInvoiceUrl, + 'items': items.map((i) => i.toJson()).toList(), + }; + } + + PurchaseOrder copyWith({ + int? id, + int? userId, + int? vendorId, + String? poNumber, + DateTime? issueDate, + DateTime? dueDate, + double? subtotal, + double? taxTotal, + double? discountTotal, + double? totalAmount, + String? status, + String? notes, + DateTime? poDate, + double? amountPaid, + DateTime? nextPaymentDate, + String? vendorInvoiceUrl, + List? items, + Vendor? vendor, + }) { + return PurchaseOrder( + id: id ?? this.id, + userId: userId ?? this.userId, + vendorId: vendorId ?? this.vendorId, + poNumber: poNumber ?? this.poNumber, + issueDate: issueDate ?? this.issueDate, + dueDate: dueDate ?? this.dueDate, + subtotal: subtotal ?? this.subtotal, + taxTotal: taxTotal ?? this.taxTotal, + discountTotal: discountTotal ?? this.discountTotal, + totalAmount: totalAmount ?? this.totalAmount, + status: status ?? this.status, + notes: notes ?? this.notes, + createdAt: createdAt, + updatedAt: updatedAt, + poDate: poDate ?? this.poDate, + amountPaid: amountPaid ?? this.amountPaid, + nextPaymentDate: nextPaymentDate ?? this.nextPaymentDate, + vendorInvoiceUrl: vendorInvoiceUrl ?? this.vendorInvoiceUrl, + items: items ?? this.items, + vendor: vendor ?? this.vendor, + ); + } +} diff --git a/kifi-app/lib/features/vendor/domain/purchase_order_item.dart b/kifi-app/lib/features/vendor/domain/purchase_order_item.dart new file mode 100644 index 0000000..5324847 --- /dev/null +++ b/kifi-app/lib/features/vendor/domain/purchase_order_item.dart @@ -0,0 +1,93 @@ +class PurchaseOrderItem { + final int? id; + final int? poId; + final int? productId; + final double quantity; + final double unitPrice; + final double taxRate; + final double discount; + final double total; + final String? description; + final double makingCharge; + final double otherCharges; + final String? sku; + + PurchaseOrderItem({ + this.id, + this.poId, + this.productId, + this.quantity = 1.0, + this.unitPrice = 0.0, + this.taxRate = 0.0, + this.discount = 0.0, + this.total = 0.0, + this.description, + this.makingCharge = 0.0, + this.otherCharges = 0.0, + this.sku, + }); + + factory PurchaseOrderItem.fromJson(Map json) { + return PurchaseOrderItem( + id: json['id'], + poId: json['poId'], + productId: json['productId'], + quantity: (json['quantity'] ?? 1.0).toDouble(), + unitPrice: (json['unitPrice'] ?? 0.0).toDouble(), + taxRate: (json['taxRate'] ?? 0.0).toDouble(), + discount: (json['discount'] ?? 0.0).toDouble(), + total: (json['total'] ?? 0.0).toDouble(), + description: json['description'], + makingCharge: (json['makingCharge'] ?? 0.0).toDouble(), + otherCharges: (json['otherCharges'] ?? 0.0).toDouble(), + sku: json['sku'], + ); + } + + Map toJson() { + return { + 'id': id, + 'poId': poId, + 'productId': productId, + 'quantity': quantity, + 'unitPrice': unitPrice, + 'taxRate': taxRate, + 'discount': discount, + 'total': total, + 'description': description, + 'makingCharge': makingCharge, + 'otherCharges': otherCharges, + 'sku': sku, + }; + } + + PurchaseOrderItem copyWith({ + int? id, + int? poId, + int? productId, + double? quantity, + double? unitPrice, + double? taxRate, + double? discount, + double? total, + String? description, + double? makingCharge, + double? otherCharges, + String? sku, + }) { + return PurchaseOrderItem( + id: id ?? this.id, + poId: poId ?? this.poId, + productId: productId ?? this.productId, + quantity: quantity ?? this.quantity, + unitPrice: unitPrice ?? this.unitPrice, + taxRate: taxRate ?? this.taxRate, + discount: discount ?? this.discount, + total: total ?? this.total, + description: description ?? this.description, + makingCharge: makingCharge ?? this.makingCharge, + otherCharges: otherCharges ?? this.otherCharges, + sku: sku ?? this.sku, + ); + } +} diff --git a/kifi-app/lib/features/vendor/domain/purchase_payment.dart b/kifi-app/lib/features/vendor/domain/purchase_payment.dart new file mode 100644 index 0000000..62aa63a --- /dev/null +++ b/kifi-app/lib/features/vendor/domain/purchase_payment.dart @@ -0,0 +1,62 @@ +class PurchasePayment { + final int? id; + final int? poId; + final int? transactionId; + final double amount; + final String? paymentMethod; + final DateTime? paymentDate; + final String? notes; + + PurchasePayment({ + this.id, + this.poId, + this.transactionId, + required this.amount, + this.paymentMethod, + this.paymentDate, + this.notes, + }); + + factory PurchasePayment.fromJson(Map json) { + return PurchasePayment( + id: json['id'], + poId: json['poId'], + transactionId: json['transactionId'], + amount: (json['amount'] ?? 0.0).toDouble(), + paymentMethod: json['paymentMethod'], + paymentDate: json['paymentDate'] != null ? DateTime.parse(json['paymentDate']) : null, + notes: json['notes'], + ); + } + + Map toJson() { + return { + 'id': id, + 'poId': poId, + 'transactionId': transactionId, + 'amount': amount, + 'paymentMethod': paymentMethod, + 'notes': notes, + }; + } + + PurchasePayment copyWith({ + int? id, + int? poId, + int? transactionId, + double? amount, + String? paymentMethod, + DateTime? paymentDate, + String? notes, + }) { + return PurchasePayment( + id: id ?? this.id, + poId: poId ?? this.poId, + transactionId: transactionId ?? this.transactionId, + amount: amount ?? this.amount, + paymentMethod: paymentMethod ?? this.paymentMethod, + paymentDate: paymentDate ?? this.paymentDate, + notes: notes ?? this.notes, + ); + } +} diff --git a/kifi-app/lib/features/vendor/domain/vendor.dart b/kifi-app/lib/features/vendor/domain/vendor.dart new file mode 100644 index 0000000..b522b27 --- /dev/null +++ b/kifi-app/lib/features/vendor/domain/vendor.dart @@ -0,0 +1,91 @@ +class Vendor { + final int? id; + final int? userId; + final String name; + final String? email; + final String? phone; + final String? address; + final String? gstin; + final int? stateId; + final String? idNumber; + final String? photoUrl; + final String? contactPerson; + final DateTime? createdAt; + + Vendor({ + this.id, + this.userId, + required this.name, + this.email, + this.phone, + this.address, + this.gstin, + this.stateId, + this.idNumber, + this.photoUrl, + this.contactPerson, + this.createdAt, + }); + + factory Vendor.fromJson(Map json) { + return Vendor( + id: json['id'], + userId: json['userId'], + name: json['name'] ?? '', + email: json['email'], + phone: json['phone'], + address: json['address'], + gstin: json['gstin'], + stateId: json['stateId'], + idNumber: json['idNumber'], + photoUrl: json['photoUrl'], + contactPerson: json['contactPerson'], + createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'userId': userId, + 'name': name, + 'email': email, + 'phone': phone, + 'address': address, + 'gstin': gstin, + 'stateId': stateId, + 'idNumber': idNumber, + 'photoUrl': photoUrl, + 'contactPerson': contactPerson, + }; + } + + Vendor copyWith({ + int? id, + int? userId, + String? name, + String? email, + String? phone, + String? address, + String? gstin, + int? stateId, + String? idNumber, + String? photoUrl, + String? contactPerson, + }) { + return Vendor( + id: id ?? this.id, + userId: userId ?? this.userId, + name: name ?? this.name, + email: email ?? this.email, + phone: phone ?? this.phone, + address: address ?? this.address, + gstin: gstin ?? this.gstin, + stateId: stateId ?? this.stateId, + idNumber: idNumber ?? this.idNumber, + photoUrl: photoUrl ?? this.photoUrl, + contactPerson: contactPerson ?? this.contactPerson, + createdAt: createdAt, + ); + } +} diff --git a/kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart b/kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart new file mode 100644 index 0000000..9ed7874 --- /dev/null +++ b/kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart @@ -0,0 +1,322 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../providers/vendors_provider.dart'; +import '../domain/vendor.dart'; +import 'dart:async'; +import 'dart:io'; +import 'package:image_picker/image_picker.dart'; +import '../../../core/network/dio_client.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage; +import '../../../core/widgets/premium_text_field.dart'; +import '../../../core/widgets/smart_search_dropdown.dart'; +import '../../business/providers/indian_states_provider.dart'; +import 'package:dio/dio.dart'; + +class AddVendorSheet extends ConsumerStatefulWidget { + final Vendor? vendor; + + const AddVendorSheet({super.key, this.vendor}); + + @override + ConsumerState createState() => _AddVendorSheetState(); +} + +class _AddVendorSheetState extends ConsumerState { + final _formKey = GlobalKey(); + late TextEditingController _nameCtrl; + late TextEditingController _phoneCtrl; + late TextEditingController _emailCtrl; + late TextEditingController _addressCtrl; + late TextEditingController _gstinCtrl; + late TextEditingController _idNumberCtrl; + late TextEditingController _contactPersonCtrl; + int? _selectedStateId; + XFile? _photo; + String? _token; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _nameCtrl = TextEditingController(text: widget.vendor?.name ?? ''); + _phoneCtrl = TextEditingController(text: widget.vendor?.phone ?? ''); + _emailCtrl = TextEditingController(text: widget.vendor?.email ?? ''); + _addressCtrl = TextEditingController(text: widget.vendor?.address ?? ''); + _gstinCtrl = TextEditingController(text: widget.vendor?.gstin ?? ''); + _idNumberCtrl = TextEditingController(text: widget.vendor?.idNumber ?? ''); + _contactPersonCtrl = TextEditingController(text: widget.vendor?.contactPerson ?? ''); + _selectedStateId = widget.vendor?.stateId; + import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) { + if (mounted) setState(() => _token = val); + }); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _phoneCtrl.dispose(); + _emailCtrl.dispose(); + _addressCtrl.dispose(); + _gstinCtrl.dispose(); + _idNumberCtrl.dispose(); + _contactPersonCtrl.dispose(); + super.dispose(); + } + + Future _pickImage(ImageSource source) async { + final picker = ImagePicker(); + final picked = await picker.pickImage(source: source, imageQuality: 80); + if (picked != null) { + setState(() => _photo = picked); + } + } + + void _showImagePickerModal() { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (ctx) => SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(LucideIcons.camera), + title: const Text('Take a photo'), + onTap: () { + Navigator.pop(ctx); + _pickImage(ImageSource.camera); + }, + ), + ListTile( + leading: const Icon(LucideIcons.image), + title: const Text('Choose from gallery'), + onTap: () { + Navigator.pop(ctx); + _pickImage(ImageSource.gallery); + }, + ), + ], + ), + ), + ); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _isLoading = true); + + try { + String? photoUrl = widget.vendor?.photoUrl; + if (_photo != null && _token != null) { + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(_photo!.path, filename: _photo!.name), + 'type': 'VENDOR', + }); + + final uploadResp = await DioClient().dio.post( + '/upload', + data: formData, + ); + + if (uploadResp.statusCode == 200) { + photoUrl = uploadResp.data['url']; + } + } + + final vendor = Vendor( + id: widget.vendor?.id, + name: _nameCtrl.text, + phone: _phoneCtrl.text.isEmpty ? null : _phoneCtrl.text, + email: _emailCtrl.text.isEmpty ? null : _emailCtrl.text, + address: _addressCtrl.text.isEmpty ? null : _addressCtrl.text, + gstin: _gstinCtrl.text.isEmpty ? null : _gstinCtrl.text, + idNumber: _idNumberCtrl.text.isEmpty ? null : _idNumberCtrl.text, + stateId: _selectedStateId, + photoUrl: photoUrl, + contactPerson: _contactPersonCtrl.text.isEmpty ? null : _contactPersonCtrl.text, + ); + + if (widget.vendor == null) { + await ref.read(vendorsProvider.notifier).addVendor(vendor); + } else { + await ref.read(vendorsProvider.notifier).updateVendor(vendor); + } + + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(LucideIcons.chevronLeft), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Text( + widget.vendor == null ? 'New Vendor' : 'Edit Vendor', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + const Divider(), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + children: [ + Center( + child: Stack( + children: [ + CircleAvatar( + radius: 50, + backgroundColor: Colors.blue.withOpacity(0.1), + backgroundImage: _photo != null + ? FileImage(File(_photo!.path)) as ImageProvider + : (widget.vendor?.photoUrl != null + ? NetworkImage(widget.vendor!.photoUrl!) + : null), + child: (_photo == null && widget.vendor?.photoUrl == null) + ? const Icon(LucideIcons.truck, size: 50, color: Colors.blue) + : null, + ), + Positioned( + bottom: 0, + right: 0, + child: GestureDetector( + onTap: _showImagePickerModal, + child: Container( + padding: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + child: const Icon(LucideIcons.camera, color: Colors.white, size: 20), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 32), + PremiumTextField( + controller: _nameCtrl, + labelText: 'Vendor Name *', + prefixIcon: const Icon(LucideIcons.building), + textCapitalization: TextCapitalization.words, + validator: (val) => val == null || val.isEmpty ? 'Required' : null, + ), + const SizedBox(height: 16), + PremiumTextField( + controller: _phoneCtrl, + labelText: 'Phone Number', + prefixIcon: const Icon(LucideIcons.phone), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 16), + PremiumTextField( + controller: _contactPersonCtrl, + labelText: 'Contact Person', + prefixIcon: const Icon(LucideIcons.user), + textCapitalization: TextCapitalization.words, + ), + const SizedBox(height: 16), + PremiumTextField( + controller: _emailCtrl, + labelText: 'Email Address', + prefixIcon: const Icon(LucideIcons.mail), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 16), + PremiumTextField( + controller: _gstinCtrl, + labelText: 'GSTIN (Optional)', + prefixIcon: const Icon(LucideIcons.fileText), + textCapitalization: TextCapitalization.characters, + ), + const SizedBox(height: 16), + PremiumTextField( + controller: _idNumberCtrl, + labelText: 'ID No. (PAN, etc.)', + prefixIcon: const Icon(LucideIcons.creditCard), + textCapitalization: TextCapitalization.characters, + ), + const SizedBox(height: 16), + Consumer( + builder: (context, ref, _) { + final statesState = ref.watch(indianStatesProvider); + return statesState.when( + data: (states) { + return SmartSearchDropdown( + labelText: 'State', + hintText: 'Select State', + value: _selectedStateId, + items: states.map((s) => s.id).toList(), + itemAsString: (id) { + final s = states.firstWhere((st) => st.id == id); + return '${s.name} (${s.gstCode})'; + }, + onChanged: (val) => setState(() => _selectedStateId = val), + ); + }, + loading: () => const CircularProgressIndicator(), + error: (e, stack) => Text('Error loading states: $e'), + ); + }, + ), + const SizedBox(height: 16), + PremiumTextField( + controller: _addressCtrl, + labelText: 'Billing Address', + prefixIcon: const Icon(LucideIcons.mapPin), + maxLines: 3, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : _save, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: _isLoading + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : Text(widget.vendor == null ? 'Save Vendor' : 'Update Vendor'), + ), + ), + SizedBox(height: MediaQuery.of(context).viewInsets.bottom), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart b/kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart new file mode 100644 index 0000000..a8ac758 --- /dev/null +++ b/kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import '../../../core/widgets/premium_text_field.dart'; +import '../../../core/widgets/smart_search_dropdown.dart'; +import '../domain/purchase_order.dart'; +import '../domain/purchase_payment.dart'; +import '../providers/purchase_orders_provider.dart'; + +class PayVendorSheet extends ConsumerStatefulWidget { + final PurchaseOrder po; + + const PayVendorSheet({super.key, required this.po}); + + @override + ConsumerState createState() => _PayVendorSheetState(); +} + +class _PayVendorSheetState extends ConsumerState { + final _formKey = GlobalKey(); + late TextEditingController _amountCtrl; + final TextEditingController _notesCtrl = TextEditingController(); + DateTime _paymentDate = DateTime.now(); + String? _selectedMethod; + bool _isLoading = false; + + final List _paymentMethods = ['CASH', 'BANK_TRANSFER', 'UPI', 'CARD', 'CHEQUE']; + + @override + void initState() { + super.initState(); + final balance = widget.po.totalAmount - widget.po.amountPaid; + _amountCtrl = TextEditingController(text: balance > 0 ? balance.toStringAsFixed(2) : '0'); + } + + @override + void dispose() { + _amountCtrl.dispose(); + _notesCtrl.dispose(); + super.dispose(); + } + + Future _savePayment() async { + if (!_formKey.currentState!.validate()) return; + if (_selectedMethod == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a payment method'))); + return; + } + + setState(() => _isLoading = true); + try { + final payment = PurchasePayment( + amount: double.parse(_amountCtrl.text), + paymentMethod: _selectedMethod, + paymentDate: _paymentDate, + notes: _notesCtrl.text.isEmpty ? null : _notesCtrl.text, + ); + + await ref.read(purchaseOrdersProvider.notifier).addPayment(widget.po.id!, payment); + + if (mounted) { + Navigator.pop(context, true); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Payment recorded successfully'))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom, left: 24, right: 24, top: 24), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Record Payment', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 16), + PremiumTextField( + controller: _amountCtrl, + labelText: 'Amount Paid', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + prefixIcon: const Icon(LucideIcons.indianRupee), + validator: (val) { + if (val == null || val.isEmpty) return 'Required'; + if (double.tryParse(val) == null) return 'Invalid amount'; + return null; + }, + ), + const SizedBox(height: 16), + SmartSearchDropdown( + labelText: 'Payment Method', + hintText: 'Select Method', + value: _selectedMethod, + items: _paymentMethods, + itemAsString: (val) => val.replaceAll('_', ' '), + onChanged: (val) => setState(() => _selectedMethod = val), + ), + const SizedBox(height: 16), + InkWell( + onTap: () async { + final d = await showDatePicker( + context: context, + initialDate: _paymentDate, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (d != null) setState(() => _paymentDate = d); + }, + child: InputDecorator( + decoration: const InputDecoration(labelText: 'Payment Date', border: OutlineInputBorder()), + child: Text(DateFormat('dd MMM yyyy').format(_paymentDate)), + ), + ), + const SizedBox(height: 16), + PremiumTextField( + controller: _notesCtrl, + labelText: 'Notes (Optional)', + maxLines: 2, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : _savePayment, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + child: _isLoading + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Text('Save Payment'), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart b/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart new file mode 100644 index 0000000..14d72cf --- /dev/null +++ b/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart @@ -0,0 +1,456 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import 'dart:io'; +import 'package:image_picker/image_picker.dart'; +import 'package:dio/dio.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/widgets/premium_text_field.dart'; +import '../../../core/widgets/smart_search_dropdown.dart'; +import '../../inventory/providers/products_provider.dart'; +import '../providers/vendors_provider.dart'; +import '../providers/purchase_orders_provider.dart'; +import '../domain/purchase_order.dart'; +import '../domain/purchase_order_item.dart'; +import '../domain/vendor.dart'; +import '../../inventory/domain/product.dart'; +import 'pay_vendor_sheet.dart' as import_pay; + +class PurchaseOrderBuilderScreen extends ConsumerStatefulWidget { + final PurchaseOrder? existingPo; + + const PurchaseOrderBuilderScreen({super.key, this.existingPo}); + + @override + ConsumerState createState() => _PurchaseOrderBuilderScreenState(); +} + +class _PurchaseOrderBuilderScreenState extends ConsumerState { + int? _selectedVendorId; + DateTime _issueDate = DateTime.now(); + DateTime? _dueDate; + final TextEditingController _poNumberCtrl = TextEditingController(); + final TextEditingController _notesCtrl = TextEditingController(); + + List _items = []; + bool _isLoading = false; + XFile? _invoiceFile; + String? _invoiceUrl; + + @override + void initState() { + super.initState(); + if (widget.existingPo != null) { + _selectedVendorId = widget.existingPo!.vendorId; + _issueDate = widget.existingPo!.issueDate; + _dueDate = widget.existingPo!.dueDate; + _poNumberCtrl.text = widget.existingPo!.poNumber; + _notesCtrl.text = widget.existingPo!.notes ?? ''; + _items = List.from(widget.existingPo!.items); + _invoiceUrl = widget.existingPo!.vendorInvoiceUrl; + } else { + _poNumberCtrl.text = 'PO-${DateTime.now().millisecondsSinceEpoch.toString().substring(5)}'; + } + } + + @override + void dispose() { + _poNumberCtrl.dispose(); + _notesCtrl.dispose(); + super.dispose(); + } + + double get _subtotal { + return _items.fold(0, (sum, item) => sum + (item.quantity * item.unitPrice)); + } + + double get _totalAmount { + return _subtotal; + } + + Future _pickInvoiceFile() async { + final picker = ImagePicker(); + final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 80); + if (picked != null) { + setState(() => _invoiceFile = picked); + } + } + + Future _savePo() async { + if (_selectedVendorId == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a vendor'))); + return; + } + if (_items.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please add at least one item'))); + return; + } + + setState(() => _isLoading = true); + try { + String? finalInvoiceUrl = _invoiceUrl; + + if (_invoiceFile != null) { + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(_invoiceFile!.path, filename: _invoiceFile!.name), + 'type': 'PO_INVOICE', + }); + final uploadResp = await DioClient().dio.post('/upload', data: formData); + if (uploadResp.statusCode == 200) { + finalInvoiceUrl = uploadResp.data['url']; + } + } + + final po = PurchaseOrder( + id: widget.existingPo?.id, + vendorId: _selectedVendorId, + poNumber: _poNumberCtrl.text, + issueDate: _issueDate, + dueDate: _dueDate, + subtotal: _subtotal, + totalAmount: _totalAmount, + notes: _notesCtrl.text, + vendorInvoiceUrl: finalInvoiceUrl, + items: _items, + ); + + if (widget.existingPo == null) { + await ref.read(purchaseOrdersProvider.notifier).createPurchaseOrder(po); + } else { + await ref.read(purchaseOrdersProvider.notifier).updatePurchaseOrder(po); + } + + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error saving PO: $e'))); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _receivePo() async { + if (widget.existingPo == null) return; + + // We update the items in the existing PO to what's currently in _items (user might have edited quantities) + final po = widget.existingPo!.copyWith(items: _items); + + setState(() => _isLoading = true); + try { + await ref.read(purchaseOrdersProvider.notifier).markAsReceived(po); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('PO Received and Stock Updated'))); + Navigator.pop(context); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error receiving PO: $e'))); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + void _showAddItemSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (ctx) { + return const _AddItemSheet(); + }, + ).then((result) { + if (result != null && result is PurchaseOrderItem) { + setState(() => _items.add(result)); + } + }); + } + + @override + Widget build(BuildContext context) { + final vendorsState = ref.watch(vendorsProvider); + + return Scaffold( + appBar: AppBar( + title: Text(widget.existingPo == null ? 'New Purchase Order' : 'Edit PO ${_poNumberCtrl.text}'), + actions: [ + if (_isLoading) + const Padding( + padding: EdgeInsets.all(16.0), + child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), + ) + else ...[ + if (widget.existingPo != null && widget.existingPo!.status == 'RECEIVED' && widget.existingPo!.amountPaid < widget.existingPo!.totalAmount) + TextButton.icon( + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (_) => import_pay.PayVendorSheet(po: widget.existingPo!), + ).then((val) { + if (val == true && mounted) { + Navigator.pop(context); // Close the builder screen to refresh the list + } + }); + }, + icon: const Icon(LucideIcons.indianRupee, size: 18), + label: const Text('Pay'), + ), + if (widget.existingPo != null && widget.existingPo!.status == 'PENDING') + TextButton.icon( + onPressed: _receivePo, + icon: const Icon(LucideIcons.packageCheck, size: 18), + label: const Text('Receive'), + ), + if (widget.existingPo == null || widget.existingPo!.status == 'PENDING') + TextButton.icon( + onPressed: _savePo, + icon: const Icon(LucideIcons.save, size: 18), + label: const Text('Save'), + ), + ] + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + vendorsState.when( + data: (vendors) => SmartSearchDropdown( + labelText: 'Vendor *', + hintText: 'Select Vendor', + value: _selectedVendorId, + items: vendors.map((v) => v.id!).toList(), + itemAsString: (id) => vendors.firstWhere((v) => v.id == id).name, + onChanged: (val) => setState(() => _selectedVendorId = val), + ), + loading: () => const CircularProgressIndicator(), + error: (e, stack) => Text('Error: $e'), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: PremiumTextField( + controller: _poNumberCtrl, + labelText: 'PO Number *', + ), + ), + const SizedBox(width: 16), + Expanded( + child: InkWell( + onTap: () async { + final d = await showDatePicker( + context: context, + initialDate: _issueDate, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (d != null) setState(() => _issueDate = d); + }, + child: InputDecorator( + decoration: const InputDecoration(labelText: 'Issue Date', border: OutlineInputBorder()), + child: Text(DateFormat('dd MMM yyyy').format(_issueDate)), + ), + ), + ), + ], + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Items', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + TextButton.icon( + onPressed: _showAddItemSheet, + icon: const Icon(LucideIcons.plus), + label: const Text('Add Item'), + ), + ], + ), + const Divider(), + if (_items.isEmpty) + const Padding( + padding: EdgeInsets.all(24.0), + child: Center(child: Text('No items added yet', style: TextStyle(color: Colors.grey))), + ) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _items.length, + itemBuilder: (context, index) { + final item = _items[index]; + final productsState = ref.watch(productsProvider); + final product = productsState.value?.firstWhere( + (p) => p.id == item.productId, + orElse: () => Product(name: 'Unknown') + ); + final productName = product?.name ?? 'Unknown'; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text(productName), + subtitle: Text('${item.quantity} x ₹${item.unitPrice}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('₹${(item.quantity * item.unitPrice).toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold)), + IconButton( + icon: const Icon(LucideIcons.trash, color: Colors.red), + onPressed: () => setState(() => _items.removeAt(index)), + ), + ], + ), + ), + ); + }, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Total Amount', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + Text('₹${_totalAmount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.green)), + ], + ), + const SizedBox(height: 24), + PremiumTextField( + controller: _notesCtrl, + labelText: 'Notes', + maxLines: 3, + ), + const SizedBox(height: 24), + Text('Attachments', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + if (_invoiceFile != null) + ListTile( + leading: const Icon(LucideIcons.image), + title: Text(_invoiceFile!.name), + trailing: IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => setState(() => _invoiceFile = null), + ), + ) + else if (_invoiceUrl != null) + ListTile( + leading: const Icon(LucideIcons.link), + title: const Text('View Attached Invoice'), + onTap: () { + // Open link (TBD) + }, + ) + else + OutlinedButton.icon( + onPressed: _pickInvoiceFile, + icon: const Icon(LucideIcons.upload), + label: const Text('Attach Vendor Invoice'), + ), + const SizedBox(height: 100), + ], + ), + ), + ); + } +} + +class _AddItemSheet extends ConsumerStatefulWidget { + const _AddItemSheet(); + + @override + ConsumerState<_AddItemSheet> createState() => _AddItemSheetState(); +} + +class _AddItemSheetState extends ConsumerState<_AddItemSheet> { + int? _selectedProductId; + final TextEditingController _qtyCtrl = TextEditingController(text: '1'); + final TextEditingController _priceCtrl = TextEditingController(); + + void _save() { + if (_selectedProductId == null) return; + final qty = double.tryParse(_qtyCtrl.text) ?? 1; + final price = double.tryParse(_priceCtrl.text) ?? 0; + + final item = PurchaseOrderItem( + productId: _selectedProductId, + quantity: qty, + unitPrice: price, + total: qty * price, + ); + Navigator.pop(context, item); + } + + @override + Widget build(BuildContext context) { + final productsState = ref.watch(productsProvider); + + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom, left: 24, right: 24, top: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Add PO Item', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + productsState.when( + data: (products) => SmartSearchDropdown( + labelText: 'Product', + hintText: 'Select Product', + value: _selectedProductId, + items: products.map((p) => p.id!).toList(), + itemAsString: (id) => products.firstWhere((p) => p.id == id).name, + onChanged: (val) { + setState(() { + _selectedProductId = val; + final p = products.firstWhere((prod) => prod.id == val); + _priceCtrl.text = p.sellingPrice?.toString() ?? '0'; + }); + }, + ), + loading: () => const CircularProgressIndicator(), + error: (e, stack) => Text('Error: $e'), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: PremiumTextField( + controller: _qtyCtrl, + labelText: 'Quantity', + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PremiumTextField( + controller: _priceCtrl, + labelText: 'Unit Price', + keyboardType: TextInputType.number, + ), + ), + ], + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _save, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + child: const Text('Add Item'), + ), + ), + const SizedBox(height: 24), + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart b/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart new file mode 100644 index 0000000..ea02323 --- /dev/null +++ b/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart @@ -0,0 +1,579 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import '../../inventory/providers/products_provider.dart'; +import '../../inventory/domain/product.dart'; +import '../domain/purchase_order.dart'; +import '../domain/purchase_payment.dart'; +import '../providers/purchase_orders_provider.dart'; +import '../providers/vendors_provider.dart'; +import '../../business/providers/business_provider.dart'; +import 'pay_vendor_sheet.dart' as import_pay; + +class PurchaseOrderDetailsScreen extends ConsumerStatefulWidget { + final PurchaseOrder po; + + const PurchaseOrderDetailsScreen({super.key, required this.po}); + + @override + ConsumerState createState() => _PurchaseOrderDetailsScreenState(); +} + +class _PurchaseOrderDetailsScreenState extends ConsumerState { + + void _showPaymentSheet(BuildContext context, WidgetRef ref, PurchaseOrder latestPo) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => import_pay.PayVendorSheet(po: latestPo), + ).then((result) { + if (result == true) { + ref.invalidate(purchaseOrdersProvider); + } + }); + } + + void _showPaymentHistory(BuildContext context, WidgetRef ref, PurchaseOrder po) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (ctx) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Payment History: ${po.poNumber}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + FutureBuilder>( + future: ref.read(purchaseOrdersProvider.notifier).fetchPaymentsForPurchaseOrder(po.id!), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + final payments = snapshot.data; + if (payments == null || payments.isEmpty) { + return const Padding( + padding: EdgeInsets.all(24.0), + child: Text('No payments recorded yet.'), + ); + } + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + headingRowColor: WidgetStateProperty.resolveWith((states) => Colors.grey.shade100), + columnSpacing: 24, + columns: const [ + DataColumn(label: Text('Date', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('Method', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('Amount', style: TextStyle(fontWeight: FontWeight.bold))), + ], + rows: payments.map((p) { + return DataRow( + cells: [ + DataCell(Text(p.paymentDate != null ? DateFormat('dd MMM yyyy').format(p.paymentDate!) : '-')), + DataCell(Text(p.paymentMethod ?? '-')), + DataCell(Text('₹${p.amount.toStringAsFixed(2)}', style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.bold))), + ], + ); + }).toList(), + ), + ); + }, + ), + ], + ), + ); + }, + ); + } + + Future _shareAsPdf(String poNumber, PurchaseOrder po, dynamic vendor, dynamic business, List products) async { + try { + final formatCurrency = NumberFormat.currency(symbol: '₹'); + final formatDate = DateFormat('dd MMM yyyy'); + final pdf = pw.Document(); + + pdf.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(32), + build: (pw.Context context) { + return [ + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text(business?.businessName ?? 'Your Company Name', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold, color: PdfColors.deepOrange800)), + pw.SizedBox(height: 4), + if (business?.address != null) pw.Text(business!.address!, style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700)), + if (business?.contactNumber != null) pw.Text('Ph: ${business!.contactNumber}', style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700)), + ], + ), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text('PURCHASE ORDER', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold, color: PdfColors.deepOrange800)), + pw.SizedBox(height: 8), + pw.Container( + padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: pw.BoxDecoration(color: PdfColors.grey200, borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4))), + child: pw.Text(po.status, style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold)), + ), + ], + ), + ], + ), + pw.SizedBox(height: 30), + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('VENDOR:', style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.grey600)), + pw.SizedBox(height: 4), + pw.Text(vendor?.name ?? 'Unknown Vendor', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)), + if (vendor?.address != null) pw.Text(vendor!.address!, style: const pw.TextStyle(fontSize: 10)), + if (vendor?.phone != null) pw.Text('Ph: ${vendor!.phone}', style: const pw.TextStyle(fontSize: 10)), + if (vendor?.gstin != null) pw.Text('GSTIN: ${vendor!.gstin}', style: const pw.TextStyle(fontSize: 10)), + ], + ), + ), + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text('PO NUMBER:', style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.grey600)), + pw.Text(po.poNumber, style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)), + pw.SizedBox(height: 8), + pw.Text('ISSUE DATE:', style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.grey600)), + pw.Text(formatDate.format(po.issueDate), style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)), + ], + ), + ), + ], + ), + pw.SizedBox(height: 30), + pw.Table( + border: const pw.TableBorder( + bottom: pw.BorderSide(color: PdfColors.grey300, width: .5), + horizontalInside: pw.BorderSide(color: PdfColors.grey300, width: .5), + ), + columnWidths: { + 0: const pw.FlexColumnWidth(3), + 1: const pw.FlexColumnWidth(1), + 2: const pw.FlexColumnWidth(1.5), + 3: const pw.FlexColumnWidth(1.5), + }, + children: [ + pw.TableRow( + decoration: const pw.BoxDecoration(color: PdfColors.deepOrange800), + children: [ + pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text('Item Description', style: pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold))), + pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text('Qty', textAlign: pw.TextAlign.center, style: pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold))), + pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text('Rate', textAlign: pw.TextAlign.right, style: pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold))), + pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text('Amount', textAlign: pw.TextAlign.right, style: pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold))), + ], + ), + ...po.items.map((item) { + final product = products.firstWhere((p) => p.id == item.productId, orElse: () => Product(name: 'Item ${item.productId}')); + final pName = product.name; + return pw.TableRow( + children: [ + pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(pName, style: const pw.TextStyle(fontSize: 10))), + pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(item.quantity.toString(), textAlign: pw.TextAlign.center, style: const pw.TextStyle(fontSize: 10))), + pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(formatCurrency.format(item.unitPrice), textAlign: pw.TextAlign.right, style: const pw.TextStyle(fontSize: 10))), + pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(formatCurrency.format(item.quantity * item.unitPrice), textAlign: pw.TextAlign.right, style: const pw.TextStyle(fontSize: 10))), + ], + ); + }), + ], + ), + pw.SizedBox(height: 20), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.end, + children: [ + pw.Container( + width: 250, + child: pw.Column( + children: [ + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('Subtotal:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)), + pw.Text(formatCurrency.format(po.subtotal), style: pw.TextStyle(fontWeight: pw.FontWeight.bold)), + ], + ), + pw.SizedBox(height: 8), + pw.Divider(color: PdfColors.grey400), + pw.SizedBox(height: 8), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('Total Amount:', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: PdfColors.deepOrange800)), + pw.Text(formatCurrency.format(po.totalAmount), style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: PdfColors.deepOrange800)), + ], + ), + if (po.status == 'RECEIVED') ...[ + pw.SizedBox(height: 8), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('Amount Paid:', style: pw.TextStyle(color: PdfColors.green700)), + pw.Text(formatCurrency.format(po.amountPaid), style: pw.TextStyle(color: PdfColors.green700)), + ], + ), + pw.SizedBox(height: 4), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('Balance Due:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.red700)), + pw.Text(formatCurrency.format(po.totalAmount - po.amountPaid), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.red700)), + ], + ), + ] + ], + ), + ), + ], + ), + ]; + }, + ), + ); + + final directory = await getApplicationDocumentsDirectory(); + final pdfPath = await File('${directory.path}/PO_$poNumber.pdf').create(); + await pdfPath.writeAsBytes(await pdf.save()); + + await Share.shareXFiles([XFile(pdfPath.path)], text: 'Purchase Order $poNumber'); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing PDF: $e'))); + } + } + + void _showShareOptions(BuildContext context, PurchaseOrder po, dynamic vendor, dynamic business, List products) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (ctx) { + return SafeArea( + child: Wrap( + children: [ + const Padding( + padding: EdgeInsets.all(16), + child: Text('Share Purchase Order', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ), + ListTile( + leading: const Icon(LucideIcons.fileText, color: Colors.red), + title: const Text('Share as PDF'), + onTap: () { + Navigator.pop(ctx); + _shareAsPdf(po.poNumber, po, vendor, business, products); + }, + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final posState = ref.watch(purchaseOrdersProvider); + final latestPo = posState.value?.firstWhere( + (p) => p.id == widget.po.id, + orElse: () => widget.po, + ) ?? widget.po; + + final vendorsState = ref.watch(vendorsProvider); + final vendor = vendorsState.value?.firstWhere( + (v) => v.id == latestPo.vendorId, + orElse: () => null as dynamic, + ); + + final businessState = ref.watch(businessProfileProvider); + final business = businessState.value; + + final productsState = ref.watch(productsProvider); + final products = productsState.value ?? []; + + final formatCurrency = NumberFormat.currency(symbol: '₹'); + final formatDate = DateFormat('dd MMM yyyy'); + + double remaining = latestPo.totalAmount - latestPo.amountPaid; + + return Scaffold( + backgroundColor: Colors.grey[50], + appBar: AppBar( + title: Text('PO #${latestPo.poNumber}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 0, + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(LucideIcons.share2, size: 20), + onPressed: () => _showShareOptions(context, latestPo, vendor, business, products), + ), + ], + ), + body: SingleChildScrollView( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header Card + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.deepOrange.shade900, Colors.deepOrange.shade800], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow(color: Colors.deepOrange.withOpacity(0.2), blurRadius: 15, offset: const Offset(0, 5)), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + business?.businessName ?? 'Your Company Name', + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + latestPo.status, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1), + ), + ), + ], + ), + ], + ), + ), + + const SizedBox(height: 20), + + // PO Details + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('PO NUMBER', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)), + const SizedBox(height: 4), + Text(latestPo.poNumber, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.deepOrange)), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Text('ISSUE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)), + const SizedBox(height: 4), + Text(formatDate.format(latestPo.issueDate), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + ], + ), + ], + ), + const Divider(height: 32), + const Text('VENDOR', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)), + const SizedBox(height: 8), + Text(vendor?.name ?? 'Unknown', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + if (vendor?.phone != null) ...[ + const SizedBox(height: 4), + Row( + children: [ + const Icon(LucideIcons.phone, size: 14, color: Colors.grey), + const SizedBox(width: 8), + Text(vendor!.phone!, style: const TextStyle(color: Colors.black87)), + ], + ), + ] + ], + ), + ), + + const SizedBox(height: 20), + + // Items List + const Text('ITEMS', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + ), + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: latestPo.items.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = latestPo.items[index]; + final product = products.firstWhere((p) => p.id == item.productId, orElse: () => Product(name: 'Item ${item.productId}')); + final pName = product.name; + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(pName, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), + const SizedBox(height: 4), + Text('${item.quantity} x ${formatCurrency.format(item.unitPrice)}', style: TextStyle(color: Colors.grey.shade600, fontSize: 13)), + ], + ), + ), + Text(formatCurrency.format(item.quantity * item.unitPrice), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), + ], + ), + ); + }, + ), + ), + + const SizedBox(height: 20), + + // Summary Card + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Subtotal', style: TextStyle(color: Colors.grey.shade600, fontSize: 15)), + Text(formatCurrency.format(latestPo.subtotal), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15)), + ], + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Divider(height: 1), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Total Amount', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + Text(formatCurrency.format(latestPo.totalAmount), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.deepOrange)), + ], + ), + if (latestPo.status == 'RECEIVED') ...[ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Amount Paid', style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.w600)), + Text(formatCurrency.format(latestPo.amountPaid), style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.bold, fontSize: 16)), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Balance Due', style: TextStyle(color: Colors.red.shade700, fontWeight: FontWeight.w600)), + Text(formatCurrency.format(remaining), style: TextStyle(color: Colors.red.shade700, fontWeight: FontWeight.bold, fontSize: 16)), + ], + ), + ] + ], + ), + ), + ], + ), + ), + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _showPaymentHistory(context, ref, latestPo), + icon: const Icon(LucideIcons.history, size: 18), + label: const Text('Payment History'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + if (latestPo.status == 'RECEIVED' && remaining > 0) ...[ + const SizedBox(width: 16), + Expanded( + child: ElevatedButton.icon( + onPressed: () => _showPaymentSheet(context, ref, latestPo), + icon: const Icon(LucideIcons.indianRupee, size: 18), + label: const Text('Record Payment'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/vendor/presentation/purchase_orders_list_screen.dart b/kifi-app/lib/features/vendor/presentation/purchase_orders_list_screen.dart new file mode 100644 index 0000000..e4f5bc2 --- /dev/null +++ b/kifi-app/lib/features/vendor/presentation/purchase_orders_list_screen.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import '../providers/purchase_orders_provider.dart'; +import '../providers/vendors_provider.dart'; +import '../domain/purchase_order.dart'; +import 'purchase_order_builder_screen.dart'; +import 'purchase_order_details_screen.dart'; + +class PurchaseOrdersListScreen extends ConsumerStatefulWidget { + const PurchaseOrdersListScreen({super.key}); + + @override + ConsumerState createState() => _PurchaseOrdersListScreenState(); +} + +class _PurchaseOrdersListScreenState extends ConsumerState { + String _searchQuery = ''; + final TextEditingController _searchController = TextEditingController(); + + String _getStatusLabel(PurchaseOrder po) { + if (po.status == 'RECEIVED') { + if (po.amountPaid <= 0) return 'UNPAID'; + if (po.amountPaid < po.totalAmount) return 'PARTIAL'; + return 'PAID'; + } + return po.status; + } + + Color _getStatusColor(PurchaseOrder po) { + if (po.status == 'RECEIVED') { + if (po.amountPaid <= 0) return Colors.orange; + if (po.amountPaid < po.totalAmount) return Colors.blue; + return Colors.green; + } + switch (po.status) { + case 'PENDING': return Colors.grey; + case 'CANCELLED': return Colors.red; + default: return Colors.blue; + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final poState = ref.watch(purchaseOrdersProvider); + final vendorsState = ref.watch(vendorsProvider); + final darkTheme = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + title: const Text('Purchase Orders'), + elevation: 0, + backgroundColor: Colors.transparent, + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search PO Number...', + prefixIcon: const Icon(LucideIcons.search), + filled: true, + fillColor: darkTheme ? Colors.grey[900] : Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(LucideIcons.xCircle), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + ), + onChanged: (val) => setState(() => _searchQuery = val), + ), + ), + Expanded( + child: poState.when( + data: (pos) { + final filtered = pos.where((p) { + return p.poNumber.toLowerCase().contains(_searchQuery.toLowerCase()); + }).toList(); + + if (filtered.isEmpty) { + return const Center(child: Text('No purchase orders found', style: TextStyle(color: Colors.grey))); + } + + return RefreshIndicator( + onRefresh: () => ref.refresh(purchaseOrdersProvider.future), + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: filtered.length, + itemBuilder: (context, index) { + final po = filtered[index]; + final vendors = vendorsState.value ?? []; + final vendor = vendors.firstWhere((v) => v.id == po.vendorId, orElse: () => po.vendor!); + + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: InkWell( + onTap: () { + if (po.status == 'PENDING') { + Navigator.push(context, MaterialPageRoute(builder: (_) => PurchaseOrderBuilderScreen(existingPo: po))); + } else { + Navigator.push(context, MaterialPageRoute(builder: (_) => PurchaseOrderDetailsScreen(po: po))); + } + }, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + po.poNumber, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _getStatusColor(po).withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _getStatusLabel(po), + style: TextStyle( + color: _getStatusColor(po), + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Icon(LucideIcons.user, size: 16, color: Colors.grey), + const SizedBox(width: 8), + Expanded( + child: Text( + vendor.name, + style: const TextStyle(fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(LucideIcons.calendar, size: 16, color: Colors.grey), + const SizedBox(width: 8), + Text( + DateFormat('MMM dd, yyyy').format(po.issueDate), + style: const TextStyle(color: Colors.grey, fontSize: 14), + ), + const Spacer(), + Text( + '₹${po.totalAmount.toStringAsFixed(2)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, stack) => Center(child: Text('Error: $e')), + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const PurchaseOrderBuilderScreen())); + }, + icon: const Icon(LucideIcons.plus), + label: const Text('New PO'), + ), + ); + } +} diff --git a/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart b/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart new file mode 100644 index 0000000..eb20f4c --- /dev/null +++ b/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../providers/vendors_provider.dart'; +import 'add_vendor_sheet.dart'; + +class VendorsListScreen extends ConsumerStatefulWidget { + const VendorsListScreen({super.key}); + + @override + ConsumerState createState() => _VendorsListScreenState(); +} + +class _VendorsListScreenState extends ConsumerState { + String _searchQuery = ''; + + @override + Widget build(BuildContext context) { + final vendorsState = ref.watch(vendorsProvider); + final darkTheme = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + title: const Text('Vendors'), + elevation: 0, + backgroundColor: Colors.transparent, + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0), + child: TextField( + decoration: InputDecoration( + hintText: 'Search vendors...', + prefixIcon: const Icon(LucideIcons.search), + filled: true, + fillColor: darkTheme ? Colors.grey[900] : Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + ), + onChanged: (val) => setState(() => _searchQuery = val), + ), + ), + Expanded( + child: vendorsState.when( + data: (vendors) { + final filtered = vendors.where((v) { + final search = _searchQuery.toLowerCase(); + return v.name.toLowerCase().contains(search) || + (v.phone != null && v.phone!.contains(search)); + }).toList(); + + if (filtered.isEmpty) { + return const Center(child: Text('No vendors found', style: TextStyle(color: Colors.grey))); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: filtered.length, + itemBuilder: (context, index) { + final vendor = filtered[index]; + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ListTile( + contentPadding: const EdgeInsets.all(16), + leading: CircleAvatar( + backgroundColor: Colors.blue.withOpacity(0.1), + child: vendor.photoUrl != null + ? ClipOval(child: Image.network(vendor.photoUrl!, width: 40, height: 40, fit: BoxFit.cover)) + : const Icon(LucideIcons.truck, color: Colors.blue), + ), + title: Text(vendor.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(vendor.phone ?? 'No phone number'), + trailing: const Icon(LucideIcons.chevronRight, color: Colors.grey), + onTap: () { + // View vendor details (TBD) + }, + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, stack) => Center(child: Text('Error: $e')), + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => const AddVendorSheet(), + ); + }, + icon: const Icon(LucideIcons.plus), + label: const Text('New Vendor'), + ), + ); + } +} diff --git a/kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart b/kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart new file mode 100644 index 0000000..c7ea474 --- /dev/null +++ b/kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart @@ -0,0 +1,129 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/network/dio_client.dart'; +import '../domain/purchase_order.dart'; +import '../domain/purchase_payment.dart'; + +class PurchaseOrdersNotifier extends AsyncNotifier> { + @override + Future> build() async { + return _fetchPurchaseOrders(); + } + + Future> _fetchPurchaseOrders() async { + try { + final response = await DioClient().dio.get('/purchase-orders'); + if (response.statusCode == 200) { + final List data = response.data; + return data.map((json) => PurchaseOrder.fromJson(json)).toList(); + } + return []; + } catch (e) { + throw Exception('Failed to load purchase orders: $e'); + } + } + + Future getPurchaseOrder(int id) async { + try { + final response = await DioClient().dio.get('/purchase-orders/$id'); + if (response.statusCode == 200) { + return PurchaseOrder.fromJson(response.data); + } + throw Exception('Purchase order not found'); + } catch (e) { + throw Exception('Failed to load purchase order: $e'); + } + } + + Future createPurchaseOrder(PurchaseOrder po) async { + try { + final response = await DioClient().dio.post( + '/purchase-orders', + data: po.toJson(), + ); + if (response.statusCode == 200) { + final newPo = PurchaseOrder.fromJson(response.data); + final current = state.value ?? []; + state = AsyncValue.data([newPo, ...current]); + return newPo; + } + throw Exception('Failed to create purchase order'); + } catch (e) { + throw Exception('Error creating purchase order: $e'); + } + } + + Future updatePurchaseOrder(PurchaseOrder po) async { + try { + final response = await DioClient().dio.put( + '/purchase-orders/${po.id}', + data: po.toJson(), + ); + if (response.statusCode == 200) { + final updatedPo = PurchaseOrder.fromJson(response.data); + final current = state.value ?? []; + state = AsyncValue.data( + current.map((c) => c.id == updatedPo.id ? updatedPo : c).toList(), + ); + return updatedPo; + } + throw Exception('Failed to update purchase order'); + } catch (e) { + throw Exception('Error updating purchase order: $e'); + } + } + + Future markAsReceived(PurchaseOrder po) async { + try { + final response = await DioClient().dio.put( + '/purchase-orders/${po.id}/receive', + data: po.toJson(), + ); + if (response.statusCode == 200) { + final updatedPo = PurchaseOrder.fromJson(response.data); + final current = state.value ?? []; + state = AsyncValue.data( + current.map((c) => c.id == updatedPo.id ? updatedPo : c).toList(), + ); + return updatedPo; + } + throw Exception('Failed to receive purchase order'); + } catch (e) { + throw Exception('Error receiving purchase order: $e'); + } + } + + Future addPayment(int poId, PurchasePayment payment) async { + try { + final response = await DioClient().dio.post( + '/purchase-orders/$poId/payments', + data: payment.toJson(), + ); + if (response.statusCode == 200) { + // Refresh POs to update amount paid + ref.invalidateSelf(); + return PurchasePayment.fromJson(response.data); + } + throw Exception('Failed to add payment'); + } catch (e) { + throw Exception('Error adding payment: $e'); + } + } + + Future> fetchPaymentsForPurchaseOrder(int poId) async { + try { + final response = await DioClient().dio.get('/purchase-orders/$poId/payments'); + if (response.statusCode == 200) { + final List data = response.data; + return data.map((json) => PurchasePayment.fromJson(json)).toList(); + } + return []; + } catch (e) { + throw Exception('Failed to load payments: $e'); + } + } +} + +final purchaseOrdersProvider = AsyncNotifierProvider>(() { + return PurchaseOrdersNotifier(); +}); diff --git a/kifi-app/lib/features/vendor/providers/vendors_provider.dart b/kifi-app/lib/features/vendor/providers/vendors_provider.dart new file mode 100644 index 0000000..47fb3f1 --- /dev/null +++ b/kifi-app/lib/features/vendor/providers/vendors_provider.dart @@ -0,0 +1,88 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/network/dio_client.dart'; +import '../domain/vendor.dart'; + +class VendorsNotifier extends AsyncNotifier> { + @override + Future> build() async { + return _fetchVendors(); + } + + Future> _fetchVendors() async { + try { + final response = await DioClient().dio.get('/vendors'); + if (response.statusCode == 200) { + final List data = response.data; + return data.map((json) => Vendor.fromJson(json)).toList(); + } + return []; + } catch (e) { + throw Exception('Failed to load vendors: $e'); + } + } + + Future getVendor(int id) async { + try { + final response = await DioClient().dio.get('/vendors/$id'); + if (response.statusCode == 200) { + return Vendor.fromJson(response.data); + } + throw Exception('Vendor not found'); + } catch (e) { + throw Exception('Failed to load vendor: $e'); + } + } + + Future addVendor(Vendor vendor) async { + try { + final response = await DioClient().dio.post( + '/vendors', + data: vendor.toJson(), + ); + if (response.statusCode == 200) { + final newVendor = Vendor.fromJson(response.data); + final current = state.value ?? []; + state = AsyncValue.data([...current, newVendor]); + return newVendor; + } + throw Exception('Failed to add vendor'); + } catch (e) { + throw Exception('Error adding vendor: $e'); + } + } + + Future updateVendor(Vendor vendor) async { + try { + final response = await DioClient().dio.put( + '/vendors/${vendor.id}', + data: vendor.toJson(), + ); + if (response.statusCode == 200) { + final updatedVendor = Vendor.fromJson(response.data); + final current = state.value ?? []; + state = AsyncValue.data( + current.map((c) => c.id == updatedVendor.id ? updatedVendor : c).toList(), + ); + return updatedVendor; + } + throw Exception('Failed to update vendor'); + } catch (e) { + throw Exception('Error updating vendor: $e'); + } + } + + Future deleteVendor(int id) async { + try { + await DioClient().dio.delete('/vendors/$id'); + final current = state.value ?? []; + state = AsyncValue.data(current.where((c) => c.id != id).toList()); + } catch (e) { + throw Exception('Error deleting vendor: $e'); + } + } +} + +final vendorsProvider = AsyncNotifierProvider>(() { + return VendorsNotifier(); +}); diff --git a/kifi_agency_os_plan.md b/kifi_agency_os_plan.md new file mode 100644 index 0000000..b709c15 --- /dev/null +++ b/kifi_agency_os_plan.md @@ -0,0 +1,32 @@ +# Kifi Agency OS: Project Management Integration Plan + +## The Goal +Transform Kifi into an all-in-one "Agency Operating System" by deeply integrating project management. This guarantees massive Daily Active Usage (DAU) and locks in teams by managing both their daily workflows and their finances in a single ecosystem. + +## 1. Modular Architecture (Preserving Existing Flows) +- **The "Modules" Strategy:** Project Management must be an optional toggle in `Settings -> Modules`. +- **UI Impact:** If toggled ON, a new **"Projects"** tab appears in the main navigation. If OFF, the UI remains exactly as it is today (perfect for retail/manufacturing users). +- **Core Entity:** `Project` (linked to `Customer/Client`). + +## 2. Financially-Aware Task Boards +Unlike Jira, Kifi connects tasks directly to money. +- **Kanban View:** Standard columns (To-Do, In Progress, Review, Done). +- **Billable Tasks:** A checkbox on every task: `[x] Billable ($50/hr)`. +- **1-Click Invoicing:** When a billable task is moved to "Done", a prompt appears: *"Generate invoice for this task?"* or *"Add to next client invoice?"* +- **Budget Tracking:** Project boards display a real-time progress bar of the client's budget vs. hours logged. + +## 3. Multi-User Collaboration & Monetization +- **Team Roles:** Invite designers, developers, and clients to specific project boards. +- **Attachments & Comments:** Upload specs, designs, and have threaded conversations on tasks. +- **Monetization Engine:** The core app remains free/freemium, but inviting team members to collaborate on tasks costs **$5/user/month** (Workspace Pricing Model). + +## 4. Minimum Viable Product (MVP) Rollout +**Phase 1:** +- `projects` table (id, name, customer_id, budget, status). +- `tasks` table (id, project_id, title, description, assignee_id, status, is_billable, hourly_rate, hours_logged). +- Basic Flutter UI: A Kanban board screen and a simple task creation modal. + +**Phase 2:** +- Automated Invoice Generation from "Done" billable tasks. +- Team member invitations and role-based permissions for boards. +- Client Portal access (clients can log in to view task progress and pay invoices). diff --git a/kifi_context.md b/kifi_context.md new file mode 100644 index 0000000..26176ba --- /dev/null +++ b/kifi_context.md @@ -0,0 +1,33 @@ +# Kifi Project Context + +## Overview +Kifi is a financial and inventory management application designed to handle strict accounting principles along with inventory, vendor, and business operations. + +### Components +- **kifi-app**: Flutter mobile frontend. +- **kifi-api**: Spring Boot WebFlux backend (reactive stack). +- **Infrastructure**: PostgreSQL database (accessed via R2DBC), MinIO for object storage (images/attachments), Redis, Docker registry (`hub.technobeesolutions.in`). + +## Core Features & Architecture + +### 1. Accounting System +- **Double-Entry Principle**: Strict double-entry accounting is mandated. Every financial entry must balance. + - *Example*: Interest/bank fees on a Credit Card are recorded as a transfer: `From: CC -> To: Expense (Bank Charges/Interest)`. +- **Entities**: The core entities are `Wallet` (which represents all forms of accounts/ledgers) and `Transaction`. +- **Account Types (Natures)**: Accounts are categorized by `nature` (e.g., SAVINGS, INCOME, EXPENSE, PAYABLES, INVESTMENTS) and further specialized by `sub_nature`. +- **Payables**: Specialized support for Credit Cards, Overdrafts (OD), EMIs, and Policy Premiums. Features configurable cycle dates, credit limits, and fixed amounts for recurring dues. +- **Dashboard**: Features an `UpcomingDuesWidget` that proactively calculates and displays urgent dues based on cycle dates and negative balances. + +### 2. Inventory Management +- Supports Products, Categories, Unit of Measure (UOM), and Bills of Material (BOM). +- Product pricing can follow auto-calculated rules. +- Images are uploaded as Multipart form data, converted to Base64 in the backend, and sent to MinIO. + +### 3. Vendor & Purchase Orders (Current Focus) +- Active development on Vendor Management, Purchase Orders, and Purchase Payments. + +## Important Technical Rules & Conventions +1. **R2DBC Limitations**: Because R2DBC is fully reactive, it does not automatically fetch relations (no lazy loading like Hibernate). Transient relational fields (e.g. `@Transient List images` in `Product`) MUST be manually populated in the Service layers using `Mono.zip` or `flatMap` before returning to the controller. +2. **API Routing (kifi vs kifi-v2)**: The application is migrating to a new reactive backend. New endpoints are mapped under `/api/kifi-v2/` (e.g., `/api/kifi-v2/inventory/products`), while some legacy mobile integrations might still point to `/api/kifi/` (e.g., transactions). Pay close attention to Base URL configurations in `DioClient` vs hardcoded URLs in UI widgets. +3. **Payload Limits**: `spring.codec.max-in-memory-size` is set to `10MB` in `application.yml` to prevent `DataBufferLimitException` when handling large image uploads/downloads. +4. **Image/Attachment Loading**: The backend reads byte buffers from MinIO (Base64) and decodes them to byte arrays (`byte[]`) to serve over HTTP. Flutter uses `Image.network` (or `NetworkImage`) with Bearer tokens in headers to fetch these secured endpoints. diff --git a/kifi_handoff.md b/kifi_handoff.md new file mode 100644 index 0000000..7c7d1e8 --- /dev/null +++ b/kifi_handoff.md @@ -0,0 +1,28 @@ +# Kifi Project Handoff + +## Current Status +- Transitioning to Vendor Management and Purchase Orders functionality. The user is currently exploring `purchase_order_details_screen.dart` and related vendor screens. +- **System Note**: The backend Spring Boot server task was previously stopped due to a server restart. It must be restarted to test APIs locally (`./mvnw spring-boot:run` in `kifi-api`). + +## Recently Completed +- **Payables Account Types**: Successfully updated schema and UI to support advanced payables (`sub_nature`, `credit_limit`, `fixed_amount`, `payment_cycle`, `cycle_date`). Handled complex logic for managing recurring CC bills, EMIs, and ODs. +- **Dashboard Enhancements**: Implemented `UpcomingDuesWidget` which accurately calculates and warns about urgent upcoming dues based on negative wallet balances and recurring cycle dates. + +## Outstanding Issues & Tasks +1. **Product Image Preview Bug (High Priority)**: + - *Symptom*: Images uploaded to products are saved successfully in MinIO and DB, but the thumbnail/preview in the Flutter `product_list_screen.dart` is not rendering correctly (showing the placeholder instead). Tapping into edit mode also reportedly fails to show existing images. + - *Context*: The user noted that the *Transaction* image preview works fine. + - *Investigation Notes*: + - Transactions use `NetworkImage` hardcoded to `/api/kifi/transactions/...` (potentially hitting the old backend), while Products use `Image.network` pointing to `/api/kifi-v2/inventory/products/...` (hitting the new WebFlux backend). + - Need to verify if `ProductService` is correctly mapping and returning `imageIds` in the JSON response to the Flutter app. + - Need to update `add_product_screen.dart` to fetch and render existing images when editing a product, similar to how `add_transaction_screen.dart` handles it. +2. **Vendor / Purchase Order Module**: Active development on Vendor Purchase Orders (frontend screens currently open by user). +3. **Other Backlog**: + - Notification scheduling customization. + - Bulk Operations (multi-select/dropdown for bulk invite/delete). + - Verify Release Build (iOS code signing verification). + +## Next Steps for the Next Agent +1. **Restart Backend**: Restart the Spring Boot server (`kifi-api`) if you need to perform local API testing. +2. **Resolve Product Image Bug**: Finalize the investigation into why product images aren't displaying in `product_list_screen.dart` and `add_product_screen.dart` by comparing with the working `Transaction` image logic. +3. **Assist with Vendor Features**: Provide support on `purchase_order_details_screen.dart` and the broader vendor management module as requested by the user. diff --git a/scratch/reset_user_data.sql b/scratch/reset_user_data.sql new file mode 100644 index 0000000..569710a --- /dev/null +++ b/scratch/reset_user_data.sql @@ -0,0 +1,58 @@ +-- Cleanup script to reset user data (transactions, sales, purchases, inventory, products, partners, budgets) +-- while keeping wallets (resetting their balances and credit limits) and UOMs. +-- Usage: Execute this script in the kifi-v2 database. + +DO $$ +DECLARE + uid INT; + target_users INT[] := ARRAY[2, 3]; +BEGIN + FOREACH uid IN ARRAY target_users LOOP + RAISE NOTICE 'Resetting data for user %', uid; + + -- SALES + DELETE FROM invoice_payments WHERE invoice_id IN (SELECT id FROM invoices WHERE user_id = uid); + DELETE FROM invoice_items WHERE invoice_id IN (SELECT id FROM invoices WHERE user_id = uid); + DELETE FROM invoices WHERE user_id = uid; + + -- PURCHASES + DELETE FROM purchase_payments WHERE po_id IN (SELECT id FROM purchase_orders WHERE user_id = uid); + DELETE FROM purchase_order_items WHERE po_id IN (SELECT id FROM purchase_orders WHERE user_id = uid); + DELETE FROM purchase_orders WHERE user_id = uid; + + -- INVENTORY & STOCK + DELETE FROM inventory_movement_items WHERE movement_id IN (SELECT id FROM inventory_movements WHERE user_id = uid); + DELETE FROM inventory_movements WHERE user_id = uid; + DELETE FROM inventory_balances WHERE product_id IN (SELECT id FROM products WHERE user_id = uid); + DELETE FROM inventory_locations WHERE user_id = uid; + + -- BUDGETS + DELETE FROM budgets WHERE user_id = uid; + + -- TRANSACTIONS + DELETE FROM transaction_items WHERE transaction_id IN (SELECT id FROM transactions WHERE user_id = uid); + DELETE FROM transaction_attachments WHERE transaction_id IN (SELECT id FROM transactions WHERE user_id = uid); + DELETE FROM recurring_transactions WHERE user_id = uid; + DELETE FROM transactions WHERE user_id = uid; + + -- PRODUCTS + DELETE FROM product_bom WHERE parent_product_id IN (SELECT id FROM products WHERE user_id = uid) OR component_product_id IN (SELECT id FROM products WHERE user_id = uid); + DELETE FROM product_images WHERE product_id IN (SELECT id FROM products WHERE user_id = uid); + DELETE FROM products WHERE user_id = uid; + DELETE FROM product_categories WHERE user_id = uid; + + -- PARTNERS (Customers & Vendors) + DELETE FROM customers WHERE user_id = uid; + DELETE FROM vendors WHERE user_id = uid; + + -- WALLETS (Reset balances, keep structure) + UPDATE wallets + SET balance = 0, + credit_limit = NULL, + fixed_amount = NULL, + cycle_date = NULL, + payment_cycle = NULL + WHERE owner_id = uid; + + END LOOP; +END $$; diff --git a/scratch/rewrite.java b/scratch/rewrite.java new file mode 100644 index 0000000..cd44d1d --- /dev/null +++ b/scratch/rewrite.java @@ -0,0 +1,35 @@ + if (initialBalance != null) { + log.info("editWallet: initialBalance is not null: {}", initialBalance); + return transactionRepository.findByWalletAndDescription(walletId, "Opening Balance") + .collectList() + .flatMap(txList -> { + if (txList.isEmpty()) { + log.info("editWallet: No old opening balance tx found. Calling handleOpeningBalance."); + return walletRepository.save(w).flatMap(saved -> handleOpeningBalance(ownerId, saved, initialBalance, initialBalanceDate)); + } else { + Transaction oldTx = txList.get(0); + log.info("editWallet: Found old opening balance tx: {}", oldTx.getId()); + // Reverse old transaction impact on w in memory + if (oldTx.getFromWalletId().equals(w.getId())) { + w.setBalance(w.getBalance().add(oldTx.getAmount())); + } else if (oldTx.getToWalletId().equals(w.getId())) { + w.setBalance(w.getBalance().subtract(oldTx.getAmount())); + } + + Long otherWalletId = oldTx.getFromWalletId().equals(w.getId()) ? oldTx.getToWalletId() : oldTx.getFromWalletId(); + + return walletRepository.findById(otherWalletId) + .flatMap(otherW -> { + if (oldTx.getFromWalletId().equals(otherW.getId())) { + otherW.setBalance(otherW.getBalance().add(oldTx.getAmount())); + } else if (oldTx.getToWalletId().equals(otherW.getId())) { + otherW.setBalance(otherW.getBalance().subtract(oldTx.getAmount())); + } + return walletRepository.save(otherW); + }) + .then(transactionRepository.delete(oldTx)) + .then(walletRepository.save(w)) + .flatMap(saved -> handleOpeningBalance(ownerId, saved, initialBalance, initialBalanceDate)); + } + }); + } diff --git a/test_api.java b/test_api.java new file mode 100644 index 0000000..b91227d --- /dev/null +++ b/test_api.java @@ -0,0 +1,11 @@ +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; + +public class test_api { + public static void main(String[] args) { + // Can't easily test without JWT. Let's just create a test controller in the backend! + } +} diff --git a/test_api.sh b/test_api.sh new file mode 100755 index 0000000..2b293fd --- /dev/null +++ b/test_api.sh @@ -0,0 +1,4 @@ +#!/bin/bash +TOKEN=$(sqlite3 /Users/maddy/Projects/Kifi/kifi-api/kifi.db 'select token from some_table limit 1' 2>/dev/null) +# Let's just create a test controller endpoint that doesn't need auth, or use JWT generator if I had one. +# Wait, I don't have a valid JWT. I can just write a quick test inside WalletController! diff --git a/test_mono.class b/test_mono.class new file mode 100644 index 0000000..694ae49 Binary files /dev/null and b/test_mono.class differ diff --git a/test_mono.java b/test_mono.java new file mode 100644 index 0000000..dc38f53 --- /dev/null +++ b/test_mono.java @@ -0,0 +1,12 @@ +import reactor.core.publisher.Mono; + +public class test_mono { + public static void main(String[] args) { + Mono source = Mono.empty(); + Mono m = source + .flatMap(s -> Mono.just("from flatMap " + s)) + .switchIfEmpty(Mono.defer(() -> Mono.just("from switchIfEmpty"))); + + m.subscribe(System.out::println); + } +}