feat: plane analytics (#1029)

* chore: global bar graph component

* chore: global pie graph component

* chore: global line graph component

* chore: removed unnecessary file

* chore: refactored global chart components to accept all props

* chore: function to convert response to chart data

* chore: global calendar graph component added

* chore: global scatter plot graph component

* feat: analytics boilerplate created

* chore: null value for segment and project

* chore: clean up file

* chore: change project query param key

* chore: export, refresh buttons, analytics table

* fix: analytics fetch key error

* chore: show only integer values in the y-axis

* chore: custom x-axis tick values and bar colors

* refactor: divide analytics page into granular components

* chore: convert analytics page to modal, save analytics modal

* fix: build error

* fix: modal overflow issues, analytics loading screen

* chore: custom tooltip, refactor: graphs folder structure

* refactor: folder structure, chore: x-axis tick values for larger data

* chore: code cleanup

* chore: remove unnecessary files

* fix: refresh analytics button on error

* feat: scope and demand analytics

* refactor: scope and demand and custom analytics folder structure

* fix: dynamic import type

* chore: minor updates

* feat: project, cycle and module level analytics

* style: project analytics modal

* fix: merge conflicts
This commit is contained in:
Aaryan Khandelwal 2023-05-11 17:38:46 +05:30 committed by GitHub
parent d7928f853d
commit 1a534a3c19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1730 additions and 287 deletions

View File

@ -0,0 +1,158 @@
import React from "react";
import { useRouter } from "next/router";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import analyticsService from "services/analytics.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
// types
import { IAnalyticsParams, ISaveAnalyticsFormData } from "types";
// types
type Props = {
isOpen: boolean;
handleClose: () => void;
params?: IAnalyticsParams;
};
type FormValues = {
name: string;
description: string;
};
const defaultValues: FormValues = {
name: "",
description: "",
};
export const CreateUpdateAnalyticsModal: React.FC<Props> = ({ isOpen, handleClose, params }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm<FormValues>({
defaultValues,
});
const onClose = () => {
handleClose();
reset(defaultValues);
};
const onSubmit = async (formData: FormValues) => {
if (!workspaceSlug) return;
const payload: ISaveAnalyticsFormData = {
name: formData.name,
description: formData.description,
query_dict: {
x_axis: "priority",
y_axis: "issue_count",
...params,
project: params?.project ? [params.project] : [],
},
};
await analyticsService
.saveAnalytics(workspaceSlug.toString(), payload)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Analytics saved successfully.",
});
onClose();
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Analytics could not be saved. Please try again.",
})
);
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg border border-brand-base bg-brand-base px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base">
Save Analytics
</Dialog.Title>
<div className="mt-5">
<Input
type="text"
id="name"
name="name"
placeholder="Title"
autoComplete="off"
error={errors.name}
register={register}
width="full"
validations={{
required: "Title is required",
}}
/>
<TextArea
id="description"
name="description"
placeholder="Description"
className="mt-3 h-32 resize-none text-sm"
error={errors.description}
register={register}
/>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Analytics"}
</PrimaryButton>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,148 @@
import { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// services
import analyticsService from "services/analytics.service";
// components
import {
AnalyticsGraph,
AnalyticsSidebar,
AnalyticsTable,
CreateUpdateAnalyticsModal,
} from "components/analytics";
// ui
import { Loader, PrimaryButton } from "components/ui";
// types
import { convertResponseToBarGraphData } from "constants/analytics";
// types
import { IAnalyticsParams } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
const defaultValues: IAnalyticsParams = {
x_axis: "priority",
y_axis: "issue_count",
segment: null,
project: null,
};
type Props = {
isProjectLevel?: boolean;
fullScreen?: boolean;
};
export const CustomAnalytics: React.FC<Props> = ({ isProjectLevel = false, fullScreen = true }) => {
const [saveAnalyticsModal, setSaveAnalyticsModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues });
const params: IAnalyticsParams = {
x_axis: watch("x_axis"),
y_axis: watch("y_axis"),
segment: watch("segment"),
project: isProjectLevel ? projectId?.toString() : watch("project"),
cycle: isProjectLevel && cycleId ? cycleId.toString() : null,
module: isProjectLevel && moduleId ? moduleId.toString() : null,
};
const {
data: analytics,
error: analyticsError,
mutate: mutateAnalytics,
} = useSWR(
workspaceSlug ? ANALYTICS(workspaceSlug.toString(), params) : null,
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
);
const yAxisKey = params.y_axis === "issue_count" ? "count" : "effort";
const barGraphData = convertResponseToBarGraphData(
analytics?.distribution,
watch("segment") ? true : false,
watch("y_axis")
);
return (
<>
<CreateUpdateAnalyticsModal
isOpen={saveAnalyticsModal}
handleClose={() => setSaveAnalyticsModal(false)}
params={params}
/>
<div
className={`overflow-y-auto ${
fullScreen ? "grid grid-cols-4 h-full" : "flex flex-col-reverse"
}`}
>
<div className="col-span-3">
{!analyticsError ? (
analytics ? (
analytics.total > 0 ? (
<>
<AnalyticsGraph
analytics={analytics}
barGraphData={barGraphData}
params={params}
yAxisKey={yAxisKey}
fullScreen={fullScreen}
/>
<AnalyticsTable
analytics={analytics}
barGraphData={barGraphData}
params={params}
yAxisKey={yAxisKey}
/>
</>
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-brand-secondary">
<p className="text-sm">
No matching issues found. Try changing the parameters.
</p>
</div>
</div>
)
) : (
<Loader className="space-y-6 p-5">
<Loader.Item height="300px" />
<Loader className="space-y-4">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
</Loader>
)
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-brand-secondary">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<PrimaryButton onClick={mutateAnalytics}>Refresh</PrimaryButton>
</div>
</div>
</div>
)}
</div>
<div className={fullScreen ? "h-full" : ""}>
<AnalyticsSidebar
analytics={analytics}
params={params}
control={control}
setValue={setValue}
setSaveAnalyticsModal={setSaveAnalyticsModal}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
/>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,34 @@
// nivo
import { BarTooltipProps } from "@nivo/bar";
// types
import { IAnalyticsParams } from "types";
type Props = {
datum: BarTooltipProps<any>;
params: IAnalyticsParams;
};
export const CustomTooltip: React.FC<Props> = ({ datum, params }) => (
<div className="flex items-center gap-2 rounded-md border border-brand-base bg-brand-base p-2 text-xs">
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: datum.color,
}}
/>
<span
className={`font-medium text-brand-secondary ${
params.segment
? params.segment === "priority" || params.segment === "state__group"
? "capitalize"
: ""
: params.x_axis === "priority" || params.x_axis === "state__group"
? "capitalize"
: ""
}`}
>
{params.segment ? datum.id : datum.id === "count" ? "Issue count" : "Effort"}:
</span>
<span>{datum.value}</span>
</div>
);

View File

