Skip to content

Commit 0515418

Browse files
committed
feat: add initial version of organisation inventory
1 parent cf0be89 commit 0515418

File tree

6 files changed

+269
-49
lines changed

6 files changed

+269
-49
lines changed
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { useGetUserInventory } from "@squonk/data-manager-client/inventory";
2+
3+
import dayjs from "dayjs";
4+
import utc from "dayjs/plugin/utc";
5+
6+
dayjs.extend(utc);
7+
8+
import { type UserDetail } from "@squonk/account-server-client";
9+
import { useGetOrganisation } from "@squonk/account-server-client/organisation";
10+
import { useGetUnits } from "@squonk/account-server-client/unit";
11+
import { useGetOrganisationUsers } from "@squonk/account-server-client/user";
12+
import { type InventoryUserDetail } from "@squonk/data-manager-client";
13+
14+
import { Alert, Container, Typography } from "@mui/material";
15+
import { createColumnHelper } from "@tanstack/react-table";
16+
17+
import { CenterLoader } from "../CenterLoader";
18+
import { DataTable } from "../DataTable";
19+
import { NextLink } from "../NextLink";
20+
import { getSharedColumns, type UserActivity } from "./sharedColumns";
21+
22+
type Unit = {
23+
id: string;
24+
name?: string;
25+
};
26+
27+
type InventoryWithUnit = UserActivity & {
28+
username: InventoryUserDetail["username"];
29+
units: Unit[];
30+
};
31+
32+
const columnHelper = createColumnHelper<InventoryWithUnit>();
33+
const sharedColumns = getSharedColumns(columnHelper);
34+
35+
export interface OrganisationUserUsageProps {
36+
organisationId: string;
37+
}
38+
39+
export const OrganisationUserUsage = ({ organisationId }: OrganisationUserUsageProps) => {
40+
const { data: organisation } = useGetOrganisation(organisationId);
41+
const { data: units } = useGetUnits({
42+
query: { select: (data) => data.units.flatMap((org) => org.units) },
43+
});
44+
const { data, error: inventoryError } = useGetUserInventory<InventoryWithUnit[]>(
45+
{ org_id: organisationId },
46+
{
47+
query: {
48+
select: (data) => {
49+
return data.users.map(({ projects, activity, first_seen, last_seen_date, username }) => ({
50+
username,
51+
activity,
52+
first_seen,
53+
last_seen_date,
54+
units: Object.values(projects)
55+
.flat()
56+
.map(({ unit_id }) => ({
57+
id: unit_id,
58+
name: units?.find((unit) => unit.id === unit_id)?.name,
59+
}))
60+
.reduce<Unit[]>((uniqueUnits, unit) => {
61+
// keep only unique units
62+
const existingUnit = uniqueUnits.find((u) => u.id === unit.id);
63+
if (!existingUnit) {
64+
uniqueUnits.push(unit);
65+
}
66+
return uniqueUnits;
67+
}, []),
68+
}));
69+
},
70+
},
71+
},
72+
);
73+
const { data: organisationMembers } = useGetOrganisationUsers(organisationId, {
74+
query: {
75+
enabled: organisation?.caller_is_member === undefined || organisation.caller_is_member,
76+
},
77+
});
78+
79+
const columns = [
80+
columnHelper.accessor("username", { header: "User" }),
81+
columnHelper.accessor("units", {
82+
header: "Units",
83+
cell: ({ getValue }) => {
84+
// keep only the unique units
85+
const units = getValue();
86+
87+
return (
88+
<ul>
89+
{units.map((unit) => (
90+
<li key={unit.id}>
91+
<NextLink
92+
component="a"
93+
href={{ pathname: "/unit/[unitId]/inventory", query: { unitId: unit.id } }}
94+
>
95+
{unit.name}
96+
</NextLink>
97+
</li>
98+
))}
99+
</ul>
100+
);
101+
},
102+
}),
103+
...sharedColumns,
104+
];
105+
106+
if (inventoryError) {
107+
return <Alert severity="error">{inventoryError.message}</Alert>;
108+
}
109+
110+
if (data === undefined) {
111+
return <CenterLoader />;
112+
}
113+
114+
return (
115+
<Container maxWidth="xl">
116+
<Typography component="h2" variant="h1">
117+
Organisation Inventory
118+
</Typography>
119+
<Typography variant="h3">
120+
Organisation: <em>{organisation?.name}</em>
121+
</Typography>
122+
123+
<Typography>Owner: {organisation?.owner_id}</Typography>
124+
<OrganisationMembers users={organisationMembers?.users} />
125+
<DataTable columns={columns} data={data} />
126+
</Container>
127+
);
128+
};
129+
130+
const OrganisationMembers = ({ users }: { users: UserDetail[] | undefined }) => {
131+
// loading
132+
if (users === undefined) {
133+
return "Members: ";
134+
}
135+
136+
// empty
137+
if (users.length === 0) {
138+
return (
139+
<Typography>
140+
Members: <em>No members</em>
141+
</Typography>
142+
);
143+
}
144+
145+
return <Typography>Members: {users.map((user) => user.id).join(", ")}</Typography>;
146+
};

