Merge branch 'approval-workflows' into develop

This commit is contained in:
Joao Correia
2025-02-05 17:26:58 +00:00
33 changed files with 3109 additions and 17 deletions

4
.gitignore vendored
View File

@@ -40,4 +40,6 @@ next-env.d.ts
.env
.yarn/*
.history*
__ENV.js
__ENV.js
settings.json

View File

@@ -0,0 +1,32 @@
import Image from "next/image";
import React from "react";
import { FaRegUser } from "react-icons/fa";
interface Props {
prefix: string;
name: string;
profileImage: string;
}
export default function RequestedBy({ prefix, name, profileImage }: Props) {
return (
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
<FaRegUser className="text-mti-purple-dark size-5"/>
</div>
<div>
<p className="text-sm font-medium text-gray-800">Requested by</p>
<div className="flex items-center space-x-2">
<p className="text-xs font-medium text-gray-800">{prefix} {name}</p>
<img
src={profileImage ? profileImage : "/defaultAvatar.png"}
alt={name}
width={24}
height={24}
className="w-6 h-6 rounded-full border-[1px] border-gray-400 border-opacity-50"
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,41 @@
import React from "react";
import { PiCalendarDots } from "react-icons/pi";
interface Props {
date: number;
}
export default function StartedOn({ date }: Props) {
const formattedDate = new Date(date);
const yearMonthDay = formattedDate.toISOString().split("T")[0];
const fullDateTime = formattedDate.toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
return (
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
<PiCalendarDots className="text-mti-purple-dark size-7" />
</div>
<div>
<p className="pb-1 text-sm font-medium text-gray-800">Started on</p>
<div className="flex items-center">
<p
className="text-xs font-medium text-gray-800"
title={fullDateTime}
>
{yearMonthDay}
</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,23 @@
import { ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel } from "@/interfaces/approval.workflow";
import React from "react";
import { RiProgress5Line } from "react-icons/ri";
interface Props {
status: ApprovalWorkflowStatus;
}
export default function Status({ status }: Props) {
return (
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
<RiProgress5Line className="text-mti-purple-dark size-7"/>
</div>
<div>
<p className="pb-1 text-sm font-medium text-gray-800">Status</p>
<div className="flex items-center">
<p className="text-xs font-medium text-gray-800">{ApprovalWorkflowStatusLabel[status]}</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,14 @@
import { MdTipsAndUpdates } from "react-icons/md";
interface Props {
text: string;
}
export default function Tip({ text }: Props) {
return (
<div className="flex flex-row gap-3 text-gray-500 font-medium">
<MdTipsAndUpdates size={25} />
<p>{text}</p>
</div>
);
};

View File

@@ -0,0 +1,24 @@
import Image from "next/image";
interface Props {
prefix: string;
name: string;
profileImage: string;
textSize?: string;
}
export default function UserWithProfilePic({ prefix, name, profileImage, textSize }: Props) {
const textClassName = `${textSize ? textSize : "text-xs"} font-medium`
return (
<div className="flex items-center space-x-2">
<p className={textClassName}>{prefix} {name}</p>
<img
src={profileImage ? profileImage : "/defaultAvatar.png"}
alt={name}
width={24}
height={24}
className="rounded-full h-auto border-[1px] border-gray-400 border-opacity-50"
/>
</div>
);
};

View File

@@ -0,0 +1,136 @@
import { EditableWorkflowStep } from "@/interfaces/approval.workflow";
import Option from "@/interfaces/option";
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser } from "@/interfaces/user";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import { AiOutlineUserAdd } from "react-icons/ai";
import { BsTrash } from "react-icons/bs";
import { LuGripHorizontal } from "react-icons/lu";
import WorkflowStepNumber from "./WorkflowStepNumber";
import WorkflowStepSelects from "./WorkflowStepSelects";
interface Props extends Pick<EditableWorkflowStep, 'stepNumber' | 'assignees' | 'finalStep' | 'onDelete'> {
entityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
isCompleted: boolean,
}
export default function WorkflowEditableStepComponent({
stepNumber,
assignees = [null],
finalStep,
onDelete,
onSelectChange,
entityApprovers,
isCompleted,
}: Props) {
const [selects, setSelects] = useState<(Option | null | undefined)[]>([null]);
const [isAdding, setIsAdding] = useState(false);
const approverOptions: Option[] = useMemo(() =>
entityApprovers
.map((approver) => ({
value: approver.id,
label: approver.name,
icon: () => <img src={approver.profilePicture} alt={approver.name} />
}))
.sort((a, b) => a.label.localeCompare(b.label)),
[entityApprovers]
);
useEffect(() => {
if (assignees && assignees.length > 0) {
const initialSelects = assignees.map((assignee) =>
typeof assignee === 'string' ? approverOptions.find(option => option.value === assignee) || null : null
);
setSelects((prevSelects) => {
// This is needed to avoid unnecessary re-renders which can cause warning of a child component being re-rendered while parent is in the midle of also re-rendering.
const areEqual = initialSelects.length === prevSelects.length && initialSelects.every((option, idx) => option?.value === prevSelects[idx]?.value);
if (!areEqual) {
return initialSelects;
}
return prevSelects;
});
}
}, [assignees, approverOptions]);
const selectedValues = useMemo(() =>
selects.filter((opt): opt is Option => !!opt).map(opt => opt.value),
[selects]
);
const availableApproverOptions = useMemo(() =>
approverOptions.filter(opt => !selectedValues.includes(opt.value)),
[approverOptions, selectedValues]
);
const handleAddSelectComponent = () => {
setIsAdding(true); // I hate to use flags... but it was the only way i was able to prevent onSelectChange to cause parent component from re-rendering in the midle of EditableWorkflowStep rerender.
setSelects(prev => [...prev, null]);
};
useEffect(() => {
if (isAdding) {
onSelectChange(selects.length, selects.length - 1, null);
setIsAdding(false);
}
}, [selects.length, isAdding, onSelectChange]);
const handleSelectChangeAt = (numberOfSelects: number, index: number, option: Option | null) => {
const updated = [...selects];
updated[index] = option;
setSelects(updated);
onSelectChange(numberOfSelects, index, option);
};
return (
<div className="flex w-full">
<div className="flex flex-col items-center">
<WorkflowStepNumber stepNumber={stepNumber} completed={false} selected={false} />
{/* Vertical Bar connecting steps */}
{!finalStep && (
<div className="w-1 h-full min-h-10 bg-mti-purple-dark"></div>
)}
</div>
{stepNumber !== 1 && !finalStep && !isCompleted
? <LuGripHorizontal className="ml-3 mt-2 cursor-grab active:cursor-grabbing min-w-[25px] min-h-[25px]" />
: <div className="ml-3 mt-2" style={{ width: 25, height: 25 }}></div>
}
<div className="ml-10 mb-12">
<WorkflowStepSelects
approvers={availableApproverOptions}
selects={selects}
placeholder={stepNumber === 1 ? "Form Intake By:" : "Approval By:"}
onSelectChange={handleSelectChangeAt}
isCompleted={isCompleted}
/>
</div>
<div className="flex flex-row items-start mt-1.5 ml-3">
<button
type="button"
onClick={handleAddSelectComponent}
className="cursor-pointer"
>
<AiOutlineUserAdd className="size-7 hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
{stepNumber !== 1 && !finalStep && (
<button
className="cursor-pointer"
onClick={onDelete}
type="button"
>
<BsTrash className="size-6 mt-0.5 ml-3 hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,203 @@
import { EditableApprovalWorkflow, EditableWorkflowStep } from "@/interfaces/approval.workflow";
import Option from "@/interfaces/option";
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser } from "@/interfaces/user";
import { AnimatePresence, Reorder, motion } from "framer-motion";
import { FaRegCheckCircle, FaSpinner } from "react-icons/fa";
import { IoIosAddCircleOutline } from "react-icons/io";
import Button from "../Low/Button";
import Tip from "./Tip";
import WorkflowEditableStepComponent from "./WorkflowEditableStepComponent";
interface Props {
workflow: EditableApprovalWorkflow;
onWorkflowChange: (workflow: EditableApprovalWorkflow) => void;
entityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
entityAvailableFormIntakers?: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
isLoading: boolean;
isRedirecting?: boolean;
}
export default function WorkflowForm({ workflow, onWorkflowChange, entityApprovers, entityAvailableFormIntakers, isLoading, isRedirecting }: Props) {
const lastStep = workflow.steps[workflow.steps.length - 1];
const renumberSteps = (steps: EditableWorkflowStep[]): EditableWorkflowStep[] => {
return steps.map((step, index) => ({
...step,
stepNumber: index + 1,
}));
};
const addStep = () => {
const newStep: EditableWorkflowStep = {
key: Date.now(),
stepType: "approval-by",
stepNumber: workflow.steps.length,
completed: false,
assignees: [null],
firstStep: false,
finalStep: false,
};
const updatedSteps = [
...workflow.steps.slice(0, -1),
newStep,
lastStep
];
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
};
const handleDelete = (key: number | undefined) => {
if (!key) return;
const updatedSteps = workflow.steps.filter((step) => step.key !== key);
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
};
const handleSelectChange = (key: number | undefined, numberOfSelects: number, index: number, selectedOption: Option | null) => {
if (!key) return;
const updatedSteps = workflow.steps.map((step) => {
if (step.key !== key) return step;
const assignees = step.assignees ?? [];
let newAssignees = [...assignees];
if (numberOfSelects === assignees.length) { // means no new select was added and instead one was changed
newAssignees[index] = selectedOption?.value;
} else if (numberOfSelects === assignees.length + 1) { // means a new select was added
newAssignees.push(selectedOption?.value || null);
}
return { ...step, assignees: newAssignees };
});
onWorkflowChange({ ...workflow, steps: updatedSteps });
};
const handleReorder = (newOrder: EditableWorkflowStep[]) => {
let draggableIndex = 0;
const updatedSteps = workflow.steps.map((step) => {
if (!step.firstStep && !step.finalStep && !step.completed) {
return newOrder[draggableIndex++];
}
// Keep static steps as-is
return step;
});
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
};
return (
<>
{workflow.entityId && workflow.name &&
<div>
<div
className="flex flex-col gap-6"
>
<Tip text="Introduce here all the steps associated with this instance." />
<Button
color="purple"
variant="solid"
onClick={addStep}
type="button"
className="max-w-fit text-lg font-medium flex items-center gap-2 text-left mb-7"
>
<IoIosAddCircleOutline className="size-6" />
Add Step
</Button>
</div>
<Reorder.Group
axis="y"
values={workflow.steps}
onReorder={handleReorder}
className="flex flex-col gap-0"
>
<AnimatePresence>
{workflow.steps.map((step, index) =>
step.completed || step.firstStep || step.finalStep ? (
<motion.div
key={step.key}
layout
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 30 }}
transition={{ duration: 0.20 }}
>
<WorkflowEditableStepComponent
stepNumber={index + 1}
assignees={step.assignees}
finalStep={step.finalStep}
onDelete={() => handleDelete(step.key)}
onSelectChange={(numberOfSelects, idx, option) =>
handleSelectChange(step.key, numberOfSelects, idx, option)
}
entityApprovers={
step.stepNumber === 1 && entityAvailableFormIntakers
? entityAvailableFormIntakers
: entityApprovers
}
isCompleted={step.completed}
/>
</motion.div>
) : (
// Render non-completed steps as draggable items
<Reorder.Item
key={step.key}
value={step}
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 30 }}
transition={{ duration: 0.20 }}
layout
drag={!step.firstStep && !step.finalStep}
dragListener={!step.firstStep && !step.finalStep}
>
<WorkflowEditableStepComponent
stepNumber={index + 1}
assignees={step.assignees}
finalStep={step.finalStep}
onDelete={() => handleDelete(step.key)}
onSelectChange={(numberOfSelects, idx, option) =>
handleSelectChange(step.key, numberOfSelects, idx, option)
}
entityApprovers={
step.stepNumber === 1 && entityAvailableFormIntakers
? entityAvailableFormIntakers
: entityApprovers
}
isCompleted={step.completed}
/>
</Reorder.Item>
)
)}
<Button
type="submit"
color="purple"
variant="solid"
disabled={isLoading}
className="max-w-fit text-lg font-medium flex items-center gap-2 text-left -mt-4"
>
{isRedirecting ? (
<>
<FaSpinner className="animate-spin size-5" />
Redirecting...
</>
) : isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<FaRegCheckCircle className="size-5" />
Confirm Exam Workflow Pipeline
</>
)}
</Button>
</AnimatePresence>
</Reorder.Group>
</div>
}
</>
);
};

