From 2c439b227313430216c58f7d563a00e0da5541e3 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Tue, 7 Jul 2026 19:13:42 +0530 Subject: [PATCH 1/5] [CSM Portal] webapp: auto-reload a stale tab after a deploy A tab left open across a deploy still holds the old build's chunk hashes; the next lazy-loaded route 404s to the SPA fallback (HTML instead of JS), which the browser rejects as a MIME mismatch. Reload once (session-guarded) on vite:preloadError / a failed dynamic import so the tab picks up the new build instead of erroring out. --- apps/csm-portal/webapp/src/main.tsx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/csm-portal/webapp/src/main.tsx b/apps/csm-portal/webapp/src/main.tsx index b090a3bbea..c500212b61 100644 --- a/apps/csm-portal/webapp/src/main.tsx +++ b/apps/csm-portal/webapp/src/main.tsx @@ -49,6 +49,31 @@ if (typeof window !== "undefined") { } } +// A tab left open across a deploy still holds the old build's chunk hashes; +// fetching one 404s to the SPA fallback (text/html), which Vite reports as +// `vite:preloadError` (and plain `import()` calls reject the same way). Reload +// once to pick up the new build — guarded by a session flag so a genuinely +// broken deployment doesn't reload-loop the tab forever. +if (typeof window !== "undefined") { + const RELOAD_GUARD_KEY = "csm_chunk_reload_once"; + const reloadForNewBuild = (): void => { + let alreadyReloaded = false; + try { + alreadyReloaded = window.sessionStorage.getItem(RELOAD_GUARD_KEY) === "1"; + window.sessionStorage.setItem(RELOAD_GUARD_KEY, "1"); + } catch { + /* sessionStorage may be unavailable; fall through and reload anyway. */ + } + if (!alreadyReloaded) window.location.reload(); + }; + window.addEventListener("vite:preloadError", reloadForNewBuild); + window.addEventListener("unhandledrejection", (event) => { + if (/Failed to fetch dynamically imported module/.test(String(event.reason))) { + reloadForNewBuild(); + } + }); +} + createRoot(document.getElementById("root")!).render( From e525ca9514d1a6e647593c95805b6e5c349b5148 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Tue, 7 Jul 2026 19:14:16 +0530 Subject: [PATCH 2/5] [CSM Portal] case detail: closed-case read-only, friendlier state actions, tab counts - Closed cases are read-only for comments/work notes and new attachments, matching the existing time-tracking gate. - "Change state" transition buttons read as verbs instead of raw state names: "Assign to me" / "Start progress" (depending on current assignee) for Work in progress, "Propose solution", "Request information", "Wait on WSO2". Clicking "Assign to me" claims the case before starting work, instead of only moving the state. - More-actions menu: drop "Escalate to lead" and "Request severity change" (no backend flow), route "Request a call" to the Call requests tab's own create dialog, and only show "Hold auto-closure" while the case is awaiting info or has a solution proposed. - SLAs, Attachments, Time tracking, and Call requests tabs show their item count in the tab label, e.g. "SLAs (3)". --- .../components/CallRequestsWidget.tsx | 11 +- .../components/CaseActionBar.test.tsx | 26 +-- .../csm-cases/components/CaseActionBar.tsx | 62 ++++-- .../csm-cases/pages/CsmCaseDetailPage.tsx | 195 ++++++++++++------ 4 files changed, 198 insertions(+), 96 deletions(-) diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CallRequestsWidget.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CallRequestsWidget.tsx index 70f333b2e3..f5ee690ff4 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CallRequestsWidget.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CallRequestsWidget.tsx @@ -28,7 +28,7 @@ import { Typography, } from "@wso2/oxygen-ui"; import { Phone, Plus, RefreshCw } from "@wso2/oxygen-ui-icons-react"; -import { useState, type JSX } from "react"; +import { useEffect, useState, type JSX } from "react"; import type { BeCallRequestView, BeCallRequestStateKey } from "@api/backend/types"; import type { Severity } from "@features/csm-dashboard/types/abtDashboard"; import { @@ -57,6 +57,9 @@ interface CallRequestsWidgetProps { caseId: string; /** Case severity (S0-S4) — passed to the create dialog to enforce the lead-time rule. */ severity?: Severity; + /** Bump this to open the "Create call request" dialog from outside the + * widget (e.g. the case action bar's "Request a call" item). */ + openCreateSignal?: number; } // --------------------------------------------------------------------------- @@ -66,6 +69,7 @@ interface CallRequestsWidgetProps { export function CallRequestsWidget({ caseId, severity, + openCreateSignal, }: CallRequestsWidgetProps): JSX.Element { // State filter — empty string means "all". Filtering happens server-side // via `filters.states` on the search request. @@ -82,6 +86,11 @@ export function CallRequestsWidget({ const [createOpen, setCreateOpen] = useState(false); const [createError, setCreateError] = useState(null); + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- syncs the dialog open to an external trigger from the case action bar + if (openCreateSignal !== undefined) setCreateOpen(true); + }, [openCreateSignal]); + // Dialog targets — only one dialog is ever open at a time, driven by which // action was clicked on a row. const [scheduleTarget, setScheduleTarget] = useState(null); 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 a934251137..eeed1b8680 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 @@ -92,7 +92,7 @@ describe("CaseActionBar — nextStates-driven buttons", () => { onAction={() => {}} />, ); - expect(screen.getByRole("button", { name: /waiting on wso2/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /wait on wso2/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /^closed$/i })).toBeInTheDocument(); }); @@ -106,9 +106,9 @@ describe("CaseActionBar — nextStates-driven buttons", () => { onAction={() => {}} />, ); - expect(screen.getByRole("button", { name: /solution proposed/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /awaiting info/i })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /waiting on wso2/i })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /propose solution/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /request information/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /wait on wso2/i })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /^closed$/i })).not.toBeInTheDocument(); }); @@ -120,11 +120,11 @@ describe("CaseActionBar — nextStates-driven buttons", () => { onAction={() => {}} />, ); - expect(screen.getByRole("button", { name: /waiting on wso2/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /wait on wso2/i })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /resume/i })).not.toBeInTheDocument(); unmount(); - // ...and from a paused state, the SAME target reads the same — "Waiting on + // ...and from a paused state, the SAME target reads the same — "Wait on // WSO2", not a fabricated "Resume work". render( { onAction={() => {}} />, ); - expect(screen.getByRole("button", { name: /waiting on wso2/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /wait on wso2/i })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /resume/i })).not.toBeInTheDocument(); }); @@ -144,9 +144,9 @@ describe("CaseActionBar — nextStates-driven buttons", () => { onAction={onAction} />, ); - // "Waiting on WSO2" has no confirm dialog, so it dispatches immediately, and + // "Wait on WSO2" has no confirm dialog, so it dispatches immediately, and // the target must be the real backend nextState. - fireEvent.click(screen.getByRole("button", { name: /waiting on wso2/i })); + fireEvent.click(screen.getByRole("button", { name: /wait on wso2/i })); expect(onAction).toHaveBeenCalledWith("wait_on_wso2", "waiting_on_wso2"); }); @@ -177,9 +177,9 @@ describe("CaseActionBar — nextStates-driven buttons", () => { onAction={() => {}} />, ); - expect(screen.queryByRole("button", { name: /solution proposed/i })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /awaiting info/i })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /waiting on wso2/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /propose solution/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /request information/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /wait on wso2/i })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /^closed$/i })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: /more/i })).toBeInTheDocument(); }); @@ -212,7 +212,7 @@ describe("CaseActionBar — nextStates-driven buttons", () => { onAction={() => {}} />, ); - expect(screen.queryByRole("button", { name: /waiting on wso2/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /wait on wso2/i })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /reopened/i })).not.toBeInTheDocument(); // The state-independent "More" overflow is unaffected. expect(screen.getByRole("button", { name: /more/i })).toBeInTheDocument(); diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.tsx index cdbc54e36a..57a8462ee2 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.tsx @@ -41,8 +41,6 @@ import { Phone, Play, Send, - ShieldAlert, - TriangleAlert, User, } from "@wso2/oxygen-ui-icons-react"; import { useState, type JSX } from "react"; @@ -141,12 +139,36 @@ const DEFAULT_TARGET_CONFIG: TargetConfig = { icon: , }; +// Friendlier verbs for the transition buttons than the raw state name reads +// as. `work_in_progress` is special-cased in `buttonFor` since its wording +// (and underlying action) depends on who the case is assigned to. +const TRANSITION_LABEL: Partial> = { + solution_proposed: "Propose solution", + awaiting_info: "Request information", + waiting_on_wso2: "Wait on WSO2", +}; + /** Build the button for a transition into `target`, labelled by the BE state. */ -function buttonFor(target: CaseState): PrimaryButton { +function buttonFor(target: CaseState, caseDetail: CsmCaseDetail): PrimaryButton { + const config = TARGET_CONFIG[target] ?? DEFAULT_TARGET_CONFIG; + if (target === "work_in_progress") { + // Moving into Work in progress reads differently depending on whether the + // case is already the current engineer's: unassigned/someone-else's case + // needs claiming first ("Assign to me"), while an already-own case just + // needs its work started ("Start progress"). `onAction` uses the `action` + // value (not just the target state) to decide whether to PATCH the + // assignee before moving the state. + return { + targetState: target, + ...config, + label: caseDetail.assigneeIsMe ? "Start progress" : "Assign to me", + action: caseDetail.assigneeIsMe ? "start_work" : "assign_to_me", + }; + } return { targetState: target, - label: stateLabel(target), - ...(TARGET_CONFIG[target] ?? DEFAULT_TARGET_CONFIG), + label: TRANSITION_LABEL[target] ?? stateLabel(target), + ...config, }; } @@ -186,18 +208,18 @@ interface SecondaryItem { * The "More" overflow lists state-independent actions on a case. Items here * map to documented use cases — see `UseCases.md`: * - Reassign engineer → ISSU-002 (self-assign generalised) - * - Escalate / Severity change → ISSU-006, ISSU-007 - * - Hold auto-closure → ISSU-027 + * - Hold auto-closure → ISSU-027 (only while awaiting info / solution proposed) * - Create incident / link incident → ISSU-021 * - Raise Git issue → ISSU-020 * - Create task → ISSU-025 - * - Request a call → ISSU-008 + * - Request a call → ISSU-008 (opens the Call requests tab's create dialog) * - Log time → ISSU-017 * - Copy case link → ISSU-010 (per-comment + per-case permalinks) * * Intentionally NOT here: * - Watch / unwatch → managed via the Watchers widget in Details (ISSU-018) * - Open in ServiceNow → this platform replaces ServiceNow; no back-link + * - Escalate to lead / Request severity change → withdrawn, no backend flow planned yet */ function buildSecondaryItems(caseDetail: CsmCaseDetail): SecondaryItem[] { const items: SecondaryItem[] = []; @@ -226,9 +248,17 @@ function buildSecondaryItems(caseDetail: CsmCaseDetail): SecondaryItem[] { const reassignBlocked = caseDetail.state === "work_in_progress" && caseDetail.workState === "ongoing"; - // Only "Copy case link" is wired up for now. The rest are disabled until - // their backend flows land, so the menu advertises the roadmap without - // exposing dead actions that would no-op or toast a mock message. + // Hold auto-closure only makes sense while the case is sitting in a state + // that's subject to auto-closure (awaiting the customer's response) — + // showing it at any other time would offer to hold a closure that isn't + // pending. + const canHoldAutoClose = + caseDetail.state === "awaiting_info" || caseDetail.state === "solution_proposed"; + + // Only "Copy case link", "Request a call", and "Log time" are wired up. + // The rest are disabled until their backend flows land, so the menu + // advertises the roadmap without exposing dead actions that would no-op or + // toast a mock message. items.push( { key: "raise_git_issue", label: "Raise internal Git issue…", icon: , divider: true }, { @@ -241,13 +271,13 @@ function buildSecondaryItems(caseDetail: CsmCaseDetail): SecondaryItem[] { ? "Can't reassign while the case is in progress and ongoing. Pause the work first, or ask the current assignee or a lead to reassign." : undefined, }, - { key: "escalate", label: "Escalate to lead…", icon: , disabled: true }, - { key: "change_severity", label: "Request severity change…", icon: , disabled: true }, - { key: "hold_auto_close", label: "Hold auto-closure…", icon: , divider: true, disabled: true }, + ...(canHoldAutoClose + ? [{ key: "hold_auto_close", label: "Hold auto-closure…", icon: , divider: true }] + : []), { key: "create_incident", label: "Create incident from case…", icon: , disabled: true }, { key: "link_incident", label: "Link to incident…", icon: , divider: true, disabled: true }, { key: "create_task", label: "Create task…", icon: , divider: true, disabled: true }, - { key: "request_call", label: "Request a call…", icon: , disabled: true }, + { key: "request_call", label: "Request a call…", icon: }, { key: "log_time", label: "Log time…", icon: , divider: true }, { key: "copy_link", label: "Copy case link", icon: }, ); @@ -287,7 +317,7 @@ export default function CaseActionBar({ const targets = caseDetail.nextStates ?? []; const lifecycle = [...new Set(targets)] .sort((a, b) => orderRank(a) - orderRank(b)) - .map(buttonFor); + .map((target) => buttonFor(target, caseDetail)); const primary = lifecycle; const secondary = buildSecondaryItems(caseDetail); 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 b7c36f59ed..5331179c27 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 @@ -82,7 +82,9 @@ import { WatchersWidget, } from "@features/csm-cases/components/CaseDetailWidgets"; import { CallRequestsWidget } from "@features/csm-cases/components/CallRequestsWidget"; +import { useGetCsmCaseCallRequests } from "@features/csm-cases/api/useCsmCaseCallRequests"; import { CaseSlaTable } from "@features/csm-cases/components/CaseSlaTable"; +import { useGetCsmCaseSlas } from "@features/csm-cases/api/useGetCsmCaseSlas"; import CaseTimeCardsPanel from "@features/csm-timecards/components/CaseTimeCardsPanel"; import LogTimeCardDialog from "@features/csm-timecards/components/LogTimeCardDialog"; import { usePostTimeCard } from "@features/csm-timecards/api/useTimeCards"; @@ -185,15 +187,12 @@ const FEEDBACK_PALETTE: Record< const SECONDARY_TOAST: Record = { reassign_engineer: "Reassign engineer dialog (mock).", reassign_group: "Reassign group dialog (mock).", - escalate: "Escalation form (mock).", - change_severity: "Severity change request (mock).", hold_auto_close: "Hold auto-closure dialog (mock).", create_incident: "Create incident from case (mock).", link_case: "Link related case picker (mock).", link_incident: "Link to incident picker (mock).", manage_watchers: "Add/remove watcher dialog (mock).", create_task: "Create task dialog (mock).", - request_call: "Request a call dialog (mock).", log_time: "Log time dialog (mock).", copy_link: "Case link copied to clipboard.", }; @@ -234,7 +233,7 @@ const TAB_DEFS: Array<{ }> = [ { id: "activities", label: "Activities", icon: }, { id: "details", label: "Details", icon: }, - { id: "sla", label: "SLA", icon: }, + { id: "sla", label: "SLAs", icon: }, { id: "attachments", label: "Attachments", icon: }, { id: "time", label: "Time tracking", icon: }, { id: "call-requests", label: "Call requests", icon: }, @@ -295,6 +294,11 @@ export default function CsmCaseDetailPage(): JSX.Element { const postAttachment = usePostCsmCaseAttachment(); const downloadAttachment = useDownloadCsmCaseAttachment(); const deleteAttachment = useDeleteCsmCaseAttachment(); + // Fetched unconditionally (not just while their tab is active) purely for + // the tab-label counts below; each widget still runs its own scoped query + // when its tab mounts, deduped against this one by react-query's cache. + const { data: slaList } = useGetCsmCaseSlas(caseId); + const { data: callRequests } = useGetCsmCaseCallRequests(caseId); const patchCase = usePatchCsmCase(caseId); const patchCaseById = usePatchCsmCaseById(); const findMyOngoingCases = useFindMyOngoingCases(); @@ -315,6 +319,9 @@ export default function CsmCaseDetailPage(): JSX.Element { const [composerOpen, setComposerOpen] = useState(false); const [assignOpen, setAssignOpen] = useState(false); const [logTimeOpen, setLogTimeOpen] = useState(false); + // Bumped to pop open the Call requests tab's "Create call request" dialog + // from the action bar's "Request a call" item. + const [requestCallSignal, setRequestCallSignal] = useState(0); const [githubIssueOpen, setGithubIssueOpen] = useState(false); // Inline error shown inside the Git-issue dialog (e.g. the SN routing 422 / // state 409). Cleared when the dialog opens or a submit is retried. @@ -420,6 +427,62 @@ export default function CsmCaseDetailPage(): JSX.Element { }); }, [data, recordView]); + // Starting work: enforce the single-active-case rule. + // 1) look up the engineer's other ongoing cases (abort on failure — we + // must not transition without knowing), 2) move this case to + // work_in_progress, 3) if none ongoing → mark this one ongoing; if some + // exist → ask before pausing them (handled in onConfirmStartWork). Shared + // by the "Start progress" transition and the "Assign to me" shortcut, + // which also puts the case into progress once the assignment lands. + const startWork = useCallback( + async (successMessage: string, successSeverity: FeedbackSeverity) => { + if (!data) return; + const caseId = data.id; + let others: MyOngoingCase[]; + try { + others = await findMyOngoingCases(caseId); + } catch (err) { + // Don't proceed blind: marking this ongoing without knowing the + // other active cases would break the single-active-case rule. + showError( + "Couldn't check your other active cases. Please try again.", + err, + ); + return; + } + try { + await patchCase.mutateAsync({ state: "work_in_progress" }); + } catch (err) { + showError( + "Could not move the case to Work in progress. Please try again.", + err, + ); + return; + } + if (others.length === 0) { + // No competing case → make this the active (ongoing) one. + try { + await patchCase.mutateAsync({ workState: "ongoing" }); + } catch (err) { + showError( + "Moved to Work in progress, but could not mark it ongoing.", + err, + ); + return; + } + setFeedback({ + message: successMessage, + severity: successSeverity, + sticky: true, + }); + return; + } + // Others are ongoing → confirm before pausing them. + setPauseConflict(others); + }, + [data, findMyOngoingCases, patchCase, showError], + ); + const onAction = useCallback( ( action: CaseLifecycleAction | { secondary: string }, @@ -434,56 +497,25 @@ export default function CsmCaseDetailPage(): JSX.Element { ? beStateFromUi(nextState) : LIFECYCLE_TARGET_STATE[action]; - // Starting work: enforce the single-active-case rule. - // 1) look up the engineer's other ongoing cases (abort on failure — we - // must not transition without knowing), 2) move this case to - // work_in_progress, 3) if none ongoing → mark this one ongoing; if some - // exist → ask before pausing them (handled in onConfirmStartWork). + // "Assign to me" on the Change-state button: the case isn't the + // engineer's yet, so claim it (PATCH assigneeEmail) before starting + // work — a plain state PATCH would move it to Work in progress + // without ever making it the clicking engineer's case. + if (action === "assign_to_me" && data && currentUserEmail) { + patchCase.mutate( + { assigneeEmail: currentUserEmail }, + { + onSuccess: () => + void startWork(LIFECYCLE_TOAST.assign_to_me, LIFECYCLE_SEVERITY.assign_to_me), + onError: (err) => + showError("Could not assign the case to you.", err), + }, + ); + return; + } + if (targetState === "work_in_progress" && data) { - const caseId = data.id; - void (async () => { - let others: MyOngoingCase[]; - try { - others = await findMyOngoingCases(caseId); - } catch (err) { - // Don't proceed blind: marking this ongoing without knowing the - // other active cases would break the single-active-case rule. - showError( - "Couldn't check your other active cases. Please try again.", - err, - ); - return; - } - try { - await patchCase.mutateAsync({ state: "work_in_progress" }); - } catch (err) { - showError( - "Could not move the case to Work in progress. Please try again.", - err, - ); - return; - } - if (others.length === 0) { - // No competing case → make this the active (ongoing) one. - try { - await patchCase.mutateAsync({ workState: "ongoing" }); - } catch (err) { - showError( - "Moved to Work in progress, but could not mark it ongoing.", - err, - ); - return; - } - setFeedback({ - message: LIFECYCLE_TOAST[action], - severity: LIFECYCLE_SEVERITY[action], - sticky: true, - }); - return; - } - // Others are ongoing → confirm before pausing them. - setPauseConflict(others); - })(); + void startWork(LIFECYCLE_TOAST[action], LIFECYCLE_SEVERITY[action]); return; } @@ -633,13 +665,29 @@ export default function CsmCaseDetailPage(): JSX.Element { return; } + // Jump to the Call requests tab and pop its own "Create call request" + // dialog, rather than a second/duplicate entry point for the same form. + if (action.secondary === "request_call") { + setActiveTab("call-requests"); + setRequestCallSignal((n) => n + 1); + return; + } + setFeedback({ message: SECONDARY_TOAST[action.secondary] ?? `Action: ${action.secondary}`, severity: "info", sticky: false, }); }, - [data, showError, patchCase, findMyOngoingCases, detailPath], + [ + data, + showError, + patchCase, + findMyOngoingCases, + detailPath, + startWork, + currentUserEmail, + ], ); // Confirm pausing the engineer's other ongoing case(s) and making this case @@ -715,6 +763,10 @@ export default function CsmCaseDetailPage(): JSX.Element { const onUploadAttachment = useCallback( (file: File) => { if (!caseId) return; + if (data?.state === "closed") { + showError("This case is closed — attachments are read-only."); + return; + } postAttachment.mutate( { caseId, file, uploadedBy: engineerName }, { @@ -728,7 +780,7 @@ export default function CsmCaseDetailPage(): JSX.Element { }, ); }, - [caseId, engineerName, postAttachment], + [caseId, engineerName, postAttachment, data, showError], ); const onDownloadAttachment = useCallback( @@ -989,11 +1041,15 @@ export default function CsmCaseDetailPage(): JSX.Element { {TAB_DEFS.map((t) => { // Counts shown only where the tab IS the list (unambiguous). const count = - t.id === "attachments" - ? attachmentList.length - : t.id === "time" - ? c.timeLogs.length - : undefined; + t.id === "sla" + ? slaList?.count + : t.id === "attachments" + ? attachmentList.length + : t.id === "time" + ? c.timeLogs.length + : t.id === "call-requests" + ? callRequests?.length + : undefined; return ( { @@ -1081,6 +1137,7 @@ export default function CsmCaseDetailPage(): JSX.Element { fullWidth variant="outlined" color="inherit" + disabled={isClosed} startIcon={} onClick={() => setComposerOpen(true)} sx={{ @@ -1099,9 +1156,11 @@ export default function CsmCaseDetailPage(): JSX.Element { }, }} > - {publicReplyGateReason - ? "Add an internal work note…" - : "Compose a reply to the customer…"} + {isClosed + ? "This case is closed — comments and work notes are read-only." + : publicReplyGateReason + ? "Add an internal work note…" + : "Compose a reply to the customer…"} )} @@ -1239,7 +1298,7 @@ export default function CsmCaseDetailPage(): JSX.Element { "Could not upload the attachment.") : null } - onUpload={onUploadAttachment} + onUpload={isClosed ? undefined : onUploadAttachment} onDownloadAll={onDownloadAllAttachments} onDownload={onDownloadAttachment} onDelete={setPendingDelete} @@ -1261,7 +1320,11 @@ export default function CsmCaseDetailPage(): JSX.Element { {activeTab === "call-requests" && caseId && ( - + )} From 983db2c767f30c93c7aff9c1c1509aa7dc6739fb Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Tue, 7 Jul 2026 19:22:25 +0530 Subject: [PATCH 3/5] [CSM Portal] fix: Request a call reopened its dialog on every tab remount The "Create call request" dialog was gated on a signal prop that was always defined (started at 0), so simply clicking into the Call requests tab remounted the widget and popped the dialog even without ever using "Request a call". Switched to a one-shot boolean that the widget clears after acting on it, so only an explicit "Request a call" click opens it. --- .../components/CallRequestsWidget.tsx | 22 +++++++++++++------ .../csm-cases/pages/CsmCaseDetailPage.tsx | 12 +++++----- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CallRequestsWidget.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CallRequestsWidget.tsx index f5ee690ff4..41c6b3cf43 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CallRequestsWidget.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CallRequestsWidget.tsx @@ -57,9 +57,13 @@ interface CallRequestsWidgetProps { caseId: string; /** Case severity (S0-S4) — passed to the create dialog to enforce the lead-time rule. */ severity?: Severity; - /** Bump this to open the "Create call request" dialog from outside the - * widget (e.g. the case action bar's "Request a call" item). */ - openCreateSignal?: number; + /** True to pop the "Create call request" dialog from outside the widget + * (e.g. the case action bar's "Request a call" item). One-shot: the + * widget calls `onAutoOpenCreateHandled` once it has acted on it, so the + * caller can drop it back to false — otherwise every remount of this + * widget (e.g. just clicking back onto the tab) would reopen the dialog. */ + autoOpenCreate?: boolean; + onAutoOpenCreateHandled?: () => void; } // --------------------------------------------------------------------------- @@ -69,7 +73,8 @@ interface CallRequestsWidgetProps { export function CallRequestsWidget({ caseId, severity, - openCreateSignal, + autoOpenCreate, + onAutoOpenCreateHandled, }: CallRequestsWidgetProps): JSX.Element { // State filter — empty string means "all". Filtering happens server-side // via `filters.states` on the search request. @@ -87,9 +92,12 @@ export function CallRequestsWidget({ const [createError, setCreateError] = useState(null); useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- syncs the dialog open to an external trigger from the case action bar - if (openCreateSignal !== undefined) setCreateOpen(true); - }, [openCreateSignal]); + if (autoOpenCreate) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- syncs the dialog open to an external one-shot trigger from the case action bar + setCreateOpen(true); + onAutoOpenCreateHandled?.(); + } + }, [autoOpenCreate, onAutoOpenCreateHandled]); // Dialog targets — only one dialog is ever open at a time, driven by which // action was clicked on a row. 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 5331179c27..e4d7b7fa7f 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 @@ -319,9 +319,10 @@ export default function CsmCaseDetailPage(): JSX.Element { const [composerOpen, setComposerOpen] = useState(false); const [assignOpen, setAssignOpen] = useState(false); const [logTimeOpen, setLogTimeOpen] = useState(false); - // Bumped to pop open the Call requests tab's "Create call request" dialog - // from the action bar's "Request a call" item. - const [requestCallSignal, setRequestCallSignal] = useState(0); + // One-shot: true to pop open the Call requests tab's "Create call request" + // dialog from the action bar's "Request a call" item. The widget flips it + // back to false once handled, so switching tabs afterwards doesn't reopen it. + const [autoOpenCallCreate, setAutoOpenCallCreate] = useState(false); const [githubIssueOpen, setGithubIssueOpen] = useState(false); // Inline error shown inside the Git-issue dialog (e.g. the SN routing 422 / // state 409). Cleared when the dialog opens or a submit is retried. @@ -669,7 +670,7 @@ export default function CsmCaseDetailPage(): JSX.Element { // dialog, rather than a second/duplicate entry point for the same form. if (action.secondary === "request_call") { setActiveTab("call-requests"); - setRequestCallSignal((n) => n + 1); + setAutoOpenCallCreate(true); return; } @@ -1323,7 +1324,8 @@ export default function CsmCaseDetailPage(): JSX.Element { setAutoOpenCallCreate(false)} /> )} From 2aaf7d25042f41452c4264c2e8a986d663587d13 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Tue, 7 Jul 2026 19:44:17 +0530 Subject: [PATCH 4/5] [CSM Portal] case detail: address review feedback, Details-tab cleanup, live deployment/account data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (PR #1079): - Broaden the chunk-reload unhandledrejection match to Firefox/Safari wording and call preventDefault() in the vite:preloadError handler. - Guard "assign to me" so a missing signed-in email surfaces an error instead of silently starting work without ever assigning the case. - Extract the duplicated single-active-case conflict logic (startWork / resume-work) into a shared resolveOngoingConflict helper. Dashboard: - Stop calling GET /csm/dashboard — csm-portal-backend has no route for it (confirmed against cmd/server/main.go), so it always 404s. Gated behind the same "not implemented yet" pattern already used for ABT scoping/the dashboard switcher; the header's existing fallback still renders. Case detail — Details tab: - Removed "Assignment group (ABT)" from Identifiers & timestamps. - "Deployment info" now looks up the live deployment (POST /deployments/search by project, matched on deploymentId) for its name/type, instead of only the snapshot embedded in the case-detail payload. - Customer card drops the "Open cases" count and adds subscription type/period, project key, and account-since date via GET /projects/{id}. - Watchers and Linked items are now shown disabled (tooltip explaining why) rather than offering an add/link action with no backing flow. Case detail — overview band: - Fixed the Deployment value overlapping into the Product cell: a + )} + {primary.length > 1 && ( <> @@ -352,20 +426,25 @@ const LINKED_LABEL: Record = { export function LinkedItemsWidget({ items, onLink, + disabled, }: { items: CaseLinkedItem[]; onLink?: () => void; + /** True while linking cases/incidents isn't wired up yet. */ + disabled?: boolean; }): JSX.Element { return ( } + disabledReason={disabled ? "Linking items isn't available yet." : undefined} action={ diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseMetaBand.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseMetaBand.tsx index a007eab12b..70dc35bf98 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseMetaBand.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseMetaBand.tsx @@ -125,6 +125,12 @@ function LinkButton({ noWrap sx={(t) => ({ display: "block", + // ` - } - > - - {watchers.map((w) => ( - - - {initialsOf(w.name)} - - - - {w.name} {w.isMe && (you)} - - - {ROLE_LABEL[w.role]} - - - - ))} - - - ); -} - -// --------------------------------------------------------------------------- -// 5. Linked items -// --------------------------------------------------------------------------- - -const LINKED_ICON: Record = { - case: , - incident: , - escalation: , - kb: , - cr: , - sr: , -}; - -const LINKED_LABEL: Record = { - case: "Case", - incident: "Incident", - escalation: "Escalation", - kb: "KB", - cr: "CR", - sr: "SR", -}; - -export function LinkedItemsWidget({ - items, - onLink, - disabled, -}: { - items: CaseLinkedItem[]; - onLink?: () => void; - /** True while linking cases/incidents isn't wired up yet. */ - disabled?: boolean; -}): JSX.Element { - return ( - } - disabledReason={disabled ? "Linking items isn't available yet." : undefined} - action={ - - } - > - {items.length === 0 ? ( - - Nothing linked yet. - - ) : ( - - {items.map((item) => { - const inner = ( - - {LINKED_ICON[item.kind]} - - - {LINKED_LABEL[item.kind]} · {item.reference} · {item.state} - - - {item.title} - - - {item.href && } - - ); - return item.href ? ( - - {inner} - - ) : ( - {inner} - ); - })} - - )} - - ); -} - -// --------------------------------------------------------------------------- -// 6. Tags +// 3. Tags // --------------------------------------------------------------------------- export function TagsWidget({ @@ -562,7 +339,7 @@ export function TagsWidget({ } // --------------------------------------------------------------------------- -// 7. Time logs +// 4. Time logs // --------------------------------------------------------------------------- export function TimeLogsWidget({ @@ -638,7 +415,7 @@ export function TimeLogsWidget({ } // --------------------------------------------------------------------------- -// 7b. Attachments (all files on the case, newest first) +// 4b. Attachments (all files on the case, newest first) // --------------------------------------------------------------------------- /** @@ -843,7 +620,7 @@ export function AttachmentsWidget({ } // --------------------------------------------------------------------------- -// 8. Audit timeline (lifecycle events, distinct from comments) +// 5. Audit timeline (lifecycle events, distinct from comments) // --------------------------------------------------------------------------- const AUDIT_ICON: Record = { 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 379b0e9af7..7ac396e258 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 @@ -77,9 +77,7 @@ import CaseMetaBand from "@features/csm-cases/components/CaseMetaBand"; import { AttachmentsWidget, CustomerContextWidget, - LinkedItemsWidget, ProductContextWidget, - WatchersWidget, } from "@features/csm-cases/components/CaseDetailWidgets"; import { CallRequestsWidget } from "@features/csm-cases/components/CallRequestsWidget"; import { useGetCsmCaseCallRequests } from "@features/csm-cases/api/useCsmCaseCallRequests"; @@ -191,9 +189,7 @@ const SECONDARY_TOAST: Record = { reassign_group: "Reassign group dialog (mock).", hold_auto_close: "Hold auto-closure dialog (mock).", create_incident: "Create incident from case (mock).", - link_case: "Link related case picker (mock).", link_incident: "Link to incident picker (mock).", - manage_watchers: "Add/remove watcher dialog (mock).", create_task: "Create task dialog (mock).", log_time: "Log time dialog (mock).", copy_link: "Case link copied to clipboard.", @@ -1295,16 +1291,6 @@ export default function CsmCaseDetailPage(): JSX.Element { !!c.productContext.deploymentId && isProjectDeploymentsLoading } /> - onAction({ secondary: "manage_watchers" })} - disabled - /> - onAction({ secondary: "link_case" })} - disabled - /> )}