diff --git a/.gitignore b/.gitignore index 30f9f1ed..46468831 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,6 @@ next-env.d.ts .env .yarn/* .history* -__ENV.js \ No newline at end of file +__ENV.js + +settings.json \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/RequestedBy.tsx b/src/components/ApprovalWorkflows/RequestedBy.tsx new file mode 100644 index 00000000..036cc968 --- /dev/null +++ b/src/components/ApprovalWorkflows/RequestedBy.tsx @@ -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 ( +
+
+ +
+
+

Requested by

+
+

{prefix} {name}

+ {name} +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/StartedOn.tsx b/src/components/ApprovalWorkflows/StartedOn.tsx new file mode 100644 index 00000000..2b1fde6d --- /dev/null +++ b/src/components/ApprovalWorkflows/StartedOn.tsx @@ -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 ( +
+
+ +
+
+

Started on

+
+

+ {yearMonthDay} +

+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/Status.tsx b/src/components/ApprovalWorkflows/Status.tsx new file mode 100644 index 00000000..f7176bfa --- /dev/null +++ b/src/components/ApprovalWorkflows/Status.tsx @@ -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 ( +
+
+ +
+
+

Status

+
+

{ApprovalWorkflowStatusLabel[status]}

+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/Tip.tsx b/src/components/ApprovalWorkflows/Tip.tsx new file mode 100644 index 00000000..5d0b1fbe --- /dev/null +++ b/src/components/ApprovalWorkflows/Tip.tsx @@ -0,0 +1,14 @@ +import { MdTipsAndUpdates } from "react-icons/md"; + +interface Props { + text: string; +} + +export default function Tip({ text }: Props) { + return ( +
+ +

{text}

+
+ ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/UserWithProfilePic.tsx b/src/components/ApprovalWorkflows/UserWithProfilePic.tsx new file mode 100644 index 00000000..c7c1a207 --- /dev/null +++ b/src/components/ApprovalWorkflows/UserWithProfilePic.tsx @@ -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 ( +
+

{prefix} {name}

+ {name} +
+ ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/WorkflowEditableStepComponent.tsx b/src/components/ApprovalWorkflows/WorkflowEditableStepComponent.tsx new file mode 100644 index 00000000..9936842f --- /dev/null +++ b/src/components/ApprovalWorkflows/WorkflowEditableStepComponent.tsx @@ -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 { + 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: () => {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 ( +
+
+ + + {/* Vertical Bar connecting steps */} + {!finalStep && ( +
+ )} +
+ + {stepNumber !== 1 && !finalStep && !isCompleted + ? + :
+ } + +
+ +
+ +
+ + {stepNumber !== 1 && !finalStep && ( + + )} +
+
+ + ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/WorkflowForm.tsx b/src/components/ApprovalWorkflows/WorkflowForm.tsx new file mode 100644 index 00000000..c7065d7c --- /dev/null +++ b/src/components/ApprovalWorkflows/WorkflowForm.tsx @@ -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 && +
+
+ + +
+ + + + {workflow.steps.map((step, index) => + step.completed || step.firstStep || step.finalStep ? ( + + handleDelete(step.key)} + onSelectChange={(numberOfSelects, idx, option) => + handleSelectChange(step.key, numberOfSelects, idx, option) + } + entityApprovers={ + step.stepNumber === 1 && entityAvailableFormIntakers + ? entityAvailableFormIntakers + : entityApprovers + } + isCompleted={step.completed} + /> + + ) : ( + // Render non-completed steps as draggable items + + handleDelete(step.key)} + onSelectChange={(numberOfSelects, idx, option) => + handleSelectChange(step.key, numberOfSelects, idx, option) + } + entityApprovers={ + step.stepNumber === 1 && entityAvailableFormIntakers + ? entityAvailableFormIntakers + : entityApprovers + } + isCompleted={step.completed} + /> + + ) + )} + + + +
+ } + + ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/WorkflowStepComponent.tsx b/src/components/ApprovalWorkflows/WorkflowStepComponent.tsx new file mode 100644 index 00000000..75681040 --- /dev/null +++ b/src/components/ApprovalWorkflows/WorkflowStepComponent.tsx @@ -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 ( +
+
+ + + {/* Vertical Bar connecting steps */} + {!finalStep && ( +
+ )} +
+ +
+ {stepType === "approval-by" ? ( + + ) : ( + + ) + } +
+ +
+ {completed && completedBy && rejected ? ( +
+

{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`}

+ +
+ ) : completed && completedBy && !rejected ? ( +
+

{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`}

+ +
+ ) : !completed && currentStep ? ( +
+

{stepType === "approval-by" ? `Approval:` : `Form Intake:`}

+ In Progress... Assignees: +
+ {assigneesUsers.map(user => ( + + + + ))} +
+
+ ) : ( +
+

{stepType === "approval-by" ? `Approval:` : `Form Intake:`}

+ Waiting for previous steps... +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/WorkflowStepNumber.tsx b/src/components/ApprovalWorkflows/WorkflowStepNumber.tsx new file mode 100644 index 00000000..842ecc6c --- /dev/null +++ b/src/components/ApprovalWorkflows/WorkflowStepNumber.tsx @@ -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 + +export default function WorkflowStepNumber({ stepNumber, selected = false, completed, rejected, finalStep }: Props) { + return ( +
+ {rejected ? ( + + ) : completed && finalStep ? ( + + ) : completed && !finalStep ? ( + + ) : ( + {stepNumber} + )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/ApprovalWorkflows/WorkflowStepSelects.tsx b/src/components/ApprovalWorkflows/WorkflowStepSelects.tsx new file mode 100644 index 00000000..878b650c --- /dev/null +++ b/src/components/ApprovalWorkflows/WorkflowStepSelects.tsx @@ -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 ( +
+ {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 ( +
+