Skip to content

Commit 852c1e8

Browse files
committed
feat: display kpis grouped by categories
1 parent 940094b commit 852c1e8

9 files changed

Lines changed: 280 additions & 52 deletions

File tree

src/components/react/LivingLabKPIs.tsx

Lines changed: 91 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,22 @@ import type { IKpi, IIKpiResultBeforeAfter } from "../../types";
1111
import { BeforeAndAfterDates, LivingLabKpiResultsForm } from "./form";
1212
import { KpiTypeBadge } from "./KpiTypeBadge";
1313
import { KpiMetricTypeBadge } from "./KpiMetricTypeBadge";
14-
import { Badge } from "./ui";
14+
import { Badge, ExpansionPanel } from "./ui";
15+
import type { ICategory } from "../../types/Category";
16+
import { tr } from "motion/react-client";
1517

1618
type Props = {
1719
kpis: IKpi[];
1820
livingLabId: number;
1921
kpiResults: IIKpiResultBeforeAfter[];
22+
categories: ICategory[];
2023
};
2124

2225
export function LivingLabKPIs({
2326
kpis = [],
2427
livingLabId,
2528
kpiResults: livingLabKpis = [],
29+
categories = [],
2630
}: Props) {
2731
if (!kpis || kpis.length === 0) {
2832
return <div>No KPIs available.</div>;
@@ -34,63 +38,99 @@ export function LivingLabKPIs({
3438
const [beforeDate, setBeforeDate] = useState<string>(today);
3539
const [afterDate, setAfterDate] = useState<string>(today);
3640

41+
const getKpiRow = (kpiId: number) => {
42+
let kpi = kpis.find((k) => k.id === kpiId);
43+
const hasChildren = kpis.some((k) => k.parent_kpi_id === kpiId);
44+
const idChild = kpi?.parent_kpi_id ? true : false;
45+
46+
if (!kpi) return null;
47+
return (
48+
<TableRow
49+
key={kpiId}
50+
className={hasChildren || !idChild ? "border-t-2 border-info/30" : ""}
51+
>
52+
<TableCell className="flex flex-col w-22">
53+
{kpi.kpi_number}
54+
<KpiTypeBadge type={kpi.type} />
55+
</TableCell>
56+
<TableCell className="whitespace-pre-line break-words">
57+
{kpi.name}
58+
<Badge tooltip={kpi.description} size="sm" color="info" />
59+
</TableCell>
60+
<TableCell>
61+
{!hasChildren && (
62+
<div className="flex flex-col text-xs">
63+
<KpiMetricTypeBadge type={kpi.metric} />
64+
{kpi.metric_description?.length &&
65+
kpi.metric_description?.length > 0 && (
66+
<strong>{kpi.metric_description}</strong>
67+
)}
68+
{typeof kpi.min_value === "number" && (
69+
<span>Min: {kpi.min_value} </span>
70+
)}
71+
{typeof kpi.max_value === "number" && (
72+
<span>Max: {kpi.max_value}</span>
73+
)}
74+
</div>
75+
)}
76+
</TableCell>
77+
<TableCell className="w-20">
78+
{!hasChildren && (
79+
<LivingLabKpiResultsForm
80+
livingLabId={livingLabId}
81+
kpiId={kpi.id}
82+
kpiMetric={kpi.metric}
83+
initialBefore={livingLabKpiMap.get(kpi.id)?.result_before}
84+
initialAfter={livingLabKpiMap.get(kpi.id)?.result_after}
85+
defaultBeforeDate={beforeDate}
86+
defaultAfterDate={afterDate}
87+
/>
88+
)}
89+
</TableCell>
90+
</TableRow>
91+
);
92+
};
93+
3794
return (
3895
<div className="flex flex-col gap-8 mx-auto">
3996
<BeforeAndAfterDates
4097
onChangeBeforeDate={setBeforeDate}
4198
onChangeAfterDate={setAfterDate}
4299
/>
43-
<Table dense striped className="lg:min-w-3xl max-w-5xl">
44-
<TableHead>
45-
<TableRow>
46-
<TableHeader>KPI Number</TableHeader>
47-
<TableHeader>Name</TableHeader>
48-
<TableHeader>Metric unit</TableHeader>
49-
<TableHeader>Value Before vs After</TableHeader>
50-
</TableRow>
51-
</TableHead>
100+
{categories.map(({ id, name, kpis }, index) => (
101+
<ExpansionPanel
102+
key={id}
103+
header={
104+
<div className="flex flex-row justify-center items-center gap-2 rounded-2xl border-info bg-info px-2 py-1">
105+
<h5>{name}</h5>
106+
<Badge
107+
color="light"
108+
size="sm"
109+
tooltip="Number of KPIs in this category"
110+
displayTooltipIcon={false}
111+
>
112+
{kpis?.length || 0} KPIs
113+
</Badge>
114+
</div>
115+
}
116+
arrow
117+
open={index === 0}
118+
content={
119+
<Table dense className="lg:min-w-3xl max-w-5xl">
120+
<TableHead>
121+
<TableRow>
122+
<TableHeader>KPI Number</TableHeader>
123+
<TableHeader>Name</TableHeader>
124+
<TableHeader>Metric unit</TableHeader>
125+
<TableHeader>Value Before vs After</TableHeader>
126+
</TableRow>
127+
</TableHead>
52128

53-
<TableBody>
54-
{kpis.map((kpi, idx) => (
55-
<TableRow key={kpi.kpi_number ?? idx}>
56-
<TableCell className="flex flex-col w-22">
57-
{kpi.kpi_number}
58-
<KpiTypeBadge type={kpi.type} />
59-
</TableCell>
60-
<TableCell className="whitespace-pre-line break-words">
61-
{kpi.name}
62-
<Badge tooltip={kpi.description} size="sm" color="info" />
63-
</TableCell>
64-
<TableCell>
65-
<div className="flex flex-col text-xs">
66-
<KpiMetricTypeBadge type={kpi.metric} />
67-
{kpi.metric_description?.length &&
68-
kpi.metric_description?.length > 0 && (
69-
<strong>{kpi.metric_description}</strong>
70-
)}
71-
{typeof kpi.min_value === "number" && (
72-
<span>Min: {kpi.min_value} </span>
73-
)}
74-
{typeof kpi.max_value === "number" && (
75-
<span>Max: {kpi.max_value}</span>
76-
)}
77-
</div>
78-
</TableCell>
79-
<TableCell className="w-20">
80-
<LivingLabKpiResultsForm
81-
livingLabId={livingLabId}
82-
kpiId={kpi.id}
83-
kpiMetric={kpi.metric}
84-
initialBefore={livingLabKpiMap.get(kpi.id)?.result_before}
85-
initialAfter={livingLabKpiMap.get(kpi.id)?.result_after}
86-
defaultBeforeDate={beforeDate}
87-
defaultAfterDate={afterDate}
88-
/>
89-
</TableCell>
90-
</TableRow>
91-
))}
92-
</TableBody>
93-
</Table>
129+
<TableBody>{kpis?.map(({ id }) => getKpiRow(id))}</TableBody>
130+
</Table>
131+
}
132+
/>
133+
))}
94134
</div>
95135
);
96136
}

src/components/react/ui/Badge.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface BadgeProps {
2121
role?: string;
2222
"aria-label"?: string;
2323
tooltip?: string;
24+
displayTooltipIcon?: boolean;
2425
}
2526

2627
const COLOR_CLASSES: Record<BadgeColor, string> = {
@@ -54,6 +55,7 @@ export function Badge({
5455
className = "",
5556
role,
5657
tooltip,
58+
displayTooltipIcon = true,
5759
"aria-label": ariaLabel,
5860
}: BadgeProps) {
5961
const colorClass = COLOR_CLASSES[color] ?? COLOR_CLASSES.secondary;
@@ -76,7 +78,7 @@ export function Badge({
7678
</span>
7779
) : null}
7880
{children}
79-
{!!tooltip?.length ? (
81+
{displayTooltipIcon && !!tooltip?.length ? (
8082
<QuestionMarkCircleIcon className={`h-4 w-4 text-warning`} />
8183
) : null}
8284
</span>
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React, { useState } from "react";
2+
3+
interface ExpansionPanelProps {
4+
header: React.ReactNode;
5+
content: React.ReactNode;
6+
arrow?: boolean;
7+
open?: boolean;
8+
}
9+
10+
export const ExpansionPanel: React.FC<ExpansionPanelProps> = ({
11+
header,
12+
content,
13+
arrow = false,
14+
open = false,
15+
}) => {
16+
const [isOpen, setIsOpen] = useState(open);
17+
18+
const handleToggle = () => {
19+
setIsOpen((prev) => !prev);
20+
};
21+
22+
return (
23+
<div className="mb-2 w-full flex flex-col min-w-0">
24+
<div
25+
className="flex items-center cursor-pointer px-4 py-3 select-none w-full"
26+
onClick={handleToggle}
27+
>
28+
<div className="flex-1">{header}</div>
29+
{arrow && (
30+
<span className="ml-2 transition-transform duration-200">
31+
{isOpen ? (
32+
<svg width="24" height="24" viewBox="0 0 24 24">
33+
<polyline
34+
points="6 15 12 9 18 15"
35+
fill="none"
36+
stroke="currentColor"
37+
strokeWidth="2"
38+
/>
39+
</svg>
40+
) : (
41+
<svg width="24" height="24" viewBox="0 0 24 24">
42+
<polyline
43+
points="6 9 12 15 18 9"
44+
fill="none"
45+
stroke="currentColor"
46+
strokeWidth="2"
47+
/>
48+
</svg>
49+
)}
50+
</span>
51+
)}
52+
</div>
53+
{isOpen && (
54+
<div className="p-4 border-t border-gray-200 w-full">{content}</div>
55+
)}
56+
</div>
57+
);
58+
};

src/components/react/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export * from "./ItemCard";
1010
export * from "./Timeline";
1111
export * from "./SidebarMenu";
1212
export * from "./SiteNavBar";
13+
export * from "./ExpansionPanel";

src/lib/api-client/ApiClient.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import livinglabs from "./mock-data/living_labs_data.json";
22
import kpis from "./mock-data/kpis.json";
3+
import categories from "./mock-data/categories.json";
34
import measures from "./mock-data/measures.json";
45
import transportModes from "./mock-data/transport_modes.json";
56
import type {
@@ -11,6 +12,7 @@ import type {
1112
ILivingLabTransportMode,
1213
ILivingLab,
1314
} from "../../types";
15+
import type { ICategory } from "../../types/Category";
1416

1517
export default class ApiClient {
1618
private baseUrl: string;
@@ -152,6 +154,14 @@ export default class ApiClient {
152154
});
153155
}
154156

157+
async getCategories(
158+
type: "KPI_SIEF" | "ITEM" | "KPI_IMPACT"
159+
): Promise<ICategory[]> {
160+
//return this.get(`/categories?type=${encodeURIComponent(type)}`);
161+
162+
return categories.filter((cat) => cat.type === type) as ICategory[];
163+
}
164+
155165
async getTransportModes(): Promise<ITransportMode[]> {
156166
//return this.get(`/transport_modes`);
157167

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
[
2+
{
3+
"id": 1,
4+
"name": "Policies",
5+
"type": "KPI_SIEF",
6+
"kpis": [
7+
{ "id": 1 },
8+
{ "id": 2 },
9+
{ "id": 3 },
10+
{ "id": 4 },
11+
{ "id": 5 },
12+
{ "id": 6 }
13+
]
14+
},
15+
{
16+
"id": 2,
17+
"name": "Transport System - Time",
18+
"type": "KPI_SIEF",
19+
"kpis": [
20+
{ "id": 12 },
21+
{ "id": 13 },
22+
{ "id": 14 },
23+
{ "id": 15 },
24+
{ "id": 16 },
25+
{ "id": 17 },
26+
{ "id": 18 },
27+
{ "id": 19 },
28+
{ "id": 33 },
29+
{ "id": 34 },
30+
{ "id": 35 },
31+
{ "id": 36 },
32+
{ "id": 37 },
33+
{ "id": 38 },
34+
{ "id": 39 }
35+
]
36+
},
37+
{
38+
"id": 3,
39+
"name": "Transport System - Safety/Comfort",
40+
"type": "KPI_SIEF",
41+
"kpis": [
42+
{ "id": 8 },
43+
{ "id": 9 },
44+
{ "id": 10 },
45+
{ "id": 11 },
46+
{ "id": 40 },
47+
{ "id": 41 },
48+
{ "id": 42 },
49+
{ "id": 43 },
50+
{ "id": 44 },
51+
{ "id": 45 },
52+
{ "id": 46 },
53+
{ "id": 47 }
54+
]
55+
},
56+
{
57+
"id": 4,
58+
"name": "Transport System - Cost",
59+
"type": "KPI_SIEF",
60+
"kpis": [
61+
{ "id": 22 },
62+
{ "id": 23 },
63+
{ "id": 24 },
64+
{ "id": 25 },
65+
{ "id": 26 },
66+
{ "id": 27 },
67+
{ "id": 28 },
68+
{ "id": 29 },
69+
{ "id": 30 },
70+
{ "id": 31 },
71+
{ "id": 32 },
72+
{ "id": 48 }
73+
]
74+
},
75+
{
76+
"id": 5,
77+
"name": "Impact - Environment",
78+
"type": "KPI_SIEF",
79+
"kpis": [{ "id": 49 }, { "id": 60 }, { "id": 61 }, { "id": 62 }]
80+
},
81+
{
82+
"id": 6,
83+
"name": "Impact - Society",
84+
"type": "KPI_SIEF",
85+
"kpis": [{ "id": 63 }, { "id": 64 }, { "id": 65 }, { "id": 66 }]
86+
},
87+
{
88+
"id": 7,
89+
"name": "Impact - Economy",
90+
"type": "KPI_SIEF",
91+
"kpis": [
92+
{ "id": 50 },
93+
{ "id": 67 },
94+
{ "id": 68 },
95+
{ "id": 69 },
96+
{ "id": 70 }
97+
]
98+
}
99+
]

0 commit comments

Comments
 (0)