Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions apps/csm-portal/webapp/src/api/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ export type BeCaseState =
| "solution_proposed"
| "closed";

/**
* Work sub-state of a `work_in_progress` case (entity `CaseWorkState`). `null`
* when the case is not in progress. The backend rejects comment creation unless
* the case is `work_in_progress` AND `ongoing`.
*/
export type BeCaseWorkState = "ongoing" | "paused";

export type BeCaseSortField = "created_at" | "updated_at" | "closed_at";

export interface BeCase {
Expand Down Expand Up @@ -138,6 +145,8 @@ export interface BeCaseView {
priority?: BeCasePriority;
issueType?: BeCaseIssueType;
state?: BeCaseState;
/** Work sub-state; only meaningful while `state` is `work_in_progress`. */
workState?: BeCaseWorkState | null;
nextStates?: BeCaseState[];
createdBy?: BeUserRef;
/** The CS engineer the case is assigned to; null when unassigned. */
Expand Down Expand Up @@ -178,12 +187,45 @@ export interface BeCaseCreateResponse {
}

/**
* Request body for `PATCH /cases/{id}`. At least one of `state` / `priority`
* must be set (mirrors the entity `UpdateCaseRequest`).
* Request body for `PATCH /cases/{id}` (mirrors the entity `UpdateCaseRequest`).
* **Exactly one** of `state` / `priority` / `assigneeEmail` / `watchList` is
* sent per call — the backend rejects zero or more than one. Encoded as a
* discriminated union (each variant `?: never`s the others) so the
* exactly-one-field contract is enforced at compile time, not just in docs.
* `assigneeEmail` and `watchList` are supported **only** for the ServiceNow
* data source.
*/
export interface BeCaseUpdatePayload {
export type BeCaseUpdatePayload =
| { state: BeCaseState; priority?: never; assigneeEmail?: never; watchList?: never }
| { state?: never; priority: BeCasePriority; assigneeEmail?: never; watchList?: never }
/** Email of the engineer to assign (ServiceNow only). */
| { state?: never; priority?: never; assigneeEmail: string; watchList?: never }
/** Full replacement watch list as emails (ServiceNow only). */
| { state?: never; priority?: never; assigneeEmail?: never; watchList: string[] };

/** A user in the case watch list, as echoed by `PATCH /cases/{id}`. */
export interface BeWatchListUser {
id: string;
userName: string;
name?: string;
email?: string;
}

/** The mutated case fields echoed by `PATCH /cases/{id}`. */
export interface BeUpdatedCase {
id: string;
updatedOn?: string;
updatedBy?: string;
state?: BeCaseState;
priority?: BeCasePriority;
watchList?: BeWatchListUser[];
assignedTo?: BeEntityRef | null;
}

/** `PATCH /cases/{id}` response: a message plus the mutated case fields. */
export interface BeUpdateCaseResponse {
message?: string;
case: BeUpdatedCase;
}

/** Request body for `POST /cases/search` (the flat, cross-project search). */
Expand Down Expand Up @@ -226,6 +268,8 @@ export interface BeCaseSearchView {
priority?: BeCasePriority;
issueType?: BeCaseIssueType;
state?: BeCaseState;
/** Work sub-state; only meaningful while `state` is `work_in_progress`. */
workState?: BeCaseWorkState | null;
createdOn?: string;
updatedOn?: string;
closedAt?: string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,8 @@ const ALL_EXTRA_CASE_SEEDS: CaseSeed[] = [
projectName: "API Manager",
severity: "S3",
state: "work_in_progress",
// Paused: exercises the comment-gate disabled state in mock mode.
workState: "paused",
assignee: "Dilan W.",
assigneeIsMe: false,
slaClockType: "resolution",
Expand Down Expand Up @@ -455,6 +457,11 @@ function deriveWso2CaseId(seed: CaseSeed): string {
function hydrate(seed: CaseSeed): CsmCaseRow {
return {
...seed,
// Default an in-progress case to `ongoing` so the comment composer is
// usable in mock mode; a seed may set `paused` explicitly. Non-in-progress
// cases have no work sub-state.
workState:
seed.workState ?? (seed.state === "work_in_progress" ? "ongoing" : null),
product: deriveProductName(seed.subject, seed.projectName),
wso2CaseId: deriveWso2CaseId(seed),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ function detailFromBeCase(c: BeCaseView): CsmCaseDetail {
product,
severity: severityFromPriority(c.priority),
state: uiStateFromBe(c.state),
workState: c.workState ?? null,
nextStates: (c.nextStates ?? []).map(uiStateFromBe),
assignee,
// "Is me" needs the signed-in user's entity id, which this mapper doesn't
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export function useGetCsmCases(
product: c.deployedProduct?.displayName ?? "—",
severity: severityFromPriority(c.priority),
state: uiStateFromBe(c.state),
workState: c.workState ?? null,
// No assignee field on the backend yet; surfaced as "Unassigned".
assignee: "Unassigned",
assigneeIsMe: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,38 +21,43 @@ import {
} from "@tanstack/react-query";
import { ApiQueryKeys } from "@constants/apiConstants";
import { isMockMode, useBackendApi } from "@api/backend/client";
import type { BeCase, BeCaseUpdatePayload } from "@api/backend/types";
import type {
BeCaseUpdatePayload,
BeUpdateCaseResponse,
} from "@api/backend/types";

const MOCK_LATENCY_MS = 200;

/**
* Update a case via `PATCH /cases/{id}` — used for state transitions (and
* priority changes). On success it invalidates this case's detail query and the
* cross-project list so both reflect the new state.
* Update a case via `PATCH /cases/{id}` — state transitions, priority changes,
* assignee (`assigneeEmail`), or watch list (`watchList`). The backend requires
* **exactly one** of those fields per call, so callers must not combine them.
* The response is `BeUpdateCaseResponse` ({ message, case }), but on success we
* ignore the body and invalidate this case's detail query and the cross-project
* list so both refetch the authoritative state (incl. fresh `nextStates`).
*
* In MOCK mode the mutation resolves without persisting (mock detail data is
* static), so the caller's optimistic feedback is the only visible effect.
*/
export function usePatchCsmCase(
caseId: string | undefined,
): UseMutationResult<BeCase, Error, BeCaseUpdatePayload> {
): UseMutationResult<BeUpdateCaseResponse, Error, BeCaseUpdatePayload> {
const api = useBackendApi();
const queryClient = useQueryClient();

return useMutation<BeCase, Error, BeCaseUpdatePayload>({
mutationFn: async (input): Promise<BeCase> => {
return useMutation<BeUpdateCaseResponse, Error, BeCaseUpdatePayload>({
mutationFn: async (input): Promise<BeUpdateCaseResponse> => {
if (!caseId) {
throw new Error("Cannot update a case without an id.");
}
if (isMockMode()) {
await new Promise((r) => setTimeout(r, MOCK_LATENCY_MS));
return {
id: caseId,
state: input.state,
priority: input.priority,
};
// The success handler ignores the body and refetches, so the mock only
// needs the id. (Echoing input.state/priority would not type-check
// against the single-field union and isn't used anyway.)
return { message: "Case updated (mock).", case: { id: caseId } };
}
return api.patch<BeCaseUpdatePayload, BeCase>(
return api.patch<BeCaseUpdatePayload, BeUpdateCaseResponse>(
`/cases/${encodeURIComponent(caseId)}`,
input,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// 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 {
Avatar,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
InputAdornment,
TextField,
Typography,
} from "@wso2/oxygen-ui";
import { Search, UserCheck } from "@wso2/oxygen-ui-icons-react";
import { useMemo, useState, type JSX } from "react";
import { useDebouncedValue } from "@hooks/useDebouncedValue";
import { useSearchUsers } from "@features/csm-users/api/useSearchUsers";
import type { User } from "@features/csm-users/types/csmUsers";

interface AssignEngineerDialogProps {
/** Current assignee display name, or "Unassigned". */
currentAssignee?: string;
/** Signed-in engineer's email — enables the "Assign to me" shortcut. */
currentUserEmail?: string;
/** True while a PATCH is in flight; disables the actions. */
isAssigning: boolean;
onClose: () => void;
/** Assign the case to this engineer's email (`PATCH { assigneeEmail }`). */
onAssign: (email: string) => void;
}

function initialsOf(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("");
}

function fullName(u: User): string {
return [u.firstName, u.lastName].filter(Boolean).join(" ").trim() || u.userName;
}

/**
* Pick the engineer to assign a case to. Searches internal users via
* `POST /users/search` and assigns by email through `PATCH /cases/{id}`
* (`assigneeEmail`). Assignment is a ServiceNow-source capability; on a
* Postgres-sourced case the backend rejects it and the caller surfaces the
* error. Mount only while open so the user search isn't issued in the
* background.
*/
export default function AssignEngineerDialog({
currentAssignee,
currentUserEmail,
isAssigning,
onClose,
onAssign,
}: AssignEngineerDialogProps): JSX.Element {
const [input, setInput] = useState("");
const search = useDebouncedValue(input.trim(), 300);
const { data, isFetching, isError } = useSearchUsers({
...(search.length > 0 && { searchQuery: search }),
pagination: { limit: 8, offset: 0 },
});

// Only internal engineers are assignable, and only ones that carry an email
// (the assign call is email-based).
const engineers = useMemo(
() =>
(data?.users ?? []).filter(
(u) => u.userType === "internal" && !!u.email,
),
[data],
);

return (
<Dialog open onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>Assign engineer</DialogTitle>
<DialogContent dividers>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
<Typography variant="body2" color="text.secondary">
Currently assigned to{" "}
<Box component="span" sx={{ fontWeight: 600, color: "text.primary" }}>
{currentAssignee || "Unassigned"}
</Box>
.
</Typography>

{currentUserEmail && (
<Button
variant="outlined"
size="small"
startIcon={<UserCheck size={16} />}
disabled={isAssigning}
onClick={() => onAssign(currentUserEmail)}
sx={{ alignSelf: "flex-start" }}
>
Assign to me
</Button>
)}

<TextField
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Search engineers by name or email…"
size="small"
fullWidth
autoFocus
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search size={16} />
</InputAdornment>
),
}}
/>

<Box sx={{ minHeight: 160 }}>
{isFetching ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
<CircularProgress size={22} />
</Box>
) : isError ? (
<Typography variant="body2" color="error" sx={{ py: 2 }}>
Could not load engineers. Try again.
</Typography>
) : engineers.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
No matching engineers.
</Typography>
) : (
<Box sx={{ display: "flex", flexDirection: "column" }}>
{engineers.map((u) => {
const name = fullName(u);
return (
<Button
key={u.id}
variant="text"
color="inherit"
disabled={isAssigning}
onClick={() => onAssign(u.email)}
sx={{
justifyContent: "flex-start",
textTransform: "none",
px: 1,
py: 0.75,
gap: 1.25,
}}
>
<Avatar sx={{ width: 28, height: 28, fontSize: "0.75rem" }}>
{initialsOf(name)}
</Avatar>
<Box sx={{ minWidth: 0, textAlign: "left" }}>
<Typography variant="body2" sx={{ lineHeight: 1.2 }} noWrap>
{name}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ display: "block" }}
>
{u.email}
</Typography>
</Box>
</Button>
);
})}
</Box>
)}
</Box>

<Typography variant="caption" color="text.secondary">
Assignment applies to ServiceNow-managed cases.
</Typography>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isAssigning}>
Cancel
</Button>
</DialogActions>
</Dialog>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ function buildSecondaryItems(): SecondaryItem[] {
// their backend flows land, so the menu advertises the roadmap without
// exposing dead actions that would no-op or toast a mock message.
return [
{ key: "reassign_engineer", label: "Reassign engineer…", icon: <User size={16} />, disabled: true },
{ key: "reassign_engineer", label: "Assign / reassign engineer…", icon: <User size={16} /> },
{ key: "reassign_group", label: "Reassign to group…", icon: <Users size={16} />, divider: true, disabled: true },
{ key: "escalate", label: "Escalate to lead…", icon: <TriangleAlert size={16} />, disabled: true },
{ key: "change_severity", label: "Request severity change…", icon: <ShieldAlert size={16} />, disabled: true },
Expand Down
Loading