diff --git a/apps/csm-portal/webapp/src/constants/apiConstants.ts b/apps/csm-portal/webapp/src/constants/apiConstants.ts index 4b0a2521f4..c4dc92b0ab 100644 --- a/apps/csm-portal/webapp/src/constants/apiConstants.ts +++ b/apps/csm-portal/webapp/src/constants/apiConstants.ts @@ -90,6 +90,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..afe8bc3906 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseSlas.ts @@ -0,0 +1,65 @@ +// 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 = 50; // entity caps the page size at 50 + +/** + * 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}). The query only runs while `caseId` is set, + * so callers that mount this hook conditionally (e.g. only while the SLA tab + * is active) get lazy fetching for free. + */ +export function useGetCsmCaseSlas( + caseId: string | undefined, +): UseQueryResult { + const api = useBackendApi(); + const enabled = !!caseId; + + return useQuery({ + queryKey: [ApiQueryKeys.CSM_CASE_SLAS, caseId ?? ""], + queryFn: async (): Promise => { + if (!caseId) return null; + const res = await api.post( + "/slas/search", + { + filters: { taskIds: [caseId] }, + pagination: { limit: TASK_SLA_PAGE_LIMIT, offset: 0 }, + }, + ); + const slas = (res.slas ?? []).map(toCaseSla); + // Count reflects the backend's true total for the case, which may + // exceed the page of rows actually fetched/rendered (page is capped at + // TASK_SLA_PAGE_LIMIT). Fall back to the fetched length if the backend + // omits `total`. + 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..5408c52390 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseSlaTable.test.tsx @@ -0,0 +1,110 @@ +// 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("calls refetch when the header refresh button is clicked", () => { + const refetch = vi.fn(); + const list: CaseSlaList = { caseId: "case-1", count: 1, slas: [SLA_ROW] }; + mockResult({ data: list, refetch }); + render(); + fireEvent.click(screen.getByRole("button", { name: /refresh slas/i })); + expect(refetch).toHaveBeenCalled(); + }); + + it("shows a truncation notice when fewer rows are rendered than the total count", () => { + const list: CaseSlaList = { caseId: "case-1", count: 51, slas: [SLA_ROW] }; + mockResult({ data: list }); + render(); + expect(screen.getByText("Showing first 1 of 51")).toBeInTheDocument(); + }); +}); 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..d1c311eda6 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseSlaTable.tsx @@ -0,0 +1,244 @@ +// 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, + LinearProgress, + 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 } from "@features/csm-cases/types/csmCases"; + +/** The known, closed set of SLA stages that map to a curated chip color. */ +type KnownSlaStage = "in_progress" | "paused" | "completed" | "cancelled" | "breached"; + +const SLA_STAGE_COLOR: Record< + KnownSlaStage, + "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 as KnownSlaStage] ?? "default"); +} + +/** Progress bar color for a row: redder as more of the SLA target is consumed. */ +function slaProgressColor(sla: CaseSla): "error" | "warning" | "success" { + if (sla.hasBreached || sla.businessElapsedPercent >= 100) return "error"; + if (sla.businessElapsedPercent >= 75) return "warning"; + return "success"; +} + +interface CaseSlaTableProps { + caseId: string; +} + +/** + * Table of the case's SLA records, shown on the SLA + * tab of the case detail page. Only ever mounted while the SLA tab is + * active (see the case detail page), so the fetch runs as soon as this + * component renders. + */ +export function CaseSlaTable({ caseId }: CaseSlaTableProps): JSX.Element { + const { data, isLoading, isError, isFetching, dataUpdatedAt, refetch } = + useGetCsmCaseSlas(caseId); + + const slas = data?.slas ?? []; + const count = data?.count ?? slas.length; + const isTruncated = !isLoading && !isError && slas.length < count; + + return ( + + {/* Header */} + + + + SLAs + {!isLoading && !isError && ( + + )} + + + {dataUpdatedAt ? ( + + Last refreshed {formatRelativeTime(new Date(dataUpdatedAt).toISOString())} + + ) : null} + void refetch()} + disabled={isFetching} + aria-label="Refresh SLAs" + > + + + + + + {isTruncated ? ( + + {`Showing first ${slas.length} of ${count}`} + + ) : null} + + {/* 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..75987c9814 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 (