Fixed UI issue
This commit is contained in:
15
TestDeserialization.java
Normal file
15
TestDeserialization.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ public class SecurityConfig {
|
|||||||
.pathMatchers(HttpMethod.OPTIONS).permitAll()
|
.pathMatchers(HttpMethod.OPTIONS).permitAll()
|
||||||
.pathMatchers("/api/kifi-v2/auth/**").permitAll()
|
.pathMatchers("/api/kifi-v2/auth/**").permitAll()
|
||||||
.pathMatchers("/api/kifi-v2/health/**").permitAll()
|
.pathMatchers("/api/kifi-v2/health/**").permitAll()
|
||||||
|
.pathMatchers("/api/kifi-v2/wallets/test/**").permitAll()
|
||||||
.anyExchange().authenticated()
|
.anyExchange().authenticated()
|
||||||
)
|
)
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -28,13 +28,18 @@ public class WalletController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
public Mono<Wallet> createWallet(@RequestBody CreateWalletRequest request, Authentication authentication) {
|
public Mono<Wallet> createWallet(@RequestBody CreateWalletRequest request, Authentication authentication) {
|
||||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
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}")
|
@PutMapping("/{walletId}")
|
||||||
public Mono<Wallet> editWallet(@PathVariable Long walletId, @RequestBody CreateWalletRequest request, Authentication authentication) {
|
public Mono<Wallet> editWallet(@PathVariable Long walletId, @RequestBody CreateWalletRequest request, Authentication authentication) {
|
||||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
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}")
|
@DeleteMapping("/{walletId}")
|
||||||
@@ -95,6 +100,7 @@ public class WalletController {
|
|||||||
private String color;
|
private String color;
|
||||||
private String currency;
|
private String currency;
|
||||||
private java.math.BigDecimal initialBalance;
|
private java.math.BigDecimal initialBalance;
|
||||||
|
private java.time.LocalDate initialBalanceDate;
|
||||||
private String subNature;
|
private String subNature;
|
||||||
private java.math.BigDecimal creditLimit;
|
private java.math.BigDecimal creditLimit;
|
||||||
private java.math.BigDecimal fixedAmount;
|
private java.math.BigDecimal fixedAmount;
|
||||||
|
|||||||
78
kifi-api/src/main/java/com/kifi/api/controller/vendor/PurchaseOrderController.java
vendored
Normal file
78
kifi-api/src/main/java/com/kifi/api/controller/vendor/PurchaseOrderController.java
vendored
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
kifi-api/src/main/java/com/kifi/api/controller/vendor/VendorController.java
vendored
Normal file
51
kifi-api/src/main/java/com/kifi/api/controller/vendor/VendorController.java
vendored
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ public class BusinessFeature {
|
|||||||
private Long userId;
|
private Long userId;
|
||||||
private Boolean inventoryManagement;
|
private Boolean inventoryManagement;
|
||||||
private Boolean salesManagement;
|
private Boolean salesManagement;
|
||||||
|
private Boolean purchaseManagement;
|
||||||
private Boolean multiLocation;
|
private Boolean multiLocation;
|
||||||
|
|
||||||
@Column("bom_reduction_strategy")
|
@Column("bom_reduction_strategy")
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class Product {
|
|||||||
private String name;
|
private String name;
|
||||||
private String sku;
|
private String sku;
|
||||||
private String barcode;
|
private String barcode;
|
||||||
|
private String hsnCode;
|
||||||
private String description;
|
private String description;
|
||||||
private BigDecimal purchasePrice;
|
private BigDecimal purchasePrice;
|
||||||
private BigDecimal sellingPrice;
|
private BigDecimal sellingPrice;
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ public class InvoiceItem {
|
|||||||
@Column("product_id")
|
@Column("product_id")
|
||||||
private Long productId;
|
private Long productId;
|
||||||
|
|
||||||
|
@Column("hsn_code")
|
||||||
|
private String hsnCode;
|
||||||
|
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
private BigDecimal quantity;
|
private BigDecimal quantity;
|
||||||
|
|||||||
45
kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrder.java
vendored
Normal file
45
kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrder.java
vendored
Normal 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;
|
||||||
|
}
|
||||||
31
kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrderItem.java
vendored
Normal file
31
kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrderItem.java
vendored
Normal 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;
|
||||||
|
}
|
||||||
27
kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchasePayment.java
vendored
Normal file
27
kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchasePayment.java
vendored
Normal 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;
|
||||||
|
}
|
||||||
31
kifi-api/src/main/java/com/kifi/api/entity/vendor/Vendor.java
vendored
Normal file
31
kifi-api/src/main/java/com/kifi/api/entity/vendor/Vendor.java
vendored
Normal 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;
|
||||||
|
}
|
||||||
@@ -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")
|
@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);
|
Flux<Transaction> findVisibleTransactionsForUser(Long userId);
|
||||||
Mono<Long> countByFromWalletIdOrToWalletId(Long fromWalletId, Long toWalletId);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ import reactor.core.publisher.Flux;
|
|||||||
|
|
||||||
public interface WalletRepository extends ReactiveCrudRepository<Wallet, Long> {
|
public interface WalletRepository extends ReactiveCrudRepository<Wallet, Long> {
|
||||||
Flux<Wallet> findByOwnerId(Long ownerId);
|
Flux<Wallet> findByOwnerId(Long ownerId);
|
||||||
|
Flux<Wallet> findByOwnerIdAndNature(Long ownerId, String nature);
|
||||||
}
|
}
|
||||||
|
|||||||
11
kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchaseOrderItemRepository.java
vendored
Normal file
11
kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchaseOrderItemRepository.java
vendored
Normal 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);
|
||||||
|
}
|
||||||
11
kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchaseOrderRepository.java
vendored
Normal file
11
kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchaseOrderRepository.java
vendored
Normal 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);
|
||||||
|
}
|
||||||
9
kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchasePaymentRepository.java
vendored
Normal file
9
kifi-api/src/main/java/com/kifi/api/repository/vendor/PurchasePaymentRepository.java
vendored
Normal 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);
|
||||||
|
}
|
||||||
9
kifi-api/src/main/java/com/kifi/api/repository/vendor/VendorRepository.java
vendored
Normal file
9
kifi-api/src/main/java/com/kifi/api/repository/vendor/VendorRepository.java
vendored
Normal 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);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import com.kifi.api.repository.WalletRepository;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ import java.time.LocalDateTime;
|
|||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class WalletService {
|
public class WalletService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(WalletService.class);
|
||||||
private final WalletRepository walletRepository;
|
private final WalletRepository walletRepository;
|
||||||
private final UserWalletRepository userWalletRepository;
|
private final UserWalletRepository userWalletRepository;
|
||||||
private final WalletInvitationRepository walletInvitationRepository;
|
private final WalletInvitationRepository walletInvitationRepository;
|
||||||
@@ -29,12 +32,12 @@ public class WalletService {
|
|||||||
private final EmailService emailService;
|
private final EmailService emailService;
|
||||||
private final com.kifi.api.repository.UserRepository userRepository;
|
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 wallet = new Wallet();
|
||||||
wallet.setOwnerId(ownerId);
|
wallet.setOwnerId(ownerId);
|
||||||
wallet.setName(name);
|
wallet.setName(name);
|
||||||
wallet.setNature(nature != null ? nature : "CASH");
|
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.setCurrency(currency != null ? currency : "INR");
|
||||||
wallet.setIcon(icon);
|
wallet.setIcon(icon);
|
||||||
wallet.setColor(color);
|
wallet.setColor(color);
|
||||||
@@ -53,6 +56,76 @@ public class WalletService {
|
|||||||
uw.setRole("OWNER");
|
uw.setRole("OWNER");
|
||||||
uw.setJoinedAt(LocalDateTime.now());
|
uw.setJoinedAt(LocalDateTime.now());
|
||||||
return userWalletRepository.save(uw).thenReturn(savedWallet);
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
return walletRepository.findById(walletId)
|
||||||
.filter(w -> w.getOwnerId().equals(ownerId))
|
.filter(w -> w.getOwnerId().equals(ownerId))
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("Wallet not found or you are not the owner")))
|
.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 (fixedAmount != null) w.setFixedAmount(fixedAmount);
|
||||||
if (paymentCycle != null) w.setPaymentCycle(paymentCycle);
|
if (paymentCycle != null) w.setPaymentCycle(paymentCycle);
|
||||||
if (cycleDate != null) w.setCycleDate(cycleDate);
|
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);
|
return walletRepository.save(w);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class BusinessService {
|
|||||||
.userId(userId)
|
.userId(userId)
|
||||||
.inventoryManagement(false)
|
.inventoryManagement(false)
|
||||||
.salesManagement(false)
|
.salesManagement(false)
|
||||||
|
.purchaseManagement(false)
|
||||||
.multiLocation(false)
|
.multiLocation(false)
|
||||||
.bomReductionStrategy("COMPONENTS_ONLY")
|
.bomReductionStrategy("COMPONENTS_ONLY")
|
||||||
.stockDeductionOnInvoice(true)
|
.stockDeductionOnInvoice(true)
|
||||||
@@ -38,6 +39,7 @@ public class BusinessService {
|
|||||||
.flatMap(existing -> {
|
.flatMap(existing -> {
|
||||||
if (feature.getInventoryManagement() != null) existing.setInventoryManagement(feature.getInventoryManagement());
|
if (feature.getInventoryManagement() != null) existing.setInventoryManagement(feature.getInventoryManagement());
|
||||||
if (feature.getSalesManagement() != null) existing.setSalesManagement(feature.getSalesManagement());
|
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.getMultiLocation() != null) existing.setMultiLocation(feature.getMultiLocation());
|
||||||
if (feature.getBomReductionStrategy() != null) existing.setBomReductionStrategy(feature.getBomReductionStrategy());
|
if (feature.getBomReductionStrategy() != null) existing.setBomReductionStrategy(feature.getBomReductionStrategy());
|
||||||
if (feature.getStockDeductionOnInvoice() != null) existing.setStockDeductionOnInvoice(feature.getStockDeductionOnInvoice());
|
if (feature.getStockDeductionOnInvoice() != null) existing.setStockDeductionOnInvoice(feature.getStockDeductionOnInvoice());
|
||||||
|
|||||||
161
kifi-api/src/main/java/com/kifi/api/service/vendor/PurchaseOrderService.java
vendored
Normal file
161
kifi-api/src/main/java/com/kifi/api/service/vendor/PurchaseOrderService.java
vendored
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
52
kifi-api/src/main/java/com/kifi/api/service/vendor/VendorService.java
vendored
Normal file
52
kifi-api/src/main/java/com/kifi/api/service/vendor/VendorService.java
vendored
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -207,7 +207,7 @@ class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
|
|||||||
hintText: widget.hintText,
|
hintText: widget.hintText,
|
||||||
suffixIcon: const Icon(Icons.arrow_drop_down, color: Colors.grey),
|
suffixIcon: const Icon(Icons.arrow_drop_down, color: Colors.grey),
|
||||||
filled: Theme.of(context).inputDecorationTheme.filled ?? true,
|
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),
|
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),
|
enabledBorder: Theme.of(context).inputDecorationTheme.enabledBorder ?? OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
focusedBorder: Theme.of(context).inputDecorationTheme.focusedBorder ?? OutlineInputBorder(
|
focusedBorder: Theme.of(context).inputDecorationTheme.focusedBorder ?? OutlineInputBorder(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ class BusinessFeature {
|
|||||||
final int? userId;
|
final int? userId;
|
||||||
final bool inventoryManagement;
|
final bool inventoryManagement;
|
||||||
final bool salesManagement;
|
final bool salesManagement;
|
||||||
|
final bool purchaseManagement;
|
||||||
final bool multiLocation;
|
final bool multiLocation;
|
||||||
final String bomReductionStrategy;
|
final String bomReductionStrategy;
|
||||||
final bool stockDeductionOnInvoice;
|
final bool stockDeductionOnInvoice;
|
||||||
@@ -14,6 +15,7 @@ class BusinessFeature {
|
|||||||
this.userId,
|
this.userId,
|
||||||
this.inventoryManagement = false,
|
this.inventoryManagement = false,
|
||||||
this.salesManagement = false,
|
this.salesManagement = false,
|
||||||
|
this.purchaseManagement = false,
|
||||||
this.multiLocation = false,
|
this.multiLocation = false,
|
||||||
this.bomReductionStrategy = 'COMPONENTS_ONLY',
|
this.bomReductionStrategy = 'COMPONENTS_ONLY',
|
||||||
this.stockDeductionOnInvoice = true,
|
this.stockDeductionOnInvoice = true,
|
||||||
@@ -27,6 +29,7 @@ class BusinessFeature {
|
|||||||
userId: json['userId'],
|
userId: json['userId'],
|
||||||
inventoryManagement: json['inventoryManagement'] ?? false,
|
inventoryManagement: json['inventoryManagement'] ?? false,
|
||||||
salesManagement: json['salesManagement'] ?? false,
|
salesManagement: json['salesManagement'] ?? false,
|
||||||
|
purchaseManagement: json['purchaseManagement'] ?? false,
|
||||||
multiLocation: json['multiLocation'] ?? false,
|
multiLocation: json['multiLocation'] ?? false,
|
||||||
bomReductionStrategy: json['bomReductionStrategy'] ?? 'COMPONENTS_ONLY',
|
bomReductionStrategy: json['bomReductionStrategy'] ?? 'COMPONENTS_ONLY',
|
||||||
stockDeductionOnInvoice: json['stockDeductionOnInvoice'] ?? true,
|
stockDeductionOnInvoice: json['stockDeductionOnInvoice'] ?? true,
|
||||||
@@ -41,6 +44,7 @@ class BusinessFeature {
|
|||||||
'userId': userId,
|
'userId': userId,
|
||||||
'inventoryManagement': inventoryManagement,
|
'inventoryManagement': inventoryManagement,
|
||||||
'salesManagement': salesManagement,
|
'salesManagement': salesManagement,
|
||||||
|
'purchaseManagement': purchaseManagement,
|
||||||
'multiLocation': multiLocation,
|
'multiLocation': multiLocation,
|
||||||
'bomReductionStrategy': bomReductionStrategy,
|
'bomReductionStrategy': bomReductionStrategy,
|
||||||
'stockDeductionOnInvoice': stockDeductionOnInvoice,
|
'stockDeductionOnInvoice': stockDeductionOnInvoice,
|
||||||
@@ -49,18 +53,22 @@ class BusinessFeature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
BusinessFeature copyWith({
|
BusinessFeature copyWith({
|
||||||
|
int? id,
|
||||||
|
int? userId,
|
||||||
bool? inventoryManagement,
|
bool? inventoryManagement,
|
||||||
bool? salesManagement,
|
bool? salesManagement,
|
||||||
|
bool? purchaseManagement,
|
||||||
bool? multiLocation,
|
bool? multiLocation,
|
||||||
String? bomReductionStrategy,
|
String? bomReductionStrategy,
|
||||||
bool? stockDeductionOnInvoice,
|
bool? stockDeductionOnInvoice,
|
||||||
String? barcodeSource,
|
String? barcodeSource,
|
||||||
}) {
|
}) {
|
||||||
return BusinessFeature(
|
return BusinessFeature(
|
||||||
id: id,
|
id: id ?? this.id,
|
||||||
userId: userId,
|
userId: userId ?? this.userId,
|
||||||
inventoryManagement: inventoryManagement ?? this.inventoryManagement,
|
inventoryManagement: inventoryManagement ?? this.inventoryManagement,
|
||||||
salesManagement: salesManagement ?? this.salesManagement,
|
salesManagement: salesManagement ?? this.salesManagement,
|
||||||
|
purchaseManagement: purchaseManagement ?? this.purchaseManagement,
|
||||||
multiLocation: multiLocation ?? this.multiLocation,
|
multiLocation: multiLocation ?? this.multiLocation,
|
||||||
bomReductionStrategy: bomReductionStrategy ?? this.bomReductionStrategy,
|
bomReductionStrategy: bomReductionStrategy ?? this.bomReductionStrategy,
|
||||||
stockDeductionOnInvoice: stockDeductionOnInvoice ?? this.stockDeductionOnInvoice,
|
stockDeductionOnInvoice: stockDeductionOnInvoice ?? this.stockDeductionOnInvoice,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import '../../../sales/presentation/invoices_list_screen.dart';
|
|||||||
import '../../providers/business_provider.dart';
|
import '../../providers/business_provider.dart';
|
||||||
import '../widgets/business_profile_form_sheet.dart';
|
import '../widgets/business_profile_form_sheet.dart';
|
||||||
import '../../../inventory/providers/products_provider.dart';
|
import '../../../inventory/providers/products_provider.dart';
|
||||||
|
import '../../../vendor/presentation/vendors_list_screen.dart';
|
||||||
|
import '../../../vendor/presentation/purchase_orders_list_screen.dart';
|
||||||
|
|
||||||
class BusinessHubScreen extends ConsumerWidget {
|
class BusinessHubScreen extends ConsumerWidget {
|
||||||
const BusinessHubScreen({super.key});
|
const BusinessHubScreen({super.key});
|
||||||
@@ -29,6 +31,7 @@ class BusinessHubScreen extends ConsumerWidget {
|
|||||||
final featureState = ref.watch(businessFeatureProvider).value;
|
final featureState = ref.watch(businessFeatureProvider).value;
|
||||||
final bool showInventory = featureState?.inventoryManagement ?? false;
|
final bool showInventory = featureState?.inventoryManagement ?? false;
|
||||||
final bool showSales = featureState?.salesManagement ?? false;
|
final bool showSales = featureState?.salesManagement ?? false;
|
||||||
|
final bool showPurchase = featureState?.purchaseManagement ?? false;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
@@ -122,6 +125,24 @@ class BusinessHubScreen extends ConsumerWidget {
|
|||||||
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomersListScreen())),
|
() => 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) ...[
|
if (showInventory) ...[
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
|
|||||||
bool _taxIncludedInPrice = false;
|
bool _taxIncludedInPrice = false;
|
||||||
bool _salesEnabled = true;
|
bool _salesEnabled = true;
|
||||||
bool _inventoryEnabled = true;
|
bool _inventoryEnabled = true;
|
||||||
|
bool _purchaseEnabled = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -30,6 +31,7 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_inventoryEnabled = feature.inventoryManagement;
|
_inventoryEnabled = feature.inventoryManagement;
|
||||||
_salesEnabled = feature.salesManagement;
|
_salesEnabled = feature.salesManagement;
|
||||||
|
_purchaseEnabled = feature.purchaseManagement;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -68,6 +70,16 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
|
|||||||
},
|
},
|
||||||
secondary: const Icon(LucideIcons.shoppingCart),
|
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 SizedBox(height: 32),
|
||||||
const Text('Inventory Strategy', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
|
const Text('Inventory Strategy', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -153,6 +165,7 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
|
|||||||
final updated = featureState.copyWith(
|
final updated = featureState.copyWith(
|
||||||
inventoryManagement: _inventoryEnabled,
|
inventoryManagement: _inventoryEnabled,
|
||||||
salesManagement: _salesEnabled,
|
salesManagement: _salesEnabled,
|
||||||
|
purchaseManagement: _purchaseEnabled,
|
||||||
);
|
);
|
||||||
ref.read(businessFeatureProvider.notifier).updateFeatures(updated);
|
ref.read(businessFeatureProvider.notifier).updateFeatures(updated);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,7 +312,8 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
final newWallet = await ref.read(walletProvider.notifier).createWallet(
|
final newWallet = await ref.read(walletProvider.notifier).createWallet(
|
||||||
name: ctrl.text,
|
name: ctrl.text,
|
||||||
nature: selectedNature,
|
nature: selectedNature,
|
||||||
initialBalance: 0.0,
|
initialBalance: amt,
|
||||||
|
initialBalanceDate: openingDate,
|
||||||
subNature: selectedNature == 'PAYABLES' ? selectedSubNature : null,
|
subNature: selectedNature == 'PAYABLES' ? selectedSubNature : null,
|
||||||
creditLimit: cl,
|
creditLimit: cl,
|
||||||
fixedAmount: fa,
|
fixedAmount: fa,
|
||||||
@@ -320,19 +321,6 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
cycleDate: cd,
|
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);
|
if (context.mounted) Navigator.pop(ctx);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -522,6 +510,7 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
error: (e, st) => Center(child: Text('Error: $e')),
|
error: (e, st) => Center(child: Text('Error: $e')),
|
||||||
data: (allWallets) {
|
data: (allWallets) {
|
||||||
final wallets = allWallets.where((w) {
|
final wallets = allWallets.where((w) {
|
||||||
|
if (w.nature == 'EQUITY') return false;
|
||||||
final matchSearch = _searchQuery.isEmpty ||
|
final matchSearch = _searchQuery.isEmpty ||
|
||||||
w.name.toLowerCase().contains(_searchQuery.toLowerCase());
|
w.name.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||||
final matchNature = _filterNature == null || w.nature == _filterNature;
|
final matchNature = _filterNature == null || w.nature == _filterNature;
|
||||||
@@ -601,6 +590,14 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
final editCreditLimitCtrl = TextEditingController(text: w.creditLimit?.toString() ?? '');
|
final editCreditLimitCtrl = TextEditingController(text: w.creditLimit?.toString() ?? '');
|
||||||
final editFixedAmountCtrl = TextEditingController(text: w.fixedAmount?.toString() ?? '');
|
final editFixedAmountCtrl = TextEditingController(text: w.fixedAmount?.toString() ?? '');
|
||||||
final editCycleDateCtrl = TextEditingController(text: w.cycleDate?.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;
|
String? editPaymentCycle = w.paymentCycle;
|
||||||
|
|
||||||
final natures = ['CASH', 'SAVINGS', 'EXPENSE', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'RECEIVABLES', 'INCOME'];
|
final natures = ['CASH', 'SAVINGS', 'EXPENSE', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'RECEIVABLES', 'INCOME'];
|
||||||
@@ -663,6 +660,58 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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') ...[
|
if (editNature == 'PAYABLES') ...[
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
value: editSubNature,
|
value: editSubNature,
|
||||||
@@ -758,15 +807,26 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (editCtrl.text.isNotEmpty) {
|
if (editCtrl.text.isNotEmpty) {
|
||||||
final double? cl = double.tryParse(editCreditLimitCtrl.text);
|
final double? cl = double.tryParse(editCreditLimitCtrl.text.replaceAll(RegExp(r'[^0-9.]'), ''));
|
||||||
final double? fa = double.tryParse(editFixedAmountCtrl.text);
|
final double? fa = double.tryParse(editFixedAmountCtrl.text.replaceAll(RegExp(r'[^0-9.]'), ''));
|
||||||
final int? cd = int.tryParse(editCycleDateCtrl.text);
|
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 {
|
try {
|
||||||
await ref.read(walletProvider.notifier).editWallet(
|
await ref.read(walletProvider.notifier).editWallet(
|
||||||
w.id,
|
w.id,
|
||||||
name: editCtrl.text.trim(),
|
name: editCtrl.text.trim(),
|
||||||
nature: editNature,
|
nature: editNature,
|
||||||
|
initialBalance: ib,
|
||||||
|
initialBalanceDate: ib != null ? editOpeningDate : null,
|
||||||
subNature: editNature == 'PAYABLES' ? editSubNature : null,
|
subNature: editNature == 'PAYABLES' ? editSubNature : null,
|
||||||
creditLimit: cl,
|
creditLimit: cl,
|
||||||
fixedAmount: fa,
|
fixedAmount: fa,
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
actions: [
|
actions: [
|
||||||
|
if (isBusinessMode)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(LucideIcons.trendingUp),
|
icon: const Icon(LucideIcons.trendingUp),
|
||||||
tooltip: 'Daily Commodity Rates',
|
tooltip: 'Daily Commodity Rates',
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ class Product {
|
|||||||
final int? userId;
|
final int? userId;
|
||||||
final int? categoryId;
|
final int? categoryId;
|
||||||
final int? uomId;
|
final int? uomId;
|
||||||
|
final String? hsnCode;
|
||||||
|
|
||||||
final String name;
|
final String name;
|
||||||
final String? sku;
|
final String? sku;
|
||||||
final String? barcode;
|
final String? barcode;
|
||||||
@@ -33,6 +35,7 @@ class Product {
|
|||||||
this.categoryId,
|
this.categoryId,
|
||||||
this.uomId,
|
this.uomId,
|
||||||
required this.name,
|
required this.name,
|
||||||
|
this.hsnCode,
|
||||||
this.sku,
|
this.sku,
|
||||||
this.barcode,
|
this.barcode,
|
||||||
this.description,
|
this.description,
|
||||||
@@ -64,6 +67,7 @@ class Product {
|
|||||||
categoryId: json['categoryId'] ?? json['category_id'],
|
categoryId: json['categoryId'] ?? json['category_id'],
|
||||||
uomId: json['uomId'] ?? json['uom_id'],
|
uomId: json['uomId'] ?? json['uom_id'],
|
||||||
name: json['name'],
|
name: json['name'],
|
||||||
|
hsnCode: json['hsnCode'] ?? json['hsn_code'],
|
||||||
sku: json['sku'],
|
sku: json['sku'],
|
||||||
barcode: json['barcode'],
|
barcode: json['barcode'],
|
||||||
description: json['description'],
|
description: json['description'],
|
||||||
@@ -98,6 +102,7 @@ class Product {
|
|||||||
'categoryId': categoryId,
|
'categoryId': categoryId,
|
||||||
'uomId': uomId,
|
'uomId': uomId,
|
||||||
'name': name,
|
'name': name,
|
||||||
|
'hsnCode': hsnCode,
|
||||||
'sku': sku,
|
'sku': sku,
|
||||||
'barcode': barcode,
|
'barcode': barcode,
|
||||||
'description': description,
|
'description': description,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
// Basic
|
// Basic
|
||||||
String _name = '';
|
String _name = '';
|
||||||
String _sku = '';
|
String _sku = '';
|
||||||
|
String _hsnCode = '';
|
||||||
ProductCategory? _selectedCategory;
|
ProductCategory? _selectedCategory;
|
||||||
int? _uomId;
|
int? _uomId;
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
final p = widget.product!;
|
final p = widget.product!;
|
||||||
_name = p.name;
|
_name = p.name;
|
||||||
_sku = p.sku ?? '';
|
_sku = p.sku ?? '';
|
||||||
|
_hsnCode = p.hsnCode ?? '';
|
||||||
_uomId = p.uomId;
|
_uomId = p.uomId;
|
||||||
_color = p.color ?? '';
|
_color = p.color ?? '';
|
||||||
_size = p.size ?? '';
|
_size = p.size ?? '';
|
||||||
@@ -269,6 +271,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
final product = Product(
|
final product = Product(
|
||||||
id: widget.product?.id,
|
id: widget.product?.id,
|
||||||
name: _name,
|
name: _name,
|
||||||
|
hsnCode: _hsnCode.isNotEmpty ? _hsnCode : null,
|
||||||
sku: _sku.isNotEmpty ? _sku : null,
|
sku: _sku.isNotEmpty ? _sku : null,
|
||||||
categoryId: _selectedCategory?.id,
|
categoryId: _selectedCategory?.id,
|
||||||
uomId: _uomId,
|
uomId: _uomId,
|
||||||
@@ -701,13 +704,27 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
],
|
],
|
||||||
|
|
||||||
_buildPremiumTextField(
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildPremiumTextField(
|
||||||
|
label: 'HSN Code',
|
||||||
|
initialValue: _hsnCode,
|
||||||
|
onChanged: (val) => _hsnCode = val,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: _buildPremiumTextField(
|
||||||
label: 'GST Rate (%)',
|
label: 'GST Rate (%)',
|
||||||
initialValue: _gstRate == 0 ? '' : _gstRate.toString(),
|
initialValue: _gstRate == 0 ? '' : _gstRate.toString(),
|
||||||
suffixText: '%',
|
suffixText: '%',
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
onChanged: (val) => _gstRate = double.tryParse(val) ?? 0,
|
onChanged: (val) => _gstRate = double.tryParse(val) ?? 0,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
Container(
|
Container(
|
||||||
|
|||||||
@@ -60,10 +60,22 @@ class _AdjustStockSheetState extends ConsumerState<AdjustStockSheet> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||||
@@ -86,10 +98,7 @@ class _AdjustStockSheetState extends ConsumerState<AdjustStockSheet> {
|
|||||||
|
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
value: _selectedType,
|
value: _selectedType,
|
||||||
decoration: InputDecoration(
|
decoration: inputDecoration.copyWith(labelText: "Movement Type"),
|
||||||
labelText: "Movement Type",
|
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
),
|
|
||||||
items: _types.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
|
items: _types.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
if (val != null) setState(() => _selectedType = val);
|
if (val != null) setState(() => _selectedType = val);
|
||||||
@@ -100,9 +109,8 @@ class _AdjustStockSheetState extends ConsumerState<AdjustStockSheet> {
|
|||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _qtyController,
|
controller: _qtyController,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
decoration: InputDecoration(
|
decoration: inputDecoration.copyWith(
|
||||||
labelText: "Quantity",
|
labelText: "Quantity",
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
prefixIcon: const Icon(Icons.inventory_2),
|
prefixIcon: const Icon(Icons.inventory_2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -110,9 +118,8 @@ class _AdjustStockSheetState extends ConsumerState<AdjustStockSheet> {
|
|||||||
|
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _notesController,
|
controller: _notesController,
|
||||||
decoration: InputDecoration(
|
decoration: inputDecoration.copyWith(
|
||||||
labelText: "Notes (Optional)",
|
labelText: "Notes (Optional)",
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
prefixIcon: const Icon(Icons.note),
|
prefixIcon: const Icon(Icons.note),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ class InvoiceItem {
|
|||||||
final int? invoiceId;
|
final int? invoiceId;
|
||||||
final int? productId;
|
final int? productId;
|
||||||
final String? sku;
|
final String? sku;
|
||||||
|
final String? hsnCode;
|
||||||
|
|
||||||
final String? description;
|
final String? description;
|
||||||
final double quantity;
|
final double quantity;
|
||||||
final double unitPrice;
|
final double unitPrice;
|
||||||
@@ -16,6 +18,7 @@ class InvoiceItem {
|
|||||||
this.id,
|
this.id,
|
||||||
this.invoiceId,
|
this.invoiceId,
|
||||||
this.productId,
|
this.productId,
|
||||||
|
this.hsnCode,
|
||||||
this.sku,
|
this.sku,
|
||||||
this.description,
|
this.description,
|
||||||
required this.quantity,
|
required this.quantity,
|
||||||
@@ -31,6 +34,7 @@ class InvoiceItem {
|
|||||||
int? id,
|
int? id,
|
||||||
int? invoiceId,
|
int? invoiceId,
|
||||||
int? productId,
|
int? productId,
|
||||||
|
String? hsnCode,
|
||||||
String? sku,
|
String? sku,
|
||||||
String? description,
|
String? description,
|
||||||
double? quantity,
|
double? quantity,
|
||||||
@@ -45,6 +49,7 @@ class InvoiceItem {
|
|||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
invoiceId: invoiceId ?? this.invoiceId,
|
invoiceId: invoiceId ?? this.invoiceId,
|
||||||
productId: productId ?? this.productId,
|
productId: productId ?? this.productId,
|
||||||
|
hsnCode: hsnCode ?? this.hsnCode,
|
||||||
sku: sku ?? this.sku,
|
sku: sku ?? this.sku,
|
||||||
description: description ?? this.description,
|
description: description ?? this.description,
|
||||||
quantity: quantity ?? this.quantity,
|
quantity: quantity ?? this.quantity,
|
||||||
@@ -62,6 +67,7 @@ class InvoiceItem {
|
|||||||
id: json['id'],
|
id: json['id'],
|
||||||
invoiceId: json['invoiceId'],
|
invoiceId: json['invoiceId'],
|
||||||
productId: json['productId'],
|
productId: json['productId'],
|
||||||
|
hsnCode: json['hsnCode'] ?? json['hsn_code'],
|
||||||
sku: json['sku'],
|
sku: json['sku'],
|
||||||
description: json['description'],
|
description: json['description'],
|
||||||
quantity: json['quantity'].toDouble(),
|
quantity: json['quantity'].toDouble(),
|
||||||
@@ -79,6 +85,7 @@ class InvoiceItem {
|
|||||||
if (id != null) data['id'] = id;
|
if (id != null) data['id'] = id;
|
||||||
if (invoiceId != null) data['invoiceId'] = invoiceId;
|
if (invoiceId != null) data['invoiceId'] = invoiceId;
|
||||||
if (productId != null) data['productId'] = productId;
|
if (productId != null) data['productId'] = productId;
|
||||||
|
if (hsnCode != null) data['hsnCode'] = hsnCode;
|
||||||
if (sku != null) data['sku'] = sku;
|
if (sku != null) data['sku'] = sku;
|
||||||
if (description != null) data['description'] = description;
|
if (description != null) data['description'] = description;
|
||||||
data['quantity'] = quantity;
|
data['quantity'] = quantity;
|
||||||
|
|||||||
@@ -293,6 +293,16 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
|||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Text('SKU: $skuToDisplay', style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600)),
|
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),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
@@ -654,8 +664,10 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
|||||||
...(latestInvoice.items ?? []).map((item) {
|
...(latestInvoice.items ?? []).map((item) {
|
||||||
final product = item.productId != null ? ref.read(productsProvider).value?.where((p) => p.id == item.productId).firstOrNull : null;
|
final product = item.productId != null ? ref.read(productsProvider).value?.where((p) => p.id == item.productId).firstOrNull : null;
|
||||||
final skuToDisplay = item.sku ?? product?.sku;
|
final skuToDisplay = item.sku ?? product?.sku;
|
||||||
|
final hsnToDisplay = item.hsnCode ?? product?.hsnCode;
|
||||||
String itemDesc = item.description ?? 'Item';
|
String itemDesc = item.description ?? 'Item';
|
||||||
if (skuToDisplay != null && skuToDisplay.isNotEmpty) itemDesc += '\nSKU: $skuToDisplay';
|
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.makingCharge > 0) itemDesc += '\n+ Making Charges: ${formatCurrency.format(item.makingCharge)}';
|
||||||
if (item.otherCharges > 0) itemDesc += '\n+ Other Charges: ${formatCurrency.format(item.otherCharges)}';
|
if (item.otherCharges > 0) itemDesc += '\n+ Other Charges: ${formatCurrency.format(item.otherCharges)}';
|
||||||
if (item.discount > 0) itemDesc += '\n- Discount: ${formatCurrency.format(item.discount)}';
|
if (item.discount > 0) itemDesc += '\n- Discount: ${formatCurrency.format(item.discount)}';
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ class ApiRepository {
|
|||||||
required String name,
|
required String name,
|
||||||
String nature = 'CASH',
|
String nature = 'CASH',
|
||||||
double initialBalance = 0.0,
|
double initialBalance = 0.0,
|
||||||
|
String? initialBalanceDate,
|
||||||
String currency = 'INR',
|
String currency = 'INR',
|
||||||
String? icon,
|
String? icon,
|
||||||
String? color,
|
String? color,
|
||||||
@@ -160,6 +161,7 @@ class ApiRepository {
|
|||||||
'name': name,
|
'name': name,
|
||||||
'nature': nature,
|
'nature': nature,
|
||||||
'initialBalance': initialBalance,
|
'initialBalance': initialBalance,
|
||||||
|
if (initialBalanceDate != null) 'initialBalanceDate': initialBalanceDate,
|
||||||
'currency': currency,
|
'currency': currency,
|
||||||
'icon': icon,
|
'icon': icon,
|
||||||
'color': color,
|
'color': color,
|
||||||
@@ -177,6 +179,8 @@ class ApiRepository {
|
|||||||
String? nature,
|
String? nature,
|
||||||
String? icon,
|
String? icon,
|
||||||
String? color,
|
String? color,
|
||||||
|
double? initialBalance,
|
||||||
|
String? initialBalanceDate,
|
||||||
String? subNature,
|
String? subNature,
|
||||||
double? creditLimit,
|
double? creditLimit,
|
||||||
double? fixedAmount,
|
double? fixedAmount,
|
||||||
@@ -188,6 +192,8 @@ class ApiRepository {
|
|||||||
if (nature != null) 'nature': nature,
|
if (nature != null) 'nature': nature,
|
||||||
if (icon != null) 'icon': icon,
|
if (icon != null) 'icon': icon,
|
||||||
if (color != null) 'color': color,
|
if (color != null) 'color': color,
|
||||||
|
if (initialBalance != null) 'initialBalance': initialBalance,
|
||||||
|
if (initialBalanceDate != null) 'initialBalanceDate': initialBalanceDate,
|
||||||
if (subNature != null) 'subNature': subNature,
|
if (subNature != null) 'subNature': subNature,
|
||||||
if (creditLimit != null) 'creditLimit': creditLimit,
|
if (creditLimit != null) 'creditLimit': creditLimit,
|
||||||
if (fixedAmount != null) 'fixedAmount': fixedAmount,
|
if (fixedAmount != null) 'fixedAmount': fixedAmount,
|
||||||
|
|||||||
@@ -423,7 +423,8 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
|
|||||||
child: walletsState.when(
|
child: walletsState.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, st) => Center(child: Text('Error: $e')),
|
error: (e, st) => Center(child: Text('Error: $e')),
|
||||||
data: (wallets) {
|
data: (walletsData) {
|
||||||
|
final wallets = walletsData.where((w) => w.nature != 'EQUITY').toList();
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: wallets.length,
|
itemCount: wallets.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class WalletNotifier extends AsyncNotifier<List<Wallet>> {
|
|||||||
required String name,
|
required String name,
|
||||||
String nature = 'CASH',
|
String nature = 'CASH',
|
||||||
double initialBalance = 0.0,
|
double initialBalance = 0.0,
|
||||||
|
DateTime? initialBalanceDate,
|
||||||
String currency = 'INR',
|
String currency = 'INR',
|
||||||
String? icon,
|
String? icon,
|
||||||
String? color,
|
String? color,
|
||||||
@@ -167,6 +168,7 @@ class WalletNotifier extends AsyncNotifier<List<Wallet>> {
|
|||||||
name: name,
|
name: name,
|
||||||
nature: nature,
|
nature: nature,
|
||||||
initialBalance: initialBalance,
|
initialBalance: initialBalance,
|
||||||
|
initialBalanceDate: initialBalanceDate != null ? "${initialBalanceDate.year}-${initialBalanceDate.month.toString().padLeft(2, '0')}-${initialBalanceDate.day.toString().padLeft(2, '0')}" : null,
|
||||||
currency: currency,
|
currency: currency,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
color: color,
|
color: color,
|
||||||
@@ -188,13 +190,15 @@ class WalletNotifier extends AsyncNotifier<List<Wallet>> {
|
|||||||
await ref.read(apiRepositoryProvider).inviteUserToWallet(walletId, email);
|
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(
|
final updated = await ref.read(apiRepositoryProvider).editWallet(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
nature: nature,
|
nature: nature,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
color: color,
|
color: color,
|
||||||
|
initialBalance: initialBalance,
|
||||||
|
initialBalanceDate: initialBalanceDate != null ? "${initialBalanceDate.year}-${initialBalanceDate.month.toString().padLeft(2, '0')}-${initialBalanceDate.day.toString().padLeft(2, '0')}" : null,
|
||||||
subNature: subNature,
|
subNature: subNature,
|
||||||
creditLimit: creditLimit,
|
creditLimit: creditLimit,
|
||||||
fixedAmount: fixedAmount,
|
fixedAmount: fixedAmount,
|
||||||
|
|||||||
139
kifi-app/lib/features/vendor/domain/purchase_order.dart
vendored
Normal file
139
kifi-app/lib/features/vendor/domain/purchase_order.dart
vendored
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
kifi-app/lib/features/vendor/domain/purchase_order_item.dart
vendored
Normal file
93
kifi-app/lib/features/vendor/domain/purchase_order_item.dart
vendored
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
kifi-app/lib/features/vendor/domain/purchase_payment.dart
vendored
Normal file
62
kifi-app/lib/features/vendor/domain/purchase_payment.dart
vendored
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
kifi-app/lib/features/vendor/domain/vendor.dart
vendored
Normal file
91
kifi-app/lib/features/vendor/domain/vendor.dart
vendored
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
322
kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart
vendored
Normal file
322
kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart
vendored
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
159
kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart
vendored
Normal file
159
kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart
vendored
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
456
kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart
vendored
Normal file
456
kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart
vendored
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
579
kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart
vendored
Normal file
579
kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart
vendored
Normal 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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
210
kifi-app/lib/features/vendor/presentation/purchase_orders_list_screen.dart
vendored
Normal file
210
kifi-app/lib/features/vendor/presentation/purchase_orders_list_screen.dart
vendored
Normal 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'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart
vendored
Normal file
106
kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart
vendored
Normal 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'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart
vendored
Normal file
129
kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart
vendored
Normal 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();
|
||||||
|
});
|
||||||
88
kifi-app/lib/features/vendor/providers/vendors_provider.dart
vendored
Normal file
88
kifi-app/lib/features/vendor/providers/vendors_provider.dart
vendored
Normal 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
32
kifi_agency_os_plan.md
Normal 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
33
kifi_context.md
Normal 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
28
kifi_handoff.md
Normal 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.
|
||||||
58
scratch/reset_user_data.sql
Normal file
58
scratch/reset_user_data.sql
Normal 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
35
scratch/rewrite.java
Normal 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
11
test_api.java
Normal 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
4
test_api.sh
Executable 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
BIN
test_mono.class
Normal file
Binary file not shown.
12
test_mono.java
Normal file
12
test_mono.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user