@ -0,0 +1,102 @@
// nivo
import { BarDatum } from "@nivo/bar";
// components
import { CustomTooltip } from "./custom-tooltip";
// ui
import { BarGraph } from "components/ui";
// helpers
import { findStringWithMostCharacters } from "helpers/array.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
// constants
import { generateBarColor } from "constants/analytics";
type Props = {
analytics: IAnalyticsResponse;
barGraphData: {
data: BarDatum[];
xAxisKeys: string[];
};
params: IAnalyticsParams;
yAxisKey: "effort" | "count";
fullScreen: boolean;
};
export const AnalyticsGraph: React.FC<Props> = ({
analytics,
barGraphData,
params,
yAxisKey,
fullScreen,
}) => {
const generateYAxisTickValues = () => {
if (!analytics) return [];
let data: number[] = [];
if (params.segment)
// find the total no of issues in each segment
data = Object.keys(analytics.distribution).map((segment) => {
let totalSegmentIssues = 0;
analytics.distribution[segment].map((s) => {
totalSegmentIssues += s[yAxisKey] as number;
});
return totalSegmentIssues;
});
else data = barGraphData.data.map((d) => d[yAxisKey] as number);
const minValue = 0;
const maxValue = Math.max(...data);
const valueRange = maxValue - minValue;
let tickInterval = 1;
if (valueRange > 10) tickInterval = 2;
if (valueRange > 50) tickInterval = 5;
if (valueRange > 100) tickInterval = 10;
if (valueRange > 200) tickInterval = 50;
if (valueRange > 300) tickInterval = (Math.ceil(valueRange / 100) * 100) / 10;
const tickValues = [];
let tickValue = minValue;
while (tickValue <= maxValue) {
tickValues.push(tickValue);
tickValue += tickInterval;
}
if (!tickValues.includes(maxValue)) tickValues.push(maxValue);
return tickValues;
};
const longestXAxisLabel = findStringWithMostCharacters(barGraphData.data.map((d) => `${d.name}`));
return (
<BarGraph
data={barGraphData.data}
indexBy="name"
keys={barGraphData.xAxisKeys}
axisLeft={{
tickSize: 0,
tickPadding: 10,
tickValues: generateYAxisTickValues(),
}}
colors={(datum) =>
generateBarColor(
params.segment ? `${datum.id}` : `${datum.indexValue}`,
analytics,
params,
params.segment ? "segment" : "x_axis"
)
}
tooltip={(datum) => <CustomTooltip datum={datum} params={params} />}
height={fullScreen ? "400px" : "300px"}
margin={{ right: 20, bottom: longestXAxisLabel.length * 5 + 20 }}
theme={{
axis: {},
}}
/>
);
};

View File

@ -0,0 +1,5 @@
export * from "./graph";
export * from "./create-update-analytics-modal";
export * from "./custom-analytics";
export * from "./sidebar";
export * from "./table";

View File

@ -0,0 +1,232 @@
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { Control, Controller, UseFormSetValue } from "react-hook-form";
// services
import analyticsService from "services/analytics.service";
// hooks
import useProjects from "hooks/use-projects";
import useToast from "hooks/use-toast";
// ui
import { CustomMenu, CustomSelect, PrimaryButton } from "components/ui";
// icons
import { ArrowPathIcon, ArrowUpTrayIcon } from "@heroicons/react/24/outline";
// types
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
// constants
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "constants/analytics";
type Props = {
analytics: IAnalyticsResponse | undefined;
params: IAnalyticsParams;
control: Control<IAnalyticsParams, any>;
setValue: UseFormSetValue<IAnalyticsParams>;
setSaveAnalyticsModal: React.Dispatch<React.SetStateAction<boolean>>;
fullScreen: boolean;
isProjectLevel?: boolean;
};
export const AnalyticsSidebar: React.FC<Props> = ({
analytics,
params,
control,
setValue,
setSaveAnalyticsModal,
fullScreen,
isProjectLevel = false,
}) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { projects } = useProjects();
const { setToastAlert } = useToast();
const exportAnalytics = () => {
if (!workspaceSlug) return;
const data: IExportAnalyticsFormData = {
x_axis: params.x_axis,
y_axis: params.y_axis,
};
if (params.segment) data.segment = params.segment;
if (params.project) data.project = [params.project];
analyticsService
.exportAnalytics(workspaceSlug.toString(), data)
.then((res) =>
setToastAlert({
type: "success",
title: "Success!",
message: res.message,
})
)
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "There was some error in exporting the analytics. Please try again.",
})
);
};
return (
<div
className={`gap-4 p-5 ${
fullScreen ? "border-l border-brand-base bg-brand-sidebar h-full" : ""
}`}
>
<div className={`sticky top-5 ${fullScreen ? "space-y-4" : "space-y-2"}`}>
<div className="flex items-center justify-between gap-2 flex-shrink-0">
<h5 className="text-lg font-medium">
{analytics?.total ?? 0}{" "}
<span className="text-xs font-normal text-brand-secondary">issues</span>
</h5>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
<div className="flex items-center gap-2">
<ArrowPathIcon className="h-3 w-3" />
Refresh
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={exportAnalytics}>
<div className="flex items-center gap-2">
<ArrowUpTrayIcon className="h-3 w-3" />
Export analytics as CSV
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<div className={`${fullScreen ? "space-y-4" : "grid items-center gap-4 grid-cols-3"}`}>
{isProjectLevel === false && (
<div>
<h6 className="text-xs text-brand-secondary">Project</h6>
<Controller
name="project"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={projects.find((p) => p.id === value)?.name ?? "All projects"}
onChange={onChange}
width="w-full"
maxHeight="lg"
>
<CustomSelect.Option value={null}>All projects</CustomSelect.Option>
{projects.map((project) => (
<CustomSelect.Option key={project.id} value={project.id}>
{project.name}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
)}
<div>
<h6 className="text-xs text-brand-secondary">Measure (y-axis)</h6>
<Controller
name="y_axis"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={
<span>
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}
</span>
}
onChange={onChange}
width="w-full"
>
{ANALYTICS_Y_AXIS_VALUES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div>
<h6 className="text-xs text-brand-secondary">Dimension (x-axis)</h6>
<Controller
name="x_axis"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={
<span>{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label}</span>
}
onChange={(val: string) => {
if (params.segment === val) setValue("segment", null);
onChange(val);
}}
width="w-full"
maxHeight="lg"
>
{ANALYTICS_X_AXIS_VALUES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div>
<h6 className="text-xs text-brand-secondary">Segment</h6>
<Controller
name="segment"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={
<span>
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label ?? (
<span className="text-brand-secondary">No value</span>
)}
</span>
}
onChange={onChange}
width="w-full"
maxHeight="lg"
>
<CustomSelect.Option value={null}>No value</CustomSelect.Option>
{ANALYTICS_X_AXIS_VALUES.map((item) => {
if (params.x_axis === item.value) return null;
return (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
);
})}
</CustomSelect>
)}
/>
</div>
</div>
{/* <div className="flex items-center justify-end gap-2">
<PrimaryButton className="py-1" onClick={() => setSaveAnalyticsModal(true)}>
Save analytics
</PrimaryButton>
</div> */}
</div>
</div>
);
};

View File

