Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type React from "react";

import {
Button,
Card,
CardBody,
CardTitle,
Content,
Flex,
FlexItem,
Stack,
StackItem,
} from "@patternfly/react-core";

import type {
CategoryResult,
RiskAssessmentResults,
} from "@app/queries/risk-assessments";
import { useDownloadAssessmentReport } from "@app/queries/risk-assessments";

import type { AssessmentCategory } from "./assessment-category-step";
import { CriteriaSummaryTable } from "./criteria-summary-table";

interface AssessmentCategoryResultsProps {
/** The assessment ID for document download. */
assessmentId: string;
/** The category definition (key, name, description). */
category: AssessmentCategory;
/** The per-category result data including criteria. */
categoryResult: CategoryResult;
/** The full results for scoring lookup. */
overallResults: RiskAssessmentResults;
onStartNewAssessment: () => void;
}

/** Displays per-category score card and criteria summary for a processed category. */
export const AssessmentCategoryResults: React.FC<
AssessmentCategoryResultsProps
> = ({
assessmentId,
category: _category,
categoryResult,
overallResults,
onStartNewAssessment,
}) => {
const { download } = useDownloadAssessmentReport(assessmentId);

const scorePercent = overallResults.overallScore;
const riskLevel = overallResults.scoring?.overall.riskLevel;

return (
<Stack hasGutter>
<StackItem>
<Card>
<CardTitle>Overall Score</CardTitle>
<CardBody>
<Stack hasGutter>
<StackItem>
<Content component="p">
<span
style={{
fontSize: "var(--pf-t--global--font--size--2xl)",
fontWeight: "var(--pf-t--global--font--weight--bold)",
}}
>
{scorePercent != null
? `${Math.round(scorePercent)}%`
: "\u2014"}
</span>
{riskLevel && (
<span
style={{
marginLeft: "var(--pf-t--global--spacer--sm)",
}}
>
({riskLevel})
</span>
)}
</Content>
</StackItem>
<StackItem>
<Flex gap={{ default: "gapSm" }}>
<FlexItem>
<Button variant="secondary" onClick={onStartNewAssessment}>
Start New Assessment
</Button>
</FlexItem>
<FlexItem>
<Button variant="primary" onClick={download}>
Download Assessment
</Button>
</FlexItem>
</Flex>
</StackItem>
</Stack>
</CardBody>
</Card>
</StackItem>
{categoryResult.criteria.length > 0 && (
<StackItem>
<Card>
<CardTitle>Criteria Summary</CardTitle>
<CardBody>
<CriteriaSummaryTable criteria={categoryResult.criteria} />
</CardBody>
</Card>
</StackItem>
)}
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";

import { useQueryClient } from "@tanstack/react-query";
import axios, { type AxiosRequestConfig } from "axios";

import {
Expand All @@ -15,11 +16,14 @@ import {
import CheckCircleIcon from "@patternfly/react-icons/dist/esm/icons/check-circle-icon";

import { FORM_DATA_FILE_KEY } from "@app/Constants";
import type { RiskAssessmentResults } from "@app/queries/risk-assessments";
import { RiskAssessmentsQueryKey } from "@app/queries/risk-assessments";

import {
ASSESSMENT_CATEGORIES,
AssessmentCategoryStep,
} from "./assessment-category-step";
import { AssessmentCategoryResults } from "./assessment-results";

const RISK_ASSESSMENTS = "/api/v2/risk-assessment";

Expand All @@ -42,17 +46,30 @@ const uploadRiskAssessmentDocument = (

interface AssessmentWizardProps {
riskAssessmentId: string;
/** Per-category results data from the API. */
results?: RiskAssessmentResults;
/** Callback to delete the current assessment and create a fresh one. */
onStartNewAssessment: () => void;
}

export const AssessmentWizard: React.FC<AssessmentWizardProps> = ({
riskAssessmentId,
results,
onStartNewAssessment,
}) => {
const queryClient = useQueryClient();
const [activeStep, setActiveStep] = React.useState(0);
const [completedSteps, setCompletedSteps] = React.useState<Set<number>>(
const [uploadedSteps, setUploadedSteps] = React.useState<Set<number>>(
new Set(),
);

const categoryResultMap = new Map(
results?.categories.map((cat) => [cat.category, cat]) ?? [],
);

const currentCategory = ASSESSMENT_CATEGORIES[activeStep];
const currentCategoryResult = categoryResultMap.get(currentCategory.key);
const isCategoryProcessed = currentCategoryResult?.processed ?? false;

const handleBack = () => {
setActiveStep((prev) => Math.max(0, prev - 1));
Expand All @@ -69,42 +86,87 @@ export const AssessmentWizard: React.FC<AssessmentWizardProps> = ({
<FlexItem>
<Nav aria-label="Assessment categories" variant="default">
<NavList>
{ASSESSMENT_CATEGORIES.map((category, index) => (
<NavItem
key={category.key}
itemId={index}
isActive={activeStep === index}
onClick={() => setActiveStep(index)}
icon={
completedSteps.has(index) ? (
<CheckCircleIcon color="var(--pf-t--global--color--status--success--default)" />
) : undefined
}
>
{`${index + 1}. ${category.name}`}
</NavItem>
))}
{ASSESSMENT_CATEGORIES.map((category, index) => {
const isActive = activeStep === index;
const isProcessed =
categoryResultMap.get(category.key)?.processed ?? false;
const isUploaded = uploadedSteps.has(index);
const isCompleted = isProcessed || isUploaded;

const stepIcon = isCompleted ? (
<CheckCircleIcon color="var(--pf-t--global--color--status--success--default)" />
) : (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 24,
height: 24,
borderRadius: "50%",
fontSize: "var(--pf-t--global--font--size--sm)",
fontWeight: "var(--pf-t--global--font--weight--bold)",
backgroundColor: isActive
? "var(--pf-t--global--color--brand--default)"
: "transparent",
color: isActive
? "#fff"
: "var(--pf-t--global--color--200)",
border: isActive
? "none"
: "1px solid var(--pf-t--global--color--200)",
}}
>
{index + 1}
</span>
);

return (
<NavItem
key={category.key}
itemId={index}
isActive={isActive}
onClick={() => setActiveStep(index)}
icon={stepIcon}
>
{category.name}
</NavItem>
);
})}
</NavList>
</Nav>
</FlexItem>
<FlexItem flex={{ default: "flex_1" }}>
<Stack hasGutter>
<StackItem isFilled>
<AssessmentCategoryStep
key={currentCategory.key}
category={currentCategory}
uploadFn={(formData, config) =>
uploadRiskAssessmentDocument(
riskAssessmentId,
currentCategory.key,
formData,
config,
)
}
onUploadSuccess={() => {
setCompletedSteps((prev) => new Set(prev).add(activeStep));
}}
/>
{isCategoryProcessed && currentCategoryResult ? (
<AssessmentCategoryResults
assessmentId={riskAssessmentId}
category={currentCategory}
categoryResult={currentCategoryResult}
overallResults={results as RiskAssessmentResults}
onStartNewAssessment={onStartNewAssessment}
/>
) : (
<AssessmentCategoryStep
key={currentCategory.key}
category={currentCategory}
uploadFn={(formData, config) =>
uploadRiskAssessmentDocument(
riskAssessmentId,
currentCategory.key,
formData,
config,
)
}
onUploadSuccess={async () => {
setUploadedSteps((prev) => new Set(prev).add(activeStep));
await queryClient.invalidateQueries({
queryKey: [RiskAssessmentsQueryKey],
});
}}
/>
)}
</StackItem>
<StackItem>
<Flex justifyContent={{ default: "justifyContentFlexEnd" }}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type React from "react";

import { Flex, FlexItem, Label } from "@patternfly/react-core";
import CheckCircleIcon from "@patternfly/react-icons/dist/esm/icons/check-circle-icon";
import ExclamationCircleIcon from "@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon";
import ExclamationTriangleIcon from "@patternfly/react-icons/dist/esm/icons/exclamation-triangle-icon";
import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table";

import type { CriterionResult } from "@app/queries/risk-assessments";

interface CriteriaSummaryTableProps {
criteria: CriterionResult[];
}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

/** Format a snake_case criterion key into a readable label. */
const formatCriterionLabel = (key: string) => {
return key.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase());
};

/** Map a completeness string to a PatternFly Label color. */
const completenessColor = (value: string) => {
switch (value) {
case "complete":
return "green";
case "partial":
return "blue";
case "missing":
return "yellow";
default:
return "grey";
}
};

/** Render a risk level with an appropriate status icon. */
const RiskLevelDisplay: React.FC<{ value: string }> = ({ value }) => {
const lower = value.toLowerCase();

let icon: React.ReactNode;
if (lower === "very high" || lower === "high") {
icon = (
<ExclamationCircleIcon color="var(--pf-t--global--color--status--danger--default)" />
);
} else if (lower === "moderate") {
icon = (
<ExclamationTriangleIcon color="var(--pf-t--global--color--status--warning--default)" />
);
} else {
icon = (
<CheckCircleIcon color="var(--pf-t--global--color--status--success--default)" />
);
}

return (
<Flex
gap={{ default: "gapSm" }}
alignItems={{ default: "alignItemsCenter" }}
flexWrap={{ default: "nowrap" }}
>
<FlexItem>{icon}</FlexItem>
<FlexItem>{formatCriterionLabel(value)}</FlexItem>
</Flex>
);
};

export const CriteriaSummaryTable: React.FC<CriteriaSummaryTableProps> = ({
criteria,
}) => {
return (
<Table aria-label="Criteria summary table" variant="compact">
<Thead>
<Tr>
<Th>Criterion</Th>
<Th>Completeness</Th>
<Th>Risk Level</Th>
<Th>Score</Th>
</Tr>
</Thead>
<Tbody>
{criteria.map((item) => (
<Tr key={item.id}>
<Td dataLabel="Criterion">
{formatCriterionLabel(item.criterion)}
</Td>
<Td dataLabel="Completeness">
<Label color={completenessColor(item.completeness)}>
{formatCriterionLabel(item.completeness)}
</Label>
</Td>
<Td dataLabel="Risk Level">
<RiskLevelDisplay value={item.riskLevel} />
</Td>
<Td dataLabel="Score">{item.score}</Td>
</Tr>
))}
</Tbody>
</Table>
);
};
Loading
Loading