573 lines
15 KiB
TypeScript
573 lines
15 KiB
TypeScript
"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>
|
|
);
|
|
}
|
|
|