Product Add Done
This commit is contained in:
20
kifi-api/build_n_push_v2.sh
Executable file
20
kifi-api/build_n_push_v2.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
REGISTRY="hub.technobeesolutions.in"
|
||||||
|
USERNAME="technobee_admin"
|
||||||
|
PASSWORD='M@tr!x#149@dm!N'
|
||||||
|
IMAGE_NAME="kifi-api"
|
||||||
|
TAG="kifi-one"
|
||||||
|
FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$TAG"
|
||||||
|
|
||||||
|
# Stop on any error
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Logging into Docker registry: $REGISTRY..."
|
||||||
|
echo "$PASSWORD" | docker login "$REGISTRY" -u "$USERNAME" --password-stdin
|
||||||
|
|
||||||
|
echo "Building and pushing Docker image for linux/amd64: $FULL_IMAGE_NAME..."
|
||||||
|
docker buildx build --platform linux/amd64 -t "$FULL_IMAGE_NAME" --push .
|
||||||
|
|
||||||
|
echo "Done! Image built and pushed successfully."
|
||||||
@@ -45,8 +45,8 @@ public class SecurityConfig {
|
|||||||
.securityContextRepository(securityContextRepository)
|
.securityContextRepository(securityContextRepository)
|
||||||
.authorizeExchange(exchange -> exchange
|
.authorizeExchange(exchange -> exchange
|
||||||
.pathMatchers(HttpMethod.OPTIONS).permitAll()
|
.pathMatchers(HttpMethod.OPTIONS).permitAll()
|
||||||
.pathMatchers("/api/kifi/auth/**").permitAll()
|
.pathMatchers("/api/kifi-v2/auth/**").permitAll()
|
||||||
.pathMatchers("/api/kifi/health/**").permitAll()
|
.pathMatchers("/api/kifi-v2/health/**").permitAll()
|
||||||
.anyExchange().authenticated()
|
.anyExchange().authenticated()
|
||||||
)
|
)
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import reactor.core.publisher.Mono;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/kifi/auth")
|
@RequestMapping("/api/kifi-v2/auth")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import reactor.core.publisher.Mono;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/kifi/budgets")
|
@RequestMapping("/api/kifi-v2/budgets")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class BudgetController {
|
public class BudgetController {
|
||||||
private final BudgetService budgetService;
|
private final BudgetService budgetService;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/kifi/categories")
|
@RequestMapping("/api/kifi-v2/categories")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class CategoryController {
|
public class CategoryController {
|
||||||
private final CategoryService categoryService;
|
private final CategoryService categoryService;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import reactor.core.publisher.Mono;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/kifi/health")
|
@RequestMapping("/api/kifi-v2/health")
|
||||||
public class HealthController {
|
public class HealthController {
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import java.util.List;
|
|||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/kifi/recurring-transactions")
|
@RequestMapping("/api/kifi-v2/recurring-transactions")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class RecurringTransactionController {
|
public class RecurringTransactionController {
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/kifi/reports")
|
@RequestMapping("/api/kifi-v2/reports")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ReportController {
|
public class ReportController {
|
||||||
private final ReportService reportService;
|
private final ReportService reportService;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import java.util.List;
|
|||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/kifi/transactions")
|
@RequestMapping("/api/kifi-v2/transactions")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class TransactionController {
|
public class TransactionController {
|
||||||
private final TransactionService transactionService;
|
private final TransactionService transactionService;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import reactor.core.publisher.Mono;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/kifi/wallets")
|
@RequestMapping("/api/kifi-v2/wallets")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class WalletController {
|
public class WalletController {
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.kifi.api.controller.business;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.business.BusinessProfile;
|
||||||
|
import com.kifi.api.service.business.BusinessService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/kifi-v2/business")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BusinessController {
|
||||||
|
private final BusinessService businessService;
|
||||||
|
|
||||||
|
// TODO: Obtain user ID from auth context, using hardcoded for now or parameter
|
||||||
|
@GetMapping("/profile")
|
||||||
|
public Mono<ResponseEntity<BusinessProfile>> getProfile(Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return businessService.getProfileByUserId(userId)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.defaultIfEmpty(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/profile")
|
||||||
|
public Mono<ResponseEntity<BusinessProfile>> saveProfile(Authentication authentication, @RequestBody BusinessProfile profile) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return businessService.saveProfile(userId, profile)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.kifi.api.controller.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.dto.inventory.MovementRequestDto;
|
||||||
|
import com.kifi.api.entity.inventory.InventoryMovement;
|
||||||
|
import com.kifi.api.service.inventory.InventoryMovementService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/kifi-v2/inventory/movements")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InventoryMovementController {
|
||||||
|
private final InventoryMovementService movementService;
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Mono<ResponseEntity<InventoryMovement>> recordMovement(Authentication authentication, @RequestBody MovementRequestDto request) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return movementService.recordMovement(userId, request.getMovement(), request.getItems())
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.kifi.api.controller.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.ProductCategory;
|
||||||
|
import com.kifi.api.repository.inventory.ProductCategoryRepository;
|
||||||
|
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.time.LocalDateTime;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/kifi-v2/inventory/categories")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProductCategoryController {
|
||||||
|
private final ProductCategoryRepository categoryRepository;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Flux<ProductCategory> getCategories(Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return categoryRepository.findByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Mono<ResponseEntity<ProductCategory>> createCategory(Authentication authentication, @RequestBody ProductCategory category) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
category.setUserId(userId);
|
||||||
|
category.setCreatedAt(LocalDateTime.now());
|
||||||
|
return categoryRepository.save(category)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Mono<ResponseEntity<ProductCategory>> updateCategory(@PathVariable Long id, @RequestBody ProductCategory category) {
|
||||||
|
return categoryRepository.findById(id)
|
||||||
|
.flatMap(existing -> {
|
||||||
|
existing.setName(category.getName());
|
||||||
|
existing.setParentCategoryId(category.getParentCategoryId());
|
||||||
|
existing.setIsCommodity(category.getIsCommodity());
|
||||||
|
existing.setDailyRate(category.getDailyRate());
|
||||||
|
return categoryRepository.save(existing);
|
||||||
|
})
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.defaultIfEmpty(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.kifi.api.controller.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.Product;
|
||||||
|
import com.kifi.api.entity.inventory.ProductImage;
|
||||||
|
import com.kifi.api.service.inventory.ProductService;
|
||||||
|
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 org.springframework.http.codec.multipart.FilePart;
|
||||||
|
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/kifi-v2/inventory/products")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProductController {
|
||||||
|
private final ProductService productService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Flux<Product> getProducts(
|
||||||
|
Authentication authentication,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size
|
||||||
|
) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return productService.getProductsByUserId(userId, page, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Mono<ResponseEntity<Product>> createProduct(Authentication authentication, @RequestBody Product product) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return productService.createProduct(userId, product)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Mono<ResponseEntity<Product>> updateProduct(
|
||||||
|
Authentication authentication,
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody Product product
|
||||||
|
) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return productService.updateProduct(userId, id, product)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.defaultIfEmpty(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/{id}/images", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public Mono<ProductImage> addImage(@PathVariable Long id, @RequestPart("file") FilePart filePart) {
|
||||||
|
return DataBufferUtils.join(filePart.content())
|
||||||
|
.flatMap(dataBuffer -> {
|
||||||
|
byte[] bytes = new byte[dataBuffer.readableByteCount()];
|
||||||
|
dataBuffer.read(bytes);
|
||||||
|
DataBufferUtils.release(dataBuffer);
|
||||||
|
String base64Content = Base64.getEncoder().encodeToString(bytes);
|
||||||
|
String fileName = filePart.filename();
|
||||||
|
String contentType = filePart.headers().getContentType() != null ? filePart.headers().getContentType().toString() : "application/octet-stream";
|
||||||
|
return productService.addProductImage(id, fileName, contentType, base64Content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/images/{imageId}/content")
|
||||||
|
public Mono<ResponseEntity<byte[]>> downloadImage(@PathVariable Long id, @PathVariable Long imageId) {
|
||||||
|
return productService.downloadProductImage(imageId)
|
||||||
|
.map(response -> {
|
||||||
|
byte[] decodedBytes = Base64.getDecoder().decode(response.getBase64Content());
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_TYPE, "image/jpeg")
|
||||||
|
.body(decodedBytes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/images/{imageId}")
|
||||||
|
public Mono<Void> deleteImage(@PathVariable Long imageId) {
|
||||||
|
return productService.deleteProductImage(imageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.kifi.api.dto.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.InventoryMovement;
|
||||||
|
import com.kifi.api.entity.inventory.InventoryMovementItem;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class MovementRequestDto {
|
||||||
|
private InventoryMovement movement;
|
||||||
|
private List<InventoryMovementItem> items;
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ public class User {
|
|||||||
private String password;
|
private String password;
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Boolean enabled = false;
|
private Boolean enabled = false;
|
||||||
|
@Builder.Default
|
||||||
|
private String profileType = "INDIVIDUAL";
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.kifi.api.entity.business;
|
||||||
|
|
||||||
|
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("business_features")
|
||||||
|
public class BusinessFeature {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private Long userId;
|
||||||
|
private Boolean inventoryManagement;
|
||||||
|
private Boolean salesManagement;
|
||||||
|
private Boolean multiLocation;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.kifi.api.entity.business;
|
||||||
|
|
||||||
|
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("business_profiles")
|
||||||
|
public class BusinessProfile {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private Long userId;
|
||||||
|
private String businessName;
|
||||||
|
private String industry;
|
||||||
|
private String taxNumber;
|
||||||
|
private String currency;
|
||||||
|
private Boolean taxIncludedInPrice;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.kifi.api.entity.inventory;
|
||||||
|
|
||||||
|
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("inventory_balances")
|
||||||
|
public class InventoryBalance {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private Long productId;
|
||||||
|
private Long locationId;
|
||||||
|
private BigDecimal quantity;
|
||||||
|
private LocalDateTime lastUpdated;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.kifi.api.entity.inventory;
|
||||||
|
|
||||||
|
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("inventory_locations")
|
||||||
|
public class InventoryLocation {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private Long userId;
|
||||||
|
private String name;
|
||||||
|
private String address;
|
||||||
|
private Boolean isPrimary;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.kifi.api.entity.inventory;
|
||||||
|
|
||||||
|
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("inventory_movements")
|
||||||
|
public class InventoryMovement {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private Long userId;
|
||||||
|
private Long locationId;
|
||||||
|
private String type;
|
||||||
|
private Long referenceTransactionId;
|
||||||
|
private String notes;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.kifi.api.entity.inventory;
|
||||||
|
|
||||||
|
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("inventory_movement_items")
|
||||||
|
public class InventoryMovementItem {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private Long movementId;
|
||||||
|
private Long productId;
|
||||||
|
private BigDecimal quantity;
|
||||||
|
private BigDecimal unitPrice;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.kifi.api.entity.inventory;
|
||||||
|
|
||||||
|
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;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.annotation.Transient;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Table("products")
|
||||||
|
public class Product {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private Long userId;
|
||||||
|
private Long categoryId;
|
||||||
|
private Long uomId;
|
||||||
|
private String name;
|
||||||
|
private String sku;
|
||||||
|
private String barcode;
|
||||||
|
private String description;
|
||||||
|
private BigDecimal purchasePrice;
|
||||||
|
private BigDecimal sellingPrice;
|
||||||
|
private BigDecimal gstRate;
|
||||||
|
private String dimensions;
|
||||||
|
private BigDecimal weight;
|
||||||
|
private String color;
|
||||||
|
private String size;
|
||||||
|
private String priceCalcRule;
|
||||||
|
private Boolean autoCalculatePrice;
|
||||||
|
private Double purityFactor;
|
||||||
|
private Double makingCharges;
|
||||||
|
private String makingChargesType;
|
||||||
|
private Double wastagePercentage;
|
||||||
|
private BigDecimal minStock;
|
||||||
|
private BigDecimal reorderLevel;
|
||||||
|
private Boolean trackInventory;
|
||||||
|
private Boolean isActive;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private List<ProductImage> images;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.kifi.api.entity.inventory;
|
||||||
|
|
||||||
|
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("product_bom")
|
||||||
|
public class ProductBom {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private Long parentProductId;
|
||||||
|
private Long componentProductId;
|
||||||
|
private BigDecimal quantity;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.kifi.api.entity.inventory;
|
||||||
|
|
||||||
|
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("product_categories")
|
||||||
|
public class ProductCategory {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private Long userId;
|
||||||
|
private String name;
|
||||||
|
private Long parentCategoryId;
|
||||||
|
private Boolean isCommodity;
|
||||||
|
private String calculationMethod;
|
||||||
|
private String baseUnit;
|
||||||
|
private Double dailyRate;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.kifi.api.entity.inventory;
|
||||||
|
|
||||||
|
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("product_images")
|
||||||
|
public class ProductImage {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private Long productId;
|
||||||
|
private String fileName;
|
||||||
|
private String filePath;
|
||||||
|
private String contentType;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.kifi.api.entity.inventory;
|
||||||
|
|
||||||
|
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("units_of_measure")
|
||||||
|
public class UnitOfMeasure {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private Long userId;
|
||||||
|
private String name;
|
||||||
|
private String abbreviation;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kifi.api.repository.business;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.business.BusinessFeature;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
public interface BusinessFeatureRepository extends ReactiveCrudRepository<BusinessFeature, Long> {
|
||||||
|
Mono<BusinessFeature> findByUserId(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kifi.api.repository.business;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.business.BusinessProfile;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
public interface BusinessProfileRepository extends ReactiveCrudRepository<BusinessProfile, Long> {
|
||||||
|
Mono<BusinessProfile> findByUserId(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.kifi.api.repository.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.InventoryBalance;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
public interface InventoryBalanceRepository extends ReactiveCrudRepository<InventoryBalance, Long> {
|
||||||
|
Mono<InventoryBalance> findByProductIdAndLocationId(Long productId, Long locationId);
|
||||||
|
Flux<InventoryBalance> findByLocationId(Long locationId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kifi.api.repository.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.InventoryLocation;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
public interface InventoryLocationRepository extends ReactiveCrudRepository<InventoryLocation, Long> {
|
||||||
|
Flux<InventoryLocation> findByUserId(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kifi.api.repository.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.InventoryMovementItem;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
public interface InventoryMovementItemRepository extends ReactiveCrudRepository<InventoryMovementItem, Long> {
|
||||||
|
Flux<InventoryMovementItem> findByMovementId(Long movementId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kifi.api.repository.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.InventoryMovement;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
public interface InventoryMovementRepository extends ReactiveCrudRepository<InventoryMovement, Long> {
|
||||||
|
Flux<InventoryMovement> findByUserId(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kifi.api.repository.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.ProductBom;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
public interface ProductBomRepository extends ReactiveCrudRepository<ProductBom, Long> {
|
||||||
|
Flux<ProductBom> findByParentProductId(Long parentProductId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kifi.api.repository.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.ProductCategory;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
public interface ProductCategoryRepository extends ReactiveCrudRepository<ProductCategory, Long> {
|
||||||
|
Flux<ProductCategory> findByUserId(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kifi.api.repository.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.ProductImage;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
public interface ProductImageRepository extends ReactiveCrudRepository<ProductImage, Long> {
|
||||||
|
Flux<ProductImage> findByProductId(Long productId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.kifi.api.repository.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.Product;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
public interface ProductRepository extends ReactiveCrudRepository<Product, Long> {
|
||||||
|
Flux<Product> findByUserId(Long userId, Pageable pageable);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kifi.api.repository.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.UnitOfMeasure;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
public interface UnitOfMeasureRepository extends ReactiveCrudRepository<UnitOfMeasure, Long> {
|
||||||
|
Flux<UnitOfMeasure> findByUserId(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.kifi.api.service.business;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.business.BusinessProfile;
|
||||||
|
import com.kifi.api.repository.business.BusinessProfileRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BusinessService {
|
||||||
|
private final BusinessProfileRepository businessProfileRepository;
|
||||||
|
|
||||||
|
public Mono<BusinessProfile> getProfileByUserId(Long userId) {
|
||||||
|
return businessProfileRepository.findByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<BusinessProfile> saveProfile(Long userId, BusinessProfile profile) {
|
||||||
|
return businessProfileRepository.findByUserId(userId)
|
||||||
|
.flatMap(existing -> {
|
||||||
|
existing.setBusinessName(profile.getBusinessName());
|
||||||
|
existing.setIndustry(profile.getIndustry());
|
||||||
|
existing.setTaxNumber(profile.getTaxNumber());
|
||||||
|
existing.setCurrency(profile.getCurrency() != null ? profile.getCurrency() : existing.getCurrency());
|
||||||
|
existing.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return businessProfileRepository.save(existing);
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
profile.setUserId(userId);
|
||||||
|
profile.setCreatedAt(LocalDateTime.now());
|
||||||
|
profile.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return businessProfileRepository.save(profile);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.kifi.api.service.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.InventoryBalance;
|
||||||
|
import com.kifi.api.entity.inventory.InventoryMovement;
|
||||||
|
import com.kifi.api.entity.inventory.InventoryMovementItem;
|
||||||
|
import com.kifi.api.repository.inventory.InventoryBalanceRepository;
|
||||||
|
import com.kifi.api.repository.inventory.InventoryMovementItemRepository;
|
||||||
|
import com.kifi.api.repository.inventory.InventoryMovementRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InventoryMovementService {
|
||||||
|
private final InventoryMovementRepository movementRepository;
|
||||||
|
private final InventoryMovementItemRepository itemRepository;
|
||||||
|
private final InventoryBalanceRepository balanceRepository;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Mono<InventoryMovement> recordMovement(Long userId, InventoryMovement movement, List<InventoryMovementItem> items) {
|
||||||
|
movement.setUserId(userId);
|
||||||
|
movement.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
return movementRepository.save(movement)
|
||||||
|
.flatMap(savedMovement -> {
|
||||||
|
return reactor.core.publisher.Flux.fromIterable(items)
|
||||||
|
.flatMap(item -> {
|
||||||
|
item.setMovementId(savedMovement.getId());
|
||||||
|
item.setCreatedAt(LocalDateTime.now());
|
||||||
|
return itemRepository.save(item)
|
||||||
|
.then(updateBalance(item.getProductId(), savedMovement.getLocationId(), item.getQuantity(), savedMovement.getType()));
|
||||||
|
})
|
||||||
|
.then(Mono.just(savedMovement));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<InventoryBalance> updateBalance(Long productId, Long locationId, BigDecimal quantity, String type) {
|
||||||
|
BigDecimal signedQuantity = quantity;
|
||||||
|
if ("REDUCTION".equals(type) || "ADJUSTMENT".equals(type)) { // Assuming adjustment is absolute or negative handled elsewhere, for now treat reduction as negative
|
||||||
|
// we'll refine this later
|
||||||
|
if("REDUCTION".equals(type)) {
|
||||||
|
signedQuantity = quantity.negate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal finalQty = signedQuantity;
|
||||||
|
|
||||||
|
return balanceRepository.findByProductIdAndLocationId(productId, locationId)
|
||||||
|
.flatMap(balance -> {
|
||||||
|
balance.setQuantity(balance.getQuantity().add(finalQty));
|
||||||
|
balance.setLastUpdated(LocalDateTime.now());
|
||||||
|
return balanceRepository.save(balance);
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
InventoryBalance newBalance = new InventoryBalance();
|
||||||
|
newBalance.setProductId(productId);
|
||||||
|
newBalance.setLocationId(locationId);
|
||||||
|
newBalance.setQuantity(finalQty);
|
||||||
|
newBalance.setLastUpdated(LocalDateTime.now());
|
||||||
|
return balanceRepository.save(newBalance);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.kifi.api.service.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.Product;
|
||||||
|
import com.kifi.api.entity.inventory.ProductImage;
|
||||||
|
import com.kifi.api.repository.inventory.ProductRepository;
|
||||||
|
import com.kifi.api.repository.inventory.ProductImageRepository;
|
||||||
|
import com.kifi.api.service.MinioServiceClient;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProductService {
|
||||||
|
private final ProductRepository productRepository;
|
||||||
|
private final ProductImageRepository productImageRepository;
|
||||||
|
private final MinioServiceClient minioServiceClient;
|
||||||
|
|
||||||
|
public Flux<Product> getProductsByUserId(Long userId, int page, int size) {
|
||||||
|
return productRepository.findByUserId(userId, PageRequest.of(page, size))
|
||||||
|
.flatMap(product -> productImageRepository.findByProductId(product.getId()).collectList()
|
||||||
|
.map(images -> {
|
||||||
|
product.setImages(images);
|
||||||
|
return product;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Product> createProduct(Long userId, Product product) {
|
||||||
|
product.setUserId(userId);
|
||||||
|
product.setCreatedAt(LocalDateTime.now());
|
||||||
|
product.setUpdatedAt(LocalDateTime.now());
|
||||||
|
if (product.getIsActive() == null) product.setIsActive(true);
|
||||||
|
if (product.getTrackInventory() == null) product.setTrackInventory(true);
|
||||||
|
if (product.getPriceCalcRule() == null) product.setPriceCalcRule("MANUAL");
|
||||||
|
if (product.getAutoCalculatePrice() == null) product.setAutoCalculatePrice(false);
|
||||||
|
return productRepository.save(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Product> updateProduct(Long userId, Long productId, Product updatedProduct) {
|
||||||
|
return productRepository.findById(productId)
|
||||||
|
.filter(p -> p.getUserId().equals(userId))
|
||||||
|
.flatMap(existingProduct -> {
|
||||||
|
existingProduct.setName(updatedProduct.getName());
|
||||||
|
existingProduct.setSku(updatedProduct.getSku());
|
||||||
|
existingProduct.setCategoryId(updatedProduct.getCategoryId());
|
||||||
|
existingProduct.setPurchasePrice(updatedProduct.getPurchasePrice());
|
||||||
|
existingProduct.setSellingPrice(updatedProduct.getSellingPrice());
|
||||||
|
existingProduct.setGstRate(updatedProduct.getGstRate());
|
||||||
|
existingProduct.setWeight(updatedProduct.getWeight());
|
||||||
|
existingProduct.setColor(updatedProduct.getColor());
|
||||||
|
existingProduct.setSize(updatedProduct.getSize());
|
||||||
|
existingProduct.setDimensions(updatedProduct.getDimensions());
|
||||||
|
existingProduct.setPriceCalcRule(updatedProduct.getPriceCalcRule());
|
||||||
|
existingProduct.setAutoCalculatePrice(updatedProduct.getAutoCalculatePrice());
|
||||||
|
existingProduct.setPurityFactor(updatedProduct.getPurityFactor());
|
||||||
|
existingProduct.setMakingCharges(updatedProduct.getMakingCharges());
|
||||||
|
existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType());
|
||||||
|
existingProduct.setWastagePercentage(updatedProduct.getWastagePercentage());
|
||||||
|
existingProduct.setMinStock(updatedProduct.getMinStock());
|
||||||
|
existingProduct.setReorderLevel(updatedProduct.getReorderLevel());
|
||||||
|
existingProduct.setTrackInventory(updatedProduct.getTrackInventory());
|
||||||
|
existingProduct.setIsActive(updatedProduct.getIsActive());
|
||||||
|
existingProduct.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return productRepository.save(existingProduct);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<ProductImage> addProductImage(Long productId, String fileName, String contentType, String base64Content) {
|
||||||
|
String uniqueFileName = UUID.randomUUID().toString() + "_" + fileName;
|
||||||
|
String directoryPath = "products/" + productId;
|
||||||
|
|
||||||
|
return minioServiceClient.uploadFile(directoryPath, contentType, uniqueFileName, base64Content)
|
||||||
|
.flatMap(minioResponse -> {
|
||||||
|
if (minioResponse.isSuccess()) {
|
||||||
|
ProductImage image = ProductImage.builder()
|
||||||
|
.productId(productId)
|
||||||
|
.fileName(fileName)
|
||||||
|
.filePath(minioResponse.getFilePath())
|
||||||
|
.contentType(contentType)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
return productImageRepository.save(image);
|
||||||
|
} else {
|
||||||
|
return Mono.error(new RuntimeException("Failed to upload product image to MinIO"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> deleteProductImage(Long imageId) {
|
||||||
|
return productImageRepository.findById(imageId)
|
||||||
|
.flatMap(image ->
|
||||||
|
minioServiceClient.deleteFile(image.getContentType(), image.getFilePath())
|
||||||
|
.then(productImageRepository.delete(image))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<MinioServiceClient.MinioDownloadResponse> downloadProductImage(Long imageId) {
|
||||||
|
return productImageRepository.findById(imageId)
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("Product image not found")))
|
||||||
|
.flatMap(image ->
|
||||||
|
minioServiceClient.downloadFile(image.getContentType(), image.getFilePath())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ spring:
|
|||||||
name: kifi-api
|
name: kifi-api
|
||||||
|
|
||||||
r2dbc:
|
r2dbc:
|
||||||
url: r2dbc:postgresql://103.125.129.116:5333/kifi
|
url: r2dbc:postgresql://103.125.129.116:5333/kifi-v2
|
||||||
username: postgres
|
username: postgres
|
||||||
password: M@triXPostgr3s@6202
|
password: M@triXPostgr3s@6202
|
||||||
pool:
|
pool:
|
||||||
|
|||||||
@@ -132,3 +132,134 @@ CREATE TABLE IF NOT EXISTS wallet_invitations (
|
|||||||
status VARCHAR(20) DEFAULT 'PENDING',
|
status VARCHAR(20) DEFAULT 'PENDING',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- KIFI V2 PHASE 1 MIGRATION: INVENTORY MANAGEMENT --
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_type VARCHAR(20) DEFAULT 'INDIVIDUAL';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS business_profiles (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
business_name VARCHAR(255) NOT NULL,
|
||||||
|
industry VARCHAR(100),
|
||||||
|
tax_number VARCHAR(100),
|
||||||
|
currency VARCHAR(10) DEFAULT 'INR',
|
||||||
|
tax_included_in_price BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS business_features (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
inventory_management BOOLEAN DEFAULT FALSE,
|
||||||
|
sales_management BOOLEAN DEFAULT FALSE,
|
||||||
|
multi_location BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_categories (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
parent_category_id INTEGER REFERENCES product_categories(id),
|
||||||
|
is_commodity BOOLEAN DEFAULT FALSE,
|
||||||
|
calculation_method VARCHAR(50) DEFAULT 'UNIT',
|
||||||
|
base_unit VARCHAR(50) DEFAULT 'pcs',
|
||||||
|
daily_rate DECIMAL(15, 2),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS units_of_measure (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
abbreviation VARCHAR(20),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
category_id INTEGER REFERENCES product_categories(id),
|
||||||
|
uom_id INTEGER REFERENCES units_of_measure(id),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
sku VARCHAR(100),
|
||||||
|
barcode VARCHAR(100),
|
||||||
|
description TEXT,
|
||||||
|
purchase_price DECIMAL(15, 2),
|
||||||
|
selling_price DECIMAL(15, 2),
|
||||||
|
gst_rate DECIMAL(5, 2),
|
||||||
|
dimensions VARCHAR(100),
|
||||||
|
weight DECIMAL(10, 3),
|
||||||
|
color VARCHAR(50),
|
||||||
|
size VARCHAR(50),
|
||||||
|
price_calc_rule VARCHAR(50) DEFAULT 'MANUAL',
|
||||||
|
auto_calculate_price BOOLEAN DEFAULT FALSE,
|
||||||
|
purity_factor DECIMAL(5, 4) DEFAULT 1.0,
|
||||||
|
making_charges DECIMAL(15, 2) DEFAULT 0.0,
|
||||||
|
making_charges_type VARCHAR(50) DEFAULT 'FLAT',
|
||||||
|
wastage_percentage DECIMAL(5, 2) DEFAULT 0.0,
|
||||||
|
min_stock DECIMAL(10, 2) DEFAULT 0,
|
||||||
|
reorder_level DECIMAL(10, 2) DEFAULT 0,
|
||||||
|
track_inventory BOOLEAN DEFAULT TRUE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_images (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
file_path VARCHAR(1024) NOT NULL,
|
||||||
|
content_type VARCHAR(100),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_bom (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
parent_product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
component_product_id INTEGER REFERENCES products(id) ON DELETE RESTRICT,
|
||||||
|
quantity DECIMAL(10, 3) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_locations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
address TEXT,
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_movements (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
location_id INTEGER REFERENCES inventory_locations(id),
|
||||||
|
type VARCHAR(50) NOT NULL, -- ADDITION, REDUCTION, TRANSFER, ADJUSTMENT
|
||||||
|
reference_transaction_id INTEGER REFERENCES transactions(id),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_movement_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
movement_id INTEGER REFERENCES inventory_movements(id) ON DELETE CASCADE,
|
||||||
|
product_id INTEGER REFERENCES products(id),
|
||||||
|
quantity DECIMAL(10, 2) NOT NULL,
|
||||||
|
unit_price DECIMAL(10, 2),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_balances (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
location_id INTEGER REFERENCES inventory_locations(id) ON DELETE CASCADE,
|
||||||
|
quantity DECIMAL(10, 2) DEFAULT 0,
|
||||||
|
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(product_id, location_id)
|
||||||
|
);
|
||||||
|
|||||||
71
kifi-app/APP_FEATURES.md
Normal file
71
kifi-app/APP_FEATURES.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Kifi Application Context & Features Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Kifi is a comprehensive personal finance, expense tracking, and wealth management mobile application built using Flutter. It is designed to track daily expenses, manage budgets, track assets and liabilities, and facilitate collaborative finance through shared wallets.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Frontend Framework**: Flutter / Dart
|
||||||
|
- **State Management**: Riverpod (Providers)
|
||||||
|
- **Networking**: Dio (HTTP client for REST API communication)
|
||||||
|
- **Backend Infrastructure**: REST APIs connected to a PostgreSQL database
|
||||||
|
- **Local Storage**: SharedPreferences for session caching
|
||||||
|
|
||||||
|
## Application Architecture (Feature-Driven Design)
|
||||||
|
The application is structured into domain-specific features inside `lib/features/`:
|
||||||
|
|
||||||
|
### 1. Authentication (`lib/features/auth`)
|
||||||
|
- **Login/Registration**: Phone number or email-based login with OTP verification.
|
||||||
|
- **Session Management**: JWT token-based authentication stored securely on the device.
|
||||||
|
- **Profile Management**: User profile handling and device tracking.
|
||||||
|
|
||||||
|
### 2. Dashboard (`lib/features/dashboard`)
|
||||||
|
- **Main Hub**: The central screen incorporating a Bottom Navigation Bar (`IndexedStack` for state preservation).
|
||||||
|
- **Navigation Tabs**:
|
||||||
|
1. Home (Dashboard summary)
|
||||||
|
2. Stats (Analytics & Charts)
|
||||||
|
3. Add (Floating middle button for quick Transaction creation)
|
||||||
|
4. Wallets (Account management)
|
||||||
|
5. Budgets (Limit management)
|
||||||
|
- **Financial Summary Cards**: High-level aggregated balances grouped by financial "Nature" (e.g., Expense, Income, Cash, Bank, Assets, Investments, Receivables, Payables, Liabilities).
|
||||||
|
- **Maturity Alerts**: Dialog prompts to close or settle matured investments and receivables.
|
||||||
|
|
||||||
|
### 3. Wallets & Accounts Management (`lib/features/dashboard/presentation/accounts_screen.dart`)
|
||||||
|
- **Wallet Natures**: Wallets are strictly categorized by their nature (`CASH`, `BANK`, `INVESTMENTS`, `LIABILITIES`, etc.).
|
||||||
|
- **Ledger System**: Each wallet maintains its own transactional ledger reflecting debit/credit logic based on its nature.
|
||||||
|
- **Shared Wallets (Collaboration)**:
|
||||||
|
- Users can invite others to co-manage a wallet (via email/phone).
|
||||||
|
- Transactions, ledgers, and budgets for shared wallets are seamlessly aggregated across all members.
|
||||||
|
- **UI Elements**: Persistent search bars and robust nature-based filtering using modern bottom-sheets.
|
||||||
|
|
||||||
|
### 4. Transactions (`lib/features/transactions`)
|
||||||
|
- **Transaction Types**: Expense, Income, and internal Transfers.
|
||||||
|
- **Data Capture**: Records amount, date, source/destination wallet, category, and optional notes.
|
||||||
|
- **Attachments**: Supports image uploads for receipts. Includes an `AttachmentGalleryScreen` allowing swipe-to-scroll, zoom-on-double-tap, and pinch-to-zoom gestures.
|
||||||
|
- **Filters & Search**: Transactions can be searched by text and filtered by type, wallet, and category.
|
||||||
|
- **Data Handling**: Uses paginated API calls (infinite scrolling) for fetching large transaction histories.
|
||||||
|
|
||||||
|
### 5. Budgets (`lib/features/budget`)
|
||||||
|
- **Account-Level Budgeting**: Budgets are explicitly mapped to individual wallets/accounts.
|
||||||
|
- **Shared Budgets**: If a wallet is shared, the budget cap applies globally to all members contributing to that wallet.
|
||||||
|
- **Progress Tracking**: Visual progress bars showing current spending versus the set limit.
|
||||||
|
- **Consistency**: UI matches the robust search and filter styling of the Transactions and Accounts screens.
|
||||||
|
|
||||||
|
### 6. Statistics & Analytics
|
||||||
|
- **Visualizations**:
|
||||||
|
- **Pie Charts**: Breakdown of spending grouped by category.
|
||||||
|
- **Bar Charts**: Day-wise and trend-based spending over time.
|
||||||
|
- **Custom Date Filtering**: Data can be instantly recalculated for predefined ranges (Today, This Week, This Month) or Custom Date selections.
|
||||||
|
|
||||||
|
### 7. Notifications & Onboarding
|
||||||
|
- **Onboarding Flow**: Welcomes new users and sets up initial data (`lib/features/onboarding`).
|
||||||
|
- **Alerts**: Handles pending wallet invitations and potentially budget threshold warnings.
|
||||||
|
|
||||||
|
## UI/UX Design System
|
||||||
|
- **Colors**: Implements a `NatureColors` scheme mapping specific financial natures to distinct, consistent colors across the app (e.g., Expenses are red, Incomes are green, Assets are blue).
|
||||||
|
- **Standardized Components**: Utilizes highly consistent bottom sheets for all filtering actions (`TransactionFilterSheet`, etc.) and unified rounded gray `TextField` designs for searching.
|
||||||
|
- **Gestures**: Focuses on mobile-first interactions, including swipeable image galleries and pull-to-refresh lists.
|
||||||
|
|
||||||
|
## Key Developer & AI Notes
|
||||||
|
- **Code Patterns**: The app makes heavy use of Riverpod `ConsumerStatefulWidget`, `ref.watch`, and `.when()` for async data states (`loading`, `error`, `data`).
|
||||||
|
- **API Interactions**: Complex data aggregation (like dashboard totals) often relies on the backend returning pre-calculated structures, while frontend filtering works locally on cached states.
|
||||||
|
- **Navigation**: Employs standard Flutter `Navigator` (`MaterialPageRoute`) for deep-linking (e.g., from a dashboard card directly into a filtered Accounts screen).
|
||||||
@@ -49,6 +49,11 @@
|
|||||||
<string>Used to select receipts for auto-filling transaction details.</string>
|
<string>Used to select receipts for auto-filling transaction details.</string>
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<key>NSFaceIDUsageDescription</key>
|
||||||
<string>Authenticate to access your personal finance data securely.</string>
|
<string>Authenticate to access your personal finance data securely.</string>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class DioClient {
|
|||||||
|
|
||||||
DioClient._internal()
|
DioClient._internal()
|
||||||
: dio = Dio(BaseOptions(
|
: dio = Dio(BaseOptions(
|
||||||
baseUrl: 'https://app.technobeesolutions.in/api/kifi',
|
baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2',
|
||||||
|
//baseUrl: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing
|
||||||
connectTimeout: const Duration(seconds: 10),
|
connectTimeout: const Duration(seconds: 10),
|
||||||
receiveTimeout: const Duration(seconds: 10),
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import '../providers/auth_provider.dart';
|
|||||||
import '../../transactions/providers/providers.dart';
|
import '../../transactions/providers/providers.dart';
|
||||||
|
|
||||||
import '../../../core/theme/theme_provider.dart';
|
import '../../../core/theme/theme_provider.dart';
|
||||||
|
import '../../business/providers/business_mode_provider.dart';
|
||||||
|
import '../../business/presentation/settings/business_settings_screen.dart';
|
||||||
|
|
||||||
class ProfileScreen extends ConsumerStatefulWidget {
|
class ProfileScreen extends ConsumerStatefulWidget {
|
||||||
const ProfileScreen({super.key});
|
const ProfileScreen({super.key});
|
||||||
@@ -114,7 +116,37 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
|
Consumer(
|
||||||
|
builder: (context, ref, child) {
|
||||||
|
final isBusinessMode = ref.watch(businessModeProvider);
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(LucideIcons.briefcase),
|
||||||
|
title: const Text('Business Mode'),
|
||||||
|
subtitle: const Text('Inventory and sales management'),
|
||||||
|
value: isBusinessMode,
|
||||||
|
onChanged: (val) {
|
||||||
|
ref.read(businessModeProvider.notifier).toggleMode();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (isBusinessMode) ...[
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(LucideIcons.settings),
|
||||||
|
title: const Text('Business Settings'),
|
||||||
|
subtitle: const Text('Tax, Modules, POS'),
|
||||||
|
trailing: const Icon(LucideIcons.chevronRight),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const BusinessSettingsScreen()));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(LucideIcons.helpCircle),
|
leading: const Icon(LucideIcons.helpCircle),
|
||||||
title: const Text('Help & Support'),
|
title: const Text('Help & Support'),
|
||||||
|
|||||||
61
kifi-app/lib/features/business/domain/business_profile.dart
Normal file
61
kifi-app/lib/features/business/domain/business_profile.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
class BusinessProfile {
|
||||||
|
final int? id;
|
||||||
|
final int? userId;
|
||||||
|
final String businessName;
|
||||||
|
final String? industry;
|
||||||
|
final String? taxNumber;
|
||||||
|
final String? currency;
|
||||||
|
final bool? taxIncludedInPrice;
|
||||||
|
|
||||||
|
BusinessProfile({
|
||||||
|
this.id,
|
||||||
|
this.userId,
|
||||||
|
required this.businessName,
|
||||||
|
this.industry,
|
||||||
|
this.taxNumber,
|
||||||
|
this.currency,
|
||||||
|
this.taxIncludedInPrice,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BusinessProfile.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BusinessProfile(
|
||||||
|
id: json['id'],
|
||||||
|
userId: json['userId'],
|
||||||
|
businessName: json['businessName'],
|
||||||
|
industry: json['industry'],
|
||||||
|
taxNumber: json['taxNumber'],
|
||||||
|
currency: json['currency'],
|
||||||
|
taxIncludedInPrice: json['taxIncludedInPrice'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'userId': userId,
|
||||||
|
'businessName': businessName,
|
||||||
|
'industry': industry,
|
||||||
|
'taxNumber': taxNumber,
|
||||||
|
'currency': currency,
|
||||||
|
'taxIncludedInPrice': taxIncludedInPrice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
BusinessProfile copyWith({
|
||||||
|
String? businessName,
|
||||||
|
String? industry,
|
||||||
|
String? taxNumber,
|
||||||
|
String? currency,
|
||||||
|
bool? taxIncludedInPrice,
|
||||||
|
}) {
|
||||||
|
return BusinessProfile(
|
||||||
|
id: id,
|
||||||
|
userId: userId,
|
||||||
|
businessName: businessName ?? this.businessName,
|
||||||
|
industry: industry ?? this.industry,
|
||||||
|
taxNumber: taxNumber ?? this.taxNumber,
|
||||||
|
currency: currency ?? this.currency,
|
||||||
|
taxIncludedInPrice: taxIncludedInPrice ?? this.taxIncludedInPrice,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../../../../core/theme/nature_colors.dart';
|
||||||
|
import '../../../inventory/presentation/product_list_screen.dart';
|
||||||
|
|
||||||
|
class BusinessHubScreen extends ConsumerWidget {
|
||||||
|
const BusinessHubScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.briefcase, color: Theme.of(context).colorScheme.primary, size: 32),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Business Overview', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
const Text('Manage your inventory and stock', style: TextStyle(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
childAspectRatio: 1.2,
|
||||||
|
children: [
|
||||||
|
_buildActionCard(
|
||||||
|
context,
|
||||||
|
'Products Catalog',
|
||||||
|
'View all products',
|
||||||
|
LucideIcons.packageSearch,
|
||||||
|
Colors.blue,
|
||||||
|
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductListScreen())),
|
||||||
|
),
|
||||||
|
_buildActionCard(
|
||||||
|
context,
|
||||||
|
'Adjust Stock',
|
||||||
|
'Add or reduce inventory',
|
||||||
|
LucideIcons.arrowRightLeft,
|
||||||
|
Colors.orange,
|
||||||
|
() {
|
||||||
|
// TODO: Navigate to Stock Adjustment
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildActionCard(
|
||||||
|
context,
|
||||||
|
'Sales & Invoices',
|
||||||
|
'Coming in Phase 2',
|
||||||
|
LucideIcons.receipt,
|
||||||
|
Colors.green,
|
||||||
|
() {},
|
||||||
|
),
|
||||||
|
_buildActionCard(
|
||||||
|
context,
|
||||||
|
'Customers',
|
||||||
|
'Coming in Phase 2',
|
||||||
|
LucideIcons.users,
|
||||||
|
NatureColors.getColor('RECEIVABLES'),
|
||||||
|
() {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Text('Low Stock Alerts', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Placeholder for low stock items
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.withOpacity(0.05),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Text('All products are adequately stocked.', style: TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionCard(BuildContext context, String title, String subtitle, IconData icon, Color color, VoidCallback onTap) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: color.withOpacity(0.2)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 28),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(title, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(subtitle, style: TextStyle(color: color.withOpacity(0.7), fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../../providers/business_provider.dart';
|
||||||
|
|
||||||
|
class BusinessSettingsScreen extends ConsumerStatefulWidget {
|
||||||
|
const BusinessSettingsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<BusinessSettingsScreen> createState() => _BusinessSettingsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen> {
|
||||||
|
bool _taxIncludedInPrice = false;
|
||||||
|
bool _salesEnabled = true;
|
||||||
|
bool _inventoryEnabled = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final profile = ref.read(businessProfileProvider).value;
|
||||||
|
if (profile != null) {
|
||||||
|
setState(() {
|
||||||
|
_taxIncludedInPrice = profile.taxIncludedInPrice ?? false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Business Settings'),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
children: [
|
||||||
|
const Text('Module Configuration', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Inventory Management'),
|
||||||
|
subtitle: const Text('Track stock levels, multiple locations, and products'),
|
||||||
|
value: _inventoryEnabled,
|
||||||
|
onChanged: (val) => setState(() => _inventoryEnabled = val),
|
||||||
|
secondary: const Icon(LucideIcons.package),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Sales & POS'),
|
||||||
|
subtitle: const Text('Enable point of sale, invoicing, and receivables'),
|
||||||
|
value: _salesEnabled,
|
||||||
|
onChanged: (val) => setState(() => _salesEnabled = val),
|
||||||
|
secondary: const Icon(LucideIcons.shoppingCart),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
const Text('Pricing & Taxation', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Selling Price includes Tax'),
|
||||||
|
subtitle: const Text('If enabled, GST is assumed to be inclusive in the entered selling price.'),
|
||||||
|
value: _taxIncludedInPrice,
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() => _taxIncludedInPrice = val);
|
||||||
|
_saveSettings();
|
||||||
|
},
|
||||||
|
secondary: const Icon(LucideIcons.receipt),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _saveSettings() {
|
||||||
|
final profileState = ref.read(businessProfileProvider).value;
|
||||||
|
if (profileState != null) {
|
||||||
|
final updated = profileState.copyWith(taxIncludedInPrice: _taxIncludedInPrice);
|
||||||
|
ref.read(businessProfileProvider.notifier).updateProfile(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class BusinessModeNotifier extends Notifier<bool> {
|
||||||
|
@override
|
||||||
|
bool build() {
|
||||||
|
_loadState();
|
||||||
|
return false; // Default until loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadState() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
state = prefs.getBool('is_business_mode') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
state = !state;
|
||||||
|
await prefs.setBool('is_business_mode', state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final businessModeProvider = NotifierProvider<BusinessModeNotifier, bool>(() {
|
||||||
|
return BusinessModeNotifier();
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/network/dio_client.dart';
|
||||||
|
import '../domain/business_profile.dart';
|
||||||
|
|
||||||
|
class BusinessProfileNotifier extends AsyncNotifier<BusinessProfile?> {
|
||||||
|
@override
|
||||||
|
FutureOr<BusinessProfile?> build() async {
|
||||||
|
return _fetchProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BusinessProfile?> _fetchProfile() async {
|
||||||
|
final response = await DioClient().dio.get('/business/profile');
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return BusinessProfile.fromJson(response.data);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateProfile(BusinessProfile profile) async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.post(
|
||||||
|
'/business/profile',
|
||||||
|
data: profile.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
state = AsyncValue.data(BusinessProfile.fromJson(response.data));
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
state = AsyncValue.error(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final businessProfileProvider = AsyncNotifierProvider<BusinessProfileNotifier, BusinessProfile?>(() {
|
||||||
|
return BusinessProfileNotifier();
|
||||||
|
});
|
||||||
@@ -19,6 +19,8 @@ import '../../../core/theme/nature_colors.dart';
|
|||||||
import 'wallet_ledger_screen.dart';
|
import 'wallet_ledger_screen.dart';
|
||||||
import 'maturity_dialog.dart';
|
import 'maturity_dialog.dart';
|
||||||
import 'accounts_screen.dart';
|
import 'accounts_screen.dart';
|
||||||
|
import '../../business/providers/business_mode_provider.dart';
|
||||||
|
import '../../business/presentation/hub/business_hub_screen.dart';
|
||||||
|
|
||||||
class DashboardScreen extends ConsumerStatefulWidget {
|
class DashboardScreen extends ConsumerStatefulWidget {
|
||||||
const DashboardScreen({super.key});
|
const DashboardScreen({super.key});
|
||||||
@@ -137,6 +139,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
final categoriesState = ref.watch(categoryProvider);
|
final categoriesState = ref.watch(categoryProvider);
|
||||||
final insight = ref.watch(insightProvider);
|
final insight = ref.watch(insightProvider);
|
||||||
final walletsState = ref.watch(walletProvider);
|
final walletsState = ref.watch(walletProvider);
|
||||||
|
final isBusinessMode = ref.watch(businessModeProvider);
|
||||||
final allTransactions = transState.value ?? [];
|
final allTransactions = transState.value ?? [];
|
||||||
|
|
||||||
final range = _getDateRange(allTransactions);
|
final range = _getDateRange(allTransactions);
|
||||||
@@ -145,7 +148,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
String title;
|
String title;
|
||||||
if (_currentIndex == 0) title = 'Dashboard';
|
if (_currentIndex == 0) title = 'Dashboard';
|
||||||
else if (_currentIndex == 1) title = 'Statistics';
|
else if (_currentIndex == 1) title = isBusinessMode ? 'Business Hub' : 'Statistics';
|
||||||
else if (_currentIndex == 2) title = 'My Accounts';
|
else if (_currentIndex == 2) title = 'My Accounts';
|
||||||
else title = 'Budgets';
|
else title = 'Budgets';
|
||||||
|
|
||||||
@@ -512,8 +515,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ---------------- STATS TAB ----------------
|
// ---------------- STATS/BUSINESS TAB ----------------
|
||||||
StatisticsTab(
|
isBusinessMode ? const BusinessHubScreen() : StatisticsTab(
|
||||||
transactions: transactions,
|
transactions: transactions,
|
||||||
wallets: safeWallets,
|
wallets: safeWallets,
|
||||||
categories: categoriesState.value ?? [],
|
categories: categoriesState.value ?? [],
|
||||||
@@ -553,12 +556,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
unselectedItemColor: Colors.grey,
|
unselectedItemColor: Colors.grey,
|
||||||
showSelectedLabels: true,
|
showSelectedLabels: true,
|
||||||
showUnselectedLabels: true,
|
showUnselectedLabels: true,
|
||||||
items: const [
|
items: [
|
||||||
BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'),
|
const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'),
|
||||||
BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'),
|
BottomNavigationBarItem(
|
||||||
BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'),
|
icon: Icon(isBusinessMode ? LucideIcons.briefcase : LucideIcons.pieChart),
|
||||||
BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'),
|
label: isBusinessMode ? 'Business' : 'Stats'),
|
||||||
BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'),
|
const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'),
|
||||||
|
const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'),
|
||||||
|
const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
class InventoryMovementItem {
|
||||||
|
final int? id;
|
||||||
|
final int? movementId;
|
||||||
|
final int productId;
|
||||||
|
final double quantity;
|
||||||
|
final double? unitPrice;
|
||||||
|
|
||||||
|
InventoryMovementItem({
|
||||||
|
this.id,
|
||||||
|
this.movementId,
|
||||||
|
required this.productId,
|
||||||
|
required this.quantity,
|
||||||
|
this.unitPrice,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory InventoryMovementItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return InventoryMovementItem(
|
||||||
|
id: json['id'],
|
||||||
|
movementId: json['movementId'],
|
||||||
|
productId: json['productId'],
|
||||||
|
quantity: (json['quantity'] as num).toDouble(),
|
||||||
|
unitPrice: (json['unitPrice'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'movementId': movementId,
|
||||||
|
'productId': productId,
|
||||||
|
'quantity': quantity,
|
||||||
|
'unitPrice': unitPrice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InventoryMovement {
|
||||||
|
final int? id;
|
||||||
|
final int? userId;
|
||||||
|
final int locationId;
|
||||||
|
final String type;
|
||||||
|
final int? referenceTransactionId;
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
InventoryMovement({
|
||||||
|
this.id,
|
||||||
|
this.userId,
|
||||||
|
required this.locationId,
|
||||||
|
required this.type,
|
||||||
|
this.referenceTransactionId,
|
||||||
|
this.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory InventoryMovement.fromJson(Map<String, dynamic> json) {
|
||||||
|
return InventoryMovement(
|
||||||
|
id: json['id'],
|
||||||
|
userId: json['userId'],
|
||||||
|
locationId: json['locationId'],
|
||||||
|
type: json['type'],
|
||||||
|
referenceTransactionId: json['referenceTransactionId'],
|
||||||
|
notes: json['notes'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'userId': userId,
|
||||||
|
'locationId': locationId,
|
||||||
|
'type': type,
|
||||||
|
'referenceTransactionId': referenceTransactionId,
|
||||||
|
'notes': notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
120
kifi-app/lib/features/inventory/domain/product.dart
Normal file
120
kifi-app/lib/features/inventory/domain/product.dart
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
class Product {
|
||||||
|
final int? id;
|
||||||
|
final int? userId;
|
||||||
|
final int? categoryId;
|
||||||
|
final int? uomId;
|
||||||
|
final String name;
|
||||||
|
final String? sku;
|
||||||
|
final String? barcode;
|
||||||
|
final String? description;
|
||||||
|
final double? purchasePrice;
|
||||||
|
final double? sellingPrice;
|
||||||
|
final double? minStock;
|
||||||
|
final double? reorderLevel;
|
||||||
|
final double? gstRate;
|
||||||
|
final String? dimensions;
|
||||||
|
final double? weight;
|
||||||
|
final String? color;
|
||||||
|
final String? size;
|
||||||
|
final String priceCalcRule;
|
||||||
|
final bool autoCalculatePrice;
|
||||||
|
final double? purityFactor;
|
||||||
|
final double? makingCharges;
|
||||||
|
final String? makingChargesType;
|
||||||
|
final double? wastagePercentage;
|
||||||
|
final bool trackInventory;
|
||||||
|
final bool isActive;
|
||||||
|
final List<int> imageIds;
|
||||||
|
|
||||||
|
Product({
|
||||||
|
this.id,
|
||||||
|
this.userId,
|
||||||
|
this.categoryId,
|
||||||
|
this.uomId,
|
||||||
|
required this.name,
|
||||||
|
this.sku,
|
||||||
|
this.barcode,
|
||||||
|
this.description,
|
||||||
|
this.purchasePrice,
|
||||||
|
this.sellingPrice,
|
||||||
|
this.minStock,
|
||||||
|
this.reorderLevel,
|
||||||
|
this.gstRate,
|
||||||
|
this.dimensions,
|
||||||
|
this.weight,
|
||||||
|
this.color,
|
||||||
|
this.size,
|
||||||
|
this.priceCalcRule = 'MANUAL',
|
||||||
|
this.autoCalculatePrice = false,
|
||||||
|
this.purityFactor = 1.0,
|
||||||
|
this.makingCharges = 0.0,
|
||||||
|
this.makingChargesType = 'FLAT',
|
||||||
|
this.wastagePercentage = 0.0,
|
||||||
|
this.trackInventory = true,
|
||||||
|
this.isActive = true,
|
||||||
|
this.imageIds = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Product.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Product(
|
||||||
|
id: json['id'],
|
||||||
|
userId: json['userId'],
|
||||||
|
categoryId: json['categoryId'],
|
||||||
|
uomId: json['uomId'],
|
||||||
|
name: json['name'],
|
||||||
|
sku: json['sku'],
|
||||||
|
barcode: json['barcode'],
|
||||||
|
description: json['description'],
|
||||||
|
purchasePrice: (json['purchasePrice'] as num?)?.toDouble(),
|
||||||
|
sellingPrice: (json['sellingPrice'] as num?)?.toDouble(),
|
||||||
|
minStock: (json['minStock'] as num?)?.toDouble(),
|
||||||
|
reorderLevel: (json['reorderLevel'] as num?)?.toDouble(),
|
||||||
|
gstRate: (json['gstRate'] as num?)?.toDouble(),
|
||||||
|
dimensions: json['dimensions'],
|
||||||
|
weight: (json['weight'] as num?)?.toDouble(),
|
||||||
|
color: json['color'],
|
||||||
|
size: json['size'],
|
||||||
|
priceCalcRule: json['priceCalcRule'] ?? 'MANUAL',
|
||||||
|
autoCalculatePrice: json['autoCalculatePrice'] ?? false,
|
||||||
|
purityFactor: (json['purityFactor'] as num?)?.toDouble() ?? 1.0,
|
||||||
|
makingCharges: (json['makingCharges'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
makingChargesType: json['makingChargesType'] ?? 'FLAT',
|
||||||
|
wastagePercentage: (json['wastagePercentage'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
trackInventory: json['trackInventory'] ?? true,
|
||||||
|
isActive: json['isActive'] ?? true,
|
||||||
|
imageIds: json['images'] != null
|
||||||
|
? (json['images'] as List).map((i) => i['id'] as int).toList()
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'userId': userId,
|
||||||
|
'categoryId': categoryId,
|
||||||
|
'uomId': uomId,
|
||||||
|
'name': name,
|
||||||
|
'sku': sku,
|
||||||
|
'barcode': barcode,
|
||||||
|
'description': description,
|
||||||
|
'purchasePrice': purchasePrice,
|
||||||
|
'sellingPrice': sellingPrice,
|
||||||
|
'minStock': minStock,
|
||||||
|
'reorderLevel': reorderLevel,
|
||||||
|
'gstRate': gstRate,
|
||||||
|
'dimensions': dimensions,
|
||||||
|
'weight': weight,
|
||||||
|
'color': color,
|
||||||
|
'size': size,
|
||||||
|
'priceCalcRule': priceCalcRule,
|
||||||
|
'autoCalculatePrice': autoCalculatePrice,
|
||||||
|
'purityFactor': purityFactor,
|
||||||
|
'makingCharges': makingCharges,
|
||||||
|
'makingChargesType': makingChargesType,
|
||||||
|
'wastagePercentage': wastagePercentage,
|
||||||
|
'trackInventory': trackInventory,
|
||||||
|
'isActive': isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,812 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
import '../domain/product.dart';
|
||||||
|
import '../providers/products_provider.dart';
|
||||||
|
import '../providers/product_categories_provider.dart';
|
||||||
|
import '../../business/providers/business_provider.dart';
|
||||||
|
|
||||||
|
class AddProductScreen extends ConsumerStatefulWidget {
|
||||||
|
final Product? product;
|
||||||
|
const AddProductScreen({super.key, this.product});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AddProductScreen> createState() => _AddProductScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||||
|
final PageController _pageController = PageController();
|
||||||
|
int _currentPage = 0;
|
||||||
|
final int _totalPages = 4;
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
// Basic
|
||||||
|
String _name = '';
|
||||||
|
String _sku = '';
|
||||||
|
ProductCategory? _selectedCategory;
|
||||||
|
|
||||||
|
// Properties
|
||||||
|
String _color = '';
|
||||||
|
String _size = '';
|
||||||
|
String _dimensions = '';
|
||||||
|
double _weight = 0;
|
||||||
|
|
||||||
|
// Pricing & Inventory
|
||||||
|
double _purchasePrice = 0;
|
||||||
|
double _sellingPrice = 0;
|
||||||
|
double _gstRate = 0;
|
||||||
|
bool _trackInventory = true;
|
||||||
|
bool _autoCalculatePrice = false;
|
||||||
|
|
||||||
|
// Advanced Commodity Fields
|
||||||
|
double _purityFactor = 1.0;
|
||||||
|
double _makingCharges = 0;
|
||||||
|
String _makingChargesType = 'FLAT'; // FLAT, PER_UNIT, PERCENTAGE
|
||||||
|
double _wastagePercentage = 0;
|
||||||
|
|
||||||
|
// Media
|
||||||
|
final List<XFile> _images = [];
|
||||||
|
bool _isSaving = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.product != null) {
|
||||||
|
final p = widget.product!;
|
||||||
|
_name = p.name;
|
||||||
|
_sku = p.sku ?? '';
|
||||||
|
_color = p.color ?? '';
|
||||||
|
_size = p.size ?? '';
|
||||||
|
_dimensions = p.dimensions ?? '';
|
||||||
|
_weight = p.weight ?? 0;
|
||||||
|
_purchasePrice = p.purchasePrice ?? 0;
|
||||||
|
_sellingPrice = p.sellingPrice ?? 0;
|
||||||
|
_gstRate = p.gstRate ?? 0;
|
||||||
|
_trackInventory = p.trackInventory;
|
||||||
|
_autoCalculatePrice = p.autoCalculatePrice;
|
||||||
|
_purityFactor = p.purityFactor ?? 1.0;
|
||||||
|
_makingCharges = p.makingCharges ?? 0.0;
|
||||||
|
_makingChargesType = p.makingChargesType ?? 'FLAT';
|
||||||
|
_wastagePercentage = p.wastagePercentage ?? 0.0;
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final cats = ref.read(productCategoriesProvider).value ?? [];
|
||||||
|
if (cats.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCategory = cats.firstWhere((c) => c.id == p.categoryId, orElse: () => cats.first);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _nextPage() {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
if (_currentPage < _totalPages - 1) {
|
||||||
|
if (_currentPage == 0 && _selectedCategory == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a category.')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_pageController.animateToPage(_currentPage + 1, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
|
||||||
|
} else {
|
||||||
|
_save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _prevPage() {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
if (_currentPage > 0) {
|
||||||
|
_pageController.animateToPage(_currentPage - 1, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickImages() async {
|
||||||
|
if (_images.length >= 4) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final ImagePicker picker = ImagePicker();
|
||||||
|
final List<XFile> picked = await picker.pickMultiImage();
|
||||||
|
if (picked.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_images.addAll(picked.take(4 - _images.length));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeImage(int index) {
|
||||||
|
setState(() {
|
||||||
|
_images.removeAt(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddCategoryDialog() {
|
||||||
|
String newCatName = '';
|
||||||
|
bool newCatCommodity = false;
|
||||||
|
double newCatRate = 0;
|
||||||
|
String newCalcMethod = 'UNIT'; // WEIGHT, UNIT, VOLUME
|
||||||
|
String newBaseUnit = 'pcs';
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
|
title: const Text('New Category', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildPremiumTextField(
|
||||||
|
label: 'Category Name',
|
||||||
|
onChanged: (val) => newCatName = val,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Is this a Commodity?'),
|
||||||
|
subtitle: const Text('Enable for daily rate pricing (e.g. Gold)'),
|
||||||
|
value: newCatCommodity,
|
||||||
|
onChanged: (val) => setDialogState(() => newCatCommodity = val),
|
||||||
|
),
|
||||||
|
if (newCatCommodity) ...[
|
||||||
|
_buildPremiumDropdown(
|
||||||
|
label: 'Calculation Method',
|
||||||
|
value: newCalcMethod,
|
||||||
|
items: ['UNIT', 'WEIGHT', 'VOLUME'],
|
||||||
|
onChanged: (val) => setDialogState(() {
|
||||||
|
newCalcMethod = val!;
|
||||||
|
if (val == 'WEIGHT') newBaseUnit = 'gm';
|
||||||
|
else if (val == 'VOLUME') newBaseUnit = 'liter';
|
||||||
|
else newBaseUnit = 'pcs';
|
||||||
|
})
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildPremiumTextField(
|
||||||
|
label: 'Base Unit (e.g. gm, kg, pcs)',
|
||||||
|
initialValue: newBaseUnit,
|
||||||
|
onChanged: (val) => newBaseUnit = val,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildPremiumTextField(
|
||||||
|
label: 'Daily Rate',
|
||||||
|
prefixText: '₹',
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
onChanged: (val) => newCatRate = double.tryParse(val) ?? 0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
if (newCatName.trim().isEmpty) return;
|
||||||
|
final newCategory = ProductCategory(
|
||||||
|
name: newCatName.trim(),
|
||||||
|
isCommodity: newCatCommodity,
|
||||||
|
calculationMethod: newCalcMethod,
|
||||||
|
baseUnit: newBaseUnit,
|
||||||
|
dailyRate: newCatCommodity ? newCatRate : null,
|
||||||
|
);
|
||||||
|
await ref.read(productCategoriesProvider.notifier).createCategory(newCategory);
|
||||||
|
if (context.mounted) Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Create'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
_formKey.currentState!.save();
|
||||||
|
|
||||||
|
if (_selectedCategory == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a category')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isSaving = true);
|
||||||
|
try {
|
||||||
|
final product = Product(
|
||||||
|
name: _name,
|
||||||
|
sku: _sku.isNotEmpty ? _sku : null,
|
||||||
|
purchasePrice: _purchasePrice,
|
||||||
|
sellingPrice: _autoCalculatePrice ? _calculateLivePrice() : _sellingPrice,
|
||||||
|
gstRate: _gstRate,
|
||||||
|
weight: _weight,
|
||||||
|
color: _color,
|
||||||
|
size: _size,
|
||||||
|
dimensions: _dimensions,
|
||||||
|
priceCalcRule: _autoCalculatePrice ? 'COMMODITY' : 'MANUAL',
|
||||||
|
autoCalculatePrice: _autoCalculatePrice,
|
||||||
|
purityFactor: _purityFactor,
|
||||||
|
makingCharges: _makingCharges,
|
||||||
|
makingChargesType: _makingChargesType,
|
||||||
|
wastagePercentage: _wastagePercentage,
|
||||||
|
trackInventory: _trackInventory,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (widget.product != null) {
|
||||||
|
await ref.read(productsProvider.notifier).updateProduct(widget.product!.id!, product, newImages: _images);
|
||||||
|
} else {
|
||||||
|
await ref.read(productsProvider.notifier).createProduct(product, images: _images);
|
||||||
|
}
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e')));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isSaving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double _calculateLivePrice() {
|
||||||
|
if (_selectedCategory == null || !_selectedCategory!.isCommodity) return _sellingPrice;
|
||||||
|
double rate = _selectedCategory!.dailyRate ?? 0;
|
||||||
|
double baseVal = (_selectedCategory!.calculationMethod == 'WEIGHT') ? _weight : 1.0;
|
||||||
|
|
||||||
|
// Base Material Cost = (Weight + Wastage) * Rate * Purity
|
||||||
|
double materialWeight = baseVal + (baseVal * (_wastagePercentage / 100));
|
||||||
|
double materialCost = materialWeight * rate * _purityFactor;
|
||||||
|
|
||||||
|
// Making Charges
|
||||||
|
double making = 0;
|
||||||
|
if (_makingChargesType == 'FLAT') making = _makingCharges;
|
||||||
|
else if (_makingChargesType == 'PER_UNIT') making = _makingCharges * baseVal;
|
||||||
|
else if (_makingChargesType == 'PERCENTAGE') making = materialCost * (_makingCharges / 100);
|
||||||
|
|
||||||
|
return materialCost + making;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(widget.product != null ? 'Edit Product' : 'New Product', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: GestureDetector(
|
||||||
|
onTap: () => FocusScope.of(context).unfocus(),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildProgressPills(),
|
||||||
|
Expanded(
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: PageView(
|
||||||
|
controller: _pageController,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
onPageChanged: (idx) {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
setState(() => _currentPage = idx);
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
_buildBasicStep(),
|
||||||
|
_buildPropertiesStep(),
|
||||||
|
_buildPricingStep(),
|
||||||
|
_buildMediaStep(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildBottomBar(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProgressPills() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(_totalPages, (index) {
|
||||||
|
bool isActive = index <= _currentPage;
|
||||||
|
return Expanded(
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
margin: EdgeInsets.only(right: index == _totalPages - 1 ? 0 : 8),
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isActive ? Theme.of(context).colorScheme.primary : Colors.grey.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomBar() {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (_currentPage > 0)
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _isSaving ? null : _prevPage,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
child: const Icon(LucideIcons.chevronLeft),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_currentPage > 0) const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isSaving ? null : _nextPage,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: Theme.of(context).colorScheme.primary.withOpacity(0.4),
|
||||||
|
),
|
||||||
|
child: _isSaving
|
||||||
|
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||||
|
: Text(
|
||||||
|
_currentPage == _totalPages - 1
|
||||||
|
? (widget.product != null ? 'Update Product' : 'Publish Product')
|
||||||
|
: 'Continue',
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== STEPS ====
|
||||||
|
|
||||||
|
Widget _buildBasicStep() {
|
||||||
|
final categoriesState = ref.watch(productCategoriesProvider);
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Basic Information', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Let\'s start with the core details of your product.', style: TextStyle(color: Colors.grey)),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
_buildPremiumTextField(
|
||||||
|
label: 'Product Name*',
|
||||||
|
initialValue: _name,
|
||||||
|
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
||||||
|
onChanged: (val) => _name = val,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildPremiumTextField(
|
||||||
|
label: 'SKU / Barcode',
|
||||||
|
initialValue: _sku,
|
||||||
|
onChanged: (val) => _sku = val,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
const Text('Category', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
categoriesState.when(
|
||||||
|
loading: () => const CircularProgressIndicator(),
|
||||||
|
error: (err, stack) => Text('Error loading categories: $err'),
|
||||||
|
data: (categories) {
|
||||||
|
final leafCategories = categories.where((c) => !categories.any((child) => child.parentCategoryId == c.id)).toList();
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (leafCategories.isEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(color: Colors.orange.withOpacity(0.1), borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.alertCircle, color: Colors.orange),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Expanded(child: Text('No categories found. Create one to organize your inventory.', style: TextStyle(color: Colors.orange))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_buildPremiumDropdown<ProductCategory>(
|
||||||
|
label: 'Select Category*',
|
||||||
|
value: _selectedCategory,
|
||||||
|
items: leafCategories,
|
||||||
|
itemLabel: (c) {
|
||||||
|
String displayName = c.name;
|
||||||
|
if (c.parentCategoryId != null) {
|
||||||
|
final parent = categories.firstWhere((p) => p.id == c.parentCategoryId, orElse: () => c);
|
||||||
|
displayName = '${parent.name} > ${c.name}';
|
||||||
|
}
|
||||||
|
return displayName;
|
||||||
|
},
|
||||||
|
onChanged: (val) => setState(() => _selectedCategory = val),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _showAddCategoryDialog,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.plusCircle, size: 18, color: Theme.of(context).colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Create New Category', style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPropertiesStep() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Properties & Attributes', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Define the physical characteristics.', style: TextStyle(color: Colors.grey)),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
_buildPremiumTextField(
|
||||||
|
label: 'Weight',
|
||||||
|
initialValue: _weight == 0 ? '' : _weight.toString(),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
onChanged: (val) => setState(() => _weight = double.tryParse(val) ?? 0),
|
||||||
|
suffixText: _selectedCategory?.baseUnit ?? 'unit',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildPremiumTextField(
|
||||||
|
label: 'Color',
|
||||||
|
initialValue: _color,
|
||||||
|
onChanged: (val) => _color = val,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildPremiumTextField(
|
||||||
|
label: 'Size',
|
||||||
|
initialValue: _size,
|
||||||
|
onChanged: (val) => _size = val,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildPremiumTextField(
|
||||||
|
label: 'Dimensions',
|
||||||
|
initialValue: _dimensions,
|
||||||
|
onChanged: (val) => _dimensions = val,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPricingStep() {
|
||||||
|
final taxInclusive = ref.watch(businessProfileProvider).value?.taxIncludedInPrice ?? false;
|
||||||
|
final isCommodity = _selectedCategory?.isCommodity ?? false;
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Pricing & Inventory', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Set up costs, pricing rules, and tracking.', style: TextStyle(color: Colors.grey)),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
if (isCommodity) ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(colors: [Colors.blue.shade800, Colors.blue.shade500]),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.blue.withOpacity(0.3), blurRadius: 10, offset: const Offset(0, 4))],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(LucideIcons.activity, color: Colors.white),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text('Commodity Pricing', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
const Spacer(),
|
||||||
|
Switch(
|
||||||
|
value: _autoCalculatePrice,
|
||||||
|
activeColor: Colors.white,
|
||||||
|
onChanged: (val) => setState(() => _autoCalculatePrice = val),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Daily Rate: ₹${_selectedCategory!.dailyRate ?? 0} / ${_selectedCategory!.baseUnit}', style: const TextStyle(color: Colors.white70)),
|
||||||
|
if (_autoCalculatePrice) ...[
|
||||||
|
const Divider(color: Colors.white24, height: 32),
|
||||||
|
_buildPremiumTextField(
|
||||||
|
label: 'Purity Factor (e.g. 0.916 for 22K)',
|
||||||
|
initialValue: _purityFactor.toString(),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
onChanged: (val) => setState(() => _purityFactor = double.tryParse(val) ?? 1.0),
|
||||||
|
darkTheme: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildPremiumTextField(
|
||||||
|
label: 'Wastage Percentage (%)',
|
||||||
|
initialValue: _wastagePercentage.toString(),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
onChanged: (val) => setState(() => _wastagePercentage = double.tryParse(val) ?? 0.0),
|
||||||
|
darkTheme: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _buildPremiumTextField(
|
||||||
|
label: 'Making Charges',
|
||||||
|
initialValue: _makingCharges.toString(),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
onChanged: (val) => setState(() => _makingCharges = double.tryParse(val) ?? 0.0),
|
||||||
|
darkTheme: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: _buildPremiumDropdown<String>(
|
||||||
|
label: 'Type',
|
||||||
|
value: _makingChargesType,
|
||||||
|
items: const ['FLAT', 'PER_UNIT', 'PERCENTAGE'],
|
||||||
|
itemLabel: (t) => t == 'PER_UNIT' ? 'Per ${_selectedCategory!.baseUnit}' : t == 'PERCENTAGE' ? '%' : 'Flat',
|
||||||
|
onChanged: (val) => setState(() => _makingChargesType = val!),
|
||||||
|
darkTheme: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Live Selling Price:', style: TextStyle(color: Colors.white70)),
|
||||||
|
Text('₹${_calculateLivePrice().toStringAsFixed(2)}', style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (!_autoCalculatePrice) ...[
|
||||||
|
_buildPremiumTextField(
|
||||||
|
label: 'Purchase Price',
|
||||||
|
initialValue: _purchasePrice == 0 ? '' : _purchasePrice.toString(),
|
||||||
|
prefixText: '₹ ',
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
onChanged: (val) => _purchasePrice = double.tryParse(val) ?? 0,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildPremiumTextField(
|
||||||
|
label: taxInclusive ? 'Selling Price (Inc. Tax)*' : 'Selling Price (Exc. Tax)*',
|
||||||
|
initialValue: _sellingPrice == 0 ? '' : _sellingPrice.toString(),
|
||||||
|
prefixText: '₹ ',
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
||||||
|
onChanged: (val) => _sellingPrice = double.tryParse(val) ?? 0,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
|
|
||||||
|
_buildPremiumTextField(
|
||||||
|
label: 'GST Rate (%)',
|
||||||
|
initialValue: _gstRate == 0 ? '' : _gstRate.toString(),
|
||||||
|
suffixText: '%',
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
onChanged: (val) => _gstRate = double.tryParse(val) ?? 0,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: SwitchListTile(
|
||||||
|
title: const Text('Track Inventory', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
subtitle: const Text('Monitor stock levels automatically'),
|
||||||
|
value: _trackInventory,
|
||||||
|
onChanged: (val) => setState(() => _trackInventory = val),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMediaStep() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Product Images', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Upload up to 4 high-quality images.', style: TextStyle(color: Colors.grey)),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
if (_images.isNotEmpty)
|
||||||
|
GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
childAspectRatio: 1,
|
||||||
|
),
|
||||||
|
itemCount: _images.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Container(
|
||||||
|
color: Colors.grey[200],
|
||||||
|
child: Image.file(
|
||||||
|
File(_images[index].path),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _removeImage(index),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: const Icon(LucideIcons.x, color: Colors.white, size: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (_images.length < 4)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _pickImages,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primary.withOpacity(0.05),
|
||||||
|
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.3), style: BorderStyle.solid),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.uploadCloud, size: 48, color: Theme.of(context).colorScheme.primary),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Tap to Upload', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
Text('${4 - _images.length} slots remaining', style: const TextStyle(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== WIDGET HELPERS ====
|
||||||
|
|
||||||
|
Widget _buildPremiumTextField({
|
||||||
|
required String label,
|
||||||
|
String? initialValue,
|
||||||
|
String? prefixText,
|
||||||
|
String? suffixText,
|
||||||
|
TextInputType? keyboardType,
|
||||||
|
void Function(String)? onChanged,
|
||||||
|
void Function(String?)? onSaved,
|
||||||
|
String? Function(String?)? validator,
|
||||||
|
bool darkTheme = false,
|
||||||
|
}) {
|
||||||
|
return TextFormField(
|
||||||
|
initialValue: initialValue,
|
||||||
|
style: TextStyle(color: darkTheme ? Colors.white : null),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey[600]),
|
||||||
|
prefixText: prefixText,
|
||||||
|
prefixStyle: TextStyle(color: darkTheme ? Colors.white : Colors.black, fontWeight: FontWeight.bold),
|
||||||
|
suffixText: suffixText,
|
||||||
|
suffixStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey),
|
||||||
|
filled: true,
|
||||||
|
fillColor: darkTheme ? Colors.black26 : Colors.grey[100],
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide(color: darkTheme ? Colors.white : Theme.of(context).colorScheme.primary, width: 2)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
onChanged: onChanged,
|
||||||
|
onSaved: onSaved,
|
||||||
|
validator: validator,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPremiumDropdown<T>({
|
||||||
|
required String label,
|
||||||
|
required T? value,
|
||||||
|
required List<T> items,
|
||||||
|
String Function(T)? itemLabel,
|
||||||
|
required void Function(T?) onChanged,
|
||||||
|
bool darkTheme = false,
|
||||||
|
}) {
|
||||||
|
return DropdownButtonFormField<T>(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey[600]),
|
||||||
|
filled: true,
|
||||||
|
fillColor: darkTheme ? Colors.black26 : Colors.grey[100],
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide(color: darkTheme ? Colors.white : Theme.of(context).colorScheme.primary, width: 2)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dropdownColor: darkTheme ? Colors.blue.shade900 : null,
|
||||||
|
style: TextStyle(color: darkTheme ? Colors.white : Colors.black87, fontSize: 16),
|
||||||
|
value: value,
|
||||||
|
items: items.map((e) => DropdownMenuItem(
|
||||||
|
value: e,
|
||||||
|
child: Text(itemLabel != null ? itemLabel(e) : e.toString()),
|
||||||
|
)).toList(),
|
||||||
|
onChanged: onChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../providers/products_provider.dart';
|
||||||
|
import '../providers/product_categories_provider.dart';
|
||||||
|
import 'add_product_screen.dart';
|
||||||
|
import '../../../core/network/dio_client.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
class ProductListScreen extends ConsumerStatefulWidget {
|
||||||
|
const ProductListScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ProductListScreen> createState() => _ProductListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
String _searchQuery = '';
|
||||||
|
String? _token;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
_loadToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadToken() async {
|
||||||
|
_token = await const FlutterSecureStorage().read(key: 'jwt_token');
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
|
||||||
|
ref.read(productsProvider.notifier).fetchNextPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final productsState = ref.watch(productsProvider);
|
||||||
|
final categoriesState = ref.watch(productCategoriesProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Products Catalog'),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search products by name or SKU...',
|
||||||
|
prefixIcon: const Icon(LucideIcons.search),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = val.toLowerCase();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: productsState.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||||
|
data: (products) {
|
||||||
|
final filtered = products.where((p) => p.name.toLowerCase().contains(_searchQuery) || (p.sku != null && p.sku!.toLowerCase().contains(_searchQuery))).toList();
|
||||||
|
|
||||||
|
if (filtered.isEmpty) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
||||||
|
child: ListView(
|
||||||
|
children: const [
|
||||||
|
SizedBox(height: 100),
|
||||||
|
Center(child: Text('No products found.', style: TextStyle(color: Colors.grey)))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
itemCount: filtered.length + 1, // +1 for loading indicator
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == filtered.length) {
|
||||||
|
// We reached the end of the filtered list, show a loader if we are loading more
|
||||||
|
// The notifier state doesn't expose _isLoadingMore cleanly without another property,
|
||||||
|
// but if we are at the end, we can just return a tiny spacer.
|
||||||
|
return const SizedBox(height: 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
final p = filtered[index];
|
||||||
|
final catName = categoriesState.value?.firstWhere((c) => c.id == p.categoryId, orElse: () => categoriesState.value!.first).name ?? 'No Category';
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
elevation: 2,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => AddProductScreen(product: p)));
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildProductImage(p),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(color: Colors.blue.withOpacity(0.1), borderRadius: BorderRadius.circular(8)),
|
||||||
|
child: Text(catName, style: const TextStyle(color: Colors.blue, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
if (p.sku != null && p.sku!.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(p.sku!, style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'₹${(p.sellingPrice ?? 0).toStringAsFixed(2)}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
p.trackInventory ? 'Stock: 0' : 'Untracked', // We'll link stock later
|
||||||
|
style: TextStyle(
|
||||||
|
color: p.trackInventory ? Colors.orange : Colors.grey,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddProductScreen()));
|
||||||
|
},
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
child: const Icon(LucideIcons.plus, color: Colors.white),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProductImage(product) {
|
||||||
|
if (product.imageIds.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(LucideIcons.package, color: Colors.grey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${product.id}/images/${product.imageIds.first}/content';
|
||||||
|
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
color: Colors.grey[200],
|
||||||
|
child: _token == null
|
||||||
|
? const Icon(LucideIcons.image, color: Colors.grey)
|
||||||
|
: Image.network(
|
||||||
|
imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
headers: {'Authorization': 'Bearer $_token'},
|
||||||
|
errorBuilder: (context, error, stackTrace) => const Icon(LucideIcons.imageOff, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/network/dio_client.dart';
|
||||||
|
|
||||||
|
class ProductCategory {
|
||||||
|
final int? id;
|
||||||
|
final int? userId;
|
||||||
|
final String name;
|
||||||
|
final int? parentCategoryId;
|
||||||
|
final bool isCommodity;
|
||||||
|
final String calculationMethod;
|
||||||
|
final String baseUnit;
|
||||||
|
final double? dailyRate;
|
||||||
|
|
||||||
|
ProductCategory({
|
||||||
|
this.id,
|
||||||
|
this.userId,
|
||||||
|
required this.name,
|
||||||
|
this.parentCategoryId,
|
||||||
|
this.isCommodity = false,
|
||||||
|
this.calculationMethod = 'UNIT',
|
||||||
|
this.baseUnit = 'pcs',
|
||||||
|
this.dailyRate,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ProductCategory.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ProductCategory(
|
||||||
|
id: json['id'],
|
||||||
|
userId: json['userId'],
|
||||||
|
name: json['name'],
|
||||||
|
parentCategoryId: json['parentCategoryId'],
|
||||||
|
isCommodity: json['isCommodity'] ?? false,
|
||||||
|
calculationMethod: json['calculationMethod'] ?? 'UNIT',
|
||||||
|
baseUnit: json['baseUnit'] ?? 'pcs',
|
||||||
|
dailyRate: (json['dailyRate'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'userId': userId,
|
||||||
|
'name': name,
|
||||||
|
'parentCategoryId': parentCategoryId,
|
||||||
|
'isCommodity': isCommodity,
|
||||||
|
'calculationMethod': calculationMethod,
|
||||||
|
'baseUnit': baseUnit,
|
||||||
|
'dailyRate': dailyRate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProductCategoriesNotifier extends AsyncNotifier<List<ProductCategory>> {
|
||||||
|
@override
|
||||||
|
FutureOr<List<ProductCategory>> build() async {
|
||||||
|
return _fetchCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<ProductCategory>> _fetchCategories() async {
|
||||||
|
final response = await DioClient().dio.get('/inventory/categories');
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final List<dynamic> data = response.data;
|
||||||
|
return data.map((e) => ProductCategory.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> createCategory(ProductCategory category) async {
|
||||||
|
try {
|
||||||
|
await DioClient().dio.post(
|
||||||
|
'/inventory/categories',
|
||||||
|
data: category.toJson(),
|
||||||
|
);
|
||||||
|
state = AsyncValue.data(await _fetchCategories());
|
||||||
|
} catch (e) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final productCategoriesProvider = AsyncNotifierProvider<ProductCategoriesNotifier, List<ProductCategory>>(() {
|
||||||
|
return ProductCategoriesNotifier();
|
||||||
|
});
|
||||||
123
kifi-app/lib/features/inventory/providers/products_provider.dart
Normal file
123
kifi-app/lib/features/inventory/providers/products_provider.dart
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'dart:io';
|
||||||
|
import '../../../core/network/dio_client.dart';
|
||||||
|
import '../domain/product.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
class ProductsNotifier extends AsyncNotifier<List<Product>> {
|
||||||
|
int _currentPage = 0;
|
||||||
|
bool _hasMore = true;
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
final int _pageSize = 20;
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<List<Product>> build() async {
|
||||||
|
_currentPage = 0;
|
||||||
|
_hasMore = true;
|
||||||
|
return _fetchProducts(page: _currentPage, size: _pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Product>> _fetchProducts({required int page, required int size}) async {
|
||||||
|
final response = await DioClient().dio.get(
|
||||||
|
'/inventory/products',
|
||||||
|
queryParameters: {'page': page, 'size': size}
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final List<dynamic> data = response.data;
|
||||||
|
final products = data.map((e) => Product.fromJson(e)).toList();
|
||||||
|
if (products.length < size) {
|
||||||
|
_hasMore = false;
|
||||||
|
}
|
||||||
|
return products;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchNextPage() async {
|
||||||
|
if (!_hasMore || _isLoadingMore || state.isLoading) return;
|
||||||
|
|
||||||
|
_isLoadingMore = true;
|
||||||
|
try {
|
||||||
|
final currentList = state.value ?? [];
|
||||||
|
final nextPage = _currentPage + 1;
|
||||||
|
final newProducts = await _fetchProducts(page: nextPage, size: _pageSize);
|
||||||
|
|
||||||
|
_currentPage = nextPage;
|
||||||
|
state = AsyncValue.data([...currentList, ...newProducts]);
|
||||||
|
} catch (e, stack) {
|
||||||
|
// Don't override state with error, just keep the current list, but maybe show a toast.
|
||||||
|
} finally {
|
||||||
|
_isLoadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
_currentPage = 0;
|
||||||
|
_hasMore = true;
|
||||||
|
try {
|
||||||
|
state = AsyncValue.data(await _fetchProducts(page: _currentPage, size: _pageSize));
|
||||||
|
} catch (e, stack) {
|
||||||
|
state = AsyncValue.error(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> createProduct(Product product, {List<XFile>? images}) async {
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.post(
|
||||||
|
'/inventory/products',
|
||||||
|
data: product.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (images != null && images.isNotEmpty && response.data != null) {
|
||||||
|
final productId = response.data['id'];
|
||||||
|
for (var image in images) {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'file': await MultipartFile.fromFile(image.path, filename: image.name),
|
||||||
|
});
|
||||||
|
await DioClient().dio.post(
|
||||||
|
'/inventory/products/$productId/images',
|
||||||
|
data: formData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh list
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateProduct(int id, Product product, {List<XFile>? newImages}) async {
|
||||||
|
try {
|
||||||
|
await DioClient().dio.put(
|
||||||
|
'/inventory/products/$id',
|
||||||
|
data: product.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newImages != null && newImages.isNotEmpty) {
|
||||||
|
for (var image in newImages) {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'file': await MultipartFile.fromFile(image.path, filename: image.name),
|
||||||
|
});
|
||||||
|
await DioClient().dio.post(
|
||||||
|
'/inventory/products/$id/images',
|
||||||
|
data: formData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh list
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final productsProvider = AsyncNotifierProvider<ProductsNotifier, List<Product>>(() {
|
||||||
|
return ProductsNotifier();
|
||||||
|
});
|
||||||
2207
promptv2.md
Normal file
2207
promptv2.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user