src/components/usage/UserUsageTable.tsx

Lines changed: 6 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,17 @@ import {
1919
type UseAutocompleteProps,
2020
} from "@mui/material";
2121
import { createColumnHelper } from "@tanstack/react-table";
22-
import dayjs from "dayjs";
23-
import utc from "dayjs/plugin/utc";
2422

25-
import { DATE_FORMAT, TIME_FORMAT } from "../../constants/datetimes";
2623
import { useEnqueueError } from "../../hooks/useEnqueueStackError";
2724
import { DataTable } from "../DataTable";
25+
import { getSharedColumns } from "./sharedColumns";
2826

29-
dayjs.extend(utc);
30-
31-
type UserEntry = InventoryUserDetail & { isEditor: boolean };
27+
export interface UserEntry extends InventoryUserDetail {
28+
isEditor: boolean;
29+
}
3230

3331
const columnHelper = createColumnHelper<UserEntry>();
32+
const sharedColumns = getSharedColumns(columnHelper);
3433

3534
const getProjectsList = (users: UserEntry[]) =>
3635
users
@@ -65,49 +64,7 @@ export const UserUsageTable = ({ users, toolbarContent, onChange }: UserUsageTab
6564
header: "Unit Editor",
6665
cell: ({ row }) => (row.original.isEditor ? <Done /> : <Close />),
6766
}),
68-
columnHelper.group({
69-
header: "Activity",
70-
columns: [
71-
columnHelper.accessor("first_seen", {
72-
header: "First Seen",
73-
cell: ({ getValue, row }) =>
74-
`${dayjs.utc(getValue()).format(`${DATE_FORMAT} ${TIME_FORMAT}`)} (${
75-
row.original.activity.total_days_since_first_seen
76-
} days ago)`,
77-
sortingFn: (a, b) =>
78-
dayjs.utc(a.original.first_seen).diff(dayjs.utc(b.original.first_seen)),
79-
}),
80-
columnHelper.accessor("activity.total_days_active", {
81-
header: "Total",
82-
cell: ({ getValue }) => `${getValue()} days`,
83-
}),
84-
columnHelper.accessor((user) => user.activity.period_b?.active_days, {
85-
id: "activity_b",
86-
header: "API Used",
87-
cell: ({
88-
row: {
89-
original: { activity },
90-
},
91-
}) =>
92-
`${activity.period_b?.active_days} of last ${activity.period_b?.monitoring_period}`,
93-
}),
94-
columnHelper.accessor((user) => user.activity.period_a.active_days, {
95-
id: "activity_a",
96-
header: "",
97-
cell: ({
98-
row: {
99-
original: { activity },
100-
},
101-
}) => `${activity.period_a.active_days} of last ${activity.period_a.monitoring_period}`,
102-
}),
103-
columnHelper.accessor("last_seen_date", {
104-
header: "Last Seen",
105-
cell: ({ getValue }) => dayjs.utc(getValue()).format(DATE_FORMAT),
106-
sortingFn: (a, b) =>
107-
dayjs.utc(a.original.last_seen_date).diff(dayjs.utc(b.original.last_seen_date)),
108-
}),
109-
],
110-
}),
67+
...sharedColumns,
11168
columnHelper.group({
11269
header: "Datasets",
11370
columns: [
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { type UserActivityDetail } from "@squonk/data-manager-client";
2+
3+
import { type Column, type ColumnHelper } from "@tanstack/react-table";
4+
import dayjs from "dayjs";
5+
import utc from "dayjs/plugin/utc";
6+
7+
import { DATE_FORMAT, TIME_FORMAT } from "../../constants/datetimes";
8+
9+
dayjs.extend(utc);
10+
11+
export interface UserActivity {
12+
activity: UserActivityDetail;
13+
first_seen: string;
14+
last_seen_date: string;
15+
}
16+
17+
export const getSharedColumns = <T extends UserActivity>(columnHelper: ColumnHelper<T>) => {
18+
// This is a workaround to allow the columnHelper to be used as a ColumnHelper<InventoryUserDetail>
19+
// @tanstack/react-table doesn't work well with this generic function
20+
const columnHelperFixed = columnHelper as unknown as ColumnHelper<UserActivity>;
21+
const columns = [
22+
columnHelperFixed.group({
23+
header: "Activity",
24+
columns: [
25+
columnHelperFixed.accessor("first_seen", {
26+
header: "First Seen",
27+
cell: ({ getValue, row }) =>
28+
`${dayjs.utc(getValue()).format(`${DATE_FORMAT} ${TIME_FORMAT}`)} (${
29+
row.original.activity.total_days_since_first_seen
30+
} days ago)`,
31+
sortingFn: (a, b) =>
32+
dayjs.utc(a.original.first_seen).diff(dayjs.utc(b.original.first_seen)),
33+
}),
34+
],
35+
}),
36+
columnHelperFixed.accessor("activity.total_days_active", {
37+
header: "Total",
38+
cell: ({ getValue }) => `${getValue()} days`,
39+
}),
40+
columnHelperFixed.accessor((user) => user.activity.period_b?.active_days, {
41+
id: "activity_b",
42+
header: "API Used",
43+
cell: ({
44+
row: {
45+
original: { activity },
46+
},
47+
}) => `${activity.period_b?.active_days} of last ${activity.period_b?.monitoring_period}`,
48+
}),
49+
columnHelperFixed.accessor((user) => user.activity.period_a.active_days, {
50+
id: "activity_a",
51+
header: "",
52+
cell: ({
53+
row: {
54+
original: { activity },
55+
},
56+
}) => `${activity.period_a.active_days} of last ${activity.period_a.monitoring_period}`,
57+
}),
58+
columnHelperFixed.accessor("last_seen_date", {
59+
header: "Last Seen",
60+
cell: ({ getValue }) => dayjs.utc(getValue()).format(DATE_FORMAT),
61+
sortingFn: (a, b) =>
62+
dayjs.utc(a.original.last_seen_date).diff(dayjs.utc(b.original.last_seen_date)),
63+
}),
64+
];
65+
66+
return columns as Column<T, T[keyof T]>[];
67+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useGetOrganisation } from "@squonk/account-server-client/organisation";
2+
3+
import Head from "next/head";
4+
5+
import { OrganisationUserUsage } from "../../components/usage/OrganisationUserUsage";
6+
7+
export interface OrganisationUsageViewProps {
8+
organisationId: string;
9+
}
10+
11+
export const OrganisationUsageView = ({ organisationId }: OrganisationUsageViewProps) => {
12+
const { data } = useGetOrganisation(organisationId);
13+
return (
14+
<>
15+
<Head>
16+
<title>Squonk | {data?.name} - User Usage</title>
17+
</Head>
18+
<OrganisationUserUsage organisationId={organisationId} />
19+
</>
20+
);
21+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { withPageAuthRequired as withPageAuthRequiredCSR } from "@auth0/nextjs-auth0/client";
2+
import { useRouter } from "next/router";
3+
4+
import { RoleRequired } from "../../../components/auth/RoleRequired";
5+
import { AS_ROLES, DM_ROLES } from "../../../constants/auth";
6+
import { OrganisationUsageView } from "../../../features/usage/OrganisationUsageView";
7+
import Layout from "../../../layouts/Layout";
8+
9+
const UserUsage = () => {
10+
const { query } = useRouter();
11+
const organisationId = query.organisationId;
12+
13+
if (typeof organisationId !== "string") {
14+
return null;
15+
}
16+
17+
return (
18+
<RoleRequired roles={DM_ROLES}>
19+
<RoleRequired roles={AS_ROLES}>
20+
<Layout>
21+
<OrganisationUsageView organisationId={organisationId} />
22+
</Layout>
23+
</RoleRequired>
24+
</RoleRequired>
25+
);
26+
};
27+
28+
export default withPageAuthRequiredCSR(UserUsage);

types/nextjs-routes.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ declare module "nextjs-routes" {
3535
| StaticRoute<"/docs/how-to">
3636
| StaticRoute<"/docs/jobs">
3737
| StaticRoute<"/">
38+
| DynamicRoute<"/organisation/[organisationId]/inventory", { "organisationId": string }>
3839
| DynamicRoute<"/product/[productId]/charges", { "productId": string }>
3940
| StaticRoute<"/products">
4041
| StaticRoute<"/project/file">

0 commit comments

Comments
 (0)