Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: frontend expiring certification warnings and sign-in prevention #205

Merged
merged 9 commits into from
Jan 7, 2025
133 changes: 73 additions & 60 deletions js/components/operatorSignIn/attestation.tsx
Original file line number Diff line number Diff line change
@@ -1,90 +1,103 @@
import { ApiResult } from "../../api";
import { reload } from "../../browser";
import { useNow } from "../../dateTime";
import { lookupDisplayName } from "../../hooks/useEmployees";
import { Certification, getExpired } from "../../models/certification";
import { Employee } from "../../models/employee";
import { className } from "../../util/dom";
import { removeLeadingZero } from "../../util/string";
import { Bypass, CertificateBoxes } from "./expiry";
import { useSignInText } from "./text";
import { ReactElement, useEffect, useState } from "react";

export const Attestation = ({
badge,
employees,
certifications,
onComplete,
loading,
prefill,
}: {
badge: string;
employees: ApiResult<Employee[]>;
employees: Employee[];
certifications: Certification[];
onComplete: (radio: string) => void;
loading: boolean;
prefill: boolean;
}): ReactElement => {
const defaultValue = prefill ? badge : "";
const now = useNow("second");

const [enteredBadge, setEnteredBadge] = useState<string>(defaultValue);
const [enteredRadio, setEnteredRadio] = useState<string>("");
const valid = enteredBadge === badge && enteredRadio !== "";
const [bypass, setBypass] = useState<boolean>(false);

const name = lookupDisplayName(badge, employees);
const expireds = getExpired(certifications, now);

if (employees.status === "loading") {
return <div>Loading...</div>;
} else if (employees.status === "error") {
return (
<div className="text-center">
<div className="mb-4">Unable to download employee data</div>
<div>
<button
className="rounded bg-blue text-gray-200 w-1/4 max-w-20"
onClick={reload}
>
Reload
</button>
</div>
</div>
);
}
const name = lookupDisplayName(badge, employees.result);
return (
<div className="text-sm">
Step 2 of 2
<SignInText />
<form
onSubmit={(e) => {
e.preventDefault();
}}
>
<InputBox
title={"Operator Badge Number"}
defaultValue={defaultValue}
onChange={(value) => {
setEnteredBadge(removeLeadingZero(value));
}}
/>
<SignatureHint badge={badge} signatureText={enteredBadge} />
<InputBox
title={"Radio Number"}
defaultValue={""}
onChange={(value) => {
setEnteredRadio(value);
}}
/>
<p className="my-3">
By pressing the button below I, <b className="fs-mask">{name}</b>,
confirm the above is true.
</p>
<button
className={className([
"block w-full md:max-w-64 mx-auto h-10 px-5 bg-gray-500 text-gray-200 rounded-md",
(!valid || loading) && "opacity-50",
])}
onClick={() => {
onComplete(enteredRadio);
}}
disabled={!valid}
>
Complete Fit for Duty Check
</button>
</form>
<CertificateBoxes
certifications={certifications}
displayName={name}
ignoreExpired={bypass}
now={now}
/>
{expireds.length === 0 || bypass ?
<>
<SignInText />
<form
onSubmit={(e) => {
e.preventDefault();
}}
>
<InputBox
title={"Operator Badge Number"}
defaultValue={defaultValue}
onChange={(value) => {
setEnteredBadge(removeLeadingZero(value));
}}
/>
<SignatureHint badge={badge} signatureText={enteredBadge} />
<InputBox
title={"Radio Number"}
defaultValue={""}
onChange={(value) => {
setEnteredRadio(value);
}}
/>
<p className="my-3">
By pressing the button below I, <b className="fs-mask">{name}</b>,
confirm the above is true.
</p>
<button
className={className([
"block w-full md:max-w-64 mx-auto h-10 px-5 bg-gray-500 text-gray-200 rounded-md",
(!valid || loading) && "opacity-50",
])}
onClick={() => {
onComplete(enteredRadio);
}}
disabled={!valid}
>
Complete Fit for Duty Check
</button>
</form>
</>
: <>
<ol className="m-8 mr-0 list-decimal">
<li>Do not allow {name} to drive.</li>
<li>Call the Office.</li>
<li>Send {name} to the Supervisors&#39; Office.</li>
</ol>
<Bypass
expireds={expireds}
displayName={name}
onContinue={function (): void {
setBypass(true);
}}
/>
</>
}
</div>
);
};
Expand Down
147 changes: 147 additions & 0 deletions js/components/operatorSignIn/expiry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { daysBetween } from "../../dateTime";
import {
Certification,
humanReadableType,
isExpired,
} from "../../models/certification";
import { className } from "../../util/dom";
import { DateTime } from "luxon";
import { ReactElement } from "react";

