Files
tartarus-app/src/app/products/page.tsx

464 lines
13 KiB
TypeScript
Raw Normal View History

"use client";
import * as React from "react";
import dynamic from "next/dynamic";
import {
Box,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
IconButton,
Tooltip,
TextField,
Button,
Stack,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
const Canvas = dynamic(() => import("@react-three/fiber").then((m) => m.Canvas), {
ssr: false,
});
const OrbitControls = dynamic(
() => import("@react-three/drei").then((m) => m.OrbitControls),
{ ssr: false }
);
type Product = {
record_num: number;
product_id: number;
name: string;
exp_date: string; // ISO yyyy-mm-dd
};
function isoTodayPlus(days: number) {
const d = new Date();
d.setDate(d.getDate() + days);
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
const seed: Product[] = [
{ record_num: 1, product_id: 1001, name: "Milk", exp_date: isoTodayPlus(30) },
{ record_num: 2, product_id: 1002, name: "Eggs", exp_date: isoTodayPlus(12) },
{ record_num: 3, product_id: 1003, name: "Bread", exp_date: isoTodayPlus(6) },
{ record_num: 4, product_id: 1004, name: "Cheese", exp_date: isoTodayPlus(90) },
{ record_num: 5, product_id: 1005, name: "Yogurt", exp_date: isoTodayPlus(20) },
{ record_num: 6, product_id: 1006, name: "Butter", exp_date: isoTodayPlus(120) },
];
const glassPaperSx = {
borderRadius: 3,
// glossy glass look
background: `
linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.05) 100%),
radial-gradient(1200px 600px at 20% 0%, rgba(130,170,255,0.20) 0%, rgba(0,0,0,0) 55%),
radial-gradient(900px 500px at 80% 10%, rgba(255,140,180,0.14) 0%, rgba(0,0,0,0) 60%)
`,
border: "1px solid rgba(255,255,255,0.14)",
backdropFilter: "blur(14px) saturate(130%)",
boxShadow: "0 12px 40px rgba(0,0,0,0.35)",
color: "rgba(245,245,245,0.92)",
};
const PLANE_SIDE = 5;
const CUBE_SIZE = 1.0;
const GAP = 0.25;
const GAP_Z = 0.9;
export default function Products() {
const [products, setProducts] = React.useState<Product[]>(seed);
const [selected, setSelected] = React.useState<Product | null>(null);
const [mode, setMode] = React.useState<"view3d" | "form">("view3d");
const sorted = React.useMemo(() => {
return [...products].sort((a, b) => (a.exp_date < b.exp_date ? 1 : -1));
}, [products]);
const startCreate = () => {
setSelected({
record_num: -1,
product_id: -1,
name: "",
exp_date: isoTodayPlus(1),
});
setMode("form");
};
const startEdit = (p: Product) => {
setSelected(p);
setMode("form");
};
const cancelForm = () => {
setMode("view3d");
setSelected(null);
};
const saveForm = (p: Product) => {
const isNew = p.record_num === -1;
if (isNew) {
const nextRecord =
Math.max(0, ...products.map((x) => x.record_num)) + 1;
const nextProductId =
Math.max(0, ...products.map((x) => x.product_id)) + 1;
const inserted: Product = {
...p,
record_num: nextRecord,
product_id: nextProductId,
};
setProducts((prev) => [inserted, ...prev]);
} else {
setProducts((prev) =>
prev.map((x) => (x.record_num === p.record_num ? p : x))
);
}
setMode("view3d");
setSelected(null);
};
return (
<Box
sx={{
minHeight: "100vh",
display: "flex",
gap: 2,
p: 2,
}}
>
<Box sx={{ width: { xs: "100%", md: 520 }, flex: "0 0 auto" }}>
<TableContainer
component={Paper}
sx={{
...glassPaperSx,
height: "calc(100vh - 16px)",
overflow: "auto",
}}
>
<Table stickyHeader size="small" aria-label="products table">
<TableHead>
<TableRow>
<TableCell
sx={{
position: "sticky",
top: 0,
zIndex: 3,
fontWeight: 800,
whiteSpace: "nowrap",
backgroundColor: "rgba(15, 18, 28, 0.65)",
backdropFilter: "blur(10px)",
borderBottom: "1px solid rgba(255,255,255,0.10)",
color: "rgba(245,245,245,0.92)",
}}
>
Products
</TableCell>
<TableCell
sx={{
position: "sticky",
top: 0,
zIndex: 3,
fontWeight: 800,
whiteSpace: "nowrap",
backgroundColor: "rgba(15, 18, 28, 0.65)",
backdropFilter: "blur(10px)",
borderBottom: "1px solid rgba(255,255,255,0.10)",
color: "rgba(245,245,245,0.92)",
}}
>
Exp Date
</TableCell>
<TableCell
sx={{
position: "sticky",
top: 0,
zIndex: 4,
width: 1,
p: 0,
}}
>
<Box sx={{ position: "relative", height: 1 }}>
<Tooltip title="Add product">
<IconButton
onClick={startCreate}
size="small"
sx={{
position: "absolute",
right: -18,
top: "50%",
transform: "translateY(-50%)",
bgcolor: "background.paper",
border: "1px solid",
borderColor: "divider",
boxShadow: 1,
"&:hover": { bgcolor: "action.hover" },
}}
aria-label="add product"
>
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sorted.map((p) => (
<TableRow
key={p.record_num}
hover
onClick={() => startEdit(p)}
sx={{ cursor: "pointer" }}
>
<TableCell>
<Typography fontWeight={700}>{p.name}</Typography>
<Typography variant="caption" sx={{ opacity: 0.7 }}>
record #{p.record_num} product_id {p.product_id}
</Typography>
</TableCell>
<TableCell sx={{ whiteSpace: "nowrap" }}>{p.exp_date}</TableCell>
<TableCell sx={{ width: 1 }} />
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
<Box
sx={{
flex: "1 1 auto",
minWidth: 0,
position: "relative",
borderRadius: 0,
bgcolor: "transparent",
}}
>
{mode === "view3d" ? (
<Box sx={{ width: "100%", height: "calc(100vh - 16px)" }}>
<World3D products={sorted} />
</Box>
) : (
<Box sx={{ width: "100%", height: "calc(100vh - 16px)" }}>
<ProductForm
value={selected}
onCancel={cancelForm}
onSave={saveForm}
/>
</Box>
)}
</Box>
</Box>
);
}
function rowsUsedForPlane(total: number, planeIndex: number, side: number) {
const perPlane = side * side;
const start = planeIndex * perPlane;
const remaining = Math.max(0, total - start);
const countThisPlane = Math.min(perPlane, remaining);
return Math.max(1, Math.ceil(countThisPlane / side));
}
function computePosition(
i: number,
total: number,
side: number,
cubeSize: number,
gap: number,
gapZ: number
) {
const perPlane = side * side;
const plane = Math.floor(i / perPlane);
const within = i % perPlane;
// We fill "down" first, then left:
// row changes slowest? Actually: for down-first, row = within % rowsUsed, col = floor(within / rowsUsed)
// But we still want a bounded side x side layout. Easiest is:
// row = within % side (down)
// col = floor(within / side) (left)
const row = within % side; // 0..side-1 (top->down)
const col = Math.floor(within / side); // 0..side-1 (right->left)
// How many rows are actually used in this plane?
const rowsUsed = rowsUsedForPlane(total, plane, side);
// Center the grid in X around 0, but start at right-most column
const step = cubeSize + gap;
const xHalfSpan = ((side - 1) * step) / 2;
// Top-right start:
// col=0 should be right-most => x positive
const x = (xHalfSpan - col * step);
// FLOOR-LOCK:
// Bottom row sits on floor (grid) at y = cubeSize/2
// If rowsUsed < side, we compress downward so the lowest row hits the floor.
// row=0 is the top row; we want top row higher.
const rowInUsedRange = row; // row already 0..side-1
const clampedRow = Math.min(rowInUsedRange, rowsUsed - 1);
const y = cubeSize / 2 + (rowsUsed - 1 - clampedRow) * step;
// Next plane goes "forward" (you said z is forward/back); using negative z to go "forward"
const z = -plane * (cubeSize + gapZ);
return [x, y, z] as const;
}
function World3D({ products }: { products: Product[] }) {
return (
<Canvas
camera={{ position: [7, 6, 13], fov: 45, near: 0.1, far: 200 }}
onCreated={({ camera }) => {
camera.lookAt(2.8, 2.6, -2);
}}
>
{/* eslint-disable-next-line react/no-unknown-property */}
<ambientLight intensity={0.8} />
{/* eslint-disable-next-line react/no-unknown-property */}
<directionalLight position={[8, 10, 6]} intensity={1.2} />
<gridHelper args={[60, 60]} />
{/* products */}
{products.map((p, i) => (
<ProductBox key={p.record_num} product={p} index={i} total={products.length} />
))}
<OrbitControls
enableRotate={true}
enablePan={true}
enableZoom={true}
minDistance={10}
maxDistance={14}
/>
</Canvas>
);
}
function ProductBox({
product,
index,
total,
}: {
product: Product;
index: number;
total: number;
}) {
const [x, y, z] = computePosition(
index,
total,
PLANE_SIDE,
CUBE_SIZE,
GAP,
GAP_Z
);
return (
// eslint-disable-next-line react/no-unknown-property
<mesh position={[x, y, z]}>
{/* eslint-disable-next-line react/no-unknown-property */}
<boxGeometry args={[CUBE_SIZE, CUBE_SIZE, CUBE_SIZE]} />
{/* eslint-disable-next-line react/no-unknown-property */}
<meshStandardMaterial />
</mesh>
);
}
function ProductForm({
value,
onCancel,
onSave,
}: {
value: Product | null;
onCancel: () => void;
onSave: (p: Product) => void;
}) {
const [draft, setDraft] = React.useState<Product | null>(value);
React.useEffect(() => {
setDraft(value);
}, [value]);
if (!draft) return null;
const isNew = draft.record_num === -1;
return (
<Box
sx={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
px: 2,
}}
>
<Paper
elevation={0}
sx={{
...glassPaperSx,
width: "min(720px, 100%)",
p: 3,
}}
>
<Typography variant="h6" fontWeight={800} gutterBottom>
{isNew ? "Create Product" : "Update Product"}
</Typography>
<Stack spacing={2} sx={{ mt: 2 }}>
<TextField
label="Name"
value={draft.name}
onChange={(e) =>
setDraft((d) => (d ? { ...d, name: e.target.value } : d))
}
inputProps={{ maxLength: 20 }}
fullWidth
/>
<TextField
label="Expiration Date"
type="date"
value={draft.exp_date}
onChange={(e) =>
setDraft((d) => (d ? { ...d, exp_date: e.target.value } : d))
}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
<Button onClick={onCancel} variant="text">
Cancel
</Button>
<Button
onClick={() => onSave(draft)}
variant="contained"
disabled={!draft.name.trim() || !draft.exp_date}
>
Save
</Button>
</Box>
</Stack>
</Paper>
</Box>
);
}