From e25e57569d1629ed3213c061e09978f83ec21d86 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Mon, 6 Jul 2026 10:14:22 +0530 Subject: [PATCH 1/8] [CSM Portal] case detail: SLA tab Add a case-detail SLA tab that lists the case's SLA records, consuming the task-SLA search endpoint (POST /task-slas/search filtered by the case id). A CaseSlaTable (definition, target, colored stage chip, business time left/elapsed, elapsed %, start/stop times in the viewer's timezone; loading/empty/error + refresh) renders the mapped rows. Stage label is derived from the stage value and hasBreached from the elapsed percentage (the authoritative flag isn't exposed upstream yet). Removes the dead, never-populated 3-clock SLA scaffolding this replaces (list/dashboard SLA fields untouched). --- .../webapp/src/constants/apiConstants.ts | 1 + .../csm-cases/api/useGetCsmCaseDetail.ts | 7 +- .../csm-cases/api/useGetCsmCaseSlas.ts | 62 +++++ .../components/CaseActionBar.test.tsx | 1 - .../components/CaseDetailWidgets.tsx | 99 -------- .../components/CaseSlaTable.test.tsx | 103 +++++++++ .../csm-cases/components/CaseSlaTable.tsx | 213 ++++++++++++++++++ .../csm-cases/pages/CsmCaseDetailPage.tsx | 83 +------ .../src/features/csm-cases/types/csmCases.ts | 90 +++++++- .../csm-cases/utils/caseSlaMapping.test.ts | 117 ++++++++++ .../csm-cases/utils/caseSlaMapping.ts | 86 +++++++ .../csm-dashboard/utils/abtDashboard.ts | 16 -- 12 files changed, 670 insertions(+), 208 deletions(-) create mode 100644 apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseSlas.ts create mode 100644 apps/csm-portal/webapp/src/features/csm-cases/components/CaseSlaTable.test.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-cases/components/CaseSlaTable.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-cases/utils/caseSlaMapping.test.ts create mode 100644 apps/csm-portal/webapp/src/features/csm-cases/utils/caseSlaMapping.ts diff --git a/apps/csm-portal/webapp/src/constants/apiConstants.ts b/apps/csm-portal/webapp/src/constants/apiConstants.ts index fcdd91d34e..4219eebf5b 100644 --- a/apps/csm-portal/webapp/src/constants/apiConstants.ts +++ b/apps/csm-portal/webapp/src/constants/apiConstants.ts @@ -89,6 +89,7 @@ export const ApiQueryKeys = { CSM_CASE_DETAIL: "csm-case-detail", CSM_CASE_COMMENTS: "csm-case-comments", CSM_CASE_ATTACHMENTS: "csm-case-attachments", + CSM_CASE_SLAS: "csm-case-slas", CSM_PROJECTS: "csm-projects", CSM_PROJECT_DETAIL: "csm-project-detail", CSM_ACCOUNTS: "csm-accounts", diff --git a/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseDetail.ts b/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseDetail.ts index b6b617f546..f2d07d5551 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseDetail.ts +++ b/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseDetail.ts @@ -26,9 +26,9 @@ import type { CsmCaseDetail } from "@features/csm-cases/types/csmCases"; * Build a CsmCaseDetail from the rich `BeCaseView`. The view embeds the * account / project / deployment / deployed-product / reporter as objects, so * their names (and the account tier) come straight off the response with no - * extra lookups. Side widgets the backend doesn't return yet (SLA clocks, - * watchers, tags, time logs, attachments, linked items) default to empty / - * placeholder values. + * extra lookups. Side widgets the backend doesn't return yet (watchers, tags, + * time logs, attachments, linked items) default to empty / placeholder + * values. */ function detailFromBeCase( c: BeCaseView, @@ -99,7 +99,6 @@ function detailFromBeCase( deploymentId: c.deployment?.id, environment: "prod", }, - slaClocks: [], watchers: [], linkedItems: [], tags: [], diff --git a/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseSlas.ts b/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseSlas.ts new file mode 100644 index 0000000000..372b7b9d94 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseSlas.ts @@ -0,0 +1,62 @@ +// 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 { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import { useBackendApi } from "@api/backend/client"; +import { toCaseSla } from "@features/csm-cases/utils/caseSlaMapping"; +import type { + CaseSlaList, + TaskSlaSearchPayload, + TaskSlaSearchResponse, +} from "@features/csm-cases/types/csmCases"; + +const TASK_SLA_PAGE_LIMIT = 100; + +/** + * Lists the SLA records attached to a case. + * + * Calls the task-SLA search endpoint scoped to this case's id (a case is a + * task) and maps each result onto the {@link CaseSla} row model the SLA table + * renders (see {@link toCaseSla}). Lazy: pass `enabled: false` until the SLA + * tab is actually selected so the request doesn't fire for every case-detail + * load. + */ +export function useGetCsmCaseSlas( + caseId: string | undefined, + options?: { enabled?: boolean }, +): UseQueryResult { + const api = useBackendApi(); + const enabled = (options?.enabled ?? true) && !!caseId; + + return useQuery({ + queryKey: [ApiQueryKeys.CSM_CASE_SLAS, caseId ?? ""], + queryFn: async (): Promise => { + if (!caseId) return null; + const res = await api.post( + "/task-slas/search", + { + filters: { taskIds: [caseId] }, + pagination: { limit: TASK_SLA_PAGE_LIMIT, offset: 0 }, + }, + ); + const slas = (res.taskSlas ?? []).map(toCaseSla); + return { caseId, count: res.total ?? slas.length, slas }; + }, + enabled, + staleTime: 30_000, + }); +} diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx index 2068958bb1..a934251137 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx @@ -59,7 +59,6 @@ const BASE_CASE: CsmCaseDetail = { deployment: "IAM Production", environment: "prod", }, - slaClocks: [], watchers: [], linkedItems: [], tags: [], diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseDetailWidgets.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseDetailWidgets.tsx index d45d61ecd0..0b24457068 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseDetailWidgets.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseDetailWidgets.tsx @@ -57,17 +57,12 @@ import type { CaseCustomerContext, CaseLinkedItem, CaseProductContext, - CaseSlaClock, CaseTag, CaseTimeLogEntry, CaseWatcher, DeploymentCategory, } from "@features/csm-cases/types/csmCases"; import { tierColor, tierLabel } from "@features/csm-cases/utils/caseTier"; -import { - SLA_CLOCK_LABEL, - formatTimeToBreach, -} from "@features/csm-dashboard/utils/abtDashboard"; import RelativeTime from "@components/RelativeTime"; // --------------------------------------------------------------------------- @@ -271,100 +266,6 @@ export function ProductContextWidget({ ); } -// --------------------------------------------------------------------------- -// 3. SLA timeline -// --------------------------------------------------------------------------- - -function clockProgress(c: CaseSlaClock): number { - if (c.state === "met") return 100; - if (c.state === "breached") return 100; - const elapsed = c.targetMinutes - Math.max(0, c.minutesToBreach); - if (c.targetMinutes <= 0) return 0; - return Math.min(100, Math.max(0, (elapsed / c.targetMinutes) * 100)); -} - -const CLOCK_COLOR: Record< - CaseSlaClock["state"], - "primary" | "warning" | "error" | "success" -> = { - running: "primary", - paused: "warning", - met: "success", - breached: "error", -}; - -export function SlaTimelineWidget({ - clocks, -}: { - clocks: CaseSlaClock[]; -}): JSX.Element { - return ( - }> - - {clocks.map((c) => { - const stateText = - c.state === "met" - ? "Met" - : c.state === "breached" - ? formatTimeToBreach(c.minutesToBreach) - : c.state === "paused" - ? "Paused" - : formatTimeToBreach(c.minutesToBreach); - return ( - - - - {SLA_CLOCK_LABEL[c.clockType]} - - - {stateText} - - - - - Target: {formatMinutes(c.targetMinutes)} - - - ); - })} - - - ); -} - -function formatMinutes(m: number): string { - if (m < 60) return `${m}m`; - const h = m / 60; - if (h < 24) return `${h}h`; - return `${Math.round(h / 24)}d`; -} - // --------------------------------------------------------------------------- // 4. Watchers // --------------------------------------------------------------------------- diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseSlaTable.test.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseSlaTable.test.tsx new file mode 100644 index 0000000000..baad3b1a5a --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseSlaTable.test.tsx @@ -0,0 +1,103 @@ +// 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 { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import { CaseSlaTable } from "@features/csm-cases/components/CaseSlaTable"; +import type { CaseSla, CaseSlaList } from "@features/csm-cases/types/csmCases"; +import { useGetCsmCaseSlas } from "@features/csm-cases/api/useGetCsmCaseSlas"; + +vi.mock("@features/csm-cases/api/useGetCsmCaseSlas", () => ({ + useGetCsmCaseSlas: vi.fn(), +})); + +const mockedUseGetCsmCaseSlas = vi.mocked(useGetCsmCaseSlas); + +const SLA_ROW: CaseSla = { + id: "sla-1", + definition: "S1 - Response", + target: "1 Business Hour", + stage: "in_progress", + stageLabel: "In progress", + hasBreached: false, + businessTimeLeftLabel: "30 minutes", + businessElapsedLabel: "30 minutes", + businessElapsedPercent: 50, + startTime: "2026-07-01T10:00:00Z", + stopTime: null, +}; + +function mockResult(overrides: Partial>): void { + mockedUseGetCsmCaseSlas.mockReturnValue({ + data: null, + isLoading: false, + isError: false, + isFetching: false, + dataUpdatedAt: 0, + refetch: vi.fn(), + ...overrides, + } as unknown as ReturnType); +} + +describe("CaseSlaTable", () => { + beforeEach(() => { + mockedUseGetCsmCaseSlas.mockReset(); + }); + + it("renders a loading skeleton while the query is in flight", () => { + mockResult({ isLoading: true }); + render(); + expect(screen.getByText("SLAs")).toBeInTheDocument(); + }); + + it("renders an empty state when there are no SLAs", () => { + const empty: CaseSlaList = { caseId: "case-1", count: 0, slas: [] }; + mockResult({ data: empty }); + render(); + expect(screen.getByText("No SLAs on this case.")).toBeInTheDocument(); + }); + + it("renders SLA rows with definition, stage, and time labels", () => { + const list: CaseSlaList = { caseId: "case-1", count: 1, slas: [SLA_ROW] }; + mockResult({ data: list }); + render(); + expect(screen.getByText("S1 - Response")).toBeInTheDocument(); + expect(screen.getByText("In progress")).toBeInTheDocument(); + expect(screen.getAllByText("30 minutes").length).toBeGreaterThan(0); + expect(screen.getByText("50%")).toBeInTheDocument(); + // Stop time is null → rendered as an em dash. + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("renders an error state with a retry action that refetches", () => { + const refetch = vi.fn(); + mockResult({ isError: true, refetch }); + render(); + expect(screen.getByText("Could not load SLAs for this case.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + expect(refetch).toHaveBeenCalled(); + }); + + it("does not fetch when the tab is inactive", () => { + mockResult({}); + render(); + expect(mockedUseGetCsmCaseSlas).toHaveBeenCalledWith( + "case-1", + { enabled: false }, + ); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseSlaTable.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseSlaTable.tsx new file mode 100644 index 0000000000..4e02ab640a --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseSlaTable.tsx @@ -0,0 +1,213 @@ +// 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 { + Box, + Button, + Card, + Chip, + IconButton, + Skeleton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@wso2/oxygen-ui"; +import { Clock, RefreshCw } from "@wso2/oxygen-ui-icons-react"; +import type { JSX } from "react"; +import { formatAbsoluteForUser } from "@utils/dateTime"; +import { formatRelativeTime } from "@features/csm-dashboard/utils/abtDashboard"; +import { useGetCsmCaseSlas } from "@features/csm-cases/api/useGetCsmCaseSlas"; +import type { CaseSla, SlaStage } from "@features/csm-cases/types/csmCases"; + +const SLA_STAGE_COLOR: Record< + SlaStage, + "info" | "warning" | "success" | "default" | "error" +> = { + in_progress: "info", + paused: "warning", + completed: "success", + cancelled: "default", + breached: "error", +}; + +const SLA_TABLE_COLUMNS = [ + "SLA definition", + "Target", + "Stage", + "Business time left", + "Business elapsed time", + "Business elapsed %", + "Start time", + "Stop time", +]; + +/** Formats a backend UTC timestamp for the SLA table's start/stop columns. */ +function formatSlaDateTime(iso: string | null): string { + if (!iso) return "—"; + return formatAbsoluteForUser(iso) ?? "—"; +} + +/** Chip color for a row: a breached SLA is always shown in error styling. */ +function stageColor(sla: CaseSla): "info" | "warning" | "success" | "default" | "error" { + return sla.hasBreached ? "error" : (SLA_STAGE_COLOR[sla.stage] ?? "default"); +} + +interface CaseSlaTableProps { + caseId: string; + /** Whether the SLA tab is the active tab — gates the (lazy) fetch. */ + active: boolean; +} + +/** + * Table of the case's SLA records, shown on the SLA + * tab of the case detail page. Fetches the case's SLA records lazily: the + * query only runs once `active` is true so switching between tabs doesn't + * fire the request until the SLA tab is actually opened. + */ +export function CaseSlaTable({ caseId, active }: CaseSlaTableProps): JSX.Element { + const { data, isLoading, isError, isFetching, dataUpdatedAt, refetch } = + useGetCsmCaseSlas(caseId, { enabled: active }); + + const slas = data?.slas ?? []; + const count = data?.count ?? slas.length; + + return ( + + {/* Header */} + + + + SLAs + {!isLoading && !isError && ( + + )} + + + {dataUpdatedAt ? ( + + Last refreshed {formatRelativeTime(new Date(dataUpdatedAt).toISOString())} + + ) : null} + void refetch()} + disabled={isFetching} + aria-label="Refresh SLAs" + > + + + + + + {/* Content */} + {isError ? ( + + + Could not load SLAs for this case. + + + + ) : ( + + + + + {SLA_TABLE_COLUMNS.map((col) => ( + {col} + ))} + + + + {isLoading ? ( + [0, 1, 2].map((i) => ( + + {SLA_TABLE_COLUMNS.map((col) => ( + + + + ))} + + )) + ) : slas.length === 0 ? ( + + + + No SLAs on this case. + + + + ) : ( + slas.map((sla) => ( + + {sla.definition} + {sla.target ?? "—"} + + + + {sla.businessTimeLeftLabel} + {sla.businessElapsedLabel} + {`${Math.round(sla.businessElapsedPercent)}%`} + {formatSlaDateTime(sla.startTime)} + {formatSlaDateTime(sla.stopTime)} + + )) + )} + +
+
+ )} +
+ ); +} + +export default CaseSlaTable; diff --git a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx index bef3a5191c..3abda03a35 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx @@ -39,7 +39,6 @@ import { MessageSquarePlus, Paperclip, Phone, - TriangleAlert, X, } from "@wso2/oxygen-ui-icons-react"; import { useCallback, useEffect, useMemo, useState, type JSX } from "react"; @@ -76,14 +75,13 @@ import CaseActivitiesFeed from "@features/csm-cases/components/CaseActivitiesFee import CaseMetaBand from "@features/csm-cases/components/CaseMetaBand"; import { AttachmentsWidget, - AuditTimelineWidget, CustomerContextWidget, LinkedItemsWidget, ProductContextWidget, - SlaTimelineWidget, WatchersWidget, } from "@features/csm-cases/components/CaseDetailWidgets"; import { CallRequestsWidget } from "@features/csm-cases/components/CallRequestsWidget"; +import { CaseSlaTable } from "@features/csm-cases/components/CaseSlaTable"; import CaseTimeCardsPanel from "@features/csm-timecards/components/CaseTimeCardsPanel"; import LogTimeCardDialog from "@features/csm-timecards/components/LogTimeCardDialog"; import { usePostTimeCard } from "@features/csm-timecards/api/useTimeCards"; @@ -95,10 +93,6 @@ import { import { useRecordRecentView } from "@features/csm-recent/hooks/useRecentViews"; import { useIdTokenClaims } from "@hooks/useIdTokenClaims"; import { useErrorBanner } from "@context/error-banner/ErrorBannerContext"; -import { - SLA_CLOCK_LABEL, - formatTimeToBreach, -} from "@features/csm-dashboard/utils/abtDashboard"; import RelativeTime from "@components/RelativeTime"; import SeverityChip from "@components/SeverityChip"; import StateChip from "@components/StateChip"; @@ -239,8 +233,7 @@ const TAB_DEFS: Array<{ }> = [ { id: "activities", label: "Activities", icon: }, { id: "details", label: "Details", icon: }, - // SLA tab is parked until its backend clock data is wired; disabled for now. - { id: "sla", label: "SLA", icon: , disabled: true }, + { id: "sla", label: "SLA", icon: }, { id: "attachments", label: "Attachments", icon: }, { id: "time", label: "Time tracking", icon: }, { id: "call-requests", label: "Call requests", icon: }, @@ -829,13 +822,6 @@ export default function CsmCaseDetailPage(): JSX.Element { // notes. Mirrors the BFF comment guard so the engineer sees a clear reason // instead of a generic error. const publicReplyGateReason = publicCommentGateReason(c.state, c.workState); - const breached = c.minutesToBreach < 0; - // The backend carries no SLA timing yet (LIVE detail leaves slaClocks empty - // and minutesToBreach at 0). Without this guard the header SLA chip reads - // `0 <= 60` and paints every open case orange ("Ack · 0m left"), which is - // both wrong and the main source of orange on the screen. Only show SLA - // affordances when we actually have clock data (mock, and LIVE once wired). - const hasSlaData = c.slaClocks.length > 0; // The case description is already returned by `comments/search` as the // opening comment, so the stream renders it directly — no synthetic entry is // injected (that duplicated the first comment). The linked chat transcript @@ -843,25 +829,6 @@ export default function CsmCaseDetailPage(): JSX.Element { // oldest — sinks below the case comments in the default newest-first view. const safeComments = mergedComments; - // SLA breach is a live condition, not a dismissible notice: surface it in a - // persistent banner under the header so it stays visible on every tab, not - // just the SLA tab. - const breachedClocks = c.slaClocks.filter((k) => k.state === "breached"); - const showBreachBanner = breached || breachedClocks.length > 0; - const breachMessage = - breachedClocks.length > 0 - ? `SLA breached — ${breachedClocks - .map( - (k) => - `${SLA_CLOCK_LABEL[k.clockType]} ${formatTimeToBreach( - k.minutesToBreach, - )}`, - ) - .join(", ")}` - : `SLA breached — ${SLA_CLOCK_LABEL[c.slaClockType]} ${formatTimeToBreach( - c.minutesToBreach, - )}`; - return (