@ -0,0 +1,116 @@
// nivo
import { BarDatum } from "@nivo/bar";
// icons
import { getPriorityIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
// constants
import {
ANALYTICS_X_AXIS_VALUES,
ANALYTICS_Y_AXIS_VALUES,
generateBarColor,
} from "constants/analytics";
type Props = {
analytics: IAnalyticsResponse;
barGraphData: {
data: BarDatum[];
xAxisKeys: string[];
};
params: IAnalyticsParams;
yAxisKey: "effort" | "count";
};
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => (
<div className="flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<table className="min-w-full divide-y divide-brand-base whitespace-nowrap border-y border-brand-base">
<thead className="bg-brand-base">
<tr className="divide-x divide-brand-base text-sm text-brand-base">
<th scope="col" className="py-3 px-2.5 text-left font-medium">
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === params.x_axis)?.label}
</th>
{params.segment ? (
barGraphData.xAxisKeys.map((key) => (
<th
key={`segment-${key}`}
scope="col"
className={`px-2.5 py-3 text-left font-medium ${
params.segment === "priority" || params.segment === "state__group"
? "capitalize"
: ""
}`}
>
<div className="flex items-center gap-2">
{params.segment === "priority" ? (
getPriorityIcon(key)
) : (
<span
className="h-3 w-3 flex-shrink-0 rounded"
style={{
backgroundColor: generateBarColor(key, analytics, params, "segment"),
}}
/>
)}
{key}
</div>
</th>
))
) : (
<th scope="col" className="py-3 px-2.5 text-left font-medium sm:pr-0">
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === params.y_axis)?.label}
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-brand-base">
{barGraphData.data.map((item, index) => (
<tr
key={`table-row-${index}`}
className="divide-x divide-brand-base text-xs text-brand-secondary"
>
<td
className={`flex items-center gap-2 whitespace-nowrap py-2 px-2.5 font-medium ${
params.x_axis === "priority" ? "capitalize" : ""
}`}
>
{params.x_axis === "priority" ? (
getPriorityIcon(`${item.name}`)
) : (
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: generateBarColor(
`${item.name}`,
analytics,
params,
"x_axis"
),
}}
/>
)}
{addSpaceIfCamelCase(`${item.name}`)}
</td>
{params.segment ? (
barGraphData.xAxisKeys.map((key, index) => (
<td
key={`segment-value-${index}`}
className="whitespace-nowrap py-2 px-2.5 sm:pr-0"
>
{item[key] ?? 0}
</td>
))
) : (
<td className="whitespace-nowrap py-2 px-2.5 sm:pr-0">{item[yAxisKey]}</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);

View File

@ -0,0 +1,4 @@
export * from "./custom-analytics";
export * from "./scope-and-demand";
export * from "./project-modal";
export * from "./workspace-modal";

View File

@ -0,0 +1,93 @@
import React, { Fragment, useState } from "react";
// headless ui
import { Tab } from "@headlessui/react";
// components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
// icons
import {
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
type Props = {
isOpen: boolean;
onClose: () => void;
};
const tabsList = ["Scope and Demand", "Custom Analytics"];
export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
const [fullScreen, setFullScreen] = useState(false);
const handleClose = () => {
onClose();
};
return (
<div
className={`absolute top-0 z-30 h-full bg-brand-surface-1 ${
fullScreen ? "p-2 w-full" : "w-1/2"
} ${isOpen ? "right-0" : "-right-full"} duration-300 transition-all`}
>
<div
className={`flex h-full flex-col overflow-hidden border-brand-base bg-brand-surface-1 text-left ${
fullScreen ? "rounded-lg border" : "border-l"
}`}
>
<div
className={`flex items-center justify-between gap-2 border-b border-b-brand-base bg-brand-sidebar p-3 text-sm ${
fullScreen ? "" : "py-[1.3rem]"
}`}
>
<h3>Project Analytics</h3>
<div className="flex items-center gap-2">
<button
type="button"
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
onClick={() => setFullScreen((prevData) => !prevData)}
>
{fullScreen ? (
<ArrowsPointingInIcon className="h-4 w-4" />
) : (
<ArrowsPointingOutIcon className="h-3 w-3" />
)}
</button>
<button
type="button"
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
onClick={handleClose}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
<Tab.Group as={Fragment}>
<Tab.List className="space-x-2 border-b border-brand-base px-5 py-3">
{tabsList.map((tab) => (
<Tab
key={tab}
className={({ selected }) =>
`rounded-3xl border border-brand-base px-4 py-2 text-xs hover:bg-brand-base ${
selected ? "bg-brand-base" : ""
}`
}
>
{tab}
</Tab>
))}
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel as={Fragment}>
<ScopeAndDemand fullScreen={fullScreen} />
</Tab.Panel>
<Tab.Panel as={Fragment}>
<CustomAnalytics fullScreen={fullScreen} isProjectLevel />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</div>
);
};

View File

@ -0,0 +1,63 @@
// icons
import { PlayIcon } from "@heroicons/react/24/outline";
// types
import { IDefaultAnalyticsResponse } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
type Props = {
defaultAnalytics: IDefaultAnalyticsResponse;
};
export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
<div className="space-y-3 self-start rounded-[10px] border border-brand-base p-3">
<h5 className="text-xs text-red-500">DEMAND</h5>
<div>
<h4 className="text-brand-bas text-base font-medium">Total open tasks</h4>
<h3 className="mt-1 text-xl font-semibold">{defaultAnalytics.open_issues}</h3>
</div>
<div className="space-y-6">
{defaultAnalytics.open_issues_classified.map((group) => {
const percentage = ((group.state_count / defaultAnalytics.total_issues) * 100).toFixed(0);
return (
<div key={group.state_group} className="space-y-2">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-1">
<span
className="h-2 w-2 rounded-full"
style={{
backgroundColor: STATE_GROUP_COLORS[group.state_group],
}}
/>
<h6 className="capitalize">{group.state_group}</h6>
<span className="ml-1 rounded-3xl bg-brand-surface-2 px-2 py-0.5 text-[0.65rem] text-brand-secondary">
{group.state_count}
</span>
</div>
<p className="text-brand-secondary">{percentage}%</p>
</div>
<div className="bar relative h-1 w-full rounded bg-brand-base">
<div
className="absolute top-0 left-0 h-1 rounded duration-300"
style={{
width: `${percentage}%`,
backgroundColor: STATE_GROUP_COLORS[group.state_group],
}}
/>
</div>
</div>
);
})}
</div>
<div className="!mt-6 flex w-min items-center gap-2 whitespace-nowrap rounded-md border border-brand-base bg-brand-base p-2 text-xs">
<p className="flex items-center gap-1 text-brand-secondary">
<PlayIcon className="h-4 w-4 -rotate-90" aria-hidden="true" />
<span>Estimate Demand:</span>
</p>
<p className="font-medium">
{defaultAnalytics.open_estimate_sum}/{defaultAnalytics.total_estimate_sum}
</p>
</div>
</div>
);

View File

@ -0,0 +1,3 @@
export * from "./demand";
export * from "./scope-and-demand";
export * from "./scope";

View File

@ -0,0 +1,67 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import analyticsService from "services/analytics.service";
// components
import { AnalyticsDemand, AnalyticsScope } from "components/analytics";
// ui
import { Loader, PrimaryButton } from "components/ui";
// fetch-keys
import { DEFAULT_ANALYTICS } from "constants/fetch-keys";
type Props = {
fullScreen?: boolean;
};
export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const params = {
project: projectId ? projectId.toString() : null,
cycle: cycleId ? cycleId.toString() : null,
module: moduleId ? moduleId.toString() : null,
};
const {
data: defaultAnalytics,
error: defaultAnalyticsError,
mutate: mutateDefaultAnalytics,
} = useSWR(
workspaceSlug ? DEFAULT_ANALYTICS(workspaceSlug.toString(), params) : null,
workspaceSlug
? () => analyticsService.getDefaultAnalytics(workspaceSlug.toString(), params)
: null
);
return (
<>
{!defaultAnalyticsError ? (
defaultAnalytics ? (
<div className="h-full overflow-y-auto p-5 text-sm">
<div className={`grid grid-cols-1 gap-5 ${fullScreen ? "lg:grid-cols-2" : ""}`}>
<AnalyticsDemand defaultAnalytics={defaultAnalytics} />
<AnalyticsScope defaultAnalytics={defaultAnalytics} />
</div>
</div>
) : (
<Loader className="grid grid-cols-1 gap-5 p-5 lg:grid-cols-2">
<Loader.Item height="300px" />
<Loader.Item height="300px" />
</Loader>
)
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-brand-secondary">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<PrimaryButton onClick={mutateDefaultAnalytics}>Refresh</PrimaryButton>
</div>
</div>
</div>
)}
</>
);
};

View File

