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
Original file line number Diff line number Diff line change
Expand Up @@ -28,25 +28,28 @@ import {
callRequestStateLabel,
} from "@features/csm-cases/utils/callRequestState";
import RelativeTime from "@components/RelativeTime";
import { formatBackendTimestampForDisplay } from "@utils/dateTime";

// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------

// Backend returns these times as UTC wall-clock (unzoned SN format). Convert to
// the user's timezone for display; the timezone name makes the value explicit.
function formatPreferredTimes(times: string[] | undefined): string {
if (!times || times.length === 0) return "—";
return times
.map((t) => {
try {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
}).format(new Date(t)) + " UTC";
} catch {
return t;
}
})
.map(
(t) =>
formatBackendTimestampForDisplay(t, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
}) ?? t,
)
.join(", ");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import { Phone, Plus, RefreshCw } from "@wso2/oxygen-ui-icons-react";
import { useState, type JSX } from "react";
import type { BeCallRequestView, BeCallRequestStateKey } from "@api/backend/types";
import type { Severity } from "@features/csm-dashboard/types/abtDashboard";
import {
useGetCsmCaseCallRequests,
usePostCsmCaseCallRequest,
Expand All @@ -49,13 +50,18 @@ import { CallRequestRow } from "./CallRequestRow";

interface CallRequestsWidgetProps {
caseId: string;
/** Case severity (S0-S4) — passed to the create dialog to enforce the lead-time rule. */
severity?: Severity;
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export function CallRequestsWidget({ caseId }: CallRequestsWidgetProps): JSX.Element {
export function CallRequestsWidget({
caseId,
severity,
}: CallRequestsWidgetProps): JSX.Element {
const { data, isLoading, isError, refetch } = useGetCsmCaseCallRequests(caseId);
const postCallRequest = usePostCsmCaseCallRequest();
const patchCallRequest = usePatchCsmCaseCallRequest();
Expand Down Expand Up @@ -232,6 +238,7 @@ export function CallRequestsWidget({ caseId }: CallRequestsWidgetProps): JSX.Ele
open={createOpen}
submitting={postCallRequest.isPending}
error={createError}
severity={severity}
onClose={() => {
setCreateOpen(false);
setCreateError(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,119 @@ import {
} from "@wso2/oxygen-ui";
import { Plus } from "@wso2/oxygen-ui-icons-react";
import { useState, type JSX } from "react";
import type { Severity } from "@features/csm-dashboard/types/abtDashboard";
import {
resolveDisplayTimeZone,
utcMsToZonedInputValue,
zonedInputToUtcIso,
} from "@utils/dateTime";

// ---------------------------------------------------------------------------
// Helper
// Constants — mirror the backend's call-request contract so we fail in the
// dialog instead of round-tripping to a generic 400.
// ---------------------------------------------------------------------------

/** Parse an ISO datetime local-value from an <input type="datetime-local"> back to UTC ISO. */
function localInputToUtcIso(localValue: string): string {
// datetime-local gives "YYYY-MM-DDTHH:mm" which is treated as local time by Date.
const d = new Date(localValue);
return d.toISOString();
const MIN_DURATION_MINUTES = 15;
const MAX_DURATION_MINUTES = 240;
const MAX_TIME_SLOTS = 3;

/**
* Minimum lead time (minutes) each proposed slot must be in the future, keyed by
* the UI severity scale (S0-S4). Hardcoded mirror of the ServiceNow rule until
* the backend exposes it as a contract. Composed from three backend maps:
* UI Severity -> BeCaseSeverity (mappers.priorityFromSeverity):
* S0=catastrophic, S1=critical, S2=high, S3=medium, S4=low
* BeCaseSeverity -> SN priority id (entity-service snSeverityIDMap):
* catastrophic=14, critical=10, high=11, medium=12, low=13
* SN priority id -> offset (SN CallRequestUtils._PRIORITY_TIME_OFFSETS):
* 14=15, 10=30, 11=60, 12=90, 13=120; null/unknown -> 300
* Caveat: the mapper collapses null-priority cases to S3, so a genuinely
* null-priority case is enforced at 90 min here vs 300 min at the backend
* (lenient: it may still 400, but never falsely blocks a valid time).
* If these backend maps change, this table must change with them.
*/
const LEAD_TIME_MINUTES_BY_SEVERITY: Record<Severity, number> = {
S0: 15,
S1: 30,
S2: 60,
S3: 90,
S4: 120,
};
const DEFAULT_LEAD_TIME_MINUTES = 300;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Human-readable lead time, e.g. 300 -> "5 hours", 90 -> "90 minutes". */
function formatLeadTime(minutes: number): string {
if (minutes % 60 === 0) {
const hours = minutes / 60;
return `${hours} hour${hours === 1 ? "" : "s"}`;
}
return `${minutes} minutes`;
}

/** Earliest acceptable instant (epoch ms) given the required lead time. */
function earliestAllowedMs(leadMinutes: number): number {
return Date.now() + leadMinutes * 60_000;
}

/** UTC instant (epoch ms) for a datetime-local value interpreted in `timeZone`. */
function slotUtcMs(localValue: string, timeZone: string): number {
const iso = zonedInputToUtcIso(localValue, timeZone);
return iso ? new Date(iso).getTime() : NaN;
}

/**
* Validation + UTC-preview state for a single slot. The datetime-local value is
* interpreted in the user's timezone; we show the resolved UTC instant so the
* conversion is explicit.
*/
function slotStatus(
localValue: string,
timeZone: string,
minAllowedMs: number,
leadMinutes: number,
): { error?: string; hint?: string } {
if (!localValue) return {};
const ms = slotUtcMs(localValue, timeZone);
if (Number.isNaN(ms)) return { error: "Invalid date/time." };
if (ms < minAllowedMs) {
return {
error: `Must be at least ${formatLeadTime(leadMinutes)} from now for this case severity.`,
};
}
const utc = new Date(ms).toISOString().slice(0, 16).replace("T", " ");
return { hint: `= ${utc} UTC` };
}

/** Filled slots mapped to future (>= lead time), de-duplicated UTC ISO instants. */
function toFutureUtcTimes(
filledSlots: string[],
timeZone: string,
minAllowedMs: number,
): string[] {
return Array.from(
new Set(
filledSlots
.map((v) => slotUtcMs(v, timeZone))
.filter((ms) => !Number.isNaN(ms) && ms >= minAllowedMs)
.map((ms) => new Date(ms).toISOString()),
),
);
}

/** True if any filled slot is unparseable or earlier than the required lead time. */
function hasInvalidFutureSlot(
filledSlots: string[],
timeZone: string,
minAllowedMs: number,
): boolean {
return filledSlots.some((v) => {
const ms = slotUtcMs(v, timeZone);
return Number.isNaN(ms) || ms < minAllowedMs;
});
}

// ---------------------------------------------------------------------------
Expand All @@ -46,6 +149,8 @@ export interface CreateDialogProps {
open: boolean;
submitting: boolean;
error: string | null;
/** Case severity (S0-S4) — drives the minimum lead time for each proposed slot. */
severity?: Severity;
onClose: () => void;
onSubmit: (reason: string, utcTimes: string[], durationInMinutes: number) => void;
}
Expand All @@ -58,12 +163,13 @@ export function CreateCallRequestDialog({
open,
submitting,
error,
severity,
onClose,
onSubmit,
}: CreateDialogProps): JSX.Element {
const [reason, setReason] = useState("");
const [duration, setDuration] = useState("30");
// Each entry is a datetime-local string from the input.
// Each entry is a datetime-local string entered in the user's timezone.
const [timeslots, setTimeslots] = useState<string[]>([""]);

const handleClose = () => {
Expand All @@ -73,27 +179,43 @@ export function CreateCallRequestDialog({
onClose();
};

const addTimeslot = () => setTimeslots((prev) => [...prev, ""]);
const addTimeslot = () =>
setTimeslots((prev) => (prev.length >= MAX_TIME_SLOTS ? prev : [...prev, ""]));
const removeTimeslot = (i: number) =>
setTimeslots((prev) => prev.filter((_, idx) => idx !== i));
const updateTimeslot = (i: number, val: string) =>
setTimeslots((prev) => prev.map((v, idx) => (idx === i ? val : v)));

// Times are entered in the user's timezone and stored/submitted as UTC.
const timeZone = resolveDisplayTimeZone();
const leadMinutes = severity
? LEAD_TIME_MINUTES_BY_SEVERITY[severity]
: DEFAULT_LEAD_TIME_MINUTES;
const minAllowedMs = earliestAllowedMs(leadMinutes);
const minLocal = utcMsToZonedInputValue(minAllowedMs, timeZone);

const filledSlots = timeslots.filter(Boolean);
const durationNum = parseInt(duration, 10);

// Future (>= lead time), de-duplicated UTC instants — the exact payload sent.
const utcTimes = toFutureUtcTimes(filledSlots, timeZone, minAllowedMs);
const hasInvalidSlot = hasInvalidFutureSlot(filledSlots, timeZone, minAllowedMs);

const durationValid =
!isNaN(durationNum) &&
durationNum >= MIN_DURATION_MINUTES &&
durationNum <= MAX_DURATION_MINUTES;

const canSubmit =
reason.trim().length > 0 &&
filledSlots.length > 0 &&
!isNaN(durationNum) &&
durationNum >= 1;
utcTimes.length > 0 &&
utcTimes.length <= MAX_TIME_SLOTS &&
!hasInvalidSlot &&
durationValid;

const handleSubmit = () => {
if (!canSubmit) return;
onSubmit(
reason.trim(),
filledSlots.map(localInputToUtcIso),
durationNum,
);
onSubmit(reason.trim(), utcTimes, durationNum);
};

return (
Expand Down Expand Up @@ -125,45 +247,60 @@ export function CreateCallRequestDialog({
fullWidth
required
disabled={submitting}
inputProps={{ min: 1 }}
error={duration !== "" && !durationValid}
helperText={`Between ${MIN_DURATION_MINUTES} and ${MAX_DURATION_MINUTES} minutes.`}
inputProps={{ min: MIN_DURATION_MINUTES, max: MAX_DURATION_MINUTES }}
/>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="caption" color="text.secondary">
Preferred times (UTC) — add one or more options
Preferred times — enter in your timezone ({timeZone}); stored as
UTC. Each must be at least {formatLeadTime(leadMinutes)} from now.
Add up to {MAX_TIME_SLOTS} options.
</Typography>
{timeslots.map((slot, i) => (
<Box key={i} sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<TextField
type="datetime-local"
value={slot}
onChange={(e) => updateTimeslot(i, e.target.value)}
fullWidth
disabled={submitting}
size="small"
/>
{timeslots.length > 1 && (
<Button
size="small"
color="inherit"
onClick={() => removeTimeslot(i)}
{timeslots.map((slot, i) => {
const status = slotStatus(slot, timeZone, minAllowedMs, leadMinutes);
return (
<Box
key={i}
sx={{ display: "flex", gap: 1, alignItems: "flex-start" }}
>
<TextField
type="datetime-local"
value={slot}
onChange={(e) => updateTimeslot(i, e.target.value)}
fullWidth
disabled={submitting}
sx={{ minWidth: 0, px: 1 }}
>
Remove
</Button>
)}
</Box>
))}
<Button
size="small"
variant="text"
startIcon={<Plus size={14} />}
onClick={addTimeslot}
disabled={submitting}
sx={{ alignSelf: "flex-start", textTransform: "none" }}
>
Add another time slot
</Button>
size="small"
error={Boolean(status.error)}
helperText={status.error ?? status.hint ?? " "}
inputProps={{ min: minLocal }}
/>
{timeslots.length > 1 && (
<Button
size="small"
color="inherit"
onClick={() => removeTimeslot(i)}
disabled={submitting}
sx={{ minWidth: 0, px: 1, mt: 0.5 }}
>
Remove
</Button>
)}
</Box>
);
})}
{timeslots.length < MAX_TIME_SLOTS && (
<Button
size="small"
variant="text"
startIcon={<Plus size={14} />}
onClick={addTimeslot}
disabled={submitting}
sx={{ alignSelf: "flex-start", textTransform: "none" }}
>
Add another time slot
</Button>
)}
</Box>
</Box>
</DialogContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1263,7 +1263,7 @@ export default function CsmCaseDetailPage(): JSX.Element {

{activeTab === "call-requests" && caseId && (
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: "1fr" }}>
<CallRequestsWidget caseId={caseId} />
<CallRequestsWidget caseId={caseId} severity={c.severity} />
</Box>
)}

Expand Down
Loading