616 lines
30 KiB
TypeScript
616 lines
30 KiB
TypeScript
import RequestedBy from "@/components/ApprovalWorkflows/RequestedBy";
|
|
import StartedOn from "@/components/ApprovalWorkflows/StartedOn";
|
|
import Status from "@/components/ApprovalWorkflows/Status";
|
|
import Tip from "@/components/ApprovalWorkflows/Tip";
|
|
import UserWithProfilePic from "@/components/ApprovalWorkflows/UserWithProfilePic";
|
|
import WorkflowStepComponent from "@/components/ApprovalWorkflows/WorkflowStepComponent";
|
|
import Layout from "@/components/High/Layout";
|
|
import Button from "@/components/Low/Button";
|
|
import useApprovalWorkflow from "@/hooks/useApprovalWorkflow";
|
|
import { ApprovalWorkflow, getUserTypeLabelShort, WorkflowStep } from "@/interfaces/approval.workflow";
|
|
import { User } from "@/interfaces/user";
|
|
import { sessionOptions } from "@/lib/session";
|
|
import useExamStore from "@/stores/exam";
|
|
import { redirect, serialize } from "@/utils";
|
|
import { requestUser } from "@/utils/api";
|
|
import { getApprovalWorkflow } from "@/utils/approval.workflows.be";
|
|
import { getEntityWithRoles } from "@/utils/entities.be";
|
|
import { getExamById } from "@/utils/exams";
|
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
|
import { doesEntityAllow } from "@/utils/permissions";
|
|
import { getSpecificUsers, getUser } from "@/utils/users.be";
|
|
import axios from "axios";
|
|
import { AnimatePresence, LayoutGroup, motion } from "framer-motion";
|
|
import { withIronSessionSsr } from "iron-session/next";
|
|
import Head from "next/head";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/router";
|
|
import { useState } from "react";
|
|
import { BsChevronLeft } from "react-icons/bs";
|
|
import { FaSpinner, FaWpforms } from "react-icons/fa6";
|
|
import { FiSave } from "react-icons/fi";
|
|
import { IoMdCheckmarkCircleOutline } from "react-icons/io";
|
|
import { IoDocumentTextOutline } from "react-icons/io5";
|
|
import { MdKeyboardArrowDown, MdKeyboardArrowUp, MdOutlineDoubleArrow } from "react-icons/md";
|
|
import { RiThumbUpLine } from "react-icons/ri";
|
|
import { RxCrossCircled } from "react-icons/rx";
|
|
import { TiEdit } from "react-icons/ti";
|
|
import { toast, ToastContainer } from "react-toastify";
|
|
|
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
|
|
const user = await requestUser(req, res);
|
|
if (!user) return redirect("/login")
|
|
|
|
if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type))
|
|
return redirect("/")
|
|
|
|
const { id } = params as { id: string };
|
|
|
|
const workflow: ApprovalWorkflow | null = await getApprovalWorkflow("active-workflows", id);
|
|
|
|
if (!workflow) return redirect("/approval-workflows")
|
|
|
|
const entityWithRole = await getEntityWithRoles(workflow.entityId);
|
|
if (!entityWithRole) return redirect("/approval-workflows");
|
|
|
|
if (!doesEntityAllow(user, entityWithRole, "view_workflows")) return redirect("/approval-workflows");
|
|
|
|
const allAssigneeIds: string[] = [
|
|
...new Set(
|
|
workflow.steps
|
|
.map((step) => {
|
|
const assignees = step.assignees;
|
|
if (step.completedBy) {
|
|
assignees.push(step.completedBy);
|
|
}
|
|
return assignees;
|
|
})
|
|
.flat()
|
|
)
|
|
];
|
|
|
|
return {
|
|
props: serialize({
|
|
user,
|
|
initialWorkflow: workflow,
|
|
id,
|
|
workflowAssignees: await getSpecificUsers(allAssigneeIds),
|
|
workflowRequester: await getUser(workflow.requester),
|
|
}),
|
|
};
|
|
}, sessionOptions);
|
|
|
|
interface Props {
|
|
user: User,
|
|
initialWorkflow: ApprovalWorkflow,
|
|
id: string,
|
|
workflowAssignees: User[],
|
|
workflowRequester: User,
|
|
}
|
|
|
|
export default function Home({ user, initialWorkflow, id, workflowAssignees, workflowRequester }: Props) {
|
|
|
|
const { workflow, reload, isLoading } = useApprovalWorkflow(id);
|
|
|
|
const currentWorkflow = workflow || initialWorkflow;
|
|
|
|
let currentStepIndex = currentWorkflow.steps.findIndex(step => !step.completed || step.rejected);
|
|
if (currentStepIndex === -1)
|
|
currentStepIndex = currentWorkflow.steps.length - 1;
|
|
|
|
const [selectedStepIndex, setSelectedStepIndex] = useState<number>(currentStepIndex);
|
|
const [selectedStep, setSelectedStep] = useState<WorkflowStep>(currentWorkflow.steps[selectedStepIndex]);
|
|
const [isPanelOpen, setIsPanelOpen] = useState(true);
|
|
const [isAccordionOpen, setIsAccordionOpen] = useState(false);
|
|
const [comments, setComments] = useState<string>(selectedStep.comments || "");
|
|
const [viewExamIsLoading, setViewExamIsLoading] = useState<boolean>(false);
|
|
const [editExamIsLoading, setEditExamIsLoading] = useState<boolean>(false);
|
|
|
|
const router = useRouter();
|
|
|
|
const handleStepClick = (index: number, stepInfo: WorkflowStep) => {
|
|
setSelectedStep(stepInfo);
|
|
setSelectedStepIndex(index);
|
|
setComments(stepInfo.comments || "");
|
|
setIsPanelOpen(true);
|
|
};
|
|
|
|
const handleSaveComments = () => {
|
|
const updatedWorkflow: ApprovalWorkflow = {
|
|
...currentWorkflow,
|
|
steps: currentWorkflow.steps.map((step, index) =>
|
|
index === selectedStepIndex ?
|
|
{
|
|
...step,
|
|
comments: comments,
|
|
}
|
|
: step
|
|
)
|
|
};
|
|
|
|
axios
|
|
.put(`/api/approval-workflows/${id}`, updatedWorkflow)
|
|
.then(() => {
|
|
toast.success("Comments saved successfully.");
|
|
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.");
|
|
}
|
|
console.log("Submitted Values:", updatedWorkflow);
|
|
return;
|
|
})
|
|
};
|
|
|
|
const handleApproveStep = () => {
|
|
const isLastStep = (selectedStepIndex + 1 === currentWorkflow.steps.length);
|
|
if (isLastStep) {
|
|
if (!confirm(`Are you sure you want to approve the last step and complete the approval process?`)) return;
|
|
}
|
|
|
|
const updatedWorkflow: ApprovalWorkflow = {
|
|
...currentWorkflow,
|
|
status: selectedStepIndex === currentWorkflow.steps.length - 1 ? "approved" : "pending",
|
|
steps: currentWorkflow.steps.map((step, index) =>
|
|
index === selectedStepIndex ?
|
|
{
|
|
...step,
|
|
completed: true,
|
|
completedBy: user.id,
|
|
completedDate: Date.now(),
|
|
}
|
|
: step
|
|
)
|
|
};
|
|
|
|
axios
|
|
.put(`/api/approval-workflows/${id}`, updatedWorkflow)
|
|
.then(() => {
|
|
toast.success("Step approved successfully.");
|
|
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.");
|
|
}
|
|
console.log("Submitted Values:", updatedWorkflow);
|
|
return;
|
|
})
|
|
|
|
if (isLastStep) {
|
|
setIsPanelOpen(false);
|
|
const examModule = currentWorkflow.modules[0];
|
|
const examId = currentWorkflow.examId;
|
|
|
|
axios
|
|
.patch(`/api/exam/${examModule}/${examId}`, { approved: true })
|
|
.then(() => toast.success(`The exam was successfuly approved and this workflow has been completed.`))
|
|
.catch((reason) => {
|
|
if (reason.response.status === 404) {
|
|
toast.error("Exam not found!");
|
|
return;
|
|
}
|
|
|
|
if (reason.response.status === 403) {
|
|
toast.error("You do not have permission to update this exam!");
|
|
return;
|
|
}
|
|
|
|
toast.error("Something went wrong, please try again later.");
|
|
})
|
|
.finally(reload);
|
|
} else {
|
|
handleStepClick(selectedStepIndex + 1, currentWorkflow.steps[selectedStepIndex + 1]);
|
|
}
|
|
};
|
|
|
|
const handleRejectStep = () => {
|
|
if (!confirm(`Are you sure you want to reject this step? Doing so will terminate this approval workflow.`)) return;
|
|
|
|
const updatedWorkflow: ApprovalWorkflow = {
|
|
...currentWorkflow,
|
|
status: "rejected",
|
|
steps: currentWorkflow.steps.map((step, index) =>
|
|
index === selectedStepIndex ?
|
|
{
|
|
...step,
|
|
completed: true,
|
|
completedBy: user.id,
|
|
completedDate: Date.now(),
|
|
rejected: true,
|
|
}
|
|
: step
|
|
)
|
|
};
|
|
|
|
axios
|
|
.put(`/api/approval-workflows/${id}`, updatedWorkflow)
|
|
.then(() => {
|
|
toast.success("Step rejected successfully.");
|
|
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.");
|
|
}
|
|
console.log("Submitted Values:", updatedWorkflow);
|
|
return;
|
|
})
|
|
};
|
|
|
|
const dispatch = useExamStore((store) => store.dispatch);
|
|
const handleViewExam = async () => {
|
|
setViewExamIsLoading(true);
|
|
const examModule = currentWorkflow.modules[0];
|
|
const examId = currentWorkflow.examId;
|
|
|
|
if (examModule && examId) {
|
|
const exam = await getExamById(examModule, examId.trim());
|
|
if (!exam) {
|
|
toast.error("Something went wrong while fetching exam!");
|
|
setViewExamIsLoading(false);
|
|
return;
|
|
}
|
|
dispatch({
|
|
type: "INIT_EXAM",
|
|
payload: { exams: [exam], modules: [examModule] },
|
|
});
|
|
router.push("/exam");
|
|
}
|
|
}
|
|
|
|
const handleEditExam = () => {
|
|
setEditExamIsLoading(true);
|
|
const examModule = currentWorkflow.modules[0];
|
|
const examId = currentWorkflow.examId;
|
|
|
|
router.push(`/generation?id=${examId}&module=${examModule}`);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title> Workflow | EnCoach</title>
|
|
<meta
|
|
name="description"
|
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
|
/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<link rel="icon" href="/favicon.ico" />
|
|
</Head>
|
|
<ToastContainer />
|
|
|
|
<section className="flex items-center gap-2">
|
|
<Link
|
|
href="/approval-workflows"
|
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
|
<BsChevronLeft />
|
|
</Link>
|
|
<h1 className="text-2xl font-semibold">{currentWorkflow.name}</h1>
|
|
</section>
|
|
|
|
<section className="flex flex-col gap-6">
|
|
<div className="flex flex-row gap-6">
|
|
<RequestedBy
|
|
prefix={getUserTypeLabelShort(workflowRequester.type)}
|
|
name={workflowRequester.name}
|
|
profileImage={workflowRequester.profilePicture}
|
|
/>
|
|
<StartedOn
|
|
date={currentWorkflow.startDate}
|
|
/>
|
|
<Status
|
|
status={currentWorkflow.status}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-row gap-3">
|
|
<Button
|
|
color="purple"
|
|
variant="solid"
|
|
onClick={handleViewExam}
|
|
disabled={viewExamIsLoading}
|
|
padding="px-6 py-2"
|
|
className="w-[240px] text-lg flex items-center justify-center gap-2 text-left"
|
|
>
|
|
{viewExamIsLoading ? (
|
|
<>
|
|
<FaSpinner className="animate-spin size-5" />
|
|
Loading...
|
|
</>
|
|
) : (
|
|
<>
|
|
<IoDocumentTextOutline />
|
|
Load Exam
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
color="purple"
|
|
variant="solid"
|
|
onClick={handleEditExam}
|
|
padding="px-6 py-2"
|
|
disabled={(!currentWorkflow.steps[currentStepIndex].assignees.includes(user.id) && user.type !== "admin" && user.type !== "developer") || editExamIsLoading}
|
|
className="w-[240px] text-lg flex items-center justify-center gap-2 text-left"
|
|
>
|
|
{editExamIsLoading ? (
|
|
<>
|
|
<FaSpinner className="animate-spin size-5" />
|
|
Loading...
|
|
</>
|
|
) : (
|
|
<>
|
|
<TiEdit size={20} />
|
|
Edit Exam
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
</div>
|
|
{currentWorkflow.steps.find((step) => !step.completed) === undefined &&
|
|
<Tip text="All steps in this instance have been completed." />
|
|
}
|
|
</section>
|
|
|
|
<section className="flex flex-col gap-0">
|
|
{currentWorkflow.steps.map((step, index) => (
|
|
<WorkflowStepComponent
|
|
workflowAssignees={workflowAssignees}
|
|
key={index}
|
|
completed={step.completed}
|
|
completedBy={step.completedBy}
|
|
rejected={step.rejected}
|
|
stepNumber={step.stepNumber}
|
|
stepType={step.stepType}
|
|
assignees={step.assignees}
|
|
finalStep={index === currentWorkflow.steps.length - 1}
|
|
currentStep={index === currentStepIndex}
|
|
selected={index === selectedStepIndex}
|
|
onClick={() => handleStepClick(index, step)}
|
|
/>
|
|
))}
|
|
</section>
|
|
|
|
{/* Side panel */}
|
|
<AnimatePresence mode="wait">
|
|
<LayoutGroup key="sidePanel">
|
|
<section className={`absolute inset-y-0 right-0 h-full overflow-y-auto bg-mti-purple-ultralight bg-opacity-50 shadow-xl shadow-mti-purple transition-all duration-300 overflow-hidden ${isPanelOpen ? 'w-[500px]' : 'w-0'}`}>
|
|
{isPanelOpen && selectedStep && (
|
|
<motion.div
|
|
className="p-6"
|
|
key={selectedStep.stepNumber}
|
|
initial={{ opacity: 0, x: 30 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: 30 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
|
|
<div className="flex flex-row gap-2">
|
|
<p className="text-2xl font-medium text-left align-middle">Step {selectedStepIndex + 1}</p>
|
|
<div className="ml-auto flex flex-row">
|
|
<button
|
|
className="min-w-fit max-h-fit text-lg font-medium flex items-center gap-2 text-left"
|
|
onClick={() => setIsPanelOpen(false)}
|
|
>
|
|
Collapse
|
|
<MdOutlineDoubleArrow size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
|
|
|
|
<div>
|
|
<div className="my-8 flex flex-row gap-4 items-center text-lg font-medium">
|
|
{selectedStep.stepType === "approval-by" ? (
|
|
<>
|
|
<RiThumbUpLine size={30} />
|
|
Approval Step
|
|
</>
|
|
) : (
|
|
<>
|
|
<FaWpforms size={30} />
|
|
Form Intake Step
|
|
</>
|
|
)
|
|
}
|
|
</div>
|
|
|
|
{selectedStep.completed ? (
|
|
<div className={"text-base font-medium text-gray-500 flex flex-col gap-6"}>
|
|
{selectedStep.rejected ? "Rejected" : "Approved"} on {new Date(selectedStep.completedDate!).toLocaleString("en-CA", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hour12: false,
|
|
}).replace(", ", " at ")}
|
|
<div className="flex flex-row gap-1 text-sm">
|
|
<p className="text-base">{selectedStep.rejected ? "Rejected" : "Approved"} by:</p>
|
|
{(() => {
|
|
const assignee = workflowAssignees.find(
|
|
(assignee) => assignee.id === selectedStep.completedBy
|
|
);
|
|
return assignee ? (
|
|
<UserWithProfilePic
|
|
textSize="text-base"
|
|
prefix={getUserTypeLabelShort(assignee.type)}
|
|
name={assignee.name}
|
|
profileImage={assignee.profilePicture}
|
|
/>
|
|
) : (
|
|
"Unknown"
|
|
);
|
|
})()}
|
|
</div>
|
|
<p className="text-sm">No additional actions are required.</p>
|
|
</div>
|
|
|
|
) : (
|
|
<div className={"text-base font-medium text-gray-500 mb-6"}>
|
|
One assignee is required to sign off to complete this step:
|
|
<div className="flex flex-col gap-2 mt-3">
|
|
{workflowAssignees.filter(user => selectedStep.assignees.includes(user.id)).map(user => (
|
|
<span key={user.id}>
|
|
<UserWithProfilePic
|
|
textSize="text-sm"
|
|
prefix={`- ${getUserTypeLabelShort(user.type)}`}
|
|
name={user.name}
|
|
profileImage={user.profilePicture}
|
|
/>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedStepIndex === currentStepIndex && !selectedStep.completed && !selectedStep.rejected &&
|
|
<div className="flex flex-row gap-2 ">
|
|
<Button
|
|
type="submit"
|
|
color="purple"
|
|
variant="solid"
|
|
disabled={(!selectedStep.assignees.includes(user.id) && user.type !== "admin" && user.type !== "developer") || isLoading}
|
|
onClick={handleApproveStep}
|
|
padding="px-6 py-2"
|
|
className="mb-3 w-full text-lg flex items-center justify-center gap-2 text-left"
|
|
>
|
|
{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) && user.type !== "admin" && user.type !== "developer") || 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"
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<FaSpinner className="animate-spin size-5" />
|
|
Loading...
|
|
</>
|
|
) : (
|
|
<>
|
|
<RxCrossCircled size={20} />
|
|
Reject
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
}
|
|
|
|
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
|
|
|
|
{/* Accordion for Exam Changes */}
|
|
<div className="mb-4">
|
|
<div
|
|
className="flex items-center justify-between cursor-pointer p-2 rounded-lg"
|
|
onClick={() => setIsAccordionOpen((prev) => !prev)}
|
|
>
|
|
<h2 className="font-medium text-gray-500">
|
|
Changes ({currentWorkflow.steps[selectedStepIndex].examChanges?.length || "0"})
|
|
</h2>
|
|
{isAccordionOpen ? (
|
|
<MdKeyboardArrowUp size={24} />
|
|
) : (
|
|
<MdKeyboardArrowDown size={24} />
|
|
)}
|
|
</div>
|
|
<AnimatePresence>
|
|
{isAccordionOpen && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: "auto", opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="overflow-hidden mt-2"
|
|
>
|
|
<div className="p-3 border border-gray-300 rounded-xl bg-white bg-opacity-80 overflow-y-auto max-h-[300px]">
|
|
{currentWorkflow.steps[selectedStepIndex].examChanges?.length ? (
|
|
currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => (
|
|
<>
|
|
<p key={index} className="whitespace-pre-wrap text-sm text-gray-500 mb-2">
|
|
<span className="text-mti-purple-light text-lg">{change.charAt(0)}</span>
|
|
{change.slice(1)}
|
|
</p>
|
|
<hr className="my-3 h-[3px] bg-mti-purple-light rounded-full w-full" />
|
|
</>
|
|
))
|
|
) : (
|
|
<p className="text-normal text-opacity-70 text-gray-500">No changes made so far.</p>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
|
|
|
|
<textarea
|
|
value={comments}
|
|
onChange={(e) => setComments(e.target.value)}
|
|
placeholder="Input comments here"
|
|
className="w-full h-[200px] p-2 border-2 rounded-xl shadow-lg focus:border-mti-purple focus:outline-none mt-3 resize-none"
|
|
/>
|
|
|
|
<Button
|
|
type="submit"
|
|
color="purple"
|
|
variant="solid"
|
|
onClick={handleSaveComments}
|
|
disabled={isLoading}
|
|
padding="px-6 py-2"
|
|
className="mt-6 mb-3 w-full text-lg flex items-center justify-center gap-2 text-left"
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<FaSpinner className="animate-spin size-5" />
|
|
Loading...
|
|
</>
|
|
) : (
|
|
<>
|
|
<FiSave size={20} />
|
|
Save Comments
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
|
|
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</section>
|
|
</LayoutGroup>
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|