feat: add profile route and sidebar navigation; implement inventory and view product pages

- Added a new profile route under the hidden layout.
- Introduced a Sidebar component for navigation between inventory, add product, and profile pages.
- Created InventoryPage to display a list of products with sorting and pagination.
- Implemented ViewProduct page to show details of a selected product.
- Integrated API calls for fetching products and product details.
- Updated route tree to include new routes and components.
This commit is contained in:
2026-05-26 21:37:30 +02:00
parent 56a31bb614
commit 616058b603
13 changed files with 1091 additions and 12 deletions
@@ -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
+29 -1
View File
@@ -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;
+279 -1
View File
@@ -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",
+1
View File
@@ -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",
+73
View File
@@ -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 (
<aside className="flex h-full min-h-screen w-full max-w-70 flex-col gap-8 border-r border-white/80 bg-linear-to-b from-[#f7fbff] via-[#f2f6fb] to-[#eef3f9] px-6 py-8 shadow-[0_20px_60px_rgba(11,107,203,0.08)]">
<div className="space-y-2">
<Typography
level="h2"
className="text-[22px] font-semibold text-[#0b6bcb]"
>
{t("app-title")}
</Typography>
<Typography
level="body-lg"
className="text-sm font-medium text-slate-500"
>
{t("app-subtitle")}
</Typography>
</div>
<div className="flex flex-1 flex-col gap-2">
<Button
onClick={() => {
navigate({ to: "/app/inventory" });
setCurrentPage("inventory");
}}
variant={currentPage === "inventory" ? "soft" : "plain"}
startDecorator={<InventoryIcon />}
className="h-11 w-full justify-start! rounded-2xl px-4 text-left text-sm font-semibold text-slate-700 transition hover:bg-white/80 hover:text-[#0b6bcb] [&_.MuiButton-startDecorator]:mr-3! [&_.MuiButton-startDecorator]:ml-0!"
>
{t("inventory")}
</Button>
<Button
onClick={() => {
navigate({ to: "/app/add-product" });
setCurrentPage("add-product");
}}
variant={currentPage === "add-product" ? "soft" : "plain"}
startDecorator={<AddBoxIcon />}
className="h-11 w-full justify-start! rounded-2xl px-4 text-left text-sm font-semibold text-slate-700 transition hover:bg-white/80 hover:text-[#0b6bcb] [&_.MuiButton-startDecorator]:mr-3! [&_.MuiButton-startDecorator]:ml-0!"
>
{t("add")}
</Button>
<Button
onClick={() => {
navigate({ to: "/app/profile" });
setCurrentPage("profile");
}}
variant={currentPage === "profile" ? "soft" : "plain"}
startDecorator={<AccountBoxIcon />}
className="h-11 w-full justify-start! rounded-2xl px-4 text-left text-sm font-semibold text-slate-700 transition hover:bg-white/80 hover:text-[#0b6bcb] [&_.MuiButton-startDecorator]:mr-3! [&_.MuiButton-startDecorator]:ml-0!"
>
{t("profile")}
</Button>
</div>
<div className="rounded-2xl border border-white/70 bg-white/80 px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-[#0b6bcb] shadow-[0_12px_30px_rgba(12,38,78,0.12)]">
Stockhome
</div>
</aside>
);
};
+572
View File
@@ -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 = <T,>(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<unknown>,
property: keyof ProductRow,
) => void;
onSelectAllClick: (event: React.ChangeEvent<HTMLInputElement>) => 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<unknown>) => {
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 (
<thead>
<tr className="bg-slate-50 text-slate-600">
<th className="px-4 py-4">
<Checkbox
indeterminate={numSelected > 0 && numSelected < rowCount}
checked={rowCount > 0 && numSelected === rowCount}
onChange={onSelectAllClick}
slotProps={{ input: { "aria-label": "select all products" } }}
sx={{ verticalAlign: "sub" }}
/>
</th>
{headCells.map((headCell) => {
const active = orderBy === headCell.id;
return (
<th
key={headCell.id}
className="px-6 py-4"
aria-sort={
active
? ({ asc: "ascending", desc: "descending" } as const)[order]
: undefined
}
>
<Link
underline="none"
color="neutral"
textColor={active ? "primary.plainColor" : undefined}
component="button"
onClick={createSortHandler(headCell.id)}
startDecorator={
headCell.numeric ? (
<ArrowDownwardIcon
sx={[active ? { opacity: 1 } : { opacity: 0 }]}
/>
) : null
}
endDecorator={
!headCell.numeric ? (
<ArrowDownwardIcon
sx={[active ? { opacity: 1 } : { opacity: 0 }]}
/>
) : null
}
sx={{
fontWeight: "lg",
"& svg": {
transition: "0.2s",
transform:
active && order === "desc"
? "rotate(0deg)"
: "rotate(180deg)",
},
"&:hover": { "& svg": { opacity: 1 } },
}}
>
{headCell.label}
{active ? (
<Box component="span" sx={visuallyHidden}>
{order === "desc"
? "sorted descending"
: "sorted ascending"}
</Box>
) : null}
</Link>
</th>
);
})}
<th className="px-6 py-4 text-right">Aktionen</th>
</tr>
</thead>
);
};
const EnhancedTableToolbar = ({ numSelected }: { numSelected: number }) => {
const { t } = useTranslation();
return (
<Box
sx={[
{
display: "flex",
alignItems: "center",
py: 1,
pl: { sm: 2 },
pr: { xs: 1, sm: 1 },
borderTopLeftRadius: "var(--unstable_actionRadius)",
borderTopRightRadius: "var(--unstable_actionRadius)",
},
numSelected > 0 && {
bgcolor: "background.level1",
},
]}
className="text-slate-700"
>
{numSelected > 0 ? (
<Typography sx={{ flex: "1 1 100%" }} component="div">
{numSelected} ausgewahlt
</Typography>
) : (
<Typography level="body-lg" sx={{ flex: "1 1 100%" }} component="div">
{t("inventory")}
</Typography>
)}
{numSelected > 0 ? (
<Tooltip title="Delete">
<IconButton size="sm" color="danger" variant="solid">
<DeleteIcon />
</IconButton>
</Tooltip>
) : (
<Tooltip title="Filter list">
<IconButton size="sm" variant="outlined" color="neutral">
<FilterListIcon />
</IconButton>
</Tooltip>
)}
</Box>
);
};
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<Order>("asc");
const [orderBy, setOrderBy] = React.useState<keyof ProductRow>("name");
const [selected, setSelected] = React.useState<readonly string[]>([]);
const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(5);
const handleRequestSort = (
event: React.MouseEvent<unknown>,
property: keyof ProductRow,
) => {
const isAsc = orderBy === property && order === "asc";
setOrder(isAsc ? "desc" : "asc");
setOrderBy(property);
};
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
const newSelected = rows.map((row) => row.id);
setSelected(newSelected);
return;
}
setSelected([]);
};
const handleClick = (event: React.MouseEvent<unknown>, 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 (
<>
<Typography level="h2">{t("inventory-subtitle")}</Typography>
<Typography level="body-lg">{t("inventory-header")}</Typography>
<div className="mt-4 flex items-center gap-3">
<Button
startDecorator={<AddIcon />}
onClick={() => navigate({ to: "/app/add-product" })}
variant="solid"
>
{t("add")}
</Button>
{productsIsLoading && <CircularProgress size="sm" />}
</div>
<Sheet
variant="outlined"
className="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white/80 shadow-sm"
>
<EnhancedTableToolbar numSelected={selected.length} />
<Table
aria-labelledby="tableTitle"
hoverRow
className="min-w-[960px] text-slate-700"
sx={{
"--TableCell-headBackground": "transparent",
"--TableCell-selectedBackground": (theme) =>
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" },
}}
>
<EnhancedTableHead
numSelected={selected.length}
order={order}
orderBy={orderBy}
onSelectAllClick={handleSelectAllClick}
onRequestSort={handleRequestSort}
rowCount={rows.length}
/>
<tbody>
{[...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 (
<tr
key={row.id}
onClick={(event) => 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)
: {}
}
>
<th scope="row" className="px-4 py-5">
<Checkbox
checked={isItemSelected}
slotProps={{ input: { "aria-labelledby": labelId } }}
sx={{ verticalAlign: "top" }}
/>
</th>
<th id={labelId} scope="row" className="px-6 py-5">
<div className="flex items-center gap-4">
<Avatar size="lg" variant="soft" src={row.imageUrl} />
<div>
<Typography
level="title-md"
className="text-slate-900"
>
{row.name}
</Typography>
<Typography
level="body-sm"
className="text-slate-500"
>
{row.description}
</Typography>
</div>
</div>
</th>
<td className="px-6 py-5">
<Typography level="title-md">{row.price}</Typography>
<Typography level="body-sm" className="text-slate-400">
{t("currency")}
</Typography>
</td>
<td className="px-6 py-5">
<Chip
variant="soft"
color={
row.stockStatus === "low" ? "warning" : "success"
}
size="lg"
className="px-3"
>
{row.stock}
</Chip>
<Chip
variant="soft"
color={
row.stockStatus === "missing" ? "danger" : "success"
}
size="md"
className="ml-2 mt-2"
>
{row.stockLabel}
</Chip>
</td>
<td className="px-6 py-5">
<Typography level="title-md">{row.location}</Typography>
<Typography level="body-sm" className="text-slate-500">
{row.locationDetail}
</Typography>
</td>
<td className="px-6 py-5">
<Typography level="title-md">{row.expiryDate}</Typography>
</td>
<td className="px-6 py-5">
<Typography level="title-md">{row.refillDate}</Typography>
</td>
<td className="px-6 py-5 text-right">
<Button
onClick={() =>
navigate({
to: `/app/view-product?product=${row.uuid}`,
})
}
variant="outlined"
size="sm"
>
{t("details")}
</Button>
</td>
</tr>
);
})}
{emptyRows > 0 && (
<tr
style={
{
height: `calc(${emptyRows} * 40px)`,
"--TableRow-hoverBackground": "transparent",
} as React.CSSProperties
}
>
<td colSpan={8} aria-hidden />
</tr>
)}
</tbody>
<tfoot>
<tr>
<td colSpan={8}>
<Box
className="px-6 py-4 text-slate-600"
sx={{
display: "flex",
alignItems: "center",
gap: 2,
justifyContent: "flex-end",
}}
>
<FormControl orientation="horizontal" size="sm">
<FormLabel>{t("rows-per-page")}</FormLabel>
<Select
onChange={handleChangeRowsPerPage}
value={rowsPerPage}
>
<Option value={5}>5</Option>
<Option value={10}>10</Option>
<Option value={25}>25</Option>
</Select>
</FormControl>
<Typography sx={{ textAlign: "center", minWidth: 120 }}>
{labelDisplayedRows({
from: rows.length === 0 ? 0 : page * rowsPerPage + 1,
to: getLabelDisplayedRowsTo(),
count: rows.length === -1 ? -1 : rows.length,
})}
</Typography>
<Box sx={{ display: "flex", gap: 1 }}>
<IconButton
size="sm"
color="neutral"
variant="outlined"
disabled={page === 0}
onClick={() => handleChangePage(page - 1)}
sx={{ bgcolor: "background.surface" }}
>
<KeyboardArrowLeftIcon />
</IconButton>
<IconButton
size="sm"
color="neutral"
variant="outlined"
disabled={
rows.length !== -1
? page >= Math.ceil(rows.length / rowsPerPage) - 1
: false
}
onClick={() => handleChangePage(page + 1)}
sx={{ bgcolor: "background.surface" }}
>
<KeyboardArrowRightIcon />
</IconButton>
</Box>
</Box>
</td>
</tr>
</tfoot>
</Table>
</Sheet>
</>
);
};
+25
View File
@@ -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 (
<>
<Typography>{t("product-details")}</Typography>
{productDetailsLoading && <CircularProgress size="sm" />}
</>
);
};
+21
View File
@@ -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,
}
+6 -4
View File
@@ -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 (
<div>
<h1>Layout</h1>
<Outlet />
<div className="flex min-h-screen w-full bg-[#f7f9fc]">
<Sidebar />
<main className="flex-1 px-8 py-6">
<Outlet />
</main>
</div>
);
}
@@ -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 (
<>
<p>Inventar</p>
</>
);
return <InventoryPage />;
}
@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/app/_hiddenLayout/profile')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/app/_hiddenLayout/profile"!</div>
}
@@ -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 <div>Hello "/app/view-product"!</div>;
const { product } = Route.useSearch();
return <ViewProduct uuid={product} />;
}
+41
View File
@@ -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;
}
};