View File

@@ -0,0 +1,101 @@
import { getUserTypeLabel, getUserTypeLabelShort, WorkflowStep } from "@/interfaces/approval.workflow";
import WorkflowStepNumber from "./WorkflowStepNumber";
import clsx from "clsx";
import { RiThumbUpLine } from "react-icons/ri";
import { FaWpforms } from "react-icons/fa6";
import { User } from "@/interfaces/user";
import UserWithProfilePic from "./UserWithProfilePic";
interface Props extends WorkflowStep {
workflowAssignees: User[],
currentStep: boolean,
}
export default function WorkflowStepComponent({
workflowAssignees,
currentStep,
stepType,
stepNumber,
completed,
rejected = false,
completedBy,
assignees,
finalStep,
selected = false,
onClick,
}: Props) {
const completedByUser = workflowAssignees.find((assignee) => assignee.id === completedBy);
const assigneesUsers = workflowAssignees.filter(user => assignees.includes(user.id));
return (
<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} rejected={rejected} />
{/* Vertical Bar connecting steps */}
{!finalStep && (
<div className="absolute w-1 bg-mti-purple-dark -bottom-20 top-11"></div>
)}
</div>
<div className="mt-1.5">
{stepType === "approval-by" ? (
<RiThumbUpLine size={25} />
) : (
<FaWpforms size={25} />
)
}
</div>
<div className="mt-1 flex flex-col gap-0">
{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={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">{stepType === "approval-by" ? `Approval:` : `Form Intake:`} </p>
Waiting for previous steps...
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,31 @@
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' | 'rejected'>
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,
}
)}
>
{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} />
) : (
<span className="text-lg font-semibold">{stepNumber}</span>
)}
</div>
);
};

