feat: user profile analytics, views and filters (#1698)
* feat: user profile overview * chore: profile sidebar designed * feat: user issues filters and view options * refactor: filters * refactor: mutation logic * fix: percentage calculation logic and sidebar shadow
This commit is contained in:
parent
8930840a76
commit
10f145f85c
33 changed files with 2396 additions and 427 deletions
5
apps/app/components/profile/index.ts
Normal file
5
apps/app/components/profile/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./overview";
|
||||
export * from "./navbar";
|
||||
export * from "./profile-issues-view-options";
|
||||
export * from "./profile-issues-view";
|
||||
export * from "./sidebar";
|
||||
54
apps/app/components/profile/navbar.tsx
Normal file
54
apps/app/components/profile/navbar.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
// components
|
||||
import { ProfileIssuesViewOptions } from "components/profile";
|
||||
|
||||
const tabsList = [
|
||||
{
|
||||
route: "",
|
||||
label: "Overview",
|
||||
selected: "/[workspaceSlug]/profile/[userId]",
|
||||
},
|
||||
{
|
||||
route: "assigned",
|
||||
label: "Assigned",
|
||||
selected: "/[workspaceSlug]/profile/[userId]/assigned",
|
||||
},
|
||||
{
|
||||
route: "created",
|
||||
label: "Created",
|
||||
selected: "/[workspaceSlug]/profile/[userId]/created",
|
||||
},
|
||||
{
|
||||
route: "subscribed",
|
||||
label: "Subscribed",
|
||||
selected: "/[workspaceSlug]/profile/[userId]/subscribed",
|
||||
},
|
||||
];
|
||||
|
||||
export const ProfileNavbar = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, userId } = router.query;
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-5 flex items-center justify-between gap-4 border-b border-custom-border-300">
|
||||
<div className="flex items-center overflow-x-scroll">
|
||||
{tabsList.map((tab) => (
|
||||
<Link key={tab.route} href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}>
|
||||
<a
|
||||
className={`border-b-2 p-4 text-sm font-medium outline-none whitespace-nowrap ${
|
||||
router.pathname === tab.selected
|
||||
? "border-custom-primary-100 text-custom-primary-100"
|
||||
: "border-transparent"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<ProfileIssuesViewOptions />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
4
apps/app/components/profile/overview/index.ts
Normal file
4
apps/app/components/profile/overview/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./priority-distribution";
|
||||
export * from "./state-distribution";
|
||||
export * from "./stats";
|
||||
export * from "./workload";
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// ui
|
||||
import { BarGraph, Loader } from "components/ui";
|
||||
// helpers
|
||||
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||
// types
|
||||
import { IUserProfileData } from "types";
|
||||
|
||||
type Props = {
|
||||
userProfile: IUserProfileData | undefined;
|
||||
};
|
||||
|
||||
export const ProfilePriorityDistribution: React.FC<Props> = ({ userProfile }) => (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">Issues by Priority</h3>
|
||||
{userProfile ? (
|
||||
<div className="border border-custom-border-100 rounded">
|
||||
<BarGraph
|
||||
data={userProfile.priority_distribution.map((priority) => ({
|
||||
priority: capitalizeFirstLetter(priority.priority ?? "None"),
|
||||
value: priority.priority_count,
|
||||
}))}
|
||||
height="300px"
|
||||
indexBy="priority"
|
||||
keys={["value"]}
|
||||
borderRadius={4}
|
||||
padding={0.7}
|
||||
customYAxisTickValues={userProfile.priority_distribution.map((p) => p.priority_count)}
|
||||
tooltip={(datum) => (
|
||||
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
<span
|
||||
className="h-3 w-3 rounded"
|
||||
style={{
|
||||
backgroundColor: datum.color,
|
||||
}}
|
||||
/>
|
||||
<span className="font-medium text-custom-text-200">{datum.data.priority}:</span>
|
||||
<span>{datum.value}</span>
|
||||
</div>
|
||||
)}
|
||||
colors={(datum) => {
|
||||
if (datum.data.priority === "Urgent") return "#991b1b";
|
||||
else if (datum.data.priority === "High") return "#ef4444";
|
||||
else if (datum.data.priority === "Medium") return "#f59e0b";
|
||||
else if (datum.data.priority === "Low") return "#16a34a";
|
||||
else return "#e5e5e5";
|
||||
}}
|
||||
theme={{
|
||||
axis: {
|
||||
domain: {
|
||||
line: {
|
||||
stroke: "transparent",
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
line: {
|
||||
stroke: "transparent",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid place-items-center p-7">
|
||||
<Loader className="flex items-end gap-12">
|
||||
<Loader.Item width="30px" height="200px" />
|
||||
<Loader.Item width="30px" height="150px" />
|
||||
<Loader.Item width="30px" height="250px" />
|
||||
<Loader.Item width="30px" height="150px" />
|
||||
<Loader.Item width="30px" height="100px" />
|
||||
</Loader>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
81
apps/app/components/profile/overview/state-distribution.tsx
Normal file
81
apps/app/components/profile/overview/state-distribution.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// ui
|
||||
import { PieGraph } from "components/ui";
|
||||
// types
|
||||
import { IUserProfileData, IUserStateDistribution } from "types";
|
||||
// constants
|
||||
import { STATE_GROUP_COLORS } from "constants/state";
|
||||
|
||||
type Props = {
|
||||
stateDistribution: IUserStateDistribution[];
|
||||
userProfile: IUserProfileData | undefined;
|
||||
};
|
||||
|
||||
export const ProfileStateDistribution: React.FC<Props> = ({ stateDistribution, userProfile }) => {
|
||||
if (!userProfile) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">Issues by State</h3>
|
||||
<div className="border border-custom-border-100 rounded p-7">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||
<div>
|
||||
<PieGraph
|
||||
data={
|
||||
userProfile.state_distribution.map((group) => ({
|
||||
id: group.state_group,
|
||||
label: group.state_group,
|
||||
value: group.state_count,
|
||||
color: STATE_GROUP_COLORS[group.state_group],
|
||||
})) ?? []
|
||||
}
|
||||
height="250px"
|
||||
innerRadius={0.6}
|
||||
cornerRadius={5}
|
||||
padAngle={2}
|
||||
enableArcLabels
|
||||
arcLabelsTextColor="#000000"
|
||||
enableArcLinkLabels={false}
|
||||
activeInnerRadiusOffset={5}
|
||||
colors={(datum) => datum.data.color}
|
||||
tooltip={(datum) => (
|
||||
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 p-2 text-xs">
|
||||
<span className="text-custom-text-200 capitalize">
|
||||
{datum.datum.label} issues:
|
||||
</span>{" "}
|
||||
{datum.datum.value}
|
||||
</div>
|
||||
)}
|
||||
margin={{
|
||||
top: 32,
|
||||
right: 0,
|
||||
bottom: 32,
|
||||
left: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="space-y-4 w-full">
|
||||
{stateDistribution.map((group) => (
|
||||
<div
|
||||
key={group.state_group}
|
||||
className="flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: STATE_GROUP_COLORS[group.state_group],
|
||||
}}
|
||||
/>
|
||||
<div className="capitalize whitespace-nowrap">{group.state_group}</div>
|
||||
</div>
|
||||
<div>{group.state_count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
66
apps/app/components/profile/overview/stats.tsx
Normal file
66
apps/app/components/profile/overview/stats.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
// ui
|
||||
import { Icon, Loader } from "components/ui";
|
||||
// types
|
||||
import { IUserProfileData } from "types";
|
||||
|
||||
type Props = {
|
||||
userProfile: IUserProfileData | undefined;
|
||||
};
|
||||
|
||||
export const ProfileStats: React.FC<Props> = ({ userProfile }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, userId } = router.query;
|
||||
|
||||
const overviewCards = [
|
||||
{
|
||||
icon: "new_window",
|
||||
route: "created",
|
||||
title: "Issues created",
|
||||
value: userProfile?.created_issues ?? "...",
|
||||
},
|
||||
{
|
||||
icon: "account_circle",
|
||||
route: "assigned",
|
||||
title: "Issues assigned",
|
||||
value: userProfile?.assigned_issues ?? "...",
|
||||
},
|
||||
{
|
||||
icon: "subscriptions",
|
||||
route: "subscribed",
|
||||
title: "Issues subscribed",
|
||||
value: userProfile?.subscribed_issues ?? "...",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">Overview</h3>
|
||||
{userProfile ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{overviewCards.map((card) => (
|
||||
<Link key={card.route} href={`/${workspaceSlug}/profile/${userId}/${card.route}`}>
|
||||
<a className="flex items-center gap-3 p-4 rounded border border-custom-border-100 whitespace-nowrap">
|
||||
<div className="h-11 w-11 bg-custom-background-90 rounded grid place-items-center">
|
||||
<Icon iconName={card.icon} className="!text-xl" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-custom-text-400 text-sm">{card.title}</p>
|
||||
<p className="text-xl font-semibold">{card.value}</p>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Loader.Item height="80px" />
|
||||
<Loader.Item height="80px" />
|
||||
<Loader.Item height="80px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
apps/app/components/profile/overview/workload.tsx
Normal file
32
apps/app/components/profile/overview/workload.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// types
|
||||
import { IUserStateDistribution } from "types";
|
||||
// constants
|
||||
import { STATE_GROUP_COLORS } from "constants/state";
|
||||
|
||||
type Props = {
|
||||
stateDistribution: IUserStateDistribution[];
|
||||
};
|
||||
|
||||
export const ProfileWorkload: React.FC<Props> = ({ stateDistribution }) => (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">Workload</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-5 gap-4 justify-stretch">
|
||||
{stateDistribution.map((group) => (
|
||||
<div key={group.state_group}>
|
||||
<a className="flex gap-2 p-4 rounded border border-custom-border-100 whitespace-nowrap">
|
||||
<div
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: STATE_GROUP_COLORS[group.state_group],
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1 -mt-1">
|
||||
<p className="text-custom-text-400 text-sm capitalize">{group.state_group}</p>
|
||||
<p className="text-xl font-semibold">{group.state_count}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
298
apps/app/components/profile/profile-issues-view-options.tsx
Normal file
298
apps/app/components/profile/profile-issues-view-options.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// headless ui
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useProfileIssues from "hooks/use-profile-issues";
|
||||
import useEstimateOption from "hooks/use-estimate-option";
|
||||
// components
|
||||
import { MyIssuesSelectFilters } from "components/issues";
|
||||
// ui
|
||||
import { CustomMenu, ToggleSwitch, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { FormatListBulletedOutlined, GridViewOutlined } from "@mui/icons-material";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
|
||||
// types
|
||||
import { Properties, TIssueViewOptions } from "types";
|
||||
// constants
|
||||
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
|
||||
|
||||
const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
|
||||
{
|
||||
type: "list",
|
||||
Icon: FormatListBulletedOutlined,
|
||||
},
|
||||
{
|
||||
type: "kanban",
|
||||
Icon: GridViewOutlined,
|
||||
},
|
||||
];
|
||||
|
||||
export const ProfileIssuesViewOptions: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, userId } = router.query;
|
||||
|
||||
const {
|
||||
issueView,
|
||||
setIssueView,
|
||||
groupByProperty,
|
||||
setGroupByProperty,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
showEmptyGroups,
|
||||
setShowEmptyGroups,
|
||||
filters,
|
||||
properties,
|
||||
setProperties,
|
||||
setFilters,
|
||||
} = useProfileIssues(workspaceSlug?.toString(), userId?.toString());
|
||||
|
||||
const { isEstimateActive } = useEstimateOption();
|
||||
|
||||
if (
|
||||
!router.pathname.includes("assigned") &&
|
||||
!router.pathname.includes("created") &&
|
||||
!router.pathname.includes("subscribed")
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
{issueViewOptions.map((option) => (
|
||||
<Tooltip
|
||||
key={option.type}
|
||||
tooltipContent={
|
||||
<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} View</span>
|
||||
}
|
||||
position="bottom"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${
|
||||
issueView === option.type
|
||||
? "bg-custom-sidebar-background-80"
|
||||
: "text-custom-sidebar-text-200"
|
||||
}`}
|
||||
onClick={() => setIssueView(option.type)}
|
||||
>
|
||||
<option.Icon
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
}}
|
||||
className={option.type === "gantt_chart" ? "rotate-90" : ""}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<MyIssuesSelectFilters
|
||||
filters={filters}
|
||||
onSelect={(option) => {
|
||||
const key = option.key as keyof typeof filters;
|
||||
|
||||
if (key === "target_date") {
|
||||
const valueExists = checkIfArraysHaveSameElements(
|
||||
filters?.target_date ?? [],
|
||||
option.value
|
||||
);
|
||||
|
||||
setFilters({
|
||||
target_date: valueExists ? null : option.value,
|
||||
});
|
||||
} else {
|
||||
const valueExists = filters[key]?.includes(option.value);
|
||||
|
||||
if (valueExists)
|
||||
setFilters({
|
||||
[option.key]: ((filters[key] ?? []) as any[])?.filter(
|
||||
(val) => val !== option.value
|
||||
),
|
||||
});
|
||||
else
|
||||
setFilters({
|
||||
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
|
||||
});
|
||||
}
|
||||
}}
|
||||
direction="left"
|
||||
height="rg"
|
||||
/>
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center gap-2 rounded-md border border-custom-sidebar-border-200 bg-transparent px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
|
||||
open
|
||||
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
|
||||
: "text-custom-sidebar-text-200"
|
||||
}`}
|
||||
>
|
||||
View
|
||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-custom-border-200 bg-custom-background-90 p-3 shadow-lg">
|
||||
<div className="relative divide-y-2 divide-custom-border-200">
|
||||
<div className="space-y-4 pb-3 text-xs">
|
||||
{issueView !== "calendar" && issueView !== "spreadsheet" && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Group by</h4>
|
||||
<CustomMenu
|
||||
label={
|
||||
groupByProperty === "project"
|
||||
? "Project"
|
||||
: GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
|
||||
?.name ?? "Select"
|
||||
}
|
||||
>
|
||||
{GROUP_BY_OPTIONS.map((option) => {
|
||||
if (issueView === "kanban" && option.key === null) return null;
|
||||
if (option.key === "state" || option.key === "created_by")
|
||||
return null;
|
||||
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => setGroupByProperty(option.key)}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Order by</h4>
|
||||
<CustomMenu
|
||||
label={
|
||||
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
|
||||
"Select"
|
||||
}
|
||||
>
|
||||
{ORDER_BY_OPTIONS.map((option) => {
|
||||
if (groupByProperty === "priority" && option.key === "priority")
|
||||
return null;
|
||||
if (option.key === "sort_order") return null;
|
||||
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
setOrderBy(option.key);
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Issue type</h4>
|
||||
<CustomMenu
|
||||
label={
|
||||
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters?.type)
|
||||
?.name ?? "Select"
|
||||
}
|
||||
>
|
||||
{FILTER_ISSUE_OPTIONS.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
type: option.key,
|
||||
})
|
||||
}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
|
||||
{issueView !== "calendar" && issueView !== "spreadsheet" && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Show empty states</h4>
|
||||
<ToggleSwitch value={showEmptyGroups} onChange={setShowEmptyGroups} />
|
||||
</div>
|
||||
{/* <div className="relative flex justify-end gap-x-3">
|
||||
<button type="button" onClick={() => resetFilterToDefault()}>
|
||||
Reset to default
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-custom-primary"
|
||||
onClick={() => setNewFilterDefaultView()}
|
||||
>
|
||||
Set as default
|
||||
</button>
|
||||
</div> */}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 py-3">
|
||||
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{Object.keys(properties).map((key) => {
|
||||
if (key === "estimate" && !isEstimateActive) return null;
|
||||
|
||||
if (
|
||||
issueView === "spreadsheet" &&
|
||||
(key === "attachment_count" ||
|
||||
key === "link" ||
|
||||
key === "sub_issue_count")
|
||||
)
|
||||
return null;
|
||||
|
||||
if (
|
||||
issueView !== "spreadsheet" &&
|
||||
(key === "created_on" || key === "updated_on")
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||
properties[key as keyof Properties]
|
||||
? "border-custom-primary bg-custom-primary text-white"
|
||||
: "border-custom-border-200"
|
||||
}`}
|
||||
onClick={() => setProperties(key as keyof Properties)}
|
||||
>
|
||||
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
273
apps/app/components/profile/profile-issues-view.tsx
Normal file
273
apps/app/components/profile/profile-issues-view.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-beautiful-dnd
|
||||
import { DropResult } from "react-beautiful-dnd";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// hooks
|
||||
import useProfileIssues from "hooks/use-profile-issues";
|
||||
import useUser from "hooks/use-user";
|
||||
// components
|
||||
import { AllViews, FiltersList } from "components/core";
|
||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||
// helpers
|
||||
import { orderArrayBy } from "helpers/array.helper";
|
||||
// types
|
||||
import { IIssue, IIssueFilterOptions } from "types";
|
||||
// fetch-keys
|
||||
import { WORKSPACE_LABELS } from "constants/fetch-keys";
|
||||
|
||||
export const ProfileIssuesView = () => {
|
||||
// create issue modal
|
||||
const [createIssueModal, setCreateIssueModal] = useState(false);
|
||||
const [preloadedData, setPreloadedData] = useState<
|
||||
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
|
||||
>(undefined);
|
||||
|
||||
// update issue modal
|
||||
const [editIssueModal, setEditIssueModal] = useState(false);
|
||||
const [issueToEdit, setIssueToEdit] = useState<
|
||||
(IIssue & { actionType: "edit" | "delete" }) | undefined
|
||||
>(undefined);
|
||||
|
||||
// delete issue modal
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
const [issueToDelete, setIssueToDelete] = useState<IIssue | null>(null);
|
||||
|
||||
// trash box
|
||||
const [trashBox, setTrashBox] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, userId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const {
|
||||
groupedIssues,
|
||||
mutateProfileIssues,
|
||||
issueView,
|
||||
groupByProperty,
|
||||
orderBy,
|
||||
isEmpty,
|
||||
showEmptyGroups,
|
||||
filters,
|
||||
setFilters,
|
||||
properties,
|
||||
params,
|
||||
} = useProfileIssues(workspaceSlug?.toString(), userId?.toString());
|
||||
|
||||
const { data: labels } = useSWR(
|
||||
workspaceSlug && (filters?.labels ?? []).length > 0
|
||||
? WORKSPACE_LABELS(workspaceSlug.toString())
|
||||
: null,
|
||||
workspaceSlug && (filters?.labels ?? []).length > 0
|
||||
? () => issuesService.getWorkspaceLabels(workspaceSlug.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const handleDeleteIssue = useCallback(
|
||||
(issue: IIssue) => {
|
||||
setDeleteIssueModal(true);
|
||||
setIssueToDelete(issue);
|
||||
},
|
||||
[setDeleteIssueModal, setIssueToDelete]
|
||||
);
|
||||
|
||||
const handleOnDragEnd = useCallback(
|
||||
async (result: DropResult) => {
|
||||
setTrashBox(false);
|
||||
|
||||
if (!result.destination || !workspaceSlug || !groupedIssues || groupByProperty !== "priority")
|
||||
return;
|
||||
|
||||
const { source, destination } = result;
|
||||
|
||||
if (source.droppableId === destination.droppableId) return;
|
||||
|
||||
const draggedItem = groupedIssues[source.droppableId][source.index];
|
||||
|
||||
if (!draggedItem) return;
|
||||
|
||||
if (destination.droppableId === "trashBox") handleDeleteIssue(draggedItem);
|
||||
else {
|
||||
const sourceGroup = source.droppableId;
|
||||
const destinationGroup = destination.droppableId;
|
||||
|
||||
draggedItem[groupByProperty] = destinationGroup;
|
||||
|
||||
mutateProfileIssues((prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const sourceGroupArray = [...groupedIssues[sourceGroup]];
|
||||
const destinationGroupArray = [...groupedIssues[destinationGroup]];
|
||||
|
||||
sourceGroupArray.splice(source.index, 1);
|
||||
destinationGroupArray.splice(destination.index, 0, draggedItem);
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
[sourceGroup]: orderArrayBy(sourceGroupArray, orderBy),
|
||||
[destinationGroup]: orderArrayBy(destinationGroupArray, orderBy),
|
||||
};
|
||||
}, false);
|
||||
|
||||
// patch request
|
||||
issuesService
|
||||
.patchIssue(
|
||||
workspaceSlug as string,
|
||||
draggedItem.project,
|
||||
draggedItem.id,
|
||||
{
|
||||
priority: draggedItem.priority,
|
||||
},
|
||||
user
|
||||
)
|
||||
.catch(() => mutateProfileIssues());
|
||||
}
|
||||
},
|
||||
[
|
||||
groupByProperty,
|
||||
groupedIssues,
|
||||
handleDeleteIssue,
|
||||
mutateProfileIssues,
|
||||
orderBy,
|
||||
user,
|
||||
workspaceSlug,
|
||||
]
|
||||
);
|
||||
|
||||
const addIssueToGroup = useCallback((groupTitle: string) => {
|
||||
setCreateIssueModal(true);
|
||||
return;
|
||||
}, []);
|
||||
|
||||
const addIssueToDate = useCallback(
|
||||
(date: string) => {
|
||||
setCreateIssueModal(true);
|
||||
setPreloadedData({
|
||||
target_date: date,
|
||||
actionType: "createIssue",
|
||||
});
|
||||
},
|
||||
[setCreateIssueModal, setPreloadedData]
|
||||
);
|
||||
|
||||
const makeIssueCopy = useCallback(
|
||||
(issue: IIssue) => {
|
||||
setCreateIssueModal(true);
|
||||
|
||||
setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" });
|
||||
},
|
||||
[setCreateIssueModal, setPreloadedData]
|
||||
);
|
||||
|
||||
const handleEditIssue = useCallback(
|
||||
(issue: IIssue) => {
|
||||
setEditIssueModal(true);
|
||||
setIssueToEdit({
|
||||
...issue,
|
||||
actionType: "edit",
|
||||
cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null,
|
||||
module: issue.issue_module ? issue.issue_module.module : null,
|
||||
});
|
||||
},
|
||||
[setEditIssueModal, setIssueToEdit]
|
||||
);
|
||||
|
||||
const handleIssueAction = useCallback(
|
||||
(issue: IIssue, action: "copy" | "edit" | "delete") => {
|
||||
if (action === "copy") makeIssueCopy(issue);
|
||||
else if (action === "edit") handleEditIssue(issue);
|
||||
else if (action === "delete") handleDeleteIssue(issue);
|
||||
},
|
||||
[makeIssueCopy, handleEditIssue, handleDeleteIssue]
|
||||
);
|
||||
|
||||
const filtersToDisplay = { ...filters, assignees: null, created_by: null, subscriber: null };
|
||||
|
||||
const nullFilters = Object.keys(filtersToDisplay).filter(
|
||||
(key) => filtersToDisplay[key as keyof IIssueFilterOptions] === null
|
||||
);
|
||||
const areFiltersApplied =
|
||||
Object.keys(filtersToDisplay).length > 0 &&
|
||||
nullFilters.length !== Object.keys(filtersToDisplay).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
|
||||
handleClose={() => setCreateIssueModal(false)}
|
||||
prePopulateData={{
|
||||
...preloadedData,
|
||||
}}
|
||||
onSubmit={async () => {
|
||||
mutateProfileIssues();
|
||||
}}
|
||||
/>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
|
||||
handleClose={() => setEditIssueModal(false)}
|
||||
data={issueToEdit}
|
||||
onSubmit={async () => {
|
||||
mutateProfileIssues();
|
||||
}}
|
||||
/>
|
||||
<DeleteIssueModal
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
isOpen={deleteIssueModal}
|
||||
data={issueToDelete}
|
||||
user={user}
|
||||
/>
|
||||
{areFiltersApplied && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
|
||||
<FiltersList
|
||||
filters={filtersToDisplay}
|
||||
setFilters={setFilters}
|
||||
labels={labels}
|
||||
members={undefined}
|
||||
states={undefined}
|
||||
clearAllFilters={() =>
|
||||
setFilters({
|
||||
labels: null,
|
||||
priority: null,
|
||||
state_group: null,
|
||||
target_date: null,
|
||||
type: null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{<div className="mt-3 border-t border-custom-border-200" />}
|
||||
</>
|
||||
)}
|
||||
<AllViews
|
||||
addIssueToDate={addIssueToDate}
|
||||
addIssueToGroup={addIssueToGroup}
|
||||
disableUserActions={false}
|
||||
dragDisabled={groupByProperty !== "priority"}
|
||||
handleOnDragEnd={handleOnDragEnd}
|
||||
handleIssueAction={handleIssueAction}
|
||||
openIssuesListModal={null}
|
||||
removeIssue={null}
|
||||
trashBox={trashBox}
|
||||
setTrashBox={setTrashBox}
|
||||
viewProps={{
|
||||
groupByProperty,
|
||||
groupedIssues,
|
||||
isEmpty,
|
||||
issueView,
|
||||
mutateIssues: mutateProfileIssues,
|
||||
orderBy,
|
||||
params,
|
||||
properties,
|
||||
showEmptyGroups,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
256
apps/app/components/profile/sidebar.tsx
Normal file
256
apps/app/components/profile/sidebar.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// next-themes
|
||||
import { useTheme } from "next-themes";
|
||||
// headless ui
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import userService from "services/user.service";
|
||||
// ui
|
||||
import { Icon, Loader } from "components/ui";
|
||||
// helpers
|
||||
import { renderLongDetailDateFormat } from "helpers/date-time.helper";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// fetch-keys
|
||||
import { USER_PROFILE_PROJECT_SEGREGATION } from "constants/fetch-keys";
|
||||
|
||||
export const ProfileSidebar = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, userId } = router.query;
|
||||
|
||||
const { theme } = useTheme();
|
||||
|
||||
const { data: userProjectsData } = useSWR(
|
||||
workspaceSlug && userId
|
||||
? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString())
|
||||
: null,
|
||||
workspaceSlug && userId
|
||||
? () =>
|
||||
userService.getUserProfileProjectsSegregation(workspaceSlug.toString(), userId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const userDetails = [
|
||||
{
|
||||
label: "Username",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
label: "Joined on",
|
||||
value: renderLongDetailDateFormat(userProjectsData?.user_data.date_joined ?? ""),
|
||||
},
|
||||
{
|
||||
label: "Timezone",
|
||||
value: userProjectsData?.user_data.user_timezone,
|
||||
},
|
||||
{
|
||||
label: "Status",
|
||||
value: "Online",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 h-full w-80 overflow-y-auto"
|
||||
style={{
|
||||
boxShadow:
|
||||
theme === "light"
|
||||
? "0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12)"
|
||||
: "0px 0px 4px 0px rgba(0, 0, 0, 0.20), 0px 2px 6px 0px rgba(0, 0, 0, 0.50)",
|
||||
}}
|
||||
>
|
||||
{userProjectsData ? (
|
||||
<>
|
||||
<div className="relative h-32">
|
||||
<img
|
||||
src={
|
||||
userProjectsData.user_data.cover_image ??
|
||||
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80"
|
||||
}
|
||||
alt={userProjectsData.user_data.first_name}
|
||||
className="h-32 w-full object-cover"
|
||||
/>
|
||||
<div className="absolute -bottom-[26px] left-5 h-[52px] w-[52px] rounded">
|
||||
{userProjectsData.user_data.avatar && userProjectsData.user_data.avatar !== "" ? (
|
||||
<img
|
||||
src={userProjectsData.user_data.avatar}
|
||||
alt={userProjectsData.user_data.first_name}
|
||||
className="rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-custom-background-90 text-custom-text-100">
|
||||
{userProjectsData.user_data.first_name[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5">
|
||||
<div className="mt-[38px]">
|
||||
<h4 className="text-lg font-semibold">
|
||||
{userProjectsData.user_data.first_name} {userProjectsData.user_data.last_name}
|
||||
</h4>
|
||||
<h6 className="text-custom-text-200 text-sm">{userProjectsData.user_data.email}</h6>
|
||||
</div>
|
||||
<div className="mt-6 space-y-5">
|
||||
{userDetails.map((detail) => (
|
||||
<div key={detail.label} className="flex items-center gap-4 text-sm">
|
||||
<div className="text-custom-text-200 w-2/5">{detail.label}</div>
|
||||
<div className="font-medium">{detail.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-9 divide-y divide-custom-border-100">
|
||||
{userProjectsData.project_data.map((project, index) => {
|
||||
const totalIssues =
|
||||
project.created_issues +
|
||||
project.assigned_issues +
|
||||
project.pending_issues +
|
||||
project.completed_issues;
|
||||
const totalAssignedIssues = totalIssues - project.created_issues;
|
||||
|
||||
const completedIssuePercentage =
|
||||
totalAssignedIssues === 0
|
||||
? 0
|
||||
: Math.round((project.completed_issues / totalAssignedIssues) * 100);
|
||||
|
||||
return (
|
||||
<Disclosure
|
||||
key={project.id}
|
||||
as="div"
|
||||
className={`${index === 0 ? "pb-3" : "py-3"}`}
|
||||
>
|
||||
{({ open }) => (
|
||||
<div className="w-full">
|
||||
<Disclosure.Button className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="flex items-center gap-2 w-3/4">
|
||||
{project.emoji ? (
|
||||
<div className="flex-shrink-0 grid h-7 w-7 place-items-center">
|
||||
{renderEmoji(project.emoji)}
|
||||
</div>
|
||||
) : project.icon_prop ? (
|
||||
<div className="flex-shrink-0 h-7 w-7 grid place-items-center">
|
||||
{renderEmoji(project.icon_prop)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-shrink-0 grid place-items-center h-7 w-7 rounded bg-custom-background-90 uppercase text-custom-text-100 text-xs">
|
||||
{project?.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium truncate break-words">
|
||||
{project.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<div
|
||||
className={`px-1 py-0.5 text-xs font-medium rounded ${
|
||||
completedIssuePercentage <= 35
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: completedIssuePercentage <= 70
|
||||
? "bg-yellow-500/10 text-yellow-500"
|
||||
: "bg-green-500/10 text-green-500"
|
||||
}`}
|
||||
>
|
||||
{completedIssuePercentage}%
|
||||
</div>
|
||||
<Icon iconName="arrow_drop_down" className="!text-lg" />
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0"
|
||||
enterTo="transform opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform opacity-100"
|
||||
leaveTo="transform opacity-0"
|
||||
>
|
||||
<Disclosure.Panel className="pl-9 mt-5">
|
||||
{totalIssues > 0 && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div
|
||||
className="h-1 rounded"
|
||||
style={{
|
||||
backgroundColor: "#203b80",
|
||||
width: `${(project.created_issues / totalIssues) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-1 rounded"
|
||||
style={{
|
||||
backgroundColor: "#3f76ff",
|
||||
width: `${(project.assigned_issues / totalIssues) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-1 rounded"
|
||||
style={{
|
||||
backgroundColor: "#f59e0b",
|
||||
width: `${(project.pending_issues / totalIssues) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-1 rounded"
|
||||
style={{
|
||||
backgroundColor: "#16a34a",
|
||||
width: `${(project.completed_issues / totalIssues) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-7 space-y-5 text-sm text-custom-text-200">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2.5 w-2.5 bg-[#203b80] rounded-sm" />
|
||||
Created
|
||||
</div>
|
||||
<div className="font-medium">{project.created_issues} Issues</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2.5 w-2.5 bg-[#3f76ff] rounded-sm" />
|
||||
Assigned
|
||||
</div>
|
||||
<div className="font-medium">{project.assigned_issues} Issues</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2.5 w-2.5 bg-[#f59e0b] rounded-sm" />
|
||||
Due
|
||||
</div>
|
||||
<div className="font-medium">{project.pending_issues} Issues</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2.5 w-2.5 bg-[#16a34a] rounded-sm" />
|
||||
Completed
|
||||
</div>
|
||||
<div className="font-medium">{project.completed_issues} Issues</div>
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Loader className="px-5 space-y-7">
|
||||
<Loader.Item height="130px" />
|
||||
<div className="space-y-5">
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
</div>
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue