From d6603577a23340a24388585dc87e0c6c5eda8677 Mon Sep 17 00:00:00 2001 From: Theis Gaedigk Date: Tue, 1 Sep 2026 00:28:58 +0200 Subject: [PATCH] refactor: update ui and make it modern --- .gitignore | 1 + frontend/package.json | 4 +- frontend/src/components/AmountStepper.tsx | 61 ++ frontend/src/components/LoginCard.tsx | 111 ++-- frontend/src/components/Mono.tsx | 16 + frontend/src/components/MyAlert.tsx | 2 +- frontend/src/components/PageHeader.tsx | 24 + frontend/src/components/ProductStatusBar.tsx | 43 ++ frontend/src/components/Sidebar.tsx | 241 +++---- frontend/src/components/StatTiles.tsx | 53 ++ frontend/src/components/StorageRow.tsx | 37 +- .../src/components/modals/AddStorageModal.tsx | 18 +- .../components/modals/ChangePasswordModal.tsx | 22 +- frontend/src/index.css | 55 +- frontend/src/main.tsx | 5 + frontend/src/misc/interfaces.ts | 1 + frontend/src/pages/AddProduct.tsx | 382 ++++------- frontend/src/pages/Inventory.tsx | 616 +++++++++++------- frontend/src/pages/ProductQuickView.tsx | 433 ++++-------- frontend/src/pages/Settings.tsx | 336 +++++----- frontend/src/pages/Storages.tsx | 189 +++--- frontend/src/pages/ViewProduct.tsx | 460 ++++++------- frontend/src/routes/app/_hiddenLayout.tsx | 8 +- frontend/src/theme.ts | 324 +++++---- frontend/src/utils/i18n/locales/de/de.json | 45 +- frontend/src/utils/i18n/locales/en/en.json | 45 +- frontend/src/utils/productStatus.ts | 53 ++ package-lock.json | 351 ++-------- package.json | 3 +- 29 files changed, 1925 insertions(+), 2014 deletions(-) create mode 100644 frontend/src/components/AmountStepper.tsx create mode 100644 frontend/src/components/Mono.tsx create mode 100644 frontend/src/components/PageHeader.tsx create mode 100644 frontend/src/components/ProductStatusBar.tsx create mode 100644 frontend/src/components/StatTiles.tsx create mode 100644 frontend/src/utils/productStatus.ts diff --git a/.gitignore b/.gitignore index 0e143bb..a0aae2f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ tsconfig.tsbuildinfo # Icon must end with two \r +.claude # Thumbnails ._* diff --git a/frontend/package.json b/frontend/package.json index d5f0040..a6e3be8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,8 +16,8 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@fontsource/inter": "^5.2.8", - "@mui/icons-material": "^9.0.1", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", "@mui/joy": "^5.0.0-beta.52", "@tailwindcss/vite": "^4.3.0", "@tanstack/react-form": "^1.32.0", diff --git a/frontend/src/components/AmountStepper.tsx b/frontend/src/components/AmountStepper.tsx new file mode 100644 index 0000000..9b5e687 --- /dev/null +++ b/frontend/src/components/AmountStepper.tsx @@ -0,0 +1,61 @@ +import { IconButton, Input } from "@mui/joy"; +import { Minus, Plus } from "lucide-react"; + +interface AmountStepperProps { + value: number; + onChange: (value: number) => void; + onBlur?: () => void; + min?: number; +} + +export const AmountStepper = ({ + value, + onChange, + onBlur, + min = 0, +}: AmountStepperProps) => { + const step = (delta: number) => onChange(Math.max(min, (value || 0) + delta)); + + return ( +
+ step(-1)} + className="transition-transform active:scale-90" + sx={{ borderRadius: 0 }} + > + + + { + const nextValue = Number(e.target.value); + onChange(Number.isNaN(nextValue) ? min : Math.max(min, nextValue)); + }} + onBlur={onBlur} + variant="plain" + size="lg" + slotProps={{ input: { className: "text-center font-semibold" } }} + sx={{ + borderRadius: 0, + "--Input-focusedThickness": "0px", + bgcolor: "var(--joy-palette-background-surface)", + }} + /> + step(1)} + className="transition-transform active:scale-90" + sx={{ borderRadius: 0 }} + > + + +
+ ); +}; diff --git a/frontend/src/components/LoginCard.tsx b/frontend/src/components/LoginCard.tsx index 6dfcbc5..d15e3e6 100644 --- a/frontend/src/components/LoginCard.tsx +++ b/frontend/src/components/LoginCard.tsx @@ -5,12 +5,16 @@ import { signInUser } from "../utils/api/auth"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useEffect, useState } from "react"; +import { useColorScheme } from "@mui/joy/styles"; +import { Layers } from "lucide-react"; import type { AlertInterface } from "../misc/interfaces"; +import { changeTranslation } from "../utils/uxFncs"; import { MyAlert } from "./MyAlert.tsx"; export const LoginCard = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const navigate = useNavigate(); + const { mode, setMode } = useColorScheme(); const search: { loggedOut?: boolean } = useSearch({ from: "/login" }); const [alert, setAlert] = useState({ isAlert: false, @@ -72,37 +76,59 @@ export const LoginCard = () => { }); return ( -
-
-
-
+
+
+
+ + + + {t("app-title")} +
+
+

+ {t("login-tagline-1")} +

+

+ {t("login-tagline-2")} +

+
+

+ v0.9 MVP +

+
+ +
+
+
+ + + + + {t("app-title")} + +
+ +

- Stockhome + {t("app-title")}

-

+

{t("login")}

+
{ @@ -111,11 +137,7 @@ export const LoginCard = () => { }} > {alert.isAlert && ( - + )} {(field) => ( @@ -126,10 +148,6 @@ export const LoginCard = () => { variant="outlined" size="lg" className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} /> )} @@ -143,10 +161,6 @@ export const LoginCard = () => { variant="outlined" size="lg" className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} /> )} @@ -156,15 +170,28 @@ export const LoginCard = () => { size="lg" color="primary" variant="solid" - className="w-full rounded-2xl transition hover:-translate-y-0.5" - sx={{ - boxShadow: - "0 16px 36px color-mix(in srgb, var(--joy-palette-primary-solidBg) 35%, transparent)", - }} + className="btn-lift w-full rounded-2xl" > {t("login")} + +
+ + | + +
diff --git a/frontend/src/components/Mono.tsx b/frontend/src/components/Mono.tsx new file mode 100644 index 0000000..3a8e800 --- /dev/null +++ b/frontend/src/components/Mono.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; + +interface MonoProps { + children: ReactNode; + className?: string; +} + +// Small helper for the numeric/date values the mockups render in IBM Plex Mono. +export const Mono = ({ children, className = "" }: MonoProps) => ( + + {children} + +); diff --git a/frontend/src/components/MyAlert.tsx b/frontend/src/components/MyAlert.tsx index 738b114..56b182d 100644 --- a/frontend/src/components/MyAlert.tsx +++ b/frontend/src/components/MyAlert.tsx @@ -11,7 +11,7 @@ export const MyAlert = (props: MyAlertProps) => { {props.header}
diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx new file mode 100644 index 0000000..40808d5 --- /dev/null +++ b/frontend/src/components/PageHeader.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; +import { Typography } from "@mui/joy"; + +interface PageHeaderProps { + title: string; + subtitle?: string; + actions?: ReactNode; +} + +export const PageHeader = ({ title, subtitle, actions }: PageHeaderProps) => ( +
+
+ + {title} + + {subtitle && ( + + {subtitle} + + )} +
+ {actions &&
{actions}
} +
+); diff --git a/frontend/src/components/ProductStatusBar.tsx b/frontend/src/components/ProductStatusBar.tsx new file mode 100644 index 0000000..1d4b7d8 --- /dev/null +++ b/frontend/src/components/ProductStatusBar.tsx @@ -0,0 +1,43 @@ +import { Chip } from "@mui/joy"; +import { useTranslation } from "react-i18next"; +import { getExpiryDays, type ProductStatus } from "../utils/productStatus"; + +const accentColor: Record = { + expired: "var(--joy-palette-danger-solidBg)", + "expiring-soon": "var(--joy-palette-warning-solidBg)", + ok: "transparent", +}; + +// Colored left-edge bar used on Inventory rows/cards to flag expiry status. +export const ProductStatusBar = ({ status }: { status: ProductStatus }) => ( + +); + +const chipColor: Record = { + expired: "danger", + "expiring-soon": "warning", + ok: "neutral", +}; + +// "Expires in N days" / "Expired N days ago" chip used on product detail headers. +export const ExpiryBadge = ({ 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("expires-in-days-badge", { count: expiry.days }); + + return ( + + {text} + + ); +}; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index b26e1ea..c71d40a 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,90 +1,77 @@ import { useState } from "react"; 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 StorageIcon from "@mui/icons-material/Storage"; -import SettingsIcon from "@mui/icons-material/Settings"; -import ExitToAppIcon from "@mui/icons-material/ExitToApp"; -import TranslateIcon from "@mui/icons-material/Translate"; -import Brightness4Icon from "@mui/icons-material/Brightness4"; -import MenuIcon from "@mui/icons-material/Menu"; -import CloseIcon from "@mui/icons-material/Close"; -import { useMatchRoute, useNavigate } from "@tanstack/react-router"; +import { Button, Chip, Typography } from "@mui/joy"; +import { Package, Plus, Layers, Settings, Menu, X, ChevronRight } from "lucide-react"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; import Cookies from "js-cookie"; -import { changeTranslation } from "../utils/uxFncs"; -import { useColorScheme } from "@mui/joy/styles"; -import { useLogout } from "../hooks/useLogout.ts"; +import { getProducts } from "../utils/api/products.ts"; +import { getStorages } from "../utils/api/storages.ts"; export const Sidebar = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const { logout } = useLogout(); - const matchRoute = useMatchRoute(); - const { mode, setMode } = useColorScheme(); + const pathname = useRouterState({ select: (state) => state.location.pathname }); const [isOpen, setIsOpen] = useState(false); - const btnClass = - "h-11 w-full justify-start! rounded-2xl px-4 text-left text-sm font-semibold transition [&_.MuiButton-startDecorator]:mr-3! [&_.MuiButton-startDecorator]:ml-0!"; + const { data: products } = useQuery({ queryKey: ["products"], queryFn: getProducts }); + const { data: storages } = useQuery({ queryKey: ["storages"], queryFn: getStorages }); - const variant = (to: string) => - !!matchRoute({ to, fuzzy: false }) ? "soft" : "plain"; + const btnClass = + "h-11 w-full justify-start! rounded-2xl px-4 text-left text-sm font-semibold transition-colors duration-150 [&_.MuiButton-startDecorator]:mr-3! [&_.MuiButton-startDecorator]:ml-0! [&_.MuiButton-endDecorator]:ml-auto!"; + + const isActive = (to: string) => pathname === to || pathname.startsWith(`${to}/`); const handleNavigate = (to: string) => { void navigate({ to }); setIsOpen(false); }; + const appName = Cookies.get("app-name") || t("app-title"); + const initials = appName.slice(0, 2).toUpperCase(); + + const navItems = [ + { to: "/app/inventory", label: t("inventory"), icon: , count: products?.length }, + { to: "/app/add-product", label: t("add"), icon: }, + { to: "/app/storages", label: t("storages"), icon: , count: storages?.length }, + { to: "/app/app-settings", label: t("settings"), icon: }, + ]; + return ( ); diff --git a/frontend/src/components/StatTiles.tsx b/frontend/src/components/StatTiles.tsx new file mode 100644 index 0000000..15f3af6 --- /dev/null +++ b/frontend/src/components/StatTiles.tsx @@ -0,0 +1,53 @@ +import { Typography } from "@mui/joy"; +import { useTranslation } from "react-i18next"; + +interface StatTilesProps { + expired: number; + expiringSoon: number; + lowStock: number; + unitsStored: number; +} + +export const StatTiles = ({ + expired, + expiringSoon, + lowStock, + unitsStored, +}: StatTilesProps) => { + const { t } = useTranslation(); + + const tiles = [ + { value: expired, label: t("stat-expired"), accent: "var(--joy-palette-danger-solidBg)" }, + { value: expiringSoon, label: t("stat-expiring-soon"), accent: "var(--joy-palette-warning-solidBg)" }, + { value: lowStock, label: t("stat-low-stock"), accent: "var(--joy-palette-neutral-500)" }, + { value: unitsStored, label: t("stat-units-stored"), accent: "var(--joy-palette-primary-solidBg)" }, + ]; + + return ( +
+ {tiles.map((tile) => ( +
+ +
+ + {tile.value} + + + {tile.label} + +
+
+ ))} +
+ ); +}; diff --git a/frontend/src/components/StorageRow.tsx b/frontend/src/components/StorageRow.tsx index 6b60a7f..473f38f 100644 --- a/frontend/src/components/StorageRow.tsx +++ b/frontend/src/components/StorageRow.tsx @@ -2,9 +2,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { deleteStorage, updateStorage } from "../utils/api/storages"; import { useForm } from "@tanstack/react-form"; import { useStore } from "@tanstack/react-store"; -import { Button, Input } from "@mui/joy"; +import { Button, IconButton, Input } from "@mui/joy"; +import { Trash2 } from "lucide-react"; import type { Storage } from "../misc/interfaces"; import { formatDate } from "../utils/uxFncs"; +import { Mono } from "./Mono.tsx"; import { useTranslation } from "react-i18next"; interface StorageRowProps { @@ -93,36 +95,37 @@ export const StorageRow = ({ storage, onError }: StorageRowProps) => { className="px-6 py-5 text-sm" style={{ color: "var(--joy-palette-text-tertiary)" }} > - {formatDate(storage.created_at)} + {formatDate(storage.created_at)} - {formatDate(storage.updated_at)} + {formatDate(storage.updated_at)}
- - + )} + deleteMutation.mutateAsync(storage.uuid)} disabled={mutation.isPending || deleteMutation.isPending} size="sm" className="rounded-xl" > - {deleteMutation.isPending ? "..." : t("delete")} - + +
diff --git a/frontend/src/components/modals/AddStorageModal.tsx b/frontend/src/components/modals/AddStorageModal.tsx index 53eba21..cde1dd7 100644 --- a/frontend/src/components/modals/AddStorageModal.tsx +++ b/frontend/src/components/modals/AddStorageModal.tsx @@ -52,13 +52,11 @@ export const AddStorageModal = (props: AddStorageModalProps) => { <> props.setOpen(false)}> @@ -83,10 +81,6 @@ export const AddStorageModal = (props: AddStorageModalProps) => { variant="outlined" size="lg" className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} /> )} @@ -99,10 +93,6 @@ export const AddStorageModal = (props: AddStorageModalProps) => { variant="outlined" size="lg" className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} /> )} @@ -111,11 +101,7 @@ export const AddStorageModal = (props: AddStorageModalProps) => { size="lg" color="primary" variant="solid" - className="rounded-2xl transition hover:-translate-y-0.5" - sx={{ - boxShadow: - "0 16px 36px color-mix(in srgb, var(--joy-palette-primary-solidBg) 35%, transparent)", - }} + className="btn-lift rounded-2xl" > {t("submit")} diff --git a/frontend/src/components/modals/ChangePasswordModal.tsx b/frontend/src/components/modals/ChangePasswordModal.tsx index c088c80..50e6630 100644 --- a/frontend/src/components/modals/ChangePasswordModal.tsx +++ b/frontend/src/components/modals/ChangePasswordModal.tsx @@ -85,13 +85,11 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => { <> props.setOpen(false)}> @@ -114,10 +112,6 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => { variant="outlined" size="lg" className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} /> )} @@ -131,10 +125,6 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => { variant="outlined" size="lg" className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} /> )} @@ -148,10 +138,6 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => { variant="outlined" size="lg" className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} /> )} @@ -160,11 +146,7 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => { size="lg" color="primary" variant="solid" - className="rounded-2xl transition hover:-translate-y-0.5" - sx={{ - boxShadow: - "0 16px 36px color-mix(in srgb, var(--joy-palette-primary-solidBg) 35%, transparent)", - }} + className="btn-lift rounded-2xl" > {t("change")} diff --git a/frontend/src/index.css b/frontend/src/index.css index a461c50..06da765 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1 +1,54 @@ -@import "tailwindcss"; \ No newline at end of file +@import "tailwindcss"; + +@keyframes page-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-page-in { + animation: page-in 220ms ease-out; +} + +.animate-fade-in { + animation: fade-in 180ms ease-out; +} + +.btn-lift { + transition: + transform 150ms ease, + box-shadow 150ms ease; +} + +.btn-lift:hover { + transform: translateY(-1px); +} + +.btn-lift:active { + transform: translateY(0); +} + +@media (prefers-reduced-motion: reduce) { + .animate-page-in, + .animate-fade-in, + .btn-lift { + animation: none !important; + transition: none !important; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 820ab0f..f0cd7c7 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,10 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import "@fontsource/ibm-plex-sans/400.css"; +import "@fontsource/ibm-plex-sans/500.css"; +import "@fontsource/ibm-plex-sans/600.css"; +import "@fontsource/ibm-plex-sans/700.css"; +import "@fontsource/ibm-plex-mono/500.css"; import "./index.css"; import "./utils/i18n"; import App from "./App.tsx"; diff --git a/frontend/src/misc/interfaces.ts b/frontend/src/misc/interfaces.ts index 2bec59a..00cc8a1 100644 --- a/frontend/src/misc/interfaces.ts +++ b/frontend/src/misc/interfaces.ts @@ -59,6 +59,7 @@ export type ProductRow = { location: string; locationDetail: string; expiryDate: string; + expiryDateRaw: string | null; refillDate: string; }; diff --git a/frontend/src/pages/AddProduct.tsx b/frontend/src/pages/AddProduct.tsx index ac841c9..aedf010 100644 --- a/frontend/src/pages/AddProduct.tsx +++ b/frontend/src/pages/AddProduct.tsx @@ -1,28 +1,23 @@ import { useMutation, useQuery } from "@tanstack/react-query"; -import { - Box, - Button, - Chip, - Divider, - Input, - Option, - Select, - Typography, -} from "@mui/joy"; +import { Button, Input, Option, Select, Typography } from "@mui/joy"; import { useTranslation } from "react-i18next"; import { useEffect, useState } from "react"; import { useForm } from "@tanstack/react-form"; import { createProduct } from "../utils/api/products"; import { getStorages } from "../utils/api/storages"; -import type { - AlertInterface, - ProductFormValues, - Storage, -} from "../misc/interfaces"; +import type { AlertInterface, ProductFormValues, Storage } from "../misc/interfaces"; import type { ApiError } from "../utils/api/apiError"; import Cookies from "js-cookie"; +import { AmountStepper } from "../components/AmountStepper.tsx"; +import { PageHeader } from "../components/PageHeader.tsx"; import { MyAlert } from "../components/MyAlert.tsx"; +const cardSx = { + border: "1px solid", + borderColor: "divider", + bgcolor: "background.surface", +} as const; + export const AddProduct = () => { const { t } = useTranslation(); const [alert, setAlert] = useState({ @@ -86,56 +81,23 @@ export const AddProduct = () => { }); return ( - <> -
-
-
- - {t("add-product")} - - - {t("add-product-subtitle")} - -
- - {t("details")} - -
-
- + + +
{ + e.preventDefault(); + form.handleSubmit(); }} > - { - e.preventDefault(); - form.handleSubmit(); - }} - > -
-
+
+
+ + {t("product")} + +
- + {t("product-name")} @@ -143,75 +105,39 @@ export const AddProduct = () => { field.handleChange(e.target.value)} onBlur={field.handleBlur} size="lg" variant="outlined" className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} /> )}
- + {t("description")} {(field) => ( field.handleChange(e.target.value)} onBlur={field.handleBlur} size="lg" variant="outlined" className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} /> )}
-
+
- - {t("expiry-date")} - - - {(field) => ( - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - size="lg" - variant="outlined" - className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - }} - /> - )} - -
-
- + {t("bottling-date")} @@ -224,162 +150,122 @@ export const AddProduct = () => { size="lg" variant="outlined" className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - }} + /> + )} + +
+
+ + {t("expiry-date")} + + + {(field) => ( + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + size="lg" + variant="outlined" + className="rounded-2xl" /> )}
-
-
- - {t("inventory")} +
+ +
+ + {t("inventory")} + +
+
+ + {t("amount")} - -
-
- - {t("amount")} - - - {(field) => ( - { - const nextValue = Number(e.target.value); - field.handleChange( - Number.isNaN(nextValue) ? 0 : nextValue, - ); - }} - onBlur={field.handleBlur} - className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - }} - /> - )} - -
-
- - {t("price")} - - - {(field) => ( - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - size="lg" - variant="outlined" - className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - }} - /> - )} - - - {Cookies.get("currency")} - -
-
- - {t("storage-place")} - - - {(field) => ( - - )} - -
-
+ + {(field) => ( + + )} +
+
+ + {t("price")} + + + {(field) => ( + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + size="lg" + variant="outlined" + className="rounded-2xl" + endDecorator={ + + {Cookies.get("currency")} + + } + /> + )} + +
+
+ + {t("storage-place")} + + + {(field) => ( + + )} + +
+ + {t("amount-price-optional-hint")} +
-
- - {t("product-details")} - -
-
+ +
+ + {t("all-fields-editable-later")} + +
+
- {alert.isAlert && ( - - )} - - - +
+ + {alert.isAlert && ( +
+ +
+ )} + +
); }; diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index 4a29d5b..71ee468 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -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 ( + + {text} + + ); +}; + +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("all"); + const [collapsedGroups, setCollapsedGroups] = useState>(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(); + 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([]); const { mutate: deleteSelection, isPending: isDeleting } = useMutation({ @@ -79,81 +172,104 @@ export const InventoryPage = () => { }); const handleSelectAllClick = (event: ChangeEvent) => { - 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, 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 ( - <> - {t("inventory")} - {t("inventory-subtitle")} -
- - {productsIsLoading && } +
+ } + onClick={() => navigate({ to: "/app/add-product" })} + variant="solid" + className="btn-lift rounded-2xl" + > + {t("add")} + + } + /> + + {productsIsLoading && } + {alert.isAlert && } + + + +
+ setSearch(e.target.value)} + placeholder={t("search-products")} + startDecorator={} + size="lg" + variant="outlined" + className="rounded-2xl sm:max-w-xs" + /> +
+ {filterTabs.map((tab) => ( + setFilter(tab.key)} + className="cursor-pointer rounded-full px-3 transition-all duration-150 active:scale-95" + > + {tab.label} {tab.count} + + ))} +
- {alert.isAlert && ( - - )} -
- - {t("inventory")} - - -
-
+ + {selected.length} {t("selected")} + + +
+ )} + + {/* Desktop: grouped table */} +
{ 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 = () => { - - + - - {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 ( - handleClick(event, row.id)} - role="checkbox" - aria-checked={isItemSelected} - tabIndex={-1} - > - - + + + {!isCollapsed && + group.rows.map((row) => { + const isItemSelected = selected.includes(row.id); + const status = getProductStatus(row.expiryDateRaw); + return ( + handleClick(event, row.id)} + role="checkbox" + aria-checked={isItemSelected} > - {row.name} - - - {row.description} - - - - - - - - - - - + + + + + + + + ); + })} + ); })}
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" }} /> {t("product-name")}{t("price")} {t("stock")}{t("storage-place")}{t("price")} {t("expiry-date")}{t("bottling-date")} {t("actions")}
- - -
- - - -
- +
+ +
- - {row.price} - - - {Cookies.get("currency")} - - - - {row.stock} - - - - {row.location} - - - {row.locationDetail} - - - - {row.expiryDate} - - - - {row.refillDate} - - - -
+ + +
+ +
+ + {row.name} + + + {row.description} + +
+
+
+ + {row.stock} + + + + + {row.price === "-" ? "-" : `${row.price} ${Cookies.get("currency") ?? ""}`} + + + + + + {row.expiryDate} + + + + + +
+ + {/* Mobile: grouped card list */} +
+ {groups.map((group) => { + const isCollapsed = collapsedGroups.has(group.name); + return ( +
+ +
+
+ {group.rows.map((row) => { + const status = getProductStatus(row.expiryDateRaw); + return ( + + ); + })} +
+
+
+ ); + })} +
+ +
+
- +
); }; diff --git a/frontend/src/pages/ProductQuickView.tsx b/frontend/src/pages/ProductQuickView.tsx index e2ae529..a20e7fa 100644 --- a/frontend/src/pages/ProductQuickView.tsx +++ b/frontend/src/pages/ProductQuickView.tsx @@ -1,22 +1,27 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getStorages } from "../utils/api/storages.ts"; -import { Box, Button, Chip, CircularProgress, Divider, Input, Option, Select, Typography, } from "@mui/joy"; +import { Button, Chip, CircularProgress, Option, Select, Typography } from "@mui/joy"; import { useTranslation } from "react-i18next"; import { useEffect, useState } from "react"; import { useForm } from "@tanstack/react-form"; +import { Link } from "@tanstack/react-router"; import { getProductDetails, mutateProduct } from "../utils/api/products.ts"; import { toInputDate } from "../utils/uxFncs"; -import type { AlertInterface, productDetailsInterface, ProductFormValues, Storage, } from "../misc/interfaces"; +import type { + AlertInterface, + productDetailsInterface, + ProductFormValues, + Storage, +} from "../misc/interfaces"; import type { ApiError } from "../utils/api/apiError"; -import Cookies from "js-cookie"; -import ElectricBoltIcon from "@mui/icons-material/ElectricBolt"; +import { AmountStepper } from "../components/AmountStepper.tsx"; +import { ExpiryBadge } from "../components/ProductStatusBar.tsx"; import { MyAlert } from "../components/MyAlert.tsx"; export const ProductQuickView = () => { const uuid = window.location.search.split("uuid=")[1]; const { t } = useTranslation(); const queryClient = useQueryClient(); - const [success, setSuccess] = useState(false); const [alert, setAlert] = useState({ isAlert: false, type: "neutral", @@ -66,6 +71,8 @@ export const ProductQuickView = () => { } }, [storagesError, storagesErrorObj]); + // Only Amount/Expiry/Storage are shown here — name/description/bottling + // date/price stay in form state untouched and are sent back as-is on save. const form = useForm({ defaultValues: { amount: 0, @@ -86,15 +93,10 @@ export const ProductQuickView = () => { }); const { mutate, isPending } = useMutation({ - mutationFn: ({ - values, - uuid, - }: { - values: ProductFormValues; - uuid: string; - }) => mutateProduct(values, uuid), + mutationFn: ({ values, uuid }: { values: ProductFormValues; uuid: string }) => + mutateProduct(values, uuid), onSuccess: (_data, variables) => { - setSuccess(true); + setAlert({ isAlert: true, type: "success", header: t("success"), text: t("save") }); queryClient.invalidateQueries({ queryKey: ["product", variables.uuid] }); }, onError: showError, @@ -106,312 +108,127 @@ export const ProductQuickView = () => { } form.setFieldValue("amount", productDetails.amount ?? 0); - form.setFieldValue( - "bottling_date", - toInputDate(productDetails.bottling_date), - ); + form.setFieldValue("bottling_date", toInputDate(productDetails.bottling_date)); form.setFieldValue("description", productDetails.description ?? ""); form.setFieldValue("expiry_date", toInputDate(productDetails.expiry_date)); form.setFieldValue("name", productDetails.name ?? ""); form.setFieldValue("price", productDetails.price ?? ""); - form.setFieldValue( - "storage_location_uuid", - productDetails.storage_location_uuid ?? "", - ); + form.setFieldValue("storage_location_uuid", productDetails.storage_location_uuid ?? ""); }, [form, productDetails]); return ( - <> -
-
-
- - - {t("product-details-quick")} - - -
- - {t("details")} - -
+
+
+ + {t("scanned-badge")} + + {productDetailsLoading && } -
- {isSuccess && ( - -
{ - e.preventDefault(); - form.handleSubmit(); - }} - > -
-
-
- - {t("product-name")} - - - {(field) => ( - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - size="lg" - variant="outlined" - className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} - /> - )} - -
-
- - {t("description")} - - - {(field) => ( - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - size="lg" - variant="outlined" - className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} - /> - )} - -
-
-
- - {t("expiry-date")} - - - {(field) => ( - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - size="lg" - variant="outlined" - className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - }} - /> - )} - -
-
- - {t("bottling-date")} - - - {(field) => ( - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - size="lg" - variant="outlined" - className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - }} - /> - )} - -
-
-
-
-
- - {t("inventory")} - - -
-
- - {t("amount")} - - - {(field) => ( - { - const nextValue = Number(e.target.value); - field.handleChange( - Number.isNaN(nextValue) ? 0 : nextValue, - ); - }} - onBlur={field.handleBlur} - className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - }} - /> - )} - -
-
- - {t("price")} - - - {(field) => ( - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - size="lg" - variant="outlined" - className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - }} - /> - )} - - - {Cookies.get("currency")} - -
-
- - {t("storage-place")} - - - {(field) => ( - - )} - -
-
-
-
-
-
- - {t("product-details")} + + {isSuccess && ( + <> +
+ + {productDetails.name} -
+ + { + e.preventDefault(); + form.handleSubmit(); + }} + > +
+ + {t("amount-in-storage")} + + + {(field) => ( + + )} + + + {t("set-to-zero-hint")} + +
+ +
+ + {t("expiry-date")} + + + {(field) => ( + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + className="border-none bg-transparent text-right font-semibold outline-none" + style={{ color: "var(--joy-palette-danger-solidBg)" }} + /> + )} + +
+ +
+ + {t("storage-place")} + + + {(field) => ( + + )} + +
+ + + {t("open-full-details")} + + + {alert.isAlert && ( + + )} + + -
- {alert.isAlert && ( - - )} - -
- )} - + + + )} +
+
); }; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 5c6e1f0..55589f5 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -import { Button, Chip, CircularProgress, Divider, Input, Sheet, Typography, } from "@mui/joy"; +import { Button, CircularProgress, Divider, Input, Sheet, Typography } from "@mui/joy"; import { useForm } from "@tanstack/react-form"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import Cookies from "js-cookie"; @@ -7,12 +7,25 @@ import type { AlertInterface, SettingsIntf } from "../misc/interfaces"; import type { ApiError } from "../utils/api/apiError"; import { fetchSettings, mutateSettings } from "../utils/api/settings"; import { useEffect, useState } from "react"; +import { useColorScheme } from "@mui/joy/styles"; +import { ChevronRight } from "lucide-react"; import { ChangePasswordModal } from "../components/modals/ChangePasswordModal"; import { MyAlert } from "../components/MyAlert.tsx"; +import { PageHeader } from "../components/PageHeader.tsx"; +import { changeTranslation } from "../utils/uxFncs"; +import { useLogout } from "../hooks/useLogout.ts"; + +const cardSx = { + border: "1px solid", + borderColor: "divider", + bgcolor: "background.surface", +} as const; export const Settings = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); + const { mode, setMode } = useColorScheme(); + const { logout } = useLogout(); const [modal, setModal] = useState(false); const [alert, setAlert] = useState({ isAlert: false, @@ -85,177 +98,192 @@ export const Settings = () => { onError: showError, }); + const segmentBtn = (active: boolean) => ({ + className: "rounded-xl px-4", + variant: active ? ("solid" as const) : ("plain" as const), + color: active ? ("primary" as const) : ("neutral" as const), + sx: { color: active ? undefined : "var(--joy-palette-text-secondary)" }, + }); + return ( <>
-
-
- - {t("settings")} - - - {t("settings-sub")} - -
- - {t("preferences")} - -
-
+ - {alert.isAlert && ( )} + {settingsPending ? (
) : ( -
{ - e.preventDefault(); - form.handleSubmit(); - }} - > -
-
-
- - {t("app-name")} - - - {t("app-name-sub")} - - - {(field) => ( - field.handleChange(e.target.value)} - size="lg" - variant="outlined" - placeholder="Stockhome" - className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} - /> - )} - -
-
- - {t("currency")} - - - {t("currency-sub")} - - - {(field) => ( - field.handleChange(e.target.value)} - size="lg" - variant="outlined" - placeholder="EUR" - className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} - /> - )} - -
-
-
- - {t("quick-tips")} +
+
+ + + {t("general")} - -
+ { + e.preventDefault(); + form.handleSubmit(); + }} > -

