From b91640d168a182af19937a4c093b03e6070a6cd7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 2 Feb 2026 18:15:31 +0000 Subject: [PATCH] switch timeline to use tremor scatter chart --- .../condition/components/ElementTable.tsx | 562 ++++++++++++------ .../condition/components/RenewalsTimeline.tsx | 151 ++--- 2 files changed, 427 insertions(+), 286 deletions(-) diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/condition/components/ElementTable.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/condition/components/ElementTable.tsx index bfb1db06..c87b96b9 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/condition/components/ElementTable.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/condition/components/ElementTable.tsx @@ -1,17 +1,231 @@ +// "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>(new Set()); +// const [expandedParents, setExpandedParents] = useState>(new Set()); +// const [showDropdown, setShowDropdown] = useState(false); +// const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); +// const buttonRef = useRef(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) => { +// 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 ( +//
+//
+// +// +// +// +// Element +// {/* Filter button stays in header */} +// +// +// No. Aspects +// +// + +// +// {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 ( +// +// {/* Parent Row */} +// toggleParent(parentKey)} +// > +// +// {`${ELEMENT_TYPE_LABELS[group.elementType] ?? group.elementType.replaceAll("_", " ")}${group.elementInstance > 1 ? ` (${group.elementInstance})` : ""}`} +// +// {totalAspects} +// + +// {/* Child Table */} +// {isExpanded && ( +// +// +//
+// +// +// Aspect Type +// Value +// Quantity +// Install Date +// Renewal Year +// Comments +// +// +// +// {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 => ( +// +// {ASPECT_TYPE_LABELS[aspect.aspectType] ?? aspect.aspectType} +// {aspect.value ?? "-"} +// {aspect.quantity ?? "-"} +// {aspect.installDate ?? "-"} +// {aspect.renewalYear ?? "-"} +// {aspect.comments ?? "-"} +// +// )) +// )} +// +//
+// +// +// )} +// +// ); +// })} +// +// +//
+ +// {/* Floating Dropdown */} +// {showDropdown && ( +//
+//
+// +// +//
+// {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 => ( +// +// ))} +// +//
+// )} +//
+// ); +// } + "use client"; -import React, { useRef, useState } from "react"; +import React, { useState, useEffect } from "react"; import { - Table, - TableHeader, - TableBody, - TableRow, - TableHead, - TableCell, -} from "@/app/shadcn_components/ui/table"; + useReactTable, + getCoreRowModel, + getFilteredRowModel, + ColumnDef, + flexRender, +} from "@tanstack/react-table"; +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"; - +import { ELEMENT_TYPE_LABELS, ASPECT_TYPE_LABELS } from "../constants"; export type ElementGroup = { elementType: string; @@ -19,196 +233,168 @@ export type ElementGroup = { 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>(new Set()); - const [expandedParents, setExpandedParents] = useState>(new Set()); - const [showDropdown, setShowDropdown] = useState(false); - const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); - const buttonRef = useRef(null); + // ----------------------- + // Hooks + // ----------------------- + const [expandedRows, setExpandedRows] = useState>(new Set()); + const [filterText, setFilterText] = useState(""); + const [isMounted, setIsMounted] = useState(false); - const uniqueElementTypes = Array.from(new Set(groupedElements.map(g => g.elementType))).sort((a, b) => - a.localeCompare(b) - ); + useEffect(() => setIsMounted(true), []); - const toggleTypeFilter = (type: string) => { - const newSet = new Set(selectedTypes); - if (newSet.has(type)) newSet.delete(type); - else newSet.add(type); - setSelectedTypes(newSet); + // ----------------------- + // Columns + // ----------------------- + const columns: ColumnDef[] = [ + { + accessorKey: "elementType", + header: "Element", + cell: info => { + const value = info.getValue() as string; + const label = ELEMENT_TYPE_LABELS[value] ?? value.replaceAll("_", " "); + const instance = info.row.original.elementInstance; + return instance > 1 ? `${label} (${instance})` : label; + }, + }, + { + accessorFn: row => row.elements.reduce((acc, el) => acc + el.aspects.length, 0), + id: "noAspects", + header: "No. Aspects", + cell: info => info.getValue(), + enableColumnFilter: false, // disable column filtering + }, + ]; + + // ----------------------- + // Table instance + // ----------------------- + const table = useReactTable({ + data: groupedElements, + columns, + state: { globalFilter: filterText }, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getRowId: row => `${row.elementType}__${row.elementInstance}`, + globalFilterFn: (row, _columnId, filterValue) => { + // filter only elementType + return row.original.elementType + .toLowerCase() + .includes((filterValue as string).toLowerCase()); + }, + onGlobalFilterChange: setFilterText, + }); + + const toggleRow = (rowId: string) => { + setExpandedRows(prev => { + const newSet = new Set(prev); + if (newSet.has(rowId)) newSet.delete(rowId); + else newSet.add(rowId); + return 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) => { - 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); - }; + // ----------------------- + // Prevent SSR hydration errors + // ----------------------- + if (!isMounted) return null; return ( -
-
- - - - - Element - {/* Filter button stays in header */} - - - No. Aspects - - - - - {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 ( - - {/* Parent Row */} - toggleParent(parentKey)} - > - - {`${ELEMENT_TYPE_LABELS[group.elementType] ?? group.elementType.replaceAll("_", " ")}${group.elementInstance > 1 ? ` (${group.elementInstance})` : ""}`} - - {totalAspects} - - - {/* Child Table */} - {isExpanded && ( - - -
- - - Aspect Type - Value - Quantity - Install Date - Renewal Year - Comments - - - - {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 => ( - - {ASPECT_TYPE_LABELS[aspect.aspectType] ?? aspect.aspectType} - {aspect.value ?? "-"} - {aspect.quantity ?? "-"} - {aspect.installDate ?? "-"} - {aspect.renewalYear ?? "-"} - {aspect.comments ?? "-"} - - )) - )} - -
- - - )} - - ); - })} - - +
+ {/* Filter input above the table */} +
+ setFilterText(e.target.value)} + className="border rounded p-2 max-w-sm" + />
- {/* Floating Dropdown */} - {showDropdown && ( -
-
- - -
- {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 => ( - - ))} - -
- )} + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + {flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + + {table.getRowModel().rows.map(row => { + const rowId = row.id; + const isExpanded = expandedRows.has(rowId); + + return ( + + {/* Parent row */} + toggleRow(rowId)} + > + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + + {/* Child row / expanded table */} + {isExpanded && ( + + + + + + )} + + ); + })} + +
); -} \ No newline at end of file +} + +// ----------------------- +// Child table (ShadCN table) +// ----------------------- +function ChildTable({ elements }: { elements: Element[] }) { + return ( + + + + Aspect Type + Value + Quantity + Install Date + Renewal Year + Comments + + + + {elements.flatMap(el => + el.aspects.map(aspect => ( + + {ASPECT_TYPE_LABELS[aspect.aspectType] ?? aspect.aspectType} + {aspect.value ?? "-"} + {aspect.quantity ?? "-"} + {aspect.installDate ?? "-"} + {aspect.renewalYear ?? "-"} + {aspect.comments ?? "-"} + + )) + )} + +
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/condition/components/RenewalsTimeline.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/condition/components/RenewalsTimeline.tsx index 9a9e1c28..a11ce20a 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/condition/components/RenewalsTimeline.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/condition/components/RenewalsTimeline.tsx @@ -1,18 +1,20 @@ "use client"; import React from "react"; -import { - ScatterChart, - Scatter, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - ReferenceLine, - Label, -} from "recharts"; +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"; type RenewalsTimelineProps = { renewals: Renewal[]; @@ -21,97 +23,50 @@ type RenewalsTimelineProps = { export default function RenewalsTimeline({ renewals }: RenewalsTimelineProps) { const today = new Date().getFullYear(); - const elementTypes = Array.from( - new Set(renewals.map(r => (r.elementType ?? "").trim())) - ); - const elementTypeToY = Object.fromEntries(elementTypes.map((type, i) => [type, i])); + const renewalYears = renewals.map(r => r.renewalYear); + const elementIds = renewals.map(r => parseInt(r.elementId)); - const data = renewals.map(r => { - let color = "#10b981"; - if (r.renewalYear < today) color = "#ef4444"; - else if (r.renewalYear - today <= 3) color = "#f59e0b"; + const minXValue = Math.min(...renewalYears); + const maxXValue = Math.max(...renewalYears); + const minYValue = Math.min(...elementIds); + const maxYValue = Math.max(...elementIds); - return { - id: r.id, - elementType: r.elementType, - x: r.renewalYear, - y: elementTypeToY[r.elementType ?? ""], - comments: r.comments ?? "", - color, - }; + const colouredData = renewals.map(r => ({ + ...r, + colourCategory: + r.renewalYear < today + ? "past" + : r.renewalYear <= today + 3 + ? "soon" + : "future", + })); + + const orderedData = colouredData.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']; }); - // 3️⃣ Click handler - const handleClick = (payload: any) => { - if (!payload) return; - alert(`Element Type: ${payload.elementType}\nYear: ${payload.x}\nComments: ${payload.comments}`); - }; + const todayPosition = + ((today - (minXValue - 1)) / ((maxXValue + 1) - (minXValue - 1))) * 100; return ( -
- - - - tick.toString()} - /> - i)} - tickFormatter={index => elementTypes[index]} - interval={0} // force all element labels to render - /> - { - if (active && payload && payload.length) { - const data = payload[0].payload; - return ( -
-
{data.elementType}
-
Renewal year: {data.x}
-
- ); - } - return null; - }} - /> - - - - { - const { cx, cy, payload } = props; - return ( - handleClick(payload)} - style={{ cursor: "pointer" }} - /> - ); - }} - /> -
-
-
+ ); -} +}; \ No newline at end of file