From bcc54a28d06106cac2f069b1bf376d44b33303b1 Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Sat, 12 Oct 2024 15:19:07 +0200 Subject: [PATCH 1/3] feat: projects are searchable --- components/accounts/AccountDetails.tsx | 8 +- components/accounts/ProjectList.tsx | 44 ++------ .../projects/project-filter-btn-group.tsx | 31 ++++++ components/projects/useProjectFilter.tsx | 103 ++++++++++++++++++ components/search/search-input.tsx | 45 ++++++++ components/search/useSearch.tsx | 60 ++++++++++ docs/releases/next.md | 10 +- helpers/projects.ts | 56 ++++++++-- pages/projects/index.tsx | 39 +++---- 9 files changed, 322 insertions(+), 74 deletions(-) create mode 100644 components/projects/project-filter-btn-group.tsx create mode 100644 components/projects/useProjectFilter.tsx create mode 100644 components/search/search-input.tsx create mode 100644 components/search/useSearch.tsx diff --git a/components/accounts/AccountDetails.tsx b/components/accounts/AccountDetails.tsx index 365bd51da..ccef00b21 100644 --- a/components/accounts/AccountDetails.tsx +++ b/components/accounts/AccountDetails.tsx @@ -10,6 +10,8 @@ import { Editor } from "@tiptap/core"; import { filter, flow, map } from "lodash/fp"; import { FC } from "react"; import CrmLink from "../crm/CrmLink"; +import { ProjectFilterProvider } from "../projects/useProjectFilter"; +import { SearchProvider } from "../search/useSearch"; import DefaultAccordionItem from "../ui-elements/accordion/DefaultAccordionItem"; import { debouncedUpdateAccountDetails } from "../ui-elements/account-details/account-updates-helpers"; import NotesWriter from "../ui-elements/notes-writer/NotesWriter"; @@ -129,7 +131,11 @@ const AccountDetails: FC = ({ )(projects)} isVisible={!!showProjects} > - + + + + + diff --git a/components/accounts/ProjectList.tsx b/components/accounts/ProjectList.tsx index ba84793d7..73433e2f4 100644 --- a/components/accounts/ProjectList.tsx +++ b/components/accounts/ProjectList.tsx @@ -1,53 +1,23 @@ -import { useAccountsContext } from "@/api/ContextAccounts"; -import { Project, useProjectsContext } from "@/api/ContextProjects"; -import { filterAndSortProjects } from "@/helpers/projects"; import { flow, identity, map, times } from "lodash/fp"; -import { FC, useEffect, useState } from "react"; +import { FC } from "react"; import ApiLoadingError from "../layouts/ApiLoadingError"; import ProjectAccordionItem from "../projects/ProjectAccordionItem"; +import { useProjectFilter } from "../projects/useProjectFilter"; import LoadingAccordionItem from "../ui-elements/accordion/LoadingAccordionItem"; import { Accordion } from "../ui/accordion"; -const PROJECT_FILTERS = ["WIP", "On Hold", "Done"] as const; -export type ProjectFilters = (typeof PROJECT_FILTERS)[number]; -export const isValidProjectFilter = ( - filter: string -): filter is ProjectFilters => - PROJECT_FILTERS.includes(filter as ProjectFilters); - -type ProjectsByAccount = { - accountId: string; - filter?: never; -}; -type ProjectsByFilter = { - accountId?: never; - filter: ProjectFilters; -}; -type ProjectListProps = (ProjectsByAccount | ProjectsByFilter) & { +type ProjectListProps = { allowPushToNextDay?: boolean; }; -const ProjectList: FC = ({ - accountId, - filter: projectFilter, - allowPushToNextDay, -}) => { - const { projects, loadingProjects, errorProjects } = useProjectsContext(); - const { accounts } = useAccountsContext(); - const [filteredProjects, setFilteredProjects] = useState([]); - - useEffect(() => { - if (!projects) return setFilteredProjects([]); - setFilteredProjects( - filterAndSortProjects(projects, accountId, projectFilter, accounts) - ); - }, [accountId, accounts, projectFilter, projects]); +const ProjectList: FC = ({ allowPushToNextDay }) => { + const { projects, loadingProjects, errorProjects } = useProjectFilter(); return (
- {!loadingProjects && filteredProjects.length === 0 && ( + {!loadingProjects && projects.length === 0 && (
No projects
@@ -67,7 +37,7 @@ const ProjectList: FC = ({ )) )(10)} - {filteredProjects.map((project) => ( + {projects.map((project) => ( = ({ className }) => { + const { isSearchActive, projectFilter, setProjectFilter } = + useProjectFilter(); + + return ( + !isSearchActive && ( +
+ +
+ ) + ); +}; + +export default ProjectFilterBtnGrp; diff --git a/components/projects/useProjectFilter.tsx b/components/projects/useProjectFilter.tsx new file mode 100644 index 000000000..ba9dbb2da --- /dev/null +++ b/components/projects/useProjectFilter.tsx @@ -0,0 +1,103 @@ +import { useAccountsContext } from "@/api/ContextAccounts"; +import { Project, useProjectsContext } from "@/api/ContextProjects"; +import { filterAndSortProjects } from "@/helpers/projects"; +import { + ComponentType, + createContext, + FC, + useContext, + useEffect, + useState, +} from "react"; +import { SearchProvider, useSearch } from "../search/useSearch"; + +export const PROJECT_FILTERS = ["WIP", "On Hold", "Done"]; +const PROJECT_FILTERS_CONST = ["WIP", "On Hold", "Done"] as const; +export type ProjectFilters = (typeof PROJECT_FILTERS_CONST)[number]; + +export const isValidProjectFilter = ( + filter: string +): filter is ProjectFilters => + PROJECT_FILTERS_CONST.includes(filter as ProjectFilters); + +interface ProjectFilterType { + projects: Project[]; + loadingProjects: ReturnType["loadingProjects"]; + errorProjects: ReturnType["errorProjects"]; + isSearchActive: boolean; + projectFilter: ProjectFilters; + setProjectFilter: (filter: string) => void; +} + +const ProjectFilter = createContext(null); + +export const useProjectFilter = () => { + const searchContext = useContext(ProjectFilter); + if (!searchContext) + throw new Error( + "useProjectFilter must be used within ProjectFilterProvider" + ); + return searchContext; +}; + +interface ProjectFilterProviderProps { + children: React.ReactNode; + accountId?: string; +} + +export const ProjectFilterProvider: FC = ({ + children, + accountId, +}) => { + const { projects, loadingProjects, errorProjects } = useProjectsContext(); + const { accounts } = useAccountsContext(); + const [filteredProjects, setFilteredProjects] = useState([]); + const { searchText, isSearchActive } = useSearch(); + const [filter, setFilter] = useState("WIP"); + + const onFilterChange = (newFilter: string) => + isValidProjectFilter(newFilter) && setFilter(newFilter); + + useEffect(() => { + if (!projects) return setFilteredProjects([]); + setFilteredProjects( + filterAndSortProjects({ + projects, + accountId, + projectFilter: filter, + accounts, + searchText, + }) + ); + }, [accountId, accounts, filter, projects, searchText]); + + return ( + + {children} + + ); +}; + +export function withProjectFilter( + Component: ComponentType +) { + return function WrappedProvider(componentProps: Props) { + return ( + + + + + ; + + ); + }; +} diff --git a/components/search/search-input.tsx b/components/search/search-input.tsx new file mode 100644 index 000000000..82cfb1e65 --- /dev/null +++ b/components/search/search-input.tsx @@ -0,0 +1,45 @@ +import { cn } from "@/lib/utils"; +import { Delete, Search } from "lucide-react"; +import { FC } from "react"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { useSearch } from "./useSearch"; + +type SearchInputProps = { + className?: string; +}; + +const SearchInput: FC = ({ className }) => { + const { searchText, setSearchText, isSearchActive } = useSearch(); + + return ( +
+
+ + { + setSearchText(e.target.value); + }} + /> +
+ {isSearchActive && ( + + )} +
+ ); +}; + +export default SearchInput; diff --git a/components/search/useSearch.tsx b/components/search/useSearch.tsx new file mode 100644 index 000000000..8a65acd05 --- /dev/null +++ b/components/search/useSearch.tsx @@ -0,0 +1,60 @@ +import { + ComponentType, + createContext, + FC, + useContext, + useEffect, + useState, +} from "react"; + +interface SearchType { + searchText: string; + setSearchText: (searchText: string) => void; + isSearchActive: boolean; +} + +const Search = createContext(null); + +export const useSearch = () => { + const searchContext = useContext(Search); + if (!searchContext) + throw new Error("useSearch must be used within SearchProvider"); + return searchContext; +}; + +interface SearchProviderProps { + children: React.ReactNode; +} + +export const SearchProvider: FC = ({ children }) => { + const [search, setSearch] = useState(""); + const [isSearchActive, setIsSearchActive] = useState(false); + + useEffect(() => { + setIsSearchActive(!!search); + }, [search]); + + return ( + + {children} + + ); +}; + +export function isSearchable( + Component: ComponentType +) { + return function WrappedProvider(componentProps: Props) { + return ( + + + + ); + }; +} diff --git a/docs/releases/next.md b/docs/releases/next.md index d30fad346..f9c9830e4 100644 --- a/docs/releases/next.md +++ b/docs/releases/next.md @@ -1,13 +1,11 @@ -# Fehlerbehebungen Importieren von geschlossenen CRM Projekten (Version :VERSION) +# Einfacher Suchen (Projekte/Personsn) (Version :VERSION) -- Geschlossene CRM Projekte werden beim Importieren nicht wieder als fehlend gekennzeichnet sein. -- Die Badges an Projekten oder Besprechungen wurden in die Höhe gestreckt, wenn der Titel eines Akkordions zwei Zeilen hatte. -- In den CRM Projektdetails werden die Hygiene-Probleme aufgelistet. -- Feld in der Datenbank hinzugefügt, in dem gespeichert werden kann, bis wann eine Bestätigung gilt, dass der Nutzer die Hygiene-Probleme aufgelöst hat. -- Für ein CRM Projekt können nun die Hygiene-Probleme als behoben markiert werden. Für eine Stunde wird der Anwender nicht mehr darauf hingewiesen. Damit kann der Anwender alle Änderungen vornehmen und schließlich die CRM Projekte neu importieren. +- Die Projektliste kann nun durchsucht werden. ## In Arbeit +- Ich möchte besser ein Meeting mit einer bestimmten Person starten. + ## Geplant ### Account Details diff --git a/helpers/projects.ts b/helpers/projects.ts index fe28b3135..afd70fbf4 100644 --- a/helpers/projects.ts +++ b/helpers/projects.ts @@ -1,7 +1,7 @@ import { Account } from "@/api/ContextAccounts"; import { CrmDataProps, Project } from "@/api/ContextProjects"; import { STAGES_PROBABILITY, TCrmStages } from "@/api/useCrmProject"; -import { ProjectFilters } from "@/components/accounts/ProjectList"; +import { ProjectFilters } from "@/components/projects/useProjectFilter"; import { differenceInCalendarMonths } from "date-fns"; import { filter, flatMap, flow, map, max, round, sortBy, sum } from "lodash/fp"; import { calcOrder } from "./accounts"; @@ -102,8 +102,29 @@ export const make2YearsRevenueText = (revenue: number) => export const getRevenue2Years = (projects: ICalcRevenueTwoYears[]) => make2YearsRevenueText(flow(map(calcRevenueTwoYears), sum)(projects)); +export type FilterAndSortProjectsProps = { + projects: Project[]; + accountId?: string; + projectFilter?: ProjectFilters; + accounts?: Account[]; + searchText?: string; +}; + +type FilterProjectsProps = Pick< + FilterAndSortProjectsProps, + "accountId" | "projectFilter" | "searchText" +>; + +const searchTextInProjectName = ( + projectName: string, + searchText: FilterProjectsProps["searchText"] +) => + searchText + ? projectName.toLowerCase().includes(searchText.toLowerCase()) + : true; + const filterByProjectStatus = - (accountId: string | undefined, projectFilter: ProjectFilters | undefined) => + ({ accountId, projectFilter }: FilterProjectsProps) => ({ accountIds, done, onHoldTill }: Project) => accountId ? accountIds.includes(accountId) && !done @@ -111,25 +132,38 @@ const filterByProjectStatus = (projectFilter === "On Hold" && !done && !!onHoldTill) || (projectFilter === "Done" && done); +const filterBySearch = + ({ accountId, searchText }: FilterProjectsProps) => + ({ accountIds, done, project }: Project) => + accountId + ? accountIds.includes(accountId) && + searchTextInProjectName(project, searchText) && + !done + : searchTextInProjectName(project, searchText) && !done; + export const calcPipelineByAccountId = (accountId: string | undefined) => (projects?: Project[]): number => !projects ? 0 : flow( - filter(filterByProjectStatus(accountId, undefined)), + filter(filterByProjectStatus({ accountId })), map((p) => p.pipeline), sum )(projects); -export const filterAndSortProjects = ( - projects: Project[], - accountId: string | undefined, - projectFilter: ProjectFilters | undefined, - accounts: Account[] | undefined -) => - flow( - filter(filterByProjectStatus(accountId, projectFilter)), +export const filterAndSortProjects = ({ + projectFilter, + accountId, + projects, + accounts, + searchText, +}: FilterAndSortProjectsProps) => { + const filterProjects = searchText ? filterBySearch : filterByProjectStatus; + + return flow( + filter(filterProjects({ accountId, projectFilter, searchText })), map(updateProjectOrder(accounts)), sortBy((p) => -p.order) )(projects); +}; diff --git a/pages/projects/index.tsx b/pages/projects/index.tsx index 4948dd300..fb7f8ef11 100644 --- a/pages/projects/index.tsx +++ b/pages/projects/index.tsx @@ -1,16 +1,18 @@ import { useProjectsContext } from "@/api/ContextProjects"; -import ProjectList, { - ProjectFilters, - isValidProjectFilter, -} from "@/components/accounts/ProjectList"; +import ProjectList from "@/components/accounts/ProjectList"; import MainLayout from "@/components/layouts/MainLayout"; -import ButtonGroup from "@/components/ui-elements/btn-group/btn-group"; +import ProjectFilterBtnGrp from "@/components/projects/project-filter-btn-group"; +import { + useProjectFilter, + withProjectFilter, +} from "@/components/projects/useProjectFilter"; +import SearchInput from "@/components/search/search-input"; +import { cn } from "@/lib/utils"; import { useRouter } from "next/router"; -import { useState } from "react"; const ProjectListPage = () => { const { createProject } = useProjectsContext(); - const [filter, setFilter] = useState("WIP"); + const { isSearchActive } = useProjectFilter(); const router = useRouter(); const createAndOpenNewProject = async () => { @@ -19,9 +21,6 @@ const ProjectListPage = () => { router.push(`/projects/${project.id}`); }; - const onFilterChange = (newFilter: string) => - isValidProjectFilter(newFilter) && setFilter(newFilter); - return ( { addButton={{ label: "New", onClick: createAndOpenNewProject }} >
-
- -
+ + + - +
); }; -export default ProjectListPage; +export default withProjectFilter(ProjectListPage); From 6462ea7cde02562c72732ae53381b74a76852e78 Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Sat, 12 Oct 2024 16:23:41 +0200 Subject: [PATCH 2/3] feat: create 1:1 meetings from meetings list page --- .../meetings/create-one-on-one-meeting.tsx | 34 +++++++++++++++++++ components/meetings/useMeetingFilter.tsx | 34 ++++++++++++++++--- .../navigation-menu/CreateOneOnOneMeeting.tsx | 30 ++++++++-------- docs/releases/next.md | 3 +- helpers/meetings.ts | 24 +++++++++++-- pages/meetings/index.tsx | 16 +++++++-- 6 files changed, 114 insertions(+), 27 deletions(-) create mode 100644 components/meetings/create-one-on-one-meeting.tsx diff --git a/components/meetings/create-one-on-one-meeting.tsx b/components/meetings/create-one-on-one-meeting.tsx new file mode 100644 index 000000000..ebfa80346 --- /dev/null +++ b/components/meetings/create-one-on-one-meeting.tsx @@ -0,0 +1,34 @@ +import { useContextContext } from "@/contexts/ContextContext"; +import { FC } from "react"; +import PeopleSelector from "../ui-elements/selectors/people-selector"; +import { Label } from "../ui/label"; +import { CreateMeetingProps } from "./useMeetingFilter"; + +type CreateOneOnOneMeetingProps = { + createMeeting: (props: CreateMeetingProps) => void; +}; + +const CreateOneOnOneMeeting: FC = ({ + createMeeting, +}) => { + const { context } = useContextContext(); + + return ( +
+ + + createMeeting({ + topic: "", + participantId: personId ?? undefined, + context, + }) + } + /> +
+ ); +}; + +export default CreateOneOnOneMeeting; diff --git a/components/meetings/useMeetingFilter.tsx b/components/meetings/useMeetingFilter.tsx index 5d55fe04f..4cea4870d 100644 --- a/components/meetings/useMeetingFilter.tsx +++ b/components/meetings/useMeetingFilter.tsx @@ -1,5 +1,7 @@ import useMeetings, { Meeting } from "@/api/useMeetings"; -import { useContextContext } from "@/contexts/ContextContext"; +import usePeople from "@/api/usePeople"; +import { Context, useContextContext } from "@/contexts/ContextContext"; +import { createMeetingName } from "@/helpers/meetings"; import { filter, flow, map, uniq } from "lodash/fp"; import { ComponentType, @@ -11,10 +13,16 @@ import { } from "react"; import useMeetingPagination from "./useMeetingPagination"; +export type CreateMeetingProps = { + topic: string; + context?: Context; + participantId?: string; +}; + interface MeetingFilterType { meetings: ReturnType["meetings"] | undefined; meetingDates: string[]; - createMeeting: ReturnType["createMeeting"]; + createMeeting: (props: CreateMeetingProps) => Promise; selectedFilter: TMeetingFilters; availableFilters: TMeetingFilters[]; onSelectFilter: (selectedFilter: string) => void; @@ -59,7 +67,7 @@ const MeetingFilterProvider: FC = ({ const { fromDate, toDate, handleNextClick, handlePrevClick } = useMeetingPagination(); - const { meetings, createMeeting } = useMeetings({ + const { meetings, createMeeting, createMeetingParticipant } = useMeetings({ context, startDate: fromDate, }); @@ -68,6 +76,8 @@ const MeetingFilterProvider: FC = ({ const [meetingFilter, setMeetingFilter] = useState("All"); const [filtered, setFiltered] = useState(undefined); + const { people } = usePeople(); + useEffect(() => { if (!meetings) return setFiltered(undefined); if (meetingFilter === "All") return setFiltered(meetings); @@ -86,12 +96,28 @@ const MeetingFilterProvider: FC = ({ setMeetingFilter(newFilter); }; + const createMeetingAndParticipant = async ({ + topic, + context, + participantId, + }: CreateMeetingProps) => { + const meetingName = !participantId + ? topic + : createMeetingName({ participantId, people }); + if (!meetingName) return; + const meetingId = await createMeeting(meetingName, context); + if (!meetingId) return; + if (!participantId) return meetingId; + await createMeetingParticipant(meetingId, participantId); + return meetingId; + }; + return ( , split(" "), first); - const CreateOneOnOneMeeting: FC = ({ metaPressed, items, }) => { const { context } = useContextContext(); const { createMeeting, createMeetingParticipant } = useMeetings({ context }); + const { people } = usePeople(); - const handleCreate = - (personId: string, personName: string, accountNames: string | undefined) => - async () => { - const meetingName = `Meet ${getFirstName(personName)}${ - !accountNames ? "" : `/${getFirstName(accountNames)}` - }`; - const meetingId = await createMeeting(meetingName, context); - if (!meetingId) return; - await createMeetingParticipant(meetingId, personId); - return meetingId; - }; + const handleCreate = (personId: string) => async () => { + const meetingName = createMeetingName({ + people, + participantId: personId, + }); + if (!meetingName) return; + const meetingId = await createMeeting(meetingName, context); + if (!meetingId) return; + await createMeetingParticipant(meetingId, personId); + return meetingId; + }; return ( = ({ id, value: `…with ${name}${!accountNames ? "" : ` (${accountNames})`}`, link: "/meetings", - processFn: handleCreate(id, name, accountNames), + processFn: handleCreate(id), }))} /> ); diff --git a/docs/releases/next.md b/docs/releases/next.md index f9c9830e4..bda10bb55 100644 --- a/docs/releases/next.md +++ b/docs/releases/next.md @@ -1,11 +1,10 @@ # Einfacher Suchen (Projekte/Personsn) (Version :VERSION) - Die Projektliste kann nun durchsucht werden. +- Meeting mit einer einzelnen Person können nun auch in der Meetingsliste erstellt werden. ## In Arbeit -- Ich möchte besser ein Meeting mit einer bestimmten Person starten. - ## Geplant ### Account Details diff --git a/helpers/meetings.ts b/helpers/meetings.ts index c09699fcf..d93edbf37 100644 --- a/helpers/meetings.ts +++ b/helpers/meetings.ts @@ -1,8 +1,28 @@ import { MeetingUpdateProps } from "@/api/useMeeting"; import { Meeting } from "@/api/useMeetings"; +import { LeanPerson } from "@/api/usePeople"; import { debounce } from "lodash"; +import { first, flow, identity, split } from "lodash/fp"; -const debouncedUpdateMeeting = ( +const getFirstName = flow(identity, split(" "), first); +const getAccountName = flow(identity, split(","), first); + +type CreateMeetingNameProps = { + people?: LeanPerson[]; + participantId?: string; +}; +export const createMeetingName = ({ + people, + participantId, +}: CreateMeetingNameProps) => { + const person = people?.find((p) => p.id === participantId); + if (!person) return; + return `Meet ${getFirstName(person.name)}${ + !person.accountNames ? "" : `/${getAccountName(person.accountNames)}` + }`; +}; + +export const debouncedUpdateMeeting = ( meeting: Meeting, updateMeeting: (props: MeetingUpdateProps) => void ) => @@ -16,5 +36,3 @@ const debouncedUpdateMeeting = ( }, 1500 ); - -export { debouncedUpdateMeeting }; diff --git a/pages/meetings/index.tsx b/pages/meetings/index.tsx index 09fbba11f..48fc595d3 100644 --- a/pages/meetings/index.tsx +++ b/pages/meetings/index.tsx @@ -1,8 +1,10 @@ import MainLayout from "@/components/layouts/MainLayout"; +import CreateOneOnOneMeeting from "@/components/meetings/create-one-on-one-meeting"; import MeetingDateList from "@/components/meetings/meeting-date-list"; import MeetingFilter from "@/components/meetings/meeting-filter"; import MeetingPagination from "@/components/meetings/meeting-pagination"; import { + CreateMeetingProps, useMeetingFilter, withMeetingFilter, } from "@/components/meetings/useMeetingFilter"; @@ -11,6 +13,7 @@ import { useRouter } from "next/router"; const MeetingsPage = () => { const { context } = useContextContext(); + const { createMeeting, meetingDates, @@ -22,8 +25,8 @@ const MeetingsPage = () => { } = useMeetingFilter(); const router = useRouter(); - const createAndOpenNewMeeting = async () => { - const id = await createMeeting("New Meeting", context); + const createAndOpenNewMeeting = async (props: CreateMeetingProps) => { + const id = await createMeeting(props); if (!id) return; router.push(`/meetings/${id}`); }; @@ -32,13 +35,20 @@ const MeetingsPage = () => { + createAndOpenNewMeeting({ topic: "New Meeting", context }), + }} > + + + {meetingDates.map((date) => ( ))} From e6003df74e7ab315095dca1e6139a46377f52864 Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Sat, 12 Oct 2024 16:25:38 +0200 Subject: [PATCH 3/3] fix: change log --- docs/releases/next.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/next.md b/docs/releases/next.md index bda10bb55..b8f0a4bb3 100644 --- a/docs/releases/next.md +++ b/docs/releases/next.md @@ -1,4 +1,4 @@ -# Einfacher Suchen (Projekte/Personsn) (Version :VERSION) +# Einfacher Suchen (Projekte/Personen) (Version :VERSION) - Die Projektliste kann nun durchsucht werden. - Meeting mit einer einzelnen Person können nun auch in der Meetingsliste erstellt werden.