{t("quick-tips-1")}

-

{t("quick-tips-2")}

+
+
+ + {t("app-name")} + + + {(field) => ( + field.handleChange(e.target.value)} + size="lg" + variant="outlined" + placeholder="Stockhome" + className="rounded-2xl" + /> + )} + + + {t("app-name-sub")} + +
+
+ + {t("currency")} + + + {(field) => ( + field.handleChange(e.target.value)} + size="lg" + variant="outlined" + placeholder="EUR" + className="rounded-2xl" + /> + )} + + + {t("currency-sub")} + +
+
+
+ +
+ + + + + + {t("appearance-language")} + + +
+
+ + {t("theme")} + +
+ + + +
+
+
+ + {t("language")} + +
+ + +
+
+
+ + + + {t("account")} + + +
+ + +
+
+ + {t("sign-out")} + + + {t("sign-out-sub")} + +
+ +
+
+
+
+ +
+ + {t("quick-tips")} + + +
+

{t("quick-tips-1")}

+

{t("quick-tips-2")}

-
- - -
- +
)} - +
); }; diff --git a/frontend/src/pages/Storages.tsx b/frontend/src/pages/Storages.tsx index 7a1299f..dce5b29 100644 --- a/frontend/src/pages/Storages.tsx +++ b/frontend/src/pages/Storages.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { getStorages } from "../utils/api/storages"; +import { getProducts } from "../utils/api/products"; import { Button, CircularProgress, Sheet, Table, Typography } from "@mui/joy"; import { useTranslation } from "react-i18next"; import type { AlertInterface, Storage } from "../misc/interfaces"; @@ -7,8 +8,9 @@ import type { ApiError } from "../utils/api/apiError"; import { StorageRow } from "../components/StorageRow"; import { useEffect, useState } from "react"; import { AddStorageModal } from "../components/modals/AddStorageModal"; -import AddIcon from "@mui/icons-material/Add"; +import { AlertTriangle, Plus } from "lucide-react"; import { MyAlert } from "../components/MyAlert.tsx"; +import { PageHeader } from "../components/PageHeader.tsx"; export const Storages = () => { const { t } = useTranslation(); @@ -40,6 +42,13 @@ export const Storages = () => { queryFn: () => getStorages(), }); + // Already cached by the sidebar/inventory query — just reused here for the count. + const { data: products } = useQuery<{ amount?: number }[]>({ + queryKey: ["products"], + queryFn: getProducts, + }); + const itemsStored = (products ?? []).reduce((sum, product) => sum + (product?.amount ?? 0), 0); + useEffect(() => { if (storagesError && storagesErrorObj) { showError(storagesErrorObj); @@ -49,30 +58,29 @@ export const Storages = () => { return ( <>
-
- - {t("storages")} - - + } onClick={() => setModal(true)} variant="solid" className="btn-lift rounded-2xl"> + {t("new-storage")} + + } + /> + +
+ + {t("storage-delete-info")}
-
- -
{alert.isAlert && ( )} @@ -80,105 +88,72 @@ export const Storages = () => { - {isLoading ? (
) : ( - <> -
+ *:nth-child(n+3)": { textAlign: "left" }, + "& tbody tr": { + borderTop: "1px solid var(--joy-palette-divider)", + transition: "background-color 120ms ease", + }, }} > - - {t("storages")} - - -
-
*:nth-child(n+3)": { textAlign: "left" }, - "& tbody tr": { - borderTop: "1px solid var(--joy-palette-divider)", - }, - }} - > - - - - - - - - - - - {storages?.map((storage: Storage) => ( - - ))} - -
{t("storage-name")}{t("description")}{t("created-at")}{t("updated-at")}
-
- + + + {t("storage-name")} + {t("description")} + {t("created-at")} + {t("updated-at")} + + + + + {storages?.map((storage: Storage) => ( + + ))} + + +
)} diff --git a/frontend/src/pages/ViewProduct.tsx b/frontend/src/pages/ViewProduct.tsx index efe2675..f03a37a 100644 --- a/frontend/src/pages/ViewProduct.tsx +++ b/frontend/src/pages/ViewProduct.tsx @@ -1,18 +1,36 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getStorages } from "../utils/api/storages.ts"; -import { Box, Button, Chip, CircularProgress, Divider, Input, Option, Select, Typography, } from "@mui/joy"; +import { Button, CircularProgress, Input, Option, Select, Typography } from "@mui/joy"; import { useTranslation } from "react-i18next"; import { useEffect, useState } from "react"; import { useForm } from "@tanstack/react-form"; -import { getProductDetails, mutateProduct } from "../utils/api/products.ts"; +import { useNavigate } from "@tanstack/react-router"; +import { + deleteSelectedProducts, + getProductDetails, + mutateProduct, +} from "../utils/api/products.ts"; import { toInputDate } from "../utils/uxFncs"; -import type { AlertInterface, productDetailsInterface, ProductFormValues, Storage, } from "../misc/interfaces"; +import type { + AlertInterface, + productDetailsInterface, + ProductFormValues, + Storage, +} from "../misc/interfaces"; import type { ApiError } from "../utils/api/apiError"; -import QrCodeIcon from "@mui/icons-material/QrCode"; +import { QrCode, Trash2 } from "lucide-react"; import Cookies from "js-cookie"; import QRCode from "qrcode"; +import { AmountStepper } from "../components/AmountStepper.tsx"; +import { ExpiryBadge } from "../components/ProductStatusBar.tsx"; import { MyAlert } from "../components/MyAlert.tsx"; +const cardSx = { + border: "1px solid", + borderColor: "divider", + bgcolor: "background.surface", +} as const; + interface ViewProductProps { uuid: string; } @@ -20,8 +38,8 @@ interface ViewProductProps { export const ViewProduct = (props: ViewProductProps) => { const uuid = props.uuid; const { t } = useTranslation(); + const navigate = useNavigate(); const queryClient = useQueryClient(); - const [success, setSuccess] = useState(false); const [alert, setAlert] = useState({ isAlert: false, type: "neutral", @@ -91,48 +109,43 @@ export const ViewProduct = (props: ViewProductProps) => { }); const { mutate, isPending } = useMutation({ - mutationFn: ({ - values, - uuid, - }: { - values: ProductFormValues; - uuid: string; - }) => mutateProduct(values, uuid), + mutationFn: ({ values, uuid }: { values: ProductFormValues; uuid: string }) => + mutateProduct(values, uuid), onSuccess: (_data, variables) => { - setSuccess(true); + setAlert({ isAlert: true, type: "success", header: t("success"), text: t("save") }); queryClient.invalidateQueries({ queryKey: ["product", variables.uuid] }); }, onError: showError, }); + const { mutate: deleteProduct, isPending: isDeleting } = useMutation({ + mutationFn: () => deleteSelectedProducts([uuid]), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["products"] }); + void navigate({ to: "/app/inventory" }); + }, + onError: showError, + }); + useEffect(() => { if (!productDetails) { return; } form.setFieldValue("amount", productDetails.amount ?? 0); - form.setFieldValue( - "bottling_date", - toInputDate(productDetails.bottling_date), - ); + form.setFieldValue("bottling_date", toInputDate(productDetails.bottling_date)); form.setFieldValue("description", productDetails.description ?? ""); form.setFieldValue("expiry_date", toInputDate(productDetails.expiry_date)); form.setFieldValue("name", productDetails.name ?? ""); form.setFieldValue("price", productDetails.price ?? ""); - form.setFieldValue( - "storage_location_uuid", - productDetails.storage_location_uuid ?? "", - ); + form.setFieldValue("storage_location_uuid", productDetails.storage_location_uuid ?? ""); }, [form, productDetails]); const downloadQRcode = async () => { const baseUrl: string = `${window.location.protocol}//${window.location.host}`; const url = `${baseUrl}/app/quick-view/product?uuid=${uuid}`; - const dataUrl = await QRCode.toDataURL(url, { - width: 300, - margin: 2, - }); + const dataUrl = await QRCode.toDataURL(url, { width: 300, margin: 2 }); const link = document.createElement("a"); link.download = "qrcode.png"; @@ -141,52 +154,42 @@ export const ViewProduct = (props: ViewProductProps) => { }; return ( - <> -
-
-
- - {t("product-details")} - -
- - {t("details")} - -
- {productDetailsLoading && } +
+
+ + {isSuccess ? productDetails.name : t("product-details")} + + {isSuccess && } +
+ {productDetailsLoading && } + {isSuccess && ( - { + e.preventDefault(); + form.handleSubmit(); }} > -
{ - e.preventDefault(); - form.handleSubmit(); - }} - > -
-
+
+
+ + {t("product")} + +
- + {t("product-name")} @@ -199,19 +202,12 @@ export const ViewProduct = (props: ViewProductProps) => { size="lg" variant="outlined" className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} /> )}
- + {t("description")} @@ -224,44 +220,13 @@ export const ViewProduct = (props: ViewProductProps) => { size="lg" variant="outlined" className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - boxShadow: "0 10px 24px var(--joy-palette-divider)", - }} /> )}
-
+
- - {t("expiry-date")} - - - {(field) => ( - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - size="lg" - variant="outlined" - className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - }} - /> - )} - -
-
- + {t("bottling-date")} @@ -274,180 +239,137 @@ export const ViewProduct = (props: ViewProductProps) => { size="lg" variant="outlined" className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - }} + /> + )} + +
+
+ + {t("expiry-date")} + + + {(field) => ( + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + size="lg" + variant="outlined" + className="rounded-2xl" /> )}
-
-
- - {t("inventory")} +
+ +
+ + {t("inventory")} + +
+
+ + {t("amount")} - -
-
- + {(field) => ( + + )} + +
+
+ + {t("price")} + + + {(field) => ( + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + size="lg" + variant="outlined" + className="rounded-2xl" + endDecorator={ + + {Cookies.get("currency")} + + } + /> + )} + +
+
+ + {t("storage-place")} + + + {(field) => ( + { - const nextValue = Number(e.target.value); - field.handleChange( - Number.isNaN(nextValue) ? 0 : nextValue, - ); - }} - onBlur={field.handleBlur} - className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - }} - /> - )} - -
-
- - {t("price")} - - - {(field) => ( - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - size="lg" - variant="outlined" - className="rounded-2xl" - sx={{ - bgcolor: "var(--joy-palette-background-surface)", - }} - /> - )} - - - {Cookies.get("currency")} - -
-
- - {t("storage-place")} - - - {(field) => ( - - )} - -
-
+ {storages?.map((storage) => ( + + ))} + + )} + +
+
+ +
+ +
+ + {t("jar-label")} + + + {t("jar-label-hint")} +
-
-
- - {t("product-details")} - -
- - -
-
+
- {alert.isAlert && ( - - )} - - +
+ + +
+ + {alert.isAlert && ( + + )} + )} - +
); }; diff --git a/frontend/src/routes/app/_hiddenLayout.tsx b/frontend/src/routes/app/_hiddenLayout.tsx index 20d04e5..5a8eb2b 100644 --- a/frontend/src/routes/app/_hiddenLayout.tsx +++ b/frontend/src/routes/app/_hiddenLayout.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, Outlet } from "@tanstack/react-router"; +import { createFileRoute, Outlet, useRouterState } from "@tanstack/react-router"; import { Sidebar } from "../../components/Sidebar"; export const Route = createFileRoute("/app/_hiddenLayout")({ @@ -6,6 +6,8 @@ export const Route = createFileRoute("/app/_hiddenLayout")({ }); function AppLayout() { + const pathname = useRouterState({ select: (state) => state.location.pathname }); + return (
- +
+ +
); diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index 571704d..74af22a 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -1,6 +1,6 @@ import { extendTheme } from "@mui/joy/styles"; -// I generated this theme with AI (Claude Opus 4.6) +// Palette tokens sourced from the v0.9 design mockups (canvas/surface/ink/brand/etc.) export const theme = extendTheme({ cssVarPrefix: "joy", @@ -8,23 +8,23 @@ export const theme = extendTheme({ light: { palette: { primary: { - 50: "#eef7ff", - 100: "#dcedff", - 200: "#b3d9ff", - 300: "#7fbdff", - 400: "#469cf5", - 500: "#0b6bcb", - 600: "#0958a8", - 700: "#08478a", - 800: "#0a3a6e", - 900: "#0c2f59", - solidBg: "#0b6bcb", - solidHoverBg: "#095aa7", - solidActiveBg: "#08478a", - outlinedBorder: "#7fbdff", - outlinedColor: "#08478a", - softBg: "#dcedff", // Color in sidebar for selected view - softColor: "#08478a", + 50: "#eef3fe", + 100: "#dbe6fc", + 200: "#b0c8f8", + 300: "#7fa3f0", + 400: "#4c7de0", + 500: "#2159c9", + 600: "#1c48a3", + 700: "#163a82", + 800: "#122e68", + 900: "#0e2453", + solidBg: "#2159c9", + solidHoverBg: "#1c48a3", + solidActiveBg: "#163a82", + outlinedBorder: "#7fa3f0", + outlinedColor: "#163a82", + softBg: "#eef3fe", // Color in sidebar for selected view + softColor: "#163a82", }, success: { 50: "#ecfdf5", @@ -39,88 +39,100 @@ export const theme = extendTheme({ 900: "#0a423f", solidBg: "#0d9488", solidHoverBg: "#0b7c73", + outlinedBorder: "#a6f4dc", + outlinedColor: "#0b7c73", softBg: "#d1faee", softColor: "#0a655e", }, warning: { - 50: "#fffaeb", - 100: "#fef0c7", - 200: "#fedf89", - 300: "#fdc74a", - 400: "#fbaf24", - 500: "#f59e0b", - 600: "#d67e06", - 700: "#b25f08", - 800: "#8f4a0e", - 900: "#763d10", - solidBg: "#f59e0b", - solidHoverBg: "#d67e06", - softBg: "#fef0c7", - softColor: "#8f4a0e", + 50: "#fdf1e0", + 100: "#fbe3c1", + 200: "#f6c98a", + 300: "#eeab54", + 400: "#e2932c", + 500: "#d98014", + 600: "#b46810", + 700: "#92530f", + 800: "#734111", + 900: "#5c350f", + solidBg: "#d98014", + solidHoverBg: "#b46810", + outlinedBorder: "#f6c98a", + outlinedColor: "#b46810", + softBg: "#fdf1e0", + softColor: "#734111", }, danger: { - 50: "#fff1f2", - 100: "#ffe1e4", - 200: "#ffc7cd", - 300: "#ff9ba7", - 400: "#fb6b7d", - 500: "#e11d48", - 600: "#be123c", - 700: "#9b1038", - 800: "#7f1230", - 900: "#6a122b", - solidBg: "#e11d48", - solidHoverBg: "#be123c", - softBg: "#ffe1e4", - softColor: "#9b1038", + 50: "#fbeae9", + 100: "#f6d2cf", + 200: "#eaa39c", + 300: "#dc746a", + 400: "#cd4f42", + 500: "#c4342c", + 600: "#a32a24", + 700: "#84221f", + 800: "#691c1b", + 900: "#551818", + solidBg: "#c4342c", + solidHoverBg: "#a32a24", + outlinedBorder: "#eaa39c", + outlinedColor: "#a32a24", + softBg: "#fbeae9", + softColor: "#84221f", }, neutral: { - 50: "#f8fafc", - 100: "#f1f5f9", - 200: "#e2e8f0", - 300: "#cbd5e1", - 400: "#94a3b8", - 500: "#64748b", - 600: "#475569", - 700: "#334155", - 800: "#1e293b", - 900: "#0f172a", + 50: "#f6f7f9", + 100: "#f2f4f6", + 200: "#e4e7ec", + 300: "#dfe3e8", + 400: "#7b8494", + 500: "#667283", + 600: "#4a5364", + 700: "#39414f", + 800: "#232833", + 900: "#10151f", + plainColor: "#4a5364", + outlinedColor: "#4a5364", + outlinedBorder: "#e4e7ec", + outlinedHoverBg: "#fbfcfd", + softColor: "#4a5364", + softBg: "#f2f4f6", }, background: { - body: "#f7f9fc", + body: "#f6f7f9", surface: "#ffffff", - level1: "#f1f5f9", - level2: "#e2e8f0", - level3: "#cbd5e1", + level1: "#fbfcfd", + level2: "#f2f4f6", + level3: "#e4e7ec", }, text: { - primary: "#0f172a", - secondary: "#475569", - tertiary: "#64748b", + primary: "#10151f", + secondary: "#4a5364", + tertiary: "#667283", }, - divider: "rgba(15, 23, 42, 0.08)", + divider: "#e4e7ec", }, }, dark: { palette: { primary: { - 50: "#0c2f59", - 100: "#0a3a6e", - 200: "#08478a", - 300: "#0958a8", - 400: "#0b6bcb", - 500: "#3b8ce0", - 600: "#6ba9ea", - 700: "#9cc6f2", - 800: "#c7defa", - 900: "#eef7ff", - solidBg: "#3b8ce0", - solidHoverBg: "#6ba9ea", - solidActiveBg: "#0958a8", - outlinedBorder: "#0958a8", - outlinedColor: "#9cc6f2", - softBg: "rgba(59, 140, 224, 0.16)", // Color in sidebar for selected view - softColor: "#9cc6f2", + 50: "#0e2453", + 100: "#122e68", + 200: "#163a82", + 300: "#1c48a3", + 400: "#2159c9", + 500: "#5b8def", + 600: "#82a8f2", + 700: "#a9c3f6", + 800: "#cddcfa", + 900: "#eef3fe", + solidBg: "#5b8def", + solidHoverBg: "#82a8f2", + solidActiveBg: "#1c48a3", + outlinedBorder: "#1c48a3", + outlinedColor: "#a9c3f6", + softBg: "#16233d", // Color in sidebar for selected view + softColor: "#a9c3f6", }, success: { 50: "#0a423f", @@ -135,72 +147,85 @@ export const theme = extendTheme({ 900: "#ecfdf5", solidBg: "#0d9488", solidHoverBg: "#2dc8b1", + outlinedBorder: "#0a655e", + outlinedColor: "#6ee7c9", softBg: "rgba(13, 148, 136, 0.18)", softColor: "#6ee7c9", }, warning: { - 50: "#763d10", - 100: "#8f4a0e", - 200: "#b25f08", - 300: "#d67e06", - 400: "#f59e0b", - 500: "#fbaf24", - 600: "#fdc74a", - 700: "#fedf89", - 800: "#fef0c7", - 900: "#fffaeb", - solidBg: "#f59e0b", - solidHoverBg: "#fbaf24", - softBg: "rgba(245, 158, 11, 0.16)", - softColor: "#fdc74a", + 50: "#5c350f", + 100: "#734111", + 200: "#92530f", + 300: "#b46810", + 400: "#d98014", + 500: "#e3a054", + 600: "#edbb82", + 700: "#f4d3ac", + 800: "#f9e6d3", + 900: "#fdf1e0", + solidBg: "#e3a054", + solidHoverBg: "#edbb82", + outlinedBorder: "#92530f", + outlinedColor: "#edbb82", + softBg: "rgba(227, 160, 84, 0.16)", + softColor: "#f4d3ac", }, danger: { - 50: "#6a122b", - 100: "#7f1230", - 200: "#9b1038", - 300: "#be123c", - 400: "#e11d48", - 500: "#f04463", - 600: "#fb6b7d", - 700: "#ff9ba7", - 800: "#ffc7cd", - 900: "#fff1f2", - solidBg: "#e11d48", - solidHoverBg: "#f04463", - softBg: "rgba(225, 29, 72, 0.18)", - softColor: "#fb6b7d", + 50: "#551818", + 100: "#691c1b", + 200: "#84221f", + 300: "#a32a24", + 400: "#c4342c", + 500: "#f07168", + 600: "#f4948d", + 700: "#f8b7b2", + 800: "#fbd6d3", + 900: "#fdeceb", + solidBg: "#f07168", + solidHoverBg: "#f4948d", + outlinedBorder: "#84221f", + outlinedColor: "#f4948d", + softBg: "rgba(240, 113, 104, 0.16)", + softColor: "#f8b7b2", }, neutral: { - 50: "#0b1220", - 100: "#0f172a", - 200: "#1e293b", - 300: "#334155", - 400: "#475569", - 500: "#64748b", - 600: "#94a3b8", - 700: "#cbd5e1", - 800: "#e2e8f0", - 900: "#f1f5f9", + 50: "#0e1116", + 100: "#161b22", + 200: "#1b212a", + 300: "#20262f", + 400: "#272e39", + 500: "#8590a0", + 600: "#aab4c2", + 700: "#c7cfd9", + 800: "#e8ecf2", + 900: "#f6f8fa", + plainColor: "#aab4c2", + outlinedColor: "#aab4c2", + outlinedBorder: "#272e39", + outlinedHoverBg: "#1b212a", + softColor: "#aab4c2", + softBg: "#20262f", }, background: { - body: "#0b1220", - surface: "#111a2e", - level1: "#16213a", - level2: "#1e293b", - level3: "#334155", + body: "#0e1116", + surface: "#161b22", + level1: "#1b212a", + level2: "#20262f", + level3: "#272e39", }, text: { - primary: "#f1f5f9", - secondary: "#cbd5e1", - tertiary: "#94a3b8", + primary: "#e8ecf2", + secondary: "#aab4c2", + tertiary: "#8590a0", }, - divider: "rgba(226, 232, 240, 0.08)", + divider: "#272e39", }, }, }, fontFamily: { - body: "'Inter', 'Segoe UI', system-ui, sans-serif", - display: "'Inter', 'Segoe UI', system-ui, sans-serif", + body: "'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif", + display: "'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif", + code: "'IBM Plex Mono', ui-monospace, monospace", }, radius: { xs: "6px", @@ -209,6 +234,45 @@ export const theme = extendTheme({ lg: "20px", xl: "28px", }, + typography: { + h1: { + fontSize: "30px", + fontWeight: 700, + letterSpacing: "-0.03em", + lineHeight: 1.2, + }, + h2: { + fontSize: "22px", + fontWeight: 700, + letterSpacing: "-0.02em", + lineHeight: 1.25, + }, + "title-lg": { + fontSize: "15px", + fontWeight: 600, + lineHeight: 1.4, + }, + "title-md": { + fontSize: "13px", + fontWeight: 600, + lineHeight: 1.4, + }, + "body-lg": { + fontSize: "13.5px", + fontWeight: 400, + lineHeight: 1.5, + }, + "body-md": { + fontSize: "13.5px", + fontWeight: 400, + lineHeight: 1.5, + }, + "body-sm": { + fontSize: "11.5px", + fontWeight: 600, + lineHeight: 1.4, + }, + }, components: { JoyInput: { styleOverrides: { diff --git a/frontend/src/utils/i18n/locales/de/de.json b/frontend/src/utils/i18n/locales/de/de.json index 4e2dbb7..76abdff 100644 --- a/frontend/src/utils/i18n/locales/de/de.json +++ b/frontend/src/utils/i18n/locales/de/de.json @@ -86,5 +86,48 @@ "EP005": "Das Produkt konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.", "EP006": "Das Produkt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.", "EP007": "Bitte geben Sie mindestens einen Produktnamen ein.", - "EP008": "Einige erforderliche Produktinformationen fehlen. Bitte überprüfen Sie Ihre Eingabe." + "EP008": "Einige erforderliche Produktinformationen fehlen. Bitte überprüfen Sie Ihre Eingabe.", + "stat-expired": "Abgelaufen", + "stat-expiring-soon": "Läuft in 90 Tagen ab", + "stat-low-stock": "Niedriger Bestand", + "stat-units-stored": "Gelagerte Einheiten", + "days-since-expired": "Vor {{count}} Tagen abgelaufen", + "days-until-expiry": "in {{count}} Tagen", + "expires-in-days-badge": "Läuft in {{count}} Tagen ab", + "general": "Allgemein", + "appearance-language": "Darstellung & Sprache", + "theme": "Design", + "theme-light": "Hell", + "theme-dark": "Dunkel", + "theme-system": "System", + "language": "Sprache", + "account": "Konto", + "sign-out": "Abmelden", + "sign-out-sub": "Beendet diese Sitzung auf diesem Gerät.", + "login-tagline-1": "Alles, was du verstaut hast, im Januar noch auffindbar.", + "login-tagline-2": "Gefrierfach, Vorrat, Keller. Eine Liste, ein Ablaufdatum pro Glas.", + "storages-sub": "{{count}} Orte, {{items}} Artikel gelagert.", + "new-storage": "Neuer Lagerort", + "product": "Produkt", + "product-name-placeholder": "z. B. Tomatensauce", + "description-placeholder": "Kurze Notiz zu dieser Charge", + "choose-storage": "Lagerort wählen", + "amount-price-optional-hint": "Menge und Preis sind optional. Der Lagerort bestimmt, wo der Artikel im Inventar erscheint.", + "all-fields-editable-later": "Alle Felder können später bearbeitet werden", + "save-product": "Produkt speichern", + "jar-label": "Glas-Etikett", + "jar-label-hint": "Ausdrucken und auf den Deckel kleben. Scannen öffnet die Schnellansicht.", + "scanned-badge": "Gescannt", + "opened-from-jar-label": "Über das Glas-Etikett geöffnet", + "amount-in-storage": "Menge im Lager", + "set-to-zero-hint": "Auf 0 setzen, um den Eintrag zu behalten, aber das Fach zu leeren.", + "open-full-details": "Vollständige Produktdetails öffnen", + "inventory-summary": "{{count}} Artikel in {{storages}} Lagerorten", + "search-products": "Produkte durchsuchen", + "filter-all": "Alle", + "filter-expired": "Abgelaufen", + "filter-soon": "Bald", + "filter-low": "Niedrig", + "products": "Produkte", + "units": "Einheiten" } diff --git a/frontend/src/utils/i18n/locales/en/en.json b/frontend/src/utils/i18n/locales/en/en.json index 8abd620..8327d6f 100644 --- a/frontend/src/utils/i18n/locales/en/en.json +++ b/frontend/src/utils/i18n/locales/en/en.json @@ -86,5 +86,48 @@ "EP005": "The product could not be updated. Please try again.", "EP006": "The product could not be deleted. Please try again.", "EP007": "Please enter at least a product name.", - "EP008": "Some required product information is missing. Please check your input." + "EP008": "Some required product information is missing. Please check your input.", + "stat-expired": "Expired", + "stat-expiring-soon": "Expiring in 90 days", + "stat-low-stock": "Low stock", + "stat-units-stored": "Units stored", + "days-since-expired": "Expired {{count}} days ago", + "days-until-expiry": "in {{count}} days", + "expires-in-days-badge": "Expires in {{count}} days", + "general": "General", + "appearance-language": "Appearance & language", + "theme": "Theme", + "theme-light": "Light", + "theme-dark": "Dark", + "theme-system": "System", + "language": "Language", + "account": "Account", + "sign-out": "Sign out", + "sign-out-sub": "Ends this session on this device.", + "login-tagline-1": "Everything you put away, still findable in January.", + "login-tagline-2": "Freezer, pantry, cellar. One list, one expiry date per jar.", + "storages-sub": "{{count}} places, {{items}} items stored.", + "new-storage": "New storage", + "product": "Product", + "product-name-placeholder": "e.g. Tomato Sauce", + "description-placeholder": "Short note about this batch", + "choose-storage": "Choose a storage", + "amount-price-optional-hint": "Amount and price are optional. Storage place decides where the item shows up in the inventory list.", + "all-fields-editable-later": "All fields can be edited later", + "save-product": "Save product", + "jar-label": "Jar label", + "jar-label-hint": "Print and stick it on the lid. Scanning opens fast details.", + "scanned-badge": "Scanned", + "opened-from-jar-label": "Opened from the jar label", + "amount-in-storage": "Amount in storage", + "set-to-zero-hint": "Set to 0 to keep the record but empty the shelf.", + "open-full-details": "Open full product details", + "inventory-summary": "{{count}} items across {{storages}} storages", + "search-products": "Search products", + "filter-all": "All", + "filter-expired": "Expired", + "filter-soon": "Soon", + "filter-low": "Low", + "products": "products", + "units": "units" } diff --git a/frontend/src/utils/productStatus.ts b/frontend/src/utils/productStatus.ts new file mode 100644 index 0000000..bee4ceb --- /dev/null +++ b/frontend/src/utils/productStatus.ts @@ -0,0 +1,53 @@ +const EXPIRING_SOON_DAYS = 90; +const LOW_STOCK_MAX = 2; + +const daysUntil = (value?: string | null) => { + if (!value) { + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + const today = new Date(); + today.setHours(0, 0, 0, 0); + date.setHours(0, 0, 0, 0); + return Math.round((date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); +}; + +export const isExpired = (expiryDate?: string | null) => { + const days = daysUntil(expiryDate); + return days !== null && days < 0; +}; + +export const isExpiringSoon = (expiryDate?: string | null) => { + const days = daysUntil(expiryDate); + return days !== null && days >= 0 && days <= EXPIRING_SOON_DAYS; +}; + +export const isLowStock = (amount: number) => amount > 0 && amount <= LOW_STOCK_MAX; + +export type ProductStatus = "expired" | "expiring-soon" | "ok"; + +export const getProductStatus = (expiryDate?: string | null): ProductStatus => { + if (isExpired(expiryDate)) { + return "expired"; + } + if (isExpiringSoon(expiryDate)) { + return "expiring-soon"; + } + return "ok"; +}; + +// Days remaining until expiry (negative = days since it expired), plus the +// derived status. Components format this into copy via i18next (t()) so the +// wording stays translatable. +export const getExpiryDays = ( + expiryDate?: string | null, +): { days: number; status: ProductStatus } | null => { + const days = daysUntil(expiryDate); + if (days === null) { + return null; + } + return { days, status: getProductStatus(expiryDate) }; +}; diff --git a/package-lock.json b/package-lock.json index 45eaf48..9ed1b4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "concurrently": "^10.0.3", - "globals": "^17.7.0" + "globals": "^17.7.0", + "prettier": "^3.9.6" } }, "backend": { @@ -45,8 +46,8 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@fontsource/inter": "^5.2.8", - "@mui/icons-material": "^9.0.1", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", "@mui/joy": "^5.0.0-beta.52", "@tailwindcss/vite": "^4.3.0", "@tanstack/react-form": "^1.32.0", @@ -1103,10 +1104,19 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, - "node_modules/@fontsource/inter": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", - "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -1266,32 +1276,6 @@ "url": "https://opencollective.com/mui-org" } }, - "node_modules/@mui/icons-material": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.2.0.tgz", - "integrity": "sha512-VgBd3z7Qc3vd/thcNSMC03nHRh/U4DzMUd+1dRyJTbm/hGo7+N6N4GDuJZDNHa6LZhhwG6Cu1X3DNvrVv8sNag==", - "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.2.0", - "@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", @@ -1333,220 +1317,6 @@ } } }, - "node_modules/@mui/material": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.2.0.tgz", - "integrity": "sha512-+YTRSgGKGrrRo2XJZXs7JRA6qHoHWvNtxyqxnrRJTBmIuLOUpxxh7m4G9lF4tWberxGFY+EqkkRPgJCl+fSMJg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.29.2", - "@mui/core-downloads-tracker": "^9.2.0", - "@mui/system": "^9.2.0", - "@mui/types": "^9.1.1", - "@mui/utils": "^9.2.0", - "@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.6", - "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.2.0", - "@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.2.0", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.2.0.tgz", - "integrity": "sha512-+XMav+ZaXkZKUFUgzjrfMEedfyJKxxviAske2q8N8CWDMeqZdDU2lWMkiUPiB388hGaDqhwvOAwkrsc/pUyp8g==", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - } - }, - "node_modules/@mui/material/node_modules/@mui/private-theming": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.2.0.tgz", - "integrity": "sha512-w9wpyDxGPGnAACPB2hKhCDmILJIAvQxrfjUbIAEa0AznX1rOjaz5N+yB1uuw8ixnJcpEh/tPbD9oEe19wcWPHw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.29.2", - "@mui/utils": "^9.2.0", - "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.1.1", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.1.1.tgz", - "integrity": "sha512-neaYKdJfvEG54q8efHLJR7swpHG/gfSv9xGqW5iTSMsubD7yPCPFrhVBt284j1DOF3uZaaDJSHQL7gz6jGF21Q==", - "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.2.0", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.2.0.tgz", - "integrity": "sha512-YvUJwKoGVtbnOm2PyPi5TvX2d1rOA6sqSpEWVs4WmXNIaFTuYmNUaVdU2o1NKUEe31URnD3E8ZVUMcsLQXwcYg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.29.2", - "@mui/private-theming": "^9.2.0", - "@mui/styled-engine": "^9.1.1", - "@mui/types": "^9.1.1", - "@mui/utils": "^9.2.0", - "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.1.1", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.1.1.tgz", - "integrity": "sha512-Zjt7u8wNvDg40rPTGoL+TnfkpuSKjwubsNSFRH1KAVZLcaV4I3AFNHIFbvH7p4F3alEibSbdd90xAgn5Rnfndg==", - "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.2.0", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.2.0.tgz", - "integrity": "sha512-OsUH5zhlSOM4xmLl53+agug1M1UyWb4zxFxWQCqwKTKUeQPvTENtg3JhrroBD2qpCLKsX5W/DYGERJ4mBUbc8g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.29.2", - "@mui/types": "^9.1.1", - "@types/prop-types": "^15.7.15", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.2.6" - }, - "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", @@ -2830,6 +2600,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2845,16 +2616,6 @@ "@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/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -3510,16 +3271,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -3705,15 +3466,15 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", - "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", + "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", "dev": true, "license": "MIT", "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", - "shell-quote": "1.8.4", + "shell-quote": "1.9.0", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" @@ -3921,17 +3682,6 @@ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, - "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/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -4451,9 +4201,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -5590,9 +5340,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -5949,9 +5699,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -5968,7 +5718,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5987,9 +5737,9 @@ } }, "node_modules/prettier": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", - "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -6331,23 +6081,6 @@ "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "license": "MIT" }, - "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", @@ -6590,9 +6323,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 73b8d65..83fe89d 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "concurrently": "^10.0.3", - "globals": "^17.7.0" + "globals": "^17.7.0", + "prettier": "^3.9.6" } }