diff --git a/frontend/app/(protected)/(sidebar)/inventory/page.tsx b/frontend/app/(protected)/(sidebar)/inventory/page.tsx index b2de1064..feb7ad7b 100644 --- a/frontend/app/(protected)/(sidebar)/inventory/page.tsx +++ b/frontend/app/(protected)/(sidebar)/inventory/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; import { AlertCircle, Edit, @@ -14,20 +14,6 @@ import { Trash, } from "lucide-react"; -interface InventoryTransactionResponseDto { - id: number; - inventoryId: number; - transactionType: string; - quantityChange: number; - quantityBefore: number; - quantityAfter: number; - referenceId?: string; - notes?: string; - createdBy: string; - createdByName?: string; - createdByEmail?: string; - createdAt: string; -} import { Select, SelectContent, @@ -35,6 +21,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; + import { Dialog, DialogContent, @@ -42,39 +29,125 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; + import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; export const dynamic = "force-dynamic"; +// ------------ Types (lightweight) ------------ +interface InventoryItem { + id: number; + productId: number; + productName: string; + basePrice: string | number; + minPrice?: string | number; + maxPrice?: string | number; + quantity: string | number; + updatedAt: string; +} + +interface Category { + id: number; + name: string; + dynamicPricing?: boolean; +} + +interface InventoryTransactionResponseDto { + id: number; + inventoryId: number; + transactionType: string; + quantityChange: number; + quantityBefore: number; + quantityAfter: number; + referenceId?: string; + notes?: string; + createdBy: string; + createdByName?: string; + createdByEmail?: string; + createdAt: string; +} + +// ------------ Helper functions ------------ +const toNumber = (v: unknown, fallback = 0) => { + const n = Number(v); + return Number.isFinite(n) ? n : fallback; +}; + +const formatMoney = (v: unknown) => { + const n = toNumber(v, NaN); + if (Number.isNaN(n)) return "--€"; + return `${n.toFixed(2)}€`; +}; + +const formatQty = (v: unknown) => { + const n = toNumber(v, NaN); + if (Number.isNaN(n)) return "--"; + return n.toFixed(2); +}; + +const getStockStatus = (quantity: unknown) => { + const qty = toNumber(quantity, 0); + if (qty === 0) + return { color: "text-red-100", bg: "bg-red-900", label: "Out of Stock" }; + if (qty < 10) + return { + color: "text-yellow-600", + bg: "bg-yellow-50", + label: "Low Stock", + }; + return { color: "text-green-100", bg: "bg-green-900", label: "In Stock" }; +}; + +type SortOption = + | "updated_desc" + | "name_asc" + | "name_desc" + | "price_asc" + | "price_desc" + | "qty_asc" + | "qty_desc"; + export default function Inventory() { - const [inventory, setInventory] = useState([]); + const [inventory, setInventory] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + const [sortOption, setSortOption] = useState("updated_desc"); + const [showAddModal, setShowAddModal] = useState(false); const [showRemoveModal, setShowRemoveModal] = useState(false); const [showAdjustModal, setShowAdjustModal] = useState(false); const [showHistoryModal, setShowHistoryModal] = useState(false); - const [selectedProduct, setSelectedProduct] = useState(null); + + const [selectedProduct, setSelectedProduct] = useState( + null + ); + const [transactionHistory, setTransactionHistory] = useState< InventoryTransactionResponseDto[] >([]); const [loadingHistory, setLoadingHistory] = useState(false); + const [formData, setFormData] = useState({ quantity: "", notes: "", referenceId: "", }); + const [showCreateProductModal, setShowCreateProductModal] = useState(false); const [showDeleteProductModal, setShowDeleteProductModal] = useState(false); - const [categories, setCategories] = useState([]); + + const [categories, setCategories] = useState([]); const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false); + const [categoryForm, setCategoryForm] = useState({ name: "", dynamicPricing: true, }); + const [productForm, setProductForm] = useState({ name: "", description: "", @@ -86,11 +159,7 @@ export default function Inventory() { notes: "", }); - useEffect(() => { - fetchInventory(); - fetchCategories(); - }, []); - + // ------------ Fetchers ------------ const fetchInventory = async () => { try { setLoading(true); @@ -104,11 +173,8 @@ export default function Inventory() { setInventory(data); setError(null); } catch (err) { - if (err instanceof Error) { - setError(err.message); - } else { - setError("An unknown error occurred"); - } + if (err instanceof Error) setError(err.message); + else setError("An unknown error occurred"); } finally { setLoading(false); } @@ -129,11 +195,10 @@ export default function Inventory() { const fetchTransactionHistory = async (productId: number) => { try { setLoadingHistory(true); + const response = await fetch( `/api/backend/inventory/product/${productId}/history`, - { - credentials: "include", - } + { credentials: "include" } ); if (!response.ok) throw new Error("Failed to fetch history"); @@ -148,6 +213,54 @@ export default function Inventory() { } }; + useEffect(() => { + fetchInventory(); + fetchCategories(); + }, []); + + // ------------ Actions ------------ + const closeModals = () => { + setShowAddModal(false); + setShowRemoveModal(false); + setShowAdjustModal(false); + setShowHistoryModal(false); + setShowCreateCategoryModal(false); + setShowDeleteProductModal(false); + + setSelectedProduct(null); + setFormData({ quantity: "", notes: "", referenceId: "" }); + + setTransactionHistory([]); + setLoadingHistory(false); + }; + + const openAddModal = (item: InventoryItem) => { + setSelectedProduct(item); + setShowAddModal(true); + }; + + const openRemoveModal = (item: InventoryItem) => { + setSelectedProduct(item); + setShowRemoveModal(true); + }; + + const openAdjustModal = (item: InventoryItem) => { + setSelectedProduct(item); + setFormData((prev) => ({ ...prev, quantity: String(item.quantity ?? "") })); + setShowAdjustModal(true); + }; + + const openHistoryModal = async (item: InventoryItem) => { + setSelectedProduct(item); + setShowHistoryModal(true); + await fetchTransactionHistory(item.productId); + }; + + const openDeleteModal = (item: InventoryItem) => { + setSelectedProduct(item); + setShowDeleteProductModal(true); + }; + const handleCreateProduct = async () => { try { const productResponse = await fetch("/api/backend/product", { @@ -170,10 +283,8 @@ export default function Inventory() { const newProduct = await productResponse.json(); - if ( - productForm.initialQuantity && - parseFloat(productForm.initialQuantity) > 0 - ) { + // optional initial stock + if (productForm.initialQuantity && toNumber(productForm.initialQuantity) > 0) { await fetch("/api/backend/inventory/add", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -187,6 +298,7 @@ export default function Inventory() { } await fetchInventory(); + setShowCreateProductModal(false); setProductForm({ name: "", @@ -199,11 +311,8 @@ export default function Inventory() { notes: "", }); } catch (err) { - if (err instanceof Error) { - alert(err.message); - } else { - alert("An unknown error occurred"); - } + if (err instanceof Error) alert(err.message); + else alert("An unknown error occurred"); } }; @@ -219,7 +328,6 @@ export default function Inventory() { headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ - // @ts-expect-error: types aren't imported currently from backend productId: selectedProduct.productId, quantity: parseFloat(formData.quantity), notes: formData.notes, @@ -231,22 +339,20 @@ export default function Inventory() { await fetchInventory(); closeModals(); } catch (err) { - if (err instanceof Error) { - alert(err.message); - } else { - alert("An unknown error occurred"); - } + if (err instanceof Error) alert(err.message); + else alert("An unknown error occurred"); } }; const handleRemoveStock = async () => { + if (!selectedProduct) return; + try { const response = await fetch("/api/backend/inventory/remove", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ - // @ts-expect-error: types aren't imported currently from backend productId: selectedProduct.productId, quantity: parseFloat(formData.quantity), referenceId: formData.referenceId, @@ -262,22 +368,20 @@ export default function Inventory() { await fetchInventory(); closeModals(); } catch (err) { - if (err instanceof Error) { - alert(err.message); - } else { - alert("An unknown error occurred"); - } + if (err instanceof Error) alert(err.message); + else alert("An unknown error occurred"); } }; const handleAdjustStock = async () => { + if (!selectedProduct) return; + try { const response = await fetch("/api/backend/inventory/adjust", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ - // @ts-expect-error: types aren't imported currently from backend productId: selectedProduct.productId, newQuantity: parseFloat(formData.quantity), notes: formData.notes, @@ -289,11 +393,8 @@ export default function Inventory() { await fetchInventory(); closeModals(); } catch (err) { - if (err instanceof Error) { - alert(err.message); - } else { - alert("An unknown error occurred"); - } + if (err instanceof Error) alert(err.message); + else alert("An unknown error occurred"); } }; @@ -314,22 +415,17 @@ export default function Inventory() { } setShowCreateCategoryModal(false); - setCategoryForm({ - name: "", - dynamicPricing: true, - }); + setCategoryForm({ name: "", dynamicPricing: true }); await fetchCategories(); } catch (err) { - if (err instanceof Error) { - alert(err.message); - } else { - alert("An unknown error occurred"); - } + if (err instanceof Error) alert(err.message); + else alert("An unknown error occurred"); } - } + }; - const handleDeleteProduct = async (id: string) => { + const handleDeleteProduct = async (id: number) => { if (!id) return; + try { const deleteResponse = await fetch(`/api/backend/product/${id}`, { method: "DELETE", @@ -344,84 +440,68 @@ export default function Inventory() { setShowDeleteProductModal(false); await fetchInventory(); } catch (err) { - if (err instanceof Error) { - alert(err.message); - } else { - alert("An unknown error occurred"); - } + if (err instanceof Error) alert(err.message); + else alert("An unknown error occurred"); } - } - - const closeModals = () => { - setShowAddModal(false); - setShowRemoveModal(false); - setShowAdjustModal(false); - setShowHistoryModal(false); - setShowCreateCategoryModal(false); - setShowDeleteProductModal(false); - setSelectedProduct(null); - setFormData({ quantity: "", notes: "", referenceId: "" }); - setTransactionHistory([]); - setLoadingHistory(false); }; - // @ts-expect-error: types aren't imported currently from backend - const openAddModal = (item) => { - setSelectedProduct(item); - setShowAddModal(true); - }; + // ------------ Search + Sort ------------ + const filteredInventory = useMemo(() => { + const term = searchTerm.trim().toLowerCase(); + if (!term) return inventory; - // @ts-expect-error: types aren't imported currently from backend - const openDeleteModal = (item) => { - setSelectedProduct(item); - setShowDeleteProductModal(true); - } + return inventory.filter((item) => + (item.productName ?? "").toLowerCase().includes(term) + ); + }, [inventory, searchTerm]); - // @ts-expect-error: types aren't imported currently from backend - const openRemoveModal = (item) => { - setSelectedProduct(item); - setShowRemoveModal(true); - }; + const sortedInventory = useMemo(() => { + const list = [...filteredInventory]; - // @ts-expect-error: types aren't imported currently from backend - const openAdjustModal = (item) => { - setSelectedProduct(item); - setFormData({ ...formData, quantity: item.quantity.toString() }); - setShowAdjustModal(true); - }; + list.sort((a, b) => { + const nameA = (a.productName ?? "").toLowerCase(); + const nameB = (b.productName ?? "").toLowerCase(); - // @ts-expect-error: types aren't imported currently from backend - const openHistoryModal = async (item) => { - setSelectedProduct(item); - setShowHistoryModal(true); - await fetchTransactionHistory(item.productId); - }; + const priceA = toNumber(a.basePrice, 0); + const priceB = toNumber(b.basePrice, 0); - const filteredInventory = searchTerm?.trim().length > 0 ? inventory.filter((item) => { - // @ts-expect-error: types aren't imported currently from backend - return item.productName.toLowerCase().includes(searchTerm.toLowerCase()) - } - ) : inventory; - - // @ts-expect-error: types aren't imported currently from backend - const getStockStatus = (quantity) => { - const qty = parseFloat(quantity); - if (qty === 0) - return { color: "text-red-100", bg: "bg-red-900", label: "Out of Stock" }; - if (qty < 10) - return { - color: "text-yellow-600", - bg: "bg-yellow-50", - label: "Low Stock", - }; - return { color: "text-green-100", bg: "bg-green-900", label: "In Stock" }; - }; + const qtyA = toNumber(a.quantity, 0); + const qtyB = toNumber(b.quantity, 0); + + const updatedA = new Date(a.updatedAt ?? 0).getTime(); + const updatedB = new Date(b.updatedAt ?? 0).getTime(); + + switch (sortOption) { + case "name_asc": + return nameA.localeCompare(nameB); + case "name_desc": + return nameB.localeCompare(nameA); + case "price_asc": + return priceA - priceB; + case "price_desc": + return priceB - priceA; + + case "qty_asc": + return qtyA - qtyB; + case "qty_desc": + return qtyB - qtyA; + + case "updated_desc": + default: + return updatedB - updatedA; + } + }); + + return list; + }, [filteredInventory, sortOption]); + + // ------------ UI states ------------ if (loading) { return (
-
+

Loading inventory...

@@ -430,185 +510,228 @@ export default function Inventory() { return (
-
-
-
- -

- Inventory Management -

-
-
- - -
- Total Items: {inventory.length} -
-
+
+
+
+ +

+ Inventory Management +

- {error && ( -
- - {error} +
+ + + + +
+ Total Items: {inventory.length}
- )} - -
- - setSearchTerm(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - />
+
+ + {error && ( +
+ + {error} +
+ )} -
- - - - - - - - - - - + {/* Search */} +
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + {/* Sort */} +
+ +
+ + {/* Table */} +
+
- Product - - Current Price - - Min Price - - Max Price - - Quantity - - Status - - Last Updated - - Actions -
+ + + + + + + + + + + + + + + {sortedInventory.length === 0 ? ( + + - - - {filteredInventory.length === 0 ? ( - - - - ) : ( - filteredInventory.map((item) => { - // @ts-expect-error: types aren't imported currently from backend - const status = getStockStatus(item.quantity); - return ( - - - - - - - + + + + + + + + + + + + + + + - - - - ); - }) - )} - -
+ Product + + Current Price + + Min Price + + Max Price + + Quantity + + Status + + Last Updated + + Actions +
+ No inventory items found +
- No inventory items found -
-
- {item.productName} -
-
- ID: {item.productId} -
-
- - {parseFloat(item.basePrice).toFixed(2)}€ - - - - {isNaN(parseFloat(item.minPrice)) ? "--" : parseFloat(item.minPrice).toFixed(2)}€ - - - - {isNaN(parseFloat(item.maxPrice)) ? "--" : parseFloat(item.maxPrice).toFixed(2)}€ - - - - {parseFloat(item.quantity).toFixed(2)} - - - { + const status = getStockStatus(item.quantity); + + return ( +
+
+ {item.productName} +
+
+ ID: {item.productId} +
+
+ + {formatMoney(item.basePrice)} + + + + {formatMoney(item.minPrice)} + + + + {formatMoney(item.maxPrice)} + + + + {formatQty(item.quantity)} + + + + {status.label} + + + {item.updatedAt + ? new Date(item.updatedAt).toLocaleString() + : "--"} + +
+
- {new Date(item.updatedAt).toLocaleString()} - -
- - - - - -
-
-
+ + + + + + + + + + +
+ + + ); + }) + )} + +
+
+ + {/* ---------- Dialogs ---------- */} + {/* Create Product */} +
+
+
+
+
+
+
+
+
- + {/* Delete Product */} + Delete Product - This action will permanently delete the product and its related data. - Are you sure you want to continue? + This action will permanently delete the product and its related + data. Are you sure you want to continue?

- Product: {selectedProduct?.productName} + Product:{" "} + + {selectedProduct?.productName} +

- ID: {selectedProduct?.productId ?? selectedProduct?.id} + ID:{" "} + + {selectedProduct?.productId ?? selectedProduct?.id} +

@@ -817,6 +959,7 @@ export default function Inventory() { > Cancel +
- + {/* Create Category */} + Create New Category + + +
+
+ - +
+ {/* Add Stock */} @@ -895,6 +1046,7 @@ export default function Inventory() { Increase the stock quantity for the selected product. +

Product:{" "} @@ -907,6 +1059,7 @@ export default function Inventory() { {selectedProduct?.quantity}

+
+
+
+ {/* Remove Stock */} @@ -956,6 +1112,7 @@ export default function Inventory() { Decrease the stock quantity for the selected product. +

Product:{" "} @@ -968,6 +1125,7 @@ export default function Inventory() { {selectedProduct?.quantity}

+
+
+
+
+ {/* Adjust Stock */} @@ -1031,6 +1193,7 @@ export default function Inventory() { Set a new stock quantity for the selected product. +

Product:{" "} @@ -1043,6 +1206,7 @@ export default function Inventory() { {selectedProduct?.quantity}

+
+
+
+ {/* History */} @@ -1092,6 +1259,7 @@ export default function Inventory() { View the transaction history for the selected product. +

Product:{" "} @@ -1100,13 +1268,14 @@ export default function Inventory() {

+
{loadingHistory ? (
-
+

Loading transaction history...

) : transactionHistory.length === 0 ? ( @@ -1122,39 +1291,45 @@ export default function Inventory() { >
{transaction.transactionType} + {new Date(transaction.createdAt).toLocaleString()}
+
Change: = 0 - ? "text-green-400" - : "text-red-400" - }`} + className={`ml-1 font-semibold ${ + Number(transaction.quantityChange) >= 0 + ? "text-green-400" + : "text-red-400" + }`} > {Number(transaction.quantityChange) >= 0 ? "+" : ""} {Number(transaction.quantityChange).toFixed(2)}
+
Before: {Number(transaction.quantityBefore).toFixed(2)}
+
After: @@ -1162,27 +1337,28 @@ export default function Inventory() {
+ {transaction.notes && (

{transaction.notes}

)} + {transaction.referenceId && (

Ref: {transaction.referenceId}

)} - {(transaction.createdByName || - transaction.createdByEmail) && ( -
- - - By:{" "} - {transaction.createdByName || - transaction.createdByEmail} - -
- )} + + {(transaction.createdByName || transaction.createdByEmail) && ( +
+ + + By:{" "} + {transaction.createdByName || transaction.createdByEmail} + +
+ )}
))}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 61dae07c..30133799 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -849,10 +849,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", - "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", - "license": "MIT" + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==" }, "node_modules/@next/eslint-plugin-next": { "version": "15.5.2", @@ -6235,12 +6234,11 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", - "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", - "license": "MIT", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "dependencies": { - "@next/env": "15.5.7", + "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31",