diff --git a/backend/pom.xml b/backend/pom.xml index 7abf8a1..535e021 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -34,7 +34,6 @@ org.springframework.boot spring-boot-starter-web - org.springframework.boot spring-boot-devtools @@ -46,6 +45,15 @@ spring-boot-starter-test test + + org.springframework.boot + spring-boot-starter-validation + + + org.projectlombok + lombok + true + diff --git a/backend/src/main/java/com/inventory/backend/controller/ProductController.java b/backend/src/main/java/com/inventory/backend/controller/ProductController.java new file mode 100644 index 0000000..bd4f766 --- /dev/null +++ b/backend/src/main/java/com/inventory/backend/controller/ProductController.java @@ -0,0 +1,90 @@ +package com.inventory.backend.controller; + +import com.inventory.backend.dto.ProductDTO; +import com.inventory.backend.dto.ProductPage; +import com.inventory.backend.model.InventoryMetrics; +import com.inventory.backend.model.Product; +import com.inventory.backend.service.ProductService; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +@RestController +@RequestMapping("/products") +@CrossOrigin(origins = "http://localhost:8080") +public class ProductController { + private final ProductService service; + + public ProductController(ProductService service) { + this.service = service; + } + + // GET /products + @GetMapping + public ProductPage getProducts( + @RequestParam Optional name, + @RequestParam Optional> category, + @RequestParam Optional availability, + @RequestParam Optional sortBy, + @RequestParam Optional sortBy2, + @RequestParam(defaultValue = "true") boolean asc, + @RequestParam(defaultValue = "true") boolean asc2, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ){ + Set categorySet = category.map(HashSet::new).orElse(null); + return service.getFilteredProductsPage( + name, + Optional.ofNullable(categorySet), + availability, + sortBy, + sortBy2, + asc, + asc2, + page, + size + ); + } + + // POST /products + @PostMapping + public Product createProduct(@RequestBody @Valid ProductDTO dto) { + return service.createProduct(dto); + } + + // PUT /products/{id} + @PutMapping("/{id}") + public Product updateProduct(@PathVariable UUID id, @RequestBody @Valid ProductDTO dto) { + return service.updateProduct(id,dto); + } + + // DELETE /products/{id} + @DeleteMapping("/{id}") + public void deleteProduct(@PathVariable UUID id) { + service.deleteProduct(id); + } + + // POST /products/{id}/outofstock + @PostMapping("/{id}/outofstock") + public void markOutOfStock(@PathVariable UUID id) { + service.markOutOfStock(id); + } + + // PUT /products/{id}/instock?defaultQuantity=10 + @PutMapping("/{id}/instock") + public void markInStock(@PathVariable UUID id, @RequestParam(defaultValue = "10") int defaultQuantity) { + service.markInStock(id, defaultQuantity); + } + + // GET /products/metrics + @GetMapping("/metrics") + public InventoryMetrics getMetrics() { + return service.getMetrics(); + } + + @GetMapping("/categories") + public Set getCategories(){ + return service.getAllCategories(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/inventory/backend/dto/ProductDTO.java b/backend/src/main/java/com/inventory/backend/dto/ProductDTO.java new file mode 100644 index 0000000..fe61fc8 --- /dev/null +++ b/backend/src/main/java/com/inventory/backend/dto/ProductDTO.java @@ -0,0 +1,62 @@ +package com.inventory.backend.dto; + +import jakarta.validation.constraints.*; +import java.time.LocalDate; + +public class ProductDTO { + @NotBlank(message = "Name is required") + @Size(max = 120, message = "Name must not exceed 120 characters") + private String name; + + @NotBlank(message = "Category is required") + private String category; + + @NotNull(message = "Unit price is required") + @PositiveOrZero(message = "Unit price must be 0 or more") + private Double unitPrice; + + @PositiveOrZero(message = "Stock must be 0 or more") + private Integer quantityInStock; + + private LocalDate expirationDate; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public Double getUnitPrice() { + return unitPrice; + } + + public void setUnitPrice(Double unitPrice) { + this.unitPrice = unitPrice; + } + + public Integer getQuantityInStock() { + return quantityInStock; + } + + public void setQuantityInStock(Integer quantityInStock) { + this.quantityInStock = quantityInStock; + } + + public LocalDate getExpirationDate() { + return expirationDate; + } + + public void setExpirationDate(LocalDate expirationDate) { + this.expirationDate = expirationDate; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/inventory/backend/dto/ProductPage.java b/backend/src/main/java/com/inventory/backend/dto/ProductPage.java new file mode 100644 index 0000000..5dacf88 --- /dev/null +++ b/backend/src/main/java/com/inventory/backend/dto/ProductPage.java @@ -0,0 +1,30 @@ +package com.inventory.backend.dto; + +import com.inventory.backend.model.Product; +import java.util.List; + +public class ProductPage { + private List items; + private long total; + + public ProductPage(List items, long total) { + this.items = items; + this.total = total; + } + + public List getItems(){ + return items; + } + + public void setItems(List items) { + this.items = items; + } + + public long getTotal() { + return total; + } + + public void setTotal(long total) { + this.total = total; + } +} diff --git a/backend/src/main/java/com/inventory/backend/model/InventoryMetrics.java b/backend/src/main/java/com/inventory/backend/model/InventoryMetrics.java new file mode 100644 index 0000000..a4c1f58 --- /dev/null +++ b/backend/src/main/java/com/inventory/backend/model/InventoryMetrics.java @@ -0,0 +1,124 @@ +package com.inventory.backend.model; + +import java.util.*; +import java.util.stream.Collectors; + +public class InventoryMetrics { + private int totalInStock; + private double totalValue; + private double averagePrice; + + private Map byCategory; + + public InventoryMetrics() {} + + public InventoryMetrics(int totalInStock, double totalValue, double averagePrice, Map byCategory) { + this.totalInStock = totalInStock; + this.totalValue = totalValue; + this.averagePrice = averagePrice; + this.byCategory = byCategory; + } + + public static InventoryMetrics fromProductList(List products) { + int totalStock = products.stream().mapToInt(Product::getQuantityInStock).sum(); + + double totalValue = products.stream() + .mapToDouble(Product::getTotalValue) + .sum(); + + List inStock = products.stream() + .filter(p -> p.getQuantityInStock() > 0) + .collect(Collectors.toList()); + + double averagePrice = inStock.isEmpty() ? 0: + inStock.stream().mapToDouble(Product::getUnitPrice).average().orElse(0); + + Map categoryMetrics = products.stream() + .collect(Collectors.groupingBy( + Product::getCategory, + Collectors.collectingAndThen(Collectors.toList(), CategoryMetrics::fromProductList) + )); + return new InventoryMetrics(totalStock, totalValue, averagePrice, categoryMetrics); + } + + public int getTotalInStock() { + return totalInStock; + } + + public void setTotalInStock(int totalInStock) { + this.totalInStock = totalInStock; + } + + public double getTotalValue() { + return totalValue; + } + + public void setTotalValue(double totalValue) { + this.totalValue = totalValue; + } + + public double getAveragePrice() { + return averagePrice; + } + + public void setAveragePrice(double averagePrice) { + this.averagePrice = averagePrice; + } + + public Map getByCategory() { + return byCategory; + } + + public void setByCategory(Map byCategory) { + this.byCategory = byCategory; + } + + public static class CategoryMetrics { + private int inStock; + private double totalValue; + private double averagePrice; + + public static CategoryMetrics fromProductList(List products) { + int stock = products.stream().mapToInt(Product::getQuantityInStock).sum(); + double value = products.stream().mapToDouble(Product::getTotalValue).sum(); + + List inStock = products.stream().filter(p -> p.getQuantityInStock() > 0).toList(); + double avg = inStock.isEmpty() ? 0 : + inStock.stream().mapToDouble(Product::getUnitPrice).average().orElse(0); + + return new CategoryMetrics(stock, value, avg); + } + + public CategoryMetrics() {} + + public CategoryMetrics(int inStock, double totalValue, double averagePrice) { + this.inStock = inStock; + this.totalValue = totalValue; + this.averagePrice = averagePrice; + } + + public double getTotalValue() { + return totalValue; + } + + public void setTotalValue(double totalValue) { + this.totalValue = totalValue; + } + + public double getAveragePrice() { + return averagePrice; + } + + public void setAveragePrice(double averagePrice) { + this.averagePrice = averagePrice; + } + + public int getInStock() { + return inStock; + } + + public void setInStock(int inStock) { + this.inStock = inStock; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/inventory/backend/model/Product.java b/backend/src/main/java/com/inventory/backend/model/Product.java index 32a9197..7695688 100644 --- a/backend/src/main/java/com/inventory/backend/model/Product.java +++ b/backend/src/main/java/com/inventory/backend/model/Product.java @@ -1,6 +1,5 @@ package com.inventory.backend.model; -import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.UUID; @@ -9,9 +8,94 @@ public class Product { private UUID id; private String name; private String category; - private BigDecimal number; - private LocalDate expirationDate; + private double unitPrice; private int quantityInStock; + private LocalDate expirationDate; private LocalDateTime createdAt; private LocalDateTime updatedAt; + + public Product(){} + + public Product(UUID id, String name, String category, double unitPrice, int quantityInStock, LocalDate expirationDate) { + this.id = id; + this.name = name; + this.category = category; + this.unitPrice = unitPrice; + this.quantityInStock = quantityInStock; + this.expirationDate = expirationDate; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public double getUnitPrice() { + return unitPrice; + } + + public void setUnitPrice(double unitPrice) { + this.unitPrice = unitPrice; + } + + public int getQuantityInStock() { + return quantityInStock; + } + + public void setQuantityInStock(int quantityInStock) { + this.quantityInStock = quantityInStock; + } + + public LocalDate getExpirationDate() { + return expirationDate; + } + + public void setExpirationDate(LocalDate expirationDate) { + this.expirationDate = expirationDate; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public boolean isInStock() { + return quantityInStock > 0; + } + + public double getTotalValue() { + return unitPrice * quantityInStock; + } } \ No newline at end of file diff --git a/backend/src/main/java/com/inventory/backend/repository/ProductRepository.java b/backend/src/main/java/com/inventory/backend/repository/ProductRepository.java new file mode 100644 index 0000000..d61cd93 --- /dev/null +++ b/backend/src/main/java/com/inventory/backend/repository/ProductRepository.java @@ -0,0 +1,49 @@ +package com.inventory.backend.repository; + +import com.inventory.backend.model.Product; +import org.springframework.stereotype.Repository; + +import java.util.*; +import java.util.stream.Collectors; + +@Repository +public class ProductRepository { + private final Map products = new HashMap<>(); + + public List findAll(){ + return new ArrayList<>(products.values()); + } + + public Optional findById(UUID id) { + return Optional.ofNullable(products.get(id)); + } + + public Product save(Product product) { + products.put(product.getId(), product); + return product; + } + + public void delete(UUID id) { + products.remove(id); + } + + public boolean existsById(UUID id) { + return products.containsKey(id); + } + + public void clear() { + products.clear(); + } + + public List findByNameOrCategoryOrAvailability( + Optional nameFilter, + Optional> categoryFilter, + Optional availability + ) { + return products.values().stream() + .filter(p -> nameFilter.map(name -> p.getName().toLowerCase().contains(name.toLowerCase())).orElse(true)) + .filter(p -> categoryFilter.map(categories -> categories.contains(p.getCategory())).orElse(true)) + .filter(p -> availability.map(avail -> avail == p.isInStock()).orElse(true)) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/inventory/backend/service/ProductService.java b/backend/src/main/java/com/inventory/backend/service/ProductService.java new file mode 100644 index 0000000..23ac353 --- /dev/null +++ b/backend/src/main/java/com/inventory/backend/service/ProductService.java @@ -0,0 +1,159 @@ +package com.inventory.backend.service; + +import com.inventory.backend.dto.ProductDTO; +import com.inventory.backend.dto.ProductPage; +import com.inventory.backend.model.Product; +import com.inventory.backend.model.InventoryMetrics; +import com.inventory.backend.repository.ProductRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class ProductService { + private final ProductRepository repository; + + public ProductService(ProductRepository repository) { + this.repository = repository; + } + + public List getFilteredAndSortedProducts( + Optional nameFilter, + Optional> categoryFilter, + Optional availability, + Optional sortBy, + Optional sortBy2, + boolean ascending, + boolean ascending2, + int page, + int size + ){ + List filtered = repository.findByNameOrCategoryOrAvailability(nameFilter, categoryFilter,availability); + + Comparator comparator = getComparator(sortBy.orElse(null), ascending); + if(sortBy2.isPresent()) { + comparator = comparator.thenComparing(getComparator(sortBy2.get(), ascending2)); + } + + return filtered.stream() + .sorted(comparator) + .skip((long) page * size) + .limit(size) + .collect(Collectors.toList()); + } + public ProductPage getFilteredProductsPage( + Optional nameFilter, + Optional> categoryFilter, + Optional availability, + Optional sortBy, + Optional sortBy2, + boolean ascending, + boolean ascending2, + int page, + int size + ){ + List filtered = repository.findByNameOrCategoryOrAvailability(nameFilter, categoryFilter,availability); + + Comparator comparator = getComparator(sortBy.orElse(null), ascending); + if(sortBy2.isPresent()) { + comparator = comparator.thenComparing(getComparator(sortBy2.get(), ascending2)); + } + + long total = filtered.size(); + + List pageItems = filtered.stream() + .sorted(comparator) + .skip((long) page * size) + .limit(size) + .toList(); + + return new ProductPage(pageItems, total); + + } + + private Comparator getComparator(String sortField, boolean ascending) { + Comparator comparator; + + switch (sortField) { + case "name": + comparator = Comparator.comparing(Product::getName, String.CASE_INSENSITIVE_ORDER); + break; + case "category": + comparator = Comparator.comparing(Product::getCategory, String.CASE_INSENSITIVE_ORDER); + break; + case "unitPrice": + comparator = Comparator.comparingDouble(Product::getUnitPrice); + break; + case "quantityInStock": + comparator = Comparator.comparingInt(Product::getQuantityInStock); + break; + case "expirationDate": + comparator = Comparator.comparing(p -> Optional.ofNullable(p.getExpirationDate()).orElse(null), + Comparator.nullsLast(Comparator.naturalOrder())); + break; + default: + comparator = Comparator.comparing(Product::getName); + } + return ascending ? comparator : comparator.reversed(); + } + + public Product createProduct(ProductDTO dto) { + Product product = new Product( + UUID.randomUUID(), + dto.getName(), + dto.getCategory(), + dto.getUnitPrice(), + dto.getQuantityInStock() != null ? dto.getQuantityInStock() : 0, + dto.getExpirationDate() + ); + return repository.save(product); + } + + public Product updateProduct(UUID id, ProductDTO dto) { + Product product = repository.findById(id).orElseThrow(() -> new NoSuchElementException("Product not found")); + + product.setName(dto.getName()); + product.setCategory(dto.getCategory()); + product.setUnitPrice(dto.getUnitPrice()); + product.setQuantityInStock(dto.getQuantityInStock()); + product.setExpirationDate(dto.getExpirationDate()); + product.setUpdatedAt(LocalDateTime.now()); + + return repository.save(product); + } + + public void deleteProduct(UUID id) { + if(!repository.existsById(id)) { + throw new NoSuchElementException("Product not found"); + } + repository.delete(id); + } + + public void markOutOfStock(UUID id) { + Product product = repository.findById(id).orElseThrow(() -> new NoSuchElementException("Product not found")); + product.setQuantityInStock(0); + product.setUpdatedAt(LocalDateTime.now()); + repository.save(product); + } + + public void markInStock(UUID id, int defaultQuantity) { + Product product = repository.findById(id).orElseThrow(() -> new NoSuchElementException("Product not found")); + product.setQuantityInStock(defaultQuantity); + product.setUpdatedAt(LocalDateTime.now()); + repository.save(product); + } + + public InventoryMetrics getMetrics() { + List all = repository.findAll(); + return InventoryMetrics.fromProductList(all); + } + + public Set getAllCategories() { + return repository.findAll().stream() + .map(Product::getCategory) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(TreeSet::new)); + } +} \ No newline at end of file diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 3ca17a4..661d54c 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1 +1,2 @@ spring.application.name=backend +server.port=9090 diff --git a/backend/src/test/java/com/inventory/backend/service/ProductServiceTest.java b/backend/src/test/java/com/inventory/backend/service/ProductServiceTest.java new file mode 100644 index 0000000..6f80f6a --- /dev/null +++ b/backend/src/test/java/com/inventory/backend/service/ProductServiceTest.java @@ -0,0 +1,55 @@ +package com.inventory.backend.service; + +import com.inventory.backend.dto.ProductDTO; +import com.inventory.backend.model.Product; +import com.inventory.backend.repository.ProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + + +public class ProductServiceTest { + private ProductRepository repository; + private ProductService service; + + @BeforeEach + void setUp() { + repository = new ProductRepository(); + service = new ProductService(repository); + } + + @Test + void createProduct_shouldAddProduct() { + ProductDTO dto = new ProductDTO(); + dto.setName("Keyboard"); + dto.setCategory("Electronics"); + dto.setUnitPrice(95.99); + dto.setQuantityInStock(2); + + Product created = service.createProduct(dto); + + assertNotNull(created.getId()); + assertEquals("Keyboard", created.getName()); + assertEquals(1, repository.findAll().size()); + } + + @Test + void markOutOfStock_shouldSetQuantityToZero() { + ProductDTO dto = new ProductDTO(); + dto.setName("Mouse"); + dto.setCategory("Electronics"); + dto.setUnitPrice(10.50); + dto.setQuantityInStock(10); + + Product created = service.createProduct(dto); + UUID id = created.getId(); + + service.markOutOfStock(id); + + Product updated = repository.findById(id).orElseThrow(); + assertEquals(0, updated.getQuantityInStock()); + } +} diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..d63aadf --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +REACT_APP_API_URL=http://localhost:9090 \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f526601..729f7f9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,11 +16,28 @@ "@types/node": "^16.18.126", "@types/react": "^19.1.7", "@types/react-dom": "^19.1.6", +<<<<<<< HEAD + "autoprefixer": "^10.4.21", + "axios": "^1.10.0", + "postcss": "^8.5.6", +======= + "axios": "^1.10.0", +>>>>>>> fullstack "react": "^19.1.0", "react-dom": "^19.1.0", "react-scripts": "5.0.1", + "tailwindcss": "^4.1.10", "typescript": "^4.9.5", "web-vitals": "^2.1.4" + }, + "devDependencies": { +<<<<<<< HEAD + "@types/uuid": "^10.0.0" +======= + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17" +>>>>>>> fullstack } }, "node_modules/@adobe/css-tools": { @@ -3932,6 +3949,13 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -4926,6 +4950,33 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -5312,9 +5363,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -8106,9 +8157,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -12247,9 +12298,9 @@ } }, "node_modules/postcss": { - "version": "8.5.4", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz", - "integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -13679,6 +13730,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -14043,6 +14100,55 @@ } } }, + "node_modules/react-scripts/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/react-scripts/node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -15649,9 +15755,9 @@ } }, "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -15909,53 +16015,10 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "3.4.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", - "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.6", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tailwindcss/node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz", + "integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==", + "license": "MIT" }, "node_modules/tapable": { "version": "2.2.2", diff --git a/frontend/package.json b/frontend/package.json index e06d41a..4db3087 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,9 +12,17 @@ "@types/node": "^16.18.126", "@types/react": "^19.1.7", "@types/react-dom": "^19.1.6", +<<<<<<< HEAD + "autoprefixer": "^10.4.21", + "axios": "^1.10.0", + "postcss": "^8.5.6", +======= + "axios": "^1.10.0", +>>>>>>> fullstack "react": "^19.1.0", "react-dom": "^19.1.0", "react-scripts": "5.0.1", + "tailwindcss": "^4.1.10", "typescript": "^4.9.5", "web-vitals": "^2.1.4" }, @@ -42,7 +50,7 @@ "last 1 safari version" ] }, - "jest": { + "jest": { "collectCoverageFrom": [ "src/**/*.tsx" ], @@ -55,5 +63,14 @@ "html", "text" ] + }, + "devDependencies": { +<<<<<<< HEAD + "@types/uuid": "^10.0.0" +======= + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17" +>>>>>>> fullstack } } diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a53698a..6003503 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,25 +1,10 @@ import React from 'react'; -import logo from './logo.svg'; import './App.css'; +import ProductListPage from './pages/ProductListPage'; function App() { return ( -
-
- logo -

- Edit src/App.tsx and save to reload. -

- - Learn React - -
-
+ ); } diff --git a/frontend/src/components/PaginationControls.tsx b/frontend/src/components/PaginationControls.tsx new file mode 100644 index 0000000..a762910 --- /dev/null +++ b/frontend/src/components/PaginationControls.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +interface Props { + filters: any; + setFilters: (filters: any) => void; + total: number; +} + +const PaginationControls: React.FC = ({filters, setFilters, total}) => { + const page = filters.page || 0; + const size = filters.size || 10; + const totalPages = Math.ceil(total / size); + + const goToPage = (newPage: number) => { + setFilters({...filters, page: newPage}); + }; + + return ( +
+ + + Page {page + 1} of {totalPages} + + +
+ ); +}; + +export default PaginationControls; \ No newline at end of file diff --git a/frontend/src/components/ProductFilters.tsx b/frontend/src/components/ProductFilters.tsx new file mode 100644 index 0000000..86ffb28 --- /dev/null +++ b/frontend/src/components/ProductFilters.tsx @@ -0,0 +1,86 @@ +import React, {useState, useEffect, useCallback} from "react"; +import { getCategories } from "../services/productService"; + +interface Props { + filters: any; + setFilters: (filters: any) => void; +} + +const ProductFilters: React.FC = ({filters, setFilters}) => { + const [name, setName] = useState(filters.name || ""); + const [category, setCategory] = useState(filters.category || []); + const [availability, setAvailability] = useState(filters.availability || ""); + + const [allCategories, setAllCategories] = useState ([]); + + useEffect(() => { + getCategories().then(setAllCategories).catch(err => { + console.error("Error getting categories"); + setAllCategories([]); + }); + }, []); + + const handleApply = useCallback( () => { + setFilters((prevFilters: any) =>({ + ...prevFilters, + name, + category, + availability: availability === "" ? "" : availability === "in", + page: 0, + })); + }, [setFilters, name, category, availability]); + + const toggleCategory = (value: string) => { + setCategory(prev => + prev.includes(value) + ? prev.filter(c => c !== value) + : [...prev, value] + ); + }; + + useEffect(() => { + handleApply(); + }, [handleApply]); + + return ( +
+
+ + setName(e.target.value)} + className="w-full p-2 border rounded" + placeholder="Product name..." /> +
+ +
+ +
+ {allCategories.map(cat => ( + + ))} +
+
+ +
+ + +
+
+ ); +}; + +export default ProductFilters \ No newline at end of file diff --git a/frontend/src/components/ProductFormModal.tsx b/frontend/src/components/ProductFormModal.tsx new file mode 100644 index 0000000..e7041ae --- /dev/null +++ b/frontend/src/components/ProductFormModal.tsx @@ -0,0 +1,151 @@ +import React, {useEffect, useState} from "react"; +import { Product, ProductDTO } from "../types/Product"; +import { createProduct, updateProduct } from "../services/productService"; + +interface Props{ + isOpen: boolean; + onClose:() => void; + onSucces: () => void; + initialData?: Product; +} + +const ProductFormModal: React.FC = ({isOpen, onClose, onSucces, initialData}) => { + const [form, setForm] = useState({ + name: "", + category: "", + unitPrice: 0, + quantityInStock: 0, + expirationDate: "", + }); + + const [errors, setErrors] = useState>({}); + + useEffect(() => { + if(initialData) { + setForm({ + name: initialData.name, + category: initialData.category, + unitPrice: initialData.unitPrice, + quantityInStock: initialData.quantityInStock, + expirationDate: initialData.expirationDate || "", + }); + } else { + setForm({ + name: "", + category: "", + unitPrice: 0, + quantityInStock: 0, + expirationDate: "", + }); + } + setErrors({}); + }, [initialData, isOpen]); + + if(!isOpen) return null; + + const handleChange = (e: React.ChangeEvent) => { + const {name, value} = e.target; + setForm({...form, [name]: name ==="unitPrice" || name === "quantityInStock" ? Number(value) : value}); + }; + + const validate = (): boolean => { + const newErrors: Record ={}; + if(!form.name.trim()) newErrors.name = "Name is required"; + if(!form.category.trim()) newErrors.category = "Category is required"; + if(form.unitPrice < 0) newErrors.unitPrice = "Price can't less than 0"; + if (form.quantityInStock < 0) newErrors.quantityInStock = "Stock can't be less than 0"; + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async () => { + if(!validate()) return; + + try { + if(initialData) { + await updateProduct(initialData.id, form); + } else { + await createProduct(form); + } + onSucces(); + onClose(); + }catch (error) { + alert("Error saving product"); + console.error(error); + } + }; + + return ( +
+
+

+ {initialData ? "Edit product" : "New product"} +

+ +
+
+ + + {errors.name &&

{errors.name}

} +
+ +
+ + + {errors.category &&

{errors.category}

} +
+ +
+
+ + + {errors.unitPrice &&

{errors.unitPrice}

} +
+ +
+ + + {errors.category &&

{errors.category}

} +
+
+ +
+ + +
+
+ +
+ + +
+
+
+ ); +}; + +export default ProductFormModal; \ No newline at end of file diff --git a/frontend/src/components/ProductTable.tsx b/frontend/src/components/ProductTable.tsx new file mode 100644 index 0000000..01b6753 --- /dev/null +++ b/frontend/src/components/ProductTable.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { Product } from "../types/Product"; +import { markOutOfStock, markInStock, deleteProduct } from "../services/productService"; + +interface Props{ + products: Product[]; + filters: any; + setFilters: (f: any) => void; + onEdit: (product: Product) => void; +} + +const ProductTable: React.FC = ({products, filters, setFilters, onEdit}) => { + const handleSort = (field: string, secondary = false) => { + if(secondary) { + setFilters({ ...filters, sortBy2: field, asc2: !filters.asc2}); + }else { + setFilters({...filters, sortBy: field, asc: !filters.asc}); + } + }; + + const toggleStock = async (product: Product) => { + if(product.quantityInStock === 0) { + await markInStock(product.id); + } else { + await markOutOfStock(product.id); + } + setFilters({ ...filters}); + } + + const handleDelete = async (id:string) => { + if(window.confirm("Are you sure you want to delete this product?")) { + await deleteProduct(id); + setFilters({...filters}); + } + }; + + const getExpirationColor = (exp?: string) => { + if(!exp) return ""; + const now = new Date(); + const date = new Date(exp); + const diff = (date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); + if ( diff < 7) return "bg-red-100"; + if (diff <14) return "bg-yellow-100"; + return "bg-green-100"; + }; + + const getStockColor = (stock: number) => { + if(stock === 0) return "text-gray-400 line-through"; + if(stock < 5) return "text-red-600"; + if(stock <= 10) return "text-yellow-500"; + return ""; + }; + + return( + + + + + + + + + + + + + + {products.map(p => ( + + + + + + + + + + ))} + {products.length === 0 && ( + + )} + +
handleSort("name")} className="cursor-pointer">Name handleSort("category")} className="cursor-pointer">Category handleSort("unitPrice")} className="cursor-pointer">Price handleSort("quantityInStock")} className="cursor-pointer">Stock handleSort("expirationDate")} className="cursor-pointer">Expiration dateActions
+ toggleStock(p)}/> + {p.name}{p.category}${p.unitPrice.toFixed(2)}{p.quantityInStock}{p.expirationDate ?? "-"} + + +
No available products :C
+ ); +}; + +export default ProductTable; \ No newline at end of file diff --git a/frontend/src/components/__tests__/ProductFlow.test.tsx b/frontend/src/components/__tests__/ProductFlow.test.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/index.css b/frontend/src/index.css index ec2585e..7eb87c9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,7 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', diff --git a/frontend/src/pages/ProductListPage.tsx b/frontend/src/pages/ProductListPage.tsx new file mode 100644 index 0000000..e488842 --- /dev/null +++ b/frontend/src/pages/ProductListPage.tsx @@ -0,0 +1,99 @@ +import React, {useCallback, useEffect, useState} from "react"; +import { getProducts, getMetrics } from "../services/productService"; +import { Product, InventoryMetrics } from "../types/Product"; +import ProductTable from "../components/ProductTable"; +import ProductFilters from "../components/ProductFilters"; +import PaginationControls from "../components/PaginationControls"; +import ProductFormModal from "../components/ProductFormModal"; + +const ProductListPage: React.FC = () => { + const [products, setProducts] = useState([]); + const [metrics, setMetrics] = useState(null); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [modalOpen, setModalOpen] = useState(false); + const [editingProduct, setEditingProducts] = useState(); + + const [filters, setFilters] = useState({ + name: "", + category: [] as string[], + availability: "", + sortBy: "name", + sortBy2: "", + asc: true, + asc2: true, + page: 0, + size: 10, + }); + + const fetchData = useCallback( async () => { + setLoading(true); + try { + const data = await getProducts(filters); + setProducts(data.items); + setTotal(data.total); + const m = await getMetrics(); + setMetrics(m); + }catch (err) { + console.log("Error loading products", err); + } finally {setLoading(false);} + }, [filters]); + + const handleEdit = (product: Product) => { + setEditingProducts(product); + setModalOpen(true); + } + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return( +
+

