- implement approval of steps

- remove currentStep field from step
- implement save comments on step
- fix _id issue when saving to mongo
This commit is contained in:
Joao Correia
2025-01-31 17:01:20 +00:00
parent 9de4cba8e8
commit 662e3b0266
8 changed files with 184 additions and 59 deletions

View File

@@ -2,7 +2,7 @@
import { ApprovalWorkflow } from "@/interfaces/approval.workflow"; import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { createApprovalWorkflows, updateApprovalWorkflow } from "@/utils/approval.workflows.be"; import { updateApprovalWorkflow } from "@/utils/approval.workflows.be";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
@@ -23,6 +23,8 @@ async function put(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id?: string }; const { id } = req.query as { id?: string };
const approvalWorkflow: ApprovalWorkflow = req.body; const approvalWorkflow: ApprovalWorkflow = req.body;
if (id && approvalWorkflow) if (id && approvalWorkflow) {
return res.status(204).json(await updateApprovalWorkflow(id, approvalWorkflow)); await updateApprovalWorkflow(id, approvalWorkflow);
return res.status(204).end();
}
} }

View File

@@ -2,7 +2,7 @@
import { ApprovalWorkflow } from "@/interfaces/approval.workflow"; import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { createApprovalWorkflows, deleteApprovalWorkflow, updateApprovalWorkflow } from "@/utils/approval.workflows.be"; import { deleteApprovalWorkflow, updateApprovalWorkflow } from "@/utils/approval.workflows.be";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
@@ -10,6 +10,7 @@ export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "DELETE") return await del(req, res); if (req.method === "DELETE") return await del(req, res);
if (req.method === "PUT") return await put(req, res);
} }
async function del(req: NextApiRequest, res: NextApiResponse) { async function del(req: NextApiRequest, res: NextApiResponse) {
@@ -22,6 +23,22 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id?: string }; const { id } = req.query as { id?: string };
if (id) if (id) return res.status(200).json(await deleteApprovalWorkflow(id));
return res.status(200).json(await deleteApprovalWorkflow(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 = req.body;
if (id && workflow) {
await updateApprovalWorkflow(id, workflow);
return res.status(204).end();
}
} }

View File

@@ -124,7 +124,6 @@ export default function Home({ user, workflow, userEntitiesWithLabel, userEntiti
...cloneWorkflow, ...cloneWorkflow,
steps: cloneWorkflow.steps.map(step => ({ steps: cloneWorkflow.steps.map(step => ({
...step, ...step,
currentStep: step.stepNumber === 1 ? true : false,
completed: false, completed: false,
assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined) assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined)
})) }))
@@ -137,23 +136,21 @@ export default function Home({ user, workflow, userEntitiesWithLabel, userEntiti
setIsRedirecting(true); setIsRedirecting(true);
setTimeout(() => { setTimeout(() => {
router.push("/approval-workflows"); router.push("/approval-workflows");
}, 2000); }, 1000);
}) })
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 401) { if (reason.response.status === 401) {
toast.error("Not logged in!"); toast.error("Not logged in!");
return router.push("/login"); } else if (reason.response.status === 403) {
}
if (reason.response.status === 403) {
toast.error("You do not have permission to clone Approval Workflows!"); toast.error("You do not have permission to clone Approval Workflows!");
return router.push("/approval-workflows"); } else {
toast.error("Something went wrong, please try again later.");
} }
toast.error("Something went wrong, please try again later.");
setIsLoading(false); setIsLoading(false);
console.log("Submitted Values:", filteredWorkflow);
return; return;
}) })
console.log("Form submitted! Filtered Values:", filteredWorkflow);
}; };
const onWorkflowChange = (wf: EditableApprovalWorkflow) => { const onWorkflowChange = (wf: EditableApprovalWorkflow) => {

View File

@@ -95,7 +95,6 @@ export default function Home({ user, workflow, workflowEntityApprovers }: Props)
...updatedWorkflow, ...updatedWorkflow,
steps: updatedWorkflow.steps.map(step => ({ steps: updatedWorkflow.steps.map(step => ({
...step, ...step,
currentStep: step.stepNumber === 1 ? true : false,
completed: false, completed: false,
assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined) assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined)
})) }))
@@ -108,23 +107,20 @@ export default function Home({ user, workflow, workflowEntityApprovers }: Props)
setIsRedirecting(true); setIsRedirecting(true);
setTimeout(() => { setTimeout(() => {
router.push("/approval-workflows"); router.push("/approval-workflows");
}, 2000); }, 1000);
}) })
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 401) { if (reason.response.status === 401) {
toast.error("Not logged in!"); toast.error("Not logged in!");
return router.push("/login"); } else if (reason.response.status === 403) {
}
if (reason.response.status === 403) {
toast.error("You do not have permission to edit Approval Workflows!"); toast.error("You do not have permission to edit Approval Workflows!");
return router.push("/approval-workflows"); } else {
toast.error("Something went wrong, please try again later.");
} }
toast.error("Something went wrong, please try again later.");
setIsLoading(false); setIsLoading(false);
console.log("Submitted Values:", filteredWorkflow);
return; return;
}) })
console.log("Form submitted! Filtered Values:", filteredWorkflow);
}; };
const onWorkflowChange = (updatedWorkflow: EditableApprovalWorkflow) => { const onWorkflowChange = (updatedWorkflow: EditableApprovalWorkflow) => {
@@ -159,7 +155,7 @@ export default function Home({ user, workflow, workflowEntityApprovers }: Props)
<RequestedBy <RequestedBy
prefix={getUserTypeLabelShort(user.type)} prefix={getUserTypeLabelShort(user.type)}
name={user.name} name={user.name}
profileImage="/blue-stock-photo.png" //{user.profilePicture} profileImage={user.profilePicture}
/> />
<StartedOn <StartedOn
date={workflow.startDate} date={workflow.startDate}

View File

@@ -14,16 +14,20 @@ import { requestUser } from "@/utils/api";
import { getApprovalWorkflow } from "@/utils/approval.workflows.be"; import { getApprovalWorkflow } from "@/utils/approval.workflows.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getSpecificUsers, getUser } from "@/utils/users.be"; import { getSpecificUsers, getUser } from "@/utils/users.be";
import axios from "axios";
import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; import { AnimatePresence, LayoutGroup, motion } from "framer-motion";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react"; import { useState } from "react";
import { BsChevronLeft } from "react-icons/bs"; import { BsChevronLeft } from "react-icons/bs";
import { FaWpforms } from "react-icons/fa6"; import { FaSpinner, FaWpforms } from "react-icons/fa6";
import { MdOutlineDoubleArrow } from "react-icons/md"; import { MdOutlineDoubleArrow } from "react-icons/md";
import { RiThumbUpLine } from "react-icons/ri"; import { RiThumbUpLine } from "react-icons/ri";
import { ToastContainer } from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import { IoMdCheckmarkCircleOutline } from "react-icons/io";
import { FiSave } from "react-icons/fi";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
const user = await requestUser(req, res); const user = await requestUser(req, res);
@@ -75,6 +79,9 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
const [selectedStep, setSelectedStep] = useState<WorkflowStep>(steps[selectedStepIndex]); const [selectedStep, setSelectedStep] = useState<WorkflowStep>(steps[selectedStepIndex]);
const [isPanelOpen, setIsPanelOpen] = useState(true); const [isPanelOpen, setIsPanelOpen] = useState(true);
const [comments, setComments] = useState<string>(selectedStep.comments || ""); const [comments, setComments] = useState<string>(selectedStep.comments || "");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isRedirecting, setIsRedirecting] = useState<boolean>(false);
const router = useRouter();
const handleStepClick = (index: number, stepInfo: WorkflowStep) => { const handleStepClick = (index: number, stepInfo: WorkflowStep) => {
setSelectedStep(stepInfo); setSelectedStep(stepInfo);
@@ -84,11 +91,82 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
}; };
const handleSaveComments = () => { const handleSaveComments = () => {
setIsLoading(true);
const updatedWorkflow: ApprovalWorkflow = {
...workflow,
steps: workflow.steps.map((step, index) =>
index === selectedStepIndex ?
{
...step,
comments: comments,
}
: step
)
};
axios
.put(`/api/approval-workflows/${workflow._id}`, updatedWorkflow)
.then(() => {
toast.success("Comments saved successfully.");
setIsRedirecting(true);
setTimeout(() => {
router.reload();
}, 1000);
})
.catch((reason) => {
if (reason.response.status === 401) {
toast.error("Not logged in!");
} else if (reason.response.status === 403) {
toast.error("You do not have permission to approve this step!");
} else {
toast.error("Something went wrong, please try again later.");
}
setIsLoading(false);
console.log("Submitted Values:", updatedWorkflow);
return;
})
}; };
const handleApproveStep = () => { const handleApproveStep = () => {
if (!confirm(`Are you sure you want to approve this step?`)) return;
setIsLoading(true);
const updatedWorkflow: ApprovalWorkflow = {
...workflow,
steps: workflow.steps.map((step, index) =>
index === selectedStepIndex ?
{
...step,
completed: true,
completedBy: user.id,
completedDate: Date.now(),
}
: step
)
};
axios
.put(`/api/approval-workflows/${workflow._id}`, updatedWorkflow)
.then(() => {
toast.success("Step approved successfully.");
setIsRedirecting(true);
setTimeout(() => {
router.reload();
}, 1000);
})
.catch((reason) => {
if (reason.response.status === 401) {
toast.error("Not logged in!");
} else if (reason.response.status === 403) {
toast.error("You do not have permission to approve this step!");
} else {
toast.error("Something went wrong, please try again later.");
}
setIsLoading(false);
console.log("Submitted Values:", updatedWorkflow);
return;
})
}; };
return ( return (
@@ -120,7 +198,7 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
<RequestedBy <RequestedBy
prefix={getUserTypeLabelShort(workflowRequester.type)} prefix={getUserTypeLabelShort(workflowRequester.type)}
name={workflowRequester.name} name={workflowRequester.name}
profileImage="/blue-stock-photo.png" //{workflowRequester.profilePicture} profileImage={workflowRequester.profilePicture}
/> />
<StartedOn <StartedOn
date={workflow.startDate} date={workflow.startDate}
@@ -246,16 +324,32 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
</div> </div>
)} )}
{selectedStepIndex === currentStep && {selectedStepIndex === currentStep && !selectedStep.completed &&
<Button <Button
type="submit"
color="purple" color="purple"
variant="solid" variant="solid"
disabled={!selectedStep.assignees.includes(user.id)} disabled={!selectedStep.assignees.includes(user.id) || isLoading}
onClick={handleApproveStep} onClick={handleApproveStep}
padding="px-6 py-2" padding="px-6 py-2"
className="mb-3 w-full text-lg" className="mb-3 w-full text-lg flex items-center justify-center gap-2 text-left"
> >
Approve Step {isRedirecting ? (
<>
<FaSpinner className="animate-spin size-5" />
Reloading...
</>
) : isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<IoMdCheckmarkCircleOutline size={20} />
Approve Step
</>
)}
</Button> </Button>
} }
@@ -267,14 +361,32 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
placeholder="Input comments here" 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" 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 <Button
type="submit"
color="purple" color="purple"
variant="solid" variant="solid"
onClick={handleSaveComments} onClick={handleSaveComments}
disabled={isLoading}
padding="px-6 py-2" padding="px-6 py-2"
className="mt-6 mb-3 w-full text-lg" className="mt-6 mb-3 w-full text-lg flex items-center justify-center gap-2 text-left"
> >
Save Comments {isRedirecting ? (
<>
<FaSpinner className="animate-spin size-5" />
Reloading...
</>
) : isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<FiSave size={20} />
Save Comments
</>
)}
</Button> </Button>
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" /> <hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />

