diff --git a/apps/app/components/core/sidebar/progress-chart.tsx b/apps/app/components/core/sidebar/progress-chart.tsx index f8454650e..9006b58fc 100644 --- a/apps/app/components/core/sidebar/progress-chart.tsx +++ b/apps/app/components/core/sidebar/progress-chart.tsx @@ -12,9 +12,11 @@ type Props = { issues: IIssue[]; start: string; end: string; + width?: number; + height?: number; }; -const ProgressChart: React.FC = ({ issues, start, end }) => { +const ProgressChart: React.FC = ({ issues, start, end, width = 360, height = 160 }) => { const startDate = new Date(start); const endDate = new Date(end); const getChartData = () => { @@ -51,8 +53,8 @@ const ProgressChart: React.FC = ({ issues, start, end }) => { return (
= ({ issues, module, userAuth, + roundedTab, + noBackground, }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -100,12 +104,16 @@ export const SidebarProgressStats: React.FC = ({ > - `w-full rounded px-3 py-1 text-brand-base ${ + `w-full ${ + roundedTab ? "rounded-3xl border border-brand-base" : "rounded" + } px-3 py-1 text-brand-base ${ selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2" }` } @@ -114,7 +122,9 @@ export const SidebarProgressStats: React.FC = ({ - `w-full rounded px-3 py-1 text-brand-base ${ + `w-full ${ + roundedTab ? "rounded-3xl border border-brand-base" : "rounded" + } px-3 py-1 text-brand-base ${ selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2" }` } @@ -123,7 +133,9 @@ export const SidebarProgressStats: React.FC = ({ - `w-full rounded px-3 py-1 text-brand-base ${ + `w-full ${ + roundedTab ? "rounded-3xl border border-brand-base" : "rounded" + } px-3 py-1 text-brand-base ${ selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2" }` } @@ -131,10 +143,10 @@ export const SidebarProgressStats: React.FC = ({ States - + {members?.map((member, index) => { - const totalArray = issues?.filter((i) => i.assignees?.includes(member.member.id)); + const totalArray = issues?.filter((i) => i?.assignees?.includes(member.member.id)); const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); if (totalArray.length > 0) { @@ -150,19 +162,19 @@ export const SidebarProgressStats: React.FC = ({ completed={completeArray.length} total={totalArray.length} onClick={() => { - if (filters.assignees?.includes(member.member.id)) + if (filters?.assignees?.includes(member.member.id)) setFilters({ - assignees: filters.assignees?.filter((a) => a !== member.member.id), + assignees: filters?.assignees?.filter((a) => a !== member.member.id), }); else setFilters({ assignees: [...(filters?.assignees ?? []), member.member.id] }); }} - selected={filters.assignees?.includes(member.member.id)} + selected={filters?.assignees?.includes(member.member.id)} /> ); } })} - {issues?.filter((i) => i.assignees?.length === 0).length > 0 ? ( + {issues?.filter((i) => i?.assignees?.length === 0).length > 0 ? ( @@ -180,10 +192,10 @@ export const SidebarProgressStats: React.FC = ({ } completed={ issues?.filter( - (i) => i.state_detail.group === "completed" && i.assignees?.length === 0 + (i) => i?.state_detail.group === "completed" && i.assignees?.length === 0 ).length } - total={issues?.filter((i) => i.assignees?.length === 0).length} + total={issues?.filter((i) => i?.assignees?.length === 0).length} /> ) : ( "" @@ -191,8 +203,8 @@ export const SidebarProgressStats: React.FC = ({ {issueLabels?.map((label, index) => { - const totalArray = issues?.filter((i) => i.labels?.includes(label.id)); - const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); + const totalArray = issues?.filter((i) => i?.labels?.includes(label.id)); + const completeArray = totalArray?.filter((i) => i?.state_detail.group === "completed"); if (totalArray.length > 0) { return ( @@ -207,7 +219,7 @@ export const SidebarProgressStats: React.FC = ({ label.color && label.color !== "" ? label.color : "#000000", }} /> - {label.name} + {label?.name}
} completed={completeArray.length} @@ -215,11 +227,11 @@ export const SidebarProgressStats: React.FC = ({ onClick={() => { if (filters.labels?.includes(label.id)) setFilters({ - labels: filters.labels?.filter((l) => l !== label.id), + labels: filters?.labels?.filter((l) => l !== label.id), }); else setFilters({ labels: [...(filters?.labels ?? []), label.id] }); }} - selected={filters.labels?.includes(label.id)} + selected={filters?.labels?.includes(label.id)} /> ); } diff --git a/apps/app/components/core/sidebar/single-progress-stats.tsx b/apps/app/components/core/sidebar/single-progress-stats.tsx index 3e672ce8b..b2cea68c1 100644 --- a/apps/app/components/core/sidebar/single-progress-stats.tsx +++ b/apps/app/components/core/sidebar/single-progress-stats.tsx @@ -18,7 +18,7 @@ export const SingleProgressStats: React.FC = ({ selected = false, }) => (
= ({ cycle, isCompleted = false }) => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { setToastAlert } = useToast(); + + const endDate = new Date(cycle.end_date ?? ""); + const startDate = new Date(cycle.start_date ?? ""); + + const groupedIssues: any = { + backlog: cycle.backlog_issues, + unstarted: cycle.unstarted_issues, + started: cycle.started_issues, + completed: cycle.completed_issues, + cancelled: cycle.cancelled_issues, + }; + + const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date); + + const handleAddToFavorites = () => { + if (!workspaceSlug || !projectId || !cycle) return; + + switch (cycleStatus) { + case "current": + case "upcoming": + mutate( + CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string), + (prevData) => ({ + current_cycle: (prevData?.current_cycle ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? true : c.is_favorite, + })), + upcoming_cycle: (prevData?.upcoming_cycle ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? true : c.is_favorite, + })), + }), + false + ); + break; + case "completed": + mutate( + CYCLE_COMPLETE_LIST(projectId as string), + (prevData) => ({ + completed_cycles: (prevData?.completed_cycles ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? true : c.is_favorite, + })), + }), + false + ); + break; + case "draft": + mutate( + CYCLE_DRAFT_LIST(projectId as string), + (prevData) => ({ + draft_cycles: (prevData?.draft_cycles ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? true : c.is_favorite, + })), + }), + false + ); + break; + } + + cyclesService + .addCycleToFavorites(workspaceSlug as string, projectId as string, { + cycle: cycle.id, + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); + }); + }; + + const handleRemoveFromFavorites = () => { + if (!workspaceSlug || !projectId || !cycle) return; + + switch (cycleStatus) { + case "current": + case "upcoming": + mutate( + CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string), + (prevData) => ({ + current_cycle: (prevData?.current_cycle ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? false : c.is_favorite, + })), + upcoming_cycle: (prevData?.upcoming_cycle ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? false : c.is_favorite, + })), + }), + false + ); + break; + case "completed": + mutate( + CYCLE_COMPLETE_LIST(projectId as string), + (prevData) => ({ + completed_cycles: (prevData?.completed_cycles ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? false : c.is_favorite, + })), + }), + false + ); + break; + case "draft": + mutate( + CYCLE_DRAFT_LIST(projectId as string), + (prevData) => ({ + draft_cycles: (prevData?.draft_cycles ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? false : c.is_favorite, + })), + }), + false + ); + break; + } + + cyclesService + .removeCycleFromFavorites(workspaceSlug as string, projectId as string, cycle.id) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't remove the cycle from favorites. Please try again.", + }); + }); + }; + + const { data: issues } = useSWR( + workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null, + workspaceSlug && projectId && cycle.id + ? () => + cyclesService.getCycleIssues( + workspaceSlug as string, + projectId as string, + cycle.id as string + ) + : null + ); + + const progressIndicatorData = stateGroups.map((group, index) => ({ + id: index, + name: group.title, + value: + cycle.total_issues > 0 + ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 + : 0, + color: group.color, + })); + + return ( +
+
+
+ +
+
+ + + +

+ {truncateText(cycle.name, 70)} +

+
+
+ + + {cycleStatus === "current" ? ( + + + {findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left + + ) : cycleStatus === "upcoming" ? ( + + + {findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left + + ) : cycleStatus === "completed" ? ( + + {cycle.total_issues - cycle.completed_issues > 0 && ( + + + + + + )}{" "} + Completed + + ) : ( + cycleStatus + )} + + {cycle.is_favorite ? ( + + ) : ( + + )} + +
+ +
+
+ + {renderShortDateWithYearFormat(startDate)} +
+ +
+ + {renderShortDateWithYearFormat(endDate)} +
+
+ +
+
+ {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( + {cycle.owned_by.first_name} + ) : ( + + {cycle.owned_by.first_name.charAt(0)} + + )} + {cycle.owned_by.first_name} +
+ + {cycle.assignees.length > 0 && ( +
+ +
+ )} +
+ +
+
+ + {cycle.total_issues} issues +
+
+ + {cycle.completed_issues} issues +
+
+ + +
+ View Cycle + + +
+ +
+
+
+
+
+ Progress + +
+
+ {Object.keys(groupedIssues).map((group, index) => ( + + + {group} +
+ } + completed={groupedIssues[group]} + total={cycle.total_issues} + /> + ))} +
+
+
+
+ +
+
+
+
+
+
+
High Priority Issues
+ +
+ {issues + ?.filter((issue) => issue.priority === "urgent" || issue.priority === "high") + .map((issue) => ( +
+
+
+ + + {issue.project_detail?.identifier}-{issue.sequence_id} + + +
+ + + {truncateText(issue.name, 30)} + + +
+ +
+
+ {getPriorityIcon(issue.priority, "text-sm")} +
+ {issue.label_details.length > 0 ? ( +
+ {issue.label_details.map((label) => ( + + + {label.name} + + ))} +
+ ) : ( + "" + )} +
+ {issue.assignees && + issue.assignees.length > 0 && + Array.isArray(issue.assignees) ? ( +
+ +
+ ) : ( + "" + )} +
+
+
+ ))} +
+
+ +
+
+
+ issue?.state_detail?.group === "completed" && + (issue?.priority === "urgent" || issue?.priority === "high") + )?.length / + issues?.filter( + (issue) => issue?.priority === "urgent" || issue?.priority === "high" + )?.length) * + 100 ?? 0 + }%`, + }} + /> +
+
+ { + issues?.filter( + (issue) => + issue?.state_detail?.group === "completed" && + (issue?.priority === "urgent" || issue?.priority === "high") + )?.length + }{" "} + of{" "} + { + issues?.filter( + (issue) => issue?.priority === "urgent" || issue?.priority === "high" + )?.length + } +
+
+
+
+
+
+
+ + Ideal +
+
+ + Current +
+
+
+ + + + + Pending Issues -{" "} + {cycle.total_issues - (cycle.completed_issues + cycle.cancelled_issues)} + +
+
+
+ +
+
+
+
+ ); +}; diff --git a/apps/app/components/cycles/active-cycle-stats.tsx b/apps/app/components/cycles/active-cycle-stats.tsx new file mode 100644 index 000000000..2a8be9575 --- /dev/null +++ b/apps/app/components/cycles/active-cycle-stats.tsx @@ -0,0 +1,182 @@ +import React from "react"; + +import Image from "next/image"; +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// headless ui +import { Tab } from "@headlessui/react"; +// services +import issuesServices from "services/issues.service"; +import projectService from "services/project.service"; +// hooks +import useLocalStorage from "hooks/use-local-storage"; +// components +import { SingleProgressStats } from "components/core"; +// ui +import { Avatar } from "components/ui"; +// icons +import User from "public/user.png"; +// types +import { IIssue, IIssueLabels } from "types"; +// fetch-keys +import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys"; +// types +type Props = { + issues: IIssue[]; +}; + +export const ActiveCycleProgressStats: React.FC = ({ issues }) => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees"); + + const { data: issueLabels } = useSWR( + workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, + workspaceSlug && projectId + ? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId as string) + : null + ); + + const { data: members } = useSWR( + workspaceSlug && projectId ? PROJECT_MEMBERS(workspaceSlug as string) : null, + workspaceSlug && projectId + ? () => projectService.projectMembers(workspaceSlug as string, projectId as string) + : null + ); + + const currentValue = (tab: string | null) => { + switch (tab) { + case "Assignees": + return 0; + case "Labels": + return 1; + + default: + return 0; + } + }; + return ( + { + switch (i) { + case 0: + return setTab("Assignees"); + case 1: + return setTab("Labels"); + + default: + return setTab("Assignees"); + } + }} + > + + + `px-3 py-1 text-brand-base rounded-3xl border border-brand-base ${ + selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2" + }` + } + > + Assignees + + + `px-3 py-1 text-brand-base rounded-3xl border border-brand-base ${ + selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2" + }` + } + > + Labels + + + + + {members?.map((member, index) => { + const totalArray = issues?.filter((i) => i?.assignees?.includes(member.member.id)); + const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); + + if (totalArray.length > 0) { + return ( + + + {member.member.first_name} +
+ } + completed={completeArray.length} + total={totalArray.length} + /> + ); + } + })} + {issues?.filter((i) => i?.assignees?.length === 0).length > 0 ? ( + +
+ User +
+ No assignee + + } + completed={ + issues?.filter( + (i) => i?.state_detail.group === "completed" && i.assignees?.length === 0 + ).length + } + total={issues?.filter((i) => i?.assignees?.length === 0).length} + /> + ) : ( + "" + )} + + + {issueLabels?.map((label, index) => { + const totalArray = issues?.filter((i) => i?.labels?.includes(label.id)); + const completeArray = totalArray?.filter((i) => i?.state_detail.group === "completed"); + + if (totalArray.length > 0) { + return ( + + + {label?.name} + + } + completed={completeArray.length} + total={totalArray.length} + /> + ); + } + })} + + + + ); +}; diff --git a/apps/app/components/cycles/cycles-list.tsx b/apps/app/components/cycles/all-cycles-board.tsx similarity index 97% rename from apps/app/components/cycles/cycles-list.tsx rename to apps/app/components/cycles/all-cycles-board.tsx index f8d607dbe..3aad828fd 100644 --- a/apps/app/components/cycles/cycles-list.tsx +++ b/apps/app/components/cycles/all-cycles-board.tsx @@ -17,7 +17,7 @@ type TCycleStatsViewProps = { type: "current" | "upcoming" | "draft"; }; -export const CyclesList: React.FC = ({ +export const AllCyclesBoard: React.FC = ({ cycles, setCreateUpdateCycleModal, setSelectedCycle, diff --git a/apps/app/components/cycles/all-cycles-list.tsx b/apps/app/components/cycles/all-cycles-list.tsx new file mode 100644 index 000000000..1230a1b9d --- /dev/null +++ b/apps/app/components/cycles/all-cycles-list.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; + +// components +import { DeleteCycleModal, SingleCycleList } from "components/cycles"; +import { EmptyState, Loader } from "components/ui"; +// image +import emptyCycle from "public/empty-state/empty-cycle.svg"; +// icon +import { XMarkIcon } from "@heroicons/react/24/outline"; +// types +import { ICycle, SelectCycleType } from "types"; + +type TCycleStatsViewProps = { + cycles: ICycle[] | undefined; + setCreateUpdateCycleModal: React.Dispatch>; + setSelectedCycle: React.Dispatch>; + type: "current" | "upcoming" | "draft"; +}; + +export const AllCyclesList: React.FC = ({ + cycles, + setCreateUpdateCycleModal, + setSelectedCycle, + type, +}) => { + const [cycleDeleteModal, setCycleDeleteModal] = useState(false); + const [selectedCycleForDelete, setSelectedCycleForDelete] = useState(); + + const handleDeleteCycle = (cycle: ICycle) => { + setSelectedCycleForDelete({ ...cycle, actionType: "delete" }); + setCycleDeleteModal(true); + }; + + const handleEditCycle = (cycle: ICycle) => { + setSelectedCycle({ ...cycle, actionType: "edit" }); + setCreateUpdateCycleModal(true); + }; + + return ( + <> + + {cycles ? ( + cycles.length > 0 ? ( +
+ {cycles.map((cycle) => ( +
+
+ handleDeleteCycle(cycle)} + handleEditCycle={() => handleEditCycle(cycle)} + /> +
+
+ ))} +
+ ) : type === "current" ? ( +
+

No current cycle is present.

+
+ ) : ( + + ) + ) : ( + + + + )} + + ); +}; diff --git a/apps/app/components/cycles/completed-cycles-list.tsx b/apps/app/components/cycles/completed-cycles.tsx similarity index 66% rename from apps/app/components/cycles/completed-cycles-list.tsx rename to apps/app/components/cycles/completed-cycles.tsx index 6729ceeeb..761eb8f3a 100644 --- a/apps/app/components/cycles/completed-cycles-list.tsx +++ b/apps/app/components/cycles/completed-cycles.tsx @@ -7,9 +7,9 @@ import useSWR from "swr"; // services import cyclesService from "services/cycles.service"; // components -import { DeleteCycleModal, SingleCycleCard } from "components/cycles"; +import { DeleteCycleModal, SingleCycleCard, SingleCycleList } from "components/cycles"; // icons -import { CompletedCycleIcon, ExclamationIcon } from "components/icons"; +import { ExclamationIcon } from "components/icons"; // types import { ICycle, SelectCycleType } from "types"; // fetch-keys @@ -19,11 +19,13 @@ import { EmptyState, Loader } from "components/ui"; import emptyCycle from "public/empty-state/empty-cycle.svg"; export interface CompletedCyclesListProps { + cycleView: string; setCreateUpdateCycleModal: React.Dispatch>; setSelectedCycle: React.Dispatch>; } -export const CompletedCyclesList: React.FC = ({ +export const CompletedCycles: React.FC = ({ + cycleView, setCreateUpdateCycleModal, setSelectedCycle, }) => { @@ -72,17 +74,35 @@ export const CompletedCyclesList: React.FC = ({ /> Completed cycles are not editable. -
- {completedCycles.completed_cycles.map((cycle) => ( - handleDeleteCycle(cycle)} - handleEditCycle={() => handleEditCycle(cycle)} - isCompleted - /> - ))} -
+ {cycleView === "list" ? ( +
+ {completedCycles.completed_cycles.map((cycle) => ( +
+
+ handleDeleteCycle(cycle)} + handleEditCycle={() => handleEditCycle(cycle)} + isCompleted + /> +
+
+ ))} +
+ ) : ( +
+ {completedCycles.completed_cycles.map((cycle) => ( + handleDeleteCycle(cycle)} + handleEditCycle={() => handleEditCycle(cycle)} + isCompleted + /> + ))} +
+ )} ) : ( >; + setSelectedCycle: React.Dispatch>; + setCreateUpdateCycleModal: React.Dispatch>; + cyclesCompleteList: ICycle[] | undefined; + currentAndUpcomingCycles: CurrentAndUpcomingCyclesResponse | undefined; + draftCycles: DraftCyclesResponse | undefined; +}; + +export const CyclesView: React.FC = ({ + cycleView, + setCycleView, + setSelectedCycle, + setCreateUpdateCycleModal, + cyclesCompleteList, + currentAndUpcomingCycles, + draftCycles, +}) => { + const { storedValue: cycleTab, setValue: setCycleTab } = useLocalStorage("cycleTab", "All"); + + const currentTabValue = (tab: string | null) => { + switch (tab) { + case "All": + return 0; + case "Active": + return 1; + case "Upcoming": + return 2; + case "Completed": + return 3; + case "Drafts": + return 4; + default: + return 0; + } + }; + + const CompletedCycles = dynamic( + () => import("components/cycles").then((a) => a.CompletedCycles), + { + ssr: false, + loading: () => ( + + + + ), + } + ); + + return ( +
+ { + switch (i) { + case 0: + return setCycleTab("All"); + case 1: + return setCycleTab("Active"); + case 2: + return setCycleTab("Upcoming"); + case 3: + return setCycleTab("Completed"); + case 4: + return setCycleTab("Drafts"); + + default: + return setCycleTab("All"); + } + }} + > + {" "} +
+ + {["All", "Active", "Upcoming", "Completed", "Drafts"].map((tab, index) => ( + + `rounded-3xl border px-6 py-1 outline-none ${ + selected + ? "border-brand-accent bg-brand-accent text-white font-medium" + : "border-brand-base bg-brand-base hover:bg-brand-surface-2" + }` + } + > + {tab} + + ))} + + {cycleTab !== "Active" && ( +
+ + +
+ )} +
+ + + {cycleView === "list" && ( + + )} + {cycleView === "board" && ( + + )} + + + {currentAndUpcomingCycles?.current_cycle?.[0] && ( + + )} + + + {cycleView === "list" && ( + + )} + {cycleView === "board" && ( + + )} + + + + + + {cycleView === "list" && ( + + )} + {cycleView === "board" && ( + + )} + + +
+
+ ); +}; diff --git a/apps/app/components/cycles/delete-cycle-modal.tsx b/apps/app/components/cycles/delete-cycle-modal.tsx index 58b670f3a..136a9b847 100644 --- a/apps/app/components/cycles/delete-cycle-modal.tsx +++ b/apps/app/components/cycles/delete-cycle-modal.tsx @@ -29,6 +29,7 @@ type TConfirmCycleDeletionProps = { import { CYCLE_COMPLETE_LIST, CYCLE_CURRENT_AND_UPCOMING_LIST, + CYCLE_DETAILS, CYCLE_DRAFT_LIST, CYCLE_LIST, } from "constants/fetch-keys"; @@ -114,6 +115,14 @@ export const DeleteCycleModal: React.FC = ({ false ); } + mutate( + CYCLE_DETAILS(projectId as string), + (prevData: any) => { + if (!prevData) return; + return prevData.filter((cycle: any) => cycle.id !== data?.id); + }, + false + ); handleClose(); setToastAlert({ diff --git a/apps/app/components/cycles/index.ts b/apps/app/components/cycles/index.ts index 558801598..73fa92d88 100644 --- a/apps/app/components/cycles/index.ts +++ b/apps/app/components/cycles/index.ts @@ -1,11 +1,16 @@ -export * from "./completed-cycles-list"; -export * from "./cycles-list"; +export * from "./active-cycle-details"; +export * from "./cycles-view"; +export * from "./completed-cycles"; +export * from "./all-cycles-board"; +export * from "./all-cycles-list"; export * from "./delete-cycle-modal"; export * from "./form"; export * from "./modal"; export * from "./select"; export * from "./sidebar"; +export * from "./single-cycle-list"; export * from "./single-cycle-card"; export * from "./empty-cycle"; export * from "./transfer-issues-modal"; -export * from "./transfer-issues"; \ No newline at end of file +export * from "./transfer-issues"; +export * from "./active-cycle-stats"; diff --git a/apps/app/components/cycles/modal.tsx b/apps/app/components/cycles/modal.tsx index 968c6f46e..f96c680ca 100644 --- a/apps/app/components/cycles/modal.tsx +++ b/apps/app/components/cycles/modal.tsx @@ -20,6 +20,7 @@ import type { ICycle } from "types"; import { CYCLE_COMPLETE_LIST, CYCLE_CURRENT_AND_UPCOMING_LIST, + CYCLE_DETAILS, CYCLE_DRAFT_LIST, CYCLE_INCOMPLETE_LIST, } from "constants/fetch-keys"; @@ -58,6 +59,7 @@ export const CreateUpdateCycleModal: React.FC = ({ mutate(CYCLE_DRAFT_LIST(projectId as string)); } mutate(CYCLE_INCOMPLETE_LIST(projectId as string)); + mutate(CYCLE_DETAILS(projectId as string)); handleClose(); setToastAlert({ @@ -92,6 +94,7 @@ export const CreateUpdateCycleModal: React.FC = ({ default: mutate(CYCLE_DRAFT_LIST(projectId as string)); } + mutate(CYCLE_DETAILS(projectId as string)); if ( getDateRangeStatus(data?.start_date, data?.end_date) != getDateRangeStatus(res.start_date, res.end_date) diff --git a/apps/app/components/cycles/single-cycle-card.tsx b/apps/app/components/cycles/single-cycle-card.tsx index a5e75b3f3..8021ebdfe 100644 --- a/apps/app/components/cycles/single-cycle-card.tsx +++ b/apps/app/components/cycles/single-cycle-card.tsx @@ -13,9 +13,19 @@ import useToast from "hooks/use-toast"; // ui import { CustomMenu, LinearProgressIndicator, Tooltip } from "components/ui"; import { Disclosure, Transition } from "@headlessui/react"; +import { AssigneesList, Avatar } from "components/ui/avatar"; +import { SingleProgressStats } from "components/core"; + // icons -import { CalendarDaysIcon } from "@heroicons/react/20/solid"; -import { TargetIcon } from "components/icons"; +import { CalendarDaysIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid"; +import { + TargetIcon, + ContrastIcon, + PersonRunningIcon, + ArrowRightIcon, + TriangleExclamationIcon, + AlarmClockIcon, +} from "components/icons"; import { ChevronDownIcon, LinkIcon, @@ -24,7 +34,11 @@ import { TrashIcon, } from "@heroicons/react/24/outline"; // helpers -import { getDateRangeStatus, renderShortDateWithYearFormat } from "helpers/date-time.helper"; +import { + getDateRangeStatus, + renderShortDateWithYearFormat, + findHowManyDaysLeft, +} from "helpers/date-time.helper"; import { copyTextToClipboard, truncateText } from "helpers/string.helper"; // types import { @@ -86,14 +100,13 @@ export const SingleCycleCard: React.FC = ({ const { setToastAlert } = useToast(); + const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date); const endDate = new Date(cycle.end_date ?? ""); const startDate = new Date(cycle.start_date ?? ""); const handleAddToFavorites = () => { if (!workspaceSlug || !projectId || !cycle) return; - const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date); - switch (cycleStatus) { case "current": case "upcoming": @@ -154,8 +167,6 @@ export const SingleCycleCard: React.FC = ({ const handleRemoveFromFavorites = () => { if (!workspaceSlug || !projectId || !cycle) return; - const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date); - switch (cycleStatus) { case "current": case "upcoming": @@ -236,69 +247,158 @@ export const SingleCycleCard: React.FC = ({ color: group.color, })); + const groupedIssues: any = { + backlog: cycle.backlog_issues, + unstarted: cycle.unstarted_issues, + started: cycle.started_issues, + completed: cycle.completed_issues, + cancelled: cycle.cancelled_issues, + }; + return (
-
+
-
- -

- {truncateText(cycle.name, 75)} -

-
- {cycle.is_favorite ? ( - - ) : ( - + {cycleStatus === "current" ? ( + + + {findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left + + ) : cycleStatus === "upcoming" ? ( + + + {findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left + + ) : cycleStatus === "completed" ? ( + + {cycle.total_issues - cycle.completed_issues > 0 && ( + + + + + + )}{" "} + Completed + + ) : ( + cycleStatus + )} + + {cycle.is_favorite ? ( + + ) : ( + + )} + +
+
+ {cycleStatus !== "draft" && ( + <> +
+ + {renderShortDateWithYearFormat(startDate)} +
+ +
+ + {renderShortDateWithYearFormat(endDate)} +
+ )}
-
-
- - Start : - {renderShortDateWithYearFormat(startDate)} +
+
+
+
Creator:
+
+ {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( + {cycle.owned_by.first_name} + ) : ( + + {cycle.owned_by.first_name.charAt(0)} + + )} + {cycle.owned_by.first_name} +
+
+
+
Members:
+ {cycle.assignees.length > 0 ? ( +
+ +
+ ) : ( + "No members" + )} +
-
- - End : - {renderShortDateWithYearFormat(endDate)} -
-
-
-
- {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( - {cycle.owned_by.first_name} - ) : ( - - {cycle.owned_by.first_name.charAt(0)} - - )} - {cycle.owned_by.first_name} -
{!isCompleted && (
+ } + position="bottom" + > +
+ +
+ void; + handleDeleteCycle: () => void; + isCompleted?: boolean; +}; + +const stateGroups = [ + { + key: "backlog_issues", + title: "Backlog", + color: "#dee2e6", + }, + { + key: "unstarted_issues", + title: "Unstarted", + color: "#26b5ce", + }, + { + key: "started_issues", + title: "Started", + color: "#f7ae59", + }, + { + key: "cancelled_issues", + title: "Cancelled", + color: "#d687ff", + }, + { + key: "completed_issues", + title: "Completed", + color: "#09a953", + }, +]; + +type progress = { + progress: number; +}; + +function RadialProgressBar({ progress }: progress) { + const [circumference, setCircumference] = useState(0); + + useEffect(() => { + const radius = 40; + const circumference = 2 * Math.PI * radius; + setCircumference(circumference); + }, []); + + const progressOffset = ((100 - progress) / 100) * circumference; + + return ( +
+ + + + +
+ ); +} +export const SingleCycleList: React.FC = ({ + cycle, + handleEditCycle, + handleDeleteCycle, + isCompleted = false, +}) => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { setToastAlert } = useToast(); + + const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date); + const endDate = new Date(cycle.end_date ?? ""); + const startDate = new Date(cycle.start_date ?? ""); + + const handleAddToFavorites = () => { + if (!workspaceSlug || !projectId || !cycle) return; + + switch (cycleStatus) { + case "current": + case "upcoming": + mutate( + CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string), + (prevData) => ({ + current_cycle: (prevData?.current_cycle ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? true : c.is_favorite, + })), + upcoming_cycle: (prevData?.upcoming_cycle ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? true : c.is_favorite, + })), + }), + false + ); + break; + case "completed": + mutate( + CYCLE_COMPLETE_LIST(projectId as string), + (prevData) => ({ + completed_cycles: (prevData?.completed_cycles ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? true : c.is_favorite, + })), + }), + false + ); + break; + case "draft": + mutate( + CYCLE_DRAFT_LIST(projectId as string), + (prevData) => ({ + draft_cycles: (prevData?.draft_cycles ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? true : c.is_favorite, + })), + }), + false + ); + break; + } + + cyclesService + .addCycleToFavorites(workspaceSlug as string, projectId as string, { + cycle: cycle.id, + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); + }); + }; + + const handleRemoveFromFavorites = () => { + if (!workspaceSlug || !projectId || !cycle) return; + + switch (cycleStatus) { + case "current": + case "upcoming": + mutate( + CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string), + (prevData) => ({ + current_cycle: (prevData?.current_cycle ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? false : c.is_favorite, + })), + upcoming_cycle: (prevData?.upcoming_cycle ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? false : c.is_favorite, + })), + }), + false + ); + break; + case "completed": + mutate( + CYCLE_COMPLETE_LIST(projectId as string), + (prevData) => ({ + completed_cycles: (prevData?.completed_cycles ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? false : c.is_favorite, + })), + }), + false + ); + break; + case "draft": + mutate( + CYCLE_DRAFT_LIST(projectId as string), + (prevData) => ({ + draft_cycles: (prevData?.draft_cycles ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? false : c.is_favorite, + })), + }), + false + ); + break; + } + + cyclesService + .removeCycleFromFavorites(workspaceSlug as string, projectId as string, cycle.id) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't remove the cycle from favorites. Please try again.", + }); + }); + }; + + const handleCopyText = () => { + const originURL = + typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + + copyTextToClipboard( + `${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}` + ).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Cycle link copied to clipboard.", + }); + }); + }; + + const progressIndicatorData = stateGroups.map((group, index) => ({ + id: index, + name: group.title, + value: + cycle.total_issues > 0 + ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 + : 0, + color: group.color, + })); + + return ( +
+
+ + +
+
+ + +
+ +

+ {truncateText(cycle.name, 70)} +

+
+

{cycle.description}

+
+
+ + + {cycleStatus === "current" ? ( + + + {findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left + + ) : cycleStatus === "upcoming" ? ( + + + {findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left + + ) : cycleStatus === "completed" ? ( + + {cycle.total_issues - cycle.completed_issues > 0 && ( + + + + + + )}{" "} + Completed + + ) : ( + cycleStatus + )} + + + {cycleStatus !== "draft" && ( +
+
+ + {renderShortDateWithYearFormat(startDate)} +
+ +
+ + {renderShortDateWithYearFormat(endDate)} +
+
+ )} + +
+ {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( + {cycle.owned_by.first_name} + ) : ( + + {cycle.owned_by.first_name.charAt(0)} + + )} +
+ + Progress + +
+ } + > + + {cycleStatus === "current" ? ( + + + + {Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} % + + + ) : cycleStatus === "upcoming" ? ( + + Yet to start + + ) : cycleStatus === "completed" ? ( + + + {100} % + + ) : ( + + + {cycleStatus} + + )} + + + {cycle.is_favorite ? ( + + ) : ( + + )} +
+ + {!isCompleted && ( + { + e.preventDefault(); + handleEditCycle(); + }} + > + + + Edit Cycle + + + )} + {!isCompleted && ( + { + e.preventDefault(); + handleDeleteCycle(); + }} + > + + + Delete cycle + + + )} + { + e.preventDefault(); + handleCopyText(); + }} + > + + + Copy cycle link + + + +
+ +
+
+ + +
+
+ ); +}; diff --git a/apps/app/components/icons/alarm-clock-icon.tsx b/apps/app/components/icons/alarm-clock-icon.tsx new file mode 100644 index 000000000..598d2733c --- /dev/null +++ b/apps/app/components/icons/alarm-clock-icon.tsx @@ -0,0 +1,22 @@ +import type { Props } from "./types"; + +export const AlarmClockIcon: React.FC = ({ + width = "24", + height = "24", + color = "#858E96", + className, +}) => ( + + + +); diff --git a/apps/app/components/icons/index.ts b/apps/app/components/icons/index.ts index b802121d5..968a14a93 100644 --- a/apps/app/components/icons/index.ts +++ b/apps/app/components/icons/index.ts @@ -1,3 +1,4 @@ +export * from "./alarm-clock-icon"; export * from "./attachment-icon"; export * from "./backlog-state-icon"; export * from "./blocked-icon"; @@ -26,6 +27,7 @@ export * from "./lock-icon"; export * from "./menu-icon"; export * from "./pencil-scribble-icon"; export * from "./plus-icon"; +export * from "./person-running-icon"; export * from "./priority-icon"; export * from "./question-mark-circle-icon"; export * from "./setting-icon"; @@ -70,6 +72,7 @@ export * from "./png-file-icon"; export * from "./jpg-file-icon"; export * from "./svg-file-icon"; export * from "./txt-file-icon"; +export * from "./triangle-exclamation-icon"; export * from "./default-file-icon"; export * from "./video-file-icon"; export * from "./audio-file-icon"; diff --git a/apps/app/components/icons/person-running-icon.tsx b/apps/app/components/icons/person-running-icon.tsx new file mode 100644 index 000000000..48c3a95e9 --- /dev/null +++ b/apps/app/components/icons/person-running-icon.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const PersonRunningIcon: React.FC = ({ width = "24", height = "24", className }) => ( + + + +); diff --git a/apps/app/components/icons/triangle-exclamation-icon.tsx b/apps/app/components/icons/triangle-exclamation-icon.tsx new file mode 100644 index 000000000..acf986021 --- /dev/null +++ b/apps/app/components/icons/triangle-exclamation-icon.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const TriangleExclamationIcon: React.FC = ({ + width = "24", + height = "24", + className, +}) => ( + + + +); diff --git a/apps/app/components/ui/linear-progress-indicator.tsx b/apps/app/components/ui/linear-progress-indicator.tsx index 74152ab82..986741bc6 100644 --- a/apps/app/components/ui/linear-progress-indicator.tsx +++ b/apps/app/components/ui/linear-progress-indicator.tsx @@ -3,9 +3,10 @@ import { Tooltip } from "./tooltip"; type Props = { data: any; + noTooltip?: boolean }; -export const LinearProgressIndicator: React.FC = ({ data }) => { +export const LinearProgressIndicator: React.FC = ({ data, noTooltip=false }) => { const total = data.reduce((acc: any, cur: any) => acc + cur.value, 0); let progress = 0; @@ -16,8 +17,8 @@ export const LinearProgressIndicator: React.FC = ({ data }) => { backgroundColor: item.color, }; progress += item.value; - - return ( + if (noTooltip) return
+ else return (
@@ -26,7 +27,11 @@ export const LinearProgressIndicator: React.FC = ({ data }) => { return (
- {total === 0 ? " - 0%" :
{bars}
} + {total === 0 ? ( +
{bars}
+ ) : ( +
{bars}
+ )}
); }; diff --git a/apps/app/components/ui/tooltip.tsx b/apps/app/components/ui/tooltip.tsx index 0fb5a0352..76f4b9762 100644 --- a/apps/app/components/ui/tooltip.tsx +++ b/apps/app/components/ui/tooltip.tsx @@ -4,7 +4,8 @@ import { Tooltip2 } from "@blueprintjs/popover2"; type Props = { tooltipHeading?: string; - tooltipContent: string; + tooltipContent: string | JSX.Element; + triangle?: boolean; position?: | "top" | "right" @@ -35,17 +36,23 @@ export const Tooltip: React.FC = ({ disabled = false, className = "", theme = "light", + triangle, }) => ( +
{tooltipHeading &&
{tooltipHeading}
} -

{tooltipContent}

+ {tooltipContent}
} position={position} diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx index cf4b8b109..824de13ff 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx @@ -1,23 +1,18 @@ import React, { useEffect, useState } from "react"; import { useRouter } from "next/router"; -import dynamic from "next/dynamic"; import useSWR from "swr"; - -// headless ui -import { Tab } from "@headlessui/react"; // hooks -import useLocalStorage from "hooks/use-local-storage"; // services import cycleService from "services/cycles.service"; import projectService from "services/project.service"; // layouts import { ProjectAuthorizationWrapper } from "layouts/auth-layout"; // components -import { CompletedCyclesListProps, CreateUpdateCycleModal, CyclesList } from "components/cycles"; +import { CreateUpdateCycleModal, CyclesView } from "components/cycles"; // ui -import { Loader, PrimaryButton } from "components/ui"; +import { PrimaryButton } from "components/ui"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; // icons import { PlusIcon } from "@heroicons/react/24/outline"; @@ -29,25 +24,13 @@ import { CYCLE_CURRENT_AND_UPCOMING_LIST, CYCLE_DRAFT_LIST, PROJECT_DETAILS, + CYCLE_DETAILS, } from "constants/fetch-keys"; -const CompletedCyclesList = dynamic( - () => import("components/cycles").then((a) => a.CompletedCyclesList), - { - ssr: false, - loading: () => ( - - - - ), - } -); - const ProjectCycles: NextPage = () => { const [selectedCycle, setSelectedCycle] = useState(); const [createUpdateCycleModal, setCreateUpdateCycleModal] = useState(false); - - const { storedValue: cycleTab, setValue: setCycleTab } = useLocalStorage("cycleTab", "Upcoming"); + const [cycleView, setCycleView] = useState("list"); const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -73,6 +56,13 @@ const ProjectCycles: NextPage = () => { : null ); + const { data: cyclesCompleteList } = useSWR( + workspaceSlug && projectId ? CYCLE_DETAILS(projectId as string) : null, + workspaceSlug && projectId + ? () => cycleService.getCycles(workspaceSlug as string, projectId as string) + : null + ); + useEffect(() => { if (createUpdateCycleModal) return; const timer = setTimeout(() => { @@ -83,13 +73,16 @@ const ProjectCycles: NextPage = () => { const currentTabValue = (tab: string | null) => { switch (tab) { - case "Upcoming": + case "All": return 0; - case "Completed": + case "Active": return 1; - case "Drafts": + case "Upcoming": return 2; - + case "Completed": + return 3; + case "Drafts": + return 4; default: return 0; } @@ -126,101 +119,16 @@ const ProjectCycles: NextPage = () => { />
- {currentAndUpcomingCycles && currentAndUpcomingCycles.current_cycle.length > 0 && ( -

Current Cycle

- )} -
- -
-
-
-

Other Cycles

-
- { - switch (i) { - case 0: - return setCycleTab("Upcoming"); - case 1: - return setCycleTab("Completed"); - case 2: - return setCycleTab("Drafts"); - - default: - return setCycleTab("Upcoming"); - } - }} - > - - - `rounded-3xl border px-5 py-1.5 text-sm outline-none sm:px-7 sm:py-2 sm:text-base ${ - selected - ? "border-brand-accent bg-brand-accent text-white" - : "border-brand-base bg-brand-surface-2 hover:bg-brand-surface-1" - }` - } - > - Upcoming - - - `rounded-3xl border px-5 py-1.5 text-sm outline-none sm:px-7 sm:py-2 sm:text-base ${ - selected - ? "border-brand-accent bg-brand-accent text-white" - : "border-brand-base bg-brand-surface-2 hover:bg-brand-surface-1" - }` - } - > - Completed - - - `rounded-3xl border px-5 py-1.5 text-sm outline-none sm:px-7 sm:py-2 sm:text-base ${ - selected - ? "border-brand-accent bg-brand-accent text-white" - : "border-brand-base bg-brand-surface-2 hover:bg-brand-surface-1" - }` - } - > - Drafts - - - - - - - - - - - - - - -
+

Cycles

+
diff --git a/apps/app/types/cycles.d.ts b/apps/app/types/cycles.d.ts index 760ba03d5..ee4b2cef6 100644 --- a/apps/app/types/cycles.d.ts +++ b/apps/app/types/cycles.d.ts @@ -6,6 +6,7 @@ import type { IWorkspace, IWorkspaceLite, IIssueFilterOptions, + IUserLite, } from "types"; export interface ICycle { @@ -29,6 +30,7 @@ export interface ICycle { unstarted_issues: number; updated_at: Date; updated_by: string; + assignees: IUserLite[]; view_props: { filters: IIssueFilterOptions; };