diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f66f9fd3..3116541ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,6 +43,7 @@ jobs: - name: Commit changes run: | + VERSION=$(node -p "require('./package.json').version") git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add docs/releases/v*.md package.json diff --git a/.gitignore b/.gitignore index fea90efc1..e73883300 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ next-env.d.ts amplifyconfiguration* components/import-data/* !components/import-data/imports-aws-sdk + +scripts/temp diff --git a/README.md b/README.md index 3b5cdcd75..91e8252e5 100644 --- a/README.md +++ b/README.md @@ -1 +1,7 @@ [![Release Workflow](https://github.com/cabcookie/personal-crm/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/cabcookie/personal-crm/actions/workflows/release.yml) + +Make sure you add a `.env.local` file in the root directory with the content: + +``` +NEXT_PUBLIC_ALLOW_FAKE_DATA_CREATION=true +``` diff --git a/amplify.yml b/amplify.yml index 9e1cd440e..74901faf8 100644 --- a/amplify.yml +++ b/amplify.yml @@ -13,6 +13,7 @@ frontend: - npm ci build: commands: + - echo "NEXT_PUBLIC_ALLOW_FAKE_DATA_CREATION=$NEXT_PUBLIC_ALLOW_FAKE_DATA_CREATION" >> .env - npm run build artifacts: baseDirectory: .next diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index 172dc8828..4f1481281 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -136,6 +136,17 @@ const schema = a.schema({ projects: a.belongsTo("Projects", "projectsId"), }) .authorization((allow) => [allow.owner()]), + AccountResponsibilities: a + .model({ + owner: a + .string() + .authorization((allow) => [allow.owner().to(["read", "delete"])]), + accountId: a.id().required(), + account: a.belongsTo("Account", "accountId"), + startDate: a.date().required(), + endDate: a.date(), + }) + .authorization((allow) => [allow.owner()]), Account: a .model({ owner: a @@ -143,11 +154,13 @@ const schema = a.schema({ .authorization((allow) => [allow.owner().to(["read", "delete"])]), notionId: a.integer(), name: a.string().required(), + order: a.integer(), introduction: a.string(), subsidiaries: a.hasMany("Account", "accountSubsidiariesId"), projects: a.hasMany("AccountProjects", "accountId"), accountSubsidiariesId: a.id(), controller: a.belongsTo("Account", "accountSubsidiariesId"), + responsibilities: a.hasMany("AccountResponsibilities", "accountId"), }) .authorization((allow) => [allow.owner()]), SixWeekCycle: a diff --git a/api/ContextAccounts.tsx b/api/ContextAccounts.tsx new file mode 100644 index 000000000..ef614b14e --- /dev/null +++ b/api/ContextAccounts.tsx @@ -0,0 +1,156 @@ +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 { handleApiErrors } from "./globals"; +const client = generateClient(); + +interface AccountsContextType { + accounts: Account[] | undefined; + errorAccounts: any; + loadingAccounts: boolean; + createAccount: ( + accountName: string + ) => Promise; + getAccountById: (accountId: string) => Account | undefined; + saveAccountName: ( + accountId: string, + accountName: string + ) => Promise; +} + +export type Account = { + id: string; + name: string; + introduction: string; + controllerId?: string; + order: number; + responsibilities: { startDate: Date; endDate?: Date }[]; +}; + +const selectionSet = [ + "id", + "name", + "accountSubsidiariesId", + "introduction", + "order", + "responsibilities.startDate", + "responsibilities.endDate", +] as const; + +type AccountData = SelectionSet; + +export const mapAccount: (account: AccountData) => Account = ({ + id, + name, + accountSubsidiariesId, + introduction, + order, + responsibilities, +}) => ({ + id, + name, + introduction: introduction || "", + controllerId: accountSubsidiariesId || undefined, + order: order || 0, + responsibilities: responsibilities + .map(({ startDate, endDate }) => ({ + startDate: new Date(startDate), + endDate: !endDate ? undefined : new Date(endDate), + })) + .sort((a, b) => b.startDate.getTime() - a.startDate.getTime()), +}); + +const fetchAccounts = async () => { + const { data, errors } = await client.models.Account.list({ + limit: 500, + selectionSet, + }); + if (errors) throw errors; + return data.map(mapAccount).sort((a, b) => a.order - b.order); +}; + +interface AccountsContextProviderProps { + children: ReactNode; +} + +export const AccountsContextProvider: FC = ({ + children, +}) => { + const { + data: accounts, + error: errorAccounts, + isLoading: loadingAccounts, + mutate, + } = useSWR(`/api/accounts/`, fetchAccounts); + + const createAccount = async ( + accountName: string + ): Promise => { + if (accountName.length < 3) return; + + const newAccount: Account = { + id: crypto.randomUUID(), + name: accountName, + introduction: "", + order: 0, + responsibilities: [], + }; + + const updatedAccounts = [...(accounts || []), newAccount]; + mutate(updatedAccounts, false); + + const { data, errors } = await client.models.Account.create({ + name: accountName, + }); + if (errors) handleApiErrors(errors, "Error creating account"); + mutate(updatedAccounts); + return data; + }; + + const getAccountById = (accountId: string) => + accounts?.find((account) => account.id === accountId); + + const saveAccountName = async (accountId: string, accountName: string) => { + const updated: Account[] = + accounts?.map((a) => + a.id !== accountId ? a : { ...a, name: accountName } + ) || []; + mutate(updated, false); + const { data, errors } = await client.models.Account.update({ + id: accountId, + name: accountName, + }); + if (errors) handleApiErrors(errors, "Error updating account"); + mutate(updated); + return data?.id; + }; + + return ( + + {children} + + ); +}; + +const AccountsContext = createContext( + undefined +); + +export const useAccountsContext = () => { + const accounts = useContext(AccountsContext); + if (!accounts) + throw new Error( + "useAccountsContext must be used within AccountsContextProvider" + ); + return accounts; +}; diff --git a/api/ContextProjects.tsx b/api/ContextProjects.tsx index 0a7c39377..4858036e1 100644 --- a/api/ContextProjects.tsx +++ b/api/ContextProjects.tsx @@ -4,6 +4,7 @@ import { SelectionSet, generateClient } from "aws-amplify/data"; import useSWR from "swr"; import { Context } from "@/contexts/ContextContext"; import { handleApiErrors } from "./globals"; +import { addDaysToDate } from "@/helpers/functional"; const client = generateClient(); interface ProjectsContextType { @@ -23,6 +24,24 @@ interface ProjectsContextType { myNextActions: string, othersNextActions: string ) => Promise; + saveProjectName: ( + projectId: string, + projectName: string + ) => Promise; + saveProjectDates: (props: { + projectId: string; + dueDate?: Date; + doneOn?: Date; + onHoldTill?: Date; + }) => Promise; + updateProjectState: ( + projectId: string, + done: boolean + ) => Promise; + addAccountToProject: ( + projectId: string, + accountId: string + ) => Promise; } export type Project = { @@ -50,6 +69,7 @@ const selectionSet = [ "othersNextActions", "context", "accounts.accountId", + "accounts.createdAt", "activities.activity.id", "activities.activity.finishedOn", "activities.activity.createdAt", @@ -79,7 +99,13 @@ export const mapProject: (project: ProjectData) => Project = ({ myNextActions: myNextActions || "", othersNextActions: othersNextActions || "", context, - accountIds: accounts.map(({ accountId }) => accountId), + accountIds: accounts + .map(({ accountId, createdAt }) => ({ + accountId, + createdAt: new Date(createdAt), + })) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .map(({ accountId }) => accountId), activityIds: activities .filter(({ activity }) => !!activity) .map(({ activity: { id, createdAt, finishedOn } }) => ({ @@ -93,7 +119,17 @@ export const mapProject: (project: ProjectData) => Project = ({ const fetchProjects = (context?: Context) => async () => { if (!context) return; const { data, errors } = await client.models.Projects.list({ - filter: { context: { eq: context }, done: { ne: "true" } }, + filter: { + context: { eq: context }, + or: [ + { done: { ne: "true" } }, + { + doneOn: { + ge: addDaysToDate(-90)(new Date()).toISOString().split("T")[0], + }, + }, + ], + }, limit: 500, selectionSet, }); @@ -173,27 +209,107 @@ export const ProjectsContextProvider: FC = ({ return data.activityId; }; - const saveNextActions = async ( + type UpdateProjectProps = { + id: string; + project?: string; + dueOn?: Date; + doneOn?: Date | null; + onHoldTill?: Date; + myNextActions?: string; + othersNextActions?: string; + done?: boolean; + }; + + const updateProject = async ({ + id, + project, + done, + doneOn, + dueOn, + onHoldTill, + myNextActions, + othersNextActions, + }: UpdateProjectProps) => { + const updated: Project[] = + projects?.map((p) => + p.id !== id + ? p + : { + ...p, + project: !project ? p.project : project, + done: done === undefined ? p.done : done, + doneOn: !doneOn ? p.doneOn : doneOn, + dueOn: !dueOn ? p.dueOn : dueOn, + onHoldTill: !onHoldTill ? p.onHoldTill : onHoldTill, + myNextActions: !myNextActions ? p.myNextActions : myNextActions, + othersNextActions: !othersNextActions + ? p.othersNextActions + : othersNextActions, + } + ) || []; + mutate(updated, false); + const newProject = { + id, + project, + done, + myNextActions, + othersNextActions, + dueOn: dueOn ? dueOn.toISOString().split("T")[0] : undefined, + doneOn: + done === undefined + ? undefined + : doneOn + ? doneOn.toISOString().split("T")[0] + : null, + onHoldTill: onHoldTill + ? onHoldTill.toISOString().split("T")[0] + : undefined, + }; + const { data, errors } = await client.models.Projects.update(newProject); + if (errors) handleApiErrors(errors, "Error updating project"); + mutate(updated); + return data?.id; + }; + + const saveNextActions = ( projectId: string, myNextActions: string, othersNextActions: string - ) => { + ) => updateProject({ id: projectId, myNextActions, othersNextActions }); + + const saveProjectName = (projectId: string, projectName: string) => + updateProject({ id: projectId, project: projectName }); + + const saveProjectDates = ({ + projectId, + dueOn, + doneOn, + onHoldTill, + }: { + projectId: string; + dueOn?: Date; + doneOn?: Date; + onHoldTill?: Date; + }) => updateProject({ id: projectId, dueOn, onHoldTill, doneOn }); + + const updateProjectState = (projectId: string, done: boolean) => + updateProject({ id: projectId, done, doneOn: done ? new Date() : null }); + + const addAccountToProject = async (projectId: string, accountId: string) => { const updated: Project[] = - projects?.map((project) => - project.id !== projectId - ? project - : { ...project, myNextActions, othersNextActions } + projects?.map((p) => + p.id !== projectId + ? p + : { ...p, accountIds: [...p.accountIds, accountId] } ) || []; - mutate(updated, false); - const { data, errors } = await client.models.Projects.update({ - id: projectId, - myNextActions, - othersNextActions, + const { data, errors } = await client.models.AccountProjects.create({ + projectsId: projectId, + accountId, }); - if (errors) handleApiErrors(errors, "Error saving project's next actions"); + if (errors) handleApiErrors(errors, "Error adding account to project"); mutate(updated); - return data.id; + return data?.id; }; return ( @@ -206,6 +322,10 @@ export const ProjectsContextProvider: FC = ({ getProjectById, createProjectActivity, saveNextActions, + saveProjectName, + saveProjectDates, + updateProjectState, + addAccountToProject, }} > {children} diff --git a/api/useAccount.ts b/api/useAccount.ts deleted file mode 100644 index dbb10bc91..000000000 --- a/api/useAccount.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type Schema } from "@/amplify/data/resource"; -import { generateClient } from "aws-amplify/data"; -import useSWR from "swr"; - -const client = generateClient(); - -type Account = { - id: string; - name: string; -}; - -const mapAccount: (account: Schema["Account"]) => Account = ({ id, name }) => ({ - id, - name, -}); - -const fetchAccount = (accountId?: string) => async () => { - if (!accountId) return; - const { data, errors } = await client.models.Account.get({ id: accountId }); - if (errors) throw errors; - return mapAccount(data); -}; - -const useAccount = (accountId?: string) => { - const { - data: account, - error: errorAccount, - isLoading: loadingAccount, - } = useSWR(`/api/account/${accountId}`, fetchAccount(accountId)); - - return { account, errorAccount, loadingAccount }; -}; - -export default useAccount; diff --git a/api/useMeeting.ts b/api/useMeeting.ts index 65b4371d6..5ec92c44d 100644 --- a/api/useMeeting.ts +++ b/api/useMeeting.ts @@ -6,7 +6,6 @@ import { handleApiErrors } from "./globals"; const client = generateClient(); type MeetingUpdateProps = { - meetingId: string; meetingOn: Date; title: string; }; @@ -29,21 +28,12 @@ const useMeeting = (meetingId?: string) => { mutate: mutateMeeting, } = useSWR(`/api/meetings/${meetingId}`, fetchMeeting(meetingId)); - const updateMeeting = async ({ - meetingId, - meetingOn, - title, - }: MeetingUpdateProps) => { - const updated: Meeting = { - id: meetingId, - topic: title, - meetingOn, - participantIds: [], - activityIds: [], - }; + const updateMeeting = async ({ meetingOn, title }: MeetingUpdateProps) => { + if (!meeting) return; + const updated: Meeting = { ...meeting, topic: title, meetingOn }; mutateMeeting(updated, false); const { data, errors } = await client.models.Meeting.update({ - id: meetingId, + id: meeting.id, topic: title, meetingOn: meetingOn.toISOString(), }); diff --git a/components/dayplan/dayplan-form.tsx b/components/dayplan/dayplan-form.tsx index 32f30d6f1..95c247417 100644 --- a/components/dayplan/dayplan-form.tsx +++ b/components/dayplan/dayplan-form.tsx @@ -33,7 +33,7 @@ const DayPlanForm: FC = ({ onSubmit }) => { /> - + Confirm ); diff --git a/components/ui-elements/DateSelector.module.css b/components/ui-elements/DateSelector.module.css index 5f6fd375f..7d649d998 100644 --- a/components/ui-elements/DateSelector.module.css +++ b/components/ui-elements/DateSelector.module.css @@ -1,17 +1,4 @@ -.dateChanger { - cursor: pointer; - font-size: var(--font-size-small); - padding-top: 0.5rem; - padding-bottom: 0.5rem; - width: 5rem; - border-radius: 1rem; - border: none; - margin-right: 0.4rem; - background-color: var(--color-btn); -} -.date { - border: 1px solid var(--color-btn); - margin-right: 0.4rem; - padding: 0.6rem; - border-radius: 1rem; +.picker { + border: 1px solid #ccc; + border-radius: 0.4rem; } diff --git a/components/ui-elements/account-details/account-updates-helpers.ts b/components/ui-elements/account-details/account-updates-helpers.ts new file mode 100644 index 000000000..ee3591808 --- /dev/null +++ b/components/ui-elements/account-details/account-updates-helpers.ts @@ -0,0 +1,23 @@ +import { debounce } from "lodash"; + +type UpdateFnProps = { + id: string; + name?: string; +}; + +type UpdateAccountDetailsProps = UpdateFnProps & { + updateAccountFn: (props: UpdateFnProps) => Promise; + setSaveStatus: (status: boolean) => void; +}; + +export const debouncedUpdateAccountDetails = debounce( + async ({ + updateAccountFn, + setSaveStatus, + ...props + }: UpdateAccountDetailsProps) => { + const data = await updateAccountFn(props); + if (data) setSaveStatus(true); + }, + 1500 +); diff --git a/components/ui-elements/account-selector.tsx b/components/ui-elements/account-selector.tsx new file mode 100644 index 000000000..e4ababa5b --- /dev/null +++ b/components/ui-elements/account-selector.tsx @@ -0,0 +1,91 @@ +import { FC, ReactNode, useEffect, useState } from "react"; +import Select from "react-select"; +import CreatableSelect from "react-select/creatable"; +import AccountName from "./tokens/account-name"; +import { useAccountsContext } from "@/api/ContextAccounts"; + +type AccountSelectorProps = { + allowCreateAccounts?: boolean; + clearAfterSelection?: boolean; + onChange: (accountId: string | null) => void; +}; + +type AccountListOption = { + value: string; + label: ReactNode; +}; + +const AccountSelector: FC = ({ + allowCreateAccounts, + onChange, + clearAfterSelection, +}) => { + const { accounts, createAccount, loadingAccounts } = useAccountsContext(); + const [mappedOptions, setMappedOptions] = useState< + AccountListOption[] | undefined + >(); + const [selectedOption, setSelectedOption] = useState(null); + + useEffect(() => { + setMappedOptions( + accounts?.map((account) => ({ + value: account.id, + label: , + })) + ); + }, [accounts]); + + const selectAccount = async (selectedOption: any) => { + if (!(allowCreateAccounts && selectedOption.__isNew__)) { + onChange(selectedOption.value); + if (clearAfterSelection) setSelectedOption(null); + return; + } + const account = await createAccount(selectedOption.label); + if (account) onChange(account.id); + if (clearAfterSelection) setSelectedOption(null); + }; + + const filterAccounts = (accountId: string, input: string) => + !!accounts + ?.find(({ id }) => id === accountId) + ?.name.toLowerCase() + .includes(input.toLowerCase()); + + return ( +
+ {!allowCreateAccounts ? ( +