import { ChangeEvent, Fragment, MouseEvent, useEffect, useMemo, useState } from "react"; import { Button, Checkbox, Chip, CircularProgress, Input, Sheet, Table, Typography } from "@mui/joy"; import { useNavigate } from "@tanstack/react-router"; import { useTranslation } from "react-i18next"; import { ChevronDown, Plus, Search, Trash2 } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { deleteSelectedProducts, getProducts } from "../utils/api/products"; import { formatDate } from "../utils/uxFncs"; import Cookies from "js-cookie"; import type { AlertInterface, ProductRow } from "../misc/interfaces"; import type { ApiError } from "../utils/api/apiError"; import { MyAlert } from "../components/MyAlert.tsx"; import { PageHeader } from "../components/PageHeader.tsx"; import { StatTiles } from "../components/StatTiles.tsx"; import { Mono } from "../components/Mono.tsx"; import { ProductStatusBar } from "../components/ProductStatusBar.tsx"; import { getExpiryDays, getProductStatus, isExpired, isExpiringSoon, isLowStock } from "../utils/productStatus"; const relativeColor = { expired: "var(--joy-palette-danger-solidBg)", "expiring-soon": "var(--joy-palette-warning-solidBg)", ok: "var(--joy-palette-text-tertiary)", } as const; // Small "in N days" / "Expired N days ago" line shown under a row's expiry date. const ExpiryRelative = ({ expiryDate }: { expiryDate: string | null }) => { const { t } = useTranslation(); const expiry = getExpiryDays(expiryDate); if (!expiry) { return null; } const text = expiry.status === "expired" ? t("days-since-expired", { count: Math.abs(expiry.days) }) : t("days-until-expiry", { count: expiry.days }); return ( {text} ); }; type FilterKey = "all" | "expired" | "soon" | "low"; const matchesFilter = (row: ProductRow, filter: FilterKey) => { if (filter === "all") return true; if (filter === "expired") return isExpired(row.expiryDateRaw); if (filter === "soon") return isExpiringSoon(row.expiryDateRaw); return isLowStock(Number(row.stockLabel)); }; export const InventoryPage = () => { const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [alert, setAlert] = useState({ isAlert: false, type: "neutral", header: "", text: "", }); const [search, setSearch] = useState(""); const [filter, setFilter] = useState("all"); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); const showError = (error: unknown) => { const errorCode = (error as { code?: string })?.code; setAlert({ isAlert: true, type: "danger", header: t("error"), text: errorCode ? t(errorCode) : t("unknown-error"), }); }; const { data: productsData, isLoading: productsIsLoading, isError: productsError, error: productsErrorObj, } = useQuery({ queryKey: ["products"], queryFn: getProducts, }); useEffect(() => { if (productsError && productsErrorObj) { showError(productsErrorObj); } }, [productsError, productsErrorObj]); const rows: ProductRow[] = useMemo( () => (productsData ?? []).map((product: any, index: number) => ({ id: String(product?.uuid ?? index), uuid: String(product?.uuid ?? ""), name: product?.name ?? t("product-name"), description: product?.description ?? "", imageUrl: product?.picture ?? undefined, price: product?.price ? String(product.price) : "-", stock: `${product?.amount ?? 0} ${t("pcs")}`, stockLabel: String(product?.amount ?? 0), stockStatus: (product?.amount ?? 0) > 0 ? "ok" : "missing", location: product?.storage_location_name ?? "-", locationDetail: "", expiryDate: formatDate(product?.expiry_date), expiryDateRaw: product?.expiry_date ?? null, refillDate: formatDate(product?.bottling_date), })), [productsData, t], ); const stats = useMemo( () => ({ expired: rows.filter((row) => isExpired(row.expiryDateRaw)).length, expiringSoon: rows.filter((row) => isExpiringSoon(row.expiryDateRaw)).length, lowStock: rows.filter((row) => isLowStock(Number(row.stockLabel))).length, unitsStored: rows.reduce((sum, row) => sum + Number(row.stockLabel), 0), }), [rows], ); const filteredRows = useMemo(() => { const query = search.trim().toLowerCase(); return rows .filter((row) => matchesFilter(row, filter)) .filter( (row) => !query || row.name.toLowerCase().includes(query) || row.description.toLowerCase().includes(query), ); }, [rows, search, filter]); const groups = useMemo(() => { const map = new Map(); for (const row of filteredRows) { const bucket = map.get(row.location) ?? []; bucket.push(row); map.set(row.location, bucket); } return Array.from(map.entries()).map(([name, groupRows]) => ({ name, rows: groupRows, units: groupRows.reduce((sum, row) => sum + Number(row.stockLabel), 0), })); }, [filteredRows]); const storageCount = useMemo(() => new Set(rows.map((row) => row.location)).size, [rows]); const toggleGroup = (name: string) => { setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(name)) { next.delete(name); } else { next.add(name); } return next; }); }; const [selected, setSelected] = useState([]); const { mutate: deleteSelection, isPending: isDeleting } = useMutation({ mutationFn: (values: string[]) => deleteSelectedProducts(values), onSuccess: () => { setSelected([]); void queryClient.invalidateQueries({ queryKey: ["products"] }); }, onError: showError, }); const handleSelectAllClick = (event: ChangeEvent) => { setSelected(event.target.checked ? filteredRows.map((row) => row.id) : []); }; const handleClick = (_event: MouseEvent, id: string) => { setSelected((prev) => prev.includes(id) ? prev.filter((rowId) => rowId !== id) : [...prev, id], ); }; const filterTabs: { key: FilterKey; label: string; count: number; color: "neutral" | "danger" | "warning" }[] = [ { key: "all", label: t("filter-all"), count: rows.length, color: "neutral" }, { key: "expired", label: t("filter-expired"), count: stats.expired, color: "danger" }, { key: "soon", label: t("filter-soon"), count: stats.expiringSoon, color: "warning" }, { key: "low", label: t("filter-low"), count: stats.lowStock, color: "neutral" }, ]; return (
} onClick={() => navigate({ to: "/app/add-product" })} variant="solid" className="btn-lift rounded-2xl" > {t("add")} } /> {productsIsLoading && } {alert.isAlert && }
setSearch(e.target.value)} placeholder={t("search-products")} startDecorator={} size="lg" variant="outlined" className="rounded-2xl sm:max-w-xs" />
{filterTabs.map((tab) => ( setFilter(tab.key)} className="cursor-pointer rounded-full px-3 transition-all duration-150 active:scale-95" > {tab.label} {tab.count} ))}
{selected.length > 0 && (
{selected.length} {t("selected")}
)} {/* Desktop: grouped table */}
*:nth-child(n+3)": { textAlign: "left" }, "& tbody tr": { borderTop: "1px solid var(--joy-palette-divider)", transition: "background-color 120ms ease", }, }} > {groups.map((group) => { const isCollapsed = collapsedGroups.has(group.name); return ( {!isCollapsed && group.rows.map((row) => { const isItemSelected = selected.includes(row.id); const status = getProductStatus(row.expiryDateRaw); return ( handleClick(event, row.id)} role="checkbox" aria-checked={isItemSelected} > ); })} ); })}
0 && selected.length === filteredRows.length} indeterminate={selected.length > 0 && selected.length < filteredRows.length} onChange={handleSelectAllClick} slotProps={{ input: { "aria-label": "select all" } }} /> {t("product-name")} {t("stock")} {t("price")} {t("expiry-date")} {t("actions")}
{row.name} {row.description}
{row.stock} {row.price === "-" ? "-" : `${row.price} ${Cookies.get("currency") ?? ""}`} {row.expiryDate}
{/* Mobile: grouped card list */}
{groups.map((group) => { const isCollapsed = collapsedGroups.has(group.name); return (
{group.rows.map((row) => { const status = getProductStatus(row.expiryDateRaw); return ( ); })}
); })}
); };