const WARN_WITHIN_D = 60;

const englishDaysBetween = (now: DateTime, date: DateTime) => {
const delta = daysBetween(now, date);
const wholeAbs = Math.abs(Math.ceil(delta));
if (delta <= -2) {
return `expired ${wholeAbs} days ago`;
} else if (delta <= -1) {
return "expired yesterday";
} else if (delta <= 0) {
return "expired today";
} else if (delta <= 1) {
return "expires tomorrow";
} else {
return `expires in ${wholeAbs} days`;
}
};

const CertificateBox = ({
now,
mode,
title,
operatorName,
certifications,
}: {
now: DateTime;
mode: "warning" | "error";
title: string;
operatorName: string;
certifications: Certification[];
}): ReactElement => {
const innerString =
`Our records show that ${operatorName}'s ` +
certifications
.map(
(c) =>
`${humanReadableType(c.type)} ${englishDaysBetween(
now,
c.expires,
)} on ${c.expires.toLocaleString(DateTime.DATE_SHORT)}`,
)
.join(" and ") +
".";
return (
<div
className={className([
"mb-4 mt-2 flex flex-row rounded border-0 px-3 py-2",
mode === "warning" && "bg-[#FFDE9E]",
mode === "error" && "bg-[#FF919A]",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A previous version (when there was the WarningParagraph component) had some extra classes. light:bg-opacity-40 dark:bg-opacity-30 dark:text-white. Did you mean to remove those in f54a721 ? (Also, I don't think border-0 is needed anymore, since no border should be the default.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, all intentional 👍 we don't have light/dark mode and the colors are exact. thanks for catching border-0!

])}
>
<div className="m-0 flex-1 text-xs leading-4">
<p className="font-bold uppercase">{title}</p>
<p className="mt-2">{innerString}</p>
{mode === "warning" && (
<p className="mt-2">Please have them call the Office.</p>
)}
</div>
</div>
);
};

export const CertificateBoxes = ({
now,
displayName,
ignoreExpired,
certifications,
}: {
now: DateTime;
displayName: string;
ignoreExpired: boolean;
certifications: Certification[];
}): ReactElement => {
const expired = certifications.filter((cert) => isExpired(cert, now));
const expiresSoon = certifications.filter((cert) => {
const delta = daysBetween(now, cert.expires);
return !isExpired(cert, now) && delta <= WARN_WITHIN_D;
});
return (
<>
{expiresSoon.length > 0 && (
<CertificateBox
now={now}
mode="warning"
title={
expiresSoon.length === 1 ? "Card expires soon" : "Cards expire soon"
}
operatorName={displayName}
certifications={expiresSoon}
/>
)}
{expired.length > 0 && !ignoreExpired && (
<CertificateBox
now={now}
mode="error"
title={expired.length === 1 ? "Expired card" : "Expired cards"}
operatorName={displayName}
certifications={expired}
/>
)}
</>
);
};

export const Bypass = ({
displayName,
expireds,
onContinue,
}: {
displayName: string;
expireds: Certification[];
onContinue: () => void;
}): ReactElement => {
return (
<>
<hr className="h-[2px] bg-gray-300" />
<div className="m-2 text-sm text-gray-400">
<p>Is this warning incorrect?</p>
<span>
If {displayName} has a valid{" "}
{expireds.map((c) => humanReadableType(c.type)).join(" and ")}:
</span>
<ol className="mb-4 ml-10 mr-0 mt-2 list-decimal">
<li className="m-0">
Take a picture of {expireds.length === 1 ? "the card" : "the cards"}
.
</li>
<li className="m-0">Email pictures to supervisors.</li>
<li className="m-0">Continue to Fit for Duty Check.</li>
</ol>
<button className="underline" onClick={onContinue}>
Continue to Fit for Duty Check →
</button>
</div>
</>
);
};
Loading
Loading