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" ? ( <> +