refactor: update ui and make it modern

This commit is contained in:
2026-09-01 00:28:58 +02:00
parent ef79a07a55
commit d6603577a2
29 changed files with 1925 additions and 2014 deletions
+380 -236
View File
@@ -1,16 +1,53 @@
import { ChangeEvent, MouseEvent, useEffect, useState } from "react";
import { Avatar, Button, Checkbox, Chip, CircularProgress, Sheet, Table, Typography, } from "@mui/joy";
import { ChangeEvent, Fragment, MouseEvent, useEffect, useMemo, useState } from "react";
import { Button, Checkbox, Chip, CircularProgress, Input, Sheet, Table, Typography } from "@mui/joy";
import { useNavigate } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import { ChevronDown, Plus, Search, Trash2 } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { deleteSelectedProducts, getProducts } from "../utils/api/products";
import { formatDate } from "../utils/uxFncs";
import Cookies from "js-cookie";
import type { AlertInterface, ProductRow } from "../misc/interfaces";
import type { ApiError } from "../utils/api/apiError";
import CategoryIcon from "@mui/icons-material/Category";
import { MyAlert } from "../components/MyAlert.tsx";
import { PageHeader } from "../components/PageHeader.tsx";
import { StatTiles } from "../components/StatTiles.tsx";
import { Mono } from "../components/Mono.tsx";
import { ProductStatusBar } from "../components/ProductStatusBar.tsx";
import { getExpiryDays, getProductStatus, isExpired, isExpiringSoon, isLowStock } from "../utils/productStatus";
const relativeColor = {
expired: "var(--joy-palette-danger-solidBg)",
"expiring-soon": "var(--joy-palette-warning-solidBg)",
ok: "var(--joy-palette-text-tertiary)",
} as const;
// Small "in N days" / "Expired N days ago" line shown under a row's expiry date.
const ExpiryRelative = ({ expiryDate }: { expiryDate: string | null }) => {
const { t } = useTranslation();
const expiry = getExpiryDays(expiryDate);
if (!expiry) {
return null;
}
const text =
expiry.status === "expired"
? t("days-since-expired", { count: Math.abs(expiry.days) })
: t("days-until-expiry", { count: expiry.days });
return (
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: relativeColor[expiry.status] }}>
{text}
</Typography>
);
};
type FilterKey = "all" | "expired" | "soon" | "low";
const matchesFilter = (row: ProductRow, filter: FilterKey) => {
if (filter === "all") return true;
if (filter === "expired") return isExpired(row.expiryDateRaw);
if (filter === "soon") return isExpiringSoon(row.expiryDateRaw);
return isLowStock(Number(row.stockLabel));
};
export const InventoryPage = () => {
const { t } = useTranslation();
@@ -22,6 +59,9 @@ export const InventoryPage = () => {
header: "",
text: "",
});
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<FilterKey>("all");
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const showError = (error: unknown) => {
const errorCode = (error as { code?: string })?.code;
@@ -49,24 +89,77 @@ export const InventoryPage = () => {
}
}, [productsError, productsErrorObj]);
const rows: ProductRow[] = (productsData ?? []).map(
(product: any, index: number) => ({
id: String(product?.uuid ?? index),
uuid: String(product?.uuid ?? ""),
name: product?.name ?? t("product-name"),
description: product?.description ?? "",
imageUrl: product?.picture ?? undefined,
price: product?.price ? String(product.price) : "-",
stock: `${product?.amount ?? 0} ${t("pcs")}`,
stockLabel: String(product?.amount ?? 0),
stockStatus: (product?.amount ?? 0) > 0 ? "ok" : "missing",
location: product?.storage_location_name ?? "-",
locationDetail: "",
expiryDate: formatDate(product?.expiry_date),
refillDate: formatDate(product?.bottling_date),
}),
const rows: ProductRow[] = useMemo(
() =>
(productsData ?? []).map((product: any, index: number) => ({
id: String(product?.uuid ?? index),
uuid: String(product?.uuid ?? ""),
name: product?.name ?? t("product-name"),
description: product?.description ?? "",
imageUrl: product?.picture ?? undefined,
price: product?.price ? String(product.price) : "-",
stock: `${product?.amount ?? 0} ${t("pcs")}`,
stockLabel: String(product?.amount ?? 0),
stockStatus: (product?.amount ?? 0) > 0 ? "ok" : "missing",
location: product?.storage_location_name ?? "-",
locationDetail: "",
expiryDate: formatDate(product?.expiry_date),
expiryDateRaw: product?.expiry_date ?? null,
refillDate: formatDate(product?.bottling_date),
})),
[productsData, t],
);
const stats = useMemo(
() => ({
expired: rows.filter((row) => isExpired(row.expiryDateRaw)).length,
expiringSoon: rows.filter((row) => isExpiringSoon(row.expiryDateRaw)).length,
lowStock: rows.filter((row) => isLowStock(Number(row.stockLabel))).length,
unitsStored: rows.reduce((sum, row) => sum + Number(row.stockLabel), 0),
}),
[rows],
);
const filteredRows = useMemo(() => {
const query = search.trim().toLowerCase();
return rows
.filter((row) => matchesFilter(row, filter))
.filter(
(row) =>
!query ||
row.name.toLowerCase().includes(query) ||
row.description.toLowerCase().includes(query),
);
}, [rows, search, filter]);
const groups = useMemo(() => {
const map = new Map<string, ProductRow[]>();
for (const row of filteredRows) {
const bucket = map.get(row.location) ?? [];
bucket.push(row);
map.set(row.location, bucket);
}
return Array.from(map.entries()).map(([name, groupRows]) => ({
name,
rows: groupRows,
units: groupRows.reduce((sum, row) => sum + Number(row.stockLabel), 0),
}));
}, [filteredRows]);
const storageCount = useMemo(() => new Set(rows.map((row) => row.location)).size, [rows]);
const toggleGroup = (name: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
const [selected, setSelected] = useState<readonly string[]>([]);
const { mutate: deleteSelection, isPending: isDeleting } = useMutation({
@@ -79,81 +172,104 @@ export const InventoryPage = () => {
});
const handleSelectAllClick = (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
setSelected(rows.map((row) => row.id));
return;
}
setSelected([]);
setSelected(event.target.checked ? filteredRows.map((row) => row.id) : []);
};
const handleClick = (_event: 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);
setSelected((prev) =>
prev.includes(id) ? prev.filter((rowId) => rowId !== id) : [...prev, id],
);
};
const filterTabs: { key: FilterKey; label: string; count: number; color: "neutral" | "danger" | "warning" }[] = [
{ key: "all", label: t("filter-all"), count: rows.length, color: "neutral" },
{ key: "expired", label: t("filter-expired"), count: stats.expired, color: "danger" },
{ key: "soon", label: t("filter-soon"), count: stats.expiringSoon, color: "warning" },
{ key: "low", label: t("filter-low"), count: stats.lowStock, color: "neutral" },
];
return (
<>
<Typography level="h2">{t("inventory")}</Typography>
<Typography level="body-lg">{t("inventory-subtitle")}</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 className="space-y-6">
<PageHeader
title={t("inventory")}
subtitle={t("inventory-summary", { count: rows.length, storages: storageCount })}
actions={
<Button
startDecorator={<Plus size={16} />}
onClick={() => navigate({ to: "/app/add-product" })}
variant="solid"
className="btn-lift rounded-2xl"
>
{t("add")}
</Button>
}
/>
{productsIsLoading && <CircularProgress size="sm" />}
{alert.isAlert && <MyAlert type={alert.type} header={alert.header} text={alert.text} />}
<StatTiles
expired={stats.expired}
expiringSoon={stats.expiringSoon}
lowStock={stats.lowStock}
unitsStored={stats.unitsStored}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("search-products")}
startDecorator={<Search size={16} />}
size="lg"
variant="outlined"
className="rounded-2xl sm:max-w-xs"
/>
<div className="flex flex-wrap gap-2">
{filterTabs.map((tab) => (
<Chip
key={tab.key}
variant={filter === tab.key ? "solid" : "soft"}
color={filter === tab.key ? "neutral" : tab.color}
onClick={() => setFilter(tab.key)}
className="cursor-pointer rounded-full px-3 transition-all duration-150 active:scale-95"
>
{tab.label} {tab.count}
</Chip>
))}
</div>
</div>
{alert.isAlert && (
<MyAlert type={alert.type} header={alert.header} text={alert.text} />
)}
<Sheet
variant="outlined"
className="mt-6 flex min-h-0 w-full max-w-full flex-col overflow-hidden rounded-2xl shadow-sm sm:h-[calc(100vh-260px)]"
sx={{
border: "1px solid",
borderColor: "divider",
bgcolor: "background.surface",
}}
className="flex min-h-0 w-full max-w-full flex-col overflow-hidden rounded-2xl"
sx={{ border: "1px solid", borderColor: "divider", bgcolor: "background.surface" }}
>
<div
className="flex items-center justify-between px-6 py-4"
style={{
borderBottom: "1px solid var(--joy-palette-divider)",
color: "var(--joy-palette-text-secondary)",
}}
>
<Typography level="body-lg" fontWeight="bold">
{t("inventory")}
</Typography>
<Button
size="sm"
color="danger"
variant="solid"
disabled={selected.length === 0 || isDeleting}
onClick={() => deleteSelection([...selected])}
{selected.length > 0 && (
<div
className="flex items-center justify-between px-6 py-3"
style={{ borderBottom: "1px solid var(--joy-palette-divider)" }}
>
Delete
</Button>
</div>
<div className="flex-1 overflow-auto">
<Typography level="body-sm" sx={{ color: "var(--joy-palette-text-secondary)" }}>
{selected.length} {t("selected")}
</Typography>
<Button
size="sm"
color="danger"
variant="solid"
startDecorator={<Trash2 size={14} />}
disabled={isDeleting}
onClick={() => deleteSelection([...selected])}
className="rounded-xl"
>
{t("delete")}
</Button>
</div>
)}
{/* Desktop: grouped table */}
<div className="hidden overflow-auto lg:block">
<Table
aria-labelledby="tableTitle"
stickyHeader
stripe="odd"
variant="plain"
@@ -162,57 +278,19 @@ export const InventoryPage = () => {
sx={{
tableLayout: "fixed",
color: "var(--joy-palette-text-secondary)",
"--TableCell-headBackground":
"var(--joy-palette-background-level1)",
"& thead": {
position: "sticky",
top: 0,
zIndex: 3,
backgroundColor: "var(--joy-palette-background-level1)",
},
"& thead tr": {
backgroundColor: "var(--joy-palette-background-level1)",
},
"--TableCell-headBackground": "var(--joy-palette-background-level1)",
"& thead th": {
zIndex: 2,
backgroundColor: "var(--joy-palette-background-level1)",
backgroundImage: "none",
color: "var(--joy-palette-text-secondary)",
},
"& thead th:nth-child(1)": {
width: "44px",
},
"& thead th:nth-child(2)": {
width: "22%",
minWidth: "160px",
},
"& thead th:nth-child(3)": {
width: "9%",
minWidth: "70px",
},
"& thead th:nth-child(4)": {
width: "12%",
minWidth: "90px",
},
"& thead th:nth-child(5)": {
width: "17%",
minWidth: "120px",
},
"& thead th:nth-child(6)": {
width: "13%",
minWidth: "100px",
},
"& thead th:nth-child(7)": {
width: "13%",
minWidth: "100px",
},
"& thead th:nth-child(8)": {
width: "14%",
minWidth: "100px",
},
"& tr > *:nth-child(n+4)": { textAlign: "left" },
"& thead th:nth-child(1)": { width: "40px" },
"& thead th:nth-child(2)": { width: "26%" },
"& thead th:nth-child(6)": { width: "8%" },
"& tr > *:nth-child(n+3)": { textAlign: "left" },
"& tbody tr": {
borderTop: "1px solid var(--joy-palette-divider)",
transition: "background-color 120ms ease",
},
}}
>
@@ -220,134 +298,200 @@ export const InventoryPage = () => {
<tr>
<th className="px-2 py-4">
<Checkbox
checked={rows.length > 0 && selected.length === rows.length}
indeterminate={
selected.length > 0 && selected.length < rows.length
}
checked={filteredRows.length > 0 && selected.length === filteredRows.length}
indeterminate={selected.length > 0 && selected.length < filteredRows.length}
onChange={handleSelectAllClick}
slotProps={{ input: { "aria-label": "select all" } }}
sx={{ verticalAlign: "sub" }}
/>
</th>
<th className="px-3 py-4">{t("product-name")}</th>
<th className="px-3 py-4">{t("price")}</th>
<th className="px-3 py-4">{t("stock")}</th>
<th className="px-3 py-4">{t("storage-place")}</th>
<th className="px-3 py-4">{t("price")}</th>
<th className="px-3 py-4">{t("expiry-date")}</th>
<th className="px-3 py-4">{t("bottling-date")}</th>
<th className="px-3 py-4 text-right">{t("actions")}</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => {
const isItemSelected = selected.includes(row.id);
const labelId = `inventory-checkbox-${index}`;
{groups.map((group) => {
const isCollapsed = collapsedGroups.has(group.name);
return (
<tr
key={row.id}
onClick={(event) => handleClick(event, row.id)}
role="checkbox"
aria-checked={isItemSelected}
tabIndex={-1}
>
<th scope="row" className="px-2 py-5">
<Checkbox
checked={isItemSelected}
slotProps={{ input: { "aria-labelledby": labelId } }}
sx={{ verticalAlign: "top" }}
/>
</th>
<th
id={labelId}
scope="row"
className="px-3 py-5 overflow-hidden"
>
<div className="flex min-w-0 items-center gap-3">
<Avatar size="lg" variant="soft" className="shrink-0">
<CategoryIcon />
</Avatar>
<div className="min-w-0">
<Typography
level="title-md"
className="truncate"
sx={{ color: "var(--joy-palette-text-primary)" }}
<Fragment key={`group-${group.name}`}>
<tr>
<td colSpan={6} className="px-3 py-3" style={{ backgroundColor: "var(--joy-palette-background-level1)" }}>
<button
type="button"
onClick={() => toggleGroup(group.name)}
className="flex items-center gap-2 font-semibold"
style={{ color: "var(--joy-palette-text-primary)" }}
>
<ChevronDown
size={16}
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? "-rotate-90" : ""}`}
/>
{group.name}
<span className="font-normal" style={{ color: "var(--joy-palette-text-tertiary)" }}>
{group.rows.length} {t("products")} &middot; {group.units} {t("units")}
</span>
</button>
</td>
</tr>
{!isCollapsed &&
group.rows.map((row) => {
const isItemSelected = selected.includes(row.id);
const status = getProductStatus(row.expiryDateRaw);
return (
<tr
key={row.id}
onClick={(event) => handleClick(event, row.id)}
role="checkbox"
aria-checked={isItemSelected}
>
{row.name}
</Typography>
<Typography
level="body-sm"
className="truncate"
sx={{ color: "var(--joy-palette-text-tertiary)" }}
>
{row.description}
</Typography>
</div>
</div>
</th>
<td className="px-3 py-5 overflow-hidden">
<Typography level="title-md" className="truncate">
{row.price}
</Typography>
<Typography
level="body-sm"
className="truncate"
sx={{ color: "var(--joy-palette-neutral-400)" }}
>
{Cookies.get("currency")}
</Typography>
</td>
<td className="px-3 py-5 overflow-hidden">
<Chip
variant="soft"
color="neutral"
size="lg"
className="max-w-full px-3"
>
<span className="truncate">{row.stock}</span>
</Chip>
</td>
<td className="px-3 py-5 overflow-hidden">
<Typography level="title-md" className="truncate">
{row.location}
</Typography>
<Typography
level="body-sm"
className="truncate"
sx={{ color: "var(--joy-palette-text-tertiary)" }}
>
{row.locationDetail}
</Typography>
</td>
<td className="px-3 py-5 overflow-hidden">
<Typography level="title-md" className="truncate">
{row.expiryDate}
</Typography>
</td>
<td className="px-3 py-5 overflow-hidden">
<Typography level="title-md" className="truncate">
{row.refillDate}
</Typography>
</td>
<td className="px-3 py-5 text-right">
<Button
onClick={() =>
navigate({
to: `/app/view-product?product=${row.uuid}`,
})
}
variant="outlined"
size="sm"
>
{t("details")}
</Button>
</td>
</tr>
<td className="px-2 py-4">
<Checkbox checked={isItemSelected} sx={{ verticalAlign: "top" }} />
</td>
<td className="px-3 py-4">
<div className="flex min-w-0 items-stretch gap-3">
<ProductStatusBar status={status} />
<div className="min-w-0">
<Typography level="title-md" className="truncate" sx={{ color: "var(--joy-palette-text-primary)" }}>
{row.name}
</Typography>
<Typography level="body-sm" className="truncate normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{row.description}
</Typography>
</div>
</div>
</td>
<td className="px-3 py-4">
<Typography
level="title-md"
sx={{
color: isLowStock(Number(row.stockLabel))
? "var(--joy-palette-danger-solidBg)"
: "var(--joy-palette-text-primary)",
}}
>
{row.stock}
</Typography>
</td>
<td className="px-3 py-4">
<Mono>
<Typography level="body-sm" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{row.price === "-" ? "-" : `${row.price} ${Cookies.get("currency") ?? ""}`}
</Typography>
</Mono>
</td>
<td className="px-3 py-4">
<Mono>
<Typography level="title-md" className="truncate" sx={{ color: "var(--joy-palette-text-primary)" }}>
{row.expiryDate}
</Typography>
</Mono>
<ExpiryRelative expiryDate={row.expiryDateRaw} />
</td>
<td className="px-3 py-4 text-right">
<Button
onClick={() => navigate({ to: `/app/view-product?product=${row.uuid}` })}
variant="outlined"
size="sm"
className="rounded-xl"
>
{t("details")}
</Button>
</td>
</tr>
);
})}
</Fragment>
);
})}
</tbody>
</Table>
</div>
{/* Mobile: grouped card list */}
<div className="lg:hidden">
{groups.map((group) => {
const isCollapsed = collapsedGroups.has(group.name);
return (
<div key={group.name}>
<button
type="button"
onClick={() => toggleGroup(group.name)}
className="flex w-full items-center gap-2 px-4 py-3 text-left font-semibold"
style={{
backgroundColor: "var(--joy-palette-background-level1)",
color: "var(--joy-palette-text-primary)",
borderTop: "1px solid var(--joy-palette-divider)",
}}
>
<ChevronDown
size={16}
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? "-rotate-90" : ""}`}
/>
{group.name}
<span className="ml-auto font-normal" style={{ color: "var(--joy-palette-text-tertiary)" }}>
{group.rows.length}
</span>
</button>
<div
className="grid transition-[grid-template-rows] duration-200 ease-out"
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
>
<div className="overflow-hidden">
{group.rows.map((row) => {
const status = getProductStatus(row.expiryDateRaw);
return (
<button
key={row.id}
type="button"
onClick={() => navigate({ to: `/app/view-product?product=${row.uuid}` })}
className="flex w-full items-stretch gap-3 px-4 py-3 text-left"
style={{ borderTop: "1px solid var(--joy-palette-divider)" }}
>
<ProductStatusBar status={status} />
<div className="min-w-0 flex-1">
<Typography level="title-md" className="truncate" sx={{ color: "var(--joy-palette-text-primary)" }}>
{row.name}
</Typography>
<Mono>
<Typography level="body-sm" className="normal-case" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{row.expiryDate}
</Typography>
</Mono>
</div>
<Typography
level="title-md"
className="shrink-0"
sx={{
color: isLowStock(Number(row.stockLabel))
? "var(--joy-palette-danger-solidBg)"
: "var(--joy-palette-text-primary)",
}}
>
{row.stockLabel} <span className="text-sm font-normal">{t("pcs")}</span>
</Typography>
</button>
);
})}
</div>
</div>
</div>
);
})}
<div className="p-4">
<Button
startDecorator={<Plus size={16} />}
onClick={() => navigate({ to: "/app/add-product" })}
variant="solid"
size="lg"
className="w-full rounded-2xl"
>
{t("add-product")}
</Button>
</div>
</div>
</Sheet>
</>
</div>
);
};