diff --git a/src/demo/approval_workflows.json b/src/demo/approval_workflows.json
new file mode 100644
index 00000000..8966eb9e
--- /dev/null
+++ b/src/demo/approval_workflows.json
@@ -0,0 +1,204 @@
+[
+ {
+ "id": "kajhfakscbka-asacaca-acawesae",
+ "name": "English Exam 1st Quarter 2025",
+ "entityId": "64a92896-fa8c-4908-95f3-23ffe05560c5",
+ "modules": [
+ "reading",
+ "writing"
+ ],
+ "requester": "ffdIipRyXTRmm10Sq2eg7P97rLB2",
+ "startDate": 1737712243906,
+ "status": "pending",
+ "steps": [
+ {
+ "stepType": "form-intake",
+ "stepNumber": 1,
+ "completed": true,
+ "completedBy": "5fZibjknlJdfIZVndlV2FIdamtn1",
+ "completedDate": 1737712243906,
+ "firstStep": true,
+ "assignees": [
+ "5fZibjknlJdfIZVndlV2FIdamtn1",
+ "50jqJuESQNX0Qas64B5JZBQTIiq1",
+ "2rtgJKmBXfWFzrtG8AjFgyrGBcp1"
+ ],
+ "comments": "This is a random comment\nThis is a random comment\nThis is a random comment\nThis is a random comment\nThis is a random comment\n"
+ },
+ {
+ "stepType": "approval-by",
+ "stepNumber": 2,
+ "completed": true,
+ "completedBy": "50jqJuESQNX0Qas64B5JZBQTIiq1",
+ "completedDate": 1737712243906,
+ "assignees": [
+ "5fZibjknlJdfIZVndlV2FIdamtn1",
+ "50jqJuESQNX0Qas64B5JZBQTIiq1",
+ "2rtgJKmBXfWFzrtG8AjFgyrGBcp1"
+ ],
+ "comments": "This is a random comment"
+ },
+ {
+ "stepType": "approval-by",
+ "stepNumber": 3,
+ "completed": false,
+ "assignees": [
+ "5fZibjknlJdfIZVndlV2FIdamtn1",
+ "50jqJuESQNX0Qas64B5JZBQTIiq1",
+ "2rtgJKmBXfWFzrtG8AjFgyrGBcp1"
+ ],
+ "comments": "This is a random comment"
+ },
+ {
+ "stepType": "approval-by",
+ "stepNumber": 4,
+ "completed": false,
+ "assignees": [
+ "50jqJuESQNX0Qas64B5JZBQTIiq1"
+ ],
+ "comments": "This is a random comment"
+ },
+ {
+ "stepType": "approval-by",
+ "stepNumber": 5,
+ "completed": false,
+ "finalStep": true,
+ "assignees": [
+ "50jqJuESQNX0Qas64B5JZBQTIiq1",
+ "2rtgJKmBXfWFzrtG8AjFgyrGBcp1"
+ ],
+ "comments": "This is a random comment"
+ }
+ ]
+ },
+ {
+ "id": "aaaaaakscbka-asacaca-acawesae",
+ "name": "English Exam 2nd Quarter 2025",
+ "entityId": "64a92896-fa8c-4908-95f3-23ffe05560c5",
+ "modules": [
+ "reading",
+ "writing",
+ "level",
+ "speaking",
+ "listening"
+ ],
+ "requester": "231c84b2-a65a-49a9-803c-c664d84b13e0",
+ "startDate": 1737712243906,
+ "status": "approved",
+ "steps": [
+ {
+ "stepType": "form-intake",
+ "stepNumber": 1,
+ "completed": true,
+ "completedBy": "fd5fce42-4bcc-4150-a143-b484e750b265",
+ "completedDate": 1737712243906,
+ "firstStep": true,
+ "assignees": [
+ "fd5fce42-4bcc-4150-a143-b484e750b265",
+ "231c84b2-a65a-49a9-803c-c664d84b13e0",
+ "c5fc1514-1a94-4f8c-a046-a62099097a50"
+ ],
+ "comments": "This is a random comment"
+ },
+ {
+ "stepType": "approval-by",
+ "stepNumber": 2,
+ "completed": true,
+ "completedBy": "rTh9yz6Z1WOidHlVOSGInlpoxrk1",
+ "completedDate": 1737712243906,
+ "assignees": [
+ "fd5fce42-4bcc-4150-a143-b484e750b265",
+ "rTh9yz6Z1WOidHlVOSGInlpoxrk1",
+ "c5fc1514-1a94-4f8c-a046-a62099097a50"
+ ],
+ "comments": "This is a random comment"
+ },
+ {
+ "stepType": "approval-by",
+ "stepNumber": 3,
+ "completed": true,
+ "completedBy": "231c84b2-a65a-49a9-803c-c664d84b13e0",
+ "completedDate": 1737712243906,
+ "assignees": [
+ "fd5fce42-4bcc-4150-a143-b484e750b265",
+ "231c84b2-a65a-49a9-803c-c664d84b13e0",
+ "c5fc1514-1a94-4f8c-a046-a62099097a50"
+ ],
+ "comments": "This is a random comment"
+ },
+ {
+ "stepType": "approval-by",
+ "stepNumber": 4,
+ "completed": true,
+ "completedBy": "231c84b2-a65a-49a9-803c-c664d84b13e0",
+ "completedDate": 1737712243906,
+ "assignees": [
+ "fd5fce42-4bcc-4150-a143-b484e750b265"
+ ],
+ "comments": "This is a random comment"
+ },
+ {
+ "stepType": "approval-by",
+ "stepNumber": 5,
+ "completed": true,
+ "completedBy": "c5fc1514-1a94-4f8c-a046-a62099097a50",
+ "completedDate": 1737712243906,
+ "finalStep": true,
+ "assignees": [
+ "rTh9yz6Z1WOidHlVOSGInlpoxrk1",
+ "c5fc1514-1a94-4f8c-a046-a62099097a50"
+ ],
+ "comments": "This is a random comment"
+ }
+ ]
+ },
+ {
+ "id": "bbbbkscbka-asacaca-acawesae",
+ "name": "English Exam 3rd Quarter 2025",
+ "entityId": "49ed2f0c-7d0d-46e4-9576-7cf19edc4980",
+ "modules": [
+ "reading"
+ ],
+ "requester": "rTh9yz6Z1WOidHlVOSGInlpoxrk1",
+ "startDate": 1737712243906,
+ "status": "rejected",
+ "steps": [
+ {
+ "stepType": "form-intake",
+ "stepNumber": 1,
+ "completed": true,
+ "completedBy": "231c84b2-a65a-49a9-803c-c664d84b13e0",
+ "completedDate": 1737712243906,
+ "firstStep": true,
+ "assignees": [
+ "fd5fce42-4bcc-4150-a143-b484e750b265",
+ "231c84b2-a65a-49a9-803c-c664d84b13e0",
+ "c5fc1514-1a94-4f8c-a046-a62099097a50"
+ ],
+ "comments": "This is a random comment\nThis is a random comment\nThis is a random comment\nThis is a random comment\nThis is a random comment\n"
+ },
+ {
+ "stepType": "approval-by",
+ "stepNumber": 2,
+ "completed": true,
+ "completedBy": "rTh9yz6Z1WOidHlVOSGInlpoxrk1",
+ "completedDate": 1737712243906,
+ "assignees": [
+ "rTh9yz6Z1WOidHlVOSGInlpoxrk1",
+ "c5fc1514-1a94-4f8c-a046-a62099097a50"
+ ],
+ "comments": "This is a random comment"
+ },
+ {
+ "stepType": "approval-by",
+ "stepNumber": 3,
+ "completed": false,
+ "finalStep": true,
+ "assignees": [
+ "rTh9yz6Z1WOidHlVOSGInlpoxrk1"
+ ],
+ "comments": "This is a random comment"
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/src/hooks/useApprovalWorkflow.tsx b/src/hooks/useApprovalWorkflow.tsx
new file mode 100644
index 00000000..9908cfbe
--- /dev/null
+++ b/src/hooks/useApprovalWorkflow.tsx
@@ -0,0 +1,24 @@
+import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
+import axios from "axios";
+import { useCallback, useEffect, useState } from "react";
+
+export default function useApprovalWorkflow(id: string) {
+ const [workflow, setWorkflow] = useState
();
+ const [isLoading, setIsLoading] = useState(false);
+ const [isError, setIsError] = useState(false);
+
+ const getData = useCallback(() => {
+ setIsLoading(true);
+ axios
+ .get(`/api/approval-workflows/${id}`)
+ .then((response) => setWorkflow(response.data))
+ .catch((error) => {
+ setIsError(true);
+ })
+ .finally(() => setIsLoading(false));
+ }, []);
+
+ useEffect(getData, [getData]);
+
+ return { workflow, isLoading, isError, reload: getData };
+}
diff --git a/src/hooks/useApprovalWorkflows.tsx b/src/hooks/useApprovalWorkflows.tsx
new file mode 100644
index 00000000..1b950c2f
--- /dev/null
+++ b/src/hooks/useApprovalWorkflows.tsx
@@ -0,0 +1,24 @@
+import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
+import axios from "axios";
+import { useCallback, useEffect, useState } from "react";
+
+export default function useApprovalWorkflows() {
+ const [workflows, setWorkflows] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isError, setIsError] = useState(false);
+
+ const getData = useCallback(() => {
+ setIsLoading(true);
+ axios
+ .get(`/api/approval-workflows`)
+ .then((response) => setWorkflows(response.data))
+ .catch((error) => {
+ setIsError(true);
+ })
+ .finally(() => setIsLoading(false));
+ }, []);
+
+ useEffect(getData, [getData]);
+
+ return { workflows, isLoading, isError, reload: getData };
+}
diff --git a/src/interfaces/approval.workflow.ts b/src/interfaces/approval.workflow.ts
new file mode 100644
index 00000000..84a6567d
--- /dev/null
+++ b/src/interfaces/approval.workflow.ts
@@ -0,0 +1,71 @@
+import { ObjectId } from "mongodb";
+import { Module } from ".";
+import { Type, User, userTypeLabels, userTypeLabelsShort } from "./user";
+
+export interface ApprovalWorkflow {
+ _id?: ObjectId,
+ name: string,
+ entityId: string,
+ requester: User["id"],
+ startDate: number,
+ modules: Module[],
+ examId?: string,
+ status: ApprovalWorkflowStatus,
+ steps: WorkflowStep[],
+}
+
+export interface EditableApprovalWorkflow extends Omit {
+ id: string,
+ steps: EditableWorkflowStep[],
+}
+
+export type StepType = "form-intake" | "approval-by";
+export const StepTypeLabel: Record = {
+ "form-intake": "Form Intake",
+ "approval-by": "Approval",
+};
+
+export interface WorkflowStep {
+ stepType: StepType,
+ stepNumber: number,
+ completed: boolean,
+ rejected?: boolean,
+ completedBy?: User["id"],
+ completedDate?: number,
+ assignees: (User["id"])[];
+ firstStep?: boolean,
+ finalStep?: boolean,
+ selected?: boolean,
+ comments?: string,
+ onClick?: React.MouseEventHandler
+}
+
+export interface EditableWorkflowStep {
+ key: number,
+ stepType: StepType,
+ stepNumber: number,
+ completed: boolean,
+ rejected?: boolean,
+ completedBy?: User["id"],
+ completedDate?: number,
+ assignees: (User["id"] | null | undefined)[]; // bit of an hack, but allowing null or undefined values allows us to match one to one the select input components with the assignees array. And since select inputs allow undefined or null values, it is allowed here too, but must validate required input before form submission
+ firstStep: boolean,
+ finalStep?: boolean,
+ onDelete?: () => void;
+}
+
+export function getUserTypeLabel(type: Type | undefined): string {
+ if (type) return userTypeLabels[type];
+ return '';
+}
+export function getUserTypeLabelShort(type: Type | undefined): string {
+ if (type) return userTypeLabelsShort[type];
+ return '';
+}
+
+export type ApprovalWorkflowStatus = "approved" | "pending" | "rejected";
+export const ApprovalWorkflowStatusLabel: Record = {
+ approved: "Approved",
+ pending: "Pending",
+ rejected: "Rejected",
+};
diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts
index 79248c34..4692f2f7 100644
--- a/src/interfaces/index.ts
+++ b/src/interfaces/index.ts
@@ -1,4 +1,11 @@
export type Module = "reading" | "listening" | "writing" | "speaking" | "level";
+export const ModuleTypeLabels: Record = {
+ reading: "Reading",
+ listening: "Listening",
+ writing: "Writing",
+ speaking: "Speaking",
+ level: "Level",
+};
export interface Step {
min: number;
diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts
index b1ed48bc..e16f7e0d 100644
--- a/src/interfaces/user.ts
+++ b/src/interfaces/user.ts
@@ -170,4 +170,24 @@ export interface Code {
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"];
+export const userTypeLabels: Record = {
+ student: "Student",
+ teacher: "Teacher",
+ corporate: "Corporate",
+ admin: "Admin",
+ developer: "Developer",
+ agent: "Agent",
+ mastercorporate: "Master Corporate",
+};
+
+export const userTypeLabelsShort: Record = {
+ student: "",
+ teacher: "Prof.",
+ corporate: "Dir.",
+ admin: "Admin",
+ developer: "Dev.",
+ agent: "Agent",
+ mastercorporate: "Dir.",
+};
+
export type WithUser = T extends { participants: string[] } ? Omit & { participants: User[] } : T;
diff --git a/src/pages/api/approval-workflows/[id]/edit.ts b/src/pages/api/approval-workflows/[id]/edit.ts
new file mode 100644
index 00000000..a547f0cf
--- /dev/null
+++ b/src/pages/api/approval-workflows/[id]/edit.ts
@@ -0,0 +1,32 @@
+// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
+import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
+import { sessionOptions } from "@/lib/session";
+import { requestUser } from "@/utils/api";
+import { updateApprovalWorkflow } from "@/utils/approval.workflows.be";
+import { withIronSessionApiRoute } from "iron-session/next";
+import { ObjectId } from "mongodb";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+export default withIronSessionApiRoute(handler, sessionOptions);
+
+async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method === "PUT") return await put(req, res);
+}
+
+async function put(req: NextApiRequest, res: NextApiResponse) {
+ const user = await requestUser(req, res);
+ if (!user) return res.status(401).json({ ok: false });
+
+ if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) {
+ return res.status(403).json({ ok: false });
+ }
+
+ const { id } = req.query as { id?: string };
+ const approvalWorkflow: ApprovalWorkflow = req.body;
+
+ if (id && approvalWorkflow) {
+ approvalWorkflow._id = new ObjectId(id);
+ await updateApprovalWorkflow("active-workflows", approvalWorkflow);
+ return res.status(204).end();
+ }
+}
diff --git a/src/pages/api/approval-workflows/[id]/index.ts b/src/pages/api/approval-workflows/[id]/index.ts
new file mode 100644
index 00000000..c50026c0
--- /dev/null
+++ b/src/pages/api/approval-workflows/[id]/index.ts
@@ -0,0 +1,62 @@
+// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
+import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
+import { sessionOptions } from "@/lib/session";
+import { requestUser } from "@/utils/api";
+import { deleteApprovalWorkflow, getApprovalWorkflow, updateApprovalWorkflow } from "@/utils/approval.workflows.be";
+import { withIronSessionApiRoute } from "iron-session/next";
+import { ObjectId } from "mongodb";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+export default withIronSessionApiRoute(handler, sessionOptions);
+
+async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method === "DELETE") return await del(req, res);
+ if (req.method === "PUT") return await put(req, res);
+ if (req.method === "GET") return await get(req, res);
+}
+
+async function del(req: NextApiRequest, res: NextApiResponse) {
+ const user = await requestUser(req, res);
+ if (!user) return res.status(401).json({ ok: false });
+
+ if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) {
+ return res.status(403).json({ ok: false });
+ }
+
+ const { id } = req.query as { id?: string };
+
+ if (id) return res.status(200).json(await deleteApprovalWorkflow("active-workflows", id));
+}
+
+async function put(req: NextApiRequest, res: NextApiResponse) {
+ const user = await requestUser(req, res);
+ if (!user) return res.status(401).json({ ok: false });
+
+ if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) {
+ return res.status(403).json({ ok: false });
+ }
+
+ const { id } = req.query as { id?: string };
+ const workflow: ApprovalWorkflow = req.body;
+
+ if (id && workflow) {
+ workflow._id = new ObjectId(id);
+ await updateApprovalWorkflow("active-workflows", workflow);
+ return res.status(204).end();
+ }
+}
+
+async function get(req: NextApiRequest, res: NextApiResponse) {
+ const user = await requestUser(req, res);
+ if (!user) return res.status(401).json({ ok: false });
+
+ if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) {
+ return res.status(403).json({ ok: false });
+ }
+
+ const { id } = req.query as { id?: string };
+
+ if (id) {
+ return res.status(200).json(await getApprovalWorkflow("active-workflows", id));
+ }
+}
diff --git a/src/pages/api/approval-workflows/create.ts b/src/pages/api/approval-workflows/create.ts
new file mode 100644
index 00000000..96889899
--- /dev/null
+++ b/src/pages/api/approval-workflows/create.ts
@@ -0,0 +1,37 @@
+// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
+import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
+import { Entity } from "@/interfaces/entity";
+import { sessionOptions } from "@/lib/session";
+import { requestUser } from "@/utils/api";
+import { replaceApprovalWorkflowsByEntities } from "@/utils/approval.workflows.be";
+import { withIronSessionApiRoute } from "iron-session/next";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+export default withIronSessionApiRoute(handler, sessionOptions);
+
+interface ReplaceApprovalWorkflowsRequest {
+ filteredWorkflows: ApprovalWorkflow[];
+ userEntitiesWithLabel: Entity[];
+}
+
+async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method === "POST") return await post(req, res);
+}
+
+async function post(req: NextApiRequest, res: NextApiResponse) {
+ const user = await requestUser(req, res);
+ if (!user) return res.status(401).json({ ok: false });
+
+ if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) {
+ return res.status(403).json({ ok: false });
+ }
+
+ const { filteredWorkflows, userEntitiesWithLabel } = req.body as ReplaceApprovalWorkflowsRequest;
+
+ const configuredWorkflows: ApprovalWorkflow[] = filteredWorkflows;
+ const entitiesIds: string[] = userEntitiesWithLabel.map((e) => e.id);
+
+ await replaceApprovalWorkflowsByEntities(configuredWorkflows, entitiesIds);
+
+ return res.status(204).end();
+}
diff --git a/src/pages/api/approval-workflows/index.ts b/src/pages/api/approval-workflows/index.ts
new file mode 100644
index 00000000..26636186
--- /dev/null
+++ b/src/pages/api/approval-workflows/index.ts
@@ -0,0 +1,78 @@
+// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
+import { Module } from "@/interfaces";
+import { sessionOptions } from "@/lib/session";
+import { requestUser } from "@/utils/api";
+import { createApprovalWorkflow, getApprovalWorkflowByFormIntaker, getApprovalWorkflows } from "@/utils/approval.workflows.be";
+import { withIronSessionApiRoute } from "iron-session/next";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+export default withIronSessionApiRoute(handler, sessionOptions);
+
+interface PostRequestBody {
+ examAuthor: string;
+ examEntities: string[];
+ examId: string;
+ examName: string;
+ examModule: Module;
+}
+
+async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method === "GET") return await get(req, res);
+ if (req.method === "POST") return await post(req, res);
+}
+
+async function get(req: NextApiRequest, res: NextApiResponse) {
+ const user = await requestUser(req, res);
+ if (!user) return res.status(401).json({ ok: false });
+
+ if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) {
+ return res.status(403).json({ ok: false });
+ }
+
+ return res.status(200).json(await getApprovalWorkflows("active-workflows"));
+}
+
+async function post(req: NextApiRequest, res: NextApiResponse) {
+ const user = await requestUser(req, res);
+ if (!user) return res.status(401).json({ ok: false });
+
+ if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) {
+ return res.status(403).json({ ok: false });
+ }
+
+ const { examAuthor, examEntities, examId, examModule } = req.body as PostRequestBody;
+
+ const results = await Promise.all(
+ examEntities.map(async (entity) => {
+ const configuredWorkflow = await getApprovalWorkflowByFormIntaker(entity, examAuthor);
+ if (!configuredWorkflow) {
+ return { entity, created: false, error: "No configured workflow found for examAuthor." };
+ }
+
+ configuredWorkflow.modules.push(examModule);
+ configuredWorkflow.name = `${examId}`;
+ configuredWorkflow.examId = examId;
+ configuredWorkflow.entityId = entity;
+ configuredWorkflow.startDate = Date.now();
+
+ try {
+ const creationResponse = await createApprovalWorkflow("active-workflows", configuredWorkflow);
+ return { entity, created: true, creationResponse };
+ } catch (error) {
+ const err = error as Error;
+ return { entity, created: false, error: err.message };
+ }
+ })
+ );
+
+ const successCount = results.filter((r) => r.created).length;
+ const totalCount = examEntities.length;
+
+ if (successCount === totalCount) {
+ return res.status(200).json({ ok: true, results });
+ } else if (successCount > 0) {
+ return res.status(207).json({ ok: true, results });
+ } else {
+ return res.status(404).json({ ok: false, message: "No workflows were created", results });
+ }
+}
diff --git a/src/pages/approval-workflows/[id]/edit.tsx b/src/pages/approval-workflows/[id]/edit.tsx
new file mode 100644
index 00000000..7a026bff
--- /dev/null
+++ b/src/pages/approval-workflows/[id]/edit.tsx
@@ -0,0 +1,192 @@
+import RequestedBy from "@/components/ApprovalWorkflows/RequestedBy";
+import StartedOn from "@/components/ApprovalWorkflows/StartedOn";
+import Status from "@/components/ApprovalWorkflows/Status";
+import WorkflowForm from "@/components/ApprovalWorkflows/WorkflowForm";
+import Layout from "@/components/High/Layout";
+import { ApprovalWorkflow, EditableApprovalWorkflow, EditableWorkflowStep, getUserTypeLabelShort } from "@/interfaces/approval.workflow";
+import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser, User } from "@/interfaces/user";
+import { sessionOptions } from "@/lib/session";
+import { redirect, serialize } from "@/utils";
+import { requestUser } from "@/utils/api";
+import { getApprovalWorkflow } from "@/utils/approval.workflows.be";
+import { shouldRedirectHome } from "@/utils/navigation.disabled";
+import { getEntityUsers } from "@/utils/users.be";
+import axios from "axios";
+import { LayoutGroup, motion } from "framer-motion";
+import { withIronSessionSsr } from "iron-session/next";
+import Head from "next/head";
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { BsChevronLeft } from "react-icons/bs";
+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")
+
+ return {
+ props: serialize({
+ user,
+ workflow,
+ workflowEntityApprovers: await getEntityUsers(workflow.entityId, undefined, { type: { $in: ["teacher", "corporate", "mastercorporate", "developer"] } }) as (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
+ }),
+ };
+}, sessionOptions);
+
+interface Props {
+ user: User,
+ workflow: ApprovalWorkflow,
+ workflowEntityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
+}
+
+export default function Home({ user, workflow, workflowEntityApprovers }: Props) {
+ const [updatedWorkflow, setUpdatedWorkflow] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ const editableSteps: EditableWorkflowStep[] = workflow.steps.map(step => ({
+ key: step.stepNumber + 999, // just making sure they are unique because new steps that users add will have key=3 key=4 etc
+ stepType: step.stepType,
+ stepNumber: step.stepNumber,
+ completed: step.completed,
+ completedBy: step.completedBy || undefined,
+ completedDate: step.completedDate || undefined,
+ assignees: step.assignees,
+ firstStep: step.firstStep || false,
+ finalStep: step.finalStep || false,
+ onDelete: undefined,
+ }));
+
+ const editableWorkflow: EditableApprovalWorkflow = {
+ id: workflow._id?.toString() ?? "",
+ name: workflow.name,
+ entityId: workflow.entityId,
+ requester: user.id, // should it change to the editor?
+ startDate: workflow.startDate,
+ modules: workflow.modules,
+ status: workflow.status,
+ steps: editableSteps,
+ };
+
+ setUpdatedWorkflow(editableWorkflow);
+ }, []);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsLoading(true);
+
+ if (!updatedWorkflow) {
+ setIsLoading(false);
+ return;
+ }
+
+ for (const step of updatedWorkflow.steps) {
+ if (step.assignees.every(x => !x)) {
+ toast.warning("There is at least one empty step in the workflow.");
+ setIsLoading(false);
+ return;
+ }
+ }
+
+ const filteredWorkflow: ApprovalWorkflow = {
+ ...updatedWorkflow,
+ steps: updatedWorkflow.steps.map(step => ({
+ ...step,
+ assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined)
+ }))
+ };
+
+ axios
+ .put(`/api/approval-workflows/${updatedWorkflow.id}/edit`, filteredWorkflow)
+ .then(() => {
+ toast.success("Approval Workflow edited successfully.");
+ setIsLoading(false);
+ })
+ .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 edit Approval Workflows!");
+ } else {
+ toast.error("Something went wrong, please try again later.");
+ }
+ setIsLoading(false);
+ console.log("Submitted Values:", filteredWorkflow);
+ return;
+ })
+ };
+
+ const onWorkflowChange = (updatedWorkflow: EditableApprovalWorkflow) => {
+ setUpdatedWorkflow(updatedWorkflow);
+ };
+
+ return (
+ <>
+
+ Edit Workflow | EnCoach
+
+
+
+
+
+
+
+
+
+ {workflow.name}
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/pages/approval-workflows/[id]/index.tsx b/src/pages/approval-workflows/[id]/index.tsx
new file mode 100644
index 00000000..b9087217
--- /dev/null
+++ b/src/pages/approval-workflows/[id]/index.tsx
@@ -0,0 +1,559 @@
+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 { getExamById } from "@/utils/exams";
+import { shouldRedirectHome } from "@/utils/navigation.disabled";
+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 { 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 allAssigneeIds: string[] = [
+ ...new Set(
+ workflow.steps
+ .map(step => step.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(currentStepIndex);
+ const [selectedStep, setSelectedStep] = useState(currentWorkflow.steps[selectedStepIndex]);
+ const [isPanelOpen, setIsPanelOpen] = useState(true);
+ const [comments, setComments] = useState(selectedStep.comments || "");
+ const [viewExamIsLoading, setViewExamIsLoading] = useState(false);
+ const [editExamIsLoading, setEditExamIsLoading] = useState(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? Doing so will approve the exam.`)) 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}`, { isDiagnostic: false })
+ .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(
+ "Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
+ { toastId: "invalid-exam-id" }
+ );
+ 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 (
+ <>
+
+ Workflow | EnCoach
+
+
+
+
+
+
+
+
+
+
+ {currentWorkflow.name}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {currentWorkflow.steps.find((step) => !step.completed) === undefined &&
+
+ }
+
+
+
+ {currentWorkflow.steps.map((step, index) => (
+ handleStepClick(index, step)}
+ />
+ ))}
+
+
+ {/* Side panel */}
+
+
+
+ {isPanelOpen && selectedStep && (
+
+
+
+
Step {selectedStepIndex + 1}
+
+
+
+
+
+
+
+
+
+ {selectedStep.stepType === "approval-by" ? (
+ <>
+
+ Approval Step
+ >
+ ) : (
+ <>
+
+ Form Intake Step
+ >
+ )
+ }
+
+
+ {selectedStep.completed ? (
+
+ {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 ")}
+
+
{selectedStep.rejected ? "Rejected" : "Approved"} by:
+ {(() => {
+ const assignee = workflowAssignees.find(
+ (assignee) => assignee.id === selectedStep.completedBy
+ );
+ return assignee ? (
+
+ ) : (
+ "Unknown"
+ );
+ })()}
+
+
No additional actions are required.
+
+
+ ) : (
+
+ One assignee is required to sign off to complete this step:
+
+ {workflowAssignees.filter(user => selectedStep.assignees.includes(user.id)).map(user => (
+
+
+
+ ))}
+
+
+ )}
+
+ {selectedStepIndex === currentStepIndex && !selectedStep.completed && !selectedStep.rejected &&
+
+
+
+
+ }
+
+
+
+
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/src/pages/approval-workflows/create.tsx b/src/pages/approval-workflows/create.tsx
new file mode 100644
index 00000000..b61d6e24
--- /dev/null
+++ b/src/pages/approval-workflows/create.tsx
@@ -0,0 +1,412 @@
+import Tip from "@/components/ApprovalWorkflows/Tip";
+import WorkflowForm from "@/components/ApprovalWorkflows/WorkflowForm";
+import Layout from "@/components/High/Layout";
+import Button from "@/components/Low/Button";
+import Input from "@/components/Low/Input";
+import Select from "@/components/Low/Select";
+import { ApprovalWorkflow, EditableApprovalWorkflow } from "@/interfaces/approval.workflow";
+import { Entity } from "@/interfaces/entity";
+import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser, User } from "@/interfaces/user";
+import { sessionOptions } from "@/lib/session";
+import { redirect, serialize } from "@/utils";
+import { requestUser } from "@/utils/api";
+import { getApprovalWorkflowsByEntities } from "@/utils/approval.workflows.be";
+import { getEntities } from "@/utils/entities.be";
+import { shouldRedirectHome } from "@/utils/navigation.disabled";
+import { getEntitiesUsers } 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 { useEffect, useState } from "react";
+import { BsChevronLeft, BsTrash } from "react-icons/bs";
+import { FaRegClone } from "react-icons/fa6";
+import { MdFormatListBulletedAdd } from "react-icons/md";
+import { toast, ToastContainer } from "react-toastify";
+import { v4 as uuidv4 } from 'uuid';
+
+export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
+ const user = await requestUser(req, res)
+ if (!user) return redirect("/login")
+
+ if (shouldRedirectHome(user) || !["admin", "developer", "corporate", "mastercorporate"].includes(user.type))
+ return redirect("/")
+
+ const userEntitiesWithLabel = await getEntities(user.entities.map(entity => entity.id));
+
+ const allConfiguredWorkflows = await getApprovalWorkflowsByEntities("configured-workflows", userEntitiesWithLabel.map(entity => entity.id));
+
+ const approverTypes = ["teacher", "corporate", "mastercorporate"];
+
+ if (user.type === "developer") {
+ approverTypes.push("developer");
+ }
+
+ const userEntitiesApprovers = await getEntitiesUsers(userEntitiesWithLabel.map(entity => entity.id), { type: { $in: approverTypes } }) as (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
+
+ return {
+ props: serialize({
+ user,
+ allConfiguredWorkflows,
+ userEntitiesWithLabel,
+ userEntitiesApprovers,
+ }),
+ };
+}, sessionOptions);
+
+interface Props {
+ user: User,
+ allConfiguredWorkflows: EditableApprovalWorkflow[],
+ userEntitiesWithLabel: Entity[],
+ userEntitiesApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
+}
+
+export default function Home({ user, allConfiguredWorkflows, userEntitiesWithLabel, userEntitiesApprovers }: Props) {
+ const [workflows, setWorkflows] = useState(allConfiguredWorkflows);
+ const [selectedWorkflowId, setSelectedWorkflowId] = useState(undefined);
+ const [entityId, setEntityId] = useState(null);
+ const [entityApprovers, setEntityApprovers] = useState<(TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[]>([]);
+ const [entityAvailableFormIntakers, setEntityAvailableFormIntakers] = useState<(TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[]>([]);
+ const [isAdding, setIsAdding] = useState(false); // used to temporary timeout new workflow button. With animations, clicking too fast might cause state inconsistencies between renders.
+ const [isLoading, setIsLoading] = useState(false);
+ const [isRedirecting, setIsRedirecting] = useState(false);
+
+ const router = useRouter();
+
+ useEffect(() => {
+ if (entityId) {
+ setEntityApprovers(
+ userEntitiesApprovers.filter(approver =>
+ approver.entities.some(entity => entity.id === entityId)
+ )
+ );
+ }
+ }, [entityId, userEntitiesApprovers]);
+
+ useEffect(() => {
+ if (entityId) {
+ // Get all workflows for the selected entity
+ const workflowsForEntity = workflows.filter(wf => wf.entityId === entityId);
+
+ // For all workflows except the current one, collect the first step assignees
+ const assignedFormIntakers = workflowsForEntity.reduce((acc, wf) => {
+ if (wf.id === selectedWorkflowId) return acc; // skip current workflow so its selection isn’t removed
+
+ const formIntakeStep = wf.steps.find(step => step.stepType === "form-intake");
+ if (formIntakeStep) {
+ // Only consider non-null assignees
+ const validAssignees = formIntakeStep.assignees.filter(
+ (assignee): assignee is string => !!assignee
+ );
+ return acc.concat(validAssignees);
+ }
+ return acc;
+ }, []);
+
+ // Now filter out any user from entityApprovers whose id is in the assignedFormIntakers list.
+ // (The selected one in the current workflow is allowed even if it is in the list.)
+ const availableFormIntakers = entityApprovers.filter(assignee =>
+ !assignedFormIntakers.includes(assignee.id)
+ );
+
+ setEntityAvailableFormIntakers(availableFormIntakers);
+ }
+ }, [entityId, entityApprovers, workflows, selectedWorkflowId]);
+
+
+ const currentWorkflow = workflows.find(wf => wf.id === selectedWorkflowId);
+
+ const ENTITY_OPTIONS = userEntitiesWithLabel.map(entity => ({
+ label: entity.label,
+ value: entity.id,
+ filter: (x: EditableApprovalWorkflow) => x.entityId === entity.id,
+ }));
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsLoading(true);
+
+ if (workflows.length === 0) {
+ setIsLoading(false);
+ return;
+ }
+
+ for (const workflow of workflows) {
+ for (const step of workflow.steps) {
+ if (step.assignees.every(x => !x)) {
+ toast.warning("There are empty steps in at least one of the configured workflows.");
+ setIsLoading(false);
+ return;
+ }
+ }
+ }
+
+ const filteredWorkflows: ApprovalWorkflow[] = workflows.map(workflow => ({
+ ...workflow,
+ steps: workflow.steps.map(step => ({
+ ...step,
+ assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined)
+ }))
+ }));
+
+ const requestData = { filteredWorkflows, userEntitiesWithLabel };
+
+ axios
+ .post(`/api/approval-workflows/create`, requestData)
+ .then(() => {
+ toast.success("Approval Workflows created successfully.");
+ setIsRedirecting(true);
+ router.push("/approval-workflows");
+ })
+ .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 create Approval Workflows!");
+ }
+ else {
+ toast.error("Something went wrong, please try again later.");
+ }
+ setIsLoading(false);
+ console.log("Submitted Values:", filteredWorkflows);
+ return;
+ })
+ };
+
+ const handleAddNewWorkflow = () => {
+ if (isAdding) return;
+ setIsAdding(true);
+
+ const newId = uuidv4(); // this id is only used in UI. it is ommited on submission to DB and lets mongo handle unique id.
+ const newWorkflow: EditableApprovalWorkflow = {
+ id: newId,
+ name: "",
+ entityId: "",
+ modules: [],
+ requester: user.id,
+ startDate: Date.now(),
+ status: "pending",
+ steps: [
+ { key: 9998, stepType: "form-intake", stepNumber: 1, completed: false, firstStep: true, finalStep: false, assignees: [null] },
+ { key: 9999, stepType: "approval-by", stepNumber: 2, completed: false, firstStep: false, finalStep: true, assignees: [null] },
+ ],
+ };
+ setWorkflows((prev) => [...prev, newWorkflow]);
+ handleSelectWorkflow(newId);
+
+ setTimeout(() => setIsAdding(false), 300);
+ };
+
+ const onWorkflowChange = (updatedWorkflow: EditableApprovalWorkflow) => {
+ setWorkflows(prev =>
+ prev.map(wf => (wf.id === updatedWorkflow.id ? updatedWorkflow : wf))
+ );
+ }
+
+ const handleSelectWorkflow = (id: string | undefined) => {
+ setSelectedWorkflowId(id);
+ const selectedWorkflow = workflows.find(wf => wf.id === id);
+ if (selectedWorkflow) {
+ setEntityId(selectedWorkflow.entityId || null);
+ } else {
+ setEntityId(null);
+ }
+ };
+
+ const handleCloneWorkflow = (id: string) => {
+ const workflowToClone = workflows.find(wf => wf.id === id);
+ if (!workflowToClone) return;
+
+ const newId = uuidv4();
+
+ const clonedWorkflow: EditableApprovalWorkflow = {
+ ...workflowToClone,
+ id: newId,
+ steps: workflowToClone.steps.map(step => ({
+ ...step,
+ assignees: step.firstStep ? [null] : [...step.assignees], // we can't have more than one form intaker per teacher per entity
+ })),
+ };
+
+ setWorkflows(prev => {
+ const updatedWorkflows = [...prev, clonedWorkflow];
+ setSelectedWorkflowId(newId);
+ setEntityId(clonedWorkflow.entityId || null);
+ return updatedWorkflows;
+ });
+ };
+
+ const handleDeleteWorkflow = (id: string) => {
+ if (!confirm(`Are you sure you want to delete this Approval Workflow?`)) return;
+
+ const updatedWorkflows = workflows.filter(wf => wf.id !== id);
+
+ setWorkflows(updatedWorkflows);
+
+ if (selectedWorkflowId === id) {
+ handleSelectWorkflow(updatedWorkflows.find(wf => wf.id)?.id);
+ }
+ };
+
+ const handleEntityChange = (wf: EditableApprovalWorkflow, entityId: string) => {
+ const updatedWorkflow = {
+ ...wf,
+ entityId: entityId,
+ steps: wf.steps.map(step => ({
+ ...step,
+ assignees: step.assignees.map(() => null)
+ }))
+ }
+ onWorkflowChange(updatedWorkflow);
+ }
+
+ return (
+ <>
+
+ Configure Workflows | EnCoach
+
+
+
+
+
+
+
+
+
+
+
{"Configure Approval Workflows"}
+
+
+
+
+
+
+
+
+ {workflows.length !== 0 && }
+
+
+ {workflows.map((workflow) => (
+
+
+ ))}
+
+
+
+
+ >
+ );
+}
diff --git a/src/pages/approval-workflows/index.tsx b/src/pages/approval-workflows/index.tsx
new file mode 100644
index 00000000..fbd84ed6
--- /dev/null
+++ b/src/pages/approval-workflows/index.tsx
@@ -0,0 +1,417 @@
+import Tip from "@/components/ApprovalWorkflows/Tip";
+import Layout from "@/components/High/Layout";
+import Button from "@/components/Low/Button";
+import Input from "@/components/Low/Input";
+import Select from "@/components/Low/Select";
+import useApprovalWorkflows from "@/hooks/useApprovalWorkflows";
+import { Module, ModuleTypeLabels } from "@/interfaces";
+import { ApprovalWorkflow, ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel, StepTypeLabel } from "@/interfaces/approval.workflow";
+import { Entity, EntityWithRoles } from "@/interfaces/entity";
+import { User } from "@/interfaces/user";
+import { sessionOptions } from "@/lib/session";
+import { redirect, serialize } from "@/utils";
+import { requestUser } from "@/utils/api";
+import { getApprovalWorkflows } from "@/utils/approval.workflows.be";
+import { getEntities } from "@/utils/entities.be";
+import { shouldRedirectHome } from "@/utils/navigation.disabled";
+import { getSpecificUsers } from "@/utils/users.be";
+import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
+import axios from "axios";
+import clsx from "clsx";
+import { withIronSessionSsr } from "iron-session/next";
+import Head from "next/head";
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { BsTrash } from "react-icons/bs";
+import { FaRegEdit } from "react-icons/fa";
+import { IoIosAddCircleOutline } from "react-icons/io";
+import { toast, ToastContainer } from "react-toastify";
+
+const columnHelper = createColumnHelper();
+
+export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
+ 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 workflows = await getApprovalWorkflows("active-workflows");
+
+ const allAssigneeIds: string[] = [
+ ...new Set(
+ workflows
+ .map(workflow => workflow.steps
+ .map(step => step.assignees)
+ .flat()
+ ).flat()
+ )
+ ];
+
+ return {
+ props: serialize({
+ user,
+ initialWorkflows: workflows,
+ workflowsAssignees: await getSpecificUsers(allAssigneeIds),
+ userEntitiesWithLabel: await getEntities(user.entities.map(entity => entity.id)),
+ }),
+ };
+}, sessionOptions);
+
+const StatusClassNames: { [key in ApprovalWorkflowStatus]: string } = {
+ approved: "bg-green-100 text-green-800 border border-green-300 before:content-[''] before:w-2 before:h-2 before:bg-green-500 before:rounded-full before:inline-block before:mr-2",
+ pending: "bg-orange-100 text-orange-800 border border-orange-300 before:content-[''] before:w-2 before:h-2 before:bg-orange-500 before:rounded-full before:inline-block before:mr-2",
+ rejected: "bg-red-100 text-red-800 border border-red-300 before:content-[''] before:w-2 before:h-2 before:bg-red-500 before:rounded-full before:inline-block before:mr-2",
+};
+
+type CustomStatus = ApprovalWorkflowStatus | undefined;
+type CustomEntity = EntityWithRoles["id"] | undefined;
+
+const STATUS_OPTIONS = [
+ {
+ label: "Approved",
+ value: "approved",
+ filter: (x: ApprovalWorkflow) => x.status === "approved",
+ },
+ {
+ label: "Pending",
+ value: "pending",
+ filter: (x: ApprovalWorkflow) => x.status === "pending",
+ },
+ {
+ label: "Rejected",
+ value: "rejected",
+ filter: (x: ApprovalWorkflow) => x.status === "rejected",
+ },
+];
+
+interface Props {
+ user: User,
+ initialWorkflows: ApprovalWorkflow[],
+ workflowsAssignees: User[],
+ userEntitiesWithLabel: Entity[],
+}
+
+export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAssignees, userEntitiesWithLabel }: Props) {
+
+ const { workflows, reload } = useApprovalWorkflows();
+ const currentWorkflows = workflows || initialWorkflows;
+
+ const [filteredWorkflows, setFilteredWorkflows] = useState([]);
+
+ const [statusFilter, setStatusFilter] = useState(undefined);
+ const [entityFilter, setEntityFilter] = useState(undefined);
+ const [nameFilter, setNameFilter] = useState("");
+
+ const ENTITY_OPTIONS = [
+ ...userEntitiesWithLabel
+ .map(entity => ({
+ label: entity.label,
+ value: entity.id,
+ filter: (x: ApprovalWorkflow) => x.entityId === entity.id,
+ }))
+ .sort((a, b) => a.label.localeCompare(b.label)),
+ ];
+
+ useEffect(() => {
+ const filters: Array<(workflow: ApprovalWorkflow) => boolean> = [];
+
+ if (statusFilter && statusFilter !== undefined) {
+ const statusOption = STATUS_OPTIONS.find((x) => x.value === statusFilter);
+ if (statusOption && statusOption.filter) {
+ filters.push(statusOption.filter);
+ }
+ }
+
+ if (entityFilter && entityFilter !== undefined) {
+ const entityOption = ENTITY_OPTIONS.find((x) => x.value === entityFilter);
+ if (entityOption && entityOption.filter) {
+ filters.push(entityOption.filter);
+ }
+ }
+
+ if (nameFilter.trim() !== "") {
+ const nameFilterFunction = (workflow: ApprovalWorkflow) =>
+ workflow.name.toLowerCase().includes(nameFilter.toLowerCase());
+ filters.push(nameFilterFunction);
+ }
+
+ // Apply all filters
+ const filtered = currentWorkflows.filter(workflow => filters.every(filterFn => filterFn(workflow)));
+ setFilteredWorkflows(filtered);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [currentWorkflows, statusFilter, entityFilter, nameFilter]);
+
+
+ const handleNameFilterChange = (name: ApprovalWorkflow["name"]) => {
+ setNameFilter(name);
+ };
+
+ const deleteApprovalWorkflow = (id: string | undefined, name: string) => {
+ if (id === undefined) return;
+ if (!confirm(`Are you sure you want to delete this Approval Workflow?`)) return;
+
+ axios
+ .delete(`/api/approval-workflows/${id}`)
+ .then(() => {
+ toast.success(`Successfully deleted ${name} Approval Workflow.`);
+ reload();
+ })
+ .catch((reason) => {
+ if (reason.response.status === 404) {
+ toast.error("Approval Workflow not found!");
+ } else if (reason.response.status === 403) {
+ toast.error("You do not have permission to delete an Approval Workflow!");
+ } else {
+ toast.error("Something went wrong, please try again later.");
+ }
+ return;
+ })
+ };
+
+ const columns = [
+ columnHelper.accessor("name", {
+ header: "EXAM NAME",
+ cell: (info) => (
+
+ {info.getValue()}
+
+ ),
+ }),
+ columnHelper.accessor("modules", {
+ header: "MODULES",
+ cell: (info) => (
+
+ {info.getValue().map((module: Module, index: number) => (
+
+ {ModuleTypeLabels[module]}
+
+ ))}
+
+ ),
+ }),
+ columnHelper.accessor("status", {
+ header: "STATUS",
+ cell: (info) => (
+
+ {ApprovalWorkflowStatusLabel[info.getValue()]}
+
+ ),
+ }),
+ columnHelper.accessor("entityId", {
+ header: "ENTITY",
+ cell: (info) => (
+
+ {userEntitiesWithLabel.find((entity) => entity.id === info.getValue())?.label}
+
+ ),
+ }),
+ columnHelper.accessor("steps", {
+ id: "currentAssignees",
+ header: "CURRENT ASSIGNEES",
+ 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);
+ return assignee?.name || "Unknown Assignee";
+ });
+
+ return (
+
+ {assignees?.map((assigneeName: string, index: number) => (
+
+ {assigneeName}
+
+ ))}
+
+ );
+ },
+ }),
+ columnHelper.accessor("steps", {
+ id: "currentStep",
+ header: "CURRENT STEP",
+ cell: (info) => {
+ const steps = info.row.original.steps;
+ const currentStep = steps.find((step) => !step.completed);
+ const rejected = steps.find((step) => step.rejected);
+
+ return (
+
+ {currentStep && !rejected
+ ? `Step ${currentStep.stepNumber}: ${StepTypeLabel[currentStep.stepType]}`
+ : "Completed"}
+
+ );
+ },
+ }),
+ columnHelper.accessor("steps", {
+ header: "ACTIONS",
+ id: "actions",
+ cell: ({ row }) => {
+ const steps = row.original.steps;
+ const currentStep = steps.find((step) => !step.completed);
+ const rejected = steps.find((step) => step.rejected);
+
+ return (
+
+
+
+ {currentStep && !rejected && (
+ e.stopPropagation()}
+ data-tip="Edit"
+ href={`/approval-workflows/${row.original._id?.toString()}/edit`}
+ className="cursor-pointer tooltip"
+ >
+
+
+ )}
+
+ );
+ },
+ })
+ ];
+
+ const table = useReactTable({
+ data: filteredWorkflows,
+ columns: columns,
+ getCoreRowModel: getCoreRowModel(),
+ });
+
+ return (
+ <>
+
+ Approval Workflows Panel | EnCoach
+
+
+
+
+
+ Approval Workflows
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+ |
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ |
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => (
+ window.location.href = `/approval-workflows/${row.original._id?.toString()}`}
+ style={{ cursor: "pointer" }}
+ className="bg-purple-50"
+ >
+ {row.getVisibleCells().map((cell, cellIndex) => {
+ const lastCellIndex = row.getVisibleCells().length - 1;
+
+ let cellClasses = "pl-3 pr-4 py-2 border-y-2 border-mti-purple-light border-opacity-60";
+ if (cellIndex === 0) {
+ cellClasses += " border-l-2 rounded-l-2xl";
+ }
+ if (cellIndex === lastCellIndex) {
+ cellClasses += " border-r-2 rounded-r-2xl";
+ }
+
+ return (
+ |
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ |
+ );
+ })}
+
+ ))}
+
+
+
+
+ >
+ );
+}
diff --git a/src/resources/entityPermissions.ts b/src/resources/entityPermissions.ts
index b9c6c165..76e1b169 100644
--- a/src/resources/entityPermissions.ts
+++ b/src/resources/entityPermissions.ts
@@ -65,7 +65,8 @@ export type RolePermission =
"view_student_record" |
"download_student_record" |
"pay_entity" |
- "view_payment_record"
+ "view_payment_record" |
+ "view_approval_workflows"
export const DEFAULT_PERMISSIONS: RolePermission[] = [
"view_students",
@@ -74,7 +75,8 @@ export const DEFAULT_PERMISSIONS: RolePermission[] = [
"view_classrooms",
"view_entity_roles",
"view_statistics",
- "download_statistics_report"
+ "download_statistics_report",
+ "view_approval_workflows"
]
export const ADMIN_PERMISSIONS: RolePermission[] = [
diff --git a/src/utils/approval.workflows.be.ts b/src/utils/approval.workflows.be.ts
new file mode 100644
index 00000000..baf02bfd
--- /dev/null
+++ b/src/utils/approval.workflows.be.ts
@@ -0,0 +1,116 @@
+import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
+import client from "@/lib/mongodb";
+import { ObjectId } from "mongodb";
+
+const db = client.db(process.env.MONGODB_DB);
+
+export const getApprovalWorkflows = async (collection: string, ids?: string[]) => {
+ return await db
+ .collection(collection)
+ .find(ids ? { _id: { $in: ids.map((id) => new ObjectId(id)) } } : {})
+ .toArray();
+};
+
+export const getApprovalWorkflow = async (collection: string, id: string) => {
+ return await db.collection(collection).findOne({ _id: new ObjectId(id) });
+};
+
+export const getApprovalWorkflowsByEntities = async (collection: string, ids: string[]) => {
+ return await db
+ .collection(collection)
+ .find({ entityId: { $in: ids } })
+ .toArray();
+};
+
+export const getApprovalWorkflowByFormIntaker = async (entityId: string, formIntakerId: string) => {
+ return await db.collection("configured-workflows").findOne({
+ entityId,
+ steps: {
+ $elemMatch: {
+ stepNumber: 1,
+ assignees: formIntakerId,
+ },
+ },
+ });
+};
+
+export const getFormIntakersByEntity = async (entityId: string) => {
+ const results = await db
+ .collection("configured-workflows")
+ .aggregate([
+ // 1. Match workflows with the provided entityId
+ { $match: { entityId } },
+ // 2. Unwind the steps array to process each step individually
+ { $unwind: "$steps" },
+ // 3. Filter for the first step (you could also check for a "firstStep" flag if you prefer)
+ { $match: { "steps.stepNumber": 1 } },
+ // 4. Unwind the assignees array so that each assignee is handled separately
+ { $unwind: "$steps.assignees" },
+ // 5. Group by null (i.e. all documents) and add each assignee to a set to remove duplicates
+ { $group: { _id: null, assignees: { $addToSet: "$steps.assignees" } } },
+ ])
+ .toArray();
+
+ // Return the assignees if the aggregation found any; otherwise return an empty array
+ return results.length > 0 ? results[0].assignees : [];
+};
+
+export const createApprovalWorkflow = async (collection: string, workflow: ApprovalWorkflow) => {
+ const { _id, ...workflowWithoutId } = workflow as ApprovalWorkflow;
+ return await db.collection(collection).insertOne(workflowWithoutId);
+};
+
+export const createApprovalWorkflows = async (collection: string, workflows: ApprovalWorkflow[]) => {
+ if (workflows.length === 0) return;
+ const workflowsWithoutIds: ApprovalWorkflow[] = workflows.map(({ _id, ...wfs }) => wfs);
+ return await db.collection(collection).insertMany(workflowsWithoutIds);
+};
+
+export const updateApprovalWorkflow = async (collection: string, workflow: ApprovalWorkflow) => {
+ const { _id, ...workflowWithoutId } = workflow as ApprovalWorkflow;
+ return await db.collection(collection).replaceOne({ _id: new ObjectId(_id) }, workflowWithoutId);
+};
+
+export const updateApprovalWorkflows = async (collection: string, workflows: ApprovalWorkflow[]) => {
+ const bulkOperations = workflows.map((workflow) => {
+ const { _id, ...workflowWithoutId } = workflow;
+ return {
+ replaceOne: {
+ filter: { _id: new ObjectId(_id) },
+ replacement: workflowWithoutId,
+ },
+ };
+ });
+
+ return await db.collection(collection).bulkWrite(bulkOperations);
+};
+
+export const deleteApprovalWorkflow = async (collection: string, id: string) => {
+ return await db.collection(collection).deleteOne({ _id: new ObjectId(id) });
+};
+
+export const replaceApprovalWorkflowsByEntities = async (workflows: ApprovalWorkflow[], entityIds: string[]) => {
+ // 1. Keep track of the _id values of all workflows we want to end up with
+ const finalIds = new Set();
+
+ // 2. Process incoming workflows
+ for (const workflow of workflows) {
+ if (workflow._id) {
+ // Replace the existing ones
+ await updateApprovalWorkflow("configured-workflows", workflow);
+ finalIds.add(workflow._id.toString());
+ } else {
+ // Insert if no _id
+ const insertResult = await createApprovalWorkflow("configured-workflows", workflow);
+ finalIds.add(insertResult.insertedId.toString());
+ }
+ }
+
+ // 3. Delete any existing workflow (within these entityIds) that wasn't in the final list
+ await db.collection("configured-workflows").deleteMany({
+ _id: {
+ $nin: Array.from(finalIds).map((id) => new ObjectId(id)),
+ },
+ entityId: { $in: entityIds },
+ });
+};