Add control overview page

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-02-19 16:10:05 +01:00
parent c44c4ba8c4
commit 5ebe7a685b
4 changed files with 406 additions and 4 deletions

View File

@@ -27,12 +27,13 @@ const NotFoundPage = lazy(() => import("./pages/NotFoundPage"));
const PeopleListPage = lazy(() => import("./pages/PeopleListPage"));
const VendorListPage = lazy(() => import("./pages/VendorListPage"));
const FrameworkListPage = lazy(() => import("./pages/FrameworkListPage"));
const FrameworkOverviewPage = lazy(
() => import("./pages/FrameworkOverviewPage"),
);
const VendorOverviewPage = lazy(() => import("./pages/VendorOverviewPage"));
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
const CreatePeoplePage = lazy(() => import("./pages/CreatePeoplePage"));
const FrameworkOverviewPage = lazy(
() => import("./pages/FrameworkOverviewPage"),
);
const ControlOverviewPage = lazy(() => import("./pages/ControlOverviewPage"));
function App() {
return (
@@ -121,6 +122,16 @@ function App() {
</Suspense>
}
/>
<Route
path="/frameworks/:frameworkId/:controlId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<ControlOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="/vendors/:vendorId"
element={

View File

@@ -0,0 +1,180 @@
import { Suspense, useEffect } from "react";
import { useParams } from "react-router";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
} from "react-relay";
import { CheckCircle2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Helmet } from "react-helmet-async";
import { useBreadcrumb } from "@/contexts/BreadcrumbContext";
import type { ControlOverviewPageQuery as ControlOverviewPageQueryType } from "./__generated__/ControlOverviewPageQuery.graphql";
const controlOverviewPageQuery = graphql`
query ControlOverviewPageQuery($controlId: ID!) {
node(id: $controlId) {
id
... on Control {
name
description
state
category
tasks {
edges {
node {
id
name
description
state
}
}
}
}
}
}
`;
function ControlOverviewPageContent({
queryRef,
}: {
queryRef: PreloadedQuery<ControlOverviewPageQueryType>;
}) {
const data = usePreloadedQuery<ControlOverviewPageQueryType>(
controlOverviewPageQuery,
queryRef,
);
const { setBreadcrumbSegment } = useBreadcrumb();
const control = data.node;
const tasks = control.tasks?.edges.map((edge) => edge?.node) ?? [];
useEffect(() => {
if (control?.name) {
setBreadcrumbSegment("frameworks/:frameworkId/:controlId", control.name);
}
}, [control?.name, setBreadcrumbSegment]);
return (
<div className="min-h-screen bg-white p-6 space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">{control.name}</h1>
<div className="flex items-center gap-2">
<div className="bg-green-100 text-green-800 px-3 py-1 rounded-full text-sm">
30 min
</div>
<div className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm">
Mandatory
</div>
<div className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm">
Assigned to you
</div>
</div>
</div>
<p className="text-gray-600 max-w-3xl">{control.description}</p>
</div>
<Card className="bg-gray-50 border border-gray-200">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="w-4 h-4 rounded-full bg-white flex items-center justify-center border border-gray-200">
<div
className={`w-2 h-2 rounded-full ${control.state === "IMPLEMENTED" ? "bg-green-500" : "bg-gray-300"}`}
/>
</div>
<span className="text-sm text-gray-700">
{control.state === "IMPLEMENTED" ? "Validated" : "Not validated"}
</span>
</div>
</CardContent>
</Card>
<div>
<h2 className="text-xl font-semibold mb-4">Tasks</h2>
<div className="space-y-2">
{tasks.map((task) => (
<div
key={task?.id}
className="flex items-start gap-3 p-4 bg-white border border-gray-200 rounded-lg hover:shadow-sm transition-shadow"
>
<div
className={`mt-1 w-5 h-5 rounded-full border-2 flex items-center justify-center cursor-pointer ${task?.state === "DONE" ? "border-green-500 bg-green-50" : "border-gray-300"}`}
>
{task?.state === "DONE" && (
<CheckCircle2 className="w-4 h-4 text-green-500" />
)}
</div>
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<h3
className={`text-sm font-medium ${task?.state === "DONE" ? "text-gray-500 line-through" : "text-gray-900"}`}
>
{task?.name}
</h3>
<p
className={`text-sm mt-1 ${task?.state === "DONE" ? "text-gray-400" : "text-gray-600"}`}
>
{task?.description}
</p>
</div>
<div className="flex items-center gap-2">
<div
className={`px-2 py-1 rounded-full text-xs ${task?.state === "DONE" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}`}
>
{task?.state.toLowerCase()}
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
function ControlOverviewPageFallback() {
return (
<div className="min-h-screen bg-white p-6">
<div className="mb-8">
<div className="h-8 w-48 bg-gray-100 animate-pulse rounded" />
<div className="h-4 w-96 bg-gray-100 animate-pulse rounded mt-2" />
</div>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="h-6 w-48 bg-gray-100 animate-pulse rounded mb-2" />
<div className="h-4 w-full bg-gray-100 animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
export default function ControlOverviewPage() {
const { controlId } = useParams();
const [queryRef, loadQuery] = useQueryLoader<ControlOverviewPageQueryType>(
controlOverviewPageQuery,
);
useEffect(() => {
loadQuery({ controlId: controlId! });
}, [loadQuery, controlId]);
return (
<>
<Helmet>
<title>Control Overview - Probo Console</title>
</Helmet>
<Suspense fallback={<ControlOverviewPageFallback />}>
{queryRef && <ControlOverviewPageContent queryRef={queryRef} />}
</Suspense>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { Suspense, useEffect, useState } from "react";
import { useParams } from "react-router";
import { useParams, useNavigate } from "react-router";
import {
graphql,
PreloadedQuery,
@@ -55,6 +55,7 @@ function FrameworkOverviewPageContent({
width: number;
} | null>(null);
const [isTooltipHovered, setIsTooltipHovered] = useState(false);
const navigate = useNavigate();
useEffect(() => {
if (framework?.name) {
@@ -194,6 +195,13 @@ function FrameworkOverviewPageContent({
width: 400,
});
}}
onClick={() => {
if (control?.id) {
navigate(
`/frameworks/${framework.id}/${control.id}`,
);
}
}}
onMouseLeave={() => {
setTimeout(() => {
if (!isTooltipHovered) {

View File

@@ -0,0 +1,203 @@
/**
* @generated SignedSource<<fd114b0d31a2f605a3ab904fd12d48a2>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ControlState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
export type TaskState = "DONE" | "TODO";
export type ControlOverviewPageQuery$variables = {
controlId: string;
};
export type ControlOverviewPageQuery$data = {
readonly node: {
readonly category?: string;
readonly description?: string;
readonly id: string;
readonly name?: string;
readonly state?: ControlState;
readonly tasks?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly description: string;
readonly id: string;
readonly name: string;
readonly state: TaskState;
};
}>;
};
};
};
export type ControlOverviewPageQuery = {
response: ControlOverviewPageQuery$data;
variables: ControlOverviewPageQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "controlId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "controlId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
v6 = {
"kind": "InlineFragment",
"selections": [
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "TaskConnection",
"kind": "LinkedField",
"name": "tasks",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "TaskEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Task",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/)
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Control",
"abstractKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ControlOverviewPageQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v6/*: any*/)
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ControlOverviewPageQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v2/*: any*/),
(v6/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "adb3dd88c138a9168b25c35df888af86",
"id": null,
"metadata": {},
"name": "ControlOverviewPageQuery",
"operationKind": "query",
"text": "query ControlOverviewPageQuery(\n $controlId: ID!\n) {\n node(id: $controlId) {\n __typename\n id\n ... on Control {\n name\n description\n state\n category\n tasks {\n edges {\n node {\n id\n name\n description\n state\n }\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "3db82e512ed61585e9825785e1685240";
export default node;