tidy up renewals chart, allow filtering out of

This commit is contained in:
Daniel Roth 2026-02-03 09:59:22 +00:00
parent b91640d168
commit 3e102ae6ea
2 changed files with 65 additions and 278 deletions

View file

@ -1,218 +1,3 @@
// "use client";
// import React, { useRef, useState } from "react";
// import {
// Table,
// TableHeader,
// TableBody,
// TableRow,
// TableHead,
// TableCell,
// } from "@/app/shadcn_components/ui/table";
// import { Element } from "../types/element";
// import { ASPECT_TYPE_LABELS, ELEMENT_TYPE_LABELS } from "../constants";
// export type ElementGroup = {
// elementType: string;
// elementInstance: number;
// elements: Element[];
// };
// const thStyle: React.CSSProperties = {
// borderBottom: "1px solid #ccc",
// textAlign: "left",
// padding: "4px",
// };
// const tdStyle: React.CSSProperties = {
// borderBottom: "1px solid #eee",
// padding: "4px",
// };
// type Props = {
// groupedElements: ElementGroup[];
// };
// export default function ElementTable({ groupedElements }: Props) {
// const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
// const [expandedParents, setExpandedParents] = useState<Set<string>>(new Set());
// const [showDropdown, setShowDropdown] = useState(false);
// const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
// const buttonRef = useRef<HTMLButtonElement | null>(null);
// const uniqueElementTypes = Array.from(new Set(groupedElements.map(g => g.elementType))).sort((a, b) =>
// a.localeCompare(b)
// );
// const toggleTypeFilter = (type: string) => {
// const newSet = new Set(selectedTypes);
// if (newSet.has(type)) newSet.delete(type);
// else newSet.add(type);
// setSelectedTypes(newSet);
// };
// const selectAll = () => setSelectedTypes(new Set(uniqueElementTypes));
// const deselectAll = () => setSelectedTypes(new Set());
// const filteredGroups =
// selectedTypes.size === 0
// ? groupedElements
// : groupedElements.filter(g => selectedTypes.has(g.elementType));
// const sortedGroups = [...filteredGroups].sort((a, b) =>
// a.elementType.localeCompare(b.elementType)
// );
// const toggleParent = (key: string) => {
// const newSet = new Set(expandedParents);
// if (newSet.has(key)) newSet.delete(key);
// else newSet.add(key);
// setExpandedParents(newSet);
// };
// // Handle dropdown toggle and position
// const handleDropdownToggle = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
// const rect = e.currentTarget.getBoundingClientRect();
// const parentRect = (e.currentTarget.offsetParent as HTMLElement)?.getBoundingClientRect();
// if (parentRect) {
// setDropdownPos({
// top: rect.bottom - parentRect.top,
// left: rect.left - parentRect.left,
// });
// }
// setShowDropdown(prev => !prev);
// };
// return (
// <div className="relative">
// <div className="rounded-md border">
// <Table>
// <TableHeader>
// <TableRow>
// <TableHead>
// Element
// {/* Filter button stays in header */}
// <button
// ref={buttonRef}
// onClick={handleDropdownToggle}
// className="ml-2 btn btn-sm"
// >
// ▼
// </button>
// </TableHead>
// <TableHead>No. Aspects</TableHead>
// </TableRow>
// </TableHeader>
// <TableBody>
// {sortedGroups.map(group => {
// const parentKey = `${group.elementType}__${group.elementInstance}`;
// const isExpanded = expandedParents.has(parentKey);
// const totalAspects = group.elements.reduce((acc, el) => acc + el.aspects.length, 0);
// return (
// <React.Fragment key={parentKey}>
// {/* Parent Row */}
// <TableRow
// className="bg-gray-100 font-bold cursor-pointer"
// onClick={() => toggleParent(parentKey)}
// >
// <TableCell>
// {`${ELEMENT_TYPE_LABELS[group.elementType] ?? group.elementType.replaceAll("_", " ")}${group.elementInstance > 1 ? ` (${group.elementInstance})` : ""}`}
// </TableCell>
// <TableCell>{totalAspects}</TableCell>
// </TableRow>
// {/* Child Table */}
// {isExpanded && (
// <TableRow>
// <TableCell colSpan={2} className="p-0">
// <Table className="m-0 w-full border-t">
// <TableHeader>
// <TableRow>
// <TableHead>Aspect Type</TableHead>
// <TableHead>Value</TableHead>
// <TableHead>Quantity</TableHead>
// <TableHead>Install Date</TableHead>
// <TableHead>Renewal Year</TableHead>
// <TableHead>Comments</TableHead>
// </TableRow>
// </TableHeader>
// <TableBody>
// {group.elements.flatMap(el =>
// el.aspects
// .sort((a, b) => {
// const labelA = ASPECT_TYPE_LABELS[a.aspectType] ?? a.aspectType;
// const labelB = ASPECT_TYPE_LABELS[b.aspectType] ?? b.aspectType;
// return labelA.localeCompare(labelB);
// })
// .map(aspect => (
// <TableRow key={aspect.id}>
// <TableCell>{ASPECT_TYPE_LABELS[aspect.aspectType] ?? aspect.aspectType}</TableCell>
// <TableCell>{aspect.value ?? "-"}</TableCell>
// <TableCell>{aspect.quantity ?? "-"}</TableCell>
// <TableCell>{aspect.installDate ?? "-"}</TableCell>
// <TableCell>{aspect.renewalYear ?? "-"}</TableCell>
// <TableCell>{aspect.comments ?? "-"}</TableCell>
// </TableRow>
// ))
// )}
// </TableBody>
// </Table>
// </TableCell>
// </TableRow>
// )}
// </React.Fragment>
// );
// })}
// </TableBody>
// </Table>
// </div>
// {/* Floating Dropdown */}
// {showDropdown && (
// <div
// className="absolute z-20 max-h-64 w-72 overflow-y-auto rounded border bg-white p-2 shadow"
// style={{ top: dropdownPos.top, left: dropdownPos.left }}
// >
// <div className="mb-2 flex gap-2">
// <button onClick={selectAll} className="btn btn-xs">
// Select All
// </button>
// <button onClick={deselectAll} className="btn btn-xs">
// Deselect All
// </button>
// </div>
// {uniqueElementTypes
// .sort((a, b) => {
// const labelA = ELEMENT_TYPE_LABELS[a] ?? a.replaceAll("_", " ");
// const labelB = ELEMENT_TYPE_LABELS[b] ?? b.replaceAll("_", " ");
// return labelA.localeCompare(labelB);
// })
// .map(type => (
// <label key={type} className="block cursor-pointer">
// <input
// type="checkbox"
// checked={selectedTypes.has(type)}
// onChange={() => toggleTypeFilter(type)}
// className="mr-2"
// />
// {ELEMENT_TYPE_LABELS[type] ?? type.replaceAll("_", " ")}
// </label>
// ))}
// <button
// onClick={() => setShowDropdown(false)}
// className="mt-2 btn btn-xs"
// >
// Close
// </button>
// </div>
// )}
// </div>
// );
// }
"use client";
import React, { useState, useEffect } from "react";
@ -238,18 +23,12 @@ type Props = {
};
export default function ElementTable({ groupedElements }: Props) {
// -----------------------
// Hooks
// -----------------------
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
const [filterText, setFilterText] = useState("");
const [isMounted, setIsMounted] = useState(false);
useEffect(() => setIsMounted(true), []);
// -----------------------
// Columns
// -----------------------
const columns: ColumnDef<ElementGroup, any>[] = [
{
accessorKey: "elementType",
@ -270,9 +49,6 @@ export default function ElementTable({ groupedElements }: Props) {
},
];
// -----------------------
// Table instance
// -----------------------
const table = useReactTable({
data: groupedElements,
columns,
@ -298,9 +74,6 @@ export default function ElementTable({ groupedElements }: Props) {
});
};
// -----------------------
// Prevent SSR hydration errors
// -----------------------
if (!isMounted) return null;
return (
@ -348,7 +121,6 @@ export default function ElementTable({ groupedElements }: Props) {
))}
</TableRow>
{/* Child row / expanded table */}
{isExpanded && (
<TableRow>
<TableCell colSpan={row.getVisibleCells().length} className="p-0">
@ -365,9 +137,6 @@ export default function ElementTable({ groupedElements }: Props) {
);
}
// -----------------------
// Child table (ShadCN table)
// -----------------------
function ChildTable({ elements }: { elements: Element[] }) {
return (
<Table className="m-0 w-full border-t">

View file

@ -2,71 +2,89 @@
import React from "react";
import { ScatterChart } from "@tremor/react";
// import {
// ScatterChart,
// Scatter,
// XAxis,
// YAxis,
// CartesianGrid,
// Tooltip,
// ResponsiveContainer,
// ReferenceLine,
// Label,
// } from "recharts";
import { Renewal } from "../viewModels/renewal";
import { Tooltip } from "@/app/shadcn_components/ui/tooltip";
import { ELEMENT_TYPE_LABELS } from "../constants";
type RenewalsTimelineProps = {
renewals: Renewal[];
};
export default function RenewalsTimeline({ renewals }: RenewalsTimelineProps) {
const [showFuture, setShowFuture] = React.useState(false);
const today = new Date().getFullYear();
const renewalYears = renewals.map(r => r.renewalYear);
const elementIds = renewals.map(r => parseInt(r.elementId));
const minXValue = Math.min(...renewalYears);
const maxXValue = Math.max(...renewalYears);
const minYValue = Math.min(...elementIds);
const maxYValue = Math.max(...elementIds);
const colouredData = renewals.map(r => ({
...r,
colourCategory:
r.renewalYear < today
? "past"
: r.renewalYear <= today + 3
? "soon"
: "future",
? "past"
: r.renewalYear <= today + 3
? "soon"
: "future",
label: ELEMENT_TYPE_LABELS[r.elementType] ?? r.elementType.replaceAll("_", " "),
}));
const orderedData = colouredData.sort((a, b) => {
const filteredData = showFuture
? colouredData
: colouredData.filter(r => r.colourCategory !== "future");
const orderedData = filteredData.sort((a, b) => {
const order: Record<'past' | 'soon' | 'future', number> = { past: 0, soon: 1, future: 2 };
return order[a.colourCategory as 'past' | 'soon' | 'future'] - order[b.colourCategory as 'past' | 'soon' | 'future'];
});
const filteredRenewalYears = filteredData.map(r => r.renewalYear);
const filteredElementIds = filteredData.map(r => parseInt(r.elementId));
const todayPosition =
((today - (minXValue - 1)) / ((maxXValue + 1) - (minXValue - 1))) * 100;
const minXValue = Math.min(...filteredRenewalYears);
const maxXValue = Math.max(...filteredRenewalYears);
const minYValue = Math.min(...filteredElementIds);
const maxYValue = Math.max(...filteredElementIds);
const customTooltip = ({ payload, active }: any) => {
if (!active || !payload?.length) return null;
const data = payload[0].payload;
return (
<div className="bg-white p-3 border rounded shadow-lg z-50 pointer-events-none">
<div className="text-sm text-gray-500">{data.renewalYear}</div>
<div className="text-base font-semibold">{data.label}</div>
</div>
);
};
return (
<ScatterChart
className="h-80"
data={orderedData}
x="renewalYear"
y="elementId"
category="colorCategory"
colors={['red', 'orange', 'green']}
showLegend={false}
xAxisLabel="Year"
yAxisLabel="" // removes axis title
showXAxis={true}
showYAxis={false}
minXValue={minXValue - 1}
maxXValue={maxXValue + 1}
minYValue={minYValue - 1}
maxYValue={maxYValue + 1}
showGridLines={false}
/>
<div>
<label className="flex items-center gap-2 text-sm mb-2">
<input
type="checkbox"
checked={showFuture}
onChange={(e) => setShowFuture(e.target.checked)}
/>
Show renewals not due in the next 3 years
</label>
<ScatterChart
className="h-80 [&_.recharts-yAxis_.recharts-cartesian-axis-tick_text]:hidden"
data={orderedData}
x="renewalYear"
y="elementId"
category="colourCategory"
colors={['red', 'orange', 'green']}
showLegend={false}
xAxisLabel="Year"
yAxisLabel="" // removes axis title
showXAxis={true}
showYAxis={false}
minXValue={minXValue - 1}
maxXValue={maxXValue + 1}
minYValue={minYValue - 1}
maxYValue={maxYValue + 1}
showGridLines={false}
customTooltip={customTooltip}
/>
</div>
);
};