"use client"; import { FC, Fragment, useCallback, useMemo, useState } from "react"; import isEqual from "lodash/isEqual"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; import { AlertCircle, ChevronUp, ChevronDown } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; import { IIssueFilterOptions, TModulePlotType } from "@plane/types"; import { CustomSelect, Spinner } from "@plane/ui"; // components import ProgressChart from "@/components/core/sidebar/progress-chart"; import { ModuleProgressStats } from "@/components/modules"; // constants import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; // helpers import { getDate } from "@/helpers/date-time.helper"; // hooks import { useIssues, useModule, useProjectEstimates } from "@/hooks/store"; // plane web constants import { EEstimateSystem } from "@/plane-web/constants/estimates"; type TModuleAnalyticsProgress = { workspaceSlug: string; projectId: string; moduleId: string; }; const moduleBurnDownChartOptions = [ { value: "burndown", label: "Issues" }, { value: "points", label: "Points" }, ]; export const ModuleAnalyticsProgress: FC = observer((props) => { // props const { workspaceSlug, projectId, moduleId } = props; // router const searchParams = useSearchParams(); const peekModule = searchParams.get("peekModule") || undefined; // hooks const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates(); const { getPlotTypeByModuleId, setPlotType, getModuleById, fetchModuleDetails } = useModule(); const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(EIssuesStoreType.MODULE); // state const [loader, setLoader] = useState(false); // derived values const moduleDetails = getModuleById(moduleId); const plotType: TModulePlotType = getPlotTypeByModuleId(moduleId); const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false; const estimateDetails = isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId); const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS; const completedIssues = moduleDetails?.completed_issues || 0; const totalIssues = moduleDetails?.total_issues || 0; const completedEstimatePoints = moduleDetails?.completed_estimate_points || 0; const totalEstimatePoints = moduleDetails?.total_estimate_points || 0; const progressHeaderPercentage = moduleDetails ? plotType === "points" ? completedEstimatePoints != 0 && totalEstimatePoints != 0 ? Math.round((completedEstimatePoints / totalEstimatePoints) * 100) : 0 : completedIssues != 0 && completedIssues != 0 ? Math.round((completedIssues / totalIssues) * 100) : 0 : 0; const chartDistributionData = plotType === "points" ? moduleDetails?.estimate_distribution : moduleDetails?.distribution || undefined; const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; const groupedIssues = useMemo( () => ({ backlog: plotType === "points" ? moduleDetails?.backlog_estimate_points || 0 : moduleDetails?.backlog_issues || 0, unstarted: plotType === "points" ? moduleDetails?.unstarted_estimate_points || 0 : moduleDetails?.unstarted_issues || 0, started: plotType === "points" ? moduleDetails?.started_estimate_points || 0 : moduleDetails?.started_issues || 0, completed: plotType === "points" ? moduleDetails?.completed_estimate_points || 0 : moduleDetails?.completed_issues || 0, cancelled: plotType === "points" ? moduleDetails?.cancelled_estimate_points || 0 : moduleDetails?.cancelled_issues || 0, }), [plotType, moduleDetails] ); const moduleStartDate = getDate(moduleDetails?.start_date); const moduleEndDate = getDate(moduleDetails?.target_date); const isModuleStartDateValid = moduleStartDate && moduleStartDate <= new Date(); const isModuleEndDateValid = moduleStartDate && moduleEndDate && moduleEndDate >= moduleStartDate; const isModuleDateValid = isModuleStartDateValid && isModuleEndDateValid; // handlers const onChange = async (value: TModulePlotType) => { setPlotType(moduleId, value); if (!workspaceSlug || !projectId || !moduleId) return; try { setLoader(true); await fetchModuleDetails(workspaceSlug, projectId, moduleId); setLoader(false); } catch (error) { setLoader(false); setPlotType(moduleId, plotType); } }; const handleFiltersUpdate = useCallback( (key: keyof IIssueFilterOptions, value: string | string[]) => { if (!workspaceSlug || !projectId) return; let newValues = issueFilters?.filters?.[key] ?? []; if (Array.isArray(value)) { if (key === "state") { if (isEqual(newValues, value)) newValues = []; else newValues = value; } else { value.forEach((val) => { if (!newValues.includes(val)) newValues.push(val); else newValues.splice(newValues.indexOf(val), 1); }); } } else { if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); else newValues.push(value); } updateFilters( workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues }, moduleId ); }, [workspaceSlug, projectId, moduleId, issueFilters, updateFilters] ); if (!moduleDetails) return <>; return (
{({ open }) => (
{/* progress bar header */} {isModuleDateValid ? (
Progress
{progressHeaderPercentage > 0 && (
{`${progressHeaderPercentage}%`}
)}
{isCurrentEstimateTypeIsPoints && ( <>
{moduleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"} } onChange={onChange} maxHeight="lg" > {moduleBurnDownChartOptions.map((item) => ( {item.label} ))}
{loader && } )} {open ? (
) : (
Progress
{moduleDetails?.start_date && moduleDetails?.target_date ? "This module isn't active yet." : "Invalid date. Please enter valid date."}
)} {/* progress burndown chart */}
Ideal
Current
{moduleStartDate && moduleEndDate && completionChartDistributionData && ( {plotType === "points" ? ( ) : ( )} )}
{/* progress detailed view */} {chartDistributionData && (
)}
)}
); });