From ca03fc6a7f44ab0f550f2881d1c756184373e57c Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Mon, 14 Oct 2024 12:04:16 +0200 Subject: [PATCH] feat: group projects by accounts in weekly planning and work context --- .../planning/PlanWeekContextNotWork.tsx | 29 +++ components/planning/PlanWeekContextWork.tsx | 59 ++++++ components/planning/PlanWeekFilter.tsx | 27 +++ components/planning/PlanWeekForm.tsx | 47 +++++ components/planning/PlanWeekStatistics.tsx | 28 +++ .../planning/usePlanAccountProjects.tsx | 88 +++++++++ .../planning/usePlanningProjectFilter.tsx | 102 ++++++++++ components/planning/useWeekPlanContext.tsx | 79 ++++++++ docs/releases/next.md | 5 +- helpers/planning.ts | 63 +++++- pages/planweek.tsx | 186 ++---------------- 11 files changed, 542 insertions(+), 171 deletions(-) create mode 100644 components/planning/PlanWeekContextNotWork.tsx create mode 100644 components/planning/PlanWeekContextWork.tsx create mode 100644 components/planning/PlanWeekFilter.tsx create mode 100644 components/planning/PlanWeekForm.tsx create mode 100644 components/planning/PlanWeekStatistics.tsx create mode 100644 components/planning/usePlanAccountProjects.tsx create mode 100644 components/planning/usePlanningProjectFilter.tsx create mode 100644 components/planning/useWeekPlanContext.tsx diff --git a/components/planning/PlanWeekContextNotWork.tsx b/components/planning/PlanWeekContextNotWork.tsx new file mode 100644 index 000000000..437cb60c8 --- /dev/null +++ b/components/planning/PlanWeekContextNotWork.tsx @@ -0,0 +1,29 @@ +import MakeProjectDecision from "@/components/planning/MakeProjectDecision"; +import { usePlanningProjectFilter } from "@/components/planning/usePlanningProjectFilter"; +import { Accordion } from "@/components/ui/accordion"; +import { useWeekPlanContext } from "./useWeekPlanContext"; + +const PlanWeekContextNotWork = () => { + const { weekPlan, startDate } = useWeekPlanContext(); + const { projects, saveProjectDates } = usePlanningProjectFilter(); + + return ( + weekPlan && ( + + {projects.map((project) => ( + id === project.id)} + project={project} + saveOnHoldDate={(onHoldTill) => + saveProjectDates({ projectId: project.id, onHoldTill }) + } + /> + ))} + + ) + ); +}; + +export default PlanWeekContextNotWork; diff --git a/components/planning/PlanWeekContextWork.tsx b/components/planning/PlanWeekContextWork.tsx new file mode 100644 index 000000000..9f2c8f28c --- /dev/null +++ b/components/planning/PlanWeekContextWork.tsx @@ -0,0 +1,59 @@ +import { make2YearsRevenueText } from "@/helpers/projects"; +import { Loader2 } from "lucide-react"; +import ApiLoadingError from "../layouts/ApiLoadingError"; +import DefaultAccordionItem from "../ui-elements/accordion/DefaultAccordionItem"; +import { Accordion } from "../ui/accordion"; +import MakeProjectDecision from "./MakeProjectDecision"; +import { + usePlanAccountProjects, + withPlanAccountProjects, +} from "./usePlanAccountProjects"; +import { useWeekPlanContext } from "./useWeekPlanContext"; + +const PlanWeekContextWork = () => { + const { weekPlan, startDate } = useWeekPlanContext(); + const { accountsProjects, loadingAccounts, errorAccounts, saveProjectDates } = + usePlanAccountProjects(); + + return ( +
+ + + {loadingAccounts && ( + + )} + + + {accountsProjects?.map(({ id, name, pipeline, projects }) => ( + + + {projects.map((project) => ( + id === project.id + )} + project={project} + saveOnHoldDate={(onHoldTill) => + saveProjectDates({ projectId: project.id, onHoldTill }) + } + /> + ))} + + + ))} + +
+ ); +}; + +export default withPlanAccountProjects(PlanWeekContextWork); diff --git a/components/planning/PlanWeekFilter.tsx b/components/planning/PlanWeekFilter.tsx new file mode 100644 index 000000000..a8c242b7c --- /dev/null +++ b/components/planning/PlanWeekFilter.tsx @@ -0,0 +1,27 @@ +import ButtonGroup from "@/components/ui-elements/btn-group/btn-group"; +import { Label } from "@/components/ui/label"; +import { projectFilters, ProjectFilters } from "@/helpers/planning"; +import { usePlanningProjectFilter } from "./usePlanningProjectFilter"; + +const PlanWeekFilter = () => { + const { projectFilter, setProjectFilter } = usePlanningProjectFilter(); + + return ( +
+ + + projectFilters.includes(val as ProjectFilters) && + setProjectFilter(val as ProjectFilters) + } + /> +
+ ); +}; + +export default PlanWeekFilter; diff --git a/components/planning/PlanWeekForm.tsx b/components/planning/PlanWeekForm.tsx new file mode 100644 index 000000000..14ba5c4d1 --- /dev/null +++ b/components/planning/PlanWeekForm.tsx @@ -0,0 +1,47 @@ +import DateSelector from "@/components/ui-elements/selectors/date-selector"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { CalendarCheck, Loader2, Play } from "lucide-react"; +import { useWeekPlanContext } from "./useWeekPlanContext"; + +const PlanWeekForm = () => { + const { + weekPlan, + startDate, + setStartDate, + isLoading, + confirmProjectSelection, + createWeekPlan, + } = useWeekPlanContext(); + + return ( +
+ + + {isLoading ? ( + + ) : weekPlan ? ( + + ) : ( + + )} +
+ ); +}; + +export default PlanWeekForm; diff --git a/components/planning/PlanWeekStatistics.tsx b/components/planning/PlanWeekStatistics.tsx new file mode 100644 index 000000000..df2d7d172 --- /dev/null +++ b/components/planning/PlanWeekStatistics.tsx @@ -0,0 +1,28 @@ +import { usePlanningProjectFilter } from "./usePlanningProjectFilter"; +import { useWeekPlanContext } from "./useWeekPlanContext"; + +const PlanWeekStatistics = () => { + const { weekPlan } = useWeekPlanContext(); + const { openProjectsCount, onholdProjectsCount, focusProjectsCount } = + usePlanningProjectFilter(); + + return ( +
+ {!weekPlan ? ( + "Start Week Planning to review a list of projects for the current context." + ) : ( +
+
+ Review each project and decide if you can make progress here during + the next week. +
+
Projects to be reviewed: {openProjectsCount}
+
Projects on hold: {onholdProjectsCount}
+
Projects in focus: {focusProjectsCount}
+
+ )} +
+ ); +}; + +export default PlanWeekStatistics; diff --git a/components/planning/usePlanAccountProjects.tsx b/components/planning/usePlanAccountProjects.tsx new file mode 100644 index 000000000..68a4f2a8c --- /dev/null +++ b/components/planning/usePlanAccountProjects.tsx @@ -0,0 +1,88 @@ +import { useAccountsContext } from "@/api/ContextAccounts"; +import { + AccountProjects, + mapAccountOrder, + mapAccountProjects, +} from "@/helpers/planning"; +import { filter, flow, map, sortBy } from "lodash/fp"; +import { + ComponentType, + createContext, + FC, + useContext, + useEffect, + useState, +} from "react"; +import { usePlanningProjectFilter } from "./usePlanningProjectFilter"; + +interface PlanAccountProjectsType { + accountsProjects: AccountProjects[]; + loadingAccounts: boolean; + errorAccounts: any; + saveProjectDates: (props: { + projectId: string; + dueDate?: Date; + doneOn?: Date; + onHoldTill?: Date | null; + }) => Promise; +} + +const PlanAccountProjects = createContext(null); + +export const usePlanAccountProjects = () => { + const searchContext = useContext(PlanAccountProjects); + if (!searchContext) + throw new Error( + "usePlanAccountProjects must be used within PlanAccountProjectsProvider" + ); + return searchContext; +}; + +interface PlanAccountProjectsProviderProps { + children: React.ReactNode; +} + +export const PlanAccountProjectsProvider: FC< + PlanAccountProjectsProviderProps +> = ({ children }) => { + const { accounts, loadingAccounts, errorAccounts } = useAccountsContext(); + const { projects, saveProjectDates } = usePlanningProjectFilter(); + const [accountsProjects, setAccountsProjects] = useState( + [] + ); + + useEffect(() => { + flow( + map(mapAccountProjects(projects)), + filter(({ projects }) => projects.length > 0), + map(mapAccountOrder), + sortBy((a) => -a.order), + setAccountsProjects + )(accounts); + }, [accounts, projects]); + + return ( + + {children} + + ); +}; + +export function withPlanAccountProjects( + Component: ComponentType +) { + return function WrappedProvider(componentProps: Props) { + return ( + + + + ); + }; +} diff --git a/components/planning/usePlanningProjectFilter.tsx b/components/planning/usePlanningProjectFilter.tsx new file mode 100644 index 000000000..554ba80f5 --- /dev/null +++ b/components/planning/usePlanningProjectFilter.tsx @@ -0,0 +1,102 @@ +import { useAccountsContext } from "@/api/ContextAccounts"; +import { Project, useProjectsContext } from "@/api/ContextProjects"; +import { + filterAndSortProjectsForWeeklyPlanning, + ProjectFilters, + setProjectsFilterCount, +} from "@/helpers/planning"; +import { flow } from "lodash/fp"; +import { createContext, FC, useContext, useEffect, useState } from "react"; +import { useWeekPlanContext } from "./useWeekPlanContext"; + +interface PlanningProjectFilterType { + projects: Project[]; + projectFilter: ProjectFilters; + setProjectFilter: (filt: ProjectFilters) => void; + openProjectsCount: number; + focusProjectsCount: number; + onholdProjectsCount: number; + saveProjectDates: (props: { + projectId: string; + dueDate?: Date; + doneOn?: Date; + onHoldTill?: Date | null; + }) => Promise; +} + +const PlanningProjectFilter = createContext( + null +); + +export const usePlanningProjectFilter = () => { + const searchContext = useContext(PlanningProjectFilter); + if (!searchContext) + throw new Error( + "usePlanningProjectFilter must be used within PlanningProjectFilterProvider" + ); + return searchContext; +}; + +interface PlanningProjectFilterProviderProps { + children: React.ReactNode; +} + +export const PlanningProjectFilterProvider: FC< + PlanningProjectFilterProviderProps +> = ({ children }) => { + const { projects, saveProjectDates } = useProjectsContext(); + const { accounts } = useAccountsContext(); + const { weekPlan, startDate } = useWeekPlanContext(); + const [projectFilter, setProjectFilter] = useState("Open"); + const [filteredAndSortedProjects, setFilteredAndSortedProjects] = useState( + filterAndSortProjectsForWeeklyPlanning( + accounts, + startDate, + weekPlan, + projectFilter + )(projects) + ); + const [openProjectsCount, setOpenProjectsCount] = useState(0); + const [focusProjectsCount, setFocusProjectsCount] = useState(0); + const [onholdProjectsCount, setOnholdProjectsCount] = useState(0); + + useEffect(() => { + flow( + filterAndSortProjectsForWeeklyPlanning( + accounts, + startDate, + weekPlan, + projectFilter + ), + setFilteredAndSortedProjects + )(projects); + }, [accounts, projectFilter, projects, startDate, weekPlan]); + + useEffect(() => { + setProjectsFilterCount( + projects, + accounts, + startDate, + weekPlan, + setOpenProjectsCount, + setFocusProjectsCount, + setOnholdProjectsCount + ); + }, [accounts, projects, startDate, weekPlan]); + + return ( + + {children} + + ); +}; diff --git a/components/planning/useWeekPlanContext.tsx b/components/planning/useWeekPlanContext.tsx new file mode 100644 index 000000000..2c13a6452 --- /dev/null +++ b/components/planning/useWeekPlanContext.tsx @@ -0,0 +1,79 @@ +import useWeekPlan, { WeeklyPlan } from "@/api/useWeekPlan"; +import { addDays } from "date-fns"; +import { + ComponentType, + createContext, + FC, + useContext, + useEffect, + useState, +} from "react"; + +interface WeekPlanType { + weekPlan: WeeklyPlan | undefined; + createWeekPlan: (startDate: Date) => Promise; + confirmProjectSelection: () => Promise; + startDate: Date; + setStartDate: (date: Date) => void; + isLoading: boolean; + error: any; +} + +const WeekPlan = createContext(null); + +export const useWeekPlanContext = () => { + const searchContext = useContext(WeekPlan); + if (!searchContext) + throw new Error("useWeekPlan must be used within WeekPlanProvider"); + return searchContext; +}; + +interface WeekPlanProviderProps { + children: React.ReactNode; +} + +export const WeekPlanProvider: FC = ({ children }) => { + const { + weekPlan, + createWeekPlan, + isLoading, + error, + confirmProjectSelection, + } = useWeekPlan(); + const [startDate, setStartDate] = useState( + weekPlan?.startDate || addDays(new Date(), 1) + ); + + useEffect(() => { + if (!weekPlan) return; + setStartDate(weekPlan.startDate); + }, [weekPlan]); + + return ( + + {children} + + ); +}; + +export function withWeekPlan( + Component: ComponentType +) { + return function WrappedProvider(componentProps: Props) { + return ( + + + + ); + }; +} diff --git a/docs/releases/next.md b/docs/releases/next.md index b8f0a4bb3..0f19f0fae 100644 --- a/docs/releases/next.md +++ b/docs/releases/next.md @@ -1,7 +1,6 @@ -# Einfacher Suchen (Projekte/Personen) (Version :VERSION) +# Wochenplanung nach Accounts sortieren (WORK-Kontext) (Version :VERSION) -- Die Projektliste kann nun durchsucht werden. -- Meeting mit einer einzelnen Person können nun auch in der Meetingsliste erstellt werden. +- Bei der Wochenplanung werden die Projekte nun nach Accounts gruppiert dargestellt. Sie sind nach Größe der Pipeline absteigend sortiert. ## In Arbeit diff --git a/helpers/planning.ts b/helpers/planning.ts index 75d25520b..840c8caec 100644 --- a/helpers/planning.ts +++ b/helpers/planning.ts @@ -1,9 +1,22 @@ import { Account } from "@/api/ContextAccounts"; import { Project } from "@/api/ContextProjects"; import { WeeklyPlan } from "@/api/useWeekPlan"; -import { updateProjectOrder } from "@/helpers/projects"; +import { calcOrder } from "@/helpers/accounts"; +import { calcRevenueTwoYears, updateProjectOrder } from "@/helpers/projects"; import { differenceInCalendarDays } from "date-fns"; -import { filter, flow, map, sortBy } from "lodash/fp"; +import { + compact, + filter, + flatMap, + flow, + identity, + map, + size, + sortBy, + sum, +} from "lodash/fp"; + +export type AccountProjects = Account & { projects: Project[] }; export const projectFilters = ["Open", "In Focus", "On Hold"] as const; export type ProjectFilters = (typeof projectFilters)[number]; @@ -33,6 +46,27 @@ export const filterAndSortProjectsForWeeklyPlanning = ( sortBy((p) => -p.order) ); +export const setProjectsFilterCount = ( + projects: Project[] | undefined, + accounts: Account[] | undefined, + startDate: Date, + weekPlan: WeeklyPlan | undefined, + setOpenCount: (count: number) => void, + setFocusCount: (count: number) => void, + setOnholdCount: (count: number) => void +) => { + const simplifiedFilterFn = (projectFilter: ProjectFilters) => + filterAndSortProjectsForWeeklyPlanning( + accounts, + startDate, + weekPlan, + projectFilter + ); + flow(simplifiedFilterFn("Open"), size, setOpenCount)(projects); + flow(simplifiedFilterFn("On Hold"), size, setOnholdCount)(projects); + flow(simplifiedFilterFn("In Focus"), size, setFocusCount)(projects); +}; + export const filterAndSortProjectsForDailyPlanning = ( accounts: Account[] | undefined, planDate: Date @@ -46,3 +80,28 @@ export const filterAndSortProjectsForDailyPlanning = ( map(updateProjectOrder(accounts)), sortBy((p) => -p.order) ); + +export const mapAccountProjects = + (projects: Project[] | undefined) => (account: Account) => ({ + ...account, + projects: projects?.filter((p) => p.accountIds.includes(account.id)) ?? [], + }); + +const calcPipeline: (projects: Project[]) => number = flow( + identity, + flatMap("crmProjects"), + compact, + map(calcRevenueTwoYears), + sum, + (val: number | undefined) => val ?? 0, + Math.floor +); + +const reCalculateOrder = ({ latestQuota, projects }: AccountProjects) => + calcOrder(latestQuota, calcPipeline(projects)); + +export const mapAccountOrder = (account: AccountProjects): AccountProjects => ({ + ...account, + order: reCalculateOrder(account), + pipeline: calcPipeline(account.projects), +}); diff --git a/pages/planweek.tsx b/pages/planweek.tsx index ac0fac26d..e96b94c52 100644 --- a/pages/planweek.tsx +++ b/pages/planweek.tsx @@ -1,190 +1,44 @@ -import { useAccountsContext } from "@/api/ContextAccounts"; -import { useProjectsContext } from "@/api/ContextProjects"; -import useWeekPlan from "@/api/useWeekPlan"; import ApiLoadingError from "@/components/layouts/ApiLoadingError"; import MainLayout from "@/components/layouts/MainLayout"; import ContextSwitcher from "@/components/navigation-menu/ContextSwitcher"; -import MakeProjectDecision from "@/components/planning/MakeProjectDecision"; -import ButtonGroup from "@/components/ui-elements/btn-group/btn-group"; -import DateSelector from "@/components/ui-elements/selectors/date-selector"; -import { Accordion } from "@/components/ui/accordion"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; +import PlanWeekContextNotWork from "@/components/planning/PlanWeekContextNotWork"; +import PlanWeekContextWork from "@/components/planning/PlanWeekContextWork"; +import PlanWeekFilter from "@/components/planning/PlanWeekFilter"; +import PlanWeekForm from "@/components/planning/PlanWeekForm"; +import PlanWeekStatistics from "@/components/planning/PlanWeekStatistics"; +import { PlanningProjectFilterProvider } from "@/components/planning/usePlanningProjectFilter"; import { - filterAndSortProjectsForWeeklyPlanning, - projectFilters, - ProjectFilters, -} from "@/helpers/planning"; -import { addDays } from "date-fns"; -import { flow } from "lodash/fp"; -import { CalendarCheck, Loader2, Play } from "lucide-react"; -import { useEffect, useState } from "react"; + useWeekPlanContext, + withWeekPlan, +} from "@/components/planning/useWeekPlanContext"; +import { useContextContext } from "@/contexts/ContextContext"; const WeeklyPlanningPage = () => { - const { - weekPlan, - createWeekPlan, - isLoading, - error, - confirmProjectSelection, - } = useWeekPlan(); - const [startDate, setStartDate] = useState( - weekPlan?.startDate || addDays(new Date(), 1) - ); - const { projects, saveProjectDates } = useProjectsContext(); - const { accounts } = useAccountsContext(); - const [projectFilter, setProjectFilter] = useState("Open"); - const [filteredAndSortedProjects, setFilteredAndSortedProjects] = useState( - filterAndSortProjectsForWeeklyPlanning( - accounts, - startDate, - weekPlan, - projectFilter - )(projects) - ); - - useEffect(() => { - flow( - filterAndSortProjectsForWeeklyPlanning( - accounts, - startDate, - weekPlan, - projectFilter - ), - setFilteredAndSortedProjects - )(projects); - }, [accounts, projectFilter, projects, startDate, weekPlan]); - - useEffect(() => { - if (!weekPlan) return; - setStartDate(weekPlan.startDate); - }, [weekPlan]); + const { context } = useContextContext(); + const { error } = useWeekPlanContext(); return (
-
- - - {isLoading ? ( - - ) : weekPlan ? ( - - ) : ( - - )} -
+
-
- {!weekPlan ? ( - "Start Week Planning to review a list of projects for the current context." - ) : ( -
-
- Review each project and decide if you can make progress here - during the next week. -
-
- Projects to be reviewed:{" "} - { - filterAndSortProjectsForWeeklyPlanning( - accounts, - startDate, - weekPlan, - "Open" - )(projects).length - } -
-
- Projects on hold:{" "} - { - filterAndSortProjectsForWeeklyPlanning( - accounts, - startDate, - weekPlan, - "On Hold" - )(projects).length - } -
-
- Projects in focus:{" "} - { - filterAndSortProjectsForWeeklyPlanning( - accounts, - startDate, - weekPlan, - "In Focus" - )(projects).length - } -
-
- )} -
+ + -
- - - projectFilters.includes(val as ProjectFilters) && - setProjectFilter(val as ProjectFilters) - } - /> -
+ - {weekPlan && ( - - {filteredAndSortedProjects.map((project) => ( - id === project.id)} - project={project} - saveOnHoldDate={(onHoldTill) => - saveProjectDates({ projectId: project.id, onHoldTill }) - } - /> - ))} - - )} + {context !== "work" && } + {context === "work" && } +
); }; -export default WeeklyPlanningPage; +export default withWeekPlan(WeeklyPlanningPage);