diff --git a/apps/csm-portal/webapp/src/api/backend/mappers.test.ts b/apps/csm-portal/webapp/src/api/backend/mappers.test.ts
index 04b2b14d29..aed79f7baa 100644
--- a/apps/csm-portal/webapp/src/api/backend/mappers.test.ts
+++ b/apps/csm-portal/webapp/src/api/backend/mappers.test.ts
@@ -83,6 +83,15 @@ describe("uiStateFromBe / beStateFromUi", () => {
// known state — that is what lets the backend add a state with no FE change.
expect(uiStateFromBe("pending_review")).toBe("pending_review");
});
+
+ it("normalizes the raw ServiceNow label form to the enum", () => {
+ // The SN case-search view sends the human label instead of the enum; the
+ // mapper lowercases + collapses whitespace so SN cases render with the
+ // curated label/colour and `state === "work_in_progress"` checks match.
+ expect(uiStateFromBe("Work In Progress")).toBe("work_in_progress");
+ expect(uiStateFromBe("Waiting On WSO2")).toBe("waiting_on_wso2");
+ expect(uiStateFromBe("Solution Proposed")).toBe("solution_proposed");
+ });
});
describe("commentTypeFromInternal", () => {
diff --git a/apps/csm-portal/webapp/src/api/backend/mappers.ts b/apps/csm-portal/webapp/src/api/backend/mappers.ts
index 687750140e..1f4fb50b6f 100644
--- a/apps/csm-portal/webapp/src/api/backend/mappers.ts
+++ b/apps/csm-portal/webapp/src/api/backend/mappers.ts
@@ -76,17 +76,26 @@ export function priorityFromSeverity(severity: Severity): BeCaseSeverity {
}
/**
- * The UI and backend state vocabularies are identical (`CaseState` ===
- * `BeCaseState`), so these are identity maps. They survive as the single API
- * boundary and, deliberately, pass an *unknown* backend state straight through
- * rather than collapsing it to a known one: a state the frontend has not been
- * taught about must still reach the UI so it can render with a humanized label
- * (see `stateLabel`/`stateColor`). That is what lets the backend introduce a new
- * state with no frontend change. Only a genuinely absent value defaults to
- * `open`.
+ * Map a backend case state onto the UI `CaseState` vocabulary.
+ *
+ * The Postgres source already sends the domain enum (`work_in_progress`), but
+ * the ServiceNow case-search view sends the raw SN label (`"Work In Progress"`)
+ * because the entity-service normalizes every sibling field (severity, work
+ * state, issue type) EXCEPT state. So we normalize label → enum here at the
+ * boundary — lowercase and collapse whitespace to underscores — so both sources
+ * render with the curated label/colour and downstream `state === "…"` checks
+ * (e.g. the in-progress work-state indicator) work regardless of source.
+ * (Ideal fix is BE-side: normalize `state` in the SN search view like the other
+ * fields — tracked as a follow-up.)
+ *
+ * A state the frontend has not been taught about still passes through (in
+ * normalized form) so `stateLabel`/`stateColor` can humanize it — that is what
+ * lets the backend introduce a new state with no frontend change. A genuinely
+ * absent value defaults to `open`.
*/
export function uiStateFromBe(state: string | undefined): CaseState {
- return (state ?? "open") as CaseState;
+ if (!state) return "open";
+ return state.trim().toLowerCase().replace(/\s+/g, "_") as CaseState;
}
export function beStateFromUi(state: CaseState): BeCaseState {
diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CasesList.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CasesList.tsx
index a0c59e921a..cea90defc0 100644
--- a/apps/csm-portal/webapp/src/features/csm-cases/components/CasesList.tsx
+++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CasesList.tsx
@@ -20,6 +20,7 @@ import { Link as RouterLink } from "react-router";
import RelativeTime from "@components/RelativeTime";
import SeverityChip from "@components/SeverityChip";
import StateChip from "@components/StateChip";
+import { WORK_STATE_LABEL } from "@features/csm-cases/utils/caseWorkState";
import type { CsmCaseRow } from "@features/csm-cases/types/csmCases";
interface CasesListProps {
@@ -200,8 +201,23 @@ export default function CasesList({
-
+ {/* State, plus the work sub-state (Ongoing/Paused) when the case
+ is in progress. Paused is coloured to stand out as a stalled
+ case; ongoing stays quiet. */}
+
+ {c.state === "work_in_progress" && c.workState && (
+
+ {WORK_STATE_LABEL[c.workState]}
+
+ )}
diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useGetMyAssignedOpenCases.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useGetMyAssignedOpenCases.ts
new file mode 100644
index 0000000000..feea49cb12
--- /dev/null
+++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useGetMyAssignedOpenCases.ts
@@ -0,0 +1,166 @@
+// 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 {
+ keepPreviousData,
+ useQuery,
+ type UseQueryResult,
+} from "@tanstack/react-query";
+import { ApiQueryKeys } from "@constants/apiConstants";
+import { useBackendApi } from "@api/backend/client";
+import { severityFromPriority, uiStateFromBe } from "@api/backend/mappers";
+import { useCurrentUser } from "@context/current-user/CurrentUserContext";
+import { useIdTokenClaims } from "@hooks/useIdTokenClaims";
+import type {
+ BeCaseSearchPayload,
+ BeCaseSearchResponse,
+ BeCaseState,
+} from "@api/backend/types";
+import type { CsmCaseRow } from "@features/csm-cases/types/csmCases";
+
+/**
+ * Every lifecycle state except the terminal `closed` — the widget tracks the
+ * caller's active workload. `reopened` is included (it is a non-closed,
+ * assignable state); the severity/state matrix omits it, but "all non-closed"
+ * should not. `/cases/search` takes an inclusion list, so "non-closed" is
+ * expressed by enumerating the non-closed states rather than excluding one.
+ */
+const NON_CLOSED_STATES: BeCaseState[] = [
+ "open",
+ "work_in_progress",
+ "waiting_on_wso2",
+ "awaiting_info",
+ "reopened",
+ "solution_proposed",
+];
+
+/** UI states for the "View all" deep-link (`/cases`), which filters on the UI
+ * `CaseState` vocabulary — `reopened` has no filter option there, so it is
+ * dropped from the link only (the widget itself still lists reopened cases). */
+export const MY_OPEN_CASES_LINK_STATES = NON_CLOSED_STATES.filter(
+ (s) => s !== "reopened",
+).join(",");
+
+export interface MyAssignedOpenCases {
+ cases: CsmCaseRow[];
+ /** Total non-closed cases assigned to the caller across all pages. */
+ total: number;
+ /** Whether more rows exist beyond the current page. */
+ hasMore: boolean;
+}
+
+/** Default page size for the dashboard widget (kept small to stay compact). */
+export const MY_OPEN_CASES_PAGE_SIZE = 5;
+
+/**
+ * Non-closed cases assigned to the signed-in engineer, for the dashboard
+ * "Assigned to me" widget.
+ *
+ * A single `POST /cases/search` filtered server-side by `assignedUserIds` (the
+ * caller's platform UUID from the app-wide current-user context) and the
+ * non-closed `states` set — no client-side filtering of a superset. Sorted by
+ * `updatedOn` desc (most recently touched first), matching the main cases list.
+ * The widget paginates a small page (`page` / `pageSize`) rather than loading
+ * everything; `total` / `hasMore` drive the pager and the "View all" link.
+ *
+ * The account/customer column is not resolved here (the case-search view does
+ * not embed it and `CasesList` does not render it), so this deliberately skips
+ * the account-directory lookup `useGetCsmCases` runs — keeping the dashboard
+ * load to one request.
+ *
+ * Keyed under the `CSM_CASES` prefix so case create / assignment / close
+ * mutations (which invalidate that prefix) refresh the widget. Disabled until
+ * the caller's `id` is known — `/users/me` omits it only when the entity
+ * service is down, in which case the widget shows an unavailable state rather
+ * than silently broadening to everyone's cases.
+ */
+export function useGetMyAssignedOpenCases(
+ page: number,
+ pageSize: number,
+): UseQueryResult {
+ const api = useBackendApi();
+ const userId = useCurrentUser().user?.id;
+ const myEmail = useIdTokenClaims()?.email;
+ const offset = page * pageSize;
+
+ return useQuery({
+ queryKey: [
+ ApiQueryKeys.CSM_CASES,
+ "my-assigned-open",
+ userId ?? "",
+ page,
+ pageSize,
+ ],
+ enabled: !!userId,
+ queryFn: async (): Promise => {
+ const res = await api.post(
+ "/cases/search",
+ {
+ pagination: { offset, limit: pageSize },
+ sortBy: { field: "updatedOn", order: "desc" },
+ filters: {
+ assignedUserIds: [userId as string],
+ states: NON_CLOSED_STATES,
+ },
+ },
+ );
+
+ const myEmailLc = myEmail?.toLowerCase();
+ const cases: CsmCaseRow[] = (res.cases ?? []).map((c) => {
+ const assigneeEmail = c.assignedEngineer?.email;
+ return {
+ id: c.id,
+ caseNumber: c.number,
+ wso2CaseId: c.internalId,
+ subject: c.subject ?? "(no subject)",
+ // The search view does not embed the account; CasesList never renders
+ // it, so leave it unresolved rather than scanning the account list.
+ customer: "—",
+ accountId: "",
+ projectId: c.project?.id ?? "",
+ projectName: c.project?.name ?? "—",
+ product: c.deployedProduct?.name ?? "—",
+ severity: severityFromPriority(c.severity),
+ state: uiStateFromBe(c.state),
+ caseType: c.type,
+ workState: c.workState ?? null,
+ assignee:
+ c.assignedEngineer?.name?.trim() || assigneeEmail || "Unassigned",
+ assigneeIsMe:
+ !!assigneeEmail &&
+ !!myEmailLc &&
+ assigneeEmail.toLowerCase() === myEmailLc,
+ slaClockType: "ack",
+ minutesToBreach: 0,
+ // No SLA data from the backend yet — keep the column neutral.
+ hasSla: false,
+ createdAt: c.createdOn ?? "",
+ updatedAt: c.updatedOn ?? c.createdOn ?? "",
+ };
+ });
+
+ return {
+ cases,
+ total: res.total ?? cases.length,
+ hasMore: res.hasMore ?? false,
+ };
+ },
+ // Keep the previous page's rows/total while the next page loads, so the
+ // pager and count stay stable instead of blinking out on page change.
+ placeholderData: keepPreviousData,
+ staleTime: 30_000,
+ });
+}
diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/MyAssignedCases.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/MyAssignedCases.tsx
new file mode 100644
index 0000000000..def7c4ebd6
--- /dev/null
+++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/MyAssignedCases.tsx
@@ -0,0 +1,129 @@
+// 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 { Link, TablePagination, Typography } from "@wso2/oxygen-ui";
+import { useState, type JSX } from "react";
+import { Link as RouterLink } from "react-router";
+import { ASSIGNEE_ME_TOKEN } from "@features/csm-cases/components/CasesFilterBar";
+import CasesList from "@features/csm-cases/components/CasesList";
+import {
+ MY_OPEN_CASES_LINK_STATES,
+ MY_OPEN_CASES_PAGE_SIZE,
+ useGetMyAssignedOpenCases,
+} from "@features/csm-dashboard/api/useGetMyAssignedOpenCases";
+import { useCurrentUser } from "@context/current-user/CurrentUserContext";
+import SectionCard from "@features/csm-dashboard/components/SectionCard";
+
+// Deep-link to the full cases list, pre-filtered to the caller's non-closed
+// cases. `assignees=@me` resolves against the current user server-side; the
+// states mirror the widget's non-closed set (minus `reopened`, which the list
+// filter vocabulary has no option for).
+const VIEW_ALL_HREF = `/cases?assignees=${encodeURIComponent(
+ ASSIGNEE_ME_TOKEN,
+)}&states=${MY_OPEN_CASES_LINK_STATES}`;
+
+/**
+ * Dashboard widget: the signed-in engineer's non-closed cases (their active
+ * workload). Wraps the shared {@link CasesList} in a {@link SectionCard} and
+ * paginates a small page of the list; the data is filtered server-side by
+ * assignee + state in {@link useGetMyAssignedOpenCases}. "View all" jumps to
+ * the full cases page with the same filter.
+ */
+export default function MyAssignedCases(): JSX.Element {
+ const currentUser = useCurrentUser();
+ const [page, setPage] = useState(0);
+ const { data, isLoading, isError } = useGetMyAssignedOpenCases(
+ page,
+ MY_OPEN_CASES_PAGE_SIZE,
+ );
+
+ // `/users/me` returned but carried no id (entity service down): we can't tell
+ // which cases are the caller's, so say so rather than showing nothing.
+ const cannotIdentify = !currentUser.isLoading && !currentUser.user?.id;
+
+ const loading = isLoading || currentUser.isLoading;
+ const total = data?.total ?? 0;
+ // Clamp to the last valid page when the queue shrinks (a case closed /
+ // reassigned drops the total below the current offset). Guarded setState
+ // during render is React's documented pattern for deriving from changed
+ // inputs — not an effect. Mirrors CsmIssuesView.
+ const lastPage = total === 0 ? 0 : Math.ceil(total / MY_OPEN_CASES_PAGE_SIZE) - 1;
+ if (data !== undefined && page > lastPage) {
+ setPage(lastPage);
+ }
+ // Once loaded, an empty queue is reported via the subtitle alone (CasesList's
+ // own "no cases match the current filters" copy would be misleading here).
+ const isEmpty = !loading && !isError && !cannotIdentify && total === 0;
+ const subtitle =
+ !loading && !isError && !cannotIdentify
+ ? total === 0
+ ? "You have no open cases."
+ : `${total} open ${total === 1 ? "case" : "cases"} assigned to you`
+ : undefined;
+
+ return (
+ 0 ? (
+
+ View all
+
+ ) : undefined
+ }
+ >
+ {isError ? (
+
+ Could not load your cases.
+
+ ) : cannotIdentify ? (
+
+ We couldn't identify your account, so your assigned cases can't be
+ loaded right now.
+
+ ) : isEmpty ? null : (
+ <>
+
+ {/* Only pager past the first page when there's more than one page. */}
+ {total > MY_OPEN_CASES_PAGE_SIZE && (
+ setPage(next)}
+ rowsPerPage={MY_OPEN_CASES_PAGE_SIZE}
+ rowsPerPageOptions={[]}
+ showFirstButton
+ showLastButton
+ // The pager's toolbar is a fixed 52px tall by default, which
+ // leaves an airy dead band under the list. Collapse it to its
+ // content height and drop the left inset so it sits snug.
+ sx={{
+ "& .MuiTablePagination-toolbar": { minHeight: 0, pl: 0 },
+ }}
+ />
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx
index 1bd71c8214..ed00ce0e8e 100644
--- a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx
+++ b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx
@@ -19,6 +19,7 @@ import { useState, type JSX } from "react";
import AbtDashboardHeader from "@features/csm-dashboard/components/AbtDashboardHeader";
import CaseCompositionCharts from "@features/csm-dashboard/components/CaseCompositionCharts";
import CaseCountsMatrix from "@features/csm-dashboard/components/CaseCountsMatrix";
+import MyAssignedCases from "@features/csm-dashboard/components/MyAssignedCases";
import { useGetCsmDashboard } from "@features/csm-dashboard/api/useGetCsmDashboard";
import {
DASHBOARD_OPTIONS,
@@ -60,6 +61,7 @@ export default function CsmDashboardPage(): JSX.Element {
/>
{dashboardKey === "engineer" ? (
<>
+
>