implement rejection of steps

This commit is contained in:
Joao Correia
2025-01-31 20:56:40 +00:00
parent 662e3b0266
commit a0229cd971
8 changed files with 170 additions and 126 deletions

View File

@@ -17,6 +17,7 @@ export default function WorkflowStepComponent({
stepType,
stepNumber,
completed,
rejected = false,
completedBy,
assignees,
finalStep,
@@ -31,11 +32,12 @@ export default function WorkflowStepComponent({
<div
onClick={onClick}
className={clsx("flex flex-row gap-5 w-[600px] p-6 mb-5 rounded-2xl transition ease-in-out duration-300 cursor-pointer", {
"bg-mti-red-ultralight": rejected && selected,
"bg-mti-purple-ultralight": selected,
})}
>
<div className="relative flex flex-col items-center">
<WorkflowStepNumber stepNumber={stepNumber} selected={selected} completed={completed} finalStep={finalStep} />
<WorkflowStepNumber stepNumber={stepNumber} selected={selected} completed={completed} finalStep={finalStep} rejected={rejected} />
{/* Vertical Bar connecting steps */}
{!finalStep && (
@@ -53,71 +55,45 @@ export default function WorkflowStepComponent({
</div>
<div className="mt-1 flex flex-col gap-0">
{stepType === "form-intake" ? (
<>
<p className="text-sm font-medium text-gray-800">Form: Intake</p>
{completed && completedBy && (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<UserWithProfilePic
prefix={`Completed by: ${getUserTypeLabelShort(completedByUser!.type)}`}
name={completedByUser!.name}
profileImage={completedByUser!.profilePicture}
/>
</div>
)}
{!completed && currentStep && (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
In Progress... Assignees:
<div className="flex flex-row flex-wrap gap-3 items-center">
{assigneesUsers.map(user => (
<span key={user.id}>
<UserWithProfilePic
prefix={getUserTypeLabelShort(user.type)}
name={user.name}
profileImage={user.profilePicture}
/>
</span>
))}
</div>
</div>
)}
</>
) : (
stepType === "approval-by" && (
<>
{completed && completedBy ? (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">Approval: {getUserTypeLabel(completedByUser!.type)} Approval</p>
{completed && completedBy && rejected ? (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`} </p>
<UserWithProfilePic
prefix={`Rejected by: ${getUserTypeLabelShort(completedByUser!.type)}`}
name={completedByUser!.name}
profileImage={completedByUser!.profilePicture}
/>
</div>
) : completed && completedBy && !rejected ? (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`} </p>
<UserWithProfilePic
prefix={`Completed by: ${getUserTypeLabelShort(completedByUser!.type)}`}
name={completedByUser!.name}
profileImage={completedByUser!.profilePicture}
/>
</div>
) : !completed && currentStep ? (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval:` : `Form Intake:`} </p>
In Progress... Assignees:
<div className="flex flex-row flex-wrap gap-3 items-center">
{assigneesUsers.map(user => (
<span key={user.id}>
<UserWithProfilePic
prefix={`Approved by: ${getUserTypeLabelShort(completedByUser!.type)}`}
name={completedByUser!.name}
profileImage={completedByUser!.profilePicture}
prefix={getUserTypeLabelShort(user.type)}
name={user.name}
profileImage={user.profilePicture}
/>
</div>
) : !completed && currentStep ? (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">Approval: </p>
In Progress... Assignees:
<div className="flex flex-row flex-wrap gap-3 items-center">
{assigneesUsers.map(user => (
<span key={user.id}>
<UserWithProfilePic
prefix={getUserTypeLabelShort(user.type)}
name={user.name}
profileImage={user.profilePicture}
/>
</span>
))}
</div>
</div>
) : (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">Approval: </p>
Waiting for previous steps...
</div>
)}
</>
)
</span>
))}
</div>
</div>
) : (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval:` : `Form Intake:`} </p>
Waiting for previous steps...
</div>
)}
</div>
</div>

View File

@@ -1,21 +1,25 @@
import { WorkflowStep } from "@/interfaces/approval.workflow";
import clsx from "clsx";
import { IoCheckmarkDoneSharp, IoCheckmarkSharp } from "react-icons/io5";
import { RxCross2 } from "react-icons/rx";
type Props = Pick<WorkflowStep, 'stepNumber' | 'completed' | 'finalStep' | 'selected'>
type Props = Pick<WorkflowStep, 'stepNumber' | 'completed' | 'finalStep' | 'selected' | 'rejected'>
export default function WorkflowStepNumber({ stepNumber, selected = false, completed, finalStep }: Props) {
export default function WorkflowStepNumber({ stepNumber, selected = false, completed, rejected, finalStep }: Props) {
return (
<div
className={clsx(
'flex items-center justify-center min-w-11 min-h-11 rounded-full',
{
'bg-mti-red-dark text-mti-red-ultralight': rejected,
'bg-mti-purple-dark text-mti-purple-ultralight': selected,
'bg-mti-purple-ultralight text-gray-500': !selected,
}
)}
>
{completed && finalStep ? (
{rejected ? (
<RxCross2 className="text-xl font-bold" size={25}/>
) : completed && finalStep ? (
<IoCheckmarkDoneSharp className="text-xl font-bold" size={25} />
) : completed && !finalStep ? (
<IoCheckmarkSharp className="text-xl font-bold" size={25} />

View File

@@ -28,6 +28,7 @@ export interface WorkflowStep {
stepType: StepType,
stepNumber: number,
completed: boolean,
rejected?: boolean,
completedBy?: User["id"],
completedDate?: number,
assignees: (User["id"])[];

View File

@@ -47,7 +47,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }
user,
workflow,
userEntitiesWithLabel,
userEntitiesApprovers: await getEntitiesUsers(userEntitiesWithLabel.map(entity => entity.id), { type: {$in: ["teacher", "corporate", "mastercorporate", "developer"]} }) as (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
userEntitiesApprovers: await getEntitiesUsers(userEntitiesWithLabel.map(entity => entity.id), { type: { $in: ["teacher", "corporate", "mastercorporate", "developer"] } }) as (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
}),
};
}, sessionOptions);
@@ -134,9 +134,7 @@ export default function Home({ user, workflow, userEntitiesWithLabel, userEntiti
.then(() => {
toast.success("Approval Workflow cloned successfully.");
setIsRedirecting(true);
setTimeout(() => {
router.push("/approval-workflows");
}, 1000);
router.push("/approval-workflows");
})
.catch((reason) => {
if (reason.response.status === 401) {

View File

@@ -39,7 +39,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }
props: serialize({
user,
workflow,
workflowEntityApprovers: await getEntityUsers(workflow.entityId, undefined, { type: {$in: ["teacher", "corporate", "mastercorporate", "developer"]} }) as (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
workflowEntityApprovers: await getEntityUsers(workflow.entityId, undefined, { type: { $in: ["teacher", "corporate", "mastercorporate", "developer"] } }) as (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
}),
};
}, sessionOptions);
@@ -86,7 +86,7 @@ export default function Home({ user, workflow, workflowEntityApprovers }: Props)
e.preventDefault();
setIsLoading(true);
if (!updatedWorkflow){
if (!updatedWorkflow) {
setIsLoading(false);
return;
}
@@ -105,9 +105,7 @@ export default function Home({ user, workflow, workflowEntityApprovers }: Props)
.then(() => {
toast.success("Approval Workflow edited successfully.");
setIsRedirecting(true);
setTimeout(() => {
router.push("/approval-workflows");
}, 1000);
router.push("/approval-workflows");
})
.catch((reason) => {
if (reason.response.status === 401) {

View File

@@ -28,6 +28,7 @@ import { RiThumbUpLine } from "react-icons/ri";
import { toast, ToastContainer } from "react-toastify";
import { IoMdCheckmarkCircleOutline } from "react-icons/io";
import { FiSave } from "react-icons/fi";
import { RxCrossCircled } from "react-icons/rx";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
const user = await requestUser(req, res);
@@ -71,7 +72,7 @@ interface Props {
export default function Home({ user, workflow, workflowAssignees, workflowRequester }: Props) {
const steps = workflow.steps;
let currentStep = steps.findIndex(step => !step.completed);
let currentStep = steps.findIndex(step => !step.completed || step.rejected);
if (currentStep === -1)
currentStep = steps.length - 1;
@@ -110,9 +111,7 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
.then(() => {
toast.success("Comments saved successfully.");
setIsRedirecting(true);
setTimeout(() => {
router.reload();
}, 1000);
router.reload();
})
.catch((reason) => {
if (reason.response.status === 401) {
@@ -129,7 +128,6 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
};
const handleApproveStep = () => {
if (!confirm(`Are you sure you want to approve this step?`)) return;
setIsLoading(true);
const updatedWorkflow: ApprovalWorkflow = {
@@ -151,9 +149,48 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
.then(() => {
toast.success("Step approved successfully.");
setIsRedirecting(true);
setTimeout(() => {
router.reload();
}, 1000);
router.reload();
})
.catch((reason) => {
if (reason.response.status === 401) {
toast.error("Not logged in!");
} else if (reason.response.status === 403) {
toast.error("You do not have permission to approve this step!");
} else {
toast.error("Something went wrong, please try again later.");
}
setIsLoading(false);
console.log("Submitted Values:", updatedWorkflow);
return;
})
};
const handleRejectStep = () => {
if (!confirm(`Are you sure you want to reject this step?`)) return;
setIsLoading(true);
const updatedWorkflow: ApprovalWorkflow = {
...workflow,
status: "rejected",
steps: workflow.steps.map((step, index) =>
index === selectedStepIndex ?
{
...step,
completed: true,
completedBy: user.id,
completedDate: Date.now(),
rejected: true,
}
: step
)
};
axios
.put(`/api/approval-workflows/${workflow._id}`, updatedWorkflow)
.then(() => {
toast.success("Step rejected successfully.");
setIsRedirecting(true);
router.reload();
})
.catch((reason) => {
if (reason.response.status === 401) {
@@ -219,6 +256,7 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
key={index}
completed={step.completed}
completedBy={step.completedBy}
rejected={step.rejected}
stepNumber={step.stepNumber}
stepType={step.stepType}
assignees={step.assignees}
@@ -276,7 +314,7 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
{selectedStep.completed ? (
<div className={"text-base font-medium text-gray-500 flex flex-col gap-6"}>
Approved on {new Date(selectedStep.completedDate!).toLocaleString("en-CA", {
{selectedStep.rejected ? "Rejected" : "Approved"} on {new Date(selectedStep.completedDate!).toLocaleString("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -286,7 +324,7 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
hour12: false,
}).replace(", ", " at ")}
<div className="flex flex-row gap-1 text-sm">
<p className="text-base">Approved by:</p>
<p className="text-base">{selectedStep.rejected ? "Rejected" : "Approved"} by:</p>
{(() => {
const assignee = workflowAssignees.find(
(assignee) => assignee.id === selectedStep.completedBy
@@ -324,33 +362,61 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
</div>
)}
{selectedStepIndex === currentStep && !selectedStep.completed &&
<Button
type="submit"
color="purple"
variant="solid"
disabled={!selectedStep.assignees.includes(user.id) || isLoading}
onClick={handleApproveStep}
padding="px-6 py-2"
className="mb-3 w-full text-lg flex items-center justify-center gap-2 text-left"
>
{isRedirecting ? (
<>
<FaSpinner className="animate-spin size-5" />
Reloading...
</>
) : isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<IoMdCheckmarkCircleOutline size={20} />
Approve Step
</>
)}
</Button>
{selectedStepIndex === currentStep && !selectedStep.completed && !selectedStep.rejected &&
<div className="flex flex-row gap-2 ">
<Button
type="submit"
color="purple"
variant="solid"
disabled={!selectedStep.assignees.includes(user.id) || isLoading}
onClick={handleApproveStep}
padding="px-6 py-2"
className="mb-3 w-full text-lg flex items-center justify-center gap-2 text-left"
>
{isRedirecting ? (
<>
<FaSpinner className="animate-spin size-5" />
Reloading...
</>
) : isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<IoMdCheckmarkCircleOutline size={20} />
Approve Step
</>
)}
</Button>
<Button
type="submit"
color="red"
variant="solid"
disabled={!selectedStep.assignees.includes(user.id) || isLoading}
onClick={handleRejectStep}
padding="px-6 py-2"
className="mb-3 w-1/2 text-lg flex items-center justify-center gap-2 text-left"
>
{isRedirecting ? (
<>
<FaSpinner className="animate-spin size-5" />
Reloading...
</>
) : isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<RxCrossCircled size={20} />
Reject Step
</>
)}
</Button>
</div>
}
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />

View File

@@ -38,7 +38,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
props: serialize({
user,
userEntitiesWithLabel,
userEntitiesApprovers: await getEntitiesUsers(userEntitiesWithLabel.map(entity => entity.id), { type: {$in: ["teacher", "corporate", "mastercorporate", "developer"]} }) as (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
userEntitiesApprovers: await getEntitiesUsers(userEntitiesWithLabel.map(entity => entity.id), { type: { $in: ["teacher", "corporate", "mastercorporate", "developer"] } }) as (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
}),
};
}, sessionOptions);
@@ -103,9 +103,7 @@ export default function Home({ user, userEntitiesWithLabel, userEntitiesApprover
.then(() => {
toast.success("Approval Workflows created successfully.");
setIsRedirecting(true);
setTimeout(() => {
router.push("/approval-workflows");
}, 1000);
router.push("/approval-workflows");
})
.catch((reason) => {
if (reason.response.status === 401) {

View File

@@ -155,9 +155,7 @@ export default function ApprovalWorkflows({ user, workflows, workflowsAssignees,
.delete(`/api/approval-workflows/${id}`)
.then(() => {
toast.success(`Successfully deleted ${name} Approval Workflow.`);
setTimeout(() => {
router.reload();
}, 1000);
router.reload();
})
.catch((reason) => {
if (reason.response.status === 404) {
@@ -217,6 +215,9 @@ export default function ApprovalWorkflows({ user, workflows, workflowsAssignees,
cell: (info) => {
const steps = info.row.original.steps;
const currentStep = steps.find((step) => !step.completed);
const rejected = steps.find((step) => step.rejected);
if(rejected) return "";
const assignees = currentStep?.assignees.map((assigneeId) => {
const assignee = workflowsAssignees.find((user) => user.id === assigneeId);
@@ -243,10 +244,11 @@ export default function ApprovalWorkflows({ user, workflows, workflowsAssignees,
cell: (info) => {
const steps = info.row.original.steps;
const currentStep = steps.find((step) => !step.completed);
const rejected = steps.find((step) => step.rejected);
return (
<span className="font-medium">
{currentStep
{currentStep && !rejected
? `Step ${currentStep.stepNumber}: ${StepTypeLabel[currentStep.stepType]}`
: "Completed"}
</span>
@@ -259,6 +261,7 @@ export default function ApprovalWorkflows({ user, workflows, workflowsAssignees,
cell: ({ row }) => {
const steps = row.original.steps;
const currentStep = steps.find((step) => !step.completed);
const rejected = steps.find((step) => step.rejected);
return (
<div className="flex gap-4">
@@ -282,7 +285,7 @@ export default function ApprovalWorkflows({ user, workflows, workflowsAssignees,
<FaRegClone className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</Link>
{currentStep && (
{currentStep && !rejected && (
<Link
onClick={(e) => e.stopPropagation()}
data-tip="Edit"