diff --git a/cypress/e2e/patient_spec/patient_discharge.cy.ts b/cypress/e2e/patient_spec/patient_discharge.cy.ts index 34ad423d1e8..242c936730d 100644 --- a/cypress/e2e/patient_spec/patient_discharge.cy.ts +++ b/cypress/e2e/patient_spec/patient_discharge.cy.ts @@ -36,6 +36,7 @@ describe("Patient Discharge based on multiple reason", () => { patientDischarge.clickDischarge(); patientDischarge.selectDischargeReason(patientDischargeReason4); cy.submitButton("Confirm Discharge"); + cy.submitButton("Acknowledge & Submit"); cy.verifyNotification("Patient Discharged Successfully"); cy.closeNotification(); // Verify the consultation dashboard reflection @@ -53,6 +54,7 @@ describe("Patient Discharge based on multiple reason", () => { patientDischarge.typeDischargeNote(patientDeathCause); patientDischarge.typeDoctorName(doctorName); cy.submitButton("Confirm Discharge"); + cy.submitButton("Acknowledge & Submit"); cy.verifyNotification("Patient Discharged Successfully"); cy.closeNotification(); // Verify the consultation dashboard reflection @@ -77,6 +79,7 @@ describe("Patient Discharge based on multiple reason", () => { patientDischarge.typeReferringFacility(referringFreetextFacility); cy.wait(2000); cy.submitButton("Confirm Discharge"); + cy.submitButton("Acknowledge & Submit"); cy.wait(2000); cy.verifyNotification("Patient Discharged Successfully"); cy.closeNotification(); @@ -108,6 +111,7 @@ describe("Patient Discharge based on multiple reason", () => { cy.closeNotification(); // submit the discharge pop-up cy.submitButton("Confirm Discharge"); + cy.submitButton("Acknowledge & Submit"); cy.wait(2000); cy.verifyNotification("Patient Discharged Successfully"); cy.closeNotification(); diff --git a/src/CAREUI/display/NetworkSignal.tsx b/src/CAREUI/display/NetworkSignal.tsx index 91ce6b58b4c..d241a37674a 100644 --- a/src/CAREUI/display/NetworkSignal.tsx +++ b/src/CAREUI/display/NetworkSignal.tsx @@ -45,7 +45,7 @@ export default function NetworkSignal({ strength, children }: Props) { i === 2 && "h-[15px]", // Whether to infill with strength color or not - strength > i ? "bg-current" : "bg-zinc-600", + strength > i ? "bg-current" : "bg-zinc-500/30", )} /> )) diff --git a/src/CAREUI/interactive/KeyboardShortcut.tsx b/src/CAREUI/interactive/KeyboardShortcut.tsx index 06ce149fb51..1d2bebeb316 100644 --- a/src/CAREUI/interactive/KeyboardShortcut.tsx +++ b/src/CAREUI/interactive/KeyboardShortcut.tsx @@ -2,32 +2,50 @@ import useKeyboardShortcut from "use-keyboard-shortcut"; import { classNames, isAppleDevice } from "../../Utils/utils"; interface Props { - children: React.ReactNode; + children?: React.ReactNode; shortcut: string[]; + altShortcuts?: string[][]; onTrigger: () => void; - shortcutSeperator?: string; helpText?: string; tooltipClassName?: string; } export default function KeyboardShortcut(props: Props) { - useKeyboardShortcut(props.shortcut, props.onTrigger, { - overrideSystem: true, - }); + useKeyboardShortcut(props.shortcut, props.onTrigger); + + if (!props.children) { + return null; + } return ( <div className="tooltip"> {props.children} <span className={classNames( - "tooltip-text flex items-center gap-0.5 text-xs", + "tooltip-text space-x-1 text-xs", props.tooltipClassName || "tooltip-bottom", )} > - <span className="px-1 font-bold">{props.helpText}</span> - <kbd className="hidden items-center px-1.5 font-sans font-medium text-zinc-300 shadow md:inline-flex"> - {getShortcutKeyDescription(props.shortcut).join(" + ")} - </kbd> + {props.helpText && ( + <span className="pl-1 font-bold">{props.helpText}</span> + )} + {(props.altShortcuts || [props.shortcut]).map((shortcut, idx, arr) => ( + <> + <kbd className="hidden items-center px-1.5 font-sans font-medium text-zinc-300 shadow lg:inline-flex"> + {shortcut.map((key, idx, keys) => ( + <> + {SHORTCUT_KEY_MAP[key] || key} + {idx !== keys.length - 1 && ( + <span className="px-1 text-zinc-300/60"> + </span> + )} + </> + ))} + </kbd> + {idx !== arr.length - 1 && ( + <span className="text-zinc-300/60">or</span> + )} + </> + ))} </span> </div> ); @@ -43,7 +61,3 @@ const SHORTCUT_KEY_MAP = { ArrowLeft: "←", ArrowRight: "→", } as Record<string, string>; - -export const getShortcutKeyDescription = (shortcut: string[]) => { - return shortcut.map((key) => SHORTCUT_KEY_MAP[key] || key); -}; diff --git a/src/Common/constants.tsx b/src/Common/constants.tsx index 35790e77b16..4a490f34e78 100644 --- a/src/Common/constants.tsx +++ b/src/Common/constants.tsx @@ -46,6 +46,15 @@ export const USER_TYPE_OPTIONS = [ { id: "StateAdmin", role: "State Admin", readOnly: false }, ] as const; +export const USER_LAST_ACTIVE_OPTIONS = [ + { id: 1, text: "24 hours" }, + { id: 7, text: "7 days" }, + { id: 30, text: "30 days" }, + { id: 90, text: "90 days" }, + { id: 365, text: "1 Year" }, + { id: "never", text: "Never" }, +]; + export type UserRole = (typeof USER_TYPE_OPTIONS)[number]["id"]; export const USER_TYPES = USER_TYPE_OPTIONS.map((o) => o.id); diff --git a/src/Common/hooks/useConfirmedAction.ts b/src/Common/hooks/useConfirmedAction.ts new file mode 100644 index 00000000000..ca88a5014ed --- /dev/null +++ b/src/Common/hooks/useConfirmedAction.ts @@ -0,0 +1,17 @@ +import { useState } from "react"; + +export default function useConfirmedAction(action: () => Promise<void>) { + const [showConfirmation, setShowConfirmation] = useState(false); + + return { + requestConfirmation: () => setShowConfirmation(true), + submit: action, + + confirmationProps: { + onClose: () => setShowConfirmation(false), + show: showConfirmation, + onConfirm: action, + action: "Submit", + }, + }; +} diff --git a/src/Components/CameraFeed/AssetBedSelect.tsx b/src/Components/CameraFeed/AssetBedSelect.tsx index 3d7b7ab0951..dafb28d133f 100644 --- a/src/Components/CameraFeed/AssetBedSelect.tsx +++ b/src/Components/CameraFeed/AssetBedSelect.tsx @@ -3,8 +3,11 @@ import { AssetBedModel } from "../Assets/AssetTypes"; import { Listbox, Transition } from "@headlessui/react"; import CareIcon from "../../CAREUI/icons/CareIcon"; import { classNames } from "../../Utils/utils"; +import { dropdownOptionClassNames } from "../Form/MultiSelectMenuV2"; +import ButtonV2 from "../Common/components/ButtonV2"; interface Props { + disabled?: boolean; options: AssetBedModel[]; value?: AssetBedModel; label?: (value: AssetBedModel) => string; @@ -15,34 +18,44 @@ export default function CameraPresetSelect(props: Props) { const label = props.label ?? defaultLabel; return ( <> - <div className="hidden gap-4 whitespace-nowrap pr-2 lg:flex lg:gap-2"> - {/* Desktop View */} + {/* Desktop View */} + <div className="hidden gap-4 whitespace-nowrap pr-2 lg:flex lg:gap-1.5"> {props.options .slice(0, props.options.length > 5 ? 4 : 5) - .map((option) => ( - <button - className={classNames( - "min-w-16 max-w-40 overflow-hidden text-ellipsis whitespace-nowrap rounded-lg border-2 px-2 py-0.5 text-base transition-all duration-200 ease-in-out hover:bg-zinc-600", - props.value?.id === option.id - ? "border-white bg-zinc-100 font-bold text-black" - : "border-zinc-700 font-medium text-zinc-300", - )} - onClick={() => props.onChange?.(option)} - > - {label(option)} - </button> - ))} + .map((option) => { + const selected = props.value?.id === option.id; + + return ( + <ButtonV2 + key={option.id} + variant={selected ? "primary" : "secondary"} + className="min-w-16 max-w-40 text-ellipsis text-sm" + onClick={() => props.onChange?.(option)} + border + size="small" + > + {label(option)} + {selected && ( + <CareIcon + icon="l-check" + className="rounded-full bg-primary-500 text-base text-white" + /> + )} + </ButtonV2> + ); + })} {props.options.length > 5 && ( <CameraPresetDropdown {...props} - placeholder="More preset" + placeholder="More presets" options={props.options.slice(4)} value={props.options.slice(4).find((o) => o.id === props.value?.id)} /> )} </div> + + {/* Mobile View */} <div className="w-full lg:hidden"> - {/* Mobile View */} <CameraPresetDropdown {...props} placeholder="Select preset" /> </div> </> @@ -62,15 +75,15 @@ export const CameraPresetDropdown = ( <Listbox value={selected} onChange={props.onChange} - disabled={options.length === 0} + disabled={options.length === 0 || props.disabled} > <div className="relative flex-1"> <Listbox.Button className={classNames( - "relative min-w-32 max-w-40 overflow-hidden text-ellipsis whitespace-nowrap rounded-lg border-2 px-2 py-1 pr-8 text-left text-sm font-medium transition-all duration-200 ease-in-out hover:bg-zinc-600 focus:outline-none disabled:cursor-not-allowed disabled:bg-transparent disabled:text-zinc-700 md:py-0.5 md:text-base", + "button-size-small button-shape-square relative inline-flex h-min min-w-32 cursor-pointer items-center gap-2 whitespace-pre pr-12 text-left text-sm font-medium shadow outline-offset-1 transition-all duration-200 ease-in-out enabled:hover:shadow-md disabled:cursor-not-allowed disabled:bg-secondary-200 disabled:text-secondary-500 md:min-w-40", selected - ? "border-zinc-700 bg-zinc-700/50 text-white md:font-bold" - : "border-zinc-700 text-zinc-300", + ? "button-primary-default button-primary-border" + : "button-secondary-default button-secondary-border", )} > <span className="block truncate"> @@ -80,38 +93,32 @@ export const CameraPresetDropdown = ( ? label(selected) : props.placeholder} </span> + {selected && ( + <CareIcon + icon="l-check" + className="absolute inset-y-0 right-7 mt-1.5 rounded-full bg-primary-500 text-base text-white" + /> + )} <span className="pointer-events-none absolute inset-y-0 right-0 mr-1 mt-1 flex items-center"> <CareIcon icon="l-angle-down" className="text-xl text-zinc-400" /> </span> </Listbox.Button> <Transition as={Fragment} - leave="transition ease-in duration-100" + leave="transition ease-in duration-300" leaveFrom="opacity-100" leaveTo="opacity-0" > - <Listbox.Options className="absolute z-20 mt-1 max-h-48 w-full overflow-auto rounded-b-lg bg-zinc-900/75 py-1 text-base shadow-lg ring-1 ring-white/5 backdrop-blur-sm focus:outline-none md:max-h-60"> + <Listbox.Options className="absolute z-20 max-h-48 w-full overflow-auto rounded-b-lg bg-white py-1 text-base shadow-lg ring-1 ring-gray-500 focus:outline-none md:max-h-60"> {options?.map((obj) => ( <Listbox.Option key={obj.id} - className={({ active }) => - `relative cursor-default select-none px-2 py-1 ${ - active ? "bg-zinc-700 text-white" : "text-zinc-400" - }` + className={(args) => + classNames(dropdownOptionClassNames(args), "px-2 py-1.5") } value={obj} > - {({ selected }) => ( - <> - <span - className={`block truncate text-sm md:text-base ${ - selected ? "font-bold text-white" : "font-normal" - }`} - > - {label(obj)} - </span> - </> - )} + <span>{label(obj)}</span> </Listbox.Option> ))} </Listbox.Options> diff --git a/src/Components/CameraFeed/CameraFeed.tsx b/src/Components/CameraFeed/CameraFeed.tsx index fdb1ccbc9f9..7c5cf7a8a19 100644 --- a/src/Components/CameraFeed/CameraFeed.tsx +++ b/src/Components/CameraFeed/CameraFeed.tsx @@ -4,20 +4,19 @@ import useOperateCamera, { PTZPayload } from "./useOperateCamera"; import usePlayer from "./usePlayer"; import { getStreamUrl } from "./utils"; import ReactPlayer from "react-player"; -import { classNames, isAppleDevice, isIOS } from "../../Utils/utils"; +import { classNames, isIOS } from "../../Utils/utils"; import FeedAlert, { FeedAlertState } from "./FeedAlert"; import FeedNetworkSignal from "./FeedNetworkSignal"; import NoFeedAvailable from "./NoFeedAvailable"; import FeedControls from "./FeedControls"; import FeedWatermark from "./FeedWatermark"; -import CareIcon from "../../CAREUI/icons/CareIcon"; import useFullscreen from "../../Common/hooks/useFullscreen"; +import useBreakpoints from "../../Common/hooks/useBreakpoints"; interface Props { children?: React.ReactNode; asset: AssetData; preset?: PTZPayload; - silent?: boolean; className?: string; // Callbacks onCameraPresetsObtained?: (presets: Record<string, number>) => void; @@ -27,16 +26,16 @@ interface Props { constrolsDisabled?: boolean; shortcutsDisabled?: boolean; onMove?: () => void; - onReset?: () => void; + operate: ReturnType<typeof useOperateCamera>["operate"]; } export default function CameraFeed(props: Props) { const playerRef = useRef<HTMLVideoElement | ReactPlayer | null>(null); const playerWrapperRef = useRef<HTMLDivElement>(null); const streamUrl = getStreamUrl(props.asset); + const inlineControls = useBreakpoints({ default: false, sm: true }); const player = usePlayer(streamUrl, playerRef); - const operate = useOperateCamera(props.asset.id, props.silent); const [isFullscreen, setFullscreen] = useFullscreen(); const [state, setState] = useState<FeedAlertState>(); @@ -46,7 +45,10 @@ export default function CameraFeed(props: Props) { useEffect(() => { async function move(preset: PTZPayload) { setState("moving"); - const { res } = await operate({ type: "absolute_move", data: preset }); + const { res } = await props.operate({ + type: "absolute_move", + data: preset, + }); setTimeout(() => setState((s) => (s === "moving" ? undefined : s)), 4000); if (res?.status === 500) { setState("host_unreachable"); @@ -62,19 +64,19 @@ export default function CameraFeed(props: Props) { useEffect(() => { if (!props.onCameraPresetsObtained) return; async function getPresets(cb: (presets: Record<string, number>) => void) { - const { res, data } = await operate({ type: "get_presets" }); + const { res, data } = await props.operate({ type: "get_presets" }); if (res?.ok && data) { cb((data as { result: Record<string, number> }).result); } } getPresets(props.onCameraPresetsObtained); - }, [operate, props.onCameraPresetsObtained]); + }, [props.operate, props.onCameraPresetsObtained]); const initializeStream = useCallback(() => { player.initializeStream({ onSuccess: async () => { props.onStreamSuccess?.(); - const { res } = await operate({ type: "get_status" }); + const { res } = await props.operate({ type: "get_status" }); if (res?.status === 500) { setState("host_unreachable"); } @@ -88,31 +90,102 @@ export default function CameraFeed(props: Props) { const resetStream = () => { setState("loading"); - props.onReset?.(); initializeStream(); }; + const controls = !props.constrolsDisabled && ( + <FeedControls + inlineView={inlineControls} + shortcutsDisabled={props.shortcutsDisabled} + isFullscreen={isFullscreen} + setFullscreen={(value) => { + if (!value) { + setFullscreen(false); + return; + } + + if (isIOS) { + const element = document.querySelector("video"); + if (!element) { + return; + } + setFullscreen(true, element, true); + return; + } + + if (!playerRef.current) { + return; + } + + setFullscreen( + true, + playerWrapperRef.current || (playerRef.current as HTMLElement), + true, + ); + }} + onReset={resetStream} + onMove={async (data) => { + props.onMove?.(); + setState("moving"); + const { res } = await props.operate({ type: "relative_move", data }); + setTimeout(() => { + setState((state) => (state === "moving" ? undefined : state)); + }, 4000); + if (res?.status === 500) { + setState("host_unreachable"); + } + }} + /> + ); + return ( <div ref={playerWrapperRef} className="flex flex-col justify-center"> <div className={classNames( - "flex flex-col justify-center overflow-hidden rounded-xl bg-black md:max-h-screen", + "flex max-h-screen flex-col justify-center", props.className, - isAppleDevice && isFullscreen && "px-20", + isFullscreen ? "bg-black" : "bg-zinc-100", + isIOS && isFullscreen && "px-20", )} > - <div className="flex items-center justify-between bg-zinc-900 px-4 pt-1 md:py-2"> - {props.children} + <div + className={classNames( + isFullscreen ? "hidden lg:flex" : "flex", + "items-center justify-between px-4 py-0.5 transition-all duration-500 ease-in-out lg:py-1", + (() => { + if (player.status !== "playing") { + return "bg-black text-zinc-400"; + } + + if (isFullscreen) { + return "bg-zinc-900 text-white"; + } + + return "bg-zinc-500/20 text-zinc-800"; + })(), + )} + > + <div + className={classNames( + player.status !== "playing" + ? "pointer-events-none opacity-10" + : "opacity-100", + "transition-all duration-200 ease-in-out", + )} + > + {props.children} + </div> <div className="flex w-full flex-col items-end justify-end md:flex-row md:items-center md:gap-4"> - <span className="text-xs font-semibold text-white md:text-base"> - <CareIcon - icon="l-video" - className="hidden pr-2 text-lg text-zinc-400 md:inline-block" - /> + <span className="text-xs font-bold md:text-sm"> {props.asset.name} </span> {!isIOS && ( - <div className={state === "loading" ? "animate-pulse" : ""}> + <div + className={classNames( + state === "loading" && "animate-pulse", + "-mr-1 -mt-1 scale-90 md:mt-0 md:scale-100", + )} + > <FeedNetworkSignal playerRef={playerRef as any} playedOn={player.playedOn} @@ -123,8 +196,7 @@ export default function CameraFeed(props: Props) { )} </div> </div> - - <div className="group relative aspect-video"> + <div className="group relative aspect-video bg-black"> {/* Notifications */} <FeedAlert state={state} /> {player.status === "playing" && <FeedWatermark />} @@ -177,7 +249,7 @@ export default function CameraFeed(props: Props) { ) : ( <video onContextMenu={(e) => e.preventDefault()} - className="absolute inset-x-0 mx-auto aspect-video max-h-screen w-full" + className="absolute inset-x-0 mx-auto aspect-video max-h-full w-full" id="mse-video" autoPlay muted @@ -189,52 +261,20 @@ export default function CameraFeed(props: Props) { /> )} - {/* Controls */} - {!props.constrolsDisabled && player.status === "playing" && ( - <FeedControls - shortcutsDisabled={props.shortcutsDisabled} - isFullscreen={isFullscreen} - setFullscreen={(value) => { - if (!value) { - setFullscreen(false); - return; - } - - if (isIOS) { - const element = document.querySelector("video"); - if (!element) { - return; - } - setFullscreen(true, element, true); - return; - } - - if (!playerRef.current) { - return; - } - - setFullscreen( - true, - playerWrapperRef.current || - (playerRef.current as HTMLElement), - true, - ); - }} - onReset={resetStream} - onMove={async (data) => { - props.onMove?.(); - setState("moving"); - const { res } = await operate({ type: "relative_move", data }); - setTimeout(() => { - setState((state) => (state === "moving" ? undefined : state)); - }, 4000); - if (res?.status === 500) { - setState("host_unreachable"); - } - }} - /> - )} + {inlineControls && player.status === "playing" && controls} </div> + {!inlineControls && ( + <div + className={classNames( + "py-4 transition-all duration-500 ease-in-out", + player.status !== "playing" + ? "pointer-events-none px-6 opacity-30" + : "px-12 opacity-100", + )} + > + {controls} + </div> + )} </div> </div> ); diff --git a/src/Components/CameraFeed/CameraFeedWithBedPresets.tsx b/src/Components/CameraFeed/CameraFeedWithBedPresets.tsx index 8ce9c9ef67f..7268397b81a 100644 --- a/src/Components/CameraFeed/CameraFeedWithBedPresets.tsx +++ b/src/Components/CameraFeed/CameraFeedWithBedPresets.tsx @@ -5,6 +5,8 @@ import useQuery from "../../Utils/request/useQuery"; import routes from "../../Redux/api"; import useSlug from "../../Common/hooks/useSlug"; import { CameraPresetDropdown } from "./AssetBedSelect"; +import useOperateCamera from "./useOperateCamera"; +import { classNames } from "../../Utils/utils"; interface Props { asset: AssetData; @@ -18,24 +20,29 @@ export default function LocationFeedTile(props: Props) { query: { limit: 100, facility, asset: props.asset?.id }, }); + const { operate, key } = useOperateCamera(props.asset.id, true); + return ( <CameraFeed asset={props.asset} - silent + key={key} preset={preset?.meta.position} shortcutsDisabled + className="overflow-hidden rounded-lg" + operate={operate} > - <div className="w-64"> - {loading ? ( - <span>loading presets...</span> - ) : ( - <CameraPresetDropdown - options={data?.results ?? []} - value={preset} - onChange={setPreset} - placeholder="Select preset" - /> + <div + className={classNames( + "w-64 transition-all duration-200 ease-in-out", + loading && "pointer-events-none animate-pulse opacity-40", )} + > + <CameraPresetDropdown + options={data?.results ?? []} + value={preset} + onChange={setPreset} + placeholder={loading ? "Fetching presets..." : "Select preset"} + /> </div> </CameraFeed> ); diff --git a/src/Components/CameraFeed/FeedButton.tsx b/src/Components/CameraFeed/FeedButton.tsx index f0e568d4ad4..e2ae2a8fe9e 100644 --- a/src/Components/CameraFeed/FeedButton.tsx +++ b/src/Components/CameraFeed/FeedButton.tsx @@ -4,7 +4,7 @@ import { classNames } from "../../Utils/utils"; interface Props { className?: string; children?: React.ReactNode; - readonly shortcut?: string[]; + shortcuts?: string[][]; onTrigger: () => void; helpText?: string; shortcutsDisabled?: boolean; @@ -15,7 +15,8 @@ export default function FeedButton(props: Props) { const child = ( <button className={classNames( - "flex h-10 w-10 items-center justify-center rounded-lg border border-zinc-700/50 bg-zinc-800/40 shadow-none transition hover:bg-zinc-50 hover:text-zinc-800 hover:shadow hover:backdrop-blur-xl active:shadow-2xl md:backdrop-blur-md", + "flex h-10 w-10 items-center justify-center rounded-lg border shadow-sm transition hover:backdrop-blur-xl sm:shadow-none sm:hover:shadow sm:active:shadow-2xl md:backdrop-blur-md", + "border-zinc-500/30 text-zinc-500 sm:border-zinc-700/50 sm:bg-zinc-800/40 sm:text-zinc-200 sm:hover:bg-zinc-50 sm:hover:text-zinc-800", props.className, )} type="button" @@ -25,18 +26,27 @@ export default function FeedButton(props: Props) { </button> ); - if (props.shortcutsDisabled || !props.shortcut) { + if (props.shortcutsDisabled || !props.shortcuts) { return child; } return ( - <KeyboardShortcut - shortcut={props.shortcut} - onTrigger={props.onTrigger} - helpText={props.helpText} - tooltipClassName={props.tooltipClassName} - > - {child} - </KeyboardShortcut> + <> + {props.shortcuts.map((shortcut, idx) => ( + <KeyboardShortcut + key={idx} + shortcut={shortcut} + onTrigger={props.onTrigger} + helpText={props.helpText} + tooltipClassName={classNames( + props.tooltipClassName, + "hidden lg:inline-flex", + )} + altShortcuts={idx === 0 ? props.shortcuts : undefined} + > + {idx === 0 && child} + </KeyboardShortcut> + ))} + </> ); } diff --git a/src/Components/CameraFeed/FeedControls.tsx b/src/Components/CameraFeed/FeedControls.tsx index 46258114fc0..8cff960505a 100644 --- a/src/Components/CameraFeed/FeedControls.tsx +++ b/src/Components/CameraFeed/FeedControls.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; -import { isAppleDevice } from "../../Utils/utils"; import FeedButton from "./FeedButton"; import CareIcon from "../../CAREUI/icons/CareIcon"; import { PTZPayload } from "./useOperateCamera"; +import { isAppleDevice } from "../../Utils/utils"; const Actions = { UP: 1 << 0, @@ -13,18 +13,7 @@ const Actions = { ZOOM_OUT: 1 << 5, } as const; -const Shortcuts = { - MoveUp: [isAppleDevice ? "Meta" : "Ctrl", "Shift", "ArrowUp"], - MoveLeft: [isAppleDevice ? "Meta" : "Ctrl", "Shift", "ArrowLeft"], - MoveDown: [isAppleDevice ? "Meta" : "Ctrl", "Shift", "ArrowDown"], - MoveRight: [isAppleDevice ? "Meta" : "Ctrl", "Shift", "ArrowRight"], - TogglePrecision: ["Shift", "P"], - ZoomIn: [isAppleDevice ? "Meta" : "Ctrl", "I"], - ZoomOut: [isAppleDevice ? "Meta" : "Ctrl", "O"], - Reset: ["Shift", "R"], - SavePreset: [isAppleDevice ? "Meta" : "Ctrl", "Shift", "S"], - Fullscreen: ["Shift", "F"], -}; +const metaKey = isAppleDevice ? "Meta" : "Control"; export type PTZAction = keyof typeof Actions; @@ -62,6 +51,7 @@ interface Props { isFullscreen: boolean; setFullscreen: (state: boolean) => void; onReset: () => void; + inlineView: boolean; } export default function FeedControls({ shortcutsDisabled, ...props }: Props) { @@ -72,140 +62,187 @@ export default function FeedControls({ shortcutsDisabled, ...props }: Props) { props.onMove(payload(direction, precision)); }; - return ( - <div className="text-white opacity-0 transition-all delay-100 duration-200 ease-in-out group-hover:opacity-100 group-hover:delay-0"> - <div className="absolute bottom-0 right-6 transition-all delay-100 duration-200 ease-in-out group-hover:bottom-1.5 group-hover:delay-0 md:left-8 md:right-auto md:group-hover:bottom-5"> - <ul className="grid scale-75 grid-cols-5 gap-2.5 md:scale-100 md:grid-cols-3 md:gap-1"> - <li className="order-none hidden md:order-1 md:block"> - <FeedButton onTrigger={move(Actions.UP | Actions.LEFT)}> - <CareIcon icon="l-triangle" className="-rotate-45" /> - </FeedButton> - </li> - <li className="order-1 md:order-2"> - <FeedButton - onTrigger={move(Actions.UP)} - helpText="Up" - shortcut={Shortcuts.MoveUp} - shortcutsDisabled={shortcutsDisabled} - tooltipClassName="-translate-y-20 md:translate-y-0" - > - <CareIcon icon="l-triangle" className="rotate-0" /> - </FeedButton> - </li> - <li className="order-none hidden md:order-3 md:block"> - <FeedButton onTrigger={move(Actions.UP | Actions.RIGHT)}> - <CareIcon icon="l-triangle" className="rotate-45" /> - </FeedButton> - </li> - <li className="order-3 md:order-4"> - <FeedButton - onTrigger={move(Actions.LEFT)} - helpText="Left" - shortcut={Shortcuts.MoveLeft} - shortcutsDisabled={shortcutsDisabled} - tooltipClassName="-translate-y-20 -translate-x-1 md:translate-x-0 md:translate-y-0" - > - <CareIcon icon="l-triangle" className="-rotate-90" /> - </FeedButton> - </li> - - <li className="order-last md:order-5"> - <FeedButton - onTrigger={togglePrecision} - helpText="Toggle Precision" - shortcut={Shortcuts.TogglePrecision} - className="font-bold" - shortcutsDisabled={shortcutsDisabled} - tooltipClassName="-translate-y-20 -translate-x-20 md:translate-x-0 md:translate-y-0" - > - {precision}x - </FeedButton> - </li> - - <li className="order-4 md:order-6"> - <FeedButton - onTrigger={move(Actions.RIGHT)} - helpText="Right" - shortcut={Shortcuts.MoveRight} - shortcutsDisabled={shortcutsDisabled} - tooltipClassName="-translate-y-20 -translate-x-2 md:translate-x-0 md:translate-y-0" - > - <CareIcon icon="l-triangle" className="rotate-90" /> - </FeedButton> - </li> - - <li className="order-none hidden md:order-7 md:block"> - <FeedButton onTrigger={move(Actions.DOWN | Actions.LEFT)}> - <CareIcon icon="l-triangle" className="rotate-[-135deg]" /> - </FeedButton> - </li> - - <li className="order-2 md:order-8"> - <FeedButton - onTrigger={move(Actions.DOWN)} - helpText="Down" - shortcut={Shortcuts.MoveDown} - shortcutsDisabled={shortcutsDisabled} - tooltipClassName="-translate-y-20 -translate-x-2 md:-translate-x-14" - > - <CareIcon icon="l-triangle" className="rotate-180" /> - </FeedButton> - </li> - - <li className="order-none hidden md:order-9 md:block"> - <FeedButton onTrigger={move(Actions.DOWN | Actions.RIGHT)}> - <CareIcon icon="l-triangle" className="rotate-[135deg]" /> - </FeedButton> - </li> - </ul> - </div> + const controls = { + position: ( + <> + <FeedButton + onTrigger={move(Actions.UP | Actions.LEFT)} + shortcuts={[["Shift", "7"]]} + shortcutsDisabled={shortcutsDisabled} + helpText="Move Diagonally Up-Left" + tooltipClassName="-translate-y-20" + > + <CareIcon icon="l-triangle" className="-rotate-45" /> + </FeedButton> + + <FeedButton + onTrigger={move(Actions.UP)} + shortcuts={[ + [metaKey, "Shift", "8"], + [metaKey, "Shift", "ArrowUp"], + ]} + shortcutsDisabled={shortcutsDisabled} + tooltipClassName="-translate-y-20 -translate-x-11" + helpText="Move Up" + > + <CareIcon icon="l-triangle" className="rotate-0" /> + </FeedButton> + + <FeedButton + onTrigger={move(Actions.UP | Actions.RIGHT)} + shortcuts={[[metaKey, "Shift", "9"]]} + shortcutsDisabled={shortcutsDisabled} + helpText="Move Diagonally Up-Right" + tooltipClassName="-translate-y-20 -translate-x-24" + > + <CareIcon icon="l-triangle" className="rotate-45" /> + </FeedButton> + + <FeedButton + onTrigger={move(Actions.LEFT)} + shortcuts={[ + [metaKey, "Shift", "4"], + [metaKey, "Shift", "ArrowLeft"], + ]} + shortcutsDisabled={shortcutsDisabled} + helpText="Move Left" + > + <CareIcon icon="l-triangle" className="-rotate-90" /> + </FeedButton> + + <FeedButton + shortcuts={[["Shift", "P"]]} + onTrigger={togglePrecision} + helpText="Toggle Precision" + className="font-bold" + shortcutsDisabled={shortcutsDisabled} + > + {precision}x + </FeedButton> + + <FeedButton + onTrigger={move(Actions.RIGHT)} + shortcuts={[ + [metaKey, "Shift", "6"], + [metaKey, "Shift", "ArrowRight"], + ]} + shortcutsDisabled={shortcutsDisabled} + helpText="Move Right" + tooltipClassName="-translate-y-9 translate-x-11" + > + <CareIcon icon="l-triangle" className="rotate-90" /> + </FeedButton> + + <FeedButton + onTrigger={move(Actions.DOWN | Actions.LEFT)} + shortcuts={[[metaKey, "Shift", "1"]]} + shortcutsDisabled={shortcutsDisabled} + tooltipClassName="-translate-y-20" + helpText="Move Diagonally Down-Left" + > + <CareIcon icon="l-triangle" className="rotate-[-135deg]" /> + </FeedButton> + + <FeedButton + onTrigger={move(Actions.DOWN)} + shortcuts={[ + [metaKey, "Shift", "2"], + [metaKey, "Shift", "ArrowDown"], + ]} + shortcutsDisabled={shortcutsDisabled} + tooltipClassName="-translate-y-20 -translate-x-14" + helpText="Move Down" + > + <CareIcon icon="l-triangle" className="rotate-180" /> + </FeedButton> + + <FeedButton + onTrigger={move(Actions.DOWN | Actions.RIGHT)} + shortcuts={[[metaKey, "Shift", "3"]]} + shortcutsDisabled={shortcutsDisabled} + tooltipClassName="-translate-y-9 translate-x-11" + helpText="Move Diagonally Down-Right" + > + <CareIcon icon="l-triangle" className="rotate-[135deg]" /> + </FeedButton> + </> + ), + zoom: ( + <> + <FeedButton + onTrigger={move(Actions.ZOOM_IN)} + shortcuts={[[metaKey, "I"]]} + shortcutsDisabled={shortcutsDisabled} + tooltipClassName="tooltip-left translate-y-2 translate-x-1" + helpText="Zoom In" + > + <CareIcon icon="l-search-plus" /> + </FeedButton> + <FeedButton + onTrigger={move(Actions.ZOOM_OUT)} + shortcuts={[[metaKey, "O"]]} + shortcutsDisabled={shortcutsDisabled} + tooltipClassName="tooltip-left translate-y-2 translate-x-1" + helpText="Zoom Out" + > + <CareIcon icon="l-search-minus" /> + </FeedButton> + </> + ), + + reset: ( + <FeedButton + onTrigger={props.onReset} + shortcuts={[["Shift", "R"]]} + shortcutsDisabled={shortcutsDisabled} + tooltipClassName="tooltip-left translate-y-2 translate-x-1" + helpText="Reset" + > + <CareIcon icon="l-redo" /> + </FeedButton> + ), + fullscreen: ( + <FeedButton + onTrigger={() => props.setFullscreen(!props.isFullscreen)} + shortcuts={[["Shift", "F"]]} + shortcutsDisabled={shortcutsDisabled} + helpText={props.isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"} + tooltipClassName="tooltip-left translate-y-2 translate-x-1" + > + <CareIcon + icon={ + props.isFullscreen ? "l-compress-arrows" : "l-expand-arrows-alt" + } + /> + </FeedButton> + ), + }; - <div className="absolute -bottom-3 right-0 scale-75 transition-all delay-100 duration-200 ease-in-out group-hover:right-2 group-hover:delay-0 md:bottom-5 md:scale-100 md:group-hover:right-8"> - <div className="flex flex-col items-center justify-center gap-2.5 md:gap-1"> - <FeedButton - shortcut={Shortcuts.ZoomIn} - tooltipClassName="tooltip-left translate-y-2 translate-x-1" - helpText="Zoom In" - onTrigger={move(Actions.ZOOM_IN)} - shortcutsDisabled={shortcutsDisabled} - > - <CareIcon icon="l-search-plus" /> - </FeedButton> - <FeedButton - shortcut={Shortcuts.ZoomOut} - tooltipClassName="tooltip-left translate-y-2 translate-x-1" - helpText="Zoom Out" - onTrigger={move(Actions.ZOOM_OUT)} - shortcutsDisabled={shortcutsDisabled} - > - <CareIcon icon="l-search-minus" /> - </FeedButton> - <FeedButton - shortcut={Shortcuts.Reset} - tooltipClassName="tooltip-left translate-y-2 translate-x-1" - helpText="Reset" - onTrigger={props.onReset} - shortcutsDisabled={shortcutsDisabled} - > - <CareIcon icon="l-redo" /> - </FeedButton> - <FeedButton - shortcut={Shortcuts.Fullscreen} - tooltipClassName="tooltip-left translate-y-2 translate-x-1" - helpText={ - props.isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen" - } - onTrigger={() => props.setFullscreen(!props.isFullscreen)} - shortcutsDisabled={shortcutsDisabled} - > - <CareIcon - icon={ - props.isFullscreen ? "l-compress-arrows" : "l-expand-arrows-alt" - } - /> - </FeedButton> + if (props.inlineView) { + return ( + <div className="text-white opacity-0 transition-all delay-100 duration-200 ease-in-out group-hover:opacity-100 group-hover:delay-0"> + <div className="absolute bottom-0 left-8 transition-all delay-100 duration-200 ease-in-out group-hover:bottom-5 group-hover:delay-0"> + <div className="grid grid-cols-3 gap-1">{controls.position}</div> + </div> + <div className="absolute bottom-5 right-0 transition-all delay-100 duration-200 ease-in-out group-hover:right-8 group-hover:delay-0"> + <div className="flex flex-col items-center justify-center gap-1"> + {controls.zoom} + {controls.reset} + {controls.fullscreen} + </div> </div> </div> + ); + } + + return ( + <div className="flex items-center justify-between gap-3"> + <div className="flex flex-col gap-2">{controls.zoom}</div> + <div className="grid grid-cols-3 gap-2">{controls.position}</div> + <div className="flex flex-col gap-2"> + {controls.reset} + {controls.fullscreen} + </div> </div> ); } diff --git a/src/Components/CameraFeed/useOperateCamera.ts b/src/Components/CameraFeed/useOperateCamera.ts index 259e45fcc89..c0e35f90bf2 100644 --- a/src/Components/CameraFeed/useOperateCamera.ts +++ b/src/Components/CameraFeed/useOperateCamera.ts @@ -1,3 +1,4 @@ +import { useState } from "react"; import request from "../../Utils/request/request"; import { FeedRoutes } from "./routes"; @@ -32,23 +33,47 @@ interface RelativeMoveOperation { data: PTZPayload; } +interface ResetFeedOperation { + type: "reset"; +} + export type OperationAction = | GetStatusOperation | GetPresetsOperation | GoToPresetOperation | AbsoluteMoveOperation - | RelativeMoveOperation; + | RelativeMoveOperation + | ResetFeedOperation; /** * This hook is used to control the PTZ of a camera asset and retrieve other related information. * @param id The external id of the camera asset */ export default function useOperateCamera(id: string, silent = false) { - return (action: OperationAction) => { - return request(FeedRoutes.operateAsset, { - pathParams: { id }, - body: { action }, - silent, - }); + const [key, setKey] = useState(0); + + return { + key, + operate: (action: OperationAction) => { + if (action.type === "reset") { + setKey((prev) => prev + 1); + + return request(FeedRoutes.operateAsset, { + pathParams: { id }, + body: { + action: { + type: "get_status", + }, + }, + silent, + }); + } + + return request(FeedRoutes.operateAsset, { + pathParams: { id }, + body: { action }, + silent, + }); + }, }; } diff --git a/src/Components/Common/components/ButtonV2.tsx b/src/Components/Common/components/ButtonV2.tsx index 2c9579873b1..014a182ba94 100644 --- a/src/Components/Common/components/ButtonV2.tsx +++ b/src/Components/Common/components/ButtonV2.tsx @@ -100,6 +100,8 @@ const ButtonV2 = ({ tooltipClassName, ...props }: ButtonProps) => { + shadow ??= !ghost; + const className = classNames( props.className, "inline-flex h-min cursor-pointer items-center justify-center gap-2 whitespace-pre font-medium outline-offset-1 transition-all duration-200 ease-in-out disabled:cursor-not-allowed disabled:bg-secondary-200 disabled:text-secondary-500", @@ -107,7 +109,7 @@ const ButtonV2 = ({ `button-shape-${circle ? "circle" : "square"}`, ghost ? `button-${variant}-ghost` : `button-${variant}-default`, border && `button-${variant}-border`, - shadow && "shadow enabled:hover:shadow-lg", + shadow && "shadow enabled:hover:shadow-md", tooltip && "tooltip", ); diff --git a/src/Components/Diagnosis/DiagnosesListAccordion.tsx b/src/Components/Diagnosis/DiagnosesListAccordion.tsx index 88222da0570..adae1d8bb7c 100644 --- a/src/Components/Diagnosis/DiagnosesListAccordion.tsx +++ b/src/Components/Diagnosis/DiagnosesListAccordion.tsx @@ -75,6 +75,7 @@ export default function DiagnosesListAccordion(props: Props) { onClick={() => { setIsVisible(false); }} + shadow={false} > <CareIcon icon="l-angle-up" className="h-7" /> Hide Diagnoses diff --git a/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx b/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx index 5d447b79687..34931b303f8 100644 --- a/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx +++ b/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx @@ -8,7 +8,6 @@ import Loading from "../../Common/Loading"; import AssetBedSelect from "../../CameraFeed/AssetBedSelect"; import { triggerGoal } from "../../../Integrations/Plausible"; import useAuthUser from "../../../Common/hooks/useAuthUser"; -import PageTitle from "../../Common/PageTitle"; import useSlug from "../../../Common/hooks/useSlug"; import CareIcon from "../../../CAREUI/icons/CareIcon"; import ButtonV2 from "../../Common/components/ButtonV2"; @@ -18,8 +17,12 @@ import useOperateCamera, { import request from "../../../Utils/request/request"; import { classNames, isIOS } from "../../../Utils/utils"; import ConfirmDialog from "../../Common/ConfirmDialog"; +import useBreakpoints from "../../../Common/hooks/useBreakpoints"; +import { Warn } from "../../../Utils/Notifications"; +import { useTranslation } from "react-i18next"; export const ConsultationFeedTab = (props: ConsultationTabProps) => { + const { t } = useTranslation(); const authUser = useAuthUser(); const facility = useSlug("facility"); const bed = props.consultationData.current_bed?.bed_object; @@ -30,10 +33,23 @@ export const ConsultationFeedTab = (props: ConsultationTabProps) => { useState(false); const [isUpdatingPreset, setIsUpdatingPreset] = useState(false); const [hasMoved, setHasMoved] = useState(false); - const [key, setKey] = useState(0); const divRef = useRef<any>(); - const operate = useOperateCamera(asset?.id ?? "", true); + const suggestOptimalExperience = useBreakpoints({ default: true, sm: false }); + + useEffect(() => { + if (suggestOptimalExperience) { + Warn({ + msg: t( + isIOS + ? "feed_optimal_experience_for_apple_phones" + : "feed_optimal_experience_for_phones", + ), + }); + } + }, []); + + const { key, operate } = useOperateCamera(asset?.id ?? "", true); const { data, loading, refetch } = useQuery(routes.listAssetBeds, { query: { limit: 100, facility, bed: bed?.id, asset: asset?.id }, @@ -108,99 +124,83 @@ export const ConsultationFeedTab = (props: ConsultationTabProps) => { onConfirm={handleUpdatePreset} /> - <div> - <PageTitle - title="Camera Feed" - breadcrumbs={false} - hideBack={true} - focusOnLoad={false} - /> - <span className="mb-2 flex rounded-lg border border-warning-400 bg-warning-100 px-2 py-1 text-sm font-medium text-warning-700 sm:hidden"> - <CareIcon icon="l-exclamation-triangle" className="pr-2 text-base" /> - For better experience, rotate your device. - </span> - <div ref={divRef}> - <CameraFeed - key={key} - asset={asset} - preset={preset?.meta.position} - onMove={() => setHasMoved(true)} - onReset={() => { - if (isIOS) { - setKey(key + 1); - } - }} - onStreamError={() => { - triggerGoal("Camera Feed Viewed", { - consultationId: props.consultationId, - userId: authUser.id, - result: "error", - }); - }} - onStreamSuccess={() => { - triggerGoal("Camera Feed Viewed", { - consultationId: props.consultationId, - userId: authUser.id, - result: "success", - }); - }} - > - <div className="flex items-center"> - {presets ? ( - <> - <AssetBedSelect - options={presets} - label={(obj) => obj.meta.preset_name} - value={preset} - onChange={(value) => { - triggerGoal("Camera Preset Clicked", { - presetName: preset?.meta?.preset_name, - consultationId: props.consultationId, - userId: authUser.id, - result: "success", - }); - setHasMoved(false); - setPreset(value); - }} + <div + ref={divRef} + className={classNames( + "-mx-3 lg:-mb-2", + isIOS && "mt-8", // For some reason iOS based browser alone seems to be needing this. + )} + > + <CameraFeed + key={key} + asset={asset} + preset={preset?.meta.position} + onMove={() => setHasMoved(true)} + operate={operate} + onStreamError={() => { + triggerGoal("Camera Feed Viewed", { + consultationId: props.consultationId, + userId: authUser.id, + result: "error", + }); + }} + onStreamSuccess={() => { + triggerGoal("Camera Feed Viewed", { + consultationId: props.consultationId, + userId: authUser.id, + result: "success", + }); + }} + > + <div className="flex items-center"> + {presets ? ( + <> + <AssetBedSelect + options={presets} + label={(obj) => obj.meta.preset_name} + value={preset} + onChange={(value) => { + triggerGoal("Camera Preset Clicked", { + presetName: preset?.meta?.preset_name, + consultationId: props.consultationId, + userId: authUser.id, + result: "success", + }); + setHasMoved(false); + setPreset(value); + }} + /> + {isUpdatingPreset ? ( + <CareIcon + icon="l-spinner" + className="animate-spin text-base text-zinc-300 md:mx-2" /> - {isUpdatingPreset ? ( - <CareIcon - icon="l-spinner" - className="animate-spin text-base text-zinc-300 md:mx-2" - /> - ) : ( - <ButtonV2 - size="small" - variant="secondary" - disabled={!hasMoved} - className="hover:bg-zinc-700 disabled:bg-transparent" - ghost - tooltip={ - hasMoved - ? "Save current position to selected preset" - : "Change camera position to update preset" - } - tooltipClassName="translate-x-3 translate-y-8 text-xs" - onClick={() => setShowPresetSaveConfirmation(true)} - > - <CareIcon - icon="l-save" - className={classNames( - "text-lg", - hasMoved - ? "text-secondary-200" - : "text-secondary-500", - )} - /> - </ButtonV2> - )} - </> - ) : ( - <span>loading presets...</span> - )} - </div> - </CameraFeed> - </div> + ) : ( + <ButtonV2 + size="small" + variant={hasMoved ? "secondary" : "secondary"} + disabled={!hasMoved} + border + ghost={!hasMoved} + shadow={hasMoved} + tooltip={ + hasMoved + ? "Save current position to selected preset" + : "Change camera position to update preset" + } + tooltipClassName="translate-x-3 translate-y-8 text-xs" + className="ml-1" + onClick={() => setShowPresetSaveConfirmation(true)} + > + <CareIcon icon="l-save" className="text-lg" /> + </ButtonV2> + )} + </> + ) : ( + <span>loading presets...</span> + )} + </div> + </CameraFeed> </div> </> ); diff --git a/src/Components/Facility/ConsultationDetails/index.tsx b/src/Components/Facility/ConsultationDetails/index.tsx index 0b0d2c01a50..52996a3e769 100644 --- a/src/Components/Facility/ConsultationDetails/index.tsx +++ b/src/Components/Facility/ConsultationDetails/index.tsx @@ -205,13 +205,15 @@ export const ConsultationDetails = (props: any) => { } const tabButtonClasses = (selected: boolean) => - `capitalize min-w-max-content cursor-pointer border-transparent text-secondary-700 hover:text-secondary-700 hover:border-secondary-300 font-bold whitespace-nowrap ${ - selected === true ? "border-primary-500 text-primary-600 border-b-2" : "" + `capitalize min-w-max-content cursor-pointer font-bold whitespace-nowrap ${ + selected === true + ? "border-primary-500 hover:border-secondary-300 text-primary-600 border-b-2" + : "text-secondary-700 hover:text-secondary-700" }`; return ( <div> - <div className="px-2 pb-2"> + <div> <nav className="relative flex flex-wrap items-start justify-between"> <PageTitle title="Patient Dashboard" diff --git a/src/Components/Facility/DischargeModal.tsx b/src/Components/Facility/DischargeModal.tsx index 2311ffd7bc4..567c21dfbb6 100644 --- a/src/Components/Facility/DischargeModal.tsx +++ b/src/Components/Facility/DischargeModal.tsx @@ -26,6 +26,8 @@ import { FacilityModel } from "./models"; import dayjs from "../../Utils/dayjs"; import { FieldError } from "../Form/FieldValidators"; import { useTranslation } from "react-i18next"; +import useConfirmedAction from "../../Common/hooks/useConfirmedAction"; +import ConfirmDialog from "../Common/ConfirmDialog"; interface PreDischargeFormInterface { new_discharge_reason: number | null; @@ -60,6 +62,7 @@ const DischargeModal = ({ }: IProps) => { const { t } = useTranslation(); const { enable_hcx } = useConfig(); + const dispatch: any = useDispatch(); const [preDischargeForm, setPreDischargeForm] = useState<PreDischargeFormInterface>({ @@ -130,14 +133,12 @@ const DischargeModal = ({ } }); - const handlePatientDischarge = async (value: boolean) => { - setIsSendingDischargeApi(true); + const validate = () => { if (!new_discharge_reason && !discharge_reason) { setErrors({ ...errors, new_discharge_reason: "Please select a reason for discharge", }); - setIsSendingDischargeApi(false); return; } @@ -155,45 +156,32 @@ const DischargeModal = ({ if (Object.entries(newErrors).length) { setErrors({ ...errors, ...newErrors }); - setIsSendingDischargeApi(false); return; } } - const dischargeDetails = { - ...preDischargeForm, - discharge: value, - referred_to: referred_to?.id ?? preDischargeForm.referred_to, - discharge_date: dayjs(preDischargeForm.discharge_date).toISOString(), - }; - - if (dischargeDetails.referred_to != undefined) - delete dischargeDetails.referred_to_external; - - if (dischargeDetails.referred_to_external != undefined) - delete dischargeDetails.referred_to; + return true; + }; + const submitAction = useConfirmedAction(async () => { + setIsSendingDischargeApi(true); const dischargeResponse = await dispatch( dischargePatient( { ...preDischargeForm, - discharge: value, new_discharge_reason: discharge_reason, discharge_date: dayjs(preDischargeForm.discharge_date).toISOString(), }, { id: consultationData.id }, ), ); - setIsSendingDischargeApi(false); - if (dischargeResponse?.status === 200) { - Notification.Success({ - msg: "Patient Discharged Successfully", - }); + if (dischargeResponse?.status === 200) { + Notification.Success({ msg: "Patient Discharged Successfully" }); afterSubmit?.(); } - }; + }); const handleFacilitySelect = (selected?: FacilityModel) => { setFacility(selected ?? null); @@ -204,210 +192,248 @@ const DischargeModal = ({ })); }; - const encounterDuration = dayjs - .duration( - dayjs( - preDischargeForm[ - discharge_reason === - DISCHARGE_REASONS.find((i) => i.text == "Expired")?.id - ? "death_datetime" - : "discharge_date" - ], - ).diff(consultationData.encounter_date), - ) - .humanize(); + const encounterDuration = dayjs.duration( + dayjs( + preDischargeForm[ + discharge_reason === + DISCHARGE_REASONS.find((i) => i.text == "Expired")?.id + ? "death_datetime" + : "discharge_date" + ], + ).diff(consultationData.encounter_date), + ); + + const confirmationRequired = encounterDuration.asDays() >= 30; return ( - <DialogModal - title={ - <div> - <p>Discharge patient from CARE</p> - <span className="mt-1 flex gap-1 text-sm font-medium text-danger-500"> - <CareIcon icon="l-exclamation-triangle" className="text-base" /> - <p> - {new_discharge_reason === 3 // Expired - ? "Caution: Once a patient is marked as expired, the patient file cannot be transferred or edited. Please proceed with caution." - : "Caution: This action is irrevesible. Please proceed with caution."} - </p> - </span> + <> + <ConfirmDialog + {...submitAction.confirmationProps} + title="Confirm Discharge" + action="Acknowledge & Submit" + variant="warning" + className="md:max-w-xl" + > + <div className="flex flex-col gap-2 py-2 text-secondary-900"> + <p> + Are you sure you want to close this encounter, noting that the + patient has been admitted for{" "} + <span className="font-bold text-black"> + {Math.ceil(encounterDuration.asDays())} days + </span> + {" ?"} + </p> + <p> + By confirming, you acknowledge that no further edits can be made to + this encounter and that the information entered is accurate to the + best of your knowledge. + </p> </div> - } - show={show} - onClose={onClose} - className="md:max-w-3xl" - > - <div className="mt-6 flex flex-col"> - <SelectFormField - required - label="Reason" - name="discharge_reason" - id="discharge_reason" - value={discharge_reason} - disabled={!!new_discharge_reason} - options={DISCHARGE_REASONS} - optionValue={({ id }) => id} - optionLabel={({ text }) => text} - onChange={(e) => - setPreDischargeForm((prev) => ({ - ...prev, - new_discharge_reason: e.value, - })) - } - error={errors?.new_discharge_reason} - /> - {discharge_reason === - DISCHARGE_REASONS.find((i) => i.text == "Referred")?.id && ( - <div id="facility-referredto"> - <FieldLabel>Referred to</FieldLabel> - <FacilitySelect - name="referred_to" - setSelected={(selected) => - handleFacilitySelect(selected as FacilityModel | undefined) - } - disabled={!!referred_to} - selected={facility ?? null} - showAll - freeText - multiple={false} - errors={errors?.referred_to} - className="mb-4" - /> + </ConfirmDialog> + <DialogModal + title={ + <div> + <p>Discharge patient from CARE</p> + <span className="mt-1 flex gap-1 text-sm font-medium text-warning-500"> + <CareIcon icon="l-exclamation-triangle" className="text-base" /> + <p> + {t("caution")}: {t("action_irreversible")} + </p> + </span> </div> - )} - <TextAreaFormField - required={ - discharge_reason == - DISCHARGE_REASONS.find((i) => i.text == "Expired")?.id - } - label={ - { - "3": "Cause of death", - "1": "Discharged Advice", - }[discharge_reason ?? 0] ?? "Notes" - } - name="discharge_notes" - value={preDischargeForm.discharge_notes} - onChange={(e) => - setPreDischargeForm((prev) => ({ - ...prev, - discharge_notes: e.value, - })) + } + show={show} + onClose={() => { + if (!submitAction.confirmationProps.show) { + onClose(); } - error={errors?.discharge_notes} - /> - <TextFormField - name={ - discharge_reason === - DISCHARGE_REASONS.find((i) => i.text == "Expired")?.id - ? "death_datetime" - : "discharge_date" - } - label={ - discharge_reason === - DISCHARGE_REASONS.find((i) => i.text == "Expired")?.id - ? "Date of Death" - : "Date and Time of Discharge" - } - type="datetime-local" - value={ - preDischargeForm[ + }} + className="md:max-w-3xl" + > + <div className="mt-6 flex flex-col"> + <SelectFormField + required + label="Reason" + name="discharge_reason" + id="discharge_reason" + value={discharge_reason} + disabled={!!new_discharge_reason} + options={DISCHARGE_REASONS} + optionValue={({ id }) => id} + optionLabel={({ text }) => text} + onChange={(e) => + setPreDischargeForm((prev) => ({ + ...prev, + new_discharge_reason: e.value, + })) + } + error={errors?.new_discharge_reason} + /> + {discharge_reason === + DISCHARGE_REASONS.find((i) => i.text == "Referred")?.id && ( + <div id="facility-referredto"> + <FieldLabel>Referred to</FieldLabel> + <FacilitySelect + name="referred_to" + setSelected={(selected) => + handleFacilitySelect(selected as FacilityModel | undefined) + } + disabled={!!referred_to} + selected={facility ?? null} + showAll + freeText + multiple={false} + errors={errors?.referred_to} + className="mb-4" + /> + </div> + )} + <TextAreaFormField + required={ + discharge_reason == + DISCHARGE_REASONS.find((i) => i.text == "Expired")?.id + } + label={ + { + "3": "Cause of death", + "1": "Discharged Advice", + }[discharge_reason ?? 0] ?? "Notes" + } + name="discharge_notes" + value={preDischargeForm.discharge_notes} + onChange={(e) => + setPreDischargeForm((prev) => ({ + ...prev, + discharge_notes: e.value, + })) + } + error={errors?.discharge_notes} + /> + <TextFormField + name={ discharge_reason === DISCHARGE_REASONS.find((i) => i.text == "Expired")?.id ? "death_datetime" : "discharge_date" - ] - } - onChange={(e) => { - const updates: Record<string, string | undefined> = { - discharge_date: undefined, - death_datetime: undefined, - }; - updates[e.name] = e.value; - setPreDischargeForm((form) => ({ ...form, ...updates })); - }} - required - min={dayjs(consultationData?.encounter_date).format( - "YYYY-MM-DDTHH:mm", - )} - max={dayjs().format("YYYY-MM-DDTHH:mm")} - error={ - discharge_reason === - DISCHARGE_REASONS.find((i) => i.text == "Expired")?.id - ? errors?.death_datetime - : errors?.discharge_date - } - /> - {discharge_reason === - DISCHARGE_REASONS.find((i) => i.text == "Recovered")?.id && ( - <> - <div className="mb-4"> - <FieldLabel>Discharge Prescription Medications</FieldLabel> - <PrescriptionBuilder prescription_type="DISCHARGE" /> - </div> - <div className="mb-4"> - <FieldLabel>Discharge PRN Prescriptions</FieldLabel> - <PrescriptionBuilder prescription_type="DISCHARGE" is_prn /> - </div> - </> - )} - {discharge_reason === - DISCHARGE_REASONS.find((i) => i.text == "Expired")?.id && ( - <TextFormField - name="death_confirmed_by" - label="Confirmed By" - error={errors.death_confirmed_doctor} - value={preDischargeForm.death_confirmed_doctor ?? ""} + } + label={ + discharge_reason === + DISCHARGE_REASONS.find((i) => i.text == "Expired")?.id + ? "Date of Death" + : "Date and Time of Discharge" + } + type="datetime-local" + value={ + preDischargeForm[ + discharge_reason === + DISCHARGE_REASONS.find((i) => i.text == "Expired")?.id + ? "death_datetime" + : "discharge_date" + ] + } onChange={(e) => { - setPreDischargeForm((form) => { - return { - ...form, - death_confirmed_doctor: e.value, - }; - }); + const updates: Record<string, string | undefined> = { + discharge_date: undefined, + death_datetime: undefined, + }; + updates[e.name] = e.value; + setPreDischargeForm((form) => ({ ...form, ...updates })); }} required - placeholder="Attending Doctor's Name and Designation" + min={dayjs(consultationData?.encounter_date).format( + "YYYY-MM-DDTHH:mm", + )} + max={dayjs().format("YYYY-MM-DDTHH:mm")} + error={ + discharge_reason === + DISCHARGE_REASONS.find((i) => i.text == "Expired")?.id + ? errors?.death_datetime + : errors?.discharge_date + } /> + {discharge_reason === + DISCHARGE_REASONS.find((i) => i.text == "Recovered")?.id && ( + <> + <div className="mb-4"> + <FieldLabel>Discharge Prescription Medications</FieldLabel> + <PrescriptionBuilder prescription_type="DISCHARGE" /> + </div> + <div className="mb-4"> + <FieldLabel>Discharge PRN Prescriptions</FieldLabel> + <PrescriptionBuilder prescription_type="DISCHARGE" is_prn /> + </div> + </> + )} + {discharge_reason === + DISCHARGE_REASONS.find((i) => i.text == "Expired")?.id && ( + <TextFormField + name="death_confirmed_by" + label="Confirmed By" + error={errors.death_confirmed_doctor} + value={preDischargeForm.death_confirmed_doctor ?? ""} + onChange={(e) => { + setPreDischargeForm((form) => { + return { + ...form, + death_confirmed_doctor: e.value, + }; + }); + }} + required + placeholder="Attending Doctor's Name and Designation" + /> + )} + </div> + + {enable_hcx && ( + // TODO: if policy and approved pre-auth exists + <div className="my-5 rounded p-5 shadow"> + <h2 className="mb-2">Claim Insurance</h2> + {latestClaim ? ( + <ClaimDetailCard claim={latestClaim} /> + ) : ( + <CreateClaimCard + consultationId={consultationData.id ?? ""} + patientId={consultationData.patient ?? ""} + use="claim" + isCreating={isCreateClaimLoading} + setIsCreating={setIsCreateClaimLoading} + /> + )} + </div> )} - </div> - {enable_hcx && ( - // TODO: if policy and approved pre-auth exists - <div className="my-5 rounded p-5 shadow"> - <h2 className="mb-2">Claim Insurance</h2> - {latestClaim ? ( - <ClaimDetailCard claim={latestClaim} /> + <div className="py-4"> + <span className="text-secondary-700"> + {t("encounter_duration_confirmation")}{" "} + <strong>{encounterDuration.humanize()}</strong>. + </span> + </div> + <div className="cui-form-button-group"> + <Cancel onClick={onClose} /> + {isSendingDischargeApi ? ( + <CircularProgress /> ) : ( - <CreateClaimCard - consultationId={consultationData.id ?? ""} - patientId={consultationData.patient ?? ""} - use="claim" - isCreating={isCreateClaimLoading} - setIsCreating={setIsCreateClaimLoading} + <Submit + onClick={async () => { + if (!validate()) { + return; + } + + if (confirmationRequired) { + submitAction.requestConfirmation(); + return; + } + + submitAction.submit(); + }} + label="Confirm Discharge" + autoFocus /> )} </div> - )} - - <div className="py-4"> - <span className="text-secondary-700"> - {t("encounter_duration_confirmation")}{" "} - <strong>{encounterDuration}</strong>. - </span> - </div> - <div className="cui-form-button-group"> - <Cancel onClick={onClose} /> - {isSendingDischargeApi ? ( - <CircularProgress /> - ) : ( - <Submit - onClick={() => handlePatientDischarge(false)} - label="Confirm Discharge" - autoFocus - /> - )} - </div> - </DialogModal> + </DialogModal> + </> ); }; diff --git a/src/Components/Facility/DischargedPatientsList.tsx b/src/Components/Facility/DischargedPatientsList.tsx index 5d7a3c2975c..5a47a8ad6da 100644 --- a/src/Components/Facility/DischargedPatientsList.tsx +++ b/src/Components/Facility/DischargedPatientsList.tsx @@ -480,7 +480,7 @@ export default DischargedPatientsList; const PatientListItem = ({ patient }: { patient: PatientModel }) => { return ( <div className="flex rounded-lg border bg-white p-5 shadow hover:ring-1 hover:ring-primary-400"> - <div className="bg-secondary-50 flex rounded border border-secondary-300 p-6"> + <div className="flex rounded border border-secondary-300 bg-secondary-50 p-6"> <CareIcon icon="l-user-injured" className="text-3xl text-secondary-800" diff --git a/src/Components/Facility/FacilityHome.tsx b/src/Components/Facility/FacilityHome.tsx index f53fa43efa8..0a885c0b6e3 100644 --- a/src/Components/Facility/FacilityHome.tsx +++ b/src/Components/Facility/FacilityHome.tsx @@ -445,19 +445,6 @@ export const FacilityHome = (props: any) => { <CareIcon icon="l-user-injured" className="text-lg" /> <span>View Patients</span> </ButtonV2> - <ButtonV2 - id="view-patient-facility-list" - variant="primary" - ghost - border - className="mt-2 flex w-full flex-row justify-center md:w-auto" - onClick={() => - navigate(`/facility/${facilityId}/discharged-patients`) - } - > - <CareIcon icon="l-user-injured" className="text-lg" /> - <span>View Discharged Patients</span> - </ButtonV2> </div> </div> </div> diff --git a/src/Components/Users/ManageUsers.tsx b/src/Components/Users/ManageUsers.tsx index 446b210aa9d..19bafebd115 100644 --- a/src/Components/Users/ManageUsers.tsx +++ b/src/Components/Users/ManageUsers.tsx @@ -94,6 +94,7 @@ export default function ManageUsers() { user_type: qParams.user_type, district_id: qParams.district, home_facility: qParams.home_facility, + last_active_days: qParams.last_active_days, }, }); @@ -557,6 +558,15 @@ export default function ManageUsers() { "home_facility", qParams.home_facility ? homeFacilityData?.name || "" : "", ), + value( + "Last Active", + "last_active_days", + (() => { + if (!qParams.last_active_days) return ""; + if (qParams.last_active_days === "never") return "Never"; + return `in the last ${qParams.last_active_days} day${qParams.last_active_days > 1 ? "s" : ""}`; + })(), + ), ]} /> </div> diff --git a/src/Components/Users/UserFilter.tsx b/src/Components/Users/UserFilter.tsx index d5c16d22571..f79968cef4f 100644 --- a/src/Components/Users/UserFilter.tsx +++ b/src/Components/Users/UserFilter.tsx @@ -2,7 +2,10 @@ import { parsePhoneNumber } from "../../Utils/utils"; import TextFormField from "../Form/FormFields/TextFormField"; import SelectMenuV2 from "../Form/SelectMenuV2"; import { FieldLabel } from "../Form/FormFields/FormField"; -import { USER_TYPE_OPTIONS } from "../../Common/constants"; +import { + USER_LAST_ACTIVE_OPTIONS, + USER_TYPE_OPTIONS, +} from "../../Common/constants"; import useMergeState from "../../Common/hooks/useMergeState"; import PhoneNumberFormField from "../Form/FormFields/PhoneNumberFormField"; import FiltersSlideover from "../../CAREUI/interactive/FiltersSlideover"; @@ -35,6 +38,7 @@ export default function UserFilter(props: any) { state: filter.state || "", home_facility: filter.home_facility || "", home_facility_ref: null, + last_active_days: filter.last_active_days || "", }); useQuery(routes.getAnyFacility, { @@ -53,6 +57,7 @@ export default function UserFilter(props: any) { district, state, home_facility, + last_active_days, } = filterState; const data = { first_name: first_name || "", @@ -63,6 +68,7 @@ export default function UserFilter(props: any) { district: district || "", state: district ? state || "" : "", home_facility: home_facility || "", + last_active_days: last_active_days || "", }; if (state && !district) { Notify.Warn({ @@ -142,6 +148,21 @@ export default function UserFilter(props: any) { /> </div> + <div className="w-full flex-none"> + <FieldLabel>Active in last...</FieldLabel> + <SelectMenuV2 + id="last_active_days" + placeholder="Anytime" + options={USER_LAST_ACTIVE_OPTIONS} + optionLabel={(o) => o.text} + optionValue={(o) => o.id} + value={filterState.last_active_days} + onChange={(v) => + setFilterState({ ...filterState, last_active_days: v }) + } + /> + </div> + <StateAutocompleteFormField {...field("state")} errorClassName="hidden" /> <DistrictAutocompleteFormField errorClassName="hidden" diff --git a/src/Locale/en/Common.json b/src/Locale/en/Common.json index 3267cb9ad5b..1c1e3b49ecb 100644 --- a/src/Locale/en/Common.json +++ b/src/Locale/en/Common.json @@ -173,5 +173,8 @@ "ration_card__NO_CARD": "Non-card holder", "ration_card__BPL": "BPL", "ration_card__APL": "APL", - "caution": "Caution" + "caution": "Caution", + "feed_optimal_experience_for_phones": "For optimal viewing experience, consider rotating your device.", + "feed_optimal_experience_for_apple_phones": "For optimal viewing experience, consider rotating your device. Ensure auto-rotate is enabled in your device settings.", + "action_irreversible": "This action is irreversible" } \ No newline at end of file