Files
stockhome/frontend/src/pages/Inventory.tsx
T

577 lines
18 KiB
TypeScript

import * as React from "react";
import {
Typography,
Button,
CircularProgress,
Sheet,
Table,
Chip,
Avatar,
Box,
IconButton,
Checkbox,
FormControl,
FormLabel,
Link,
Tooltip,
Select,
Option,
} from "@mui/joy";
import { useNavigate } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import DeleteIcon from "@mui/icons-material/Delete";
import FilterListIcon from "@mui/icons-material/FilterList";
import KeyboardArrowLeftIcon from "@mui/icons-material/KeyboardArrowLeft";
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getProducts, deleteSelectedProducts } from "../utils/uxFncs";
import { visuallyHidden } from "@mui/utils";
import { formatDate } from "../utils/uxFncs";
import Cookies from "js-cookie";
type Order = "asc" | "desc";
type ProductRow = {
id: string;
uuid: string;
name: string;
description: string;
imageUrl?: string;
price: string;
stock: string;
stockLabel: string;
stockStatus: "ok" | "low" | "missing";
location: string;
locationDetail: string;
expiryDate: string;
refillDate: string;
};
const descendingComparator = <T,>(a: T, b: T, orderBy: keyof T) => {
const aValue = (a[orderBy] ?? "") as string | number;
const bValue = (b[orderBy] ?? "") as string | number;
if (bValue < aValue) {
return -1;
}
if (bValue > aValue) {
return 1;
}
return 0;
};
const getComparator = (order: Order, orderBy: keyof ProductRow) => {
return order === "desc"
? (a: ProductRow, b: ProductRow) => descendingComparator(a, b, orderBy)
: (a: ProductRow, b: ProductRow) => -descendingComparator(a, b, orderBy);
};
type EnhancedTableHeadProps = {
numSelected: number;
onRequestSort: (
event: React.MouseEvent<unknown>,
property: keyof ProductRow,
) => void;
onSelectAllClick: (event: React.ChangeEvent<HTMLInputElement>) => void;
order: Order;
orderBy: string;
rowCount: number;
};
const EnhancedTableHead = (props: EnhancedTableHeadProps) => {
const { t } = useTranslation();
const {
onSelectAllClick,
order,
orderBy,
numSelected,
rowCount,
onRequestSort,
} = props;
const createSortHandler =
(property: keyof ProductRow) => (event: React.MouseEvent<unknown>) => {
onRequestSort(event, property);
};
const headCells: readonly {
id: keyof ProductRow;
label: string;
numeric: boolean;
}[] = [
{ id: "name", label: t("product-name"), numeric: false },
{ id: "price", label: t("price"), numeric: true },
{ id: "stock", label: t("stock"), numeric: true },
{ id: "location", label: t("storage-place"), numeric: false },
{ id: "expiryDate", label: t("expiry-date"), numeric: false },
{ id: "refillDate", label: t("bottling-date"), numeric: false },
];
return (
<thead>
<tr className="bg-slate-50 text-slate-600">
<th className="px-4 py-4">
<Checkbox
indeterminate={numSelected > 0 && numSelected < rowCount}
checked={rowCount > 0 && numSelected === rowCount}
onChange={onSelectAllClick}
slotProps={{ input: { "aria-label": "select all products" } }}
sx={{ verticalAlign: "sub" }}
/>
</th>
{headCells.map((headCell) => {
const active = orderBy === headCell.id;
return (
<th
key={headCell.id}
className="px-6 py-4"
aria-sort={
active
? ({ asc: "ascending", desc: "descending" } as const)[order]
: undefined
}
>
<Link
underline="none"
color="neutral"
textColor={active ? "primary.plainColor" : undefined}
component="button"
onClick={createSortHandler(headCell.id)}
startDecorator={
headCell.numeric ? (
<ArrowDownwardIcon
sx={[active ? { opacity: 1 } : { opacity: 0 }]}
/>
) : null
}
endDecorator={
!headCell.numeric ? (
<ArrowDownwardIcon
sx={[active ? { opacity: 1 } : { opacity: 0 }]}
/>
) : null
}
sx={{
fontWeight: "lg",
"& svg": {
transition: "0.2s",
transform:
active && order === "desc"
? "rotate(0deg)"
: "rotate(180deg)",
},
"&:hover": { "& svg": { opacity: 1 } },
}}
>
{headCell.label}
{active ? (
<Box component="span" sx={visuallyHidden}>
{order === "desc"
? "sorted descending"
: "sorted ascending"}
</Box>
) : null}
</Link>
</th>
);
})}
<th className="px-6 py-4 text-right">{t("actions")}</th>
</tr>
</thead>
);
};
const EnhancedTableToolbar = ({
numSelected,
selected,
}: {
numSelected: number;
selected: readonly string[];
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: (values: string[]) => deleteSelectedProducts(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
},
});
return (
<Box
sx={[
{
display: "flex",
alignItems: "center",
py: 1,
pl: { sm: 2 },
pr: { xs: 1, sm: 1 },
borderTopLeftRadius: "var(--unstable_actionRadius)",
borderTopRightRadius: "var(--unstable_actionRadius)",
},
numSelected > 0 && {
bgcolor: "background.level1",
},
]}
className="text-slate-700"
>
{numSelected > 0 ? (
<Typography sx={{ flex: "1 1 100%" }} component="div">
{numSelected} {t("selected")}
</Typography>
) : (
<Typography
level="body-lg"
fontWeight={"bold"}
sx={{ flex: "1 1 100%" }}
component="div"
>
{t("inventory")}
</Typography>
)}
{numSelected > 0 ? (
<Tooltip title="Delete">
<IconButton
onClick={() => mutate([...selected])}
size="sm"
color="danger"
variant="solid"
>
<DeleteIcon />
</IconButton>
</Tooltip>
) : (
<Tooltip title="Filter list">
<IconButton size="sm" variant="outlined" color="neutral">
<FilterListIcon />
</IconButton>
</Tooltip>
)}
</Box>
);
};
export const InventoryPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const labelDisplayedRows = ({
from,
to,
count,
}: {
from: number;
to: number;
count: number;
}) => `${from}-${to} ${t("of")} ${count}`;
const { data: productsData, isLoading: productsIsLoading } = useQuery({
queryKey: ["products"],
queryFn: getProducts,
});
const rows: ProductRow[] = (productsData ?? []).map(
(product: any, index: number) => ({
id: String(product?.uuid ?? index),
uuid: String(product?.uuid ?? ""),
name: product?.name ?? t("product-name"),
description: product?.description ?? "",
imageUrl: product?.picture ?? undefined,
price: product?.price ?? "-",
stock: `${product?.amount ?? 0} Stk.`,
location: product?.storage_location_name ?? "-",
locationDetail: "",
expiryDate: formatDate(product?.expiry_date),
refillDate: formatDate(product?.bottling_date),
}),
);
const [order, setOrder] = React.useState<Order>("asc");
const [orderBy, setOrderBy] = React.useState<keyof ProductRow>("name");
const [selected, setSelected] = React.useState<readonly string[]>([]);
const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(5);
const handleRequestSort = (
_event: React.MouseEvent<unknown>,
property: keyof ProductRow,
) => {
const isAsc = orderBy === property && order === "asc";
setOrder(isAsc ? "desc" : "asc");
setOrderBy(property);
};
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
const newSelected = rows.map((row) => row.id);
setSelected(newSelected);
return;
}
setSelected([]);
};
const handleClick = (_event: React.MouseEvent<unknown>, id: string) => {
const selectedIndex = selected.indexOf(id);
let newSelected: readonly string[] = [];
if (selectedIndex === -1) {
newSelected = newSelected.concat(selected, id);
} else if (selectedIndex === 0) {
newSelected = newSelected.concat(selected.slice(1));
} else if (selectedIndex === selected.length - 1) {
newSelected = newSelected.concat(selected.slice(0, -1));
} else if (selectedIndex > 0) {
newSelected = newSelected.concat(
selected.slice(0, selectedIndex),
selected.slice(selectedIndex + 1),
);
}
setSelected(newSelected);
};
const handleChangePage = (newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (_event: any, newValue: number | null) => {
setRowsPerPage(parseInt(newValue!.toString(), 10));
setPage(0);
};
const getLabelDisplayedRowsTo = () => {
if (rows.length === -1) {
return (page + 1) * rowsPerPage;
}
return rowsPerPage === -1
? rows.length
: Math.min(rows.length, (page + 1) * rowsPerPage);
};
const emptyRows =
page > 0 ? Math.max(0, (1 + page) * rowsPerPage - rows.length) : 0;
return (
<>
<Typography level="h2">{t("inventory")}</Typography>
<Typography level="body-lg">{t("inventory-subtitle")}</Typography>
<div className="mt-4 flex items-center gap-3">
<Button
startDecorator={<AddIcon />}
onClick={() => navigate({ to: "/app/add-product" })}
variant="solid"
>
{t("add")}
</Button>
{productsIsLoading && <CircularProgress size="sm" />}
</div>
<Sheet
variant="outlined"
className="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white/80 shadow-sm"
>
<EnhancedTableToolbar
numSelected={selected.length}
selected={selected}
/>
<Table
aria-labelledby="tableTitle"
hoverRow
className="min-w-240 text-slate-700"
sx={{
"--TableCell-headBackground": "transparent",
"--TableCell-selectedBackground": (theme) =>
theme.vars.palette.success.softBg,
"& thead th:nth-child(1)": {
width: "40px",
},
"& thead th:nth-child(2)": {
width: "30%",
},
"& tr > *:nth-child(n+3)": { textAlign: "left" },
}}
>
<EnhancedTableHead
numSelected={selected.length}
order={order}
orderBy={orderBy}
onSelectAllClick={handleSelectAllClick}
onRequestSort={handleRequestSort}
rowCount={rows.length}
/>
<tbody>
{[...rows]
.sort(getComparator(order, orderBy))
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row, index) => {
const isItemSelected = selected.includes(row.id);
const labelId = `enhanced-table-checkbox-${index}`;
return (
<tr
key={row.id}
onClick={(event) => handleClick(event, row.id)}
role="checkbox"
aria-checked={isItemSelected}
tabIndex={-1}
className="border-t border-slate-200"
style={
isItemSelected
? ({
"--TableCell-dataBackground":
"var(--TableCell-selectedBackground)",
"--TableCell-headBackground":
"var(--TableCell-selectedBackground)",
} as React.CSSProperties)
: {}
}
>
<th scope="row" className="px-4 py-5">
<Checkbox
checked={isItemSelected}
slotProps={{ input: { "aria-labelledby": labelId } }}
sx={{ verticalAlign: "top" }}
/>
</th>
<th id={labelId} scope="row" className="px-6 py-5">
<div className="flex items-center gap-4">
<Avatar size="lg" variant="soft" src={row.imageUrl} />
<div>
<Typography
level="title-md"
className="text-slate-900"
>
{row.name}
</Typography>
<Typography
level="body-sm"
className="text-slate-500"
>
{row.description}
</Typography>
</div>
</div>
</th>
<td className="px-6 py-5">
<Typography level="title-md">{row.price}</Typography>
<Typography level="body-sm" className="text-slate-400">
{Cookies.get("currency")}
</Typography>
</td>
<td className="px-6 py-5">
<Chip
variant="soft"
color={"neutral"}
size="lg"
className="px-3"
>
{row.stock}
</Chip>
</td>
<td className="px-6 py-5">
<Typography level="title-md">{row.location}</Typography>
<Typography level="body-sm" className="text-slate-500">
{row.locationDetail}
</Typography>
</td>
<td className="px-6 py-5">
<Typography level="title-md">{row.expiryDate}</Typography>
</td>
<td className="px-6 py-5">
<Typography level="title-md">{row.refillDate}</Typography>
</td>
<td className="px-6 py-5 text-right">
<Button
onClick={() =>
navigate({
to: `/app/view-product?product=${row.uuid}`,
})
}
variant="outlined"
size="sm"
>
{t("details")}
</Button>
</td>
</tr>
);
})}
{emptyRows > 0 && (
<tr
style={
{
height: `calc(${emptyRows} * 40px)`,
"--TableRow-hoverBackground": "transparent",
} as React.CSSProperties
}
>
<td colSpan={8} aria-hidden />
</tr>
)}
</tbody>
<tfoot>
<tr>
<td colSpan={8}>
<Box
className="px-6 py-4 text-slate-600"
sx={{
display: "flex",
alignItems: "center",
gap: 2,
justifyContent: "flex-end",
}}
>
<FormControl orientation="horizontal" size="sm">
<FormLabel>{t("rows-per-page")}</FormLabel>
<Select
onChange={handleChangeRowsPerPage}
value={rowsPerPage}
>
<Option value={5}>5</Option>
<Option value={10}>10</Option>
<Option value={25}>25</Option>
</Select>
</FormControl>
<Typography sx={{ textAlign: "center", minWidth: 120 }}>
{labelDisplayedRows({
from: rows.length === 0 ? 0 : page * rowsPerPage + 1,
to: getLabelDisplayedRowsTo(),
count: rows.length === -1 ? -1 : rows.length,
})}
</Typography>
<Box sx={{ display: "flex", gap: 1 }}>
<IconButton
size="sm"
color="neutral"
variant="outlined"
disabled={page === 0}
onClick={() => handleChangePage(page - 1)}
sx={{ bgcolor: "background.surface" }}
>
<KeyboardArrowLeftIcon />
</IconButton>
<IconButton
size="sm"
color="neutral"
variant="outlined"
disabled={
rows.length !== -1
? page >= Math.ceil(rows.length / rowsPerPage) - 1
: false
}
onClick={() => handleChangePage(page + 1)}
sx={{ bgcolor: "background.surface" }}
>
<KeyboardArrowRightIcon />
</IconButton>
</Box>
</Box>
</td>
</tr>
</tfoot>
</Table>
</Sheet>
</>
);
};