diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CallRequestRow.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CallRequestRow.tsx
index 02a073956a..8930aff885 100644
--- a/apps/csm-portal/webapp/src/features/csm-cases/components/CallRequestRow.tsx
+++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CallRequestRow.tsx
@@ -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(", ");
}
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 16331ab8bd..8a4e0a91a3 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
@@ -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,
@@ -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();
@@ -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);
diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CreateCallRequestDialog.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CreateCallRequestDialog.tsx
index c620187d21..6605689a98 100644
--- a/apps/csm-portal/webapp/src/features/csm-cases/components/CreateCallRequestDialog.tsx
+++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CreateCallRequestDialog.tsx
@@ -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 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 = {
+ 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;
+ });
}
// ---------------------------------------------------------------------------
@@ -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;
}
@@ -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([""]);
const handleClose = () => {
@@ -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 (
@@ -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 }}
/>
- 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.
- {timeslots.map((slot, i) => (
-
- updateTimeslot(i, e.target.value)}
- fullWidth
- disabled={submitting}
- size="small"
- />
- {timeslots.length > 1 && (
-
- )}
-
- ))}
- }
- onClick={addTimeslot}
- disabled={submitting}
- sx={{ alignSelf: "flex-start", textTransform: "none" }}
- >
- Add another time slot
-
+ size="small"
+ error={Boolean(status.error)}
+ helperText={status.error ?? status.hint ?? " "}
+ inputProps={{ min: minLocal }}
+ />
+ {timeslots.length > 1 && (
+
+ )}
+
+ );
+ })}
+ {timeslots.length < MAX_TIME_SLOTS && (
+ }
+ onClick={addTimeslot}
+ disabled={submitting}
+ sx={{ alignSelf: "flex-start", textTransform: "none" }}
+ >
+ Add another time slot
+
+ )}
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 6e96c206eb..1a50ad0161 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
@@ -1263,7 +1263,7 @@ export default function CsmCaseDetailPage(): JSX.Element {
{activeTab === "call-requests" && caseId && (
-
+
)}
diff --git a/apps/csm-portal/webapp/src/utils/dateTime.ts b/apps/csm-portal/webapp/src/utils/dateTime.ts
index d49d7453eb..864dc09e14 100644
--- a/apps/csm-portal/webapp/src/utils/dateTime.ts
+++ b/apps/csm-portal/webapp/src/utils/dateTime.ts
@@ -203,6 +203,102 @@ export function formatBackendTimestampForDisplay(
return new Intl.DateTimeFormat(locale, { ...options, timeZone }).format(date);
}
+/**
+ * Offset in milliseconds to ADD to a UTC instant to obtain the wall-clock time
+ * shown in `timeZone` at that instant. Positive for zones east of UTC.
+ *
+ * @param utcMs - UTC instant in epoch milliseconds.
+ * @param timeZone - IANA timezone.
+ * @returns {number} Offset in milliseconds.
+ */
+function timeZoneOffsetMs(utcMs: number, timeZone: string): number {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hourCycle: "h23", // 00-23; avoids the h24 midnight "24" edge case
+ }).formatToParts(new Date(utcMs));
+ const get = (t: Intl.DateTimeFormatPartTypes): number =>
+ Number(parts.find((p) => p.type === t)?.value ?? "0");
+ const asUtc = Date.UTC(
+ get("year"),
+ get("month") - 1,
+ get("day"),
+ get("hour"),
+ get("minute"),
+ get("second"),
+ );
+ return asUtc - utcMs;
+}
+
+/**
+ * Interprets a `` wall-clock value ("YYYY-MM-DDTHH:mm")
+ * as being in the resolved user timezone and returns the corresponding UTC ISO
+ * instant. This keeps entry and display symmetric: the user types in their own
+ * timezone, and we store/submit UTC.
+ *
+ * @param localValue - datetime-local input value.
+ * @param explicitTimeZone - Optional timezone override.
+ * @returns {string | null} UTC ISO string, or null when unparseable.
+ */
+export function zonedInputToUtcIso(
+ localValue: string,
+ explicitTimeZone?: string,
+): string | null {
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(
+ localValue.trim(),
+ );
+ if (!m) {
+ const d = new Date(localValue);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+ }
+ const timeZone = resolveDisplayTimeZone(explicitTimeZone);
+ const guessUtc = Date.UTC(
+ Number(m[1]),
+ Number(m[2]) - 1,
+ Number(m[3]),
+ Number(m[4]),
+ Number(m[5]),
+ Number(m[6] ?? "0"),
+ );
+ // Two passes so the offset is correct across a DST boundary.
+ let offset = timeZoneOffsetMs(guessUtc, timeZone);
+ offset = timeZoneOffsetMs(guessUtc - offset, timeZone);
+ return new Date(guessUtc - offset).toISOString();
+}
+
+/**
+ * Formats a UTC instant as a `` wall-clock value
+ * ("YYYY-MM-DDTHH:mm") in the resolved user timezone. Inverse of
+ * {@link zonedInputToUtcIso}; used for the input's `min` attribute.
+ *
+ * @param utcMs - UTC instant in epoch milliseconds.
+ * @param explicitTimeZone - Optional timezone override.
+ * @returns {string} datetime-local value in the resolved timezone.
+ */
+export function utcMsToZonedInputValue(
+ utcMs: number,
+ explicitTimeZone?: string,
+): string {
+ const timeZone = resolveDisplayTimeZone(explicitTimeZone);
+ const parts = new Intl.DateTimeFormat("en-CA", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hourCycle: "h23", // 00-23; avoids the h24 midnight "24" edge case
+ timeZone,
+ }).formatToParts(new Date(utcMs));
+ const get = (t: Intl.DateTimeFormatPartTypes): string =>
+ parts.find((p) => p.type === t)?.value ?? "00";
+ return `${get("year")}-${get("month")}-${get("day")}T${get("hour")}:${get("minute")}`;
+}
+
/**
* Formats a backend (UTC) timestamp as an absolute date-time string in the
* user's resolved timezone, suitable for tooltip text. Format:
@@ -225,17 +321,14 @@ export function formatAbsoluteForUser(
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
- hour12: false,
+ hourCycle: "h23", // 00-23; avoids the h24 midnight "24" edge case
timeZone,
timeZoneName: "shortOffset",
}).formatToParts(date);
const find = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "";
const datePart = `${find("year")}-${find("month")}-${find("day")}`;
- // hour with hour12=false in en-CA returns "00"-"24"; "24" can appear for
- // midnight — normalize to "00".
- const hh = find("hour") === "24" ? "00" : find("hour");
- const timePart = `${hh}:${find("minute")}:${find("second")}`;
+ const timePart = `${find("hour")}:${find("minute")}:${find("second")}`;
const tzPart = find("timeZoneName") || "UTC";
return `${datePart} ${timePart} ${tzPart}`;
} catch {