refactor: update ui and make it modern
This commit is contained in:
+380
-236
@@ -1,16 +1,53 @@
|
||||
import { ChangeEvent, MouseEvent, useEffect, useState } from "react";
|
||||
import { Avatar, Button, Checkbox, Chip, CircularProgress, Sheet, Table, Typography, } from "@mui/joy";
|
||||
import { ChangeEvent, Fragment, MouseEvent, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Checkbox, Chip, CircularProgress, Input, Sheet, Table, Typography } from "@mui/joy";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { ChevronDown, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { deleteSelectedProducts, getProducts } from "../utils/api/products";
|
||||
import { formatDate } from "../utils/uxFncs";
|
||||
import Cookies from "js-cookie";
|
||||
import type { AlertInterface, ProductRow } from "../misc/interfaces";
|
||||
import type { ApiError } from "../utils/api/apiError";
|
||||
import CategoryIcon from "@mui/icons-material/Category";
|
||||
import { MyAlert } from "../components/MyAlert.tsx";
|
||||
import { PageHeader } from "../components/PageHeader.tsx";
|
||||
import { StatTiles } from "../components/StatTiles.tsx";
|
||||
import { Mono } from "../components/Mono.tsx";
|
||||
import { ProductStatusBar } from "../components/ProductStatusBar.tsx";
|
||||
import { getExpiryDays, getProductStatus, isExpired, isExpiringSoon, isLowStock } from "../utils/productStatus";
|
||||
|
||||
const relativeColor = {
|
||||
expired: "var(--joy-palette-danger-solidBg)",
|
||||
"expiring-soon": "var(--joy-palette-warning-solidBg)",
|
||||
ok: "var(--joy-palette-text-tertiary)",
|
||||
} as const;
|
||||
|
||||
// Small "in N days" / "Expired N days ago" line shown under a row's expiry date.
|
||||
const ExpiryRelative = ({ expiryDate }: { expiryDate: string | null }) => {
|
||||
const { t } = useTranslation();
|
||||
const expiry = getExpiryDays(expiryDate);
|
||||
if (!expiry) {
|
||||
return null;
|
||||
}
|
||||
const text =
|
||||
expiry.status === "expired"
|
||||
? t("days-since-expired", { count: Math.abs(expiry.days) })
|
||||
: t("days-until-expiry", { count: expiry.days });
|
||||
return (
|
||||
<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 = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -22,6 +59,9 @@ export const InventoryPage = () => {
|
||||
header: "",
|
||||
text: "",
|
||||
});
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<FilterKey>("all");
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const showError = (error: unknown) => {
|
||||
const errorCode = (error as { code?: string })?.code;
|
||||
@@ -49,24 +89,77 @@ export const InventoryPage = () => {
|
||||
}
|
||||
}, [productsError, productsErrorObj]);
|
||||
|
||||
const rows: ProductRow[] = (productsData ?? []).map(
|
||||
(product: any, index: number) => ({
|
||||
id: String(product?.uuid ?? index),
|
||||
uuid: String(product?.uuid ?? ""),
|
||||
name: product?.name ?? t("product-name"),
|
||||
description: product?.description ?? "",
|
||||
imageUrl: product?.picture ?? undefined,
|
||||
price: product?.price ? String(product.price) : "-",
|
||||
stock: `${product?.amount ?? 0} ${t("pcs")}`,
|
||||
stockLabel: String(product?.amount ?? 0),
|
||||
stockStatus: (product?.amount ?? 0) > 0 ? "ok" : "missing",
|
||||
location: product?.storage_location_name ?? "-",
|
||||
locationDetail: "",
|
||||
expiryDate: formatDate(product?.expiry_date),
|
||||
refillDate: formatDate(product?.bottling_date),
|
||||
}),
|
||||
const rows: ProductRow[] = useMemo(
|
||||
() =>
|
||||
(productsData ?? []).map((product: any, index: number) => ({
|
||||
id: String(product?.uuid ?? index),
|
||||
uuid: String(product?.uuid ?? ""),
|
||||
name: product?.name ?? t("product-name"),
|
||||
description: product?.description ?? "",
|
||||
imageUrl: product?.picture ?? undefined,
|
||||
price: product?.price ? String(product.price) : "-",
|
||||
stock: `${product?.amount ?? 0} ${t("pcs")}`,
|
||||
stockLabel: String(product?.amount ?? 0),
|
||||
stockStatus: (product?.amount ?? 0) > 0 ? "ok" : "missing",
|
||||
location: product?.storage_location_name ?? "-",
|
||||
locationDetail: "",
|
||||
expiryDate: formatDate(product?.expiry_date),
|
||||
expiryDateRaw: product?.expiry_date ?? null,
|
||||
refillDate: formatDate(product?.bottling_date),
|
||||
})),
|
||||
[productsData, t],
|
||||
);
|
||||
|
||||
const stats = useMemo(
|
||||
() => ({
|
||||
expired: rows.filter((row) => isExpired(row.expiryDateRaw)).length,
|
||||
expiringSoon: rows.filter((row) => isExpiringSoon(row.expiryDateRaw)).length,
|
||||
lowStock: rows.filter((row) => isLowStock(Number(row.stockLabel))).length,
|
||||
unitsStored: rows.reduce((sum, row) => sum + Number(row.stockLabel), 0),
|
||||
}),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return rows
|
||||
.filter((row) => matchesFilter(row, filter))
|
||||
.filter(
|
||||
(row) =>
|
||||
!query ||
|
||||
row.name.toLowerCase().includes(query) ||
|
||||
row.description.toLowerCase().includes(query),
|
||||
);
|
||||
}, [rows, search, filter]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<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 { mutate: deleteSelection, isPending: isDeleting } = useMutation({
|
||||
@@ -79,81 +172,104 @@ export const InventoryPage = () => {
|
||||
});
|
||||
|
||||
const handleSelectAllClick = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.checked) {
|
||||
setSelected(rows.map((row) => row.id));
|
||||
return;
|
||||
}
|
||||
setSelected([]);
|
||||
setSelected(event.target.checked ? filteredRows.map((row) => row.id) : []);
|
||||
};
|
||||
|
||||
const handleClick = (_event: MouseEvent<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);
|
||||
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 (
|
||||
<>
|
||||
<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 className="space-y-6">
|
||||
<PageHeader
|
||||
title={t("inventory")}
|
||||
subtitle={t("inventory-summary", { count: rows.length, storages: storageCount })}
|
||||
actions={
|
||||
<Button
|
||||
startDecorator={<Plus size={16} />}
|
||||
onClick={() => navigate({ to: "/app/add-product" })}
|
||||
variant="solid"
|
||||
className="btn-lift rounded-2xl"
|
||||
>
|
||||
{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>
|
||||
{alert.isAlert && (
|
||||
<MyAlert type={alert.type} header={alert.header} text={alert.text} />
|
||||
)}
|
||||
|
||||
<Sheet
|
||||
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)]"
|
||||
sx={{
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.surface",
|
||||
}}
|
||||
className="flex min-h-0 w-full max-w-full flex-col overflow-hidden rounded-2xl"
|
||||
sx={{ border: "1px solid", borderColor: "divider", bgcolor: "background.surface" }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4"
|
||||
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])}
|
||||
{selected.length > 0 && (
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-3"
|
||||
style={{ borderBottom: "1px solid var(--joy-palette-divider)" }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Typography level="body-sm" sx={{ color: "var(--joy-palette-text-secondary)" }}>
|
||||
{selected.length} {t("selected")}
|
||||
</Typography>
|
||||
<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
|
||||
aria-labelledby="tableTitle"
|
||||
stickyHeader
|
||||
stripe="odd"
|
||||
variant="plain"
|
||||
@@ -162,57 +278,19 @@ export const InventoryPage = () => {
|
||||
sx={{
|
||||
tableLayout: "fixed",
|
||||
color: "var(--joy-palette-text-secondary)",
|
||||
"--TableCell-headBackground":
|
||||
"var(--joy-palette-background-level1)",
|
||||
"& thead": {
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 3,
|
||||
backgroundColor: "var(--joy-palette-background-level1)",
|
||||
},
|
||||
"& thead tr": {
|
||||
backgroundColor: "var(--joy-palette-background-level1)",
|
||||
},
|
||||
"--TableCell-headBackground": "var(--joy-palette-background-level1)",
|
||||
"& thead th": {
|
||||
zIndex: 2,
|
||||
backgroundColor: "var(--joy-palette-background-level1)",
|
||||
backgroundImage: "none",
|
||||
color: "var(--joy-palette-text-secondary)",
|
||||
},
|
||||
"& thead th:nth-child(1)": {
|
||||
width: "44px",
|
||||
},
|
||||
"& thead th:nth-child(2)": {
|
||||
width: "22%",
|
||||
minWidth: "160px",
|
||||
},
|
||||
"& thead th:nth-child(3)": {
|
||||
width: "9%",
|
||||
minWidth: "70px",
|
||||
},
|
||||
"& thead th:nth-child(4)": {
|
||||
width: "12%",
|
||||
minWidth: "90px",
|
||||
},
|
||||
"& thead th:nth-child(5)": {
|
||||
width: "17%",
|
||||
minWidth: "120px",
|
||||
},
|
||||
"& thead th:nth-child(6)": {
|
||||
width: "13%",
|
||||
minWidth: "100px",
|
||||
},
|
||||
"& thead th:nth-child(7)": {
|
||||
width: "13%",
|
||||
minWidth: "100px",
|
||||
},
|
||||
"& thead th:nth-child(8)": {
|
||||
width: "14%",
|
||||
minWidth: "100px",
|
||||
},
|
||||
"& tr > *:nth-child(n+4)": { textAlign: "left" },
|
||||
"& thead th:nth-child(1)": { width: "40px" },
|
||||
"& thead th:nth-child(2)": { width: "26%" },
|
||||
"& thead th:nth-child(6)": { width: "8%" },
|
||||
"& tr > *:nth-child(n+3)": { textAlign: "left" },
|
||||
"& tbody tr": {
|
||||
borderTop: "1px solid var(--joy-palette-divider)",
|
||||
transition: "background-color 120ms ease",
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -220,134 +298,200 @@ export const InventoryPage = () => {
|
||||
<tr>
|
||||
<th className="px-2 py-4">
|
||||
<Checkbox
|
||||
checked={rows.length > 0 && selected.length === rows.length}
|
||||
indeterminate={
|
||||
selected.length > 0 && selected.length < rows.length
|
||||
}
|
||||
checked={filteredRows.length > 0 && selected.length === filteredRows.length}
|
||||
indeterminate={selected.length > 0 && selected.length < filteredRows.length}
|
||||
onChange={handleSelectAllClick}
|
||||
slotProps={{ input: { "aria-label": "select all" } }}
|
||||
sx={{ verticalAlign: "sub" }}
|
||||
/>
|
||||
</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("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("bottling-date")}</th>
|
||||
<th className="px-3 py-4 text-right">{t("actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => {
|
||||
const isItemSelected = selected.includes(row.id);
|
||||
const labelId = `inventory-checkbox-${index}`;
|
||||
{groups.map((group) => {
|
||||
const isCollapsed = collapsedGroups.has(group.name);
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={(event) => handleClick(event, row.id)}
|
||||
role="checkbox"
|
||||
aria-checked={isItemSelected}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<th scope="row" className="px-2 py-5">
|
||||
<Checkbox
|
||||
checked={isItemSelected}
|
||||
slotProps={{ input: { "aria-labelledby": labelId } }}
|
||||
sx={{ verticalAlign: "top" }}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
id={labelId}
|
||||
scope="row"
|
||||
className="px-3 py-5 overflow-hidden"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar size="lg" variant="soft" className="shrink-0">
|
||||
<CategoryIcon />
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<Typography
|
||||
level="title-md"
|
||||
className="truncate"
|
||||
sx={{ color: "var(--joy-palette-text-primary)" }}
|
||||
<Fragment key={`group-${group.name}`}>
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-3" style={{ backgroundColor: "var(--joy-palette-background-level1)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroup(group.name)}
|
||||
className="flex items-center gap-2 font-semibold"
|
||||
style={{ color: "var(--joy-palette-text-primary)" }}
|
||||
>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
{group.name}
|
||||
<span className="font-normal" style={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||
{group.rows.length} {t("products")} · {group.units} {t("units")}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{!isCollapsed &&
|
||||
group.rows.map((row) => {
|
||||
const isItemSelected = selected.includes(row.id);
|
||||
const status = getProductStatus(row.expiryDateRaw);
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={(event) => handleClick(event, row.id)}
|
||||
role="checkbox"
|
||||
aria-checked={isItemSelected}
|
||||
>
|
||||
{row.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
level="body-sm"
|
||||
className="truncate"
|
||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
||||
>
|
||||
{row.description}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
<td className="px-3 py-5 overflow-hidden">
|
||||
<Typography level="title-md" className="truncate">
|
||||
{row.price}
|
||||
</Typography>
|
||||
<Typography
|
||||
level="body-sm"
|
||||
className="truncate"
|
||||
sx={{ color: "var(--joy-palette-neutral-400)" }}
|
||||
>
|
||||
{Cookies.get("currency")}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-3 py-5 overflow-hidden">
|
||||
<Chip
|
||||
variant="soft"
|
||||
color="neutral"
|
||||
size="lg"
|
||||
className="max-w-full px-3"
|
||||
>
|
||||
<span className="truncate">{row.stock}</span>
|
||||
</Chip>
|
||||
</td>
|
||||
<td className="px-3 py-5 overflow-hidden">
|
||||
<Typography level="title-md" className="truncate">
|
||||
{row.location}
|
||||
</Typography>
|
||||
<Typography
|
||||
level="body-sm"
|
||||
className="truncate"
|
||||
sx={{ color: "var(--joy-palette-text-tertiary)" }}
|
||||
>
|
||||
{row.locationDetail}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-3 py-5 overflow-hidden">
|
||||
<Typography level="title-md" className="truncate">
|
||||
{row.expiryDate}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-3 py-5 overflow-hidden">
|
||||
<Typography level="title-md" className="truncate">
|
||||
{row.refillDate}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-3 py-5 text-right">
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: `/app/view-product?product=${row.uuid}`,
|
||||
})
|
||||
}
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
>
|
||||
{t("details")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<td className="px-2 py-4">
|
||||
<Checkbox checked={isItemSelected} sx={{ verticalAlign: "top" }} />
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex min-w-0 items-stretch gap-3">
|
||||
<ProductStatusBar status={status} />
|
||||
<div className="min-w-0">
|
||||
<Typography level="title-md" className="truncate" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||
{row.name}
|
||||
</Typography>
|
||||
<Typography level="body-sm" className="truncate normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||
{row.description}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<Typography
|
||||
level="title-md"
|
||||
sx={{
|
||||
color: isLowStock(Number(row.stockLabel))
|
||||
? "var(--joy-palette-danger-solidBg)"
|
||||
: "var(--joy-palette-text-primary)",
|
||||
}}
|
||||
>
|
||||
{row.stock}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<Mono>
|
||||
<Typography level="body-sm" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
|
||||
{row.price === "-" ? "-" : `${row.price} ${Cookies.get("currency") ?? ""}`}
|
||||
</Typography>
|
||||
</Mono>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<Mono>
|
||||
<Typography level="title-md" className="truncate" sx={{ color: "var(--joy-palette-text-primary)" }}>
|
||||
{row.expiryDate}
|
||||
</Typography>
|
||||
</Mono>
|
||||
<ExpiryRelative expiryDate={row.expiryDateRaw} />
|
||||
</td>
|
||||
<td className="px-3 py-4 text-right">
|
||||
<Button
|
||||
onClick={() => navigate({ to: `/app/view-product?product=${row.uuid}` })}
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
className="rounded-xl"
|
||||
>
|
||||
{t("details")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</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>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user