From bd1c253ca78f70ddd140a9764afc7ebf9591b7ac Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Tue, 30 Apr 2024 13:49:19 +0200 Subject: [PATCH] feat: allow changing context and linking CRM projects --- amplify/data/resource.ts | 26 ++++ api/ContextProjects.tsx | 49 +++++-- api/useCrmProject.ts | 71 ++++++++++ api/useCrmProjects.ts | 134 ++++++++++++++++++ api/useMeeting.ts | 15 ++ .../OtherNavigationSection.tsx | 1 + .../context-warning/ContextWarning.module.css | 4 + .../context-warning/context-warning.tsx | 38 +++++ .../CrmProjectDetails.module.css | 26 ++++ .../crm-project-details.tsx | 132 +++++++++++++++++ .../crm-project-details/crm-project-form.tsx | 99 +++++++++++++ components/ui-elements/form-fields/input.tsx | 38 +++++ .../notes-writer/NotesWriter.module.css | 8 +- .../ui-elements/notes-writer/NotesWriter.tsx | 10 +- .../project-details/ProjectDetails.module.css | 24 +--- .../project-details/next-actions.tsx | 4 +- .../project-details/project-dates.tsx | 4 +- .../project-details/project-details.tsx | 63 +++++++- .../project-notes-form/project-notes-form.tsx | 18 ++- .../record-details/RecordDetails.module.css | 16 +++ .../record-details/record-details.tsx | 27 ++++ docs/releases/next.md | 12 +- helpers/functional.ts | 6 + pages/_app.tsx | 2 +- pages/_document.tsx | 4 - pages/crm-projects/index.tsx | 15 ++ pages/meetings/[id].tsx | 67 ++++++--- pages/projects/[id].tsx | 7 +- 28 files changed, 834 insertions(+), 86 deletions(-) create mode 100644 api/useCrmProject.ts create mode 100644 api/useCrmProjects.ts create mode 100644 components/ui-elements/context-warning/ContextWarning.module.css create mode 100644 components/ui-elements/context-warning/context-warning.tsx create mode 100644 components/ui-elements/crm-project-details/CrmProjectDetails.module.css create mode 100644 components/ui-elements/crm-project-details/crm-project-details.tsx create mode 100644 components/ui-elements/crm-project-details/crm-project-form.tsx create mode 100644 components/ui-elements/form-fields/input.tsx create mode 100644 components/ui-elements/record-details/RecordDetails.module.css create mode 100644 components/ui-elements/record-details/record-details.tsx create mode 100644 pages/crm-projects/index.tsx diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index 4f1481281..b066da7d0 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -212,6 +212,31 @@ const schema = a.schema({ createdOn: a.datetime(), }) .authorization((allow) => [allow.owner()]), + CrmProjectProjects: a + .model({ + owner: a + .string() + .authorization((allow) => [allow.owner().to(["read", "delete"])]), + projectId: a.id().required(), + crmProjectId: a.id().required(), + project: a.belongsTo("Projects", "projectId"), + crmProject: a.belongsTo("CrmProject", "crmProjectId"), + }) + .authorization((allow) => [allow.owner()]), + CrmProject: a + .model({ + owner: a + .string() + .authorization((allow) => [allow.owner().to(["read", "delete"])]), + name: a.string().required(), + crmId: a.string(), + annualRecurringRevenue: a.integer(), + totalContractVolume: a.integer(), + closeDate: a.date().required(), + projects: a.hasMany("CrmProjectProjects", "crmProjectId"), + stage: a.string().required(), + }) + .authorization((allow) => [allow.owner()]), Projects: a .model({ owner: a @@ -231,6 +256,7 @@ const schema = a.schema({ activities: a.hasMany("ProjectActivity", "projectsId"), dayTasks: a.hasMany("DayProjectTask", "projectsDayTasksId"), todos: a.hasMany("DayPlanTodo", "projectsTodosId"), + crmProjects: a.hasMany("CrmProjectProjects", "projectId"), }) .authorization((allow) => [allow.owner()]), }); diff --git a/api/ContextProjects.tsx b/api/ContextProjects.tsx index d0f176449..a612d7876 100644 --- a/api/ContextProjects.tsx +++ b/api/ContextProjects.tsx @@ -1,7 +1,7 @@ import { FC, ReactNode, createContext, useContext } from "react"; import { type Schema } from "@/amplify/data/resource"; import { SelectionSet, generateClient } from "aws-amplify/data"; -import useSWR from "swr"; +import useSWR, { KeyedMutator } from "swr"; import { Context } from "@/contexts/ContextContext"; import { handleApiErrors } from "./globals"; import { addDaysToDate } from "@/helpers/functional"; @@ -42,6 +42,11 @@ interface ProjectsContextType { projectId: string, accountId: string ) => Promise; + updateProjectContext: ( + projectId: string, + context: Context + ) => Promise; + mutateProjects: KeyedMutator; } export type Project = { @@ -56,6 +61,7 @@ export type Project = { context: Context; accountIds: string[]; activityIds: string[]; + crmProjectIds: string[]; }; const selectionSet = [ @@ -73,6 +79,7 @@ const selectionSet = [ "activities.activity.id", "activities.activity.finishedOn", "activities.activity.createdAt", + "crmProjects.crmProject.id", ] as const; type ProjectData = SelectionSet; @@ -89,6 +96,7 @@ export const mapProject: (project: ProjectData) => Project = ({ context, accounts, activities, + crmProjects, }) => ({ id, project, @@ -114,6 +122,7 @@ export const mapProject: (project: ProjectData) => Project = ({ })) .sort((a, b) => b.finishedOn.getTime() - a.finishedOn.getTime()) .map(({ id }) => id), + crmProjectIds: crmProjects.map(({ crmProject: { id } }) => id), }); const fetchProjects = (context?: Context) => async () => { @@ -130,7 +139,7 @@ const fetchProjects = (context?: Context) => async () => { }, ], }, - limit: 500, + limit: 5000, selectionSet, }); if (errors) throw errors; @@ -150,7 +159,7 @@ export const ProjectsContextProvider: FC = ({ data: projects, error: errorProjects, isLoading: loadingProjects, - mutate, + mutate: mutateProjects, } = useSWR(`/api/projects/${context}`, fetchProjects(context)); const createProject = async ( @@ -168,10 +177,11 @@ export const ProjectsContextProvider: FC = ({ context, accountIds: [], activityIds: [], + crmProjectIds: [], }; const updatedProjects = [...(projects || []), newProject]; - mutate(updatedProjects, false); + mutateProjects(updatedProjects, false); const { data, errors } = await client.models.Projects.create({ context, @@ -179,7 +189,7 @@ export const ProjectsContextProvider: FC = ({ done: false, }); if (errors) handleApiErrors(errors, "Error creating project"); - mutate(updatedProjects); + mutateProjects(updatedProjects); return data; }; @@ -199,13 +209,13 @@ export const ProjectsContextProvider: FC = ({ ? project : { ...project, activityIds: [activity.id, ...project.activityIds] } ) || []; - mutate(updated, false); + mutateProjects(updated, false); const { data, errors } = await client.models.ProjectActivity.create({ activityId: activity.id, projectsId: projectId, }); if (errors) handleApiErrors(errors, "Error linking activity with project"); - mutate(updated); + mutateProjects(updated); return data.activityId; }; @@ -247,7 +257,7 @@ export const ProjectsContextProvider: FC = ({ : othersNextActions, } ) || []; - mutate(updated, false); + mutateProjects(updated, false); const newProject = { id, project, @@ -267,7 +277,7 @@ export const ProjectsContextProvider: FC = ({ }; const { data, errors } = await client.models.Projects.update(newProject); if (errors) handleApiErrors(errors, "Error updating project"); - mutate(updated); + mutateProjects(updated); return data?.id; }; @@ -302,16 +312,31 @@ export const ProjectsContextProvider: FC = ({ ? p : { ...p, accountIds: [...p.accountIds, accountId] } ) || []; - mutate(updated, false); + mutateProjects(updated, false); const { data, errors } = await client.models.AccountProjects.create({ projectsId: projectId, accountId, }); if (errors) handleApiErrors(errors, "Error adding account to project"); - mutate(updated); + mutateProjects(updated); return data?.id; }; + const updateProjectContext = async ( + projectId: string, + newContext: Context + ) => { + const { data, errors } = await client.models.Projects.update({ + id: projectId, + context: newContext, + }); + if (errors) { + handleApiErrors(errors, "Error updating project's context"); + return; + } + return data.id; + }; + return ( = ({ saveProjectDates, updateProjectState, addAccountToProject, + updateProjectContext, + mutateProjects, }} > {children} diff --git a/api/useCrmProject.ts b/api/useCrmProject.ts new file mode 100644 index 000000000..bb07b6421 --- /dev/null +++ b/api/useCrmProject.ts @@ -0,0 +1,71 @@ +import { type Schema } from "@/amplify/data/resource"; +import { generateClient } from "aws-amplify/data"; +import useSWR from "swr"; +import { + CrmProject, + mapCrmProject, + selectionSetCrmProject, +} from "./useCrmProjects"; +import { getDayOfDate } from "@/helpers/functional"; +import { handleApiErrors } from "./globals"; +import { Project, useProjectsContext } from "./ContextProjects"; +const client = generateClient(); + +const fetchCrmProject = (projectId?: string) => async () => { + if (!projectId) return; + const { data, errors } = await client.models.CrmProject.get( + { id: projectId }, + { selectionSet: selectionSetCrmProject } + ); + if (errors) throw errors; + return mapCrmProject(data); +}; + +const useCrmProject = (projectId?: string) => { + const { + data: crmProject, + error: errorCrmProject, + isLoading: loadingCrmProject, + mutate, + } = useSWR(`/api/crm-projects/${projectId}`, fetchCrmProject(projectId)); + const { projects, mutateProjects } = useProjectsContext(); + + const createCrmProject = async (project: CrmProject) => { + const { data: newProject, errors: projectErrors } = + await client.models.CrmProject.create({ + closeDate: getDayOfDate(project.closeDate), + name: project.name, + stage: project.stage, + annualRecurringRevenue: project.arr, + crmId: project.crmId, + totalContractVolume: project.tcv, + }); + if (projectErrors) { + handleApiErrors(projectErrors, "Error creating CRM project"); + return; + } + if (!newProject) return; + const { data, errors } = await client.models.CrmProjectProjects.create({ + projectId: project.projectIds[0], + crmProjectId: newProject.id, + }); + if (errors) { + handleApiErrors(errors, "Error linking CRM project to project"); + return; + } + if (projects) { + mutateProjects( + projects.map((p) => + p.id !== project.projectIds[0] + ? p + : { ...p, crmProjectIds: [...p.crmProjectIds, newProject.id] } + ) + ); + } + return newProject.id; + }; + + return { crmProject, errorCrmProject, loadingCrmProject, createCrmProject }; +}; + +export default useCrmProject; diff --git a/api/useCrmProjects.ts b/api/useCrmProjects.ts new file mode 100644 index 000000000..fa3af4b74 --- /dev/null +++ b/api/useCrmProjects.ts @@ -0,0 +1,134 @@ +import { type Schema } from "@/amplify/data/resource"; +import { SelectionSet, generateClient } from "aws-amplify/data"; +import useSWR from "swr"; +import { flow } from "lodash/fp"; +import { + addDaysToDate, + getDayOfDate, + toISODateString, +} from "@/helpers/functional"; +const client = generateClient(); + +export type CrmStage = + | "Prospect" + | "Qualified" + | "Technical Validation" + | "Business Validation" + | "Committed" + | "Closed Lost" + | "Launched"; + +export const crmStages = [ + "Prospect", + "Qualified", + "Technical Validation", + "Business Validation", + "Committed", + "Closed Lost", + "Launched", +]; + +export type CrmProject = { + id: string; + name: string; + crmId?: string; + arr: number; + tcv: number; + closeDate: Date; + projectIds: string[]; + stage: string; +}; + +export const selectionSetCrmProject = [ + "id", + "name", + "crmId", + "annualRecurringRevenue", + "totalContractVolume", + "closeDate", + "projects.project.id", + "stage", +] as const; + +export const mapCrmProject: (data: CrmProjectData) => CrmProject = ({ + id, + name, + crmId, + annualRecurringRevenue, + totalContractVolume, + closeDate, + projects, + stage, +}) => ({ + id, + name, + crmId: crmId || undefined, + arr: annualRecurringRevenue || 0, + tcv: totalContractVolume || 0, + closeDate: new Date(closeDate), + projectIds: projects.map(({ project: { id } }) => id), + stage, +}); + +type CrmProjectData = SelectionSet< + Schema["CrmProject"], + typeof selectionSetCrmProject +>; + +type FetchCrmProjectsWithTokenFn = ( + token?: string +) => Promise; + +const fetchCrmProjectsWithToken: FetchCrmProjectsWithTokenFn = async ( + token +) => { + const closed = { + or: [{ stage: { eq: "Launched" } }, { stage: { eq: "Closed Lost" } }], + }; + + const { data, errors, nextToken } = await client.models.CrmProject.list({ + filter: { + or: [ + { not: closed }, + { + and: [ + closed, + { + closeDate: { + ge: flow( + addDaysToDate(-14), + toISODateString, + getDayOfDate + )(new Date()), + }, + }, + ], + }, + ], + }, + selectionSet: selectionSetCrmProject, + nextToken: token, + }); + if (errors) throw errors; + if (!nextToken) return data; + return [...data, ...((await fetchCrmProjectsWithToken(nextToken)) || [])]; +}; + +const fetchCrmProjects = async () => { + return (await fetchCrmProjectsWithToken()) + ?.map(mapCrmProject) + .sort((a, b) => a.closeDate.getTime() - b.closeDate.getTime()); +}; + +const useCrmProjects = () => { + const { + data: crmProjects, + error: errorCrmProjects, + isLoading: loadingCrmProjects, + mutate, + } = useSWR("/api/crm-projects/", fetchCrmProjects); + + return { crmProjects, errorCrmProjects, loadingCrmProjects }; +}; + +export default useCrmProjects; diff --git a/api/useMeeting.ts b/api/useMeeting.ts index 5ec92c44d..5ef2717a7 100644 --- a/api/useMeeting.ts +++ b/api/useMeeting.ts @@ -3,6 +3,7 @@ import { generateClient } from "aws-amplify/data"; import useSWR from "swr"; import { Meeting, mapMeeting, meetingSelectionSet } from "./useMeetings"; import { handleApiErrors } from "./globals"; +import { Context } from "@/contexts/ContextContext"; const client = generateClient(); type MeetingUpdateProps = { @@ -75,6 +76,19 @@ const useMeeting = (meetingId?: string) => { return data.id; }; + const updateMeetingContext = async (newContext: Context) => { + if (!meeting) return; + const { data, errors } = await client.models.Meeting.update({ + id: meeting.id, + context: newContext, + }); + if (errors) { + handleApiErrors(errors, "Error updating meeting's context"); + return; + } + return data.id; + }; + return { meeting, errorMeeting, @@ -82,6 +96,7 @@ const useMeeting = (meetingId?: string) => { updateMeeting, createMeetingParticipant, createMeetingActivity, + updateMeetingContext, }; }; diff --git a/components/navigation-menu/OtherNavigationSection.tsx b/components/navigation-menu/OtherNavigationSection.tsx index f6ea2ef1d..8de34473c 100644 --- a/components/navigation-menu/OtherNavigationSection.tsx +++ b/components/navigation-menu/OtherNavigationSection.tsx @@ -16,6 +16,7 @@ type MenuItem = { const menuItems: MenuItem[] = [ { name: "Projects", link: "/projects" }, { name: "Accounts", link: "/accounts" }, + { name: "CRM Projects", link: "/crm-projects" }, // { name: "People", link: "/people" }, ]; diff --git a/components/ui-elements/context-warning/ContextWarning.module.css b/components/ui-elements/context-warning/ContextWarning.module.css new file mode 100644 index 000000000..595c12cec --- /dev/null +++ b/components/ui-elements/context-warning/ContextWarning.module.css @@ -0,0 +1,4 @@ +.warning { + color: red; + font-weight: bold; +} diff --git a/components/ui-elements/context-warning/context-warning.tsx b/components/ui-elements/context-warning/context-warning.tsx new file mode 100644 index 000000000..cb4764b5c --- /dev/null +++ b/components/ui-elements/context-warning/context-warning.tsx @@ -0,0 +1,38 @@ +import { Context, useContextContext } from "@/contexts/ContextContext"; +import { FC } from "react"; +import styles from "./ContextWarning.module.css"; +import SubmitButton from "../submit-button"; + +type ContextWarningProps = { + recordContext?: Context; + className?: string; +}; + +const ContextWarning: FC = ({ + recordContext, + className, +}) => { + const { context, setContext } = useContextContext(); + return ( + context !== recordContext && ( +
+ You are working currently in the{" "} + {context?.toUpperCase()}{" "} + context. Your project is not visible in this context. Do you want to + switch to the{" "} + {recordContext?.toUpperCase()} + context? + { + if (!recordContext) return; + setContext(recordContext); + }} + > + Yes + +
+ ) + ); +}; + +export default ContextWarning; diff --git a/components/ui-elements/crm-project-details/CrmProjectDetails.module.css b/components/ui-elements/crm-project-details/CrmProjectDetails.module.css new file mode 100644 index 000000000..45377cc8c --- /dev/null +++ b/components/ui-elements/crm-project-details/CrmProjectDetails.module.css @@ -0,0 +1,26 @@ +.oneLine { + display: flex; + flex-direction: row; + gap: 1rem; + width: 100%; +} + +.oneRow { + display: flex; + flex-direction: row; + gap: 1rem; + width: 100%; + padding: 0; + margin: 0; + margin-bottom: 1rem; +} + +@media (max-width: 480px) { + .oneRow { + flex-direction: column; + } +} + +.fullWidth { + width: 100%; +} diff --git a/components/ui-elements/crm-project-details/crm-project-details.tsx b/components/ui-elements/crm-project-details/crm-project-details.tsx new file mode 100644 index 000000000..2bfdd6ca1 --- /dev/null +++ b/components/ui-elements/crm-project-details/crm-project-details.tsx @@ -0,0 +1,132 @@ +import useCrmProject from "@/api/useCrmProject"; +import { FC, FormEvent, useState } from "react"; +import RecordDetails from "../record-details/record-details"; +import SubmitButton from "../submit-button"; +import { + addDaysToDate, + makeRevenueString, + toLocaleDateString, +} from "@/helpers/functional"; +import CrmProjectForm from "./crm-project-form"; +import { CrmProject } from "@/api/useCrmProjects"; +import Link from "next/link"; + +type CrmProjectDetailsProps = { + projectId: string; + crmProjectId: string; + crmProjectDetails?: boolean; +}; + +const makeNewCrmProject = (projectId: string): CrmProject => ({ + id: crypto.randomUUID(), + name: "", + arr: 0, + closeDate: addDaysToDate(60)(new Date()), + projectIds: [projectId], + stage: "Prospect", + tcv: 0, + crmId: "", +}); + +const CrmProjectDetails: FC = ({ + projectId, + crmProjectId, + crmProjectDetails, +}) => { + const { crmProject, createCrmProject } = useCrmProject(crmProjectId); + const [newCrmProject, setNewCrmProject] = useState( + !crmProject ? makeNewCrmProject(projectId) : undefined + ); + + const handleFormSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!newCrmProject) return; + const data = await createCrmProject(newCrmProject); + if (data) setNewCrmProject(makeNewCrmProject(projectId)); + }; + + const getCrmId = (input: string) => { + if (/^006\w{15}$/.test(input)) return input; + const match = input.match(/\/Opportunity\/(006\w{15})\//); + if (match) return match[1]; + return ""; + }; + + const handleUpdateNewProject = (fieldName: string, val: string | Date) => { + if (!newCrmProject) return; + switch (fieldName) { + case "name": + setNewCrmProject({ ...newCrmProject, name: val as string }); + break; + case "arr": + setNewCrmProject({ ...newCrmProject, arr: parseInt(val as string) }); + break; + case "tcv": + setNewCrmProject({ ...newCrmProject, tcv: parseInt(val as string) }); + break; + case "stage": + setNewCrmProject({ ...newCrmProject, stage: val as string }); + break; + case "closeDate": + setNewCrmProject({ ...newCrmProject, closeDate: val as Date }); + break; + case "crmId": + const crmId = getCrmId(val as string); + setNewCrmProject({ ...newCrmProject, crmId }); + break; + + default: + break; + } + }; + + return !crmProject ? ( + +
+ + Create CRM Project + +
+ ) : ( + + CRM:{" "} + {crmProject.crmId && crmProject.crmId.length > 6 ? ( + + {crmProject.name} + + ) : ( + crmProject.name + )}{" "} + (Stage: {crmProject.stage}) + + } + > +
+ {crmProject.arr > 0 && ( +
+ Annual recurring revenue: {makeRevenueString(crmProject.arr)} +
+ )} + {crmProject.tcv > 0 && ( +
Total contract volume: {makeRevenueString(crmProject.tcv)}
+ )} +
Close date: {toLocaleDateString(crmProject.closeDate)}
+ {crmProjectDetails && ( +
+ Projects can not be updated at the moment +
+ )} +
+
+ ); +}; + +export default CrmProjectDetails; diff --git a/components/ui-elements/crm-project-details/crm-project-form.tsx b/components/ui-elements/crm-project-details/crm-project-form.tsx new file mode 100644 index 000000000..7b908fe40 --- /dev/null +++ b/components/ui-elements/crm-project-details/crm-project-form.tsx @@ -0,0 +1,99 @@ +import { CrmProject, crmStages } from "@/api/useCrmProjects"; +import { FC, useState } from "react"; +import styles from "./CrmProjectDetails.module.css"; +import Input from "../form-fields/input"; +import DateSelector from "../date-selector"; +import Select from "react-select"; +import Link from "next/link"; + +type CrmProjectFormProps = { + crmProject?: CrmProject; + onChange: (fieldName: string, val: string | Date) => void; +}; + +const CrmProjectForm: FC = ({ crmProject, onChange }) => { + const [mappedOptions] = useState( + crmStages.map((stage) => ({ value: stage, label: stage })) + ); + const [selectedOption, setSelectedOption] = useState( + mappedOptions.find(({ label }) => label === crmProject?.stage) + ); + + const selectStage = (selectedOption: any) => { + onChange("stage", selectedOption.label); + }; + + return ( + crmProject && ( +
+
+ onChange("name", newVal)} + label="Name" + placeholder="Opportunity Name" + className={styles.fullWidth} + inputClassName={styles.fullWidth} + /> + onChange("crmId", newVal)} + label={ +
+
CRM ID
+ {crmProject.crmId && crmProject.crmId.length > 6 && ( + + Visit CRM + + )} +
+ } + placeholder="Paste Opportunity URL or ID..." + className={styles.fullWidth} + inputClassName={styles.fullWidth} + /> +
+
+ onChange("arr", newval)} + label="Annual Recurring Revenue" + className={styles.fullWidth} + inputClassName={styles.fullWidth} + /> + onChange("tcv", newval)} + label="Total Contract Volume" + className={styles.fullWidth} + inputClassName={styles.fullWidth} + /> +
+
+
+
Stage
+ onChange(event.target.value)} + placeholder={placeholder} + /> +
+ ); +}; + +export default Input; diff --git a/components/ui-elements/notes-writer/NotesWriter.module.css b/components/ui-elements/notes-writer/NotesWriter.module.css index dff6b80b7..44403bea1 100644 --- a/components/ui-elements/notes-writer/NotesWriter.module.css +++ b/components/ui-elements/notes-writer/NotesWriter.module.css @@ -2,13 +2,9 @@ width: 100%; } -.title { - margin: 1rem 1rem 0 1rem; - padding: 0; -} - .editorInput { - padding: 1rem; + margin: -0.5rem; + padding: 0.5rem; border-radius: 0.4rem; background-color: inherit; transition: background-color 1s ease; diff --git a/components/ui-elements/notes-writer/NotesWriter.tsx b/components/ui-elements/notes-writer/NotesWriter.tsx index bccb38c79..0103a4000 100644 --- a/components/ui-elements/notes-writer/NotesWriter.tsx +++ b/components/ui-elements/notes-writer/NotesWriter.tsx @@ -1,4 +1,4 @@ -import React, { FC, useEffect, useState } from "react"; +import React, { FC, ReactNode, useEffect, useState } from "react"; import { createEditor, Descendant } from "slate"; import { withHistory } from "slate-history"; import { Editable, Slate, withReact } from "slate-react"; @@ -9,6 +9,7 @@ import { transformNotesToMd, } from "./notes-writer-helpers"; import styles from "./NotesWriter.module.css"; +import RecordDetails from "../record-details/record-details"; type NotesWriterProps = { notes: string; @@ -19,7 +20,7 @@ type NotesWriterProps = { unsaved?: boolean; autoFocus?: boolean; placeholder?: string; - title?: string; + title?: ReactNode; }; const NotesWriter: FC = ({ @@ -47,8 +48,7 @@ const NotesWriter: FC = ({ }; return ( -
-

{title}

+ = ({ placeholder={placeholder || "Start taking notes..."} /> -
+ ); }; diff --git a/components/ui-elements/project-details/ProjectDetails.module.css b/components/ui-elements/project-details/ProjectDetails.module.css index cc6e62c1b..093373f02 100644 --- a/components/ui-elements/project-details/ProjectDetails.module.css +++ b/components/ui-elements/project-details/ProjectDetails.module.css @@ -1,18 +1,10 @@ .oneRow { display: flex; + flex-direction: row; gap: 1rem; width: 100%; - border: 1px solid #ccc; - border-radius: 0.4rem; - margin-bottom: 1rem; -} - -.accounts { - width: 100%; - border: 1px solid #ccc; - border-radius: 0.4rem; - margin-bottom: 1rem; - font-size: 1.4rem; + padding: 0; + margin: 0; } .wrapper { @@ -24,17 +16,11 @@ } .title { - margin: 1rem 1rem 0 1rem; - padding: 0; -} - -.text { - margin: 1rem; - padding: 0; + margin: 0; } @media (max-width: 640px) { .oneRow { - display: block; + flex-direction: column; } } diff --git a/components/ui-elements/project-details/next-actions.tsx b/components/ui-elements/project-details/next-actions.tsx index 6e1fac32b..c73d1290a 100644 --- a/components/ui-elements/project-details/next-actions.tsx +++ b/components/ui-elements/project-details/next-actions.tsx @@ -1,4 +1,4 @@ -import { FC, useState } from "react"; +import { FC, ReactNode, useState } from "react"; import NotesWriter from "../notes-writer/NotesWriter"; import { Descendant } from "slate"; import { TransformNotesToMdFunction } from "../notes-writer/notes-writer-helpers"; @@ -12,7 +12,7 @@ type NextActionsProps = { }; type NextActionHelperProps = { - title: string; + title: ReactNode; actions: string; saveFn: (actions: string) => Promise; }; diff --git a/components/ui-elements/project-details/project-dates.tsx b/components/ui-elements/project-details/project-dates.tsx index 9f1a41951..5a5ebad12 100644 --- a/components/ui-elements/project-details/project-dates.tsx +++ b/components/ui-elements/project-details/project-dates.tsx @@ -26,9 +26,7 @@ const ProjectDatesHelper: FC = ({ return (

{title}

-
- -
+
); }; diff --git a/components/ui-elements/project-details/project-details.tsx b/components/ui-elements/project-details/project-details.tsx index 754050ad8..bd61950d3 100644 --- a/components/ui-elements/project-details/project-details.tsx +++ b/components/ui-elements/project-details/project-details.tsx @@ -4,32 +4,45 @@ import ProjectDates from "./project-dates"; import NextActions from "./next-actions"; import AccountName from "../tokens/account-name"; import SavedState from "../project-notes-form/saved-state"; -import styles from "./ProjectDetails.module.css"; import AccountSelector from "../account-selector"; +import SelectionSlider from "../selection-slider/selection-slider"; +import { Context } from "@/contexts/ContextContext"; +import ContextWarning from "../context-warning/context-warning"; +import RecordDetails from "../record-details/record-details"; +import { contexts } from "@/components/navigation-menu/ContextSwitcher"; +import CrmProjectDetails from "../crm-project-details/crm-project-details"; type ProjectDetailsProps = { projectId: string; includeAccounts?: boolean; + showContext?: boolean; + showCrmDetails?: boolean; }; const ProjectDetails: FC = ({ projectId, includeAccounts, + showContext, + showCrmDetails, }) => { const { getProjectById, saveNextActions, saveProjectDates, addAccountToProject, + updateProjectContext, } = useProjectsContext(); const [project, setProject] = useState( projectId ? getProjectById(projectId) : undefined ); + const [projectContext, setProjectContext] = useState(project?.context); const [detailsSaved, setDetailsSaved] = useState(true); + const [newCrmProjectId, setNewCrmProjectId] = useState(crypto.randomUUID()); useEffect(() => { setProject(getProjectById(projectId)); - }, [getProjectById, projectId]); + setProjectContext(project?.context); + }, [getProjectById, project, projectId]); const handleDateChange = async (props: { dueOn?: Date | undefined; @@ -52,12 +65,45 @@ const ProjectDetails: FC = ({ return data; }; + const updateContext = async (context: Context) => { + if (!project) return; + setDetailsSaved(false); + setProjectContext(context); + const data = await updateProjectContext(project.id, context); + if (data) setDetailsSaved(true); + }; + return ( project && (
+ {showContext && ( + + + + + )} + + {(!showCrmDetails + ? project.crmProjectIds + : [ + ...project.crmProjectIds.filter((id) => id !== newCrmProjectId), + newCrmProjectId, + ] + ).map((id) => ( + + ))} + {includeAccounts && ( -
-

Accounts

+ {project.accountIds.map((accountId) => ( ))} @@ -65,14 +111,19 @@ const ProjectDetails: FC = ({ allowCreateAccounts onChange={handleSelectAccount} /> -
+ )} - + + + + + saveNextActions(project.id, own, others)} /> +
) diff --git a/components/ui-elements/project-notes-form/project-notes-form.tsx b/components/ui-elements/project-notes-form/project-notes-form.tsx index 1997b74c4..5c7ad7ca7 100644 --- a/components/ui-elements/project-notes-form/project-notes-form.tsx +++ b/components/ui-elements/project-notes-form/project-notes-form.tsx @@ -9,6 +9,7 @@ import { TransformNotesToMdFunction } from "../notes-writer/notes-writer-helpers import ActivityMetaData from "../activity-meta-data"; import { debouncedUpdateNotes } from "../activity-helper"; import ProjectDetails from "../project-details/project-details"; +import RecordDetails from "../record-details/record-details"; type ProjectNotesFormProps = { className?: string; @@ -54,13 +55,16 @@ const ProjectNotesForm: FC = ({ return (
- {activity?.projectIds.map((id) => ( -
- - -
- ))} - + + {activity?.projectIds.map((id) => ( +
+ + +
+ ))} + +
+ = ({ + className, + title, + children, + contentClassName, +}) => { + return ( +
+ {title &&

{title}

} +
+ {children} +
+
+ ); +}; + +export default RecordDetails; diff --git a/docs/releases/next.md b/docs/releases/next.md index 2ecf7bbf4..19086a7ca 100644 --- a/docs/releases/next.md +++ b/docs/releases/next.md @@ -1,5 +1,11 @@ -# Tagespläne und Meetings vollständig laden (Version :VERSION) +# Kontexte von Meetings und Projekten ändern und Projekten CRM Projekte verlinken (Version :VERSION) -## Fehlerbehebungen +## Kontexte von Meetings und Projekten ändern -Es wurden nicht alle Tagespläne und Meetings geladen, da wir im Moment einen Table Scan durchführen und nur bis zu 100 Datensätze prüfen und dann einen `nextToken` erhalten. Den verwenden wir jetzt, um weitere Datensätze zu laden, bis kein `nextToken` mehr zurück gegeben wird. +Kontexte von Meetings und Projekten können nun nachträglich geändert werden. Wenn der Kontext geändert wird, erscheint eine Warnung, dass das Projekt beim nächsten Laden verschwinden wird und es wird empfohlen, in den Kontext zu wechseln. Das liegt daran, dass wir die Projekte abhängig vom Kontext zentral laden, so dass sie an verschiedenen Stellen in der App zur Verfügung stehen. Wenn nun der Kontext eines Projekts gewechselt wird, wird es auch aus dieser Liste gelöscht. + +Bei Meetings besteht dieses Problem nicht, da Meetings immer nach der ID geladen werden. Das Meeting verschwindet dann nur aus der Listenansicht, aber das ist auch erwartet. + +## CRM Projekte verlinken + +In der Projektliste werden verlinkte Projekte im CRM System angezeigt. In der Detailansicht der Projekte können neue CRM Projekte angelegt/verlinkt werden. Es ist im Moment noch nicht möglich, CRM Projekte zu editieren. diff --git a/helpers/functional.ts b/helpers/functional.ts index 2a0dccdec..a4547cb88 100644 --- a/helpers/functional.ts +++ b/helpers/functional.ts @@ -67,3 +67,9 @@ export const sortByDate = }; export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +export const makeRevenueString = (revenue: number) => { + const inM = revenue > 800000; + const val = Math.round((revenue / (inM ? 1000000 : 1000)) * 100) / 100; + const label = inM ? "M" : "k"; + return `$${val}${label}`; +}; diff --git a/pages/_app.tsx b/pages/_app.tsx index aebf9ea9c..b641bdb25 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -32,7 +32,7 @@ function App(appProps: AppProps) { contextLocalStorage}> diff --git a/pages/_document.tsx b/pages/_document.tsx index fb58c0474..d7a41ab83 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -7,10 +7,6 @@ export default function Document() { -
diff --git a/pages/crm-projects/index.tsx b/pages/crm-projects/index.tsx new file mode 100644 index 000000000..328a08b98 --- /dev/null +++ b/pages/crm-projects/index.tsx @@ -0,0 +1,15 @@ +import useCrmProjects from "@/api/useCrmProjects"; +import MainLayout from "@/components/layouts/MainLayout"; + +const CrmProjectsPage = () => { + const { crmProjects } = useCrmProjects(); + return ( + + {crmProjects?.map(({ id, name }) => ( +
{name}
+ ))} +
+ ); +}; + +export default CrmProjectsPage; diff --git a/pages/meetings/[id].tsx b/pages/meetings/[id].tsx index b69081c54..868cbe14b 100644 --- a/pages/meetings/[id].tsx +++ b/pages/meetings/[id].tsx @@ -9,6 +9,11 @@ import ProjectNotesForm from "@/components/ui-elements/project-notes-form/projec import { useEffect, useMemo, useState } from "react"; import SavedState from "@/components/ui-elements/project-notes-form/saved-state"; import { debounce } from "lodash"; +import RecordDetails from "@/components/ui-elements/record-details/record-details"; +import SelectionSlider from "@/components/ui-elements/selection-slider/selection-slider"; +import { contexts } from "@/components/navigation-menu/ContextSwitcher"; +import { Context } from "@/contexts/ContextContext"; +import ContextWarning from "@/components/ui-elements/context-warning/context-warning"; const MeetingDetailPage = () => { const router = useRouter(); @@ -20,22 +25,26 @@ const MeetingDetailPage = () => { updateMeeting, createMeetingParticipant, createMeetingActivity, + updateMeetingContext, } = useMeeting(meetingId); const [meetingDate, setMeetingDate] = useState( meeting?.meetingOn || new Date() ); + const [meetingContext, setMeetingContext] = useState(meeting?.context); const [dateTitleSaved, setDateTitleSaved] = useState(true); const [participantsSaved, setParticipantsSaved] = useState(true); + const [contextSaved, setContextSaved] = useState(true); const [allSaved, setAllSaved] = useState(true); const [newActivityId, setNewActivityId] = useState(crypto.randomUUID()); useEffect(() => { - setAllSaved(dateTitleSaved && participantsSaved); - }, [dateTitleSaved, participantsSaved]); + setAllSaved(dateTitleSaved && participantsSaved && contextSaved); + }, [dateTitleSaved, participantsSaved, contextSaved]); useEffect(() => { if (!meeting) return; setMeetingDate(meeting.meetingOn); + setMeetingContext(meeting.context); }, [meeting]); const debouncedUpdateMeeting = useMemo( @@ -86,6 +95,14 @@ const MeetingDetailPage = () => { return data; }; + const updateContext = async (context: Context) => { + if (!meeting) return; + setContextSaved(false); + setMeetingContext(context); + const data = await updateMeetingContext(context); + if (data) setContextSaved(true); + }; + return ( { {meeting && (
-

Meeting on:

- -

Participants:

- {meeting.participantIds.map((id) => ( - - ))} - - + + + + + + + + + + + + {meeting.participantIds.map((id) => ( + + ))} + + + + {[ ...meeting.activityIds.filter((id) => id !== newActivityId), newActivityId, diff --git a/pages/projects/[id].tsx b/pages/projects/[id].tsx index ba0aa0a5b..df3387ce8 100644 --- a/pages/projects/[id].tsx +++ b/pages/projects/[id].tsx @@ -82,7 +82,12 @@ const ProjectDetailPage = () => {
{projectId && ( - + )} {[newActivityId, ...(project?.activityIds || [])].map((id) => (