Fixed UI issue

This commit is contained in:
2026-08-24 18:38:46 +05:30
parent fc0faafa18
commit 71b2389221
58 changed files with 3521 additions and 53 deletions

BIN
.DS_Store vendored

Binary file not shown.

15
TestDeserialization.java Normal file
View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -28,13 +28,18 @@ public class WalletController {
@PostMapping
public Mono<Wallet> 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<Wallet> 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<Wallet> 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;

View File

@@ -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<ResponseEntity<Flux<PurchaseOrder>>> getPurchaseOrders(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return Mono.just(ResponseEntity.ok(purchaseOrderService.getPurchaseOrders(userId)));
}
@GetMapping("/{id}")
public Mono<ResponseEntity<PurchaseOrder>> 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<ResponseEntity<PurchaseOrder>> 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<ResponseEntity<PurchaseOrder>> 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<ResponseEntity<PurchaseOrder>> 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<ResponseEntity<PurchasePayment>> 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<ResponseEntity<List<PurchasePayment>>> getPayments(@PathVariable Long id, Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return purchaseOrderService.getPayments(userId, id)
.collectList()
.map(ResponseEntity::ok);
}
}

View File

@@ -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<ResponseEntity<Flux<Vendor>>> getVendors(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return Mono.just(ResponseEntity.ok(vendorService.getVendors(userId)));
}
@GetMapping("/{id}")
public Mono<ResponseEntity<Vendor>> getVendor(@PathVariable Long id) {
return vendorService.getVendor(id)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
@PostMapping
public Mono<ResponseEntity<Vendor>> 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<ResponseEntity<Vendor>> updateVendor(@PathVariable Long id, @RequestBody Vendor vendor) {
return vendorService.updateVendor(id, vendor)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public Mono<ResponseEntity<Void>> deleteVendor(@PathVariable Long id) {
return vendorService.deleteVendor(id)
.then(Mono.just(ResponseEntity.ok().<Void>build()));
}
}

View File

@@ -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")

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<PurchaseOrderItem> items;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -11,4 +11,7 @@ public interface TransactionRepository extends R2dbcRepository<Transaction, Long
@org.springframework.data.r2dbc.repository.Query("SELECT t.* FROM transactions t WHERE t.user_id = :userId OR t.from_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) OR t.to_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) ORDER BY t.date DESC, t.id DESC")
Flux<Transaction> findVisibleTransactionsForUser(Long userId);
Mono<Long> 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<Transaction> findByWalletAndDescription(Long walletId, String description);
}

View File

@@ -6,4 +6,5 @@ import reactor.core.publisher.Flux;
public interface WalletRepository extends ReactiveCrudRepository<Wallet, Long> {
Flux<Wallet> findByOwnerId(Long ownerId);
Flux<Wallet> findByOwnerIdAndNature(Long ownerId, String nature);
}

View File

@@ -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<PurchaseOrderItem, Long> {
Flux<PurchaseOrderItem> findByPoId(Long poId);
Mono<Void> deleteByPoId(Long poId);
}

View File

@@ -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<PurchaseOrder, Long> {
Flux<PurchaseOrder> findByUserId(Long userId);
Mono<PurchaseOrder> findByUserIdAndId(Long userId, Long id);
}

View File

@@ -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<PurchasePayment, Long> {
Flux<PurchasePayment> findByPoId(Long poId);
}

View File

@@ -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<Vendor, Long> {
Flux<Vendor> findByUserId(Long userId);
}

View File

@@ -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<Wallet> 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<Wallet> 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<Wallet> 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<Wallet> getWalletsForUser(Long userId) {
return userWalletRepository.findByUserId(userId)
.flatMap(uw -> walletRepository.findById(uw.getWalletId()));
@@ -117,7 +190,7 @@ public class WalletService {
});
}
public Mono<Wallet> 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<Wallet> 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);
});
}

View File

@@ -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());

View File

