diff --git a/backend/routes/app/database/products.database.js b/backend/routes/app/database/products.database.js index 6e40686..43c4ac2 100644 --- a/backend/routes/app/database/products.database.js +++ b/backend/routes/app/database/products.database.js @@ -40,6 +40,32 @@ export const newProduct = async ( } }; +export const productDetails = async (uuid) => { + const [result] = await pool.query( + `SELECT + BIN_TO_UUID(p.uuid) AS uuid, + p.name, + p.description, + p.price, + p.amount, + BIN_TO_UUID(s.uuid) AS storage_location_uuid, + s.name AS storage_location_name, + p.expiry_date, + p.bottling_date, + p.picture + FROM products p + JOIN storage_locations s ON p.storage_location = s.uuid + WHERE p.uuid = UUID_TO_BIN(?)`, + [uuid], + ); + + if (result.length > 0) { + return { code: "sp003", data: result[0] }; + } else { + return { code: "ep003" }; + } +}; + export const allProducts = async () => { const [result] = await pool.query(` SELECT diff --git a/backend/routes/app/products.route.js b/backend/routes/app/products.route.js index 3e5c695..167cf90 100644 --- a/backend/routes/app/products.route.js +++ b/backend/routes/app/products.route.js @@ -1,7 +1,11 @@ import express from "express"; import dotenv from "dotenv"; import { authenticate } from "../../services/tokenService.js"; -import { allProducts, newProduct } from "./database/products.database.js"; +import { + allProducts, + newProduct, + productDetails, +} from "./database/products.database.js"; dotenv.config(); const router = express.Router(); @@ -67,4 +71,28 @@ router.get("/all-products", authenticate, async (req, res) => { } }); +router.get("/view", authenticate, async (req, res) => { + const uuid = req.query.uuid; + + const result = await productDetails(uuid); + + if (result.code === "ep003") { + res.status(406).json({ + success: false, + code: "ep003", + data: null, + message: "Error while fetching product", + }); + } + + if (result.code === "sp003") { + res.status(200).json({ + success: true, + code: "sp003", + data: result.data, + message: "", + }); + } +}); + export default router; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4afdf22..c6b44e8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@fontsource/inter": "^5.2.8", + "@mui/icons-material": "^9.0.1", "@mui/joy": "^5.0.0-beta.52", "@tailwindcss/vite": "^4.3.0", "@tanstack/react-form": "^1.32.0", @@ -845,6 +846,32 @@ "url": "https://opencollective.com/mui-org" } }, + "node_modules/@mui/icons-material": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.0.1.tgz", + "integrity": "sha512-5PRpQjVLTNLyV/2J9J53Yz4R0tVbodG0BQDN2zQI1QBG1OPYM25ar+4N20eyFOfJT6zKglLzsnU70+zdVLaTkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^9.0.1", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/joy": { "version": "5.0.0-beta.52", "resolved": "https://registry.npmjs.org/@mui/joy/-/joy-5.0.0-beta.52.tgz", @@ -886,6 +913,220 @@ } } }, + "node_modules/@mui/material": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.0.1.tgz", + "integrity": "sha512-voyCpeUxcSWLN7KPZuq0pGCIt726T9K6kiVM3XUcywZDAlZSarLHaUxJVQpospbjjOzN53hwyjo8s6KoWl6utw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/core-downloads-tracker": "^9.0.1", + "@mui/system": "^9.0.1", + "@mui/types": "^9.0.0", + "@mui/utils": "^9.0.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.4", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^9.0.1", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/core-downloads-tracker": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.0.1.tgz", + "integrity": "sha512-GzamIIhZ1bH77dq7eKaeyRgJdkypsxin4jBFq2EMs4lBWRR0LFO1CSVMsoebn/VvjcNrnrOrjy48MkrkQUK2iw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/material/node_modules/@mui/private-theming": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.0.1.tgz", + "integrity": "sha512-pSIGq4Yw749KHEwlkYZWVERgHgwJELP6ODtBNUfV8V4oIb5H+h7IQDFXuk/b2oQccODK1enJAtiEzlgLZmq+8g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/utils": "^9.0.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/styled-engine": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.0.0.tgz", + "integrity": "sha512-9RLGdX4Jg0aQPRuvqh/OLzYSPlgd5zyEw5/1HIRfdavSiOd03WtUaGZH9/w1RoTYuRKwpgy0hpIFaMHIqPVIWg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/system": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.0.1.tgz", + "integrity": "sha512-WvlioaLxk6ewUIOfh0StxUvOPDS1mCfzaulcudsL1brZNXuh0N9FMk7RpH7ImJKjEz412SEy/V/yvqmtxbqxCQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/private-theming": "^9.0.1", + "@mui/styled-engine": "^9.0.0", + "@mui/types": "^9.0.0", + "@mui/utils": "^9.0.1", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/types": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.0.0.tgz", + "integrity": "sha512-i1cuFCAWN44b3AJWO7mh7tuh1sqbQSeVr/94oG0TX5uXivac8XalgE4/6fQZcmGZigzbQ35IXxj/4jLpRIBYZg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/utils": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.0.1.tgz", + "integrity": "sha512-f3UO3jNN1pYg5zxqXC81Bvv8hx5ACcYc0387382ZI7M5ono1heIwHYLrKsz85myguWdeVKPRZGmDdynWUBjK2g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/types": "^9.0.0", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/private-theming": { "version": "5.17.1", "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", @@ -2050,7 +2291,6 @@ "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2066,6 +2306,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", @@ -2650,6 +2900,17 @@ "node": ">=0.3.1" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.361", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", @@ -4006,6 +4267,23 @@ "react-dom": "^18 || ^19" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 17bece3..b2c2c36 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@fontsource/inter": "^5.2.8", + "@mui/icons-material": "^9.0.1", "@mui/joy": "^5.0.0-beta.52", "@tailwindcss/vite": "^4.3.0", "@tanstack/react-form": "^1.32.0", diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..dd1792b --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,73 @@ +import { useTranslation } from "react-i18next"; +import { Button, Typography } from "@mui/joy"; +import InventoryIcon from "@mui/icons-material/Inventory"; +import AddBoxIcon from "@mui/icons-material/AddBox"; +import AccountBoxIcon from "@mui/icons-material/AccountBox"; +import { useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; + +export const Sidebar = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const currentURL = window.location.href; + const [currentPage, setCurrentPage] = useState( + currentURL.substring(currentURL.lastIndexOf("/") + 1), + ); + + return ( + + ); +}; diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx new file mode 100644 index 0000000..2d617af --- /dev/null +++ b/frontend/src/pages/Inventory.tsx @@ -0,0 +1,572 @@ +import * as React from "react"; +import { + Typography, + Button, + CircularProgress, + Sheet, + Table, + Chip, + Avatar, + Box, + IconButton, + Checkbox, + FormControl, + FormLabel, + Link, + Tooltip, + Select, + Option, +} from "@mui/joy"; +import { useNavigate } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteIcon from "@mui/icons-material/Delete"; +import FilterListIcon from "@mui/icons-material/FilterList"; +import KeyboardArrowLeftIcon from "@mui/icons-material/KeyboardArrowLeft"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import { useQuery } from "@tanstack/react-query"; +import { getProducts } from "../utils/uxFncs"; +import { visuallyHidden } from "@mui/utils"; + +type Order = "asc" | "desc"; + +type ProductRow = { + id: string; + uuid: string; + name: string; + description: string; + imageUrl?: string; + price: string; + stock: string; + stockLabel: string; + stockStatus: "ok" | "low" | "missing"; + location: string; + locationDetail: string; + expiryDate: string; + refillDate: string; +}; + +const formatDate = (value?: string | null) => { + if (!value) { + return "-"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "-"; + } + return date.toLocaleDateString("de-DE"); +}; + +const descendingComparator = (a: T, b: T, orderBy: keyof T) => { + const aValue = (a[orderBy] ?? "") as string | number; + const bValue = (b[orderBy] ?? "") as string | number; + if (bValue < aValue) { + return -1; + } + if (bValue > aValue) { + return 1; + } + return 0; +}; + +const getComparator = (order: Order, orderBy: keyof ProductRow) => { + return order === "desc" + ? (a: ProductRow, b: ProductRow) => descendingComparator(a, b, orderBy) + : (a: ProductRow, b: ProductRow) => -descendingComparator(a, b, orderBy); +}; + +type EnhancedTableHeadProps = { + numSelected: number; + onRequestSort: ( + event: React.MouseEvent, + property: keyof ProductRow, + ) => void; + onSelectAllClick: (event: React.ChangeEvent) => void; + order: Order; + orderBy: string; + rowCount: number; +}; + +const EnhancedTableHead = (props: EnhancedTableHeadProps) => { + const { t } = useTranslation(); + + const { + onSelectAllClick, + order, + orderBy, + numSelected, + rowCount, + onRequestSort, + } = props; + const createSortHandler = + (property: keyof ProductRow) => (event: React.MouseEvent) => { + onRequestSort(event, property); + }; + + const headCells: readonly { + id: keyof ProductRow; + label: string; + numeric: boolean; + }[] = [ + { id: "name", label: t("product-name"), numeric: false }, + { id: "price", label: t("price"), numeric: true }, + { id: "stock", label: t("stock"), numeric: true }, + { id: "location", label: t("storage-place"), numeric: false }, + { id: "expiryDate", label: t("expiry-date"), numeric: false }, + { id: "refillDate", label: t("refill-date"), numeric: false }, + ]; + + return ( + + + + 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={onSelectAllClick} + slotProps={{ input: { "aria-label": "select all products" } }} + sx={{ verticalAlign: "sub" }} + /> + + {headCells.map((headCell) => { + const active = orderBy === headCell.id; + return ( + + + ) : null + } + endDecorator={ + !headCell.numeric ? ( + + ) : null + } + sx={{ + fontWeight: "lg", + "& svg": { + transition: "0.2s", + transform: + active && order === "desc" + ? "rotate(0deg)" + : "rotate(180deg)", + }, + "&:hover": { "& svg": { opacity: 1 } }, + }} + > + {headCell.label} + {active ? ( + + {order === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + ); + })} + Aktionen + + + ); +}; + +const EnhancedTableToolbar = ({ numSelected }: { numSelected: number }) => { + const { t } = useTranslation(); + + return ( + 0 && { + bgcolor: "background.level1", + }, + ]} + className="text-slate-700" + > + {numSelected > 0 ? ( + + {numSelected} ausgewahlt + + ) : ( + + {t("inventory")} + + )} + {numSelected > 0 ? ( + + + + + + ) : ( + + + + + + )} + + ); +}; + +export const InventoryPage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const labelDisplayedRows = ({ + from, + to, + count, + }: { + from: number; + to: number; + count: number; + }) => `${from}-${to} ${t("of")} ${count}`; + + const { data: productsData, isLoading: productsIsLoading } = useQuery({ + queryKey: ["products"], + queryFn: getProducts, + }); + + const rows: ProductRow[] = (productsData ?? []).map( + (product: any, index: number) => ({ + id: String(product?.uuid ?? index), + uuid: String(product?.uuid ?? ""), + name: product?.name ?? "Produktname", + description: product?.description ?? "", + imageUrl: product?.picture ?? undefined, + price: product?.price ?? "-", + stock: `${product?.amount ?? 0} Stk.`, + stockLabel: product?.amount === 0 ? "FEHLT" : "OK", + stockStatus: product?.amount === 0 ? "missing" : "ok", + location: product?.storage_location_name ?? "-", + locationDetail: "", + expiryDate: formatDate(product?.expiry_date), + refillDate: formatDate(product?.bottling_date), + }), + ); + + const [order, setOrder] = React.useState("asc"); + const [orderBy, setOrderBy] = React.useState("name"); + const [selected, setSelected] = React.useState([]); + const [page, setPage] = React.useState(0); + const [rowsPerPage, setRowsPerPage] = React.useState(5); + + const handleRequestSort = ( + event: React.MouseEvent, + property: keyof ProductRow, + ) => { + const isAsc = orderBy === property && order === "asc"; + setOrder(isAsc ? "desc" : "asc"); + setOrderBy(property); + }; + + const handleSelectAllClick = (event: React.ChangeEvent) => { + if (event.target.checked) { + const newSelected = rows.map((row) => row.id); + setSelected(newSelected); + return; + } + setSelected([]); + }; + + const handleClick = (event: React.MouseEvent, id: string) => { + const selectedIndex = selected.indexOf(id); + let newSelected: readonly string[] = []; + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, id); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1), + ); + } + setSelected(newSelected); + }; + + const handleChangePage = (newPage: number) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event: any, newValue: number | null) => { + setRowsPerPage(parseInt(newValue!.toString(), 10)); + setPage(0); + }; + + const getLabelDisplayedRowsTo = () => { + if (rows.length === -1) { + return (page + 1) * rowsPerPage; + } + return rowsPerPage === -1 + ? rows.length + : Math.min(rows.length, (page + 1) * rowsPerPage); + }; + + const emptyRows = + page > 0 ? Math.max(0, (1 + page) * rowsPerPage - rows.length) : 0; + + return ( + <> + {t("inventory-subtitle")} + {t("inventory-header")} +
+ + {productsIsLoading && } +
+ + + + + theme.vars.palette.success.softBg, + "& thead th:nth-child(1)": { + width: "40px", + }, + "& thead th:nth-child(2)": { + width: "30%", + }, + "& tr > *:nth-child(n+3)": { textAlign: "left" }, + }} + > + + + {[...rows] + .sort(getComparator(order, orderBy)) + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((row, index) => { + const isItemSelected = selected.includes(row.id); + const labelId = `enhanced-table-checkbox-${index}`; + + return ( + handleClick(event, row.id)} + role="checkbox" + aria-checked={isItemSelected} + tabIndex={-1} + className="border-t border-slate-200" + style={ + isItemSelected + ? ({ + "--TableCell-dataBackground": + "var(--TableCell-selectedBackground)", + "--TableCell-headBackground": + "var(--TableCell-selectedBackground)", + } as React.CSSProperties) + : {} + } + > + + + + + + + + + + ); + })} + {emptyRows > 0 && ( + + + )} + + + + + + +
+ + +
+ +
+ + {row.name} + + + {row.description} + +
+
+
+ {row.price} + + {t("currency")} + + + + {row.stock} + + + {row.stockLabel} + + + {row.location} + + {row.locationDetail} + + + {row.expiryDate} + + {row.refillDate} + + +
+
+ + + {t("rows-per-page")} + + + + {labelDisplayedRows({ + from: rows.length === 0 ? 0 : page * rowsPerPage + 1, + to: getLabelDisplayedRowsTo(), + count: rows.length === -1 ? -1 : rows.length, + })} + + + handleChangePage(page - 1)} + sx={{ bgcolor: "background.surface" }} + > + + + = Math.ceil(rows.length / rowsPerPage) - 1 + : false + } + onClick={() => handleChangePage(page + 1)} + sx={{ bgcolor: "background.surface" }} + > + + + + +
+
+ + ); +}; diff --git a/frontend/src/pages/ViewProduct.tsx b/frontend/src/pages/ViewProduct.tsx new file mode 100644 index 0000000..1a32797 --- /dev/null +++ b/frontend/src/pages/ViewProduct.tsx @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query"; +import { getProductDetails } from "../utils/uxFncs"; +import { CircularProgress, Typography } from "@mui/joy"; +import { useTranslation } from "react-i18next"; + +interface ViewProductProps { + uuid: string; +} + +export const ViewProduct = (props: ViewProductProps) => { + const uuid = props.uuid; + const { t } = useTranslation(); + + const { data: productDetails, isLoading: productDetailsLoading } = useQuery({ + queryKey: ["product", uuid], + queryFn: () => getProductDetails(uuid), + }); + + return ( + <> + {t("product-details")} + {productDetailsLoading && } + + ); +}; diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 716996f..3eb5c67 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as IndexRouteImport } from './routes/index' import { Route as AppHiddenLayoutRouteImport } from './routes/app/_hiddenLayout' import { Route as AppHiddenLayoutViewProductRouteImport } from './routes/app/_hiddenLayout/view-product' +import { Route as AppHiddenLayoutProfileRouteImport } from './routes/app/_hiddenLayout/profile' import { Route as AppHiddenLayoutInventoryRouteImport } from './routes/app/_hiddenLayout/inventory' import { Route as AppHiddenLayoutAddProductRouteImport } from './routes/app/_hiddenLayout/add-product' @@ -37,6 +38,11 @@ const AppHiddenLayoutViewProductRoute = path: '/view-product', getParentRoute: () => AppHiddenLayoutRoute, } as any) +const AppHiddenLayoutProfileRoute = AppHiddenLayoutProfileRouteImport.update({ + id: '/profile', + path: '/profile', + getParentRoute: () => AppHiddenLayoutRoute, +} as any) const AppHiddenLayoutInventoryRoute = AppHiddenLayoutInventoryRouteImport.update({ id: '/inventory', @@ -56,6 +62,7 @@ export interface FileRoutesByFullPath { '/app': typeof AppHiddenLayoutRouteWithChildren '/app/add-product': typeof AppHiddenLayoutAddProductRoute '/app/inventory': typeof AppHiddenLayoutInventoryRoute + '/app/profile': typeof AppHiddenLayoutProfileRoute '/app/view-product': typeof AppHiddenLayoutViewProductRoute } export interface FileRoutesByTo { @@ -64,6 +71,7 @@ export interface FileRoutesByTo { '/app': typeof AppHiddenLayoutRouteWithChildren '/app/add-product': typeof AppHiddenLayoutAddProductRoute '/app/inventory': typeof AppHiddenLayoutInventoryRoute + '/app/profile': typeof AppHiddenLayoutProfileRoute '/app/view-product': typeof AppHiddenLayoutViewProductRoute } export interface FileRoutesById { @@ -73,6 +81,7 @@ export interface FileRoutesById { '/app/_hiddenLayout': typeof AppHiddenLayoutRouteWithChildren '/app/_hiddenLayout/add-product': typeof AppHiddenLayoutAddProductRoute '/app/_hiddenLayout/inventory': typeof AppHiddenLayoutInventoryRoute + '/app/_hiddenLayout/profile': typeof AppHiddenLayoutProfileRoute '/app/_hiddenLayout/view-product': typeof AppHiddenLayoutViewProductRoute } export interface FileRouteTypes { @@ -83,6 +92,7 @@ export interface FileRouteTypes { | '/app' | '/app/add-product' | '/app/inventory' + | '/app/profile' | '/app/view-product' fileRoutesByTo: FileRoutesByTo to: @@ -91,6 +101,7 @@ export interface FileRouteTypes { | '/app' | '/app/add-product' | '/app/inventory' + | '/app/profile' | '/app/view-product' id: | '__root__' @@ -99,6 +110,7 @@ export interface FileRouteTypes { | '/app/_hiddenLayout' | '/app/_hiddenLayout/add-product' | '/app/_hiddenLayout/inventory' + | '/app/_hiddenLayout/profile' | '/app/_hiddenLayout/view-product' fileRoutesById: FileRoutesById } @@ -138,6 +150,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppHiddenLayoutViewProductRouteImport parentRoute: typeof AppHiddenLayoutRoute } + '/app/_hiddenLayout/profile': { + id: '/app/_hiddenLayout/profile' + path: '/profile' + fullPath: '/app/profile' + preLoaderRoute: typeof AppHiddenLayoutProfileRouteImport + parentRoute: typeof AppHiddenLayoutRoute + } '/app/_hiddenLayout/inventory': { id: '/app/_hiddenLayout/inventory' path: '/inventory' @@ -158,12 +177,14 @@ declare module '@tanstack/react-router' { interface AppHiddenLayoutRouteChildren { AppHiddenLayoutAddProductRoute: typeof AppHiddenLayoutAddProductRoute AppHiddenLayoutInventoryRoute: typeof AppHiddenLayoutInventoryRoute + AppHiddenLayoutProfileRoute: typeof AppHiddenLayoutProfileRoute AppHiddenLayoutViewProductRoute: typeof AppHiddenLayoutViewProductRoute } const AppHiddenLayoutRouteChildren: AppHiddenLayoutRouteChildren = { AppHiddenLayoutAddProductRoute: AppHiddenLayoutAddProductRoute, AppHiddenLayoutInventoryRoute: AppHiddenLayoutInventoryRoute, + AppHiddenLayoutProfileRoute: AppHiddenLayoutProfileRoute, AppHiddenLayoutViewProductRoute: AppHiddenLayoutViewProductRoute, } diff --git a/frontend/src/routes/app/_hiddenLayout.tsx b/frontend/src/routes/app/_hiddenLayout.tsx index 636a903..a10c261 100644 --- a/frontend/src/routes/app/_hiddenLayout.tsx +++ b/frontend/src/routes/app/_hiddenLayout.tsx @@ -1,5 +1,5 @@ -// routes/app/_layout.tsx (oder app.tsx als Parent) import { Outlet, createFileRoute } from "@tanstack/react-router"; +import { Sidebar } from "../../components/Sidebar"; export const Route = createFileRoute("/app/_hiddenLayout")({ component: AppLayout, @@ -7,9 +7,11 @@ export const Route = createFileRoute("/app/_hiddenLayout")({ function AppLayout() { return ( -
-

Layout

- +
+ +
+ +
); } diff --git a/frontend/src/routes/app/_hiddenLayout/inventory.tsx b/frontend/src/routes/app/_hiddenLayout/inventory.tsx index afeab4b..d0009aa 100644 --- a/frontend/src/routes/app/_hiddenLayout/inventory.tsx +++ b/frontend/src/routes/app/_hiddenLayout/inventory.tsx @@ -1,5 +1,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { isAuthenticated } from "../../../utils/auth"; +import { InventoryPage } from "../../../pages/Inventory"; export const Route = createFileRoute("/app/_hiddenLayout/inventory")({ beforeLoad: async () => { @@ -13,9 +14,5 @@ export const Route = createFileRoute("/app/_hiddenLayout/inventory")({ }); function RouteComponent() { - return ( - <> -

Inventar

- - ); + return ; } diff --git a/frontend/src/routes/app/_hiddenLayout/profile.tsx b/frontend/src/routes/app/_hiddenLayout/profile.tsx new file mode 100644 index 0000000..0ebec38 --- /dev/null +++ b/frontend/src/routes/app/_hiddenLayout/profile.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/_hiddenLayout/profile')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/app/_hiddenLayout/profile"!
+} diff --git a/frontend/src/routes/app/_hiddenLayout/view-product.tsx b/frontend/src/routes/app/_hiddenLayout/view-product.tsx index 2cbef25..20c4564 100644 --- a/frontend/src/routes/app/_hiddenLayout/view-product.tsx +++ b/frontend/src/routes/app/_hiddenLayout/view-product.tsx @@ -1,5 +1,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; +import { z } from "zod"; import { isAuthenticated } from "../../../utils/auth"; +import { ViewProduct } from "../../../pages/ViewProduct"; export const Route = createFileRoute("/app/_hiddenLayout/view-product")({ beforeLoad: async () => { @@ -9,9 +11,13 @@ export const Route = createFileRoute("/app/_hiddenLayout/view-product")({ }); } }, + validateSearch: z.object({ + product: z.string(), + }), component: RouteComponent, }); function RouteComponent() { - return
Hello "/app/view-product"!
; + const { product } = Route.useSearch(); + return ; } diff --git a/frontend/src/utils/uxFncs.ts b/frontend/src/utils/uxFncs.ts new file mode 100644 index 0000000..0602426 --- /dev/null +++ b/frontend/src/utils/uxFncs.ts @@ -0,0 +1,41 @@ +import { API_BASE } from "../config/api.config"; +import Cookies from "js-cookie"; + +export const getProducts = async () => { + const result = await fetch(`${API_BASE}/products/all-products`, { + headers: { + Authorization: `Bearer ${Cookies.get("token") || ""}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + const response = await result.json(); + + if (response.code === "ep002") { + return { success: false, code: response.code }; + } + + if (response.code === "sp002") { + return response.data; + } +}; + +export const getProductDetails = async (uuid: string) => { + const result = await fetch(`${API_BASE}/products/view?uuid=${uuid}`, { + headers: { + Authorization: `Bearer ${Cookies.get("token") || ""}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + const response = await result.json(); + + if (response.code === "ep003") { + return { success: false, code: response.code }; + } + + if (response.code === "sp003") { + return response.data; + } +};