Skip to content
Merged
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
16 changes: 5 additions & 11 deletions apps/csm-portal/webapp/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ const CsmSecurityCenterPage = lazy(
const CreateSecurityReportPage = lazy(
() => import("@features/csm-security-center/pages/CreateSecurityReportPage"),
);
const CsmEngagementsPage = lazy(
() => import("@features/csm-engagements/pages/CsmEngagementsPage"),
);

/**
* Landing for `/`. Defers to AuthGuard's post-login deep-link restore when a
Expand Down Expand Up @@ -233,17 +236,8 @@ export default function App(): JSX.Element {
element={<CreateServiceRequestPage />}
/>

{/* WIP placeholders for top-level features awaiting BFF support */}
<Route
path="engagements"
element={
<CsmComingSoonPage
title="Engagements"
description="Professional services engagements (migration, implementation, onboarding, training) across customers."
blockedOn="csm-portal/backend engagements endpoint"
/>
}
/>
<Route path="engagements" element={<CsmEngagementsPage />} />
<Route path="engagements/:caseId" element={<CsmCaseDetailPage />} />
<Route path="updates" element={<CsmUpdatesPage />} />
<Route path="security-center" element={<CsmSecurityCenterPage />} />
<Route
Expand Down
9 changes: 9 additions & 0 deletions apps/csm-portal/webapp/src/api/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,13 @@ export interface BeUpdateCaseResponse {
case: BeUpdatedCase;
}

export type BeEngagementType =
| "migration"
| "consultancy"
| "new_feature_improvement"
| "follow_up"
| "onboarding";

/** Request body for `POST /cases/search` (the flat, cross-project search). */
/** Filter block for `POST /cases/search`; all fields are optional. */
export interface BeCaseSearchFilters {
Expand All @@ -389,6 +396,8 @@ export interface BeCaseSearchFilters {
states?: BeCaseState[];
severities?: BeCaseSeverity[];
issueTypes?: BeCaseIssueType[];
/** Filter by engagement type; only applies when `types` includes `"engagement"`. */
engagementTypes?: BeEngagementType[];
/** Filter to cases created by these emails. */
createdBy?: string[];
/** When true, the caller's email (from the JWT) is appended to `createdBy`. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export function useGetCsmCases(
[...filters.states].sort(),
[...filters.caseTypes].sort(),
[...filters.projects].sort(),
[...filters.engagementTypes].sort(),
currentUserEmail ?? "",
page,
pageSize,
Expand All @@ -157,6 +158,9 @@ export function useGetCsmCases(
...(filters.caseTypes.length > 0 && {
types: filters.caseTypes,
}),
...(filters.engagementTypes.length > 0 && {
engagementTypes: filters.engagementTypes,
}),
// `filters.projects` holds project IDs (the filter is id-based).
...(filters.projects.length > 0 && {
projectIds: filters.projects,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ import {
SUGGESTED_FILTER_VIEWS,
useSavedFilterViews,
} from "@features/csm-cases/utils/savedFilterViews";
import type { BeCaseType } from "@api/backend/types";
import type { BeCaseType, BeEngagementType } from "@api/backend/types";
import {
ALL_CASE_TYPES,
CASE_TYPE_LABEL,
Expand All @@ -97,6 +97,8 @@ export interface CasesFilters {
/** Engineer emails (+ the `@me` sentinel) to filter by assigned engineer. */
assignees: string[];
projects: string[];
/** Engagement sub-type filter; only meaningful when `caseTypes` is locked to `engagement`. */
engagementTypes: BeEngagementType[];
}

/**
Expand All @@ -122,8 +124,26 @@ interface CasesFilterBarProps {
hideTypeFilter?: boolean;
/** Hide the project control when the surrounding view is project-scoped. */
hideProjectFilter?: boolean;
/** Show the engagement-type multi-select (only relevant when type is locked to engagement). */
showEngagementTypeFilter?: boolean;
}

const ALL_ENGAGEMENT_TYPES: BeEngagementType[] = [
"migration",
"consultancy",
"new_feature_improvement",
"follow_up",
"onboarding",
];

const ENGAGEMENT_TYPE_LABEL: Record<BeEngagementType, string> = {
migration: "Migration",
consultancy: "Consultancy",
new_feature_improvement: "New feature / improvement",
follow_up: "Follow-up",
onboarding: "Onboarding",
};