Inventory

+ + + +
+ +
+ + setModalOpen(false)} + onSucces={fetchData} + initialData={editingProduct} /> + + {loading ? ( +

Loading...

+ ) : ( + <> + + + + )} + + {metrics && ( +
+

General Metrics

+

Total in stock: {metrics.totalInStock}

+

Total Value: {metrics.totalValue.toFixed(2)}

+

Average price: ${metrics.averagePrice.toFixed(2)}

+
+ )} +
+ ); +}; + +export default ProductListPage; \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..2b456f0 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,7 @@ +import axios from "axios"; + +const api = axios.create({ + baseURL: process.env.REACT_APP_API_URL, +}); + +export default api; \ No newline at end of file diff --git a/frontend/src/services/productService.ts b/frontend/src/services/productService.ts new file mode 100644 index 0000000..18c9c77 --- /dev/null +++ b/frontend/src/services/productService.ts @@ -0,0 +1,39 @@ +import api from "./api"; +import { Product, ProductDTO, InventoryMetrics } from "../types/Product"; + +export const getProducts = async (params: any): Promise<{items: Product[], total: number}> => { + const response = await api.get("/products", {params}); + return response.data; +}; + +export const createProduct = async (product: ProductDTO): Promise => { + const response = await api.post("/products", product); + return response.data; +}; + +export const updateProduct = async (id: string, product: ProductDTO): Promise => { + const response = await api.put(`/products/${id}`, product); + return response.data; +}; + +export const deleteProduct = async (id: string): Promise => { + await api.delete(`/products/${id}`); +}; + +export const markOutOfStock = async (id: string): Promise => { + await api.post(`/products/${id}/outofstock`); +}; + +export const markInStock = async (id: string, quantity: number = 10): Promise => { + await api.post(`/products/${id}/instock?defaultQuantity=${quantity}`); +}; + +export const getMetrics = async (): Promise => { + const response = await api.get("/products/metrics"); + return response.data; +}; + +export const getCategories = async (): Promise => { + const res = await api.get("/products/categories") + return res.data; +} \ No newline at end of file diff --git a/frontend/src/types/Product.ts b/frontend/src/types/Product.ts new file mode 100644 index 0000000..abae3f5 --- /dev/null +++ b/frontend/src/types/Product.ts @@ -0,0 +1,31 @@ +export interface Product { + id: string; + name: string; + category: string; + unitPrice: number; + quantityInStock: number; + expirationDate?: string; + createdAt: string; + updatedAt: string; +} + +export interface ProductDTO { + name: string; + category: string; + unitPrice: number; + quantityInStock: number; + expirationDate?: string; +} + +export interface InventoryMetrics { + totalInStock: number; + totalValue: number; + averagePrice: number; + byCategory: { + [category: string]: { + inStock: number; + totalValue: number; + averagePrice: number; + }; + }; +} \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..2616547 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,9 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./src/**/*.tsx"], + theme: { + extend: {}, + }, + plugins: [], +} +