table styling using ShadCN

This commit is contained in:
Daniel Roth 2026-02-02 13:25:45 +00:00
parent fe1232e63f
commit d352742655
2 changed files with 278 additions and 274 deletions

View file

@ -1,138 +1,16 @@
"use client";
import { m } from "framer-motion";
import React, { useState } from "react";
import React, { useRef, useState } from "react";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/app/shadcn_components/ui/table";
import { ASPECT_TYPE_LABELS, ELEMENT_TYPE_LABELS } from "../constants";
export const ELEMENT_TYPE_LABELS: Record<string, string> = {
access_ramp: "Access Ramp",
accessible_housing_register: "Accessible Housing Register",
additional_wc_or_whb: "Additional WC or WHB",
asbestos: "Asbestos",
bathroom: "Bathroom",
bathroom_remaining_life_source: "Bathroom Remaining Life Source",
boiler_fuel: "Boiler Fuel",
central_heating: "Central Heating",
chimney: "Chimney",
community_heating: "Community Heating",
downpipes: "Downpipes",
drop_kerb: "Drop Kerb",
electrics: "Electrics",
external_brickwork_pointing: "External Brickwork Pointing",
external_decoration: "External Decoration",
external_door: "External Door",
external_noise_insulation: "External Noise Insulation",
external_structural_defects: "External Structural Defects",
external_wall: "External Wall",
external_windows: "External Windows",
fascia_soffit_bargeboards: "Fascia Soffit Bargeboards",
ffhh_damp: "FFHH Damp",
ffhh_drainage_lavatories: "FFHH Drainage Lavatories",
ffhh_food_prep_and_washup: "FFHH Food Prep and Washup",
ffhh_hold_and_cold_water: "FFHH Hold and Cold Water",
ffhh_natural_light: "FFHH Natural Light",
ffhh_neglected: "FFHH Neglected",
ffhh_unsafe_layout: "FFHH Unsafe Layout",
ffhh_unstable_building: "FFHH Unstable Building",
ffhh_ventilation: "FFHH Ventilation",
fire_risk_assessment: "Fire Risk Assessment",
floor_level: "Floor Level",
floor_level_front_door: "Floor Level Front Door",
garage_and_store_door: "Garage and Store Door",
garage_and_store_roof: "Garage and Store Roof",
garage_and_store_windows: "Garage and Store Windows",
garage_door: "Garage Door",
garage_roof: "Garage Roof",
garage_windows: "Garage Windows",
gas_available: "Gas Available",
gutters: "Gutters",
heat_recovery_units: "Heat Recovery Units",
heating_system: "Heating System",
hhsrs_amenities: "HHSRS Amenities",
hhsrs_asbestos_and_mmf: "HHSRS Asbestos and MMF",
hhsrs_biocides: "HHSRS Biocides",
hhsrs_carbon_monoxide: "HHSRS Carbon Monoxide",
hhsrs_collision_and_entrapment: "HHSRS Collision and Entrapment",
hhsrs_crowding_and_space: "HHSRS Crowding and Space",
hhsrs_damp_and_mould: "HHSRS Damp and Mould",
hhsrs_domestic_hygiene_pests_refuse: "HHSRS Domestic Hygiene, Pests & Refuse",
hhsrs_electrical_hazards: "HHSRS Electrical Hazards",
hhsrs_entry_by_intruders: "HHSRS Entry by Intruders",
hhsrs_excess_cold: "HHSRS Excess Cold",
hhsrs_excess_heat: "HHSRS Excess Heat",
hhsrs_explosions: "HHSRS Explosions",
hhsrs_falls_associated_with_baths: "HHSRS Falls Associated with Baths",
hhsrs_falls_between_levels: "HHSRS Falls Between Levels",
hhsrs_falls_on_level_surfaces: "HHSRS Falls on Level Surfaces",
hhsrs_falls_on_stairs: "HHSRS Falls on Stairs",
hhsrs_fire: "HHSRS Fire",
hhsrs_flames_hot_surfaces: "HHSRS Flames / Hot Surfaces",
hhsrs_food_safety: "HHSRS Food Safety",
hhsrs_lead: "HHSRS Lead",
hhsrs_lighting: "HHSRS Lighting",
hhsrs_noise: "HHSRS Noise",
hhsrs_personal_hygiene_sanitation: "HHSRS Personal Hygiene & Sanitation",
hhsrs_radiation: "HHSRS Radiation",
hhsrs_structural_collapse: "HHSRS Structural Collapse",
hhsrs_uncombusted_fuel_gas: "HHSRS Uncombusted Fuel Gas",
hhsrs_volatile_organic_compounds: "HHSRS Volatile Organic Compounds",
hhsrs_water_supply: "HHSRS Water Supply",
internal_downpipes_external_area: "Internal Downpipes External Area",
kitchen: "Kitchen",
kitchen_remaining_life_source: "Kitchen Remaining Life Source",
lintel: "Lintel",
loft_insulation: "Loft Insulation",
outbuilding_overhaul: "Outbuilding Overhaul",
parking_areas: "Parking Areas",
paths_and_hardstandings: "Paths and Hardstandings",
patio_french_door: "Patio French Door",
porch_canopy: "Porch Canopy",
private_balcony: "Private Balcony",
programmable_heating: "Programmable Heating",
property: "Property",
quality_standard: "Quality Standard",
roof: "Roof",
smoke_detection: "Smoke Detection",
steps_to_front_door: "Steps to Front Door",
store_door: "Store Door",
store_roof: "Store Roof",
store_windows: "Store Windows",
tenant_installed_kitchen: "Tenant Installed Kitchen",
water_heating: "Water Heating",
};
export const ASPECT_TYPE_LABELS: Record<string, string> = {
material: "Material",
condition: "Condition",
type: "Type",
area: "Area",
configuration: "Configuration",
presence: "Presence",
risk: "Risk",
severity: "Severity",
location: "Location",
finish: "Finish",
insulation: "Insulation",
pointing: "Pointing",
spalling: "Spalling",
lintels: "Lintels",
cladding: "Cladding",
category: "Category",
quantity: "Quantity",
adequacy: "Adequacy",
rating: "Rating",
strategy: "Strategy",
extent: "Extent",
distribution: "Distribution",
structure: "Structure",
covering: "Covering",
fire_rating: "Fire Rating",
external_decoration: "External Decoration",
work_required: "Work Required",
age_band: "Age Band",
construction_type: "Construction Type",
classification: "Classification",
system: "System",
}
export type AspectCondition = {
id: string;
@ -179,10 +57,12 @@ 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 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);
@ -192,7 +72,6 @@ export default function ElementTable({ groupedElements }: Props) {
};
const selectAll = () => setSelectedTypes(new Set(uniqueElementTypes));
const deselectAll = () => setSelectedTypes(new Set());
const filteredGroups =
@ -211,149 +90,144 @@ export default function ElementTable({ groupedElements }: Props) {
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 (
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={thStyle}>
Element
<div style={{ position: "relative", display: "inline-block", marginLeft: "0.5rem" }}>
<button
onClick={() => setShowDropdown(!showDropdown)}
style={{ cursor: "pointer" }}
>
</button>
{showDropdown && (
<div
style={{
position: "absolute",
top: "100%",
left: 0,
backgroundColor: "#fff",
border: "1px solid #ccc",
padding: "0.5rem",
zIndex: 10,
maxHeight: "250px",
overflowY: "auto",
minWidth: "300px",
}}
<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"
>
<div style={{ marginBottom: "0.5rem" }}>
<button onClick={selectAll} style={{ marginRight: "0.5rem" }}>
Select All
</button>
<button onClick={deselectAll}>Deselect All</button>
</div>
</button>
</TableHead>
<TableHead>No. Aspects</TableHead>
</TableRow>
</TableHeader>
{uniqueElementTypes
.sort((a, b) => {
// Sort alphabetically by human-readable label
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} style={{ display: "block", cursor: "pointer" }}>
<input
type="checkbox"
checked={selectedTypes.has(type)}
onChange={() => toggleTypeFilter(type)}
/>{" "}
{ELEMENT_TYPE_LABELS[type] ?? type.replaceAll("_", " ")}
</label>
))}
<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);
<button
style={{ marginTop: "0.5rem" }}
onClick={() => setShowDropdown(false)}
return (
<React.Fragment key={parentKey}>
{/* Parent Row */}
<TableRow
className="bg-gray-100 font-bold cursor-pointer"
onClick={() => toggleParent(parentKey)}
>
Close
</button>
</div>
)}
<TableCell>
{`${ELEMENT_TYPE_LABELS[group.elementType] ?? group.elementType.replaceAll("_", " ")}${group.elementInstance > 1 ? ` (${group.elementInstance})` : ""}`}
</TableCell>
<TableCell>{totalAspects}</TableCell>
</TableRow>
</div>
{/* 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>
</th>
<th style={thStyle}>No. Aspects</th>
</tr>
</thead>
<tbody>
{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}>
<tr
onClick={() => toggleParent(parentKey)}
style={{
backgroundColor: "#f7f7f7",
cursor: "pointer",
fontWeight: "bold",
}}
>
{/* <td style={tdStyle}>
{group.elementType.replaceAll("_", " ") + (group.elementInstance > 1 ? " (" + group.elementInstance + ")" : "")}
</td> */}
<td style={tdStyle}>
{`${ELEMENT_TYPE_LABELS[group.elementType] ?? group.elementType.replaceAll("_", " ")}${group.elementInstance > 1 ? ` (${group.elementInstance})` : ""}`}
</td>
<td style={tdStyle}>{totalAspects}</td>
</tr>
{isExpanded && (
<tr>
<td colSpan={3} style={{ padding: 0 }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
margin: "0",
}}
>
<thead>
<tr>
<th style={thStyle}>Aspect Type</th>
<th style={thStyle}>Value</th>
<th style={thStyle}>Quantity</th>
<th style={thStyle}>Install Date</th>
<th style={thStyle}>Renewal Year</th>
<th style={thStyle}>Comments</th>
</tr>
</thead>
<tbody>
{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 => (
<tr key={aspect.id}>
<td style={tdStyle}>
{ASPECT_TYPE_LABELS[aspect.aspectType] ?? aspect.aspectType}
</td>
<td style={tdStyle}>{aspect.value ?? "-"}</td>
<td style={tdStyle}>{aspect.quantity ?? "-"}</td>
<td style={tdStyle}>{aspect.installDate ?? "-"}</td>
<td style={tdStyle}>{aspect.renewalYear ?? "-"}</td>
<td style={tdStyle}>{aspect.comments ?? "-"}</td>
</tr>
))
)}
</tbody>
</table>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
{/* 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>
);
}
}

View file

@ -0,0 +1,130 @@
export const ELEMENT_TYPE_LABELS: Record<string, string> = {
access_ramp: "Access Ramp",
accessible_housing_register: "Accessible Housing Register",
additional_wc_or_whb: "Additional WC or WHB",
asbestos: "Asbestos",
bathroom: "Bathroom",
bathroom_remaining_life_source: "Bathroom Remaining Life Source",
boiler_fuel: "Boiler Fuel",
central_heating: "Central Heating",
chimney: "Chimney",
community_heating: "Community Heating",
downpipes: "Downpipes",
drop_kerb: "Drop Kerb",
electrics: "Electrics",
external_brickwork_pointing: "External Brickwork Pointing",
external_decoration: "External Decoration",
external_door: "External Door",
external_noise_insulation: "External Noise Insulation",
external_structural_defects: "External Structural Defects",
external_wall: "External Wall",
external_windows: "External Windows",
fascia_soffit_bargeboards: "Fascia Soffit Bargeboards",
ffhh_damp: "FFHH Damp",
ffhh_drainage_lavatories: "FFHH Drainage Lavatories",
ffhh_food_prep_and_washup: "FFHH Food Prep and Washup",
ffhh_hold_and_cold_water: "FFHH Hold and Cold Water",
ffhh_natural_light: "FFHH Natural Light",
ffhh_neglected: "FFHH Neglected",
ffhh_unsafe_layout: "FFHH Unsafe Layout",
ffhh_unstable_building: "FFHH Unstable Building",
ffhh_ventilation: "FFHH Ventilation",
fire_risk_assessment: "Fire Risk Assessment",
floor_level: "Floor Level",
floor_level_front_door: "Floor Level Front Door",
garage_and_store_door: "Garage and Store Door",
garage_and_store_roof: "Garage and Store Roof",
garage_and_store_windows: "Garage and Store Windows",
garage_door: "Garage Door",
garage_roof: "Garage Roof",
garage_windows: "Garage Windows",
gas_available: "Gas Available",
gutters: "Gutters",
heat_recovery_units: "Heat Recovery Units",
heating_system: "Heating System",
hhsrs_amenities: "HHSRS Amenities",
hhsrs_asbestos_and_mmf: "HHSRS Asbestos and MMF",
hhsrs_biocides: "HHSRS Biocides",
hhsrs_carbon_monoxide: "HHSRS Carbon Monoxide",
hhsrs_collision_and_entrapment: "HHSRS Collision and Entrapment",
hhsrs_crowding_and_space: "HHSRS Crowding and Space",
hhsrs_damp_and_mould: "HHSRS Damp and Mould",
hhsrs_domestic_hygiene_pests_refuse: "HHSRS Domestic Hygiene, Pests & Refuse",
hhsrs_electrical_hazards: "HHSRS Electrical Hazards",
hhsrs_entry_by_intruders: "HHSRS Entry by Intruders",
hhsrs_excess_cold: "HHSRS Excess Cold",
hhsrs_excess_heat: "HHSRS Excess Heat",
hhsrs_explosions: "HHSRS Explosions",
hhsrs_falls_associated_with_baths: "HHSRS Falls Associated with Baths",
hhsrs_falls_between_levels: "HHSRS Falls Between Levels",
hhsrs_falls_on_level_surfaces: "HHSRS Falls on Level Surfaces",
hhsrs_falls_on_stairs: "HHSRS Falls on Stairs",
hhsrs_fire: "HHSRS Fire",
hhsrs_flames_hot_surfaces: "HHSRS Flames / Hot Surfaces",
hhsrs_food_safety: "HHSRS Food Safety",
hhsrs_lead: "HHSRS Lead",
hhsrs_lighting: "HHSRS Lighting",
hhsrs_noise: "HHSRS Noise",
hhsrs_personal_hygiene_sanitation: "HHSRS Personal Hygiene & Sanitation",
hhsrs_radiation: "HHSRS Radiation",
hhsrs_structural_collapse: "HHSRS Structural Collapse",
hhsrs_uncombusted_fuel_gas: "HHSRS Uncombusted Fuel Gas",
hhsrs_volatile_organic_compounds: "HHSRS Volatile Organic Compounds",
hhsrs_water_supply: "HHSRS Water Supply",
internal_downpipes_external_area: "Internal Downpipes External Area",
kitchen: "Kitchen",
kitchen_remaining_life_source: "Kitchen Remaining Life Source",
lintel: "Lintel",
loft_insulation: "Loft Insulation",
outbuilding_overhaul: "Outbuilding Overhaul",
parking_areas: "Parking Areas",
paths_and_hardstandings: "Paths and Hardstandings",
patio_french_door: "Patio French Door",
porch_canopy: "Porch Canopy",
private_balcony: "Private Balcony",
programmable_heating: "Programmable Heating",
property: "Property",
quality_standard: "Quality Standard",
roof: "Roof",
smoke_detection: "Smoke Detection",
steps_to_front_door: "Steps to Front Door",
store_door: "Store Door",
store_roof: "Store Roof",
store_windows: "Store Windows",
tenant_installed_kitchen: "Tenant Installed Kitchen",
water_heating: "Water Heating",
};
export const ASPECT_TYPE_LABELS: Record<string, string> = {
material: "Material",
condition: "Condition",
type: "Type",
area: "Area",
configuration: "Configuration",
presence: "Presence",
risk: "Risk",
severity: "Severity",
location: "Location",
finish: "Finish",
insulation: "Insulation",
pointing: "Pointing",
spalling: "Spalling",
lintels: "Lintels",
cladding: "Cladding",
category: "Category",
quantity: "Quantity",
adequacy: "Adequacy",
rating: "Rating",
strategy: "Strategy",
extent: "Extent",
distribution: "Distribution",
structure: "Structure",
covering: "Covering",
fire_rating: "Fire Rating",
external_decoration: "External Decoration",
work_required: "Work Required",
age_band: "Age Band",
construction_type: "Construction Type",
classification: "Classification",
system: "System",
}