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..079887b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,11 +16,17 @@ "@types/node": "^16.18.126", "@types/react": "^19.1.7", "@types/react-dom": "^19.1.6", + "axios": "^1.10.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-scripts": "5.0.1", "typescript": "^4.9.5", "web-vitals": "^2.1.4" + }, + "devDependencies": { + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17" } }, "node_modules/@adobe/css-tools": { @@ -4926,6 +4932,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", @@ -12247,9 +12280,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 +13712,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", diff --git a/frontend/package.json b/frontend/package.json index e06d41a..76f4adf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@types/node": "^16.18.126", "@types/react": "^19.1.7", "@types/react-dom": "^19.1.6", + "axios": "^1.10.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-scripts": "5.0.1", @@ -42,7 +43,7 @@ "last 1 safari version" ] }, - "jest": { + "jest": { "collectCoverageFrom": [ "src/**/*.tsx" ], @@ -55,5 +56,10 @@ "html", "text" ] + }, + "devDependencies": { + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17" } } 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: [], +} +