@@ -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<PurchaseOrder> getPurchaseOrders(Long userId) {
return purchaseOrderRepository.findByUserId(userId)
.sort((a, b) -> b.getIssueDate().compareTo(a.getIssueDate()));
}
public Mono<PurchaseOrder> getPurchaseOrder(Long userId, Long id) {
return purchaseOrderRepository.findByUserIdAndId(userId, id);
}
public Flux<PurchaseOrderItem> getPurchaseOrderItems(Long poId) {
return purchaseOrderItemRepository.findByPoId(poId);
}
public Flux<PurchasePayment> getPurchasePayments(Long poId) {
return purchasePaymentRepository.findByPoId(poId);
}
@Transactional
public Mono<PurchaseOrder> createPurchaseOrder(Long userId, PurchaseOrder po, List<PurchaseOrderItem> 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<PurchaseOrder> updatePurchaseOrder(Long userId, Long id, PurchaseOrder po, List<PurchaseOrderItem> 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<PurchaseOrder> markAsReceived(Long userId, Long id, List<PurchaseOrderItem> 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<PurchasePayment> 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<PurchasePayment> getPayments(Long userId, Long poId) {
return purchaseOrderRepository.findByUserIdAndId(userId, poId)
.switchIfEmpty(Mono.error(new RuntimeException("PO not found")))
.flatMapMany(po -> purchasePaymentRepository.findByPoId(poId));
}
}

View File

@@ -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<Vendor> getVendors(Long userId) {
return vendorRepository.findByUserId(userId)
.sort((a, b) -> a.getName().compareToIgnoreCase(b.getName()));
}
public Mono<Vendor> getVendor(Long id) {
return vendorRepository.findById(id);
}
public Mono<Vendor> createVendor(Long userId, Vendor vendor) {
vendor.setUserId(userId);
vendor.setCreatedAt(LocalDateTime.now());
return vendorRepository.save(vendor);
}
public Mono<Vendor> 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<Void> deleteVendor(Long id) {
return vendorRepository.deleteById(id);
}
}

View File

@@ -207,7 +207,7 @@ class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
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(

View File

@@ -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,

View File

@@ -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) ...[

View File

@@ -14,6 +14,7 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
bool _taxIncludedInPrice = false;
bool _salesEnabled = true;
bool _inventoryEnabled = true;
bool _purchaseEnabled = false;
@override
void initState() {
@@ -30,6 +31,7 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
setState(() {
_inventoryEnabled = feature.inventoryManagement;
_salesEnabled = feature.salesManagement;
_purchaseEnabled = feature.purchaseManagement;
});
}
});
@@ -68,6 +70,16 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
},
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<BusinessSettingsScreen>
final updated = featureState.copyWith(
inventoryManagement: _inventoryEnabled,
salesManagement: _salesEnabled,
purchaseManagement: _purchaseEnabled,
);
ref.read(businessFeatureProvider.notifier).updateFeatures(updated);
}

View File

@@ -312,7 +312,8 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
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<AccountsScreen> {
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<AccountsScreen> {
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<AccountsScreen> {
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<AccountsScreen> {
),
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<String>(
value: editSubNature,
@@ -758,15 +807,26 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
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,

View File

@@ -177,16 +177,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
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);

View File

@@ -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,

View File

@@ -32,6 +32,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
// Basic
String _name = '';
String _sku = '';
String _hsnCode = '';
ProductCategory? _selectedCategory;
int? _uomId;
@@ -71,6 +72,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
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<AddProductScreen> {
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<AddProductScreen> {
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),

View File

@@ -60,10 +60,22 @@ class _AdjustStockSheetState extends ConsumerState<AdjustStockSheet> {
@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<AdjustStockSheet> {
DropdownButtonFormField<String>(
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<AdjustStockSheet> {
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<AdjustStockSheet> {
TextFormField(
controller: _notesController,
decoration: InputDecoration(
decoration: inputDecoration.copyWith(
labelText: "Notes (Optional)",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
prefixIcon: const Icon(Icons.note),
),
),

View File

@@ -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;

View File

@@ -293,6 +293,16 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
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<InvoiceDetailsScreen> {
...(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)}';

View File

@@ -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,

View File

@@ -423,7 +423,8 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
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) {

View File

@@ -154,6 +154,7 @@ class WalletNotifier extends AsyncNotifier<List<Wallet>> {
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<List<Wallet>> {
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<List<Wallet>> {
await ref.read(apiRepositoryProvider).inviteUserToWallet(walletId, email);
}
Future<void> editWallet(int id, {String? name, String? nature, String? icon, String? color, String? subNature, double? creditLimit, double? fixedAmount, String? paymentCycle, int? cycleDate}) async {
Future<void> 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,

View File

@@ -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<PurchaseOrderItem> 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<String, dynamic> 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<String, dynamic> 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<PurchaseOrderItem>? 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,
);
}
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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,
);
}
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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,
);
}
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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,
);
}
}

View File

@@ -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<AddVendorSheet> createState() => _AddVendorSheetState();
}
class _AddVendorSheetState extends ConsumerState<AddVendorSheet> {
final _formKey = GlobalKey<FormState>();
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<void> _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<void> _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<int>(
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),
],
),
),
),
),
],
),
);
}
}

View File