View File

@@ -0,0 +1,51 @@
import Option from "@/interfaces/option";
import Select from "../Low/Select";
interface Props {
approvers: Option[];
selects: (Option | null | undefined)[];
placeholder: string;
onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
isCompleted: boolean;
}
export default function WorkflowStepSelects({
approvers,
selects,
placeholder,
onSelectChange,
isCompleted,
}: Props) {
return (
<div
className={"flex flex-wrap gap-0"}
>
{selects.map((option, index) => {
let classes = "px-2 rounded-none";
if (index === 0 && selects.length === 1) {
classes += " rounded-l-2xl rounded-r-2xl";
} else if (index === 0) {
classes += " rounded-l-2xl";
} else if (index === selects.length - 1) {
classes += " rounded-r-2xl";
}
return (
<div key={index} className="w-[275px]">
<Select
options={approvers}
value={option}
onChange={(option) => onSelectChange(selects.length, index, option)}
placeholder={placeholder}
flat
isClearable
className={classes}
disabled={isCompleted}
/>
</div>
);
})}
</div>
);
}

View File

@@ -17,6 +17,7 @@ import ListeningComponents from "./listening/components";
import ReadingComponents from "./reading/components";
import SpeakingComponents from "./speaking/components";
import SectionPicker from "./Shared/SectionPicker";
import { getExamById } from "@/utils/exams";
const LevelSettings: React.FC = () => {
@@ -194,7 +195,7 @@ const LevelSettings: React.FC = () => {
category: s.settings.category
};
}).filter(part => part.exercises.length > 0),
isDiagnostic: false,
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
minTimer,
module: "level",
id: title,
@@ -213,6 +214,36 @@ const LevelSettings: React.FC = () => {
URL.revokeObjectURL(url);
});
const requestBody = await (async () => {
const handledExam = await getExamById("level", result.data.id);
return {
examAuthor: handledExam?.createdBy ?? "Unknown Author",
examEntities: handledExam?.entities ?? [],
examId: handledExam?.id ?? "Unknown ID",
examModule: "level"
};
})();
await axios
.post(`/api/approval-workflows`, requestBody)
.then((response) => {
if (response.status === 200) {
toast.success(`Approval Workflows for exam have been successfully created`);
} else if (response.status === 207) {
toast.warning(
`Approval Workflows were partially created. Exam author might not have a configured workflow for all its entities.`
);
}
})
.catch((reason) => {
if (reason.response?.status === 404) {
toast.error("No configured workflow found for examAuthor for any of its entities.");
} else {
toast.error(
"Something went wrong while creating approval workflow, please try again later."
);
}
});
} catch (error: any) {
console.error('Error submitting exam:', error);
toast.error(

View File

@@ -17,6 +17,7 @@ import { usePersistentExamStore } from "@/stores/exam";
import { playSound } from "@/utils/sound";
import { toast } from "react-toastify";
import ListeningComponents from "./components";
import { getExamById } from "@/utils/exams";
const ListeningSettings: React.FC = () => {
const router = useRouter();
@@ -137,7 +138,7 @@ const ListeningSettings: React.FC = () => {
category: s.settings.category
};
}),
isDiagnostic: false,
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
minTimer,
module: "listening",
id: title,
@@ -151,6 +152,36 @@ const ListeningSettings: React.FC = () => {
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
const requestBody = await (async () => {
const handledExam = await getExamById("listening", result.data.id);
return {
examAuthor: handledExam?.createdBy ?? "Unknown Author",
examEntities: handledExam?.entities ?? [],
examId: handledExam?.id ?? "Unknown ID",
examModule: "listening"
};
})();
await axios
.post(`/api/approval-workflows`, requestBody)
.then((response) => {
if (response.status === 200) {
toast.success(`Approval Workflows for exam have been successfully created`);
} else if (response.status === 207) {
toast.warning(
`Approval Workflows were partially created. Exam author might not have a configured workflow for all its entities.`
);
}
})
.catch((reason) => {
if (reason.response?.status === 404) {
toast.error("No configured workflow found for examAuthor for any of its entities.");
} else {
toast.error(
"Something went wrong while creating approval workflow, please try again later."
);
}
});
} else {
toast.error('No audio sections found in the exam! Please either import them or generate them.');
}

View File

@@ -12,6 +12,7 @@ import axios from "axios";
import { playSound } from "@/utils/sound";
import { toast } from "react-toastify";
import ReadingComponents from "./components";
import { getExamById } from "@/utils/exams";
const ReadingSettings: React.FC = () => {
const router = useRouter();
@@ -46,15 +47,15 @@ const ReadingSettings: React.FC = () => {
{
label: "Preset: Reading Passage 1",
value: "Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas."
},
{
label: "Preset: Reading Passage 2",
},
{
label: "Preset: Reading Passage 2",
value: "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views."
},
{
},
{
label: "Preset: Reading Passage 3",
value: "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas."
}
}
];
const canPreviewOrSubmit = sections.some(
@@ -75,7 +76,7 @@ const ReadingSettings: React.FC = () => {
category: localSettings.category
};
}),
isDiagnostic: false,
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
minTimer,
module: "reading",
id: title,
@@ -89,11 +90,37 @@ const ReadingSettings: React.FC = () => {
.then((result) => {
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
return getExamById("reading", result.data.id);
})
.then((handledExam) => {
const requestBody = {
examAuthor: handledExam?.createdBy ?? "Unknown Author",
examEntities: handledExam?.entities ?? [],
examId: handledExam?.id ?? "Unknown ID",
examModule: "reading"
};
return axios.post(`/api/approval-workflows`, requestBody);
})
.then((response) => {
if (response.status === 200) {
toast.success(`Approval Workflows for exam have been successfully created`);
} else if (response.status === 207) {
toast.warning(
`Approval Workflows were partially created. Exam author might not have a configured workflow for all its entities.`
);
}
})
.catch((error) => {
console.log(error);
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later.");
})
if (error.response && error.response.status === 404) {
toast.error("No configured workflow found for examAuthor for any of its entities.");
} else {
toast.error(
error.response?.data?.error ||
"Something went wrong, please try again later."
);
}
});
}
const preview = () => {

View File

@@ -11,6 +11,7 @@ import openDetachedTab from "@/utils/popout";
import axios from "axios";
import { playSound } from "@/utils/sound";
import SpeakingComponents from "./components";
import { getExamById } from "@/utils/exams";
export interface Avatar {
name: string;
@@ -180,7 +181,7 @@ const SpeakingSettings: React.FC = () => {
minTimer,
module: "speaking",
id: title,
isDiagnostic: false,
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
variant: undefined,
difficulty,
instructorGender: "varied",
@@ -194,6 +195,36 @@ const SpeakingSettings: React.FC = () => {
Array.from(urlMap.values()).forEach(url => {
URL.revokeObjectURL(url);
});
const requestBody = await (async () => {
const handledExam = await getExamById("speaking", result.data.id);
return {
examAuthor: handledExam?.createdBy ?? "Unknown Author",
examEntities: handledExam?.entities ?? [],
examId: handledExam?.id ?? "Unknown ID",
examModule: "speaking"
};
})();
await axios
.post(`/api/approval-workflows`, requestBody)
.then((response) => {
if (response.status === 200) {
toast.success(`Approval Workflows for exam have been successfully created`);
} else if (response.status === 207) {
toast.warning(
`Approval Workflows were partially created. Exam author might not have a configured workflow for all its entities.`
);
}
})
.catch((reason) => {
if (reason.response?.status === 404) {
toast.error("No configured workflow found for examAuthor for any of its entities.");
} else {
toast.error(
"Something went wrong while creating approval workflow, please try again later."
);
}
});
} catch (error: any) {
toast.error(

View File

@@ -12,6 +12,8 @@ import axios from "axios";
import { playSound } from "@/utils/sound";
import { toast } from "react-toastify";
import WritingComponents from "./components";
import { getExamById } from "@/utils/exams";
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
const WritingSettings: React.FC = () => {
const router = useRouter();
@@ -129,7 +131,7 @@ const WritingSettings: React.FC = () => {
minTimer,
module: "writing",
id: title,
isDiagnostic: false,
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
variant: undefined,
difficulty,
private: isPrivate,
@@ -140,6 +142,36 @@ const WritingSettings: React.FC = () => {
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
const requestBody = await (async () => {
const handledExam = await getExamById("writing", result.data.id);
return {
examAuthor: handledExam?.createdBy ?? "Unknown Author",
examEntities: handledExam?.entities ?? [],
examId: handledExam?.id ?? "Unknown ID",
examModule: "writing"
};
})();
await axios
.post(`/api/approval-workflows`, requestBody)
.then((response) => {
if (response.status === 200) {
toast.success(`Approval Workflows for exam have been successfully created`);
} else if (response.status === 207) {
toast.warning(
`Approval Workflows were partially created. Exam author might not have a configured workflow for all its entities.`
);
}
})
.catch((reason) => {
if (reason.response?.status === 404) {
toast.error("No configured workflow found for examAuthor for any of its entities.");
} else {
toast.error(
"Something went wrong while creating approval workflow, please try again later."
);
}
});
} catch (error: any) {
console.error('Error submitting exam:', error);
toast.error(

View File

@@ -13,6 +13,7 @@ import {
BsClipboardData,
BsPeople,
} from "react-icons/bs";
import { GoWorkflow } from "react-icons/go";
import { CiDumbbell } from "react-icons/ci";
import { RiLogoutBoxFill } from "react-icons/ri";
import Link from "next/link";
@@ -149,6 +150,7 @@ export default function Sidebar({
viewSettings: true,
viewPaymentRecord: true,
viewGeneration: true,
viewApprovalWorkflows: true,
};
}
const sidebarPermissions: { [key: string]: boolean } = {
@@ -160,6 +162,7 @@ export default function Sidebar({
viewSettings: false,
viewPaymentRecord: false,
viewGeneration: false,
viewApprovalWorkflows: false,
};
if (!user || !user?.type) return sidebarPermissions;
@@ -197,6 +200,7 @@ export default function Sidebar({
(entitiesAllowGeneration.length > 0 || isAdmin)
) {
sidebarPermissions["viewGeneration"] = true;
sidebarPermissions["viewApprovalWorkflows"] = true;
}
if (
getTypesOfUser(["agent"]).includes(user.type) &&
@@ -364,6 +368,17 @@ export default function Sidebar({
isMinimized={isMinimized}
/>
)}
{sidebarPermissions["viewApprovalWorkflows"] && (
<Nav
disabled={disableNavigation}
Icon={GoWorkflow}
label="Approval Workflows"
path={path}
keyPath="/approval-workflows"
isMinimized={isMinimized}
/>
)}
</div>
<div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav
@@ -432,6 +447,16 @@ export default function Sidebar({
isMinimized
/>
)}
{sidebarPermissions["viewApprovalWorkflows"] && (
<Nav
disabled={disableNavigation}
Icon={GoWorkflow}
label="Approval Workflows"
path={path}
keyPath="/approval-workflows"
isMinimized
/>
)}
</div>
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8">

View File

@@ -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"
}
]
}
]

View File

@@ -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<ApprovalWorkflow>();
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = useCallback(() => {
setIsLoading(true);
axios
.get<ApprovalWorkflow>(`/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 };
}

View File

@@ -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<ApprovalWorkflow[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = useCallback(() => {
setIsLoading(true);
axios
.get<ApprovalWorkflow[]>(`/api/approval-workflows`)
.then((response) => setWorkflows(response.data))
.catch((error) => {
setIsError(true);
})
.finally(() => setIsLoading(false));
}, []);
useEffect(getData, [getData]);
return { workflows, isLoading, isError, reload: getData };
}

View File

@@ -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<ApprovalWorkflow, "_id" | "steps"> {
id: string,
steps: EditableWorkflowStep[],
}
export type StepType = "form-intake" | "approval-by";
export const StepTypeLabel: Record<StepType, string> = {
"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<HTMLDivElement>
}
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<ApprovalWorkflowStatus, string> = {
approved: "Approved",
pending: "Pending",
rejected: "Rejected",
};

View File

@@ -1,4 +1,11 @@
export type Module = "reading" | "listening" | "writing" | "speaking" | "level";
export const ModuleTypeLabels: Record<Module, string> = {
reading: "Reading",
listening: "Listening",
writing: "Writing",
speaking: "Speaking",
level: "Level",
};
export interface Step {
min: number;

View File

@@ -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<Type, string> = {
student: "Student",
teacher: "Teacher",
corporate: "Corporate",
admin: "Admin",
developer: "Developer",
agent: "Agent",
mastercorporate: "Master Corporate",
};
export const userTypeLabelsShort: Record<Type, string> = {
student: "",
teacher: "Prof.",
corporate: "Dir.",
admin: "Admin",
developer: "Dev.",
agent: "Agent",
mastercorporate: "Dir.",
};
export type WithUser<T> = T extends { participants: string[] } ? Omit<T, "participants"> & { participants: User[] } : T;

View File

@@ -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();
}
}

View File

@@ -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));
}
}

View File

@@ -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();
}

View File

@@ -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 });
}
}

View File

@@ -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<EditableApprovalWorkflow | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(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<HTMLFormElement>) => {
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 (
<>
<Head>
<title> Edit 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">{workflow.name}</h1>
</section>
<section className="flex flex-col gap-6">
<div className="flex flex-row gap-6">
<RequestedBy
prefix={getUserTypeLabelShort(user.type)}
name={user.name}
profileImage={user.profilePicture}
/>
<StartedOn
date={workflow.startDate}
/>
<Status
status={workflow.status}
/>
</div>
</section>
<form onSubmit={handleSubmit}>
<LayoutGroup key={workflow.name}>
<motion.div
key="form"
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 60 }}
transition={{ duration: 0.20 }}
>
{updatedWorkflow &&
<WorkflowForm
workflow={updatedWorkflow}
onWorkflowChange={onWorkflowChange}
entityApprovers={workflowEntityApprovers}
isLoading={isLoading}
/>
}
</motion.div>
</LayoutGroup>
</form>
</>
);
}

View File

@@ -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<number>(currentStepIndex);
const [selectedStep, setSelectedStep] = useState<WorkflowStep>(currentWorkflow.steps[selectedStepIndex]);
const [isPanelOpen, setIsPanelOpen] = useState(true);
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? 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 (
<>
<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 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" />
<textarea
value={comments}
onChange={(e) => setComments(e.target.value)}
placeholder="Input comments here"
className="w-full h-64 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>
</>
);
}

View File

@@ -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<EditableApprovalWorkflow[]>(allConfiguredWorkflows);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | undefined>(undefined);
const [entityId, setEntityId] = useState<string | null | undefined>(null);
const [entityApprovers, setEntityApprovers] = useState<(TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[]>([]);
const [entityAvailableFormIntakers, setEntityAvailableFormIntakers] = useState<(TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[]>([]);
const [isAdding, setIsAdding] = useState<boolean>(false); // used to temporary timeout new workflow button. With animations, clicking too fast might cause state inconsistencies between renders.
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isRedirecting, setIsRedirecting] = useState<boolean>(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<string[]>((acc, wf) => {
if (wf.id === selectedWorkflowId) return acc; // skip current workflow so its selection isnt 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<HTMLFormElement>) => {
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 (
<>
<Head>
<title> Configure Workflows | 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 flex-col">
<div 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">{"Configure Approval Workflows"}</h1>
</div>
</section>
<Tip text="Setting a teacher as a Form Intaker means the configured workflow will be instantiated when said teacher publishes an exam. Only one Form Intake per teacher per entity is allowed." />
<section className="flex flex-row gap-6">
<Button
color="purple"
variant="solid"
onClick={handleAddNewWorkflow}
className="min-w-fit max-h-fit text-lg font-medium flex items-center gap-2 text-left"
>
<MdFormatListBulletedAdd className="size-6" />
Add New Workflow
</Button>
{workflows.length !== 0 && <div className="bg-gray-300 w-[1px]"></div>}
<div className="flex flex-wrap gap-2">
{workflows.map((workflow) => (
<Button
key={workflow.id}
color="purple"
variant={
selectedWorkflowId === workflow.id
? "solid"
: "outline"
}
onClick={() => handleSelectWorkflow(workflow.id)}
className="min-w-fit text-lg font-medium flex items-center gap-2 text-left"
>
{workflow.name.trim() === "" ? "Workflow" : workflow.name}
</Button>
))}
</div>
</section>
<form onSubmit={handleSubmit}>
{currentWorkflow && (
<>
<div className="mb-8 flex flex-row gap-6 items-end">
<Input
label="Name:"
type="text"
name={currentWorkflow.name}
placeholder="Enter workflow name"
value={currentWorkflow.name}
onChange={(updatedName) => {
const updatedWorkflow = {
...currentWorkflow,
name: updatedName,
};
onWorkflowChange(updatedWorkflow);
}}
/>
<Select
label="Entity:"
options={ENTITY_OPTIONS}
value={
currentWorkflow.entityId === ""
? null
: ENTITY_OPTIONS.find(option => option.value === currentWorkflow.entityId)
}
onChange={(selectedEntity) => {
if (currentWorkflow.entityId) {
if (!confirm("Clearing or changing entity will clear all the assignees for all steps in this workflow. Are you sure you want to proceed?")) return;
}
if (selectedEntity?.value) {
setEntityId(selectedEntity.value);
handleEntityChange(currentWorkflow, selectedEntity.value);
}
}}
isClearable
placeholder="Enter workflow entity"
/>
<Button
color="gray"
variant="solid"
onClick={() => handleCloneWorkflow(currentWorkflow.id)}
type="button"
className="min-w-fit h-[72px] text-lg font-medium flex items-center gap-2 text-left"
>
Clone Workflow
<FaRegClone className="size-6" />
</Button>
<Button
color="red"
variant="solid"
onClick={() => handleDeleteWorkflow(currentWorkflow.id)}
type="button"
className="min-w-fit h-[72px] text-lg font-medium flex items-center gap-2 text-left"
>
Delete Workflow
<BsTrash className="size-6" />
</Button>
</div>
<AnimatePresence mode="wait">
<LayoutGroup key={currentWorkflow.id}>
<motion.div
key="form"
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 60 }}
transition={{ duration: 0.20 }}
>
{(!currentWorkflow.name || !currentWorkflow.entityId) && (
<Tip text="Please fill in workflow name and associated entity to start configuring workflow." />
)}
<WorkflowForm
workflow={currentWorkflow}
onWorkflowChange={onWorkflowChange}
entityApprovers={entityApprovers}
entityAvailableFormIntakers={entityAvailableFormIntakers}
isLoading={isLoading}
isRedirecting={isRedirecting}
/>
</motion.div>
</LayoutGroup>
</AnimatePresence>
</>
)}
</form>
</>
);
}

View File

@@ -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<ApprovalWorkflow>();
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<ApprovalWorkflow[]>([]);
const [statusFilter, setStatusFilter] = useState<CustomStatus>(undefined);
const [entityFilter, setEntityFilter] = useState<CustomEntity>(undefined);
const [nameFilter, setNameFilter] = useState<string>("");
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) => (
<span className="font-medium">
{info.getValue()}
</span>
),
}),
columnHelper.accessor("modules", {
header: "MODULES",
cell: (info) => (
<div className="flex flex-wrap gap-2">
{info.getValue().map((module: Module, index: number) => (
<span
key={index}
className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-indigo-100 border border-indigo-300 text-indigo-900"
>
{ModuleTypeLabels[module]}
</span>
))}
</div>
),
}),
columnHelper.accessor("status", {
header: "STATUS",
cell: (info) => (
<span className={clsx("inline-block rounded-full px-3 py-1 text-sm font-medium text-left w-[110px]", StatusClassNames[info.getValue()])}>
{ApprovalWorkflowStatusLabel[info.getValue()]}
</span>
),
}),
columnHelper.accessor("entityId", {
header: "ENTITY",
cell: (info) => (
<span className="font-medium">
{userEntitiesWithLabel.find((entity) => entity.id === info.getValue())?.label}
</span>
),
}),
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 (
<div className="flex flex-wrap gap-2">
{assignees?.map((assigneeName: string, index: number) => (
<span
key={index}
className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-gray-100 border border-gray-300 text-gray-900"
>
{assigneeName}
</span>
))}
</div>
);
},
}),
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 (
<span className="font-medium">
{currentStep && !rejected
? `Step ${currentStep.stepNumber}: ${StepTypeLabel[currentStep.stepType]}`
: "Completed"}
</span>
);
},
}),
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 (
<div className="flex gap-4">
<button
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={(e) => {
e.stopPropagation();
deleteApprovalWorkflow(row.original._id?.toString(), row.original.name);
}}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
{currentStep && !rejected && (
<Link
onClick={(e) => e.stopPropagation()}
data-tip="Edit"
href={`/approval-workflows/${row.original._id?.toString()}/edit`}
className="cursor-pointer tooltip"
>
<FaRegEdit className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</Link>
)}
</div>
);
},
})
];
const table = useReactTable({
data: filteredWorkflows,
columns: columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<Head>
<title>Approval Workflows Panel | 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 />
<h1 className="text-2xl font-semibold">Approval Workflows</h1>
<div className="flex flex-row">
<Link href={"/approval-workflows/create"}>
<Button
color="purple"
variant="solid"
className="min-w-fit text-lg font-medium flex items-center gap-2 text-left"
>
<IoIosAddCircleOutline className="size-6" />
Configure Workflows
</Button>
</Link>
</div>
<div className="flex w-full items-center gap-4">
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Name</label>
<Input
name="nameFilter"
type="text"
value={nameFilter}
onChange={handleNameFilterChange}
placeholder="Filter by name..."
/>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Status</label>
<Select
options={STATUS_OPTIONS}
value={STATUS_OPTIONS.find((x) => x.value === statusFilter)}
onChange={(value) => setStatusFilter((value?.value as ApprovalWorkflowStatus) ?? undefined)}
isClearable
placeholder="Filter by status..."
/>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Entity</label>
<Select
options={ENTITY_OPTIONS}
value={ENTITY_OPTIONS.find((x) => x.value === entityFilter)}
onChange={(value) => setEntityFilter((value?.value as CustomEntity) ?? undefined)}
isClearable
placeholder="Filter by entity..."
/>
</div>
</div>
<Tip text="An exam submission will instantiate the approval workflow configured for the exam author. The exam will be valid only when all the steps of the workflow have been approved."></Tip>
<div className="px-6 pb-4 bg-mti-purple-ultralight rounded-2xl border-2 border-mti-purple-light border-opacity-40">
<table
className="w-full table-auto border-separate border-spacing-y-2"
style={{ tableLayout: "auto" }}
>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-3 py-2 text-left text-mti-purple-ultradark">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
onClick={() => 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 (
<td key={cellIndex} className={cellClasses}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</>
);
}

View File

@@ -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[] = [

View File

@@ -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<ApprovalWorkflow>(collection)
.find(ids ? { _id: { $in: ids.map((id) => new ObjectId(id)) } } : {})
.toArray();
};
export const getApprovalWorkflow = async (collection: string, id: string) => {
return await db.collection<ApprovalWorkflow>(collection).findOne({ _id: new ObjectId(id) });
};
export const getApprovalWorkflowsByEntities = async (collection: string, ids: string[]) => {
return await db
.collection<ApprovalWorkflow>(collection)
.find({ entityId: { $in: ids } })
.toArray();
};
export const getApprovalWorkflowByFormIntaker = async (entityId: string, formIntakerId: string) => {
return await db.collection<ApprovalWorkflow>("configured-workflows").findOne({
entityId,
steps: {
$elemMatch: {
stepNumber: 1,
assignees: formIntakerId,
},
},
});
};
export const getFormIntakersByEntity = async (entityId: string) => {
const results = await db
.collection<ApprovalWorkflow>("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<string>();
// 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 },
});
};