const ALL_SEVERITIES: Severity[] = ["S0", "S1", "S2", "S3", "S4"];
const PRIMARY_STATES: CaseState[] = [
"open",
Expand Down Expand Up @@ -319,6 +339,7 @@ export default function CasesFilterBar({
availableProjects,
hideTypeFilter = false,
hideProjectFilter = false,
showEngagementTypeFilter = false,
}: CasesFilterBarProps): JSX.Element {
const activeCount = countActiveFilters(filters);
const hasActive = activeCount > 0;
Expand Down Expand Up @@ -368,6 +389,10 @@ export default function CasesFilterBar({
() => ALL_CASE_TYPES.map((t) => ({ value: t, label: CASE_TYPE_LABEL[t] })),
[],
);
const engagementTypeOptions = useMemo(
() => ALL_ENGAGEMENT_TYPES.map((t) => ({ value: t, label: ENGAGEMENT_TYPE_LABEL[t] })),
[],
);

// Project filter loads the first page of projects on open and pages through
// the rest on scroll (and narrows as you type) rather than loading the whole
Expand Down Expand Up @@ -610,6 +635,17 @@ export default function CasesFilterBar({
onChange={(next) => onChange({ ...filters, states: next })}
/>
</Grid>
{showEngagementTypeFilter && (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 2 }}>
<MultiSelectField
id="cases-filter-engagement-type"
label="Engagement type"
values={filters.engagementTypes}
options={engagementTypeOptions}
onChange={(next) => onChange({ ...filters, engagementTypes: next })}
/>
</Grid>
)}
{!hideTypeFilter && (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 2 }}>
<MultiSelectField
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import type { CsmCaseRow } from "@features/csm-cases/types/csmCases";
interface CasesListProps {
cases: CsmCaseRow[];
isLoading: boolean;
/** Base path for detail links. Defaults to "/cases". */
detailBasePath?: string;
}

// Every column is left-aligned for a consistent scan line down the table.
Expand All @@ -45,6 +47,7 @@ const GRID =
export default function CasesList({
cases,
isLoading,
detailBasePath = "/cases",
}: CasesListProps): JSX.Element {
const theme = useTheme();

Expand Down Expand Up @@ -125,7 +128,7 @@ export default function CasesList({
<Box
key={c.id}
component={RouterLink}
to={`/cases/${c.id}`}
to={`${detailBasePath}/${c.id}`}
sx={{
gridColumn: "1 / -1",
display: "grid",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const FILTER_PARAM_KEYS = [
"types",
"assignees",
"projects",
"engagementTypes",
] as const;

interface CsmIssuesViewProps {
Expand All @@ -69,6 +70,10 @@ interface CsmIssuesViewProps {
hideTypeFilter?: boolean;
/** Hide the project filter control (use when the view is project-scoped). */
hideProjectFilter?: boolean;
/** Show the engagement-type sub-filter (pass when the view is locked to engagement cases). */
showEngagementTypeFilter?: boolean;
/** Base path for row detail links. Defaults to "/cases". */
detailBasePath?: string;
}

/**
Expand All @@ -85,6 +90,8 @@ export default function CsmIssuesView({
lockedFilters,
hideTypeFilter,
hideProjectFilter,
showEngagementTypeFilter,
detailBasePath,
}: CsmIssuesViewProps): JSX.Element {
const [searchParams, setSearchParams] = useSearchParams();
const filters = useMemo<CasesFilters>(
Expand Down Expand Up @@ -227,9 +234,10 @@ export default function CsmIssuesView({
availableProjects={availableProjects}
hideTypeFilter={hideTypeFilter}
hideProjectFilter={hideProjectFilter}
showEngagementTypeFilter={showEngagementTypeFilter}
/>

<CasesList cases={cases} isLoading={isLoading} />
<CasesList cases={cases} isLoading={isLoading} detailBasePath={detailBasePath} />

<TablePagination
component="div"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ export default function CsmCaseDetailPage(): JSX.Element {
const { caseId } = useParams<{ caseId: string }>();
const navigate = useNavigate();
const location = useLocation();
const isEngagementRoute = location.pathname.startsWith("/engagements/");
const backPath = isEngagementRoute ? "/engagements" : "/cases";
const backLabel = isEngagementRoute ? "Back to engagements" : "Back to cases";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const detailPath = isEngagementRoute ? `/engagements/${caseId}` : `/cases/${caseId}`;
const { data, isLoading, isError } = useGetCsmCaseDetail(caseId);
const {
data: comments,
Expand Down Expand Up @@ -366,7 +370,7 @@ export default function CsmCaseDetailPage(): JSX.Element {
? `${caseIdLabel(data)} · ${data.subject}`
: data.subject,
subtitle: `${data.customer} · ${data.projectName}`,
href: `/cases/${data.id}`,
href: detailPath,
});
}, [data, recordView]);

Expand Down Expand Up @@ -548,7 +552,7 @@ export default function CsmCaseDetailPage(): JSX.Element {
if (action.secondary === "copy_link") {
if (data && navigator.clipboard) {
navigator.clipboard
.writeText(`${window.location.origin}/cases/${data.id}`)
.writeText(`${window.location.origin}${detailPath}`)
.then(() =>
setFeedback({
message: SECONDARY_TOAST.copy_link,
Expand Down Expand Up @@ -694,10 +698,10 @@ export default function CsmCaseDetailPage(): JSX.Element {
variant="text"
size="small"
startIcon={<ArrowLeft size={16} />}
onClick={() => navigate("/cases")}
onClick={() => navigate(backPath)}
sx={{ alignSelf: "flex-start" }}
>
Back to cases
{backLabel}
</Button>
<Typography variant="body1" color="error">
Could not load case {caseId}.
Expand All @@ -713,10 +717,10 @@ export default function CsmCaseDetailPage(): JSX.Element {
variant="text"
size="small"
startIcon={<ArrowLeft size={16} />}
onClick={() => navigate("/cases")}
onClick={() => navigate(backPath)}
sx={{ alignSelf: "flex-start" }}
>
Back to cases
{backLabel}
</Button>
<Typography variant="h5">Case not found</Typography>
<Typography variant="body2" color="text.secondary">
Expand Down Expand Up @@ -771,10 +775,10 @@ export default function CsmCaseDetailPage(): JSX.Element {
variant="text"
size="small"
startIcon={<ArrowLeft size={16} />}
onClick={() => navigate("/cases")}
onClick={() => navigate(backPath)}
sx={{ alignSelf: "flex-start" }}
>
Back to cases
{backLabel}
</Button>

<Box
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {
CaseState,
Severity,
} from "@features/csm-dashboard/types/abtDashboard";
import type { BeCaseType } from "@api/backend/types";
import type { BeCaseType, BeEngagementType } from "@api/backend/types";
import type { CasesFilters } from "@features/csm-cases/components/CasesFilterBar";
import { ALL_CASE_TYPES } from "@features/csm-cases/utils/caseType";

Expand All @@ -29,6 +29,7 @@ export const DEFAULT_CASES_FILTERS: CasesFilters = {
caseTypes: [],
assignees: [],
projects: [],
engagementTypes: [],
};

const VALID_SEVERITIES: Severity[] = ["S0", "S1", "S2", "S3", "S4"];
Expand All @@ -41,6 +42,13 @@ const VALID_STATES: CaseState[] = [
"closed",
];
const VALID_CASE_TYPES: BeCaseType[] = ALL_CASE_TYPES;
const VALID_ENGAGEMENT_TYPES: BeEngagementType[] = [
"migration",
"consultancy",
"new_feature_improvement",
"follow_up",
"onboarding",
];

function parseCsv<T extends string>(raw: string | null, allowed: T[]): T[] {
if (!raw) return [];
Expand Down Expand Up @@ -73,6 +81,7 @@ export function readCasesFiltersFromUrl(
caseTypes: parseCsv(params.get("types"), VALID_CASE_TYPES),
assignees: parseFreeFormCsv(params.get("assignees")),
projects: parseFreeFormCsv(params.get("projects")),
engagementTypes: parseCsv(params.get("engagementTypes"), VALID_ENGAGEMENT_TYPES),
};
}

Expand All @@ -88,6 +97,7 @@ export function writeCasesFiltersToUrl(f: CasesFilters): URLSearchParams {
if (f.caseTypes.length) out.set("types", f.caseTypes.join(","));
if (f.assignees.length) out.set("assignees", f.assignees.join(","));
if (f.projects.length) out.set("projects", f.projects.join(","));
if (f.engagementTypes.length) out.set("engagementTypes", f.engagementTypes.join(","));
return out;
}

Expand All @@ -105,6 +115,7 @@ export function countActiveFilters(f: CasesFilters): number {
if (f.caseTypes.length) n += 1;
if (f.assignees.length) n += 1;
if (f.projects.length) n += 1;
if (f.engagementTypes.length) n += 1;
return n;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

import { type JSX } from "react";
import CsmIssuesView from "@features/csm-cases/components/CsmIssuesView";

/**
* Cross-customer engagements list — filters the shared issues view to
* `type: engagement` and surfaces the engagement-type sub-filter so engineers
* can narrow by migration, consultancy, onboarding, etc.
*/
export default function CsmEngagementsPage(): JSX.Element {
return (
<CsmIssuesView
title="Engagements"
entityNoun="engagements"
lockedFilters={{ caseTypes: ["engagement"] }}
hideTypeFilter
showEngagementTypeFilter
detailBasePath="/engagements"
/>
);
}