View File

@@ -86,13 +86,13 @@ export default function Home({ user, userEntitiesWithLabel, userEntitiesApprover
if (workflows.length === 0) { if (workflows.length === 0) {
setIsLoading(false); setIsLoading(false);
return;
} }
const filteredWorkflows: ApprovalWorkflow[] = workflows.map(workflow => ({ const filteredWorkflows: ApprovalWorkflow[] = workflows.map(workflow => ({
...workflow, ...workflow,
steps: workflow.steps.map(step => ({ steps: workflow.steps.map(step => ({
...step, ...step,
currentStep: step.stepNumber === 1 ? true : false,
completed: false, completed: false,
assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined) assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined)
})) }))
@@ -105,23 +105,22 @@ export default function Home({ user, userEntitiesWithLabel, userEntitiesApprover
setIsRedirecting(true); setIsRedirecting(true);
setTimeout(() => { setTimeout(() => {
router.push("/approval-workflows"); router.push("/approval-workflows");
}, 2000); }, 1000);
}) })
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 401) { if (reason.response.status === 401) {
toast.error("Not logged in!"); toast.error("Not logged in!");
return router.push("/login");
} }
if (reason.response.status === 403) { else if (reason.response.status === 403) {
toast.error("You do not have permission to create Approval Workflows!"); toast.error("You do not have permission to create Approval Workflows!");
return router.push("/approval-workflows");
} }
toast.error("Something went wrong, please try again later."); else {
toast.error("Something went wrong, please try again later.");
}
setIsLoading(false); setIsLoading(false);
console.log("Submitted Values:", filteredWorkflows);
return; return;
}) })
console.log("Form submitted! Filtered Values:", filteredWorkflows);
}; };
const handleAddNewWorkflow = () => { const handleAddNewWorkflow = () => {

View File

@@ -27,6 +27,7 @@ import Input from "@/components/Low/Input";
import { FaRegClone } from "react-icons/fa6"; import { FaRegClone } from "react-icons/fa6";
import useApprovalWorkflows from "@/hooks/useApprovalWorkflows"; import useApprovalWorkflows from "@/hooks/useApprovalWorkflows";
import { getApprovalWorkflows } from "@/utils/approval.workflows.be"; import { getApprovalWorkflows } from "@/utils/approval.workflows.be";
import { useRouter } from "next/router";
const columnHelper = createColumnHelper<ApprovalWorkflow>(); const columnHelper = createColumnHelper<ApprovalWorkflow>();
@@ -110,6 +111,7 @@ export default function ApprovalWorkflows({ user, workflows, workflowsAssignees,
const [statusFilter, setStatusFilter] = useState<CustomStatus>(undefined); const [statusFilter, setStatusFilter] = useState<CustomStatus>(undefined);
const [entityFilter, setEntityFilter] = useState<CustomEntity>(undefined); const [entityFilter, setEntityFilter] = useState<CustomEntity>(undefined);
const [nameFilter, setNameFilter] = useState<string>(""); const [nameFilter, setNameFilter] = useState<string>("");
const router = useRouter();
useEffect(() => { useEffect(() => {
const filters: Array<(workflow: ApprovalWorkflow) => boolean> = []; const filters: Array<(workflow: ApprovalWorkflow) => boolean> = [];
@@ -154,21 +156,18 @@ export default function ApprovalWorkflows({ user, workflows, workflowsAssignees,
.then(() => { .then(() => {
toast.success(`Successfully deleted ${name} Approval Workflow.`); toast.success(`Successfully deleted ${name} Approval Workflow.`);
setTimeout(() => { setTimeout(() => {
window.location.reload(); router.reload();
}, 2000); }, 1000);
}) })
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 404) { if (reason.response.status === 404) {
toast.error("Approval Workflow not found!"); toast.error("Approval Workflow not found!");
return; } else if (reason.response.status === 403) {
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete an Approval Workflow!"); toast.error("You do not have permission to delete an Approval Workflow!");
return; } else {
toast.error("Something went wrong, please try again later.");
} }
return;
toast.error("Something went wrong, please try again later.");
}) })
}; };

View File

@@ -15,17 +15,20 @@ export const getApprovalWorkflow = async (id: string) => {
return await db.collection<ApprovalWorkflow>("approval-workflows").findOne({ _id: new ObjectId(id) }); return await db.collection<ApprovalWorkflow>("approval-workflows").findOne({ _id: new ObjectId(id) });
}; };
export const createApprovalWorkflow = async (workflow: Omit<ApprovalWorkflow, "_id">) => { export const createApprovalWorkflow = async (workflow: ApprovalWorkflow) => {
return await db.collection("approval-workflows").insertOne(workflow); const { _id, ...workflowWithoutId } = workflow as ApprovalWorkflow;
return await db.collection("approval-workflows").insertOne(workflowWithoutId);
}; };
export const createApprovalWorkflows = async (workflows: Omit<ApprovalWorkflow, "_id">[]) => { export const createApprovalWorkflows = async (workflows: ApprovalWorkflow[]) => {
if (workflows.length === 0) return; if (workflows.length === 0) return;
return await db.collection("approval-workflows").insertMany(workflows); const workflowsWithoutIds: ApprovalWorkflow[] = workflows.map(({_id, ...wfs}) => wfs)
return await db.collection("approval-workflows").insertMany(workflowsWithoutIds);
}; };
export const updateApprovalWorkflow = async (id: string, workflow: Omit<ApprovalWorkflow, "_id">) => { export const updateApprovalWorkflow = async (id: string, workflow: ApprovalWorkflow) => {
return await db.collection("approval-workflows").replaceOne({ _id: new ObjectId(id) }, workflow); const { _id, ...workflowWithoutId } = workflow as ApprovalWorkflow;
return await db.collection("approval-workflows").replaceOne({ _id: new ObjectId(id) }, workflowWithoutId);
}; };
export const deleteApprovalWorkflow = async (id: string) => { export const deleteApprovalWorkflow = async (id: string) => {