refactor: update ui and make it modern
This commit is contained in:
@@ -23,6 +23,7 @@ tsconfig.tsbuildinfo
|
|||||||
|
|
||||||
# Icon must end with two \r
|
# Icon must end with two \r
|
||||||
|
|
||||||
|
.claude
|
||||||
|
|
||||||
# Thumbnails
|
# Thumbnails
|
||||||
._*
|
._*
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||||
"@mui/icons-material": "^9.0.1",
|
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||||
"@mui/joy": "^5.0.0-beta.52",
|
"@mui/joy": "^5.0.0-beta.52",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tanstack/react-form": "^1.32.0",
|
"@tanstack/react-form": "^1.32.0",
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex items-stretch overflow-hidden rounded-2xl border border-solid" style={{ borderColor: "var(--joy-palette-divider)" }}>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="plain"
|
||||||
|
size="lg"
|
||||||
|
disabled={value <= min}
|
||||||
|
onClick={() => step(-1)}
|
||||||
|
className="transition-transform active:scale-90"
|
||||||
|
sx={{ borderRadius: 0 }}
|
||||||
|
>
|
||||||
|
<Minus size={16} />
|
||||||
|
</IconButton>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="plain"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => step(1)}
|
||||||
|
className="transition-transform active:scale-90"
|
||||||
|
sx={{ borderRadius: 0 }}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,12 +5,16 @@ import { signInUser } from "../utils/api/auth";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useColorScheme } from "@mui/joy/styles";
|
||||||
|
import { Layers } from "lucide-react";
|
||||||
import type { AlertInterface } from "../misc/interfaces";
|
import type { AlertInterface } from "../misc/interfaces";
|
||||||
|
import { changeTranslation } from "../utils/uxFncs";
|
||||||
import { MyAlert } from "./MyAlert.tsx";
|
import { MyAlert } from "./MyAlert.tsx";
|
||||||
|
|
||||||
export const LoginCard = () => {
|
export const LoginCard = () => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { mode, setMode } = useColorScheme();
|
||||||
const search: { loggedOut?: boolean } = useSearch({ from: "/login" });
|
const search: { loggedOut?: boolean } = useSearch({ from: "/login" });
|
||||||
const [alert, setAlert] = useState<AlertInterface>({
|
const [alert, setAlert] = useState<AlertInterface>({
|
||||||
isAlert: false,
|
isAlert: false,
|
||||||
@@ -72,37 +76,59 @@ export const LoginCard = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex min-h-screen w-full flex-col lg:flex-row">
|
||||||
className="flex min-h-screen w-full items-center justify-center px-6 py-10"
|
<div
|
||||||
style={{
|
className="hidden flex-col justify-between px-12 py-10 lg:flex lg:w-1/2"
|
||||||
background:
|
style={{ backgroundColor: "#0e1116", color: "#e8ecf2" }}
|
||||||
"radial-gradient(1200px circle at 20% 10%, var(--joy-palette-primary-100) 0%, var(--joy-palette-background-body) 40%, var(--joy-palette-background-level1) 100%)",
|
>
|
||||||
}}
|
<div className="flex items-center gap-2">
|
||||||
>
|
<span className="flex h-8 w-8 items-center justify-center rounded-lg" style={{ backgroundColor: "#5b8def" }}>
|
||||||
<div className="mx-auto flex w-full max-w-4xl items-center justify-center">
|
<Layers size={16} color="white" />
|
||||||
<div
|
</span>
|
||||||
className="w-full max-w-md rounded-3xl p-8 backdrop-blur"
|
<span className="text-lg font-bold">{t("app-title")}</span>
|
||||||
style={{
|
</div>
|
||||||
border: "1px solid var(--joy-palette-divider)",
|
<div className="max-w-md space-y-3">
|
||||||
backgroundColor: "var(--joy-palette-background-surface)",
|
<h2 className="text-3xl font-bold" style={{ letterSpacing: "-0.03em" }}>
|
||||||
boxShadow:
|
{t("login-tagline-1")}
|
||||||
"0 24px 60px color-mix(in srgb, var(--joy-palette-primary-800) 18%, transparent)",
|
</h2>
|
||||||
}}
|
<p className="text-sm" style={{ color: "#8590a0" }}>
|
||||||
>
|
{t("login-tagline-2")}
|
||||||
<div className="mb-8 space-y-2">
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs" style={{ color: "#8590a0" }}>
|
||||||
|
v0.9 MVP
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex flex-1 items-center justify-center px-6 py-10"
|
||||||
|
style={{ backgroundColor: "var(--joy-palette-background-body)" }}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md space-y-6">
|
||||||
|
<div className="flex items-center gap-2 lg:hidden">
|
||||||
|
<span
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg"
|
||||||
|
style={{ backgroundColor: "var(--joy-palette-primary-solidBg)" }}
|
||||||
|
>
|
||||||
|
<Layers size={16} color="white" />
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-bold" style={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("app-title")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
<p
|
<p
|
||||||
className="text-xs font-semibold uppercase tracking-[0.3em]"
|
className="text-xs font-semibold uppercase tracking-[0.3em]"
|
||||||
style={{ color: "var(--joy-palette-primary-solidBg)" }}
|
style={{ color: "var(--joy-palette-primary-solidBg)" }}
|
||||||
>
|
>
|
||||||
Stockhome
|
{t("app-title")}
|
||||||
</p>
|
</p>
|
||||||
<h1
|
<h1 className="text-3xl font-bold" style={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
className="text-3xl font-semibold"
|
|
||||||
style={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("login")}
|
{t("login")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="space-y-5"
|
className="space-y-5"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -111,11 +137,7 @@ export const LoginCard = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{alert.isAlert && (
|
{alert.isAlert && (
|
||||||
<MyAlert
|
<MyAlert type={alert.type} header={alert.header} text={alert.text} />
|
||||||
type={alert.type}
|
|
||||||
header={alert.header}
|
|
||||||
text={alert.text}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<form.Field name="username">
|
<form.Field name="username">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
@@ -126,10 +148,6 @@ export const LoginCard = () => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="rounded-2xl"
|
className="rounded-2xl"
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
boxShadow: "0 10px 24px var(--joy-palette-divider)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@@ -143,10 +161,6 @@ export const LoginCard = () => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="rounded-2xl"
|
className="rounded-2xl"
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
boxShadow: "0 10px 24px var(--joy-palette-divider)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@@ -156,15 +170,28 @@ export const LoginCard = () => {
|
|||||||
size="lg"
|
size="lg"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
className="w-full rounded-2xl transition hover:-translate-y-0.5"
|
className="btn-lift w-full rounded-2xl"
|
||||||
sx={{
|
|
||||||
boxShadow:
|
|
||||||
"0 16px 36px color-mix(in srgb, var(--joy-palette-primary-solidBg) 35%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("login")}
|
{t("login")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center gap-3 text-sm"
|
||||||
|
style={{ color: "var(--joy-palette-text-tertiary)" }}
|
||||||
|
>
|
||||||
|
<button type="button" onClick={changeTranslation} className="hover:underline">
|
||||||
|
{i18n.language === "de" ? "English" : "Deutsch"}
|
||||||
|
</button>
|
||||||
|
<span>|</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode(mode === "dark" ? "light" : "dark")}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{mode === "dark" ? t("theme-light") : t("theme-dark")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
<span
|
||||||
|
className={`tabular-nums ${className}`}
|
||||||
|
style={{ fontFamily: "var(--joy-fontFamily-code)" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
@@ -11,7 +11,7 @@ export const MyAlert = (props: MyAlertProps) => {
|
|||||||
<Alert
|
<Alert
|
||||||
variant="soft"
|
variant="soft"
|
||||||
color={props.type}
|
color={props.type}
|
||||||
className="rounded-2xl drop-shadow-sm"
|
className="animate-fade-in rounded-2xl drop-shadow-sm"
|
||||||
>
|
>
|
||||||
{props.header}
|
{props.header}
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Typography level="h2" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{subtitle && (
|
||||||
|
<Typography level="body-lg" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center gap-3">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Chip } from "@mui/joy";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getExpiryDays, type ProductStatus } from "../utils/productStatus";
|
||||||
|
|
||||||
|
const accentColor: Record<ProductStatus, string> = {
|
||||||
|
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 }) => (
|
||||||
|
<span
|
||||||
|
className="block h-full w-[3px] shrink-0 self-stretch rounded-full"
|
||||||
|
style={{ backgroundColor: accentColor[status] }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const chipColor: Record<ProductStatus, "danger" | "warning" | "neutral"> = {
|
||||||
|
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 (
|
||||||
|
<Chip variant="soft" color={chipColor[expiry.status]} size="sm" className="rounded-full px-3">
|
||||||
|
{text}
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,90 +1,77 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button, Typography } from "@mui/joy";
|
import { Button, Chip, Typography } from "@mui/joy";
|
||||||
import InventoryIcon from "@mui/icons-material/Inventory";
|
import { Package, Plus, Layers, Settings, Menu, X, ChevronRight } from "lucide-react";
|
||||||
import AddBoxIcon from "@mui/icons-material/AddBox";
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||||
import StorageIcon from "@mui/icons-material/Storage";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
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 Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { changeTranslation } from "../utils/uxFncs";
|
import { getProducts } from "../utils/api/products.ts";
|
||||||
import { useColorScheme } from "@mui/joy/styles";
|
import { getStorages } from "../utils/api/storages.ts";
|
||||||
import { useLogout } from "../hooks/useLogout.ts";
|
|
||||||
|
|
||||||
export const Sidebar = () => {
|
export const Sidebar = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { logout } = useLogout();
|
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||||
const matchRoute = useMatchRoute();
|
|
||||||
const { mode, setMode } = useColorScheme();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const btnClass =
|
const { data: products } = useQuery({ queryKey: ["products"], queryFn: getProducts });
|
||||||
"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: storages } = useQuery({ queryKey: ["storages"], queryFn: getStorages });
|
||||||
|
|
||||||
const variant = (to: string) =>
|
const btnClass =
|
||||||
!!matchRoute({ to, fuzzy: false }) ? "soft" : "plain";
|
"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) => {
|
const handleNavigate = (to: string) => {
|
||||||
void navigate({ to });
|
void navigate({ to });
|
||||||
setIsOpen(false);
|
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: <Package size={18} />, count: products?.length },
|
||||||
|
{ to: "/app/add-product", label: t("add"), icon: <Plus size={18} /> },
|
||||||
|
{ to: "/app/storages", label: t("storages"), icon: <Layers size={18} />, count: storages?.length },
|
||||||
|
{ to: "/app/app-settings", label: t("settings"), icon: <Settings size={18} /> },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="flex w-full flex-col gap-6 px-4 py-5 sm:px-5 lg:h-full lg:min-h-screen lg:max-w-70 lg:border-b-0 lg:border-r lg:px-6 lg:py-8"
|
className="flex w-full flex-col gap-6 px-4 py-5 sm:px-5 lg:h-full lg:min-h-screen lg:max-w-70 lg:border-b-0 lg:border-r lg:px-6 lg:py-8"
|
||||||
style={{
|
style={{
|
||||||
borderBottom: "1px solid var(--joy-palette-divider)",
|
borderBottom: "1px solid var(--joy-palette-divider)",
|
||||||
borderRightColor: "var(--joy-palette-divider)",
|
borderRightColor: "var(--joy-palette-divider)",
|
||||||
background:
|
backgroundColor: "var(--joy-palette-background-surface)",
|
||||||
"linear-gradient(to bottom, var(--joy-palette-primary-50), var(--joy-palette-background-level1), var(--joy-palette-background-level2))",
|
|
||||||
boxShadow:
|
|
||||||
"0 20px 60px color-mix(in srgb, var(--joy-palette-primary-solidBg) 8%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg"
|
||||||
|
style={{ backgroundColor: "var(--joy-palette-primary-solidBg)" }}
|
||||||
|
>
|
||||||
|
<Layers size={16} color="white" />
|
||||||
|
</span>
|
||||||
<Typography
|
<Typography
|
||||||
level="h2"
|
level="title-lg"
|
||||||
className="text-[22px] font-semibold"
|
className="text-[17px] font-bold"
|
||||||
sx={{ color: "var(--joy-palette-primary-solidBg)" }}
|
sx={{ color: "var(--joy-palette-text-primary)" }}
|
||||||
>
|
>
|
||||||
{t("app-title")}
|
{t("app-title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
|
||||||
level="body-lg"
|
|
||||||
className="text-sm font-medium"
|
|
||||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
|
||||||
>
|
|
||||||
{Cookies.get("app-name") ? Cookies.get("app-name") : ""}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="soft"
|
variant="soft"
|
||||||
|
color="neutral"
|
||||||
size="sm"
|
size="sm"
|
||||||
sx={{
|
sx={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
"@media (min-width: 1024px)": { display: "none" },
|
"@media (min-width: 1024px)": { display: "none" },
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "var(--joy-palette-primary-900)",
|
|
||||||
color: "var(--joy-palette-primary-50)",
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
onClick={() => setIsOpen((open) => !open)}
|
onClick={() => setIsOpen((open) => !open)}
|
||||||
startDecorator={
|
startDecorator={isOpen ? <X size={16} /> : <Menu size={16} />}
|
||||||
<span
|
|
||||||
className={`inline-flex transition-transform duration-200 ease-out ${
|
|
||||||
isOpen ? "rotate-180" : "rotate-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isOpen ? <CloseIcon /> : <MenuIcon />}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isOpen ? t("close") : t("menu")}
|
{isOpen ? t("close") : t("menu")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -97,112 +84,68 @@ export const Sidebar = () => {
|
|||||||
: "max-h-0 opacity-0 pointer-events-none"
|
: "max-h-0 opacity-0 pointer-events-none"
|
||||||
} lg:max-h-none lg:opacity-100 lg:pointer-events-auto`}
|
} lg:max-h-none lg:opacity-100 lg:pointer-events-auto`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
<Button
|
{navItems.map((item) => {
|
||||||
onClick={() => handleNavigate("/app/inventory")}
|
const active = isActive(item.to);
|
||||||
variant={variant("/app/inventory")}
|
return (
|
||||||
startDecorator={<InventoryIcon />}
|
<Button
|
||||||
className={btnClass}
|
key={item.to}
|
||||||
sx={{
|
onClick={() => handleNavigate(item.to)}
|
||||||
color: "var(--joy-palette-text-secondary)",
|
variant={active ? "soft" : "plain"}
|
||||||
"&:hover": {
|
color="primary"
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
startDecorator={item.icon}
|
||||||
color: "var(--joy-palette-primary-solidBg)",
|
endDecorator={
|
||||||
},
|
item.count !== undefined ? (
|
||||||
}}
|
<Chip size="sm" variant="soft" color={active ? "primary" : "neutral"}>
|
||||||
>
|
{item.count}
|
||||||
{t("inventory")}
|
</Chip>
|
||||||
</Button>
|
) : undefined
|
||||||
<Button
|
}
|
||||||
onClick={() => handleNavigate("/app/add-product")}
|
className={btnClass}
|
||||||
variant={variant("/app/add-product")}
|
sx={{
|
||||||
startDecorator={<AddBoxIcon />}
|
color: active
|
||||||
className={btnClass}
|
? "var(--joy-palette-primary-solidBg)"
|
||||||
sx={{
|
: "var(--joy-palette-text-secondary)",
|
||||||
color: "var(--joy-palette-text-secondary)",
|
borderLeft: "3px solid",
|
||||||
"&:hover": {
|
borderLeftColor: active ? "var(--joy-palette-primary-solidBg)" : "transparent",
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
"&:hover": {
|
||||||
color: "var(--joy-palette-primary-solidBg)",
|
bgcolor: active
|
||||||
},
|
? "var(--joy-palette-primary-softBg)"
|
||||||
}}
|
: "var(--joy-palette-background-level1)",
|
||||||
>
|
},
|
||||||
{t("add")}
|
}}
|
||||||
</Button>
|
>
|
||||||
<Button
|
{item.label}
|
||||||
onClick={() => handleNavigate("/app/storages")}
|
</Button>
|
||||||
variant={variant("/app/storages")}
|
);
|
||||||
startDecorator={<StorageIcon />}
|
})}
|
||||||
className={btnClass}
|
|
||||||
sx={{
|
|
||||||
color: "var(--joy-palette-text-secondary)",
|
|
||||||
"&:hover": {
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
color: "var(--joy-palette-primary-solidBg)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("storages")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleNavigate("/app/app-settings")}
|
|
||||||
variant={variant("/app/app-settings")}
|
|
||||||
startDecorator={<SettingsIcon />}
|
|
||||||
className={btnClass}
|
|
||||||
sx={{
|
|
||||||
color: "var(--joy-palette-text-secondary)",
|
|
||||||
"&:hover": {
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
color: "var(--joy-palette-primary-solidBg)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("settings")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={logout}
|
|
||||||
color="danger"
|
|
||||||
startDecorator={<ExitToAppIcon />}
|
|
||||||
className={btnClass}
|
|
||||||
>
|
|
||||||
{t("logout")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
changeTranslation();
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
color="neutral"
|
|
||||||
startDecorator={<TranslateIcon />}
|
|
||||||
className={btnClass}
|
|
||||||
>
|
|
||||||
{t("change-translation")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setMode(mode === "dark" ? "light" : "dark")}
|
|
||||||
color="neutral"
|
|
||||||
startDecorator={<Brightness4Icon />}
|
|
||||||
className={btnClass}
|
|
||||||
>
|
|
||||||
{mode === "dark" ? "Light Mode" : "Dark Mode"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<button
|
||||||
className="flex items-center gap-3 rounded-2xl px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em]"
|
type="button"
|
||||||
|
onClick={() => handleNavigate("/app/app-settings")}
|
||||||
|
className="flex items-center gap-3 rounded-2xl px-3 py-2.5 text-left transition"
|
||||||
style={{
|
style={{
|
||||||
border: "1px solid var(--joy-palette-divider)",
|
border: "1px solid var(--joy-palette-divider)",
|
||||||
backgroundColor: "var(--joy-palette-background-surface)",
|
backgroundColor: "var(--joy-palette-background-level1)",
|
||||||
color: "var(--joy-palette-primary-solidBg)",
|
|
||||||
boxShadow: "0 12px 30px var(--joy-palette-divider)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<span
|
||||||
src="/favicon.png"
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
||||||
alt="Stockhome"
|
style={{
|
||||||
className="h-7 w-7 rounded-lg"
|
backgroundColor: "var(--joy-palette-primary-softBg)",
|
||||||
/>
|
color: "var(--joy-palette-primary-softColor)",
|
||||||
<span>Stockhome</span>
|
}}
|
||||||
</div>
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Typography level="title-md" className="truncate" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{appName}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} color="var(--joy-palette-text-tertiary)" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
{tiles.map((tile) => (
|
||||||
|
<div
|
||||||
|
key={tile.label}
|
||||||
|
className="flex items-center gap-3 rounded-2xl border border-solid px-4 py-4"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--joy-palette-divider)",
|
||||||
|
backgroundColor: "var(--joy-palette-background-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-9 w-[3px] shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: tile.accent }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Typography level="h2" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{tile.value}
|
||||||
|
</Typography>
|
||||||
|
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
|
{tile.label}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,9 +2,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { deleteStorage, updateStorage } from "../utils/api/storages";
|
import { deleteStorage, updateStorage } from "../utils/api/storages";
|
||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { useStore } from "@tanstack/react-store";
|
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 type { Storage } from "../misc/interfaces";
|
||||||
import { formatDate } from "../utils/uxFncs";
|
import { formatDate } from "../utils/uxFncs";
|
||||||
|
import { Mono } from "./Mono.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface StorageRowProps {
|
interface StorageRowProps {
|
||||||
@@ -93,36 +95,37 @@ export const StorageRow = ({ storage, onError }: StorageRowProps) => {
|
|||||||
className="px-6 py-5 text-sm"
|
className="px-6 py-5 text-sm"
|
||||||
style={{ color: "var(--joy-palette-text-tertiary)" }}
|
style={{ color: "var(--joy-palette-text-tertiary)" }}
|
||||||
>
|
>
|
||||||
{formatDate(storage.created_at)}
|
<Mono>{formatDate(storage.created_at)}</Mono>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className="px-6 py-5 text-sm"
|
className="px-6 py-5 text-sm"
|
||||||
style={{ color: "var(--joy-palette-text-tertiary)" }}
|
style={{ color: "var(--joy-palette-text-tertiary)" }}
|
||||||
>
|
>
|
||||||
{formatDate(storage.updated_at)}
|
<Mono>{formatDate(storage.updated_at)}</Mono>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-5 text-right">
|
<td className="px-6 py-5 text-right">
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<Button
|
{isDirty && (
|
||||||
color="primary"
|
<Button
|
||||||
onClick={form.handleSubmit}
|
color="primary"
|
||||||
disabled={
|
onClick={form.handleSubmit}
|
||||||
!isDirty || mutation.isPending || deleteMutation.isPending
|
disabled={mutation.isPending || deleteMutation.isPending}
|
||||||
}
|
size="sm"
|
||||||
size="sm"
|
className="rounded-xl"
|
||||||
className="rounded-xl"
|
>
|
||||||
>
|
{mutation.isPending ? "..." : t("save")}
|
||||||
{mutation.isPending ? "..." : t("save")}
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
<Button
|
<IconButton
|
||||||
color="danger"
|
color="danger"
|
||||||
|
variant="plain"
|
||||||
onClick={() => deleteMutation.mutateAsync(storage.uuid)}
|
onClick={() => deleteMutation.mutateAsync(storage.uuid)}
|
||||||
disabled={mutation.isPending || deleteMutation.isPending}
|
disabled={mutation.isPending || deleteMutation.isPending}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="rounded-xl"
|
className="rounded-xl"
|
||||||
>
|
>
|
||||||
{deleteMutation.isPending ? "..." : t("delete")}
|
<Trash2 size={16} />
|
||||||
</Button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -52,13 +52,11 @@ export const AddStorageModal = (props: AddStorageModalProps) => {
|
|||||||
<>
|
<>
|
||||||
<Modal open={props.isOpen} onClose={() => props.setOpen(false)}>
|
<Modal open={props.isOpen} onClose={() => props.setOpen(false)}>
|
||||||
<ModalDialog
|
<ModalDialog
|
||||||
className="rounded-3xl p-6 backdrop-blur"
|
className="rounded-3xl p-6"
|
||||||
sx={{
|
sx={{
|
||||||
border: "1px solid",
|
border: "1px solid",
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
bgcolor: "background.surface",
|
bgcolor: "background.surface",
|
||||||
boxShadow:
|
|
||||||
"0 30px 70px color-mix(in srgb, var(--joy-palette-primary-800) 20%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle sx={{ color: "var(--joy-palette-text-primary)" }}>
|
<DialogTitle sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
@@ -83,10 +81,6 @@ export const AddStorageModal = (props: AddStorageModalProps) => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="rounded-2xl"
|
className="rounded-2xl"
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
boxShadow: "0 10px 24px var(--joy-palette-divider)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@@ -99,10 +93,6 @@ export const AddStorageModal = (props: AddStorageModalProps) => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="rounded-2xl"
|
className="rounded-2xl"
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
boxShadow: "0 10px 24px var(--joy-palette-divider)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@@ -111,11 +101,7 @@ export const AddStorageModal = (props: AddStorageModalProps) => {
|
|||||||
size="lg"
|
size="lg"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
className="rounded-2xl transition hover:-translate-y-0.5"
|
className="btn-lift rounded-2xl"
|
||||||
sx={{
|
|
||||||
boxShadow:
|
|
||||||
"0 16px 36px color-mix(in srgb, var(--joy-palette-primary-solidBg) 35%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("submit")}
|
{t("submit")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -85,13 +85,11 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => {
|
|||||||
<>
|
<>
|
||||||
<Modal open={props.isOpen} onClose={() => props.setOpen(false)}>
|
<Modal open={props.isOpen} onClose={() => props.setOpen(false)}>
|
||||||
<ModalDialog
|
<ModalDialog
|
||||||
className="rounded-3xl p-6 backdrop-blur"
|
className="rounded-3xl p-6"
|
||||||
sx={{
|
sx={{
|
||||||
border: "1px solid",
|
border: "1px solid",
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
bgcolor: "background.surface",
|
bgcolor: "background.surface",
|
||||||
boxShadow:
|
|
||||||
"0 30px 70px color-mix(in srgb, var(--joy-palette-primary-800) 20%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle sx={{ color: "var(--joy-palette-text-primary)" }}>
|
<DialogTitle sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
@@ -114,10 +112,6 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="rounded-2xl"
|
className="rounded-2xl"
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
boxShadow: "0 10px 24px var(--joy-palette-divider)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@@ -131,10 +125,6 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="rounded-2xl"
|
className="rounded-2xl"
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
boxShadow: "0 10px 24px var(--joy-palette-divider)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@@ -148,10 +138,6 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="rounded-2xl"
|
className="rounded-2xl"
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
boxShadow: "0 10px 24px var(--joy-palette-divider)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@@ -160,11 +146,7 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => {
|
|||||||
size="lg"
|
size="lg"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
className="rounded-2xl transition hover:-translate-y-0.5"
|
className="btn-lift rounded-2xl"
|
||||||
sx={{
|
|
||||||
boxShadow:
|
|
||||||
"0 16px 36px color-mix(in srgb, var(--joy-palette-primary-solidBg) 35%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("change")}
|
{t("change")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1 +1,54 @@
|
|||||||
@import "tailwindcss";
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
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 "./index.css";
|
||||||
import "./utils/i18n";
|
import "./utils/i18n";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export type ProductRow = {
|
|||||||
location: string;
|
location: string;
|
||||||
locationDetail: string;
|
locationDetail: string;
|
||||||
expiryDate: string;
|
expiryDate: string;
|
||||||
|
expiryDateRaw: string | null;
|
||||||
refillDate: string;
|
refillDate: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+134
-248
@@ -1,28 +1,23 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { Button, Input, Option, Select, Typography } from "@mui/joy";
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
Divider,
|
|
||||||
Input,
|
|
||||||
Option,
|
|
||||||
Select,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/joy";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { createProduct } from "../utils/api/products";
|
import { createProduct } from "../utils/api/products";
|
||||||
import { getStorages } from "../utils/api/storages";
|
import { getStorages } from "../utils/api/storages";
|
||||||
import type {
|
import type { AlertInterface, ProductFormValues, Storage } from "../misc/interfaces";
|
||||||
AlertInterface,
|
|
||||||
ProductFormValues,
|
|
||||||
Storage,
|
|
||||||
} from "../misc/interfaces";
|
|
||||||
import type { ApiError } from "../utils/api/apiError";
|
import type { ApiError } from "../utils/api/apiError";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
|
import { AmountStepper } from "../components/AmountStepper.tsx";
|
||||||
|
import { PageHeader } from "../components/PageHeader.tsx";
|
||||||
import { MyAlert } from "../components/MyAlert.tsx";
|
import { MyAlert } from "../components/MyAlert.tsx";
|
||||||
|
|
||||||
|
const cardSx = {
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
bgcolor: "background.surface",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const AddProduct = () => {
|
export const AddProduct = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [alert, setAlert] = useState<AlertInterface>({
|
const [alert, setAlert] = useState<AlertInterface>({
|
||||||
@@ -86,56 +81,23 @@ export const AddProduct = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<div className="space-y-6">
|
<PageHeader title={t("add-product")} subtitle={t("add-product-subtitle")} />
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<div className="space-y-1">
|
<form
|
||||||
<Typography
|
onSubmit={(e) => {
|
||||||
level="h2"
|
e.preventDefault();
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
form.handleSubmit();
|
||||||
>
|
|
||||||
{t("add-product")}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
level="body-lg"
|
|
||||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
|
||||||
>
|
|
||||||
{t("add-product-subtitle")}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<Chip
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
className="ml-auto rounded-full px-3"
|
|
||||||
>
|
|
||||||
{t("details")}
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Box
|
|
||||||
className="mt-6 rounded-3xl p-6 backdrop-blur"
|
|
||||||
sx={{
|
|
||||||
border: "1px solid",
|
|
||||||
borderColor: "divider",
|
|
||||||
bgcolor: "background.surface",
|
|
||||||
boxShadow:
|
|
||||||
"0 24px 60px color-mix(in srgb, var(--joy-palette-primary-800) 12%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<form
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
className="space-y-6"
|
<div className="rounded-3xl p-6" style={cardSx}>
|
||||||
onSubmit={(e) => {
|
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
e.preventDefault();
|
{t("product")}
|
||||||
form.handleSubmit();
|
</Typography>
|
||||||
}}
|
<div className="mt-4 space-y-4">
|
||||||
>
|
|
||||||
<div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("product-name")}
|
{t("product-name")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<form.Field name="name">
|
<form.Field name="name">
|
||||||
@@ -143,75 +105,39 @@ export const AddProduct = () => {
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
|
placeholder={t("product-name-placeholder")}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
className="rounded-2xl"
|
className="rounded-2xl"
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
boxShadow: "0 10px 24px var(--joy-palette-divider)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("description")}
|
{t("description")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<form.Field name="description">
|
<form.Field name="description">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
placeholder={t("description-placeholder")}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
className="rounded-2xl"
|
className="rounded-2xl"
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
boxShadow: "0 10px 24px var(--joy-palette-divider)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("expiry-date")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="expiry_date">
|
|
||||||
{(field) => (
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
size="lg"
|
|
||||||
variant="outlined"
|
|
||||||
className="rounded-2xl"
|
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("bottling-date")}
|
{t("bottling-date")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<form.Field name="bottling_date">
|
<form.Field name="bottling_date">
|
||||||
@@ -224,162 +150,122 @@ export const AddProduct = () => {
|
|||||||
size="lg"
|
size="lg"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
className="rounded-2xl"
|
className="rounded-2xl"
|
||||||
sx={{
|
/>
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
)}
|
||||||
}}
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("expiry-date")}
|
||||||
|
</Typography>
|
||||||
|
<form.Field name="expiry_date">
|
||||||
|
{(field) => (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
variant="outlined"
|
||||||
|
className="rounded-2xl"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
</div>
|
||||||
<div
|
|
||||||
className="rounded-2xl p-5"
|
<div className="rounded-3xl p-6" style={cardSx}>
|
||||||
style={{
|
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
border: "1px solid var(--joy-palette-divider)",
|
{t("inventory")}
|
||||||
background:
|
</Typography>
|
||||||
"linear-gradient(to bottom right, var(--joy-palette-primary-50), var(--joy-palette-background-level1), var(--joy-palette-background-level2))",
|
<div className="mt-4 space-y-4">
|
||||||
boxShadow: "0 16px 40px var(--joy-palette-divider)",
|
<div className="space-y-1">
|
||||||
}}
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
>
|
{t("amount")}
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("inventory")}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Divider className="my-3" />
|
<form.Field name="amount">
|
||||||
<div className="grid gap-4">
|
{(field) => (
|
||||||
<div className="space-y-1">
|
<AmountStepper
|
||||||
<Typography
|
value={field.state.value}
|
||||||
level="title-md"
|
onChange={field.handleChange}
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
onBlur={field.handleBlur}
|
||||||
>
|
/>
|
||||||
{t("amount")}
|
)}
|
||||||
</Typography>
|
</form.Field>
|
||||||
<form.Field name="amount">
|
|
||||||
{(field) => (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
color="neutral"
|
|
||||||
id="amountInput"
|
|
||||||
placeholder={t("amount")}
|
|
||||||
value={field.state.value}
|
|
||||||
variant="soft"
|
|
||||||
size="lg"
|
|
||||||
onChange={(e) => {
|
|
||||||
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)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("price")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="price">
|
|
||||||
{(field) => (
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
size="lg"
|
|
||||||
variant="outlined"
|
|
||||||
className="rounded-2xl"
|
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
<Typography
|
|
||||||
level="body-sm"
|
|
||||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
|
||||||
>
|
|
||||||
{Cookies.get("currency")}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("storage-place")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="storage_location_uuid">
|
|
||||||
{(field) => (
|
|
||||||
<Select
|
|
||||||
required
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(_event, value) =>
|
|
||||||
field.handleChange(value ?? "")
|
|
||||||
}
|
|
||||||
size="lg"
|
|
||||||
variant="outlined"
|
|
||||||
className="rounded-2xl"
|
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{storages?.map((storage) => (
|
|
||||||
<Option key={storage.uuid} value={storage.uuid}>
|
|
||||||
{storage.name}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("price")}
|
||||||
|
</Typography>
|
||||||
|
<form.Field name="price">
|
||||||
|
{(field) => (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
variant="outlined"
|
||||||
|
className="rounded-2xl"
|
||||||
|
endDecorator={
|
||||||
|
<Typography level="body-sm" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
|
{Cookies.get("currency")}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("storage-place")}
|
||||||
|
</Typography>
|
||||||
|
<form.Field name="storage_location_uuid">
|
||||||
|
{(field) => (
|
||||||
|
<Select
|
||||||
|
required
|
||||||
|
placeholder={t("choose-storage")}
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(_event, value) => field.handleChange(value ?? "")}
|
||||||
|
size="lg"
|
||||||
|
variant="outlined"
|
||||||
|
className="rounded-2xl"
|
||||||
|
>
|
||||||
|
{storages?.map((storage) => (
|
||||||
|
<Option key={storage.uuid} value={storage.uuid}>
|
||||||
|
{storage.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
|
{t("amount-price-optional-hint")}
|
||||||
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 items-center">
|
</div>
|
||||||
<Typography
|
|
||||||
level="body-sm"
|
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
>
|
{t("all-fields-editable-later")}
|
||||||
{t("product-details")}
|
</Typography>
|
||||||
</Typography>
|
<div className="flex gap-3">
|
||||||
<div className="grow"></div>
|
<Button type="submit" loading={isPending} size="lg" color="primary" variant="solid" className="btn-lift rounded-2xl">
|
||||||
<Button
|
{t("save-product")}
|
||||||
type="submit"
|
|
||||||
loading={isPending}
|
|
||||||
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)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("save")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{alert.isAlert && (
|
</div>
|
||||||
<MyAlert
|
|
||||||
type={alert.type}
|
{alert.isAlert && (
|
||||||
header={alert.header}
|
<div className="mt-4">
|
||||||
text={alert.text}
|
<MyAlert type={alert.type} header={alert.header} text={alert.text} />
|
||||||
/>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Box>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+380
-236
@@ -1,16 +1,53 @@
|
|||||||
import { ChangeEvent, MouseEvent, useEffect, useState } from "react";
|
import { ChangeEvent, Fragment, MouseEvent, useEffect, useMemo, useState } from "react";
|
||||||
import { Avatar, Button, Checkbox, Chip, CircularProgress, Sheet, Table, Typography, } from "@mui/joy";
|
import { Button, Checkbox, Chip, CircularProgress, Input, Sheet, Table, Typography } from "@mui/joy";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { deleteSelectedProducts, getProducts } from "../utils/api/products";
|
import { deleteSelectedProducts, getProducts } from "../utils/api/products";
|
||||||
import { formatDate } from "../utils/uxFncs";
|
import { formatDate } from "../utils/uxFncs";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import type { AlertInterface, ProductRow } from "../misc/interfaces";
|
import type { AlertInterface, ProductRow } from "../misc/interfaces";
|
||||||
import type { ApiError } from "../utils/api/apiError";
|
import type { ApiError } from "../utils/api/apiError";
|
||||||
import CategoryIcon from "@mui/icons-material/Category";
|
|
||||||
import { MyAlert } from "../components/MyAlert.tsx";
|
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 = () => {
|
export const InventoryPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -22,6 +59,9 @@ export const InventoryPage = () => {
|
|||||||
header: "",
|
header: "",
|
||||||
text: "",
|
text: "",
|
||||||
});
|
});
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [filter, setFilter] = useState<FilterKey>("all");
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const showError = (error: unknown) => {
|
const showError = (error: unknown) => {
|
||||||
const errorCode = (error as { code?: string })?.code;
|
const errorCode = (error as { code?: string })?.code;
|
||||||
@@ -49,24 +89,77 @@ export const InventoryPage = () => {
|
|||||||
}
|
}
|
||||||
}, [productsError, productsErrorObj]);
|
}, [productsError, productsErrorObj]);
|
||||||
|
|
||||||
const rows: ProductRow[] = (productsData ?? []).map(
|
const rows: ProductRow[] = useMemo(
|
||||||
(product: any, index: number) => ({
|
() =>
|
||||||
id: String(product?.uuid ?? index),
|
(productsData ?? []).map((product: any, index: number) => ({
|
||||||
uuid: String(product?.uuid ?? ""),
|
id: String(product?.uuid ?? index),
|
||||||
name: product?.name ?? t("product-name"),
|
uuid: String(product?.uuid ?? ""),
|
||||||
description: product?.description ?? "",
|
name: product?.name ?? t("product-name"),
|
||||||
imageUrl: product?.picture ?? undefined,
|
description: product?.description ?? "",
|
||||||
price: product?.price ? String(product.price) : "-",
|
imageUrl: product?.picture ?? undefined,
|
||||||
stock: `${product?.amount ?? 0} ${t("pcs")}`,
|
price: product?.price ? String(product.price) : "-",
|
||||||
stockLabel: String(product?.amount ?? 0),
|
stock: `${product?.amount ?? 0} ${t("pcs")}`,
|
||||||
stockStatus: (product?.amount ?? 0) > 0 ? "ok" : "missing",
|
stockLabel: String(product?.amount ?? 0),
|
||||||
location: product?.storage_location_name ?? "-",
|
stockStatus: (product?.amount ?? 0) > 0 ? "ok" : "missing",
|
||||||
locationDetail: "",
|
location: product?.storage_location_name ?? "-",
|
||||||
expiryDate: formatDate(product?.expiry_date),
|
locationDetail: "",
|
||||||
refillDate: formatDate(product?.bottling_date),
|
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 [selected, setSelected] = useState<readonly string[]>([]);
|
||||||
|
|
||||||
const { mutate: deleteSelection, isPending: isDeleting } = useMutation({
|
const { mutate: deleteSelection, isPending: isDeleting } = useMutation({
|
||||||
@@ -79,81 +172,104 @@ export const InventoryPage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSelectAllClick = (event: ChangeEvent<HTMLInputElement>) => {
|
const handleSelectAllClick = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
if (event.target.checked) {
|
setSelected(event.target.checked ? filteredRows.map((row) => row.id) : []);
|
||||||
setSelected(rows.map((row) => row.id));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelected([]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (_event: MouseEvent<unknown>, id: string) => {
|
const handleClick = (_event: MouseEvent<unknown>, id: string) => {
|
||||||
const selectedIndex = selected.indexOf(id);
|
setSelected((prev) =>
|
||||||
let newSelected: readonly string[] = [];
|
prev.includes(id) ? prev.filter((rowId) => rowId !== id) : [...prev, id],
|
||||||
if (selectedIndex === -1) {
|
);
|
||||||
newSelected = newSelected.concat(selected, id);
|
|
||||||
} else if (selectedIndex === 0) {
|
|
||||||
newSelected = newSelected.concat(selected.slice(1));
|
|
||||||
} else if (selectedIndex === selected.length - 1) {
|
|
||||||
newSelected = newSelected.concat(selected.slice(0, -1));
|
|
||||||
} else if (selectedIndex > 0) {
|
|
||||||
newSelected = newSelected.concat(
|
|
||||||
selected.slice(0, selectedIndex),
|
|
||||||
selected.slice(selectedIndex + 1),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setSelected(newSelected);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const 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 (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<Typography level="h2">{t("inventory")}</Typography>
|
<PageHeader
|
||||||
<Typography level="body-lg">{t("inventory-subtitle")}</Typography>
|
title={t("inventory")}
|
||||||
<div className="mt-4 flex items-center gap-3">
|
subtitle={t("inventory-summary", { count: rows.length, storages: storageCount })}
|
||||||
<Button
|
actions={
|
||||||
startDecorator={<AddIcon />}
|
<Button
|
||||||
onClick={() => navigate({ to: "/app/add-product" })}
|
startDecorator={<Plus size={16} />}
|
||||||
variant="solid"
|
onClick={() => navigate({ to: "/app/add-product" })}
|
||||||
>
|
variant="solid"
|
||||||
{t("add")}
|
className="btn-lift rounded-2xl"
|
||||||
</Button>
|
>
|
||||||
{productsIsLoading && <CircularProgress size="sm" />}
|
{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>
|
</div>
|
||||||
{alert.isAlert && (
|
|
||||||
<MyAlert type={alert.type} header={alert.header} text={alert.text} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Sheet
|
<Sheet
|
||||||
variant="outlined"
|
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)]"
|
className="flex min-h-0 w-full max-w-full flex-col overflow-hidden rounded-2xl"
|
||||||
sx={{
|
sx={{ border: "1px solid", borderColor: "divider", bgcolor: "background.surface" }}
|
||||||
border: "1px solid",
|
|
||||||
borderColor: "divider",
|
|
||||||
bgcolor: "background.surface",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
{selected.length > 0 && (
|
||||||
className="flex items-center justify-between px-6 py-4"
|
<div
|
||||||
style={{
|
className="flex items-center justify-between px-6 py-3"
|
||||||
borderBottom: "1px solid var(--joy-palette-divider)",
|
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])}
|
|
||||||
>
|
>
|
||||||
Delete
|
<Typography level="body-sm" sx={{ color: "var(--joy-palette-text-secondary)" }}>
|
||||||
</Button>
|
{selected.length} {t("selected")}
|
||||||
</div>
|
</Typography>
|
||||||
<div className="flex-1 overflow-auto">
|
<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
|
<Table
|
||||||
aria-labelledby="tableTitle"
|
|
||||||
stickyHeader
|
stickyHeader
|
||||||
stripe="odd"
|
stripe="odd"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
@@ -162,57 +278,19 @@ export const InventoryPage = () => {
|
|||||||
sx={{
|
sx={{
|
||||||
tableLayout: "fixed",
|
tableLayout: "fixed",
|
||||||
color: "var(--joy-palette-text-secondary)",
|
color: "var(--joy-palette-text-secondary)",
|
||||||
"--TableCell-headBackground":
|
"--TableCell-headBackground": "var(--joy-palette-background-level1)",
|
||||||
"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)",
|
|
||||||
},
|
|
||||||
"& thead th": {
|
"& thead th": {
|
||||||
zIndex: 2,
|
|
||||||
backgroundColor: "var(--joy-palette-background-level1)",
|
backgroundColor: "var(--joy-palette-background-level1)",
|
||||||
backgroundImage: "none",
|
backgroundImage: "none",
|
||||||
color: "var(--joy-palette-text-secondary)",
|
color: "var(--joy-palette-text-secondary)",
|
||||||
},
|
},
|
||||||
"& thead th:nth-child(1)": {
|
"& thead th:nth-child(1)": { width: "40px" },
|
||||||
width: "44px",
|
"& thead th:nth-child(2)": { width: "26%" },
|
||||||
},
|
"& thead th:nth-child(6)": { width: "8%" },
|
||||||
"& thead th:nth-child(2)": {
|
"& tr > *:nth-child(n+3)": { textAlign: "left" },
|
||||||
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" },
|
|
||||||
"& tbody tr": {
|
"& tbody tr": {
|
||||||
borderTop: "1px solid var(--joy-palette-divider)",
|
borderTop: "1px solid var(--joy-palette-divider)",
|
||||||
|
transition: "background-color 120ms ease",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -220,134 +298,200 @@ export const InventoryPage = () => {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="px-2 py-4">
|
<th className="px-2 py-4">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={rows.length > 0 && selected.length === rows.length}
|
checked={filteredRows.length > 0 && selected.length === filteredRows.length}
|
||||||
indeterminate={
|
indeterminate={selected.length > 0 && selected.length < filteredRows.length}
|
||||||
selected.length > 0 && selected.length < rows.length
|
|
||||||
}
|
|
||||||
onChange={handleSelectAllClick}
|
onChange={handleSelectAllClick}
|
||||||
slotProps={{ input: { "aria-label": "select all" } }}
|
slotProps={{ input: { "aria-label": "select all" } }}
|
||||||
sx={{ verticalAlign: "sub" }}
|
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-4">{t("product-name")}</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("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("expiry-date")}</th>
|
||||||
<th className="px-3 py-4">{t("bottling-date")}</th>
|
|
||||||
<th className="px-3 py-4 text-right">{t("actions")}</th>
|
<th className="px-3 py-4 text-right">{t("actions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row, index) => {
|
{groups.map((group) => {
|
||||||
const isItemSelected = selected.includes(row.id);
|
const isCollapsed = collapsedGroups.has(group.name);
|
||||||
const labelId = `inventory-checkbox-${index}`;
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<Fragment key={`group-${group.name}`}>
|
||||||
key={row.id}
|
<tr>
|
||||||
onClick={(event) => handleClick(event, row.id)}
|
<td colSpan={6} className="px-3 py-3" style={{ backgroundColor: "var(--joy-palette-background-level1)" }}>
|
||||||
role="checkbox"
|
<button
|
||||||
aria-checked={isItemSelected}
|
type="button"
|
||||||
tabIndex={-1}
|
onClick={() => toggleGroup(group.name)}
|
||||||
>
|
className="flex items-center gap-2 font-semibold"
|
||||||
<th scope="row" className="px-2 py-5">
|
style={{ color: "var(--joy-palette-text-primary)" }}
|
||||||
<Checkbox
|
>
|
||||||
checked={isItemSelected}
|
<ChevronDown
|
||||||
slotProps={{ input: { "aria-labelledby": labelId } }}
|
size={16}
|
||||||
sx={{ verticalAlign: "top" }}
|
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? "-rotate-90" : ""}`}
|
||||||
/>
|
/>
|
||||||
</th>
|
{group.name}
|
||||||
<th
|
<span className="font-normal" style={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
id={labelId}
|
{group.rows.length} {t("products")} · {group.units} {t("units")}
|
||||||
scope="row"
|
</span>
|
||||||
className="px-3 py-5 overflow-hidden"
|
</button>
|
||||||
>
|
</td>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
</tr>
|
||||||
<Avatar size="lg" variant="soft" className="shrink-0">
|
{!isCollapsed &&
|
||||||
<CategoryIcon />
|
group.rows.map((row) => {
|
||||||
</Avatar>
|
const isItemSelected = selected.includes(row.id);
|
||||||
<div className="min-w-0">
|
const status = getProductStatus(row.expiryDateRaw);
|
||||||
<Typography
|
return (
|
||||||
level="title-md"
|
<tr
|
||||||
className="truncate"
|
key={row.id}
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
onClick={(event) => handleClick(event, row.id)}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={isItemSelected}
|
||||||
>
|
>
|
||||||
{row.name}
|
<td className="px-2 py-4">
|
||||||
</Typography>
|
<Checkbox checked={isItemSelected} sx={{ verticalAlign: "top" }} />
|
||||||
<Typography
|
</td>
|
||||||
level="body-sm"
|
<td className="px-3 py-4">
|
||||||
className="truncate"
|
<div className="flex min-w-0 items-stretch gap-3">
|
||||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
<ProductStatusBar status={status} />
|
||||||
>
|
<div className="min-w-0">
|
||||||
{row.description}
|
<Typography level="title-md" className="truncate" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
</Typography>
|
{row.name}
|
||||||
</div>
|
</Typography>
|
||||||
</div>
|
<Typography level="body-sm" className="truncate normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
</th>
|
{row.description}
|
||||||
<td className="px-3 py-5 overflow-hidden">
|
</Typography>
|
||||||
<Typography level="title-md" className="truncate">
|
</div>
|
||||||
{row.price}
|
</div>
|
||||||
</Typography>
|
</td>
|
||||||
<Typography
|
<td className="px-3 py-4">
|
||||||
level="body-sm"
|
<Typography
|
||||||
className="truncate"
|
level="title-md"
|
||||||
sx={{ color: "var(--joy-palette-neutral-400)" }}
|
sx={{
|
||||||
>
|
color: isLowStock(Number(row.stockLabel))
|
||||||
{Cookies.get("currency")}
|
? "var(--joy-palette-danger-solidBg)"
|
||||||
</Typography>
|
: "var(--joy-palette-text-primary)",
|
||||||
</td>
|
}}
|
||||||
<td className="px-3 py-5 overflow-hidden">
|
>
|
||||||
<Chip
|
{row.stock}
|
||||||
variant="soft"
|
</Typography>
|
||||||
color="neutral"
|
</td>
|
||||||
size="lg"
|
<td className="px-3 py-4">
|
||||||
className="max-w-full px-3"
|
<Mono>
|
||||||
>
|
<Typography level="body-sm" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
<span className="truncate">{row.stock}</span>
|
{row.price === "-" ? "-" : `${row.price} ${Cookies.get("currency") ?? ""}`}
|
||||||
</Chip>
|
</Typography>
|
||||||
</td>
|
</Mono>
|
||||||
<td className="px-3 py-5 overflow-hidden">
|
</td>
|
||||||
<Typography level="title-md" className="truncate">
|
<td className="px-3 py-4">
|
||||||
{row.location}
|
<Mono>
|
||||||
</Typography>
|
<Typography level="title-md" className="truncate" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
<Typography
|
{row.expiryDate}
|
||||||
level="body-sm"
|
</Typography>
|
||||||
className="truncate"
|
</Mono>
|
||||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
<ExpiryRelative expiryDate={row.expiryDateRaw} />
|
||||||
>
|
</td>
|
||||||
{row.locationDetail}
|
<td className="px-3 py-4 text-right">
|
||||||
</Typography>
|
<Button
|
||||||
</td>
|
onClick={() => navigate({ to: `/app/view-product?product=${row.uuid}` })}
|
||||||
<td className="px-3 py-5 overflow-hidden">
|
variant="outlined"
|
||||||
<Typography level="title-md" className="truncate">
|
size="sm"
|
||||||
{row.expiryDate}
|
className="rounded-xl"
|
||||||
</Typography>
|
>
|
||||||
</td>
|
{t("details")}
|
||||||
<td className="px-3 py-5 overflow-hidden">
|
</Button>
|
||||||
<Typography level="title-md" className="truncate">
|
</td>
|
||||||
{row.refillDate}
|
</tr>
|
||||||
</Typography>
|
);
|
||||||
</td>
|
})}
|
||||||
<td className="px-3 py-5 text-right">
|
</Fragment>
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
navigate({
|
|
||||||
to: `/app/view-product?product=${row.uuid}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
variant="outlined"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{t("details")}
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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>
|
</Sheet>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { getStorages } from "../utils/api/storages.ts";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
import { getProductDetails, mutateProduct } from "../utils/api/products.ts";
|
import { getProductDetails, mutateProduct } from "../utils/api/products.ts";
|
||||||
import { toInputDate } from "../utils/uxFncs";
|
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 type { ApiError } from "../utils/api/apiError";
|
||||||
import Cookies from "js-cookie";
|
import { AmountStepper } from "../components/AmountStepper.tsx";
|
||||||
import ElectricBoltIcon from "@mui/icons-material/ElectricBolt";
|
import { ExpiryBadge } from "../components/ProductStatusBar.tsx";
|
||||||
import { MyAlert } from "../components/MyAlert.tsx";
|
import { MyAlert } from "../components/MyAlert.tsx";
|
||||||
|
|
||||||
export const ProductQuickView = () => {
|
export const ProductQuickView = () => {
|
||||||
const uuid = window.location.search.split("uuid=")[1];
|
const uuid = window.location.search.split("uuid=")[1];
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const [alert, setAlert] = useState<AlertInterface>({
|
const [alert, setAlert] = useState<AlertInterface>({
|
||||||
isAlert: false,
|
isAlert: false,
|
||||||
type: "neutral",
|
type: "neutral",
|
||||||
@@ -66,6 +71,8 @@ export const ProductQuickView = () => {
|
|||||||
}
|
}
|
||||||
}, [storagesError, storagesErrorObj]);
|
}, [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({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
amount: 0,
|
amount: 0,
|
||||||
@@ -86,15 +93,10 @@ export const ProductQuickView = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutate, isPending } = useMutation({
|
const { mutate, isPending } = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({ values, uuid }: { values: ProductFormValues; uuid: string }) =>
|
||||||
values,
|
mutateProduct(values, uuid),
|
||||||
uuid,
|
|
||||||
}: {
|
|
||||||
values: ProductFormValues;
|
|
||||||
uuid: string;
|
|
||||||
}) => mutateProduct(values, uuid),
|
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
setSuccess(true);
|
setAlert({ isAlert: true, type: "success", header: t("success"), text: t("save") });
|
||||||
queryClient.invalidateQueries({ queryKey: ["product", variables.uuid] });
|
queryClient.invalidateQueries({ queryKey: ["product", variables.uuid] });
|
||||||
},
|
},
|
||||||
onError: showError,
|
onError: showError,
|
||||||
@@ -106,312 +108,127 @@ export const ProductQuickView = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
form.setFieldValue("amount", productDetails.amount ?? 0);
|
form.setFieldValue("amount", productDetails.amount ?? 0);
|
||||||
form.setFieldValue(
|
form.setFieldValue("bottling_date", toInputDate(productDetails.bottling_date));
|
||||||
"bottling_date",
|
|
||||||
toInputDate(productDetails.bottling_date),
|
|
||||||
);
|
|
||||||
form.setFieldValue("description", productDetails.description ?? "");
|
form.setFieldValue("description", productDetails.description ?? "");
|
||||||
form.setFieldValue("expiry_date", toInputDate(productDetails.expiry_date));
|
form.setFieldValue("expiry_date", toInputDate(productDetails.expiry_date));
|
||||||
form.setFieldValue("name", productDetails.name ?? "");
|
form.setFieldValue("name", productDetails.name ?? "");
|
||||||
form.setFieldValue("price", productDetails.price ?? "");
|
form.setFieldValue("price", productDetails.price ?? "");
|
||||||
form.setFieldValue(
|
form.setFieldValue("storage_location_uuid", productDetails.storage_location_uuid ?? "");
|
||||||
"storage_location_uuid",
|
|
||||||
productDetails.storage_location_uuid ?? "",
|
|
||||||
);
|
|
||||||
}, [form, productDetails]);
|
}, [form, productDetails]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex min-h-screen items-start justify-center px-4 py-10 sm:items-center" style={{ backgroundColor: "var(--joy-palette-background-body)" }}>
|
||||||
<div className="space-y-6">
|
<div className="w-full max-w-md space-y-5">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<Chip variant="soft" color="warning" size="sm" className="rounded-full px-3 font-semibold">
|
||||||
<div className="space-y-1">
|
{t("scanned-badge")}
|
||||||
<Typography
|
</Chip>
|
||||||
level="h2"
|
|
||||||
fontWeight="1000"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
<ElectricBoltIcon color="primary" />
|
|
||||||
{t("product-details-quick")}
|
|
||||||
<ElectricBoltIcon color="primary" />
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<Chip
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
className="ml-auto rounded-full px-3"
|
|
||||||
>
|
|
||||||
{t("details")}
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
{productDetailsLoading && <CircularProgress size="sm" />}
|
{productDetailsLoading && <CircularProgress size="sm" />}
|
||||||
</div>
|
|
||||||
{isSuccess && (
|
{isSuccess && (
|
||||||
<Box
|
<>
|
||||||
className="mt-6 rounded-3xl p-6 backdrop-blur"
|
<div className="space-y-1">
|
||||||
sx={{
|
<Typography level="h2" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
border: "1px solid",
|
{productDetails.name}
|
||||||
borderColor: "divider",
|
|
||||||
bgcolor: "background.surface",
|
|
||||||
boxShadow:
|
|
||||||
"0 24px 60px color-mix(in srgb, var(--joy-palette-primary-800) 12%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<form
|
|
||||||
className="space-y-6"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
form.handleSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="gap-5 lg:grid-cols-[1.2fr_1fr]">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("product-name")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="name">
|
|
||||||
{(field) => (
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => 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)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("description")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="description">
|
|
||||||
{(field) => (
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => 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)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("expiry-date")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="expiry_date">
|
|
||||||
{(field) => (
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
size="lg"
|
|
||||||
variant="outlined"
|
|
||||||
className="rounded-2xl"
|
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("bottling-date")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="bottling_date">
|
|
||||||
{(field) => (
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
size="lg"
|
|
||||||
variant="outlined"
|
|
||||||
className="rounded-2xl"
|
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div
|
|
||||||
className="rounded-2xl p-5"
|
|
||||||
style={{
|
|
||||||
border: "1px solid var(--joy-palette-divider)",
|
|
||||||
background:
|
|
||||||
"linear-gradient(to bottom right, var(--joy-palette-primary-50), var(--joy-palette-background-level1), var(--joy-palette-background-level2))",
|
|
||||||
boxShadow: "0 16px 40px var(--joy-palette-divider)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
level="title-lg"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("inventory")}
|
|
||||||
</Typography>
|
|
||||||
<Divider className="my-3" />
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("amount")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="amount">
|
|
||||||
{(field) => (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
color="neutral"
|
|
||||||
id="amountInput"
|
|
||||||
placeholder={t("amount")}
|
|
||||||
value={field.state.value}
|
|
||||||
variant="soft"
|
|
||||||
size="lg"
|
|
||||||
onChange={(e) => {
|
|
||||||
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)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("price")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="price">
|
|
||||||
{(field) => (
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
size="lg"
|
|
||||||
variant="outlined"
|
|
||||||
className="rounded-2xl"
|
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
<Typography
|
|
||||||
level="body-sm"
|
|
||||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
|
||||||
>
|
|
||||||
{Cookies.get("currency")}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("storage-place")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="storage_location_uuid">
|
|
||||||
{(field) => (
|
|
||||||
<Select
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(_event, value) =>
|
|
||||||
field.handleChange(value ?? "")
|
|
||||||
}
|
|
||||||
size="lg"
|
|
||||||
variant="outlined"
|
|
||||||
className="rounded-2xl"
|
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{storages?.map((storage) => (
|
|
||||||
<Option key={storage.uuid} value={storage.uuid}>
|
|
||||||
{storage.name}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<Typography
|
|
||||||
level="body-sm"
|
|
||||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
|
||||||
>
|
|
||||||
{t("product-details")}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Typography level="body-lg" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
type="submit"
|
{t("opened-from-jar-label")}
|
||||||
loading={isPending}
|
</Typography>
|
||||||
size="lg"
|
<ExpiryBadge expiryDate={productDetails.expiry_date} />
|
||||||
color="primary"
|
</div>
|
||||||
variant="solid"
|
|
||||||
className="rounded-2xl transition hover:-translate-y-0.5"
|
<form
|
||||||
sx={{
|
className="space-y-5 rounded-3xl p-6"
|
||||||
boxShadow:
|
style={{ border: "1px solid var(--joy-palette-divider)", backgroundColor: "var(--joy-palette-background-surface)" }}
|
||||||
"0 16px 36px color-mix(in srgb, var(--joy-palette-primary-solidBg) 35%, transparent)",
|
onSubmit={(e) => {
|
||||||
}}
|
e.preventDefault();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
<Typography level="body-sm" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
|
{t("amount-in-storage")}
|
||||||
|
</Typography>
|
||||||
|
<form.Field name="amount">
|
||||||
|
{(field) => (
|
||||||
|
<AmountStepper
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={field.handleChange}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
|
{t("set-to-zero-hint")}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between rounded-2xl px-4 py-3"
|
||||||
|
style={{ border: "1px solid var(--joy-palette-divider)" }}
|
||||||
>
|
>
|
||||||
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("expiry-date")}
|
||||||
|
</Typography>
|
||||||
|
<form.Field name="expiry_date">
|
||||||
|
{(field) => (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => 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)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between rounded-2xl px-4 py-3"
|
||||||
|
style={{ border: "1px solid var(--joy-palette-divider)" }}
|
||||||
|
>
|
||||||
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("storage-place")}
|
||||||
|
</Typography>
|
||||||
|
<form.Field name="storage_location_uuid">
|
||||||
|
{(field) => (
|
||||||
|
<Select
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(_event, value) => field.handleChange(value ?? "")}
|
||||||
|
variant="plain"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{storages?.map((storage) => (
|
||||||
|
<Option key={storage.uuid} value={storage.uuid}>
|
||||||
|
{storage.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/app/view-product"
|
||||||
|
search={{ product: uuid }}
|
||||||
|
className="block text-center text-sm font-semibold hover:underline"
|
||||||
|
style={{ color: "var(--joy-palette-primary-solidBg)" }}
|
||||||
|
>
|
||||||
|
{t("open-full-details")}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{alert.isAlert && (
|
||||||
|
<MyAlert type={alert.type} header={alert.header} text={alert.text} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" loading={isPending} size="lg" color="primary" variant="solid" className="btn-lift w-full rounded-2xl">
|
||||||
{t("save")}
|
{t("save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</form>
|
||||||
{alert.isAlert && (
|
</>
|
||||||
<MyAlert
|
)}
|
||||||
type={alert.type}
|
</div>
|
||||||
header={alert.header}
|
</div>
|
||||||
text={alert.text}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+182
-154
@@ -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 { useForm } from "@tanstack/react-form";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
@@ -7,12 +7,25 @@ import type { AlertInterface, SettingsIntf } from "../misc/interfaces";
|
|||||||
import type { ApiError } from "../utils/api/apiError";
|
import type { ApiError } from "../utils/api/apiError";
|
||||||
import { fetchSettings, mutateSettings } from "../utils/api/settings";
|
import { fetchSettings, mutateSettings } from "../utils/api/settings";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useColorScheme } from "@mui/joy/styles";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
import { ChangePasswordModal } from "../components/modals/ChangePasswordModal";
|
import { ChangePasswordModal } from "../components/modals/ChangePasswordModal";
|
||||||
import { MyAlert } from "../components/MyAlert.tsx";
|
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 = () => {
|
export const Settings = () => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { mode, setMode } = useColorScheme();
|
||||||
|
const { logout } = useLogout();
|
||||||
const [modal, setModal] = useState(false);
|
const [modal, setModal] = useState(false);
|
||||||
const [alert, setAlert] = useState<AlertInterface>({
|
const [alert, setAlert] = useState<AlertInterface>({
|
||||||
isAlert: false,
|
isAlert: false,
|
||||||
@@ -85,177 +98,192 @@ export const Settings = () => {
|
|||||||
onError: showError,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ChangePasswordModal alert={setAlert} isOpen={modal} setOpen={setModal} />
|
<ChangePasswordModal alert={setAlert} isOpen={modal} setOpen={setModal} />
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<PageHeader title={t("settings")} subtitle={t("settings-sub")} />
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="h2"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("settings")}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
level="body-lg"
|
|
||||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
|
||||||
>
|
|
||||||
{t("settings-sub")}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<Chip
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
className="ml-auto rounded-full px-3"
|
|
||||||
>
|
|
||||||
{t("preferences")}
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Sheet
|
|
||||||
className="mt-6 rounded-3xl p-6 backdrop-blur"
|
|
||||||
sx={{
|
|
||||||
border: "1px solid",
|
|
||||||
borderColor: "divider",
|
|
||||||
bgcolor: "background.surface",
|
|
||||||
boxShadow:
|
|
||||||
"0 24px 60px rgba(var(--joy-palette-primary-900, 12 38 78) / 0.12)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{alert.isAlert && (
|
{alert.isAlert && (
|
||||||
<MyAlert type={alert.type} header={alert.header} text={alert.text} />
|
<MyAlert type={alert.type} header={alert.header} text={alert.text} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{settingsPending ? (
|
{settingsPending ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
<CircularProgress size="lg" />
|
<CircularProgress size="lg" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form
|
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
|
||||||
className="space-y-6"
|
<div className="space-y-6">
|
||||||
onSubmit={(e) => {
|
<Sheet className="rounded-3xl p-6" sx={cardSx}>
|
||||||
e.preventDefault();
|
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
form.handleSubmit();
|
{t("general")}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1.1fr\_0.9fr]">
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("app-name")}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
level="body-sm"
|
|
||||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
|
||||||
>
|
|
||||||
{t("app-name-sub")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="app-name">
|
|
||||||
{(field) => (
|
|
||||||
<Input
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => 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)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("currency")}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
level="body-sm"
|
|
||||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
|
||||||
>
|
|
||||||
{t("currency-sub")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="currency">
|
|
||||||
{(field) => (
|
|
||||||
<Input
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => 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)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="rounded-2xl p-5"
|
|
||||||
style={{
|
|
||||||
border: "1px solid var(--joy-palette-divider)",
|
|
||||||
background:
|
|
||||||
"linear-gradient(to bottom right, var(--joy-palette-primary-50), var(--joy-palette-background-level1), var(--joy-palette-background-level2))",
|
|
||||||
boxShadow: "0 16px 40px var(--joy-palette-divider)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
level="title-lg"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("quick-tips")}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Divider className="my-3" />
|
<Divider className="my-4" />
|
||||||
<div
|
<form
|
||||||
className="space-y-3 text-md"
|
className="space-y-5"
|
||||||
style={{ color: "var(--joy-palette-text-secondary)" }}
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<p>{t("quick-tips-1")}</p>
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
<p>{t("quick-tips-2")}</p>
|
<div className="space-y-1">
|
||||||
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("app-name")}
|
||||||
|
</Typography>
|
||||||
|
<form.Field name="app-name">
|
||||||
|
{(field) => (
|
||||||
|
<Input
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
size="lg"
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="Stockhome"
|
||||||
|
className="rounded-2xl"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
|
{t("app-name-sub")}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("currency")}
|
||||||
|
</Typography>
|
||||||
|
<form.Field name="currency">
|
||||||
|
{(field) => (
|
||||||
|
<Input
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
size="lg"
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="EUR"
|
||||||
|
className="rounded-2xl"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
|
{t("currency-sub")}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" size="lg" color="primary" className="btn-lift rounded-2xl">
|
||||||
|
{t("save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<Sheet className="rounded-3xl p-6" sx={cardSx}>
|
||||||
|
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("appearance-language")}
|
||||||
|
</Typography>
|
||||||
|
<Divider className="my-4" />
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("theme")}
|
||||||
|
</Typography>
|
||||||
|
<div className="inline-flex gap-1 rounded-2xl border border-solid p-1" style={{ borderColor: "var(--joy-palette-divider)" }}>
|
||||||
|
<Button {...segmentBtn(mode === "light")} onClick={() => setMode("light")}>
|
||||||
|
{t("theme-light")}
|
||||||
|
</Button>
|
||||||
|
<Button {...segmentBtn(mode === "dark")} onClick={() => setMode("dark")}>
|
||||||
|
{t("theme-dark")}
|
||||||
|
</Button>
|
||||||
|
<Button {...segmentBtn(mode === "system")} onClick={() => setMode("system")}>
|
||||||
|
{t("theme-system")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("language")}
|
||||||
|
</Typography>
|
||||||
|
<div className="inline-flex gap-1 rounded-2xl border border-solid p-1" style={{ borderColor: "var(--joy-palette-divider)" }}>
|
||||||
|
<Button
|
||||||
|
{...segmentBtn(i18n.language === "de")}
|
||||||
|
onClick={() => i18n.language !== "de" && changeTranslation()}
|
||||||
|
>
|
||||||
|
Deutsch
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
{...segmentBtn(i18n.language === "en")}
|
||||||
|
onClick={() => i18n.language !== "en" && changeTranslation()}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<Sheet className="rounded-3xl p-6" sx={cardSx}>
|
||||||
|
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("account")}
|
||||||
|
</Typography>
|
||||||
|
<Divider className="my-4" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModal(true)}
|
||||||
|
className="flex w-full items-center justify-between rounded-2xl px-1 py-1 text-left"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("change-password")}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={18} color="var(--joy-palette-text-tertiary)" />
|
||||||
|
</button>
|
||||||
|
<Divider />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-danger-solidBg)" }}>
|
||||||
|
{t("sign-out")}
|
||||||
|
</Typography>
|
||||||
|
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
|
{t("sign-out-sub")}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<Button color="danger" variant="outlined" className="rounded-2xl" onClick={logout}>
|
||||||
|
{t("logout")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-3xl p-6"
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--joy-palette-divider)",
|
||||||
|
background:
|
||||||
|
"linear-gradient(to bottom right, var(--joy-palette-primary-50), var(--joy-palette-background-level1), var(--joy-palette-background-level2))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("quick-tips")}
|
||||||
|
</Typography>
|
||||||
|
<Divider className="my-3" />
|
||||||
|
<div className="space-y-3 text-md" style={{ color: "var(--joy-palette-text-secondary)" }}>
|
||||||
|
<p>{t("quick-tips-1")}</p>
|
||||||
|
<p>{t("quick-tips-2")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
</div>
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="lg"
|
|
||||||
color="primary"
|
|
||||||
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)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("save")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setModal(true)}
|
|
||||||
size="lg"
|
|
||||||
color="primary"
|
|
||||||
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)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("change-password")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
</Sheet>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+82
-107
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getStorages } from "../utils/api/storages";
|
import { getStorages } from "../utils/api/storages";
|
||||||
|
import { getProducts } from "../utils/api/products";
|
||||||
import { Button, CircularProgress, Sheet, Table, Typography } from "@mui/joy";
|
import { Button, CircularProgress, Sheet, Table, Typography } from "@mui/joy";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { AlertInterface, Storage } from "../misc/interfaces";
|
import type { AlertInterface, Storage } from "../misc/interfaces";
|
||||||
@@ -7,8 +8,9 @@ import type { ApiError } from "../utils/api/apiError";
|
|||||||
import { StorageRow } from "../components/StorageRow";
|
import { StorageRow } from "../components/StorageRow";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AddStorageModal } from "../components/modals/AddStorageModal";
|
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 { MyAlert } from "../components/MyAlert.tsx";
|
||||||
|
import { PageHeader } from "../components/PageHeader.tsx";
|
||||||
|
|
||||||
export const Storages = () => {
|
export const Storages = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -40,6 +42,13 @@ export const Storages = () => {
|
|||||||
queryFn: () => getStorages(),
|
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(() => {
|
useEffect(() => {
|
||||||
if (storagesError && storagesErrorObj) {
|
if (storagesError && storagesErrorObj) {
|
||||||
showError(storagesErrorObj);
|
showError(storagesErrorObj);
|
||||||
@@ -49,30 +58,29 @@ export const Storages = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="min-w-65 space-y-2">
|
<PageHeader
|
||||||
<Typography
|
title={t("storages")}
|
||||||
level="h2"
|
subtitle={t("storages-sub", { count: storages?.length ?? 0, items: itemsStored })}
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
actions={
|
||||||
>
|
<Button startDecorator={<Plus size={16} />} onClick={() => setModal(true)} variant="solid" className="btn-lift rounded-2xl">
|
||||||
{t("storages")}
|
{t("new-storage")}
|
||||||
</Typography>
|
</Button>
|
||||||
<Typography
|
}
|
||||||
level="body-lg"
|
/>
|
||||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
<AddStorageModal isOpen={modal} setOpen={setModal} />
|
||||||
>
|
<div
|
||||||
|
className="flex items-start gap-3 rounded-2xl border border-solid px-4 py-3"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--joy-palette-warning-softBg)",
|
||||||
|
backgroundColor: "var(--joy-palette-warning-softBg)",
|
||||||
|
color: "var(--joy-palette-warning-softColor)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={18} className="mt-0.5 shrink-0" />
|
||||||
|
<Typography level="body-lg" sx={{ color: "inherit" }}>
|
||||||
{t("storage-delete-info")}
|
{t("storage-delete-info")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
startDecorator={<AddIcon />}
|
|
||||||
onClick={() => setModal(true)}
|
|
||||||
variant="solid"
|
|
||||||
className="rounded-full px-5 py-2 text-base font-semibold shadow-sm"
|
|
||||||
>
|
|
||||||
{t("add")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{alert.isAlert && (
|
{alert.isAlert && (
|
||||||
<MyAlert type={alert.type} header={alert.header} text={alert.text} />
|
<MyAlert type={alert.type} header={alert.header} text={alert.text} />
|
||||||
)}
|
)}
|
||||||
@@ -80,105 +88,72 @@ export const Storages = () => {
|
|||||||
|
|
||||||
<Sheet
|
<Sheet
|
||||||
variant="outlined"
|
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)]"
|
className="mt-6 flex min-h-0 w-full max-w-full flex-col overflow-hidden rounded-2xl sm:h-[calc(100vh-300px)]"
|
||||||
sx={{
|
sx={{
|
||||||
border: "1px solid",
|
border: "1px solid",
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
bgcolor: "background.surface",
|
bgcolor: "background.surface",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AddStorageModal isOpen={modal} setOpen={setModal} />
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
<CircularProgress size="lg" />
|
<CircularProgress size="lg" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex-1 overflow-auto">
|
||||||
<div
|
<Table
|
||||||
className="flex items-center justify-between px-6 py-4"
|
stickyHeader
|
||||||
style={{
|
stripe="odd"
|
||||||
borderBottom: "1px solid var(--joy-palette-divider)",
|
variant="plain"
|
||||||
|
hoverRow
|
||||||
|
className="w-full"
|
||||||
|
sx={{
|
||||||
|
tableLayout: "fixed",
|
||||||
color: "var(--joy-palette-text-secondary)",
|
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)",
|
||||||
|
},
|
||||||
|
"& thead th": {
|
||||||
|
zIndex: 2,
|
||||||
|
backgroundColor: "var(--joy-palette-background-level1)",
|
||||||
|
backgroundImage: "none",
|
||||||
|
color: "var(--joy-palette-text-secondary)",
|
||||||
|
},
|
||||||
|
"& thead th:nth-child(1)": { width: "26%", minWidth: "140px" },
|
||||||
|
"& thead th:nth-child(2)": { width: "34%", minWidth: "160px" },
|
||||||
|
"& thead th:nth-child(3)": { width: "16%", minWidth: "110px" },
|
||||||
|
"& thead th:nth-child(4)": { width: "16%", minWidth: "110px" },
|
||||||
|
"& thead th:nth-child(5)": { width: "8%", minWidth: "90px" },
|
||||||
|
"& tr > *:nth-child(n+3)": { textAlign: "left" },
|
||||||
|
"& tbody tr": {
|
||||||
|
borderTop: "1px solid var(--joy-palette-divider)",
|
||||||
|
transition: "background-color 120ms ease",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level="body-lg" fontWeight="bold">
|
<thead>
|
||||||
{t("storages")}
|
<tr>
|
||||||
</Typography>
|
<th className="px-3 py-4">{t("storage-name")}</th>
|
||||||
</div>
|
<th className="px-3 py-4">{t("description")}</th>
|
||||||
<div className="flex-1 overflow-auto">
|
<th className="px-3 py-4">{t("created-at")}</th>
|
||||||
<Table
|
<th className="px-3 py-4">{t("updated-at")}</th>
|
||||||
stickyHeader
|
<th className="px-3 py-4 text-right"></th>
|
||||||
stripe="odd"
|
</tr>
|
||||||
variant="plain"
|
</thead>
|
||||||
hoverRow
|
<tbody>
|
||||||
className="w-full"
|
{storages?.map((storage: Storage) => (
|
||||||
sx={{
|
<StorageRow key={storage.uuid} storage={storage} onError={showError} />
|
||||||
tableLayout: "fixed",
|
))}
|
||||||
color: "var(--joy-palette-text-secondary)",
|
</tbody>
|
||||||
"--TableCell-headBackground":
|
</Table>
|
||||||
"var(--joy-palette-background-level1)",
|
</div>
|
||||||
"& thead": {
|
|
||||||
position: "sticky",
|
|
||||||
top: 0,
|
|
||||||
zIndex: 3,
|
|
||||||
backgroundColor: "var(--joy-palette-background-level1)",
|
|
||||||
},
|
|
||||||
"& thead tr": {
|
|
||||||
backgroundColor: "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: "26%",
|
|
||||||
minWidth: "140px",
|
|
||||||
},
|
|
||||||
"& thead th:nth-child(2)": {
|
|
||||||
width: "34%",
|
|
||||||
minWidth: "160px",
|
|
||||||
},
|
|
||||||
"& thead th:nth-child(3)": {
|
|
||||||
width: "16%",
|
|
||||||
minWidth: "110px",
|
|
||||||
},
|
|
||||||
"& thead th:nth-child(4)": {
|
|
||||||
width: "16%",
|
|
||||||
minWidth: "110px",
|
|
||||||
},
|
|
||||||
"& thead th:nth-child(5)": {
|
|
||||||
width: "8%",
|
|
||||||
minWidth: "80px",
|
|
||||||
},
|
|
||||||
"& tr > *:nth-child(n+3)": { textAlign: "left" },
|
|
||||||
"& tbody tr": {
|
|
||||||
borderTop: "1px solid var(--joy-palette-divider)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-4">{t("storage-name")}</th>
|
|
||||||
<th className="px-3 py-4">{t("description")}</th>
|
|
||||||
<th className="px-3 py-4">{t("created-at")}</th>
|
|
||||||
<th className="px-3 py-4">{t("updated-at")}</th>
|
|
||||||
<th className="px-3 py-4 text-right"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{storages?.map((storage: Storage) => (
|
|
||||||
<StorageRow
|
|
||||||
key={storage.uuid}
|
|
||||||
storage={storage}
|
|
||||||
onError={showError}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+191
-269
@@ -1,18 +1,36 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { getStorages } from "../utils/api/storages.ts";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "@tanstack/react-form";
|
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 { 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 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 Cookies from "js-cookie";
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
|
import { AmountStepper } from "../components/AmountStepper.tsx";
|
||||||
|
import { ExpiryBadge } from "../components/ProductStatusBar.tsx";
|
||||||
import { MyAlert } from "../components/MyAlert.tsx";
|
import { MyAlert } from "../components/MyAlert.tsx";
|
||||||
|
|
||||||
|
const cardSx = {
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
bgcolor: "background.surface",
|
||||||
|
} as const;
|
||||||
|
|
||||||
interface ViewProductProps {
|
interface ViewProductProps {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
}
|
}
|
||||||
@@ -20,8 +38,8 @@ interface ViewProductProps {
|
|||||||
export const ViewProduct = (props: ViewProductProps) => {
|
export const ViewProduct = (props: ViewProductProps) => {
|
||||||
const uuid = props.uuid;
|
const uuid = props.uuid;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const [alert, setAlert] = useState<AlertInterface>({
|
const [alert, setAlert] = useState<AlertInterface>({
|
||||||
isAlert: false,
|
isAlert: false,
|
||||||
type: "neutral",
|
type: "neutral",
|
||||||
@@ -91,48 +109,43 @@ export const ViewProduct = (props: ViewProductProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutate, isPending } = useMutation({
|
const { mutate, isPending } = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({ values, uuid }: { values: ProductFormValues; uuid: string }) =>
|
||||||
values,
|
mutateProduct(values, uuid),
|
||||||
uuid,
|
|
||||||
}: {
|
|
||||||
values: ProductFormValues;
|
|
||||||
uuid: string;
|
|
||||||
}) => mutateProduct(values, uuid),
|
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
setSuccess(true);
|
setAlert({ isAlert: true, type: "success", header: t("success"), text: t("save") });
|
||||||
queryClient.invalidateQueries({ queryKey: ["product", variables.uuid] });
|
queryClient.invalidateQueries({ queryKey: ["product", variables.uuid] });
|
||||||
},
|
},
|
||||||
onError: showError,
|
onError: showError,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteProduct, isPending: isDeleting } = useMutation({
|
||||||
|
mutationFn: () => deleteSelectedProducts([uuid]),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||||
|
void navigate({ to: "/app/inventory" });
|
||||||
|
},
|
||||||
|
onError: showError,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!productDetails) {
|
if (!productDetails) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.setFieldValue("amount", productDetails.amount ?? 0);
|
form.setFieldValue("amount", productDetails.amount ?? 0);
|
||||||
form.setFieldValue(
|
form.setFieldValue("bottling_date", toInputDate(productDetails.bottling_date));
|
||||||
"bottling_date",
|
|
||||||
toInputDate(productDetails.bottling_date),
|
|
||||||
);
|
|
||||||
form.setFieldValue("description", productDetails.description ?? "");
|
form.setFieldValue("description", productDetails.description ?? "");
|
||||||
form.setFieldValue("expiry_date", toInputDate(productDetails.expiry_date));
|
form.setFieldValue("expiry_date", toInputDate(productDetails.expiry_date));
|
||||||
form.setFieldValue("name", productDetails.name ?? "");
|
form.setFieldValue("name", productDetails.name ?? "");
|
||||||
form.setFieldValue("price", productDetails.price ?? "");
|
form.setFieldValue("price", productDetails.price ?? "");
|
||||||
form.setFieldValue(
|
form.setFieldValue("storage_location_uuid", productDetails.storage_location_uuid ?? "");
|
||||||
"storage_location_uuid",
|
|
||||||
productDetails.storage_location_uuid ?? "",
|
|
||||||
);
|
|
||||||
}, [form, productDetails]);
|
}, [form, productDetails]);
|
||||||
|
|
||||||
const downloadQRcode = async () => {
|
const downloadQRcode = async () => {
|
||||||
const baseUrl: string = `${window.location.protocol}//${window.location.host}`;
|
const baseUrl: string = `${window.location.protocol}//${window.location.host}`;
|
||||||
const url = `${baseUrl}/app/quick-view/product?uuid=${uuid}`;
|
const url = `${baseUrl}/app/quick-view/product?uuid=${uuid}`;
|
||||||
|
|
||||||
const dataUrl = await QRCode.toDataURL(url, {
|
const dataUrl = await QRCode.toDataURL(url, { width: 300, margin: 2 });
|
||||||
width: 300,
|
|
||||||
margin: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.download = "qrcode.png";
|
link.download = "qrcode.png";
|
||||||
@@ -141,52 +154,42 @@ export const ViewProduct = (props: ViewProductProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<div className="space-y-6">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<Typography level="h2" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
<div className="space-y-1">
|
{isSuccess ? productDetails.name : t("product-details")}
|
||||||
<Typography
|
</Typography>
|
||||||
level="h2"
|
{isSuccess && <ExpiryBadge expiryDate={productDetails.expiry_date} />}
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
<Button
|
||||||
>
|
startDecorator={<Trash2 size={16} />}
|
||||||
{t("product-details")}
|
color="danger"
|
||||||
</Typography>
|
variant="outlined"
|
||||||
</div>
|
size="sm"
|
||||||
<Chip
|
className="ml-auto rounded-2xl"
|
||||||
variant="soft"
|
loading={isDeleting}
|
||||||
color="primary"
|
onClick={() => deleteProduct()}
|
||||||
className="ml-auto rounded-full px-3"
|
>
|
||||||
>
|
{t("delete")}
|
||||||
{t("details")}
|
</Button>
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
{productDetailsLoading && <CircularProgress size="sm" />}
|
|
||||||
</div>
|
</div>
|
||||||
|
{productDetailsLoading && <CircularProgress size="sm" />}
|
||||||
|
|
||||||
{isSuccess && (
|
{isSuccess && (
|
||||||
<Box
|
<form
|
||||||
className="mt-6 rounded-3xl p-6 backdrop-blur"
|
className="space-y-6"
|
||||||
sx={{
|
onSubmit={(e) => {
|
||||||
border: "1px solid",
|
e.preventDefault();
|
||||||
borderColor: "divider",
|
form.handleSubmit();
|
||||||
bgcolor: "background.surface",
|
|
||||||
boxShadow:
|
|
||||||
"0 24px 60px color-mix(in srgb, var(--joy-palette-primary-800) 12%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<form
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
className="space-y-6"
|
<div className="rounded-3xl p-6" style={cardSx}>
|
||||||
onSubmit={(e) => {
|
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
e.preventDefault();
|
{t("product")}
|
||||||
form.handleSubmit();
|
</Typography>
|
||||||
}}
|
<div className="mt-4 space-y-4">
|
||||||
>
|
|
||||||
<div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("product-name")}
|
{t("product-name")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<form.Field name="name">
|
<form.Field name="name">
|
||||||
@@ -199,19 +202,12 @@ export const ViewProduct = (props: ViewProductProps) => {
|
|||||||
size="lg"
|
size="lg"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
className="rounded-2xl"
|
className="rounded-2xl"
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
boxShadow: "0 10px 24px var(--joy-palette-divider)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("description")}
|
{t("description")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<form.Field name="description">
|
<form.Field name="description">
|
||||||
@@ -224,44 +220,13 @@ export const ViewProduct = (props: ViewProductProps) => {
|
|||||||
size="lg"
|
size="lg"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
className="rounded-2xl"
|
className="rounded-2xl"
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
boxShadow: "0 10px 24px var(--joy-palette-divider)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("expiry-date")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="expiry_date">
|
|
||||||
{(field) => (
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
size="lg"
|
|
||||||
variant="outlined"
|
|
||||||
className="rounded-2xl"
|
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("bottling-date")}
|
{t("bottling-date")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<form.Field name="bottling_date">
|
<form.Field name="bottling_date">
|
||||||
@@ -274,180 +239,137 @@ export const ViewProduct = (props: ViewProductProps) => {
|
|||||||
size="lg"
|
size="lg"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
className="rounded-2xl"
|
className="rounded-2xl"
|
||||||
sx={{
|
/>
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
)}
|
||||||
}}
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("expiry-date")}
|
||||||
|
</Typography>
|
||||||
|
<form.Field name="expiry_date">
|
||||||
|
{(field) => (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
variant="outlined"
|
||||||
|
className="rounded-2xl"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
</div>
|
||||||
<div
|
|
||||||
className="rounded-2xl p-5"
|
<div className="rounded-3xl p-6" style={cardSx}>
|
||||||
style={{
|
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
border: "1px solid var(--joy-palette-divider)",
|
{t("inventory")}
|
||||||
background:
|
</Typography>
|
||||||
"linear-gradient(to bottom right, var(--joy-palette-primary-50), var(--joy-palette-background-level1), var(--joy-palette-background-level2))",
|
<div className="mt-4 space-y-4">
|
||||||
boxShadow: "0 16px 40px var(--joy-palette-divider)",
|
<div className="space-y-1">
|
||||||
}}
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
>
|
{t("amount")}
|
||||||
<Typography
|
|
||||||
level="title-lg"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("inventory")}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Divider className="my-3" />
|
<form.Field name="amount">
|
||||||
<div className="grid gap-4">
|
{(field) => (
|
||||||
<div className="space-y-1">
|
<AmountStepper
|
||||||
<Typography
|
value={field.state.value}
|
||||||
level="title-md"
|
onChange={field.handleChange}
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
onBlur={field.handleBlur}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("price")}
|
||||||
|
</Typography>
|
||||||
|
<form.Field name="price">
|
||||||
|
{(field) => (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
variant="outlined"
|
||||||
|
className="rounded-2xl"
|
||||||
|
endDecorator={
|
||||||
|
<Typography level="body-sm" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
|
{Cookies.get("currency")}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
|
{t("storage-place")}
|
||||||
|
</Typography>
|
||||||
|
<form.Field name="storage_location_uuid">
|
||||||
|
{(field) => (
|
||||||
|
<Select
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(_event, value) => field.handleChange(value ?? "")}
|
||||||
|
size="lg"
|
||||||
|
variant="outlined"
|
||||||
|
className="rounded-2xl"
|
||||||
>
|
>
|
||||||
{t("amount")}
|
{storages?.map((storage) => (
|
||||||
</Typography>
|
<Option key={storage.uuid} value={storage.uuid}>
|
||||||
<form.Field name="amount">
|
{storage.name}
|
||||||
{(field) => (
|
</Option>
|
||||||
<Input
|
))}
|
||||||
type="number"
|
</Select>
|
||||||
color="neutral"
|
)}
|
||||||
id="amountInput"
|
</form.Field>
|
||||||
placeholder={t("amount")}
|
</div>
|
||||||
value={field.state.value}
|
</div>
|
||||||
variant="soft"
|
|
||||||
size="lg"
|
<div
|
||||||
onChange={(e) => {
|
className="mt-4 flex items-center gap-3 rounded-2xl p-4"
|
||||||
const nextValue = Number(e.target.value);
|
style={{ backgroundColor: "var(--joy-palette-background-level1)" }}
|
||||||
field.handleChange(
|
>
|
||||||
Number.isNaN(nextValue) ? 0 : nextValue,
|
<QrCode size={20} color="var(--joy-palette-text-tertiary)" className="shrink-0" />
|
||||||
);
|
<div className="min-w-0">
|
||||||
}}
|
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||||
onBlur={field.handleBlur}
|
{t("jar-label")}
|
||||||
className="rounded-2xl"
|
</Typography>
|
||||||
sx={{
|
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
{t("jar-label-hint")}
|
||||||
}}
|
</Typography>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("price")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="price">
|
|
||||||
{(field) => (
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
size="lg"
|
|
||||||
variant="outlined"
|
|
||||||
className="rounded-2xl"
|
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
<Typography
|
|
||||||
level="body-sm"
|
|
||||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
|
||||||
>
|
|
||||||
{Cookies.get("currency")}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Typography
|
|
||||||
level="title-md"
|
|
||||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
|
||||||
>
|
|
||||||
{t("storage-place")}
|
|
||||||
</Typography>
|
|
||||||
<form.Field name="storage_location_uuid">
|
|
||||||
{(field) => (
|
|
||||||
<Select
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(_event, value) =>
|
|
||||||
field.handleChange(value ?? "")
|
|
||||||
}
|
|
||||||
size="lg"
|
|
||||||
variant="outlined"
|
|
||||||
className="rounded-2xl"
|
|
||||||
sx={{
|
|
||||||
bgcolor: "var(--joy-palette-background-surface)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{storages?.map((storage) => (
|
|
||||||
<Option key={storage.uuid} value={storage.uuid}>
|
|
||||||
{storage.name}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<div className="flex gap-3 items-center">
|
|
||||||
<Typography
|
|
||||||
level="body-sm"
|
|
||||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
|
||||||
>
|
|
||||||
{t("product-details")}
|
|
||||||
</Typography>
|
|
||||||
<div className="grow"></div>
|
|
||||||
<Button
|
|
||||||
startDecorator={<QrCodeIcon />}
|
|
||||||
loading={isPending}
|
|
||||||
onClick={() => downloadQRcode()}
|
|
||||||
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)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("download-qr-code")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
loading={isPending}
|
|
||||||
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)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{alert.isAlert && (
|
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||||
<MyAlert
|
<Button
|
||||||
type={alert.type}
|
type="button"
|
||||||
header={alert.header}
|
startDecorator={<QrCode size={16} />}
|
||||||
text={alert.text}
|
onClick={() => downloadQRcode()}
|
||||||
/>
|
size="lg"
|
||||||
)}
|
variant="outlined"
|
||||||
</form>
|
color="neutral"
|
||||||
</Box>
|
className="rounded-2xl"
|
||||||
|
>
|
||||||
|
{t("download-qr-code")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={isPending} size="lg" color="primary" variant="solid" className="btn-lift rounded-2xl">
|
||||||
|
{t("save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alert.isAlert && (
|
||||||
|
<MyAlert type={alert.type} header={alert.header} text={alert.text} />
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
import { createFileRoute, Outlet, useRouterState } from "@tanstack/react-router";
|
||||||
import { Sidebar } from "../../components/Sidebar";
|
import { Sidebar } from "../../components/Sidebar";
|
||||||
|
|
||||||
export const Route = createFileRoute("/app/_hiddenLayout")({
|
export const Route = createFileRoute("/app/_hiddenLayout")({
|
||||||
@@ -6,6 +6,8 @@ export const Route = createFileRoute("/app/_hiddenLayout")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
|
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex min-h-screen w-full flex-col lg:flex-row"
|
className="flex min-h-screen w-full flex-col lg:flex-row"
|
||||||
@@ -13,7 +15,9 @@ function AppLayout() {
|
|||||||
>
|
>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 px-4 py-5 sm:px-6 lg:px-8 lg:py-6">
|
<main className="flex-1 px-4 py-5 sm:px-6 lg:px-8 lg:py-6">
|
||||||
<Outlet />
|
<div key={pathname} className="animate-page-in">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+194
-130
@@ -1,6 +1,6 @@
|
|||||||
import { extendTheme } from "@mui/joy/styles";
|
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({
|
export const theme = extendTheme({
|
||||||
cssVarPrefix: "joy",
|
cssVarPrefix: "joy",
|
||||||
@@ -8,23 +8,23 @@ export const theme = extendTheme({
|
|||||||
light: {
|
light: {
|
||||||
palette: {
|
palette: {
|
||||||
primary: {
|
primary: {
|
||||||
50: "#eef7ff",
|
50: "#eef3fe",
|
||||||
100: "#dcedff",
|
100: "#dbe6fc",
|
||||||
200: "#b3d9ff",
|
200: "#b0c8f8",
|
||||||
300: "#7fbdff",
|
300: "#7fa3f0",
|
||||||
400: "#469cf5",
|
400: "#4c7de0",
|
||||||
500: "#0b6bcb",
|
500: "#2159c9",
|
||||||
600: "#0958a8",
|
600: "#1c48a3",
|
||||||
700: "#08478a",
|
700: "#163a82",
|
||||||
800: "#0a3a6e",
|
800: "#122e68",
|
||||||
900: "#0c2f59",
|
900: "#0e2453",
|
||||||
solidBg: "#0b6bcb",
|
solidBg: "#2159c9",
|
||||||
solidHoverBg: "#095aa7",
|
solidHoverBg: "#1c48a3",
|
||||||
solidActiveBg: "#08478a",
|
solidActiveBg: "#163a82",
|
||||||
outlinedBorder: "#7fbdff",
|
outlinedBorder: "#7fa3f0",
|
||||||
outlinedColor: "#08478a",
|
outlinedColor: "#163a82",
|
||||||
softBg: "#dcedff", // Color in sidebar for selected view
|
softBg: "#eef3fe", // Color in sidebar for selected view
|
||||||
softColor: "#08478a",
|
softColor: "#163a82",
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
50: "#ecfdf5",
|
50: "#ecfdf5",
|
||||||
@@ -39,88 +39,100 @@ export const theme = extendTheme({
|
|||||||
900: "#0a423f",
|
900: "#0a423f",
|
||||||
solidBg: "#0d9488",
|
solidBg: "#0d9488",
|
||||||
solidHoverBg: "#0b7c73",
|
solidHoverBg: "#0b7c73",
|
||||||
|
outlinedBorder: "#a6f4dc",
|
||||||
|
outlinedColor: "#0b7c73",
|
||||||
softBg: "#d1faee",
|
softBg: "#d1faee",
|
||||||
softColor: "#0a655e",
|
softColor: "#0a655e",
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
50: "#fffaeb",
|
50: "#fdf1e0",
|
||||||
100: "#fef0c7",
|
100: "#fbe3c1",
|
||||||
200: "#fedf89",
|
200: "#f6c98a",
|
||||||
300: "#fdc74a",
|
300: "#eeab54",
|
||||||
400: "#fbaf24",
|
400: "#e2932c",
|
||||||
500: "#f59e0b",
|
500: "#d98014",
|
||||||
600: "#d67e06",
|
600: "#b46810",
|
||||||
700: "#b25f08",
|
700: "#92530f",
|
||||||
800: "#8f4a0e",
|
800: "#734111",
|
||||||
900: "#763d10",
|
900: "#5c350f",
|
||||||
solidBg: "#f59e0b",
|
solidBg: "#d98014",
|
||||||
solidHoverBg: "#d67e06",
|
solidHoverBg: "#b46810",
|
||||||
softBg: "#fef0c7",
|
outlinedBorder: "#f6c98a",
|
||||||
softColor: "#8f4a0e",
|
outlinedColor: "#b46810",
|
||||||
|
softBg: "#fdf1e0",
|
||||||
|
softColor: "#734111",
|
||||||
},
|
},
|
||||||
danger: {
|
danger: {
|
||||||
50: "#fff1f2",
|
50: "#fbeae9",
|
||||||
100: "#ffe1e4",
|
100: "#f6d2cf",
|
||||||
200: "#ffc7cd",
|
200: "#eaa39c",
|
||||||
300: "#ff9ba7",
|
300: "#dc746a",
|
||||||
400: "#fb6b7d",
|
400: "#cd4f42",
|
||||||
500: "#e11d48",
|
500: "#c4342c",
|
||||||
600: "#be123c",
|
600: "#a32a24",
|
||||||
700: "#9b1038",
|
700: "#84221f",
|
||||||
800: "#7f1230",
|
800: "#691c1b",
|
||||||
900: "#6a122b",
|
900: "#551818",
|
||||||
solidBg: "#e11d48",
|
solidBg: "#c4342c",
|
||||||
solidHoverBg: "#be123c",
|
solidHoverBg: "#a32a24",
|
||||||
softBg: "#ffe1e4",
|
outlinedBorder: "#eaa39c",
|
||||||
softColor: "#9b1038",
|
outlinedColor: "#a32a24",
|
||||||
|
softBg: "#fbeae9",
|
||||||
|
softColor: "#84221f",
|
||||||
},
|
},
|
||||||
neutral: {
|
neutral: {
|
||||||
50: "#f8fafc",
|
50: "#f6f7f9",
|
||||||
100: "#f1f5f9",
|
100: "#f2f4f6",
|
||||||
200: "#e2e8f0",
|
200: "#e4e7ec",
|
||||||
300: "#cbd5e1",
|
300: "#dfe3e8",
|
||||||
400: "#94a3b8",
|
400: "#7b8494",
|
||||||
500: "#64748b",
|
500: "#667283",
|
||||||
600: "#475569",
|
600: "#4a5364",
|
||||||
700: "#334155",
|
700: "#39414f",
|
||||||
800: "#1e293b",
|
800: "#232833",
|
||||||
900: "#0f172a",
|
900: "#10151f",
|
||||||
|
plainColor: "#4a5364",
|
||||||
|
outlinedColor: "#4a5364",
|
||||||
|
outlinedBorder: "#e4e7ec",
|
||||||
|
outlinedHoverBg: "#fbfcfd",
|
||||||
|
softColor: "#4a5364",
|
||||||
|
softBg: "#f2f4f6",
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
body: "#f7f9fc",
|
body: "#f6f7f9",
|
||||||
surface: "#ffffff",
|
surface: "#ffffff",
|
||||||
level1: "#f1f5f9",
|
level1: "#fbfcfd",
|
||||||
level2: "#e2e8f0",
|
level2: "#f2f4f6",
|
||||||
level3: "#cbd5e1",
|
level3: "#e4e7ec",
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
primary: "#0f172a",
|
primary: "#10151f",
|
||||||
secondary: "#475569",
|
secondary: "#4a5364",
|
||||||
tertiary: "#64748b",
|
tertiary: "#667283",
|
||||||
},
|
},
|
||||||
divider: "rgba(15, 23, 42, 0.08)",
|
divider: "#e4e7ec",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
palette: {
|
palette: {
|
||||||
primary: {
|
primary: {
|
||||||
50: "#0c2f59",
|
50: "#0e2453",
|
||||||
100: "#0a3a6e",
|
100: "#122e68",
|
||||||
200: "#08478a",
|
200: "#163a82",
|
||||||
300: "#0958a8",
|
300: "#1c48a3",
|
||||||
400: "#0b6bcb",
|
400: "#2159c9",
|
||||||
500: "#3b8ce0",
|
500: "#5b8def",
|
||||||
600: "#6ba9ea",
|
600: "#82a8f2",
|
||||||
700: "#9cc6f2",
|
700: "#a9c3f6",
|
||||||
800: "#c7defa",
|
800: "#cddcfa",
|
||||||
900: "#eef7ff",
|
900: "#eef3fe",
|
||||||
solidBg: "#3b8ce0",
|
solidBg: "#5b8def",
|
||||||
solidHoverBg: "#6ba9ea",
|
solidHoverBg: "#82a8f2",
|
||||||
solidActiveBg: "#0958a8",
|
solidActiveBg: "#1c48a3",
|
||||||
outlinedBorder: "#0958a8",
|
outlinedBorder: "#1c48a3",
|
||||||
outlinedColor: "#9cc6f2",
|
outlinedColor: "#a9c3f6",
|
||||||
softBg: "rgba(59, 140, 224, 0.16)", // Color in sidebar for selected view
|
softBg: "#16233d", // Color in sidebar for selected view
|
||||||
softColor: "#9cc6f2",
|
softColor: "#a9c3f6",
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
50: "#0a423f",
|
50: "#0a423f",
|
||||||
@@ -135,72 +147,85 @@ export const theme = extendTheme({
|
|||||||
900: "#ecfdf5",
|
900: "#ecfdf5",
|
||||||
solidBg: "#0d9488",
|
solidBg: "#0d9488",
|
||||||
solidHoverBg: "#2dc8b1",
|
solidHoverBg: "#2dc8b1",
|
||||||
|
outlinedBorder: "#0a655e",
|
||||||
|
outlinedColor: "#6ee7c9",
|
||||||
softBg: "rgba(13, 148, 136, 0.18)",
|
softBg: "rgba(13, 148, 136, 0.18)",
|
||||||
softColor: "#6ee7c9",
|
softColor: "#6ee7c9",
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
50: "#763d10",
|
50: "#5c350f",
|
||||||
100: "#8f4a0e",
|
100: "#734111",
|
||||||
200: "#b25f08",
|
200: "#92530f",
|
||||||
300: "#d67e06",
|
300: "#b46810",
|
||||||
400: "#f59e0b",
|
400: "#d98014",
|
||||||
500: "#fbaf24",
|
500: "#e3a054",
|
||||||
600: "#fdc74a",
|
600: "#edbb82",
|
||||||
700: "#fedf89",
|
700: "#f4d3ac",
|
||||||
800: "#fef0c7",
|
800: "#f9e6d3",
|
||||||
900: "#fffaeb",
|
900: "#fdf1e0",
|
||||||
solidBg: "#f59e0b",
|
solidBg: "#e3a054",
|
||||||
solidHoverBg: "#fbaf24",
|
solidHoverBg: "#edbb82",
|
||||||
softBg: "rgba(245, 158, 11, 0.16)",
|
outlinedBorder: "#92530f",
|
||||||
softColor: "#fdc74a",
|
outlinedColor: "#edbb82",
|
||||||
|
softBg: "rgba(227, 160, 84, 0.16)",
|
||||||
|
softColor: "#f4d3ac",
|
||||||
},
|
},
|
||||||
danger: {
|
danger: {
|
||||||
50: "#6a122b",
|
50: "#551818",
|
||||||
100: "#7f1230",
|
100: "#691c1b",
|
||||||
200: "#9b1038",
|
200: "#84221f",
|
||||||
300: "#be123c",
|
300: "#a32a24",
|
||||||
400: "#e11d48",
|
400: "#c4342c",
|
||||||
500: "#f04463",
|
500: "#f07168",
|
||||||
600: "#fb6b7d",
|
600: "#f4948d",
|
||||||
700: "#ff9ba7",
|
700: "#f8b7b2",
|
||||||
800: "#ffc7cd",
|
800: "#fbd6d3",
|
||||||
900: "#fff1f2",
|
900: "#fdeceb",
|
||||||
solidBg: "#e11d48",
|
solidBg: "#f07168",
|
||||||
solidHoverBg: "#f04463",
|
solidHoverBg: "#f4948d",
|
||||||
softBg: "rgba(225, 29, 72, 0.18)",
|
outlinedBorder: "#84221f",
|
||||||
softColor: "#fb6b7d",
|
outlinedColor: "#f4948d",
|
||||||
|
softBg: "rgba(240, 113, 104, 0.16)",
|
||||||
|
softColor: "#f8b7b2",
|
||||||
},
|
},
|
||||||
neutral: {
|
neutral: {
|
||||||
50: "#0b1220",
|
50: "#0e1116",
|
||||||
100: "#0f172a",
|
100: "#161b22",
|
||||||
200: "#1e293b",
|
200: "#1b212a",
|
||||||
300: "#334155",
|
300: "#20262f",
|
||||||
400: "#475569",
|
400: "#272e39",
|
||||||
500: "#64748b",
|
500: "#8590a0",
|
||||||
600: "#94a3b8",
|
600: "#aab4c2",
|
||||||
700: "#cbd5e1",
|
700: "#c7cfd9",
|
||||||
800: "#e2e8f0",
|
800: "#e8ecf2",
|
||||||
900: "#f1f5f9",
|
900: "#f6f8fa",
|
||||||
|
plainColor: "#aab4c2",
|
||||||
|
outlinedColor: "#aab4c2",
|
||||||
|
outlinedBorder: "#272e39",
|
||||||
|
outlinedHoverBg: "#1b212a",
|
||||||
|
softColor: "#aab4c2",
|
||||||
|
softBg: "#20262f",
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
body: "#0b1220",
|
body: "#0e1116",
|
||||||
surface: "#111a2e",
|
surface: "#161b22",
|
||||||
level1: "#16213a",
|
level1: "#1b212a",
|
||||||
level2: "#1e293b",
|
level2: "#20262f",
|
||||||
level3: "#334155",
|
level3: "#272e39",
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
primary: "#f1f5f9",
|
primary: "#e8ecf2",
|
||||||
secondary: "#cbd5e1",
|
secondary: "#aab4c2",
|
||||||
tertiary: "#94a3b8",
|
tertiary: "#8590a0",
|
||||||
},
|
},
|
||||||
divider: "rgba(226, 232, 240, 0.08)",
|
divider: "#272e39",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
body: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
body: "'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif",
|
||||||
display: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
display: "'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif",
|
||||||
|
code: "'IBM Plex Mono', ui-monospace, monospace",
|
||||||
},
|
},
|
||||||
radius: {
|
radius: {
|
||||||
xs: "6px",
|
xs: "6px",
|
||||||
@@ -209,6 +234,45 @@ export const theme = extendTheme({
|
|||||||
lg: "20px",
|
lg: "20px",
|
||||||
xl: "28px",
|
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: {
|
components: {
|
||||||
JoyInput: {
|
JoyInput: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
|
|||||||
@@ -86,5 +86,48 @@
|
|||||||
"EP005": "Das Produkt konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.",
|
"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.",
|
"EP006": "Das Produkt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
|
||||||
"EP007": "Bitte geben Sie mindestens einen Produktnamen ein.",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,5 +86,48 @@
|
|||||||
"EP005": "The product could not be updated. Please try again.",
|
"EP005": "The product could not be updated. Please try again.",
|
||||||
"EP006": "The product could not be deleted. Please try again.",
|
"EP006": "The product could not be deleted. Please try again.",
|
||||||
"EP007": "Please enter at least a product name.",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
};
|
||||||
Generated
+42
-309
@@ -18,7 +18,8 @@
|
|||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
"globals": "^17.7.0"
|
"globals": "^17.7.0",
|
||||||
|
"prettier": "^3.9.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"backend": {
|
"backend": {
|
||||||
@@ -45,8 +46,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||||
"@mui/icons-material": "^9.0.1",
|
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||||
"@mui/joy": "^5.0.0-beta.52",
|
"@mui/joy": "^5.0.0-beta.52",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tanstack/react-form": "^1.32.0",
|
"@tanstack/react-form": "^1.32.0",
|
||||||
@@ -1103,10 +1104,19 @@
|
|||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource/inter": {
|
"node_modules/@fontsource/ibm-plex-mono": {
|
||||||
"version": "5.2.8",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz",
|
||||||
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
|
"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",
|
"license": "OFL-1.1",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
@@ -1266,32 +1276,6 @@
|
|||||||
"url": "https://opencollective.com/mui-org"
|
"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": {
|
"node_modules/@mui/joy": {
|
||||||
"version": "5.0.0-beta.52",
|
"version": "5.0.0-beta.52",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/joy/-/joy-5.0.0-beta.52.tgz",
|
"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": {
|
"node_modules/@mui/private-theming": {
|
||||||
"version": "5.17.1",
|
"version": "5.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz",
|
||||||
@@ -2830,6 +2600,7 @@
|
|||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -2845,16 +2616,6 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@types/send": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||||
@@ -3510,16 +3271,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
@@ -3705,15 +3466,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/concurrently": {
|
"node_modules/concurrently": {
|
||||||
"version": "10.0.3",
|
"version": "10.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz",
|
||||||
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==",
|
"integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "5.6.2",
|
"chalk": "5.6.2",
|
||||||
"rxjs": "7.8.2",
|
"rxjs": "7.8.2",
|
||||||
"shell-quote": "1.8.4",
|
"shell-quote": "1.9.0",
|
||||||
"supports-color": "10.2.2",
|
"supports-color": "10.2.2",
|
||||||
"tree-kill": "1.2.2",
|
"tree-kill": "1.2.2",
|
||||||
"yargs": "18.0.0"
|
"yargs": "18.0.0"
|
||||||
@@ -3921,17 +3682,6 @@
|
|||||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.4.2",
|
"version": "17.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||||
@@ -4451,9 +4201,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/filelist/node_modules/brace-expansion": {
|
"node_modules/filelist/node_modules/brace-expansion": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
@@ -5590,9 +5340,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.15",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5949,9 +5699,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.16",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -5968,7 +5718,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.17",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -5987,9 +5737,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "3.9.4",
|
"version": "3.9.6",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||||
"integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==",
|
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -6331,23 +6081,6 @@
|
|||||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
@@ -6590,9 +6323,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/shell-quote": {
|
"node_modules/shell-quote": {
|
||||||
"version": "1.8.4",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
|
||||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+2
-1
@@ -35,6 +35,7 @@
|
|||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
"globals": "^17.7.0"
|
"globals": "^17.7.0",
|
||||||
|
"prettier": "^3.9.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user