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

573 lines
15 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 }
);
const Text = dynamic(() => import("@react-three/drei").then((m) => m.Text), {
ssr: false,
});
const RoundedBox = dynamic(
() => import("@react-three/drei").then((m) => m.RoundedBox),
{ ssr: false }
);
const Environment = dynamic(
() => import("@react-three/drei").then((m) => m.Environment),
{ 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: 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 = 4;
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 planeStart = plane * perPlane;
const planeCount = Math.min(perPlane, Math.max(0, total - planeStart));
const within = i - planeStart; // 0..planeCount-1
// Fill DOWN first, then LEFT (next column)
const col = Math.floor(within / side); // 0..side-1 (right -> left)
const row = within % side; // 0..side-1 (top -> down)
// How many cubes are actually in *this column* on this plane?
// (last column may be partial)
const countInThisCol = Math.min(side, planeCount - col * side);
// spacing
const step = cubeSize + gap;
// Right-most column is col=0 => x positive
const xHalfSpan = ((side - 1) * step) / 2;
const x = xHalfSpan - col * step;
// FLOOR-LOCK PER COLUMN:
// bottom cube of this column sits at y = cubeSize/2
// row=0 is top => higher y
const y = cubeSize / 2 + (countInThisCol - 1 - row) * step;
// Next plane goes "forward/back". You said you want front first,
// so plane 0 should be the front. Put further planes behind it:
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);
}}
gl={{ antialias: true }}
>
{/* Nice reflections / lighting */}
<Environment preset="city" />
{/* eslint-disable-next-line react/no-unknown-property */}
<ambientLight intensity={0.35} />
{/* eslint-disable-next-line react/no-unknown-property */}
<directionalLight position={[8, 10, 6]} intensity={1.4} />
{/* eslint-disable-next-line react/no-unknown-property */}
<directionalLight position={[-8, 6, -6]} intensity={0.6} />
<gridHelper args={[60, 60]} />
{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);
const label = String(product.product_id);
const s = CUBE_SIZE;
const half = s / 2;
const pad = 0.01; // push text slightly off the face to avoid z-fighting
const fontSize = Math.max(0.14, s * 0.18);
return (
// eslint-disable-next-line react/no-unknown-property
<group position={[x, y, z]}>
{/* Rounded cube */}
<RoundedBox args={[s, s, s]} radius={0.12} smoothness={6}>
{/* eslint-disable-next-line react/no-unknown-property */}
<meshPhysicalMaterial
transmission={0.65} // glassiness
roughness={0.18}
thickness={1.2}
clearcoat={1}
clearcoatRoughness={0.12}
metalness={0.05}
envMapIntensity={1.25}
/>
</RoundedBox>
{/* +Z (front) */}
<Text
position={[0, 0, half + pad]}
fontSize={fontSize}
anchorX="center"
anchorY="middle"
outlineWidth={0.01}
outlineColor="black"
>
{label}
</Text>
{/* -Z (back) */}
<Text
position={[0, 0, -half - pad]}
rotation={[0, Math.PI, 0]}
fontSize={fontSize}
anchorX="center"
anchorY="middle"
outlineWidth={0.01}
outlineColor="black"
>
{label}
</Text>
{/* +X (right) */}
<Text
position={[half + pad, 0, 0]}
rotation={[0, -Math.PI / 2, 0]}
fontSize={fontSize}
anchorX="center"
anchorY="middle"
outlineWidth={0.01}
outlineColor="black"
>
{label}
</Text>
{/* -X (left) */}
<Text
position={[-half - pad, 0, 0]}
rotation={[0, Math.PI / 2, 0]}
fontSize={fontSize}
anchorX="center"
anchorY="middle"
outlineWidth={0.01}
outlineColor="black"
>
{label}
</Text>
{/* +Y (top) */}
<Text
position={[0, half + pad, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={fontSize}
anchorX="center"
anchorY="middle"
outlineWidth={0.01}
outlineColor="black"
>
{label}
</Text>
{/* -Y (bottom) */}
<Text
position={[0, -half - pad, 0]}
rotation={[Math.PI / 2, 0, 0]}
fontSize={fontSize}
anchorX="center"
anchorY="middle"
outlineWidth={0.01}
outlineColor="black"
>
{label}
</Text>
</group>
);
}
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>
);
}