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 && ( - - )} - - ))} - + size="small" + error={Boolean(status.error)} + helperText={status.error ?? status.hint ?? " "} + inputProps={{ min: minLocal }} + /> + {timeslots.length > 1 && ( + + )} + + ); + })} + {timeslots.length < MAX_TIME_SLOTS && ( + + )} 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 {