@@ -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<PayVendorSheet> createState() => _PayVendorSheetState();
}
class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _amountCtrl;
final TextEditingController _notesCtrl = TextEditingController();
DateTime _paymentDate = DateTime.now();
String? _selectedMethod;
bool _isLoading = false;
final List<String> _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<void> _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<String>(
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),
],
),
),
);
}
}

View File

@@ -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<PurchaseOrderBuilderScreen> createState() => _PurchaseOrderBuilderScreenState();
}
class _PurchaseOrderBuilderScreenState extends ConsumerState<PurchaseOrderBuilderScreen> {
int? _selectedVendorId;
DateTime _issueDate = DateTime.now();
DateTime? _dueDate;
final TextEditingController _poNumberCtrl = TextEditingController();
final TextEditingController _notesCtrl = TextEditingController();
List<PurchaseOrderItem> _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<void> _pickInvoiceFile() async {
final picker = ImagePicker();
final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 80);
if (picked != null) {
setState(() => _invoiceFile = picked);
}
}
Future<void> _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<void> _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<int>(
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<int>(
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),
],
),
);
}
}

View File

@@ -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<PurchaseOrderDetailsScreen> createState() => _PurchaseOrderDetailsScreenState();
}
class _PurchaseOrderDetailsScreenState extends ConsumerState<PurchaseOrderDetailsScreen> {
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<List<PurchasePayment>>(
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<void> _shareAsPdf(String poNumber, PurchaseOrder po, dynamic vendor, dynamic business, List<dynamic> 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<dynamic> 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)),
),
),
),
],
],
),
),
),
);
}
}

View File

@@ -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<PurchaseOrdersListScreen> createState() => _PurchaseOrdersListScreenState();
}
class _PurchaseOrdersListScreenState extends ConsumerState<PurchaseOrdersListScreen> {
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'),
),
);
}
}

View File

@@ -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<VendorsListScreen> createState() => _VendorsListScreenState();
}
class _VendorsListScreenState extends ConsumerState<VendorsListScreen> {
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'),
),
);
}
}

View File

@@ -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<List<PurchaseOrder>> {
@override
Future<List<PurchaseOrder>> build() async {
return _fetchPurchaseOrders();
}
Future<List<PurchaseOrder>> _fetchPurchaseOrders() async {
try {
final response = await DioClient().dio.get('/purchase-orders');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => PurchaseOrder.fromJson(json)).toList();
}
return [];
} catch (e) {
throw Exception('Failed to load purchase orders: $e');
}
}
Future<PurchaseOrder> 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<PurchaseOrder> 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<PurchaseOrder> 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<PurchaseOrder> 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<PurchasePayment> 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<List<PurchasePayment>> fetchPaymentsForPurchaseOrder(int poId) async {
try {
final response = await DioClient().dio.get('/purchase-orders/$poId/payments');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => PurchasePayment.fromJson(json)).toList();
}
return [];
} catch (e) {
throw Exception('Failed to load payments: $e');
}
}
}
final purchaseOrdersProvider = AsyncNotifierProvider<PurchaseOrdersNotifier, List<PurchaseOrder>>(() {
return PurchaseOrdersNotifier();
});

View File

@@ -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<List<Vendor>> {
@override
Future<List<Vendor>> build() async {
return _fetchVendors();
}
Future<List<Vendor>> _fetchVendors() async {
try {
final response = await DioClient().dio.get('/vendors');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => Vendor.fromJson(json)).toList();
}
return [];
} catch (e) {
throw Exception('Failed to load vendors: $e');
}
}
Future<Vendor> 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<Vendor> 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<Vendor> 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<void> 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<VendorsNotifier, List<Vendor>>(() {
return VendorsNotifier();
});

32
kifi_agency_os_plan.md Normal file
View File

@@ -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).

33
kifi_context.md Normal file
View File

@@ -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<ProductImage> 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.

28
kifi_handoff.md Normal file
View File

@@ -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.

View File

@@ -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 $$;

35
scratch/rewrite.java Normal file
View File

@@ -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));
}
});
}

11
test_api.java Normal file
View File

@@ -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!
}
}

4
test_api.sh Executable file
View File

@@ -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!

BIN
test_mono.class Normal file

Binary file not shown.

12
test_mono.java Normal file
View File

@@ -0,0 +1,12 @@
import reactor.core.publisher.Mono;
public class test_mono {
public static void main(String[] args) {
Mono<String> source = Mono.empty();
Mono<String> m = source
.flatMap(s -> Mono.just("from flatMap " + s))
.switchIfEmpty(Mono.defer(() -> Mono.just("from switchIfEmpty")));
m.subscribe(System.out::println);
}
}