577 lines
18 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
};
|