style: cycle new ui (#1052)

* style: cycles new ui

* chore: added progress bar for the high priority issues

* fix: build fix

* style: active cycle details, theming , padding and layout

* style: cycle list and card styling

* style: cycle card

* fix: tooltip text overflow

* fix: cycle mutation fix

* style: cycle list and card view improvement, chore: code refactor

* feat: view cycle button

* style: cycle list and board view improvement

* style: responsiveness added

* feat: active cycle stats component, chore: code refactor

* fix: active cycle divider fix, style: stats font color

* fix: tooltip fix

---------

Co-authored-by: kunal_17 <kunalvish17360@gmail.com>
This commit is contained in:
Anmol Singh Bhatia 2023-05-17 12:58:01 +05:30 committed by GitHub
parent c49b0d6151
commit 559b0cc9c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1980 additions and 228 deletions

View File

@ -12,9 +12,11 @@ type Props = {
issues: IIssue[]; issues: IIssue[];
start: string; start: string;
end: string; end: string;
width?: number;
height?: number;
}; };
const ProgressChart: React.FC<Props> = ({ issues, start, end }) => { const ProgressChart: React.FC<Props> = ({ issues, start, end, width = 360, height = 160 }) => {
const startDate = new Date(start); const startDate = new Date(start);
const endDate = new Date(end); const endDate = new Date(end);
const getChartData = () => { const getChartData = () => {
@ -51,8 +53,8 @@ const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
return ( return (
<div className="absolute -left-4 flex h-full w-full items-center justify-center text-xs"> <div className="absolute -left-4 flex h-full w-full items-center justify-center text-xs">
<AreaChart <AreaChart
width={360} width={width}
height={160} height={height}
data={ChartData} data={ChartData}
margin={{ margin={{
top: 12, top: 12,

View File

@ -29,6 +29,8 @@ type Props = {
issues: IIssue[]; issues: IIssue[];
module?: IModule; module?: IModule;
userAuth?: UserAuth; userAuth?: UserAuth;
roundedTab?: boolean;
noBackground?: boolean;
}; };
const stateGroupColours: { const stateGroupColours: {
@ -46,6 +48,8 @@ export const SidebarProgressStats: React.FC<Props> = ({
issues, issues,
module, module,
userAuth, userAuth,
roundedTab,
noBackground,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -100,12 +104,16 @@ export const SidebarProgressStats: React.FC<Props> = ({
> >
<Tab.List <Tab.List
as="div" as="div"
className={`flex w-full items-center justify-between rounded-md bg-brand-surface-1 px-1 py-1.5 className={`flex w-full items-center gap-2 justify-between rounded-md ${
noBackground ? "" : "bg-brand-surface-1"
} px-1 py-1.5
${module ? "text-xs" : "text-sm"} `} ${module ? "text-xs" : "text-sm"} `}
> >
<Tab <Tab
className={({ selected }) => className={({ selected }) =>
`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" selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
}` }`
} }
@ -114,7 +122,9 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab> </Tab>
<Tab <Tab
className={({ selected }) => className={({ selected }) =>
`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" selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
}` }`
} }
@ -123,7 +133,9 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab> </Tab>
<Tab <Tab
className={({ selected }) => className={({ selected }) =>
`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" selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
}` }`
} }
@ -131,10 +143,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
States States
</Tab> </Tab>
</Tab.List> </Tab.List>
<Tab.Panels className="flex w-full items-center justify-between pt-1"> <Tab.Panels className="flex w-full items-center justify-between pt-1 text-brand-secondary">
<Tab.Panel as="div" className="flex w-full flex-col text-xs"> <Tab.Panel as="div" className="flex w-full flex-col text-xs">
{members?.map((member, index) => { {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"); const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
if (totalArray.length > 0) { if (totalArray.length > 0) {
@ -150,19 +162,19 @@ export const SidebarProgressStats: React.FC<Props> = ({
completed={completeArray.length} completed={completeArray.length}
total={totalArray.length} total={totalArray.length}
onClick={() => { onClick={() => {
if (filters.assignees?.includes(member.member.id)) if (filters?.assignees?.includes(member.member.id))
setFilters({ setFilters({
assignees: filters.assignees?.filter((a) => a !== member.member.id), assignees: filters?.assignees?.filter((a) => a !== member.member.id),
}); });
else else
setFilters({ assignees: [...(filters?.assignees ?? []), member.member.id] }); 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 ? (
<SingleProgressStats <SingleProgressStats
title={ title={
<> <>
@ -180,10 +192,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
} }
completed={ completed={
issues?.filter( issues?.filter(
(i) => i.state_detail.group === "completed" && i.assignees?.length === 0 (i) => i?.state_detail.group === "completed" && i.assignees?.length === 0
).length ).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<Props> = ({
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="w-full space-y-1"> <Tab.Panel as="div" className="w-full space-y-1">
{issueLabels?.map((label, index) => { {issueLabels?.map((label, index) => {
const totalArray = issues?.filter((i) => i.labels?.includes(label.id)); const totalArray = issues?.filter((i) => i?.labels?.includes(label.id));
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); const completeArray = totalArray?.filter((i) => i?.state_detail.group === "completed");
if (totalArray.length > 0) { if (totalArray.length > 0) {
return ( return (
@ -207,7 +219,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
label.color && label.color !== "" ? label.color : "#000000", label.color && label.color !== "" ? label.color : "#000000",
}} }}
/> />
<span className="text-xs capitalize">{label.name}</span> <span className="text-xs capitalize">{label?.name}</span>
</div> </div>
} }
completed={completeArray.length} completed={completeArray.length}
@ -215,11 +227,11 @@ export const SidebarProgressStats: React.FC<Props> = ({
onClick={() => { onClick={() => {
if (filters.labels?.includes(label.id)) if (filters.labels?.includes(label.id))
setFilters({ setFilters({
labels: filters.labels?.filter((l) => l !== label.id), labels: filters?.labels?.filter((l) => l !== label.id),
}); });
else setFilters({ labels: [...(filters?.labels ?? []), label.id] }); else setFilters({ labels: [...(filters?.labels ?? []), label.id] });
}} }}
selected={filters.labels?.includes(label.id)} selected={filters?.labels?.includes(label.id)}
/> />
); );
} }

View File

@ -18,7 +18,7 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
selected = false, selected = false,
}) => ( }) => (
<div <div
className={`flex w-full items-center justify-between rounded p-2 text-xs ${ className={`flex w-full items-center gap-4 justify-between rounded-sm p-1 text-xs ${
onClick ? "cursor-pointer hover:bg-brand-surface-1" : "" onClick ? "cursor-pointer hover:bg-brand-surface-1" : ""
} ${selected ? "bg-brand-surface-1" : ""}`} } ${selected ? "bg-brand-surface-1" : ""}`}
onClick={onClick} onClick={onClick}

View File

@ -0,0 +1,602 @@
import React from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// services
import cyclesService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { LinearProgressIndicator, Tooltip } from "components/ui";
import { AssigneesList } from "components/ui/avatar";
import { SingleProgressStats } from "components/core";
// components
import ProgressChart from "components/core/sidebar/progress-chart";
import { ActiveCycleProgressStats } from "components/cycles";
// icons
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
import { getPriorityIcon } from "components/icons/priority-icon";
import {
TargetIcon,
ContrastIcon,
PersonRunningIcon,
ArrowRightIcon,
TriangleExclamationIcon,
AlarmClockIcon,
LayerDiagonalIcon,
CompletedStateIcon,
} from "components/icons";
import { StarIcon } from "@heroicons/react/24/outline";
// helpers
import {
getDateRangeStatus,
renderShortDateWithYearFormat,
findHowManyDaysLeft,
} from "helpers/date-time.helper";
import { truncateText } from "helpers/string.helper";
// types
import {
CompletedCyclesResponse,
CurrentAndUpcomingCyclesResponse,
DraftCyclesResponse,
ICycle,
IIssue,
} from "types";
// fetch-keys
import {
CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DRAFT_LIST,
CYCLE_ISSUES,
} from "constants/fetch-keys";
type TSingleStatProps = {
cycle: ICycle;
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",
},
];
export const ActiveCycleDetails: React.FC<TSingleStatProps> = ({ 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<CurrentAndUpcomingCyclesResponse>(
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<CompletedCyclesResponse>(
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<DraftCyclesResponse>(
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<CurrentAndUpcomingCyclesResponse>(
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<CompletedCyclesResponse>(
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<DraftCyclesResponse>(
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<IIssue[]>(
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 (
<div className="grid-row-2 grid rounded-[10px] shadow divide-y bg-brand-base border border-brand-base">
<div className="grid grid-cols-1 divide-y border-brand-base lg:divide-y-0 lg:divide-x lg:grid-cols-3">
<div className="flex flex-col text-xs">
<a className="w-full">
<div className="flex h-full flex-col gap-5 rounded-b-[10px] p-4">
<div className="flex items-center justify-between gap-1">
<span className="flex items-center gap-1">
<ContrastIcon
className="h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "#858E96"
: ""
}`}
/>
<Tooltip tooltipContent={cycle.name} position="top-left">
<h3 className="break-all text-lg font-semibold">
{truncateText(cycle.name, 70)}
</h3>
</Tooltip>
</span>
<span className="flex items-center gap-1 capitalize">
<span
className={`rounded-full px-1.5 py-0.5
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1">
<PersonRunningIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1">
<AlarmClockIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${
cycle.total_issues - cycle.completed_issues
} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<TriangleExclamationIcon className="h-3.5 w-3.5 fill-current" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span>
{cycle.is_favorite ? (
<button
onClick={(e) => {
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button
onClick={(e) => {
e.preventDefault();
handleAddToFavorites();
}}
>
<StarIcon className="h-4 w-4 " color="#858E96" />
</button>
)}
</span>
</div>
<div className="flex items-center justify-start gap-5 text-brand-secondary">
<div className="flex items-start gap-1">
<CalendarDaysIcon className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRightIcon className="h-4 w-4 text-brand-secondary" />
<div className="flex items-start gap-1">
<TargetIcon className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5 text-brand-secondary">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<Image
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.first_name}
/>
) : (
<span className="bg-brand-secondary flex h-5 w-5 items-center justify-center rounded-full bg-brand-base capitalize">
{cycle.owned_by.first_name.charAt(0)}
</span>
)}
<span className="text-brand-secondary">{cycle.owned_by.first_name}</span>
</div>
{cycle.assignees.length > 0 && (
<div className="flex items-center gap-1 text-brand-secondary">
<AssigneesList users={cycle.assignees} length={4} />
</div>
)}
</div>
<div className="flex items-center gap-4 text-brand-secondary">
<div className="flex gap-2">
<LayerDiagonalIcon className="h-4 w-4 flex-shrink-0" />
{cycle.total_issues} issues
</div>
<div className="flex gap-2">
<CompletedStateIcon width={16} height={16} color="#438AF3" />
{cycle.completed_issues} issues
</div>
</div>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="bg-brand-accent text-white px-4 rounded-md py-2 text-center text-sm font-medium w-full hover:bg-brand-accent/90">
View Cycle
</a>
</Link>
</div>
</a>
</div>
<div className="grid col-span-2 grid-cols-1 divide-y border-brand-base md:divide-y-0 md:divide-x md:grid-cols-2">
<div className="flex h-full flex-col border-brand-base">
<div className="flex h-full w-full flex-col text-brand-secondary p-4">
<div className="flex w-full items-center gap-2 py-1">
<span>Progress</span>
<LinearProgressIndicator data={progressIndicatorData} />
</div>
<div className="flex flex-col mt-2 gap-1 items-center">
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full "
style={{
backgroundColor: stateGroups[index].color,
}}
/>
<span className="text-xs capitalize">{group}</span>
</div>
}
completed={groupedIssues[group]}
total={cycle.total_issues}
/>
))}
</div>
</div>
</div>
<div className="border-brand-base p-4">
<ActiveCycleProgressStats issues={issues ?? []} />
</div>
</div>
</div>
<div className="grid grid-cols-1 divide-y border-brand-base lg:divide-y-0 lg:divide-x lg:grid-cols-2">
<div className="flex flex-col justify-between p-4">
<div>
<div className="text-brand-primary mb-2">High Priority Issues</div>
<div className="mb-2 flex max-h-[240px] min-h-[240px] flex-col gap-2.5 overflow-y-scroll rounded-md">
{issues
?.filter((issue) => issue.priority === "urgent" || issue.priority === "high")
.map((issue) => (
<div
key={issue.id}
className="flex flex-wrap rounded-md items-center justify-between gap-2 border border-brand-base bg-brand-surface-1 px-3 py-1.5"
>
<div className="flex flex-col gap-1">
<div>
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-brand-secondary">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
</div>
<Tooltip
position="top-left"
tooltipHeading="Title"
tooltipContent={issue.name}
>
<span className="text-[0.825rem] text-brand-base">
{truncateText(issue.name, 30)}
</span>
</Tooltip>
</div>
<div className="flex items-center gap-1.5">
<div
className={`grid h-6 w-6 place-items-center items-center rounded border shadow-sm ${
issue.priority === "urgent"
? "border-red-500/20 bg-red-500/20 text-red-500"
: issue.priority === "high"
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
: issue.priority === "medium"
? "border-yellow-500/20 bg-yellow-500/20 text-yellow-500"
: issue.priority === "low"
? "border-green-500/20 bg-green-500/20 text-green-500"
: "border-brand-base"
}`}
>
{getPriorityIcon(issue.priority, "text-sm")}
</div>
{issue.label_details.length > 0 ? (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
key={label.id}
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary"
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor:
label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</span>
))}
</div>
) : (
""
)}
<div className={`flex items-center gap-2 text-brand-secondary`}>
{issue.assignees &&
issue.assignees.length > 0 &&
Array.isArray(issue.assignees) ? (
<div className="-my-0.5 flex items-center justify-center gap-2">
<AssigneesList
userIds={issue.assignees}
length={3}
showLength={false}
/>
</div>
) : (
""
)}
</div>
</div>
</div>
))}
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="h-1 w-full rounded-full bg-brand-surface-2">
<div
className="h-1 rounded-full bg-green-600"
style={{
width:
issues &&
`${
(issues?.filter(
(issue) =>
issue?.state_detail?.group === "completed" &&
(issue?.priority === "urgent" || issue?.priority === "high")
)?.length /
issues?.filter(
(issue) => issue?.priority === "urgent" || issue?.priority === "high"
)?.length) *
100 ?? 0
}%`,
}}
/>
</div>
<div className="w-16 text-end text-xs text-brand-secondary">
{
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
}
</div>
</div>
</div>
<div className="flex flex-col justify-between border-brand-base p-4">
<div className="flex items-start justify-between gap-4 py-1.5 text-xs">
<div className="flex items-center gap-3 text-brand-base">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
<div className="flex items-center gap-1">
<span>
<LayerDiagonalIcon className="h-5 w-5 flex-shrink-0 text-brand-secondary" />
</span>
<span>
Pending Issues -{" "}
{cycle.total_issues - (cycle.completed_issues + cycle.cancelled_issues)}
</span>
</div>
</div>
<div className="relative h-64">
<ProgressChart
issues={issues ?? []}
start={cycle?.start_date ?? ""}
end={cycle?.end_date ?? ""}
width={475}
height={256}
/>
</div>
</div>
</div>
</div>
);
};

View File

@ -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<Props> = ({ issues }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees");
const { data: issueLabels } = useSWR<IIssueLabels[]>(
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 (
<Tab.Group
defaultIndex={currentValue(tab)}
onChange={(i) => {
switch (i) {
case 0:
return setTab("Assignees");
case 1:
return setTab("Labels");
default:
return setTab("Assignees");
}
}}
>
<Tab.List as="div" className="flex flex-wrap items-center justify-start gap-4 text-sm">
<Tab
className={({ selected }) =>
`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
</Tab>
<Tab
className={({ selected }) =>
`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
</Tab>
</Tab.List>
<Tab.Panels className="flex w-full">
<Tab.Panel
as="div"
className="flex flex-col w-full mt-2 gap-1 items-center text-brand-secondary"
>
{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 (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<Avatar user={member.member} />
<span>{member.member.first_name}</span>
</div>
}
completed={completeArray.length}
total={totalArray.length}
/>
);
}
})}
{issues?.filter((i) => i?.assignees?.length === 0).length > 0 ? (
<SingleProgressStats
title={
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full border-2 border-white bg-brand-surface-2">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="User"
/>
</div>
<span>No assignee</span>
</div>
}
completed={
issues?.filter(
(i) => i?.state_detail.group === "completed" && i.assignees?.length === 0
).length
}
total={issues?.filter((i) => i?.assignees?.length === 0).length}
/>
) : (
""
)}
</Tab.Panel>
<Tab.Panel
as="div"
className="flex flex-col w-full mt-2 gap-1 items-center text-brand-secondary"
>
{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 (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor:
label.color && label.color !== "" ? label.color : "#000000",
}}
/>
<span className="text-xs capitalize">{label?.name}</span>
</div>
}
completed={completeArray.length}
total={totalArray.length}
/>
);
}
})}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);
};

View File

@ -17,7 +17,7 @@ type TCycleStatsViewProps = {
type: "current" | "upcoming" | "draft"; type: "current" | "upcoming" | "draft";
}; };
export const CyclesList: React.FC<TCycleStatsViewProps> = ({ export const AllCyclesBoard: React.FC<TCycleStatsViewProps> = ({
cycles, cycles,
setCreateUpdateCycleModal, setCreateUpdateCycleModal,
setSelectedCycle, setSelectedCycle,

View File

@ -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<React.SetStateAction<boolean>>;
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
type: "current" | "upcoming" | "draft";
};
export const AllCyclesList: React.FC<TCycleStatsViewProps> = ({
cycles,
setCreateUpdateCycleModal,
setSelectedCycle,
type,
}) => {
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectCycleType>();
const handleDeleteCycle = (cycle: ICycle) => {
setSelectedCycleForDelete({ ...cycle, actionType: "delete" });
setCycleDeleteModal(true);
};
const handleEditCycle = (cycle: ICycle) => {
setSelectedCycle({ ...cycle, actionType: "edit" });
setCreateUpdateCycleModal(true);
};
return (
<>
<DeleteCycleModal
isOpen={
cycleDeleteModal &&
!!selectedCycleForDelete &&
selectedCycleForDelete.actionType === "delete"
}
setIsOpen={setCycleDeleteModal}
data={selectedCycleForDelete}
/>
{cycles ? (
cycles.length > 0 ? (
<div>
{cycles.map((cycle) => (
<div className="hover:bg-brand-surface-2">
<div className="flex flex-col border-brand-base">
<SingleCycleList
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
/>
</div>
</div>
))}
</div>
) : type === "current" ? (
<div className="flex w-full items-center justify-start rounded-[10px] bg-brand-surface-2 px-6 py-4">
<h3 className="text-base font-medium text-brand-base ">No current cycle is present.</h3>
</div>
) : (
<EmptyState
type="cycle"
title="Create New Cycle"
description="Sprint more effectively with Cycles by confining your project
to a fixed amount of time. Create new cycle now."
imgURL={emptyCycle}
/>
)
) : (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" />
</Loader>
)}
</>
);
};

View File

@ -7,9 +7,9 @@ import useSWR from "swr";
// services // services
import cyclesService from "services/cycles.service"; import cyclesService from "services/cycles.service";
// components // components
import { DeleteCycleModal, SingleCycleCard } from "components/cycles"; import { DeleteCycleModal, SingleCycleCard, SingleCycleList } from "components/cycles";
// icons // icons
import { CompletedCycleIcon, ExclamationIcon } from "components/icons"; import { ExclamationIcon } from "components/icons";
// types // types
import { ICycle, SelectCycleType } from "types"; import { ICycle, SelectCycleType } from "types";
// fetch-keys // fetch-keys
@ -19,11 +19,13 @@ import { EmptyState, Loader } from "components/ui";
import emptyCycle from "public/empty-state/empty-cycle.svg"; import emptyCycle from "public/empty-state/empty-cycle.svg";
export interface CompletedCyclesListProps { export interface CompletedCyclesListProps {
cycleView: string;
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>; setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>; setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
} }
export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({ export const CompletedCycles: React.FC<CompletedCyclesListProps> = ({
cycleView,
setCreateUpdateCycleModal, setCreateUpdateCycleModal,
setSelectedCycle, setSelectedCycle,
}) => { }) => {
@ -72,17 +74,35 @@ export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({
/> />
<span>Completed cycles are not editable.</span> <span>Completed cycles are not editable.</span>
</div> </div>
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3"> {cycleView === "list" ? (
{completedCycles.completed_cycles.map((cycle) => ( <div>
<SingleCycleCard {completedCycles.completed_cycles.map((cycle) => (
key={cycle.id} <div className="hover:bg-brand-surface-2">
cycle={cycle} <div className="flex flex-col border-brand-base">
handleDeleteCycle={() => handleDeleteCycle(cycle)} <SingleCycleList
handleEditCycle={() => handleEditCycle(cycle)} key={cycle.id}
isCompleted cycle={cycle}
/> handleDeleteCycle={() => handleDeleteCycle(cycle)}
))} handleEditCycle={() => handleEditCycle(cycle)}
</div> isCompleted
/>
</div>
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
{completedCycles.completed_cycles.map((cycle) => (
<SingleCycleCard
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
isCompleted
/>
))}
</div>
)}
</div> </div>
) : ( ) : (
<EmptyState <EmptyState

View File

@ -0,0 +1,207 @@
import React from "react";
import dynamic from "next/dynamic";
// headless ui
import { Tab } from "@headlessui/react";
// hooks
import useLocalStorage from "hooks/use-local-storage";
// components
import {
ActiveCycleDetails,
CompletedCyclesListProps,
AllCyclesBoard,
AllCyclesList,
CompletedCycles,
} from "components/cycles";
// ui
import { Loader } from "components/ui";
// icons
import { ListBulletIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
// types
import {
SelectCycleType,
ICycle,
CurrentAndUpcomingCyclesResponse,
DraftCyclesResponse,
} from "types";
type Props = {
cycleView: string;
setCycleView: React.Dispatch<React.SetStateAction<string>>;
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
cyclesCompleteList: ICycle[] | undefined;
currentAndUpcomingCycles: CurrentAndUpcomingCyclesResponse | undefined;
draftCycles: DraftCyclesResponse | undefined;
};
export const CyclesView: React.FC<Props> = ({
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<CompletedCyclesListProps>(
() => import("components/cycles").then((a) => a.CompletedCycles),
{
ssr: false,
loading: () => (
<Loader className="mb-5">
<Loader.Item height="12rem" width="100%" />
</Loader>
),
}
);
return (
<div>
<Tab.Group
defaultIndex={currentTabValue(cycleTab)}
onChange={(i) => {
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");
}
}}
>
{" "}
<div className="flex justify-between">
<Tab.List as="div" className="flex flex-wrap items-center justify-start gap-4 text-base">
{["All", "Active", "Upcoming", "Completed", "Drafts"].map((tab, index) => (
<Tab
key={index}
className={({ selected }) =>
`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}
</Tab>
))}
</Tab.List>
{cycleTab !== "Active" && (
<div className="flex items-center gap-x-1">
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-2`}
onClick={() => setCycleView("list")}
>
<ListBulletIcon className="h-4 w-4 text-brand-secondary" />
</button>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-2`}
onClick={() => setCycleView("board")}
>
<Squares2X2Icon className="h-4 w-4 text-brand-secondary" />
</button>
</div>
)}
</div>
<Tab.Panels>
<Tab.Panel as="div" className="mt-7 space-y-5">
{cycleView === "list" && (
<AllCyclesList
cycles={cyclesCompleteList}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="current"
/>
)}
{cycleView === "board" && (
<AllCyclesBoard
cycles={cyclesCompleteList}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="current"
/>
)}
</Tab.Panel>
<Tab.Panel as="div" className="mt-7 space-y-5">
{currentAndUpcomingCycles?.current_cycle?.[0] && (
<ActiveCycleDetails cycle={currentAndUpcomingCycles?.current_cycle?.[0]} />
)}
</Tab.Panel>
<Tab.Panel as="div" className="mt-7 space-y-5">
{cycleView === "list" && (
<AllCyclesList
cycles={currentAndUpcomingCycles?.upcoming_cycle}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="upcoming"
/>
)}
{cycleView === "board" && (
<AllCyclesBoard
cycles={currentAndUpcomingCycles?.upcoming_cycle}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="upcoming"
/>
)}
</Tab.Panel>
<Tab.Panel as="div" className="mt-7 space-y-5">
<CompletedCycles
cycleView={cycleView}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
/>
</Tab.Panel>
<Tab.Panel as="div" className="mt-7 space-y-5">
{cycleView === "list" && (
<AllCyclesList
cycles={draftCycles?.draft_cycles}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="draft"
/>
)}
{cycleView === "board" && (
<AllCyclesBoard
cycles={draftCycles?.draft_cycles}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="draft"
/>
)}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
);
};

View File

@ -29,6 +29,7 @@ type TConfirmCycleDeletionProps = {
import { import {
CYCLE_COMPLETE_LIST, CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST, CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DETAILS,
CYCLE_DRAFT_LIST, CYCLE_DRAFT_LIST,
CYCLE_LIST, CYCLE_LIST,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
@ -114,6 +115,14 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
false false
); );
} }
mutate(
CYCLE_DETAILS(projectId as string),
(prevData: any) => {
if (!prevData) return;
return prevData.filter((cycle: any) => cycle.id !== data?.id);
},
false
);
handleClose(); handleClose();
setToastAlert({ setToastAlert({

View File

@ -1,11 +1,16 @@
export * from "./completed-cycles-list"; export * from "./active-cycle-details";
export * from "./cycles-list"; 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 "./delete-cycle-modal";
export * from "./form"; export * from "./form";
export * from "./modal"; export * from "./modal";
export * from "./select"; export * from "./select";
export * from "./sidebar"; export * from "./sidebar";
export * from "./single-cycle-list";
export * from "./single-cycle-card"; export * from "./single-cycle-card";
export * from "./empty-cycle"; export * from "./empty-cycle";
export * from "./transfer-issues-modal"; export * from "./transfer-issues-modal";
export * from "./transfer-issues"; export * from "./transfer-issues";
export * from "./active-cycle-stats";

View File

@ -20,6 +20,7 @@ import type { ICycle } from "types";
import { import {
CYCLE_COMPLETE_LIST, CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST, CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DETAILS,
CYCLE_DRAFT_LIST, CYCLE_DRAFT_LIST,
CYCLE_INCOMPLETE_LIST, CYCLE_INCOMPLETE_LIST,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
@ -58,6 +59,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
mutate(CYCLE_DRAFT_LIST(projectId as string)); mutate(CYCLE_DRAFT_LIST(projectId as string));
} }
mutate(CYCLE_INCOMPLETE_LIST(projectId as string)); mutate(CYCLE_INCOMPLETE_LIST(projectId as string));
mutate(CYCLE_DETAILS(projectId as string));
handleClose(); handleClose();
setToastAlert({ setToastAlert({
@ -92,6 +94,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
default: default:
mutate(CYCLE_DRAFT_LIST(projectId as string)); mutate(CYCLE_DRAFT_LIST(projectId as string));
} }
mutate(CYCLE_DETAILS(projectId as string));
if ( if (
getDateRangeStatus(data?.start_date, data?.end_date) != getDateRangeStatus(data?.start_date, data?.end_date) !=
getDateRangeStatus(res.start_date, res.end_date) getDateRangeStatus(res.start_date, res.end_date)

View File

@ -13,9 +13,19 @@ import useToast from "hooks/use-toast";
// ui // ui
import { CustomMenu, LinearProgressIndicator, Tooltip } from "components/ui"; import { CustomMenu, LinearProgressIndicator, Tooltip } from "components/ui";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
import { AssigneesList, Avatar } from "components/ui/avatar";
import { SingleProgressStats } from "components/core";
// icons // icons
import { CalendarDaysIcon } from "@heroicons/react/20/solid"; import { CalendarDaysIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid";
import { TargetIcon } from "components/icons"; import {
TargetIcon,
ContrastIcon,
PersonRunningIcon,
ArrowRightIcon,
TriangleExclamationIcon,
AlarmClockIcon,
} from "components/icons";
import { import {
ChevronDownIcon, ChevronDownIcon,
LinkIcon, LinkIcon,
@ -24,7 +34,11 @@ import {
TrashIcon, TrashIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// helpers // 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"; import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types // types
import { import {
@ -86,14 +100,13 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const endDate = new Date(cycle.end_date ?? ""); const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? ""); const startDate = new Date(cycle.start_date ?? "");
const handleAddToFavorites = () => { const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId || !cycle) return; if (!workspaceSlug || !projectId || !cycle) return;
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
switch (cycleStatus) { switch (cycleStatus) {
case "current": case "current":
case "upcoming": case "upcoming":
@ -154,8 +167,6 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
const handleRemoveFromFavorites = () => { const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !projectId || !cycle) return; if (!workspaceSlug || !projectId || !cycle) return;
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
switch (cycleStatus) { switch (cycleStatus) {
case "current": case "current":
case "upcoming": case "upcoming":
@ -236,69 +247,158 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
color: group.color, 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 ( return (
<div> <div>
<div className="flex flex-col rounded-[10px] bg-brand-base text-xs shadow"> <div className="flex flex-col rounded-[10px] bg-brand-base border border-brand-base text-xs shadow">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}> <Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full"> <a className="w-full">
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4"> <div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
<div className="flex items-start justify-between gap-1"> <div className="flex items-center justify-between gap-1">
<Tooltip tooltipContent={cycle.name} position="top-left"> <span className="flex items-center gap-1">
<h3 className="break-all text-lg font-semibold"> <ContrastIcon
{truncateText(cycle.name, 75)} className="h-5 w-5"
</h3> color={`${
</Tooltip> cycleStatus === "current"
{cycle.is_favorite ? ( ? "#09A953"
<button : cycleStatus === "upcoming"
onClick={(e) => { ? "#F7AE59"
e.preventDefault(); : cycleStatus === "completed"
handleRemoveFromFavorites(); ? "#3F76FF"
}} : cycleStatus === "draft"
? "#858E96"
: ""
}`}
/>
<Tooltip tooltipContent={cycle.name} className="break-all" position="top-left">
<h3 className="break-all text-lg font-semibold">
{truncateText(cycle.name, 15)}
</h3>
</Tooltip>
</span>
<span className="flex items-center gap-1 capitalize">
<span
className={`rounded-full px-1.5 py-0.5
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
> >
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" /> {cycleStatus === "current" ? (
</button> <span className="flex gap-1">
) : ( <PersonRunningIcon className="h-4 w-4" />
<button {findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
onClick={(e) => { </span>
e.preventDefault(); ) : cycleStatus === "upcoming" ? (
handleAddToFavorites(); <span className="flex gap-1">
}} <AlarmClockIcon className="h-4 w-4" />
> {findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left
<StarIcon className="h-4 w-4 " color="#858E96" /> </span>
</button> ) : cycleStatus === "completed" ? (
<span className="flex gap-1">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${
cycle.total_issues - cycle.completed_issues
} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<TriangleExclamationIcon className="h-3.5 w-3.5 fill-current" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span>
{cycle.is_favorite ? (
<button
onClick={(e) => {
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button
onClick={(e) => {
e.preventDefault();
handleAddToFavorites();
}}
>
<StarIcon className="h-4 w-4 " color="#858E96" />
</button>
)}
</span>
</div>
<div className="flex h-4 items-center justify-start gap-5 text-brand-secondary">
{cycleStatus !== "draft" && (
<>
<div className="flex items-start gap-1">
<CalendarDaysIcon className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRightIcon className="h-4 w-4" />
<div className="flex items-start gap-1">
<TargetIcon className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</>
)} )}
</div> </div>
<div className="flex items-center justify-start gap-5 text-brand-secondary"> <div className="flex justify-between items-end">
<div className="flex items-start gap-1 "> <div className="flex flex-col gap-2 text-xs text-brand-secondary">
<CalendarDaysIcon className="h-4 w-4" /> <div className="flex items-center gap-2">
<span>Start :</span> <div className="w-16">Creator:</div>
<span>{renderShortDateWithYearFormat(startDate)}</span> <div className="flex items-center gap-2.5 text-brand-secondary">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<Image
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.first_name}
/>
) : (
<span className="bg-brand-secondary flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.first_name.charAt(0)}
</span>
)}
<span className="text-brand-secondary">{cycle.owned_by.first_name}</span>
</div>
</div>
<div className="flex h-5 items-center gap-2">
<div className="w-16">Members:</div>
{cycle.assignees.length > 0 ? (
<div className="flex items-center gap-1 text-brand-secondary">
<AssigneesList users={cycle.assignees} length={4} />
</div>
) : (
"No members"
)}
</div>
</div> </div>
<div className="flex items-start gap-1 ">
<TargetIcon className="h-4 w-4" />
<span>End :</span>
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</div>
<div className="mt-4 flex items-center justify-between text-brand-secondary">
<div className="flex items-center gap-2.5">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<Image
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.first_name}
/>
) : (
<span className="bg-brand-secondary flex h-5 w-5 items-center justify-center rounded-full capitalize">
{cycle.owned_by.first_name.charAt(0)}
</span>
)}
<span>{cycle.owned_by.first_name}</span>
</div>
<div className="flex items-center"> <div className="flex items-center">
{!isCompleted && ( {!isCompleted && (
<button <button
@ -306,7 +406,7 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
e.preventDefault(); e.preventDefault();
handleEditCycle(); handleEditCycle();
}} }}
className="flex cursor-pointer items-center rounded p-1 duration-300 hover:bg-brand-surface-1" className="flex cursor-pointer items-center rounded p-1 text-brand-secondary duration-300 hover:bg-brand-surface-1"
> >
<span> <span>
<PencilIcon className="h-4 w-4" /> <PencilIcon className="h-4 w-4" />
@ -356,7 +456,35 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
> >
<div className="flex w-full items-center gap-2 px-4 py-1"> <div className="flex w-full items-center gap-2 px-4 py-1">
<span>Progress</span> <span>Progress</span>
<LinearProgressIndicator data={progressIndicatorData} /> <Tooltip
tooltipContent={
<div className="flex w-56 flex-col">
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full "
style={{
backgroundColor: stateGroups[index].color,
}}
/>
<span className="text-xs capitalize">{group}</span>
</div>
}
completed={groupedIssues[group]}
total={cycle.total_issues}
/>
))}
</div>
}
position="bottom"
>
<div className="flex w-full items-center">
<LinearProgressIndicator data={progressIndicatorData} noTooltip={true} />
</div>
</Tooltip>
<Disclosure.Button> <Disclosure.Button>
<span className="p-1"> <span className="p-1">
<ChevronDownIcon <ChevronDownIcon

View File

@ -0,0 +1,510 @@
import React, { useEffect, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import cyclesService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { CustomMenu, LinearProgressIndicator, Tooltip } from "components/ui";
// icons
import { CalendarDaysIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid";
import {
TargetIcon,
ContrastIcon,
PersonRunningIcon,
ArrowRightIcon,
TriangleExclamationIcon,
AlarmClockIcon,
} from "components/icons";
import { LinkIcon, PencilIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
// helpers
import {
getDateRangeStatus,
renderShortDateWithYearFormat,
findHowManyDaysLeft,
} from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import {
CompletedCyclesResponse,
CurrentAndUpcomingCyclesResponse,
DraftCyclesResponse,
ICycle,
} from "types";
// fetch-keys
import {
CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DRAFT_LIST,
} from "constants/fetch-keys";
import { type } from "os";
type TSingleStatProps = {
cycle: ICycle;
handleEditCycle: () => 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 (
<div className="relative h-4 w-4">
<svg className="absolute top-0 left-0" viewBox="0 0 100 100">
<circle
className={"stroke-current opacity-10"}
cx="50"
cy="50"
r="40"
strokeWidth="12"
fill="none"
strokeDasharray={`${circumference} ${circumference}`}
/>
<circle
className={`stroke-current`}
cx="50"
cy="50"
r="40"
strokeWidth="12"
fill="none"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={progressOffset}
transform="rotate(-90 50 50)"
/>
</svg>
</div>
);
}
export const SingleCycleList: React.FC<TSingleStatProps> = ({
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<CurrentAndUpcomingCyclesResponse>(
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<CompletedCyclesResponse>(
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<DraftCyclesResponse>(
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<CurrentAndUpcomingCyclesResponse>(
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<CompletedCyclesResponse>(
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<DraftCyclesResponse>(
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 (
<div>
<div className="flex flex-col border-b border-brand-base text-xs hover:bg-brand-surface-2">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full">
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
<div className="flex items-center justify-between gap-1">
<span className="flex items-start gap-2">
<ContrastIcon
className="mt-1 h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "#858E96"
: ""
}`}
/>
<div>
<Tooltip tooltipContent={cycle.name} className="break-all" position="top-left">
<h3 className="break-all text-base font-semibold">
{truncateText(cycle.name, 70)}
</h3>
</Tooltip>
<p className="mt-2 text-brand-secondary">{cycle.description}</p>
</div>
</span>
<span className="flex items-center gap-4 capitalize">
<span
className={`rounded-full px-1.5 py-0.5
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1">
<PersonRunningIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1">
<AlarmClockIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left
</span>
) : cycleStatus === "completed" ? (
<span className="flex items-center gap-1">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${
cycle.total_issues - cycle.completed_issues
} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<TriangleExclamationIcon className="h-3.5 w-3.5 fill-current" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span>
{cycleStatus !== "draft" && (
<div className="flex items-center justify-start gap-2 text-brand-secondary">
<div className="flex items-start gap-1 ">
<CalendarDaysIcon className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRightIcon className="h-4 w-4" />
<div className="flex items-start gap-1 ">
<TargetIcon className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</div>
)}
<div className="flex items-center gap-2.5 text-brand-secondary">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<Image
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.first_name}
/>
) : (
<span className="bg-brand-secondary flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.first_name.charAt(0)}
</span>
)}
</div>
<Tooltip
position="top-right"
tooltipContent={
<div className="flex w-80 items-center gap-2 px-4 py-1">
<span>Progress</span>
<LinearProgressIndicator data={progressIndicatorData} />
</div>
}
>
<span
className={`rounded-md px-1.5 py-1
${
cycleStatus === "current"
? "border border-green-600 bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "border border-orange-300 bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "border border-blue-500 bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "border border-neutral-400 bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1">
<RadialProgressBar
progress={(cycle.completed_issues / cycle.total_issues) * 100}
/>
<span>
{Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} %
</span>
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} /> Yet to start
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} />
<span>{100} %</span>
</span>
) : (
<span className="flex gap-1">
<RadialProgressBar
progress={(cycle.total_issues / cycle.completed_issues) * 100}
/>
{cycleStatus}
</span>
)}
</span>
</Tooltip>
{cycle.is_favorite ? (
<button
onClick={(e) => {
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button
onClick={(e) => {
e.preventDefault();
handleAddToFavorites();
}}
>
<StarIcon className="h-4 w-4 " color="#858E96" />
</button>
)}
<div className="flex items-center">
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleEditCycle();
}}
>
<span className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
<span>Edit Cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleDeleteCycle();
}}
>
<span className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</span>
</div>
</div>
</a>
</Link>
</div>
</div>
);
};

View File

@ -0,0 +1,22 @@
import type { Props } from "./types";
export const AlarmClockIcon: React.FC<Props> = ({
width = "24",
height = "24",
color = "#858E96",
className,
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 18 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.98125 15.4609C8.05625 15.4609 7.18437 15.2859 6.36562 14.9359C5.54687 14.5859 4.83437 14.1078 4.22812 13.5016C3.62187 12.8953 3.14062 12.1828 2.78437 11.3641C2.42812 10.5453 2.25 9.66886 2.25 8.73469C2.25 7.80053 2.42812 6.92553 2.78437 6.10969C3.14062 5.29386 3.62187 4.57969 4.22812 3.96719C4.83437 3.35469 5.54687 2.87344 6.36562 2.52344C7.18437 2.17344 8.05625 1.99844 8.98125 1.99844C9.90625 1.99844 10.7781 2.17344 11.5969 2.52344C12.4156 2.87344 13.1312 3.35469 13.7437 3.96719C14.3562 4.57969 14.8375 5.29386 15.1875 6.10969C15.5375 6.92553 15.7125 7.80053 15.7125 8.73469C15.7125 9.66886 15.5375 10.5453 15.1875 11.3641C14.8375 12.1828 14.3562 12.8953 13.7437 13.5016C13.1312 14.1078 12.4156 14.5859 11.5969 14.9359C10.7781 15.2859 9.90625 15.4609 8.98125 15.4609ZM11.25 11.7859L12.0375 10.9984L9.6 8.56094V4.99844H8.475V9.01094L11.25 11.7859ZM4.0125 0.742188L4.8 1.52969L1.725 4.49219L0.9375 3.70469L4.0125 0.742188ZM13.95 0.742188L17.025 3.70469L16.2375 4.49219L13.1625 1.52969L13.95 0.742188ZM8.98206 14.3359C10.544 14.3359 11.8687 13.7919 12.9562 12.7039C14.0437 11.6158 14.5875 10.2908 14.5875 8.72888C14.5875 7.16692 14.0435 5.84219 12.9554 4.75469C11.8674 3.66719 10.5424 3.12344 8.98044 3.12344C7.41848 3.12344 6.09375 3.66746 5.00625 4.75549C3.91875 5.84353 3.375 7.16853 3.375 8.73049C3.375 10.2925 3.91902 11.6172 5.00706 12.7047C6.09509 13.7922 7.42009 14.3359 8.98206 14.3359Z"
fill="#F7AE59"
/>
</svg>
);

View File

@ -1,3 +1,4 @@
export * from "./alarm-clock-icon";
export * from "./attachment-icon"; export * from "./attachment-icon";
export * from "./backlog-state-icon"; export * from "./backlog-state-icon";
export * from "./blocked-icon"; export * from "./blocked-icon";
@ -26,6 +27,7 @@ export * from "./lock-icon";
export * from "./menu-icon"; export * from "./menu-icon";
export * from "./pencil-scribble-icon"; export * from "./pencil-scribble-icon";
export * from "./plus-icon"; export * from "./plus-icon";
export * from "./person-running-icon";
export * from "./priority-icon"; export * from "./priority-icon";
export * from "./question-mark-circle-icon"; export * from "./question-mark-circle-icon";
export * from "./setting-icon"; export * from "./setting-icon";
@ -70,6 +72,7 @@ export * from "./png-file-icon";
export * from "./jpg-file-icon"; export * from "./jpg-file-icon";
export * from "./svg-file-icon"; export * from "./svg-file-icon";
export * from "./txt-file-icon"; export * from "./txt-file-icon";
export * from "./triangle-exclamation-icon";
export * from "./default-file-icon"; export * from "./default-file-icon";
export * from "./video-file-icon"; export * from "./video-file-icon";
export * from "./audio-file-icon"; export * from "./audio-file-icon";

View File

@ -0,0 +1,19 @@
import React from "react";
import type { Props } from "./types";
export const PersonRunningIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 18 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.05 12L3.2625 11.2125L10.9125 3.5625H8.25V5.0625H7.125V2.4375H11.3063C11.4813 2.4375 11.65 2.46875 11.8125 2.53125C11.975 2.59375 12.1188 2.6875 12.2438 2.8125L14.4938 5.04375C14.8563 5.40625 15.275 5.68125 15.75 5.86875C16.225 6.05625 16.725 6.1625 17.25 6.1875V7.3125C16.6 7.2875 15.975 7.16563 15.375 6.94688C14.775 6.72813 14.25 6.3875 13.8 5.925L12.9375 5.0625L10.8 7.2L12.4125 8.8125L7.8375 11.4563L7.275 10.4813L10.575 8.56875L9.0375 7.03125L4.05 12ZM2.25 6.75V5.625H6V6.75H2.25ZM0.75 4.3125V3.1875H4.5V4.3125H0.75ZM14.8125 2.8125C14.45 2.8125 14.1406 2.68438 13.8844 2.42813C13.6281 2.17188 13.5 1.8625 13.5 1.5C13.5 1.1375 13.6281 0.828125 13.8844 0.571875C14.1406 0.315625 14.45 0.1875 14.8125 0.1875C15.175 0.1875 15.4844 0.315625 15.7406 0.571875C15.9969 0.828125 16.125 1.1375 16.125 1.5C16.125 1.8625 15.9969 2.17188 15.7406 2.42813C15.4844 2.68438 15.175 2.8125 14.8125 2.8125ZM2.25 1.875V0.75H6V1.875H2.25Z"
fill="#09A953"
/>
</svg>
);

View File

@ -0,0 +1,20 @@
import React from "react";
import type { Props } from "./types";
export const TriangleExclamationIcon: React.FC<Props> = ({
width = "24",
height = "24",
className,
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0.7254 13.75C0.6129 13.75 0.515075 13.7242 0.431925 13.6727C0.348775 13.6211 0.2841 13.5531 0.2379 13.4688C0.185813 13.3863 0.157169 13.2969 0.151969 13.2006C0.146757 13.1044 0.1754 13.0063 0.2379 12.9063L7.5129 0.34375C7.5754 0.24375 7.64728 0.171875 7.72853 0.128125C7.80978 0.084375 7.9004 0.0625 8.0004 0.0625C8.1004 0.0625 8.19103 0.084375 8.27228 0.128125C8.35353 0.171875 8.4254 0.24375 8.4879 0.34375L15.7629 12.9063C15.8254 13.0063 15.854 13.1044 15.8488 13.2006C15.8436 13.2969 15.815 13.3863 15.7629 13.4688C15.7167 13.5531 15.652 13.6211 15.5689 13.6727C15.4857 13.7242 15.3879 13.75 15.2754 13.75H0.7254ZM1.7004 12.625H14.3004L8.0004 1.75L1.7004 12.625ZM8.07868 11.5563C8.23899 11.5563 8.37228 11.502 8.47853 11.3936C8.58478 11.2851 8.6379 11.1508 8.6379 10.9905C8.6379 10.8302 8.58368 10.6969 8.47524 10.5906C8.36679 10.4844 8.23242 10.4313 8.07212 10.4313C7.91181 10.4313 7.77853 10.4855 7.67228 10.5939C7.56603 10.7024 7.5129 10.8367 7.5129 10.997C7.5129 11.1573 7.56712 11.2906 7.67556 11.3969C7.78401 11.5031 7.91838 11.5563 8.07868 11.5563ZM8.07868 9.475C8.23899 9.475 8.37228 9.42109 8.47853 9.31328C8.58478 9.20547 8.6379 9.07188 8.6379 8.9125V5.8375C8.6379 5.67813 8.58368 5.54453 8.47524 5.43672C8.36679 5.32891 8.23242 5.275 8.07212 5.275C7.91181 5.275 7.77853 5.32891 7.67228 5.43672C7.56603 5.54453 7.5129 5.67813 7.5129 5.8375V8.9125C7.5129 9.07188 7.56712 9.20547 7.67556 9.31328C7.78401 9.42109 7.91838 9.475 8.07868 9.475Z" />
</svg>
);

View File

@ -3,9 +3,10 @@ import { Tooltip } from "./tooltip";
type Props = { type Props = {
data: any; data: any;
noTooltip?: boolean
}; };
export const LinearProgressIndicator: React.FC<Props> = ({ data }) => { export const LinearProgressIndicator: React.FC<Props> = ({ data, noTooltip=false }) => {
const total = data.reduce((acc: any, cur: any) => acc + cur.value, 0); const total = data.reduce((acc: any, cur: any) => acc + cur.value, 0);
let progress = 0; let progress = 0;
@ -16,8 +17,8 @@ export const LinearProgressIndicator: React.FC<Props> = ({ data }) => {
backgroundColor: item.color, backgroundColor: item.color,
}; };
progress += item.value; progress += item.value;
if (noTooltip) return <div style={style} />
return ( else return (
<Tooltip key={item.id} tooltipContent={`${item.name} ${Math.round(item.value)}%`}> <Tooltip key={item.id} tooltipContent={`${item.name} ${Math.round(item.value)}%`}>
<div style={style} /> <div style={style} />
</Tooltip> </Tooltip>
@ -26,7 +27,11 @@ export const LinearProgressIndicator: React.FC<Props> = ({ data }) => {
return ( return (
<div className="flex h-1 w-full items-center justify-between gap-1"> <div className="flex h-1 w-full items-center justify-between gap-1">
{total === 0 ? " - 0%" : <div className="flex h-full w-full gap-1">{bars}</div>} {total === 0 ? (
<div className="flex h-full w-full gap-1 bg-neutral-500">{bars}</div>
) : (
<div className="flex h-full w-full gap-1">{bars}</div>
)}
</div> </div>
); );
}; };

View File

@ -4,7 +4,8 @@ import { Tooltip2 } from "@blueprintjs/popover2";
type Props = { type Props = {
tooltipHeading?: string; tooltipHeading?: string;
tooltipContent: string; tooltipContent: string | JSX.Element;
triangle?: boolean;
position?: position?:
| "top" | "top"
| "right" | "right"
@ -35,17 +36,23 @@ export const Tooltip: React.FC<Props> = ({
disabled = false, disabled = false,
className = "", className = "",
theme = "light", theme = "light",
triangle,
}) => ( }) => (
<Tooltip2 <Tooltip2
disabled={disabled} disabled={disabled}
content={ content={
<div <div
className={`${className} flex max-w-[600px] flex-col items-start justify-center gap-1 rounded-md p-2 text-left text-xs shadow-md ${ className={`${className} relative flex max-w-[600px] flex-col items-start justify-center gap-1 rounded-md p-2 text-left text-xs shadow-md ${
theme === "light" ? "bg-brand-surface-2 text-brand-muted-1" : "bg-black text-white" theme === "light" ? "text-brand-muted-1 bg-brand-surface-2" : "bg-black text-white"
}`} }`}
> >
<div
className={`absolute inset-0 left-1/2 -top-1 h-3 w-3 rotate-45 bg-brand-surface-2 ${
theme === "light" ? "text-brand-muted-1 bg-brand-surface-2" : "bg-black text-white"
}`}
/>
{tooltipHeading && <h5 className="font-medium">{tooltipHeading}</h5>} {tooltipHeading && <h5 className="font-medium">{tooltipHeading}</h5>}
<p>{tooltipContent}</p> {tooltipContent}
</div> </div>
} }
position={position} position={position}

View File

@ -1,23 +1,18 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic";
import useSWR from "swr"; import useSWR from "swr";
// headless ui
import { Tab } from "@headlessui/react";
// hooks // hooks
import useLocalStorage from "hooks/use-local-storage";
// services // services
import cycleService from "services/cycles.service"; import cycleService from "services/cycles.service";
import projectService from "services/project.service"; import projectService from "services/project.service";
// layouts // layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout"; import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// components // components
import { CompletedCyclesListProps, CreateUpdateCycleModal, CyclesList } from "components/cycles"; import { CreateUpdateCycleModal, CyclesView } from "components/cycles";
// ui // ui
import { Loader, PrimaryButton } from "components/ui"; import { PrimaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
import { PlusIcon } from "@heroicons/react/24/outline"; import { PlusIcon } from "@heroicons/react/24/outline";
@ -29,25 +24,13 @@ import {
CYCLE_CURRENT_AND_UPCOMING_LIST, CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DRAFT_LIST, CYCLE_DRAFT_LIST,
PROJECT_DETAILS, PROJECT_DETAILS,
CYCLE_DETAILS,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
const CompletedCyclesList = dynamic<CompletedCyclesListProps>(
() => import("components/cycles").then((a) => a.CompletedCyclesList),
{
ssr: false,
loading: () => (
<Loader className="mb-5">
<Loader.Item height="12rem" width="100%" />
</Loader>
),
}
);
const ProjectCycles: NextPage = () => { const ProjectCycles: NextPage = () => {
const [selectedCycle, setSelectedCycle] = useState<SelectCycleType>(); const [selectedCycle, setSelectedCycle] = useState<SelectCycleType>();
const [createUpdateCycleModal, setCreateUpdateCycleModal] = useState(false); const [createUpdateCycleModal, setCreateUpdateCycleModal] = useState(false);
const [cycleView, setCycleView] = useState<string>("list");
const { storedValue: cycleTab, setValue: setCycleTab } = useLocalStorage("cycleTab", "Upcoming");
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -73,6 +56,13 @@ const ProjectCycles: NextPage = () => {
: null : 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(() => { useEffect(() => {
if (createUpdateCycleModal) return; if (createUpdateCycleModal) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
@ -83,13 +73,16 @@ const ProjectCycles: NextPage = () => {
const currentTabValue = (tab: string | null) => { const currentTabValue = (tab: string | null) => {
switch (tab) { switch (tab) {
case "Upcoming": case "All":
return 0; return 0;
case "Completed": case "Active":
return 1; return 1;
case "Drafts": case "Upcoming":
return 2; return 2;
case "Completed":
return 3;
case "Drafts":
return 4;
default: default:
return 0; return 0;
} }
@ -126,101 +119,16 @@ const ProjectCycles: NextPage = () => {
/> />
<div className="space-y-8 p-8"> <div className="space-y-8 p-8">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{currentAndUpcomingCycles && currentAndUpcomingCycles.current_cycle.length > 0 && ( <h3 className="text-2xl font-semibold text-brand-base">Cycles</h3>
<h3 className="text-3xl font-semibold text-brand-base">Current Cycle</h3> <CyclesView
)} cycleView={cycleView}
<div className="space-y-5"> setCycleView={setCycleView}
<CyclesList setSelectedCycle={setSelectedCycle}
cycles={currentAndUpcomingCycles?.current_cycle} setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setCreateUpdateCycleModal={setCreateUpdateCycleModal} cyclesCompleteList={cyclesCompleteList}
setSelectedCycle={setSelectedCycle} currentAndUpcomingCycles={currentAndUpcomingCycles}
type="current" draftCycles={draftCycles}
/> />
</div>
</div>
<div className="flex flex-col gap-5">
<h3 className="text-3xl font-semibold text-brand-base">Other Cycles</h3>
<div>
<Tab.Group
defaultIndex={currentTabValue(cycleTab)}
onChange={(i) => {
switch (i) {
case 0:
return setCycleTab("Upcoming");
case 1:
return setCycleTab("Completed");
case 2:
return setCycleTab("Drafts");
default:
return setCycleTab("Upcoming");
}
}}
>
<Tab.List
as="div"
className="flex items-center justify-start gap-4 text-base font-medium"
>
<Tab
className={({ selected }) =>
`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
</Tab>
<Tab
className={({ selected }) =>
`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
</Tab>
<Tab
className={({ selected }) =>
`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
</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel as="div" className="mt-8 space-y-5">
<CyclesList
cycles={currentAndUpcomingCycles?.upcoming_cycle}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="upcoming"
/>
</Tab.Panel>
<Tab.Panel as="div" className="mt-8 space-y-5">
<CompletedCyclesList
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
/>
</Tab.Panel>
<Tab.Panel as="div" className="mt-8 space-y-5">
<CyclesList
cycles={draftCycles?.draft_cycles}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="draft"
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</div> </div>
</div> </div>
</ProjectAuthorizationWrapper> </ProjectAuthorizationWrapper>

View File

@ -6,6 +6,7 @@ import type {
IWorkspace, IWorkspace,
IWorkspaceLite, IWorkspaceLite,
IIssueFilterOptions, IIssueFilterOptions,
IUserLite,
} from "types"; } from "types";
export interface ICycle { export interface ICycle {
@ -29,6 +30,7 @@ export interface ICycle {
unstarted_issues: number; unstarted_issues: number;
updated_at: Date; updated_at: Date;
updated_by: string; updated_by: string;
assignees: IUserLite[];
view_props: { view_props: {
filters: IIssueFilterOptions; filters: IIssueFilterOptions;
}; };