@ -0,0 +1,98 @@
// ui
import { BarGraph, LineGraph } from "components/ui";
// types
import { IDefaultAnalyticsResponse } from "types";
// constants
import { MONTHS_LIST } from "constants/calendar";
type Props = {
defaultAnalytics: IDefaultAnalyticsResponse;
};
export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => {
const currentMonth = new Date().getMonth();
const startMonth = Math.floor(currentMonth / 3) * 3 + 1;
const quarterMonthsList = [startMonth, startMonth + 1, startMonth + 2];
return (
<div className="rounded-[10px] border border-brand-base">
<h5 className="p-3 text-xs text-green-500">SCOPE</h5>
<div className="divide-y divide-brand-base">
<div>
<h6 className="px-3 text-base font-medium">Pending issues</h6>
<BarGraph
data={defaultAnalytics.pending_issue_user}
indexBy="assignees__email"
keys={["count"]}
height="250px"
colors={() => `#f97316`}
tooltip={(datum) => (
<div className="rounded-md border border-brand-base bg-brand-base p-2 text-xs">
<span className="font-medium text-brand-secondary">
Issue count- {datum.indexValue ?? "No assignee"}:{" "}
</span>
{datum.value}
</div>
)}
axisBottom={{
tickValues: [],
}}
margin={{ top: 20 }}
/>
</div>
<div className="grid grid-cols-1 divide-y divide-brand-base sm:grid-cols-2 sm:divide-x">
<div className="p-3">
<h6 className="text-base font-medium">Most issues created</h6>
<div className="mt-3 space-y-3">
{defaultAnalytics.most_issue_created_user.map((user) => (
<div
key={user.assignees__email}
className="flex items-start justify-between gap-4 text-xs"
>
<span className="break-all text-brand-secondary">{user.assignees__email}</span>
<span className="flex-shrink-0">{user.count}</span>
</div>
))}
</div>
</div>
<div className="p-3">
<h6 className="text-base font-medium">Most issues closed</h6>
<div className="mt-3 space-y-3">
{defaultAnalytics.most_issue_closed_user.map((user) => (
<div
key={user.assignees__email}
className="flex items-start justify-between gap-4 text-xs"
>
<span className="break-all text-brand-secondary">{user.assignees__email}</span>
<span className="flex-shrink-0">{user.count}</span>
</div>
))}
</div>
</div>
</div>
<div className="py-3">
<h1 className="px-3 text-base font-medium">Issues closed in a year</h1>
<LineGraph
data={[
{
id: "issues_closed",
color: "rgb(var(--color-accent))",
data: quarterMonthsList.map((month) => ({
x: MONTHS_LIST.find((m) => m.value === month)?.label.substring(0, 3),
y:
defaultAnalytics.issue_completed_month_wise.find((data) => data.month === month)
?.count || 0,
})),
},
]}
height="300px"
colors={(datum) => datum.color}
curve="monotoneX"
margin={{ top: 20 }}
enableArea
/>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,70 @@
import React, { Fragment } from "react";
// headless ui
import { Tab } from "@headlessui/react";
// components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
type Props = {
isOpen: boolean;
onClose: () => void;
};
const tabsList = ["Scope and Demand", "Custom Analytics"];
export const AnalyticsWorkspaceModal: React.FC<Props> = ({ isOpen, onClose }) => {
const handleClose = () => {
onClose();
};
return (
<>
<div
className={`absolute z-20 h-full w-full bg-brand-surface-1 p-2 ${
isOpen ? "block" : "hidden"
}`}
>
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-brand-base bg-brand-surface-1 text-left">
<div className="flex items-center justify-between gap-2 border-b border-b-brand-base bg-brand-sidebar p-3 text-sm">
<h3>Workspace Analytics</h3>
<div>
<button
type="button"
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
onClick={handleClose}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
<Tab.Group as={Fragment}>
<Tab.List className="space-x-2 border-b border-brand-base px-5 py-3">
{tabsList.map((tab) => (
<Tab
key={tab}
className={({ selected }) =>
`rounded-3xl border border-brand-base px-4 py-2 text-xs hover:bg-brand-base ${
selected ? "bg-brand-base" : ""
}`
}
>
{tab}
</Tab>
))}
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel as={Fragment}>
<ScopeAndDemand />
</Tab.Panel>
<Tab.Panel as={Fragment}>
<CustomAnalytics />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</div>
</>
);
};

View File

@ -15,7 +15,7 @@ import {
updateDateWithYear,
} from "helpers/calendar.helper";
import React from "react";
import { monthOptions, yearOptions } from "constants/calendar";
import { MONTHS_LIST, YEARS_LIST } from "constants/calendar";
import { ICalendarRange } from "types";
import {
@ -77,7 +77,7 @@ export const CalendarHeader: React.FC<Props> = ({
>
<Popover.Panel className="absolute top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-brand-surface-2 shadow-lg">
<div className="flex items-center justify-center gap-5 px-2 py-2 text-sm">
{yearOptions.map((year) => (
{YEARS_LIST.map((year) => (
<button
onClick={() => updateDate(updateDateWithYear(year.label, currentDate))}
className={` ${
@ -91,11 +91,11 @@ export const CalendarHeader: React.FC<Props> = ({
))}
</div>
<div className="grid grid-cols-4 border-t border-brand-base px-2">
{monthOptions.map((month) => (
{MONTHS_LIST.map((month) => (
<button
onClick={() => updateDate(updateDateWithMonth(month.value, currentDate))}
onClick={() => updateDate(updateDateWithMonth(`${month.value}`, currentDate))}
className={`px-2 py-2 text-xs text-brand-secondary hover:font-medium hover:text-brand-base ${
isSameMonth(month.value, currentDate) ? "font-medium text-brand-base" : ""
isSameMonth(`${month.value}`, currentDate) ? "font-medium text-brand-base" : ""
}`}
>
{month.label}

View File

@ -1,4 +1,4 @@
export * from "./calendar-header";
export * from "./calendar";
export * from "./single-date";
export * from "./single-issue";
export * from "./calendar-header";

View File

@ -79,7 +79,7 @@ const activityDetails: {
},
estimate_point: {
message: "set the estimate point to",
icon: <PlayIcon className="h-3 w-3 -rotate-90 text-gray-500" aria-hidden="true" />,
icon: <PlayIcon className="h-3 w-3 -rotate-90 text-brand-secondary" aria-hidden="true" />,
},
target_date: {
message: "set the due date to",

View File

@ -37,7 +37,7 @@ export const TransferIssues: React.FC<Props> = ({ handleClick }) => {
? cycleDetails.backlog_issues + cycleDetails.unstarted_issues + cycleDetails.started_issues
: 0;
return (
<div className="-mt-2 mb-4 flex items-center justify-between">
<div className="-mt-2 mb-4 flex items-center justify-between px-8 pt-6">
<div className="flex items-center gap-2 text-sm text-brand-secondary">
<ExclamationIcon height={14} width={14} className="fill-current text-brand-secondary" />
<span>Completed cycles are not editable.</span>

View File

@ -40,7 +40,7 @@ import { IJiraImporterForm } from "types";
const integrationWorkflowData: Array<{
title: string;
key: TJiraIntegrationSteps;
icon: React.FC<React.SVGProps<SVGSVGElement> & React.RefAttributes<SVGSVGElement>>;
icon: any;
}> = [
{
title: "Configure",

View File

@ -55,7 +55,7 @@ const CustomMenu = ({
{ellipsis || verticalEllipsis ? (
<Menu.Button
type="button"
className="relative grid place-items-center rounded p-1 hover:bg-brand-surface-2 focus:outline-none"
className="relative grid place-items-center rounded p-1 text-brand-secondary hover:bg-brand-surface-2 hover:text-brand-base focus:outline-none"
>
<EllipsisHorizontalIcon
className={`h-4 w-4 ${verticalEllipsis ? "rotate-90" : ""}`}

View File

@ -13,7 +13,6 @@ type Props = {
export const BarGraph: React.FC<Props & TGraph & Omit<BarSvgProps<any>, "height" | "width">> = ({
indexBy,
keys,
padding = 0.3,
height = "400px",
width = "100%",
margin,
@ -24,11 +23,17 @@ export const BarGraph: React.FC<Props & TGraph & Omit<BarSvgProps<any>, "height"
<ResponsiveBar
indexBy={indexBy}
keys={keys}
margin={margin ?? DEFAULT_MARGIN}
padding={padding}
margin={{ ...DEFAULT_MARGIN, ...(margin ?? {}) }}
padding={rest.padding ?? rest.data.length > 7 ? 0.8 : 0.9}
axisBottom={{
tickSize: 0,
tickPadding: 10,
tickRotation: rest.data.length > 7 ? -45 : 0,
}}
labelTextColor={{ from: "color", modifiers: [["darker", 1.6]] }}
theme={theme ?? CHARTS_THEME}
theme={{ ...CHARTS_THEME, ...(theme ?? {}) }}
animate={true}
enableLabel={rest.enableLabel ?? false}
{...rest}
/>
</div>

View File

@ -14,7 +14,7 @@ export const CalendarGraph: React.FC<TGraph & Omit<CalendarSvgProps, "height" |
}) => (
<div style={{ height, width }}>
<ResponsiveCalendar
margin={margin ?? DEFAULT_MARGIN}
margin={{ ...DEFAULT_MARGIN, ...(margin ?? {}) }}
colors={
rest.colors ?? [
"rgba(var(--color-accent), 0.2)",
@ -27,7 +27,7 @@ export const CalendarGraph: React.FC<TGraph & Omit<CalendarSvgProps, "height" |
dayBorderColor={rest.dayBorderColor ?? "transparent"}
daySpacing={rest.daySpacing ?? 5}
monthBorderColor={rest.monthBorderColor ?? "rgb(var(--color-bg-base))"}
theme={theme ?? CHARTS_THEME}
theme={{ ...CHARTS_THEME, ...(theme ?? {}) }}
{...rest}
/>
</div>

View File

@ -14,8 +14,8 @@ export const LineGraph: React.FC<TGraph & LineSvgProps> = ({
}) => (
<div style={{ height, width }}>
<ResponsiveLine
margin={margin ?? DEFAULT_MARGIN}
theme={theme ?? CHARTS_THEME}
margin={{ ...DEFAULT_MARGIN, ...(margin ?? {}) }}
theme={{ ...CHARTS_THEME, ...(theme ?? {}) }}
animate={true}
{...rest}
/>

View File

@ -14,8 +14,8 @@ export const PieGraph: React.FC<TGraph & Omit<PieSvgProps<any>, "height" | "widt
}) => (
<div style={{ height, width }}>
<ResponsivePie
margin={margin ?? DEFAULT_MARGIN}
theme={theme ?? CHARTS_THEME}
margin={{ ...DEFAULT_MARGIN, ...(margin ?? {}) }}
theme={{ ...CHARTS_THEME, ...(theme ?? {}) }}
animate={true}
{...rest}
/>

View File

@ -10,9 +10,9 @@ export const ScatterPlotGraph: React.FC<
> = ({ height = "400px", width = "100%", margin, theme, ...rest }) => (
<div style={{ height, width }}>
<ResponsiveScatterPlot
margin={margin ?? DEFAULT_MARGIN}
margin={{ ...DEFAULT_MARGIN, ...(margin ?? {}) }}
animate={true}
theme={theme ?? CHARTS_THEME}
theme={{ ...CHARTS_THEME, ...(theme ?? {}) }}
{...rest}
/>
</div>

View File

@ -64,8 +64,8 @@ export const ProductUpdatesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
</span>
</Dialog.Title>
{updates && updates.length > 0 ? (
updates.map((item, index: number) => (
<>
updates.map((item, index) => (
<React.Fragment key={item.id}>
<div className="flex items-center gap-3 text-xs text-brand-secondary">
<span className="flex items-center rounded-full border border-brand-base bg-brand-surface-1 px-3 py-1.5 text-xs">
{item.tag_name}
@ -78,7 +78,7 @@ export const ProductUpdatesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
)}
</div>
<MarkdownRenderer markdown={item.body} />
</>
</React.Fragment>
))
) : (
<div className="flex h-full w-full items-center justify-center">

View File

@ -133,7 +133,7 @@ export const WorkspaceSidebarDropdown = () => {
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="fixed left-2 z-20 mt-1 flex w-full max-w-[17rem] origin-top-left flex-col rounded-md
className="fixed left-2 z-20 mt-1 flex w-full max-w-[17rem] origin-top-left flex-col rounded-md
border border-brand-base bg-brand-surface-2 shadow-lg focus:outline-none"
>
<div className="flex flex-col items-start justify-start gap-3 p-3">

View File

@ -1,70 +1,113 @@
import React from "react";
import { useRouter } from "next/router";
import Link from "next/link";
// icons
import { GridViewIcon, AssignmentClipboardIcon, TickMarkIcon, SettingIcon } from "components/icons";
// hooks
import useTheme from "hooks/use-theme";
// icons
import { GridViewIcon, AssignmentClipboardIcon, TickMarkIcon, SettingIcon } from "components/icons";
import { ChartBarIcon } from "@heroicons/react/24/outline";
const workspaceLinks = (workspaceSlug: string) => [
{
icon: GridViewIcon,
name: "Dashboard",
href: `/${workspaceSlug}`,
},
{
icon: AssignmentClipboardIcon,
name: "Projects",
href: `/${workspaceSlug}/projects`,
},
{
icon: TickMarkIcon,
name: "My Issues",
href: `/${workspaceSlug}/me/my-issues`,
},
{
icon: SettingIcon,
name: "Settings",
href: `/${workspaceSlug}/settings`,
},
];
type Props = {
isAnalyticsModalOpen: boolean;
setAnalyticsModal: React.Dispatch<React.SetStateAction<boolean>>;
};
export const WorkspaceSidebarMenu: React.FC = () => {
// router
export const WorkspaceSidebarMenu: React.FC<Props> = ({
isAnalyticsModalOpen,
setAnalyticsModal,
}) => {
const router = useRouter();
const { workspaceSlug } = router.query;
// theme context
const { collapsed: sidebarCollapse } = useTheme();
const workspaceLinks = (workspaceSlug: string) => [
{
icon: GridViewIcon,
name: "Dashboard",
href: `/${workspaceSlug}`,
},
{
icon: ChartBarIcon,
name: "Analytics",
highlight: isAnalyticsModalOpen,
onClick: () => setAnalyticsModal((prevData) => !prevData),
},
{
icon: AssignmentClipboardIcon,
name: "Projects",
href: `/${workspaceSlug}/projects`,
},
{
icon: TickMarkIcon,
name: "My Issues",
href: `/${workspaceSlug}/me/my-issues`,
},
{
icon: SettingIcon,
name: "Settings",
href: `/${workspaceSlug}/settings`,
},
];
return (
<div className="flex w-full flex-col items-start justify-start gap-2 px-3 py-1">
{workspaceLinks(workspaceSlug as string).map((link, index) => (
<Link key={index} href={link.href}>
<a
className={`${
(
link.name === "Settings"
? router.asPath.includes(link.href)
: router.asPath === link.href
)
? "bg-brand-surface-2 text-brand-base"
: "text-brand-secondary hover:bg-brand-surface-2 hover:text-brand-secondary focus:bg-brand-surface-2 focus:text-brand-secondary"
} group flex w-full items-center gap-3 rounded-md p-2 text-sm font-medium outline-none ${
sidebarCollapse ? "justify-center" : ""
}`}
>
<span className="grid h-5 w-5 flex-shrink-0 place-items-center">
<link.icon
className="text-brand-secondary"
aria-hidden="true"
height="20"
width="20"
/>
</span>
{!sidebarCollapse && link.name}
</a>
</Link>
))}
{workspaceLinks(workspaceSlug as string).map((link, index) => {
if (link.href)
return (
<Link key={index} href={link.href}>
<a
className={`${
(
link.name === "Settings"
? router.asPath.includes(link.href)
: router.asPath === link.href
)
? "bg-brand-surface-2 text-brand-base"
: "text-brand-secondary hover:bg-brand-surface-2 focus:bg-brand-surface-2"
} group flex w-full items-center gap-3 rounded-md p-2 text-sm font-medium outline-none ${
sidebarCollapse ? "justify-center" : ""
}`}
>
<span className="grid h-5 w-5 flex-shrink-0 place-items-center">
<link.icon
className="text-brand-secondary"
aria-hidden="true"
height="20"
width="20"
/>
</span>
{!sidebarCollapse && link.name}
</a>
</Link>
);
else
return (
<button
key={index}
type="button"
className={`group flex w-full items-center gap-3 rounded-md p-2 text-sm font-medium text-brand-secondary outline-none hover:bg-brand-surface-2 ${
sidebarCollapse ? "justify-center" : ""
} ${link.highlight ? "bg-brand-surface-2 text-brand-base" : ""}`}
onClick={() => {
if (link.onClick) link.onClick();
}}
>
<span className="grid h-5 w-5 flex-shrink-0 place-items-center">
<link.icon
className="text-brand-secondary"
aria-hidden="true"
height="20"
width="20"
/>
</span>
{!sidebarCollapse && link.name}
</button>
);
})}
</div>
);
};

View File

@ -0,0 +1,146 @@
// nivo
import { BarDatum, ComputedDatum } from "@nivo/bar";
// types
import {
IAnalyticsData,
IAnalyticsParams,
IAnalyticsResponse,
TXAxisValues,
TYAxisValues,
} from "types";
import { STATE_GROUP_COLORS } from "./state";
export const ANALYTICS_X_AXIS_VALUES: { value: TXAxisValues; label: string }[] = [
{
value: "state__name",
label: "State Name",
},
{
value: "state__group",
label: "State Group",
},
{
value: "priority",
label: "Priority",
},
{
value: "labels__name",
label: "Label",
},
{
value: "assignees__email",
label: "Assignee",
},
{
value: "estimate_point",
label: "Estimate",
},
{
value: "issue_cycle__cycle__name",
label: "Cycle",
},
{
value: "issue_module__module__name",
label: "Module",
},
{
value: "completed_at",
label: "Completed date",
},
{
value: "target_date",
label: "Due date",
},
{
value: "start_date",
label: "Start Date",
},
{
value: "created_at",
label: "Created date",
},
];
export const ANALYTICS_Y_AXIS_VALUES: { value: TYAxisValues; label: string }[] = [
{
value: "issue_count",
label: "Issue Count",
},
{
value: "effort",
label: "Effort",
},
];
export const convertResponseToBarGraphData = (
response: IAnalyticsData | undefined,
segmented: boolean,
yAxis: TYAxisValues
): { data: BarDatum[]; xAxisKeys: string[] } => {
if (!response || !(typeof response === "object") || Object.keys(response).length === 0)
return { data: [], xAxisKeys: [] };
const data: BarDatum[] = [];
let xAxisKeys: string[] = [];
const yAxisKey = yAxis === "issue_count" ? "count" : "effort";
Object.keys(response).forEach((key) => {
const segments: { [key: string]: number } = {};
if (segmented) {
response[key].map((item: any) => {
segments[item.segment ?? "None"] = item[yAxisKey] ?? 0;
// store the segment in the xAxisKeys array
if (!xAxisKeys.includes(item.segment ?? "None")) xAxisKeys.push(item.segment ?? "None");
});
data.push({
name: key,
...segments,
});
} else {
xAxisKeys = [yAxisKey];
const item = response[key][0];
data.push({
name: item.dimension ?? "None",
[yAxisKey]: item[yAxisKey] ?? 0,
});
}
});
return { data, xAxisKeys };
};
export const generateBarColor = (
value: string,
analytics: IAnalyticsResponse,
params: IAnalyticsParams,
type: "x_axis" | "segment"
): string => {
let color: string | undefined = "rgb(var(--color-accent))";
if (!analytics) return color;
if (params[type] === "state__name" || params[type] === "labels__name")
color = analytics?.extras?.colors.find((c) => c.name === value)?.color;
if (params[type] === "state__group") color = STATE_GROUP_COLORS[value];
if (params[type] === "priority")
color =
value === "urgent"
? "#ef4444"
: value === "high"
? "#f97316"
: value === "medium"
? "#eab308"
: value === "low"
? "#22c55e"
: "#ced4da";
return color ?? "rgb(var(--color-accent))";
};

View File

@ -1,19 +1,19 @@
export const monthOptions = [
{ value: "1", label: "January" },
{ value: "2", label: "February" },
{ value: "3", label: "March" },
{ value: "4", label: "April" },
{ value: "5", label: "May" },
{ value: "6", label: "June" },
{ value: "7", label: "July" },
{ value: "8", label: "August" },
{ value: "9", label: "September" },
{ value: "10", label: "October" },
{ value: "11", label: "November" },
{ value: "12", label: "December" },
export const MONTHS_LIST = [
{ value: 1, label: "January" },
{ value: 2, label: "February" },
{ value: 3, label: "March" },
{ value: 4, label: "April" },
{ value: 5, label: "May" },
{ value: 6, label: "June" },
{ value: 7, label: "July" },
{ value: 8, label: "August" },
{ value: 9, label: "September" },
{ value: 10, label: "October" },
{ value: 11, label: "November" },
{ value: 12, label: "December" },
];
export const yearOptions = [
export const YEARS_LIST = [
{ value: "2021", label: "2021" },
{ value: "2022", label: "2022" },
{ value: "2023", label: "2023" },

View File

@ -1,4 +1,4 @@
import { IJiraMetadata } from "types";
import { IAnalyticsParams, IJiraMetadata } from "types";
const paramsToKey = (params: any) => {
const { state, priority, assignees, created_by, labels } = params;
@ -173,3 +173,13 @@ export const PAGE_BLOCK_DETAILS = (pageId: string) => `PAGE_BLOCK_DETAILS_${page
export const ESTIMATES_LIST = (projectId: string) => `ESTIMATES_LIST_${projectId.toUpperCase()}`;
export const ESTIMATE_DETAILS = (estimateId: string) =>
`ESTIMATE_DETAILS_${estimateId.toUpperCase()}`;
// analytics
export const ANALYTICS = (workspaceSlug: string, params: IAnalyticsParams) =>
`ANALYTICS${workspaceSlug.toUpperCase()}_${params.x_axis}_${params.y_axis}_${params.segment}_${
params.project
}`;
export const DEFAULT_ANALYTICS = (workspaceSlug: string, params?: Partial<IAnalyticsParams>) =>
`DEFAULT_ANALYTICS_${workspaceSlug.toUpperCase()}_${params?.project}_${params?.cycle}_${
params?.module
}`;

View File

@ -1,12 +1,13 @@
// nivo
import { Theme } from "@nivo/core";
export const CHARTS_THEME: Theme = {
background: "rgb(var(--color-bg-base))",
textColor: "rgb(var(--color-text-base))",
background: "rgb(var(--color-bg-surface-1))",
textColor: "rgb(var(--color-text-secondary))",
axis: {
domain: {
line: {
stroke: "rgb(var(--color-text-base))",
stroke: "rgb(var(--color-border))",
strokeWidth: 0.5,
},
},
@ -16,6 +17,7 @@ export const CHARTS_THEME: Theme = {
background: "rgb(var(--color-bg-surface-2))",
color: "rgb(var(--color-text-secondary))",
fontSize: "0.8rem",
border: "1px solid rgb(var(--color-border))",
},
},
grid: {

View File

@ -35,3 +35,8 @@ export const orderArrayBy = (
};
export const checkDuplicates = (array: any[]) => new Set(array).size !== array.length;
export const findStringWithMostCharacters = (strings: string[]) =>
strings.reduce((longestString, currentString) =>
currentString.length > longestString.length ? currentString : longestString
);

View File

@ -11,9 +11,16 @@ import { ProjectSidebarList } from "components/project";
export interface SidebarProps {
toggleSidebar: boolean;
setToggleSidebar: React.Dispatch<React.SetStateAction<boolean>>;
isAnalyticsModalOpen: boolean;
setAnalyticsModal: React.Dispatch<React.SetStateAction<boolean>>;
}
const Sidebar: React.FC<SidebarProps> = ({ toggleSidebar, setToggleSidebar }) => {
const Sidebar: React.FC<SidebarProps> = ({
toggleSidebar,
setToggleSidebar,
isAnalyticsModalOpen,
setAnalyticsModal,
}) => {
// theme
const { collapsed: sidebarCollapse } = useTheme();
@ -27,7 +34,10 @@ const Sidebar: React.FC<SidebarProps> = ({ toggleSidebar, setToggleSidebar }) =>
>
<div className="flex h-full flex-1 flex-col">
<WorkspaceSidebarDropdown />
<WorkspaceSidebarMenu />
<WorkspaceSidebarMenu
isAnalyticsModalOpen={isAnalyticsModalOpen}
setAnalyticsModal={setAnalyticsModal}
/>
<ProjectSidebarList />
<WorkspaceHelpSection setSidebarActive={setToggleSidebar} />
</div>

View File

@ -1,166 +0,0 @@
import React, { FC, useState, useEffect } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import useSWR from "swr";
// services
import projectService from "services/project.service";
// ui
import { PrimaryButton, Spinner } from "components/ui";
// icon
import { LayerDiagonalIcon } from "components/icons";
// components
import { NotAuthorizedView, JoinProject } from "components/auth-screens";
import { CommandPalette } from "components/command-palette";
// local components
import Container from "layouts/container";
import AppSidebar from "layouts/app-layout/app-sidebar";
import AppHeader from "layouts/app-layout/app-header";
// types
import { UserAuth } from "types";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
import SettingsNavbar from "layouts/settings-navbar";
type Meta = {
title?: string | null;
description?: string | null;
image?: string | null;
url?: string | null;
};
type AppLayoutProps = {
meta?: Meta;
children: React.ReactNode;
noPadding?: boolean;
bg?: "primary" | "secondary";
noHeader?: boolean;
breadcrumbs?: JSX.Element;
left?: JSX.Element;
right?: JSX.Element;
settingsLayout?: boolean;
profilePage?: boolean;
memberType?: UserAuth;
};
const AppLayout: FC<AppLayoutProps> = ({
meta,
children,
noPadding = false,
bg = "primary",
noHeader = false,
breadcrumbs,
left,
right,
settingsLayout = false,
profilePage = false,
memberType,
}) => {
// states
const [toggleSidebar, setToggleSidebar] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: projectMembers, mutate: projectMembersMutate } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null,
{
shouldRetryOnError: false,
}
);
// flags
const isMember =
!projectId ||
memberType?.isOwner ||
memberType?.isMember ||
memberType?.isViewer ||
memberType?.isGuest;
return (
<Container meta={meta}>
<CommandPalette />
<div className="flex h-screen w-full overflow-x-hidden">
<AppSidebar toggleSidebar={toggleSidebar} setToggleSidebar={setToggleSidebar} />
{settingsLayout && (memberType?.isGuest || memberType?.isViewer) ? (
<NotAuthorizedView
actionButton={
(memberType?.isViewer || memberType?.isGuest) && projectId ? (
<Link href={`/${workspaceSlug}/projects/${projectId}/issues`}>
<PrimaryButton className="flex items-center gap-1">
<LayerDiagonalIcon height={16} width={16} color="white" /> Go to Issues
</PrimaryButton>
</Link>
) : (
(memberType?.isViewer || memberType?.isGuest) &&
workspaceSlug && (
<Link href={`/${workspaceSlug}`}>
<PrimaryButton className="flex items-center gap-1">
<LayerDiagonalIcon height={16} width={16} color="white" /> Go to workspace
</PrimaryButton>
</Link>
)
)
}
type="project"
/>
) : (
<main className="flex h-screen w-full min-w-0 flex-col overflow-y-auto">
{!noHeader && (
<AppHeader
breadcrumbs={breadcrumbs}
left={left}
right={right}
setToggleSidebar={setToggleSidebar}
/>
)}
{projectId && !projectMembers ? (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
) : isMember ? (
<div
className={`flex w-full flex-grow flex-col ${
noPadding ? "" : settingsLayout ? "p-8 lg:px-28" : "p-8"
} ${
bg === "primary"
? "bg-brand-base"
: bg === "secondary"
? "bg-brand-base"
: "bg-brand-base"
}`}
>
{settingsLayout && (
<div className="mb-12 space-y-6">
<div>
<h3 className="text-3xl font-semibold text-brand-base">
{profilePage ? "Profile" : projectId ? "Project" : "Workspace"} Settings
</h3>
<p className="mt-1 text-brand-secondary">
{profilePage
? "This information will be visible to only you."
: projectId
? "This information will be displayed to every member of the project."
: "This information will be displayed to every member of the workspace."}
</p>
</div>
<SettingsNavbar profilePage={profilePage} />
</div>
)}
{children}
</div>
) : (
<JoinProject />
)}
</main>
)}
</div>
</Container>
);
};
export default AppLayout;

View File

@ -13,6 +13,7 @@ import AppHeader from "layouts/app-layout/app-header";
import AppSidebar from "layouts/app-layout/app-sidebar";
// components
import { NotAuthorizedView, JoinProject } from "components/auth-screens";
import { AnalyticsWorkspaceModal } from "components/analytics";
import { CommandPalette } from "components/command-palette";
// ui
import { PrimaryButton, Spinner } from "components/ui";
@ -52,6 +53,7 @@ const ProjectAuthorizationWrapped: React.FC<Props> = ({
right,
}) => {
const [toggleSidebar, setToggleSidebar] = useState(false);
const [analyticsModal, setAnalyticsModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -66,7 +68,12 @@ const ProjectAuthorizationWrapped: React.FC<Props> = ({
<Container meta={meta}>
<CommandPalette />
<div className="relative flex h-screen w-full overflow-hidden">
<AppSidebar toggleSidebar={toggleSidebar} setToggleSidebar={setToggleSidebar} />
<AppSidebar
toggleSidebar={toggleSidebar}
setToggleSidebar={setToggleSidebar}
isAnalyticsModalOpen={analyticsModal}
setAnalyticsModal={setAnalyticsModal}
/>
{loading ? (
<div className="grid h-full w-full place-items-center p-4">
@ -114,6 +121,12 @@ const ProjectAuthorizationWrapped: React.FC<Props> = ({
: "bg-brand-base"
}`}
>
{analyticsModal && (
<AnalyticsWorkspaceModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
/>
)}
{!noHeader && (
<AppHeader
breadcrumbs={breadcrumbs}
@ -123,9 +136,7 @@ const ProjectAuthorizationWrapped: React.FC<Props> = ({
/>
)}
<div className="h-full w-full overflow-hidden">
<div className="relative h-full w-full overflow-x-hidden overflow-y-scroll">
{children}
</div>
<div className="h-full w-full overflow-x-hidden overflow-y-scroll">{children}</div>
</div>
</main>
)}

View File

@ -11,10 +11,10 @@ import workspaceServices from "services/workspace.service";
import Container from "layouts/container";
import AppSidebar from "layouts/app-layout/app-sidebar";
import AppHeader from "layouts/app-layout/app-header";
import SettingsNavbar from "layouts/settings-navbar";
import { UserAuthorizationLayout } from "./user-authorization-wrapper";
// components
import { NotAuthorizedView, NotAWorkspaceMember } from "components/auth-screens";
import { AnalyticsWorkspaceModal } from "components/analytics";
import { CommandPalette } from "components/command-palette";
// icons
import { PrimaryButton, Spinner } from "components/ui";
@ -49,19 +49,14 @@ export const WorkspaceAuthorizationLayout: React.FC<Props> = ({
right,
}) => {
const [toggleSidebar, setToggleSidebar] = useState(false);
const [analyticsModal, setAnalyticsModal] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: workspaceMemberMe, error } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS_ME(workspaceSlug as string) : null,
workspaceSlug ? () => workspaceServices.workspaceMemberMe(workspaceSlug.toString()) : null,
{
onErrorRetry(err, key, config, revalidate, revalidateOpts) {
if (err.status === 401 || err.status === 403) return;
revalidateOpts.retryCount = 5;
},
}
workspaceSlug ? () => workspaceServices.workspaceMemberMe(workspaceSlug.toString()) : null
);
if (!workspaceMemberMe && !error)
@ -98,7 +93,12 @@ export const WorkspaceAuthorizationLayout: React.FC<Props> = ({
<Container meta={meta}>
<CommandPalette />
<div className="relative flex h-screen w-full overflow-hidden">
<AppSidebar toggleSidebar={toggleSidebar} setToggleSidebar={setToggleSidebar} />
<AppSidebar
toggleSidebar={toggleSidebar}
setToggleSidebar={setToggleSidebar}
isAnalyticsModalOpen={analyticsModal}
setAnalyticsModal={setAnalyticsModal}
/>
{settingsLayout && (memberType?.isGuest || memberType?.isViewer) ? (
<NotAuthorizedView
actionButton={
@ -122,6 +122,12 @@ export const WorkspaceAuthorizationLayout: React.FC<Props> = ({
: "bg-brand-base"
}`}
>
{analyticsModal && (
<AnalyticsWorkspaceModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
/>
)}
{!noHeader && (
<AppHeader
breadcrumbs={breadcrumbs}

View File

@ -47,10 +47,7 @@ const WorkspacePage: NextPage = () => {
/>
<div className="p-8">
<div className="flex flex-col gap-8">
<div
className="text-brand-muted-1 flex flex-col justify-between gap-x-2 gap-y-6 rounded-lg bg-brand-base px-8 py-6 md:flex-row md:items-center md:py-3"
// style={{ background: "linear-gradient(90deg, #8e2de2 0%, #4a00e0 100%)" }}
>
<div className="text-brand-muted-1 flex flex-col justify-between gap-x-2 gap-y-6 rounded-lg border border-brand-base bg-brand-base px-8 py-6 md:flex-row md:items-center md:py-3">
<p className="font-semibold">
Plane is open source, support us by starring us on GitHub.
</p>

View File

@ -19,8 +19,10 @@ import cycleServices from "services/cycles.service";
import projectService from "services/project.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { AnalyticsProjectModal } from "components/analytics";
// ui
import { CustomMenu } from "components/ui";
import { CustomMenu, SecondaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// helpers
import { truncateText } from "helpers/string.helper";
@ -37,6 +39,7 @@ import {
const SingleCycle: React.FC = () => {
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
const [cycleSidebar, setCycleSidebar] = useState(true);
const [analyticsModal, setAnalyticsModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
@ -148,6 +151,13 @@ const SingleCycle: React.FC = () => {
className={`flex items-center gap-2 ${cycleSidebar ? "mr-[24rem]" : ""} duration-300`}
>
<IssuesFilterView />
<SecondaryButton
onClick={() => setAnalyticsModal(true)}
className="!py-1.5 font-normal rounded-md text-brand-secondary"
outline
>
Analytics
</SecondaryButton>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-1 ${
@ -160,7 +170,12 @@ const SingleCycle: React.FC = () => {
</div>
}
>
<div className={`h-full ${cycleSidebar ? "mr-[24rem]" : ""} duration-300`}>
<AnalyticsProjectModal isOpen={analyticsModal} onClose={() => setAnalyticsModal(false)} />
<div
className={`h-full ${cycleSidebar ? "mr-[24rem]" : ""} ${
analyticsModal ? "mr-[50%]" : ""
} duration-300`}
>
<IssuesView
type="cycle"
openIssuesListModal={openIssuesListModal}

View File

@ -1,3 +1,5 @@
import { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
@ -12,8 +14,9 @@ import { IssueViewContextProvider } from "contexts/issue-view.context";
import { truncateText } from "helpers/string.helper";
// components
import { IssuesFilterView, IssuesView } from "components/core";
import { AnalyticsProjectModal } from "components/analytics";
// ui
import { PrimaryButton } from "components/ui";
import { PrimaryButton, SecondaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
@ -23,6 +26,8 @@ import type { NextPage } from "next";
import { PROJECT_DETAILS } from "constants/fetch-keys";
const ProjectIssues: NextPage = () => {
const [analyticsModal, setAnalyticsModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -47,6 +52,13 @@ const ProjectIssues: NextPage = () => {
right={
<div className="flex items-center gap-2">
<IssuesFilterView />
<SecondaryButton
onClick={() => setAnalyticsModal(true)}
className="!py-1.5 rounded-md font-normal text-brand-secondary"
outline
>
Analytics
</SecondaryButton>
<PrimaryButton
className="flex items-center gap-2"
onClick={() => {
@ -60,6 +72,7 @@ const ProjectIssues: NextPage = () => {
</div>
}
>
<AnalyticsProjectModal isOpen={analyticsModal} onClose={() => setAnalyticsModal(false)} />
<IssuesView />
</ProjectAuthorizationWrapper>
</IssueViewContextProvider>

View File

@ -24,8 +24,9 @@ import { IssueViewContextProvider } from "contexts/issue-view.context";
// components
import { ExistingIssuesListModal, IssuesFilterView, IssuesView } from "components/core";
import { ModuleDetailsSidebar } from "components/modules";
import { AnalyticsProjectModal } from "components/analytics";
// ui
import { CustomMenu, EmptySpace, EmptySpaceItem, Spinner } from "components/ui";
import { CustomMenu, EmptySpace, EmptySpaceItem, SecondaryButton, Spinner } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// helpers
import { truncateText } from "helpers/string.helper";
@ -43,6 +44,7 @@ import {
const SingleModule: React.FC = () => {
const [moduleIssuesListModal, setModuleIssuesListModal] = useState(false);
const [moduleSidebar, setModuleSidebar] = useState(true);
const [analyticsModal, setAnalyticsModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
@ -152,6 +154,13 @@ const SingleModule: React.FC = () => {
className={`flex items-center gap-2 ${moduleSidebar ? "mr-[24rem]" : ""} duration-300`}
>
<IssuesFilterView />
<SecondaryButton
onClick={() => setAnalyticsModal(true)}
className="!py-1.5 font-normal rounded-md text-brand-secondary"
outline
>
Analytics
</SecondaryButton>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-1 ${
@ -164,6 +173,7 @@ const SingleModule: React.FC = () => {
</div>
}
>
<AnalyticsProjectModal isOpen={analyticsModal} onClose={() => setAnalyticsModal(false)} />
{moduleIssues ? (
moduleIssues.length > 0 ? (
<div className={`h-full ${moduleSidebar ? "mr-[24rem]" : ""} duration-300`}>

View File

@ -113,7 +113,7 @@ const ProjectModules: NextPage = () => {
/>
)
) : (
<Loader className="grid grid-cols-3 gap-4">
<Loader className="grid grid-cols-3 gap-4 p-8">
<Loader.Item height="100px" />
<Loader.Item height="100px" />
<Loader.Item height="100px" />

View File

@ -0,0 +1,59 @@
// services
import APIService from "services/api.service";
// types
import {
IAnalyticsParams,
IAnalyticsResponse,
IDefaultAnalyticsResponse,
IExportAnalyticsFormData,
ISaveAnalyticsFormData,
} from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
class AnalyticsServices extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async getAnalytics(workspaceSlug: string, params: IAnalyticsParams): Promise<IAnalyticsResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/analytics/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getDefaultAnalytics(
workspaceSlug: string,
params?: Partial<IAnalyticsParams>
): Promise<IDefaultAnalyticsResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/default-analytics/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async saveAnalytics(workspaceSlug: string, data: ISaveAnalyticsFormData): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/analytic-view/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async exportAnalytics(workspaceSlug: string, data: IExportAnalyticsFormData): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/export-analytics/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
export default new AnalyticsServices();

83
apps/app/types/analytics.d.ts vendored Normal file
View File

@ -0,0 +1,83 @@
export interface IAnalyticsResponse {
total: number;
distribution: IAnalyticsData;
extras: {
colors: IAnalyticsExtra[];
};
}
export interface IAnalyticsData {
[key: string]: {
dimension: string | null;
segment?: string;
count?: number;
effort?: number | null;
}[];
}
export interface IAnalyticsExtra {
name: string;
color: string;
}
export type TXAxisValues =
| "state__name"
| "state__group"
| "labels__name"
| "assignees__email"
| "estimate_point"
| "issue_cycle__cycle__name"
| "issue_module__module__name"
| "priority"
| "start_date"
| "target_date"
| "created_at"
| "completed_at";
export type TYAxisValues = "issue_count" | "effort";
export interface IAnalyticsParams {
x_axis: TXAxisValues;
y_axis: TYAxisValues;
segment?: TXAxisValues | null;
project?: string | null;
cycle?: string | null;
module?: string | null;
}
export interface ISaveAnalyticsFormData {
name: string;
description: string;
query_dict: IExportAnalyticsFormData;
}
export interface IExportAnalyticsFormData {
x_axis: TXAxisValues;
y_axis: TYAxisValues;
segment?: TXAxisValues | null;
project?: string[];
}
export interface IDefaultAnalyticsResponse {
issue_completed_month_wise: { month: number; count: number }[];
most_issue_closed_user: {
assignees__avatar: string | null;
assignees__email: string;
count: number;
}[];
most_issue_created_user: {
assignees__avatar: string | null;
assignees__email: string;
count: number;
}[];
open_estimate_sum: number;
open_issues: number;
open_issues_classified: { state_group: string; state_count: number }[];
pending_issue_user: {
assignees__avatar: string | null;
assignees__email: string;
count: number;
}[];
total_estimate_sum: number;
total_issues: number;
total_issues_classified: { state_group: string; state_count: number }[];
}

View File

@ -12,6 +12,7 @@ export * from "./pages";
export * from "./ai";
export * from "./estimate";
export * from "./importer";
export * from "./analytics";
export * from "./calendar";
export type NestedKeyOf<ObjectType extends object> = {