mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
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:
parent
d7928f853d
commit
1a534a3c19
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
102
apps/app/components/analytics/custom-analytics/graph/index.tsx
Normal file
102
apps/app/components/analytics/custom-analytics/graph/index.tsx
Normal 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: {},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
5
apps/app/components/analytics/custom-analytics/index.ts
Normal file
5
apps/app/components/analytics/custom-analytics/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./graph";
|
||||
export * from "./create-update-analytics-modal";
|
||||
export * from "./custom-analytics";
|
||||
export * from "./sidebar";
|
||||
export * from "./table";
|
232
apps/app/components/analytics/custom-analytics/sidebar.tsx
Normal file
232
apps/app/components/analytics/custom-analytics/sidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
116
apps/app/components/analytics/custom-analytics/table.tsx
Normal file
116
apps/app/components/analytics/custom-analytics/table.tsx
Normal 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>
|
||||
);
|
4
apps/app/components/analytics/index.ts
Normal file
4
apps/app/components/analytics/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./custom-analytics";
|
||||
export * from "./scope-and-demand";
|
||||
export * from "./project-modal";
|
||||
export * from "./workspace-modal";
|
93
apps/app/components/analytics/project-modal.tsx
Normal file
93
apps/app/components/analytics/project-modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
63
apps/app/components/analytics/scope-and-demand/demand.tsx
Normal file
63
apps/app/components/analytics/scope-and-demand/demand.tsx
Normal 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>
|
||||
);
|
3
apps/app/components/analytics/scope-and-demand/index.ts
Normal file
3
apps/app/components/analytics/scope-and-demand/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./demand";
|
||||
export * from "./scope-and-demand";
|
||||
export * from "./scope";
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
98
apps/app/components/analytics/scope-and-demand/scope.tsx
Normal file
98
apps/app/components/analytics/scope-and-demand/scope.tsx
Normal 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>
|
||||
);
|
||||
};
|
70
apps/app/components/analytics/workspace-modal.tsx
Normal file
70
apps/app/components/analytics/workspace-modal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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}
|
||||
|
@ -1,4 +1,4 @@
|
||||
export * from "./calendar-header";
|
||||
export * from "./calendar";
|
||||
export * from "./single-date";
|
||||
export * from "./single-issue";
|
||||
export * from "./calendar-header";
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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" : ""}`}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
146
apps/app/constants/analytics.ts
Normal file
146
apps/app/constants/analytics.ts
Normal 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))";
|
||||
};
|
@ -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" },
|
||||
|
@ -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
|
||||
}`;
|
||||
|
@ -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: {
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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;
|
@ -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>
|
||||
)}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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`}>
|
||||
|
@ -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" />
|
||||
|
59
apps/app/services/analytics.service.ts
Normal file
59
apps/app/services/analytics.service.ts
Normal 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
83
apps/app/types/analytics.d.ts
vendored
Normal 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 }[];
|
||||
}
|
1
apps/app/types/index.d.ts
vendored
1
apps/app/types/index.d.ts
vendored
@ -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> = {
|
||||
|
Loading…
Reference in New Issue
Block a user