diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index 1ad59697b..7a57cab48 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -1,4 +1,4 @@ -import { type ClientSchema, a, defineData } from "@aws-amplify/backend"; +import { a, defineData, type ClientSchema } from "@aws-amplify/backend"; const schema = a.schema({ Context: a.enum(["family", "hobby", "work"]), @@ -18,9 +18,10 @@ const schema = a.schema({ note: a.string(), formatVersion: a.integer().default(1), noteJson: a.json(), - done: a.id().required(), + status: a.id().required(), + movedToActivityId: a.string(), }) - .secondaryIndexes((inbox) => [inbox("done")]) + .secondaryIndexes((inbox) => [inbox("status")]) .authorization((allow) => [allow.owner()]), DayPlan: a .model({ diff --git a/api/useCrmProjects.ts b/api/useCrmProjects.ts index 440c01409..5fbedcdfe 100644 --- a/api/useCrmProjects.ts +++ b/api/useCrmProjects.ts @@ -1,12 +1,12 @@ 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"; +import { SelectionSet, generateClient } from "aws-amplify/data"; +import { flow } from "lodash/fp"; +import useSWR from "swr"; const client = generateClient(); export type CrmStage = @@ -126,7 +126,6 @@ const useCrmProjects = () => { data: crmProjects, error: errorCrmProjects, isLoading: loadingCrmProjects, - mutate, } = useSWR("/api/crm-projects/", fetchCrmProjects); return { crmProjects, errorCrmProjects, loadingCrmProjects }; diff --git a/api/useInbox.tsx b/api/useInbox.tsx index 11581978c..b442d138c 100644 --- a/api/useInbox.tsx +++ b/api/useInbox.tsx @@ -8,9 +8,37 @@ import useSWR from "swr"; import { handleApiErrors } from "./globals"; const client = generateClient(); +export type InboxStatus = + | "new" + | "actionable" + | "notActionable" + | "doNow" + | "moveToProject" + | "clarifyDeletion" + | "clarifyAction" + | "done"; + +export const STATUS_LIST: InboxStatus[] = [ + "new", + "actionable", + "notActionable", + "doNow", + "moveToProject", + "clarifyAction", + "clarifyDeletion", + "done", +]; + +export const isValidInboxStatus = (status: string): status is InboxStatus => + STATUS_LIST.includes(status as InboxStatus); + +const mapStatus = (status: string): InboxStatus => + isValidInboxStatus(status) ? status : "new"; + export type Inbox = { id: string; note: EditorJsonContent; + status: InboxStatus; createdAt: Date; }; @@ -21,9 +49,11 @@ const mapInbox: MapInboxFn = ({ note, createdAt, formatVersion, + status, noteJson, }) => ({ id, + status: mapStatus(status), note: transformNotesVersion({ version: formatVersion, notes: note, @@ -32,9 +62,9 @@ const mapInbox: MapInboxFn = ({ createdAt: new Date(createdAt), }); -const fetchInbox = async () => { - const { data, errors } = await client.models.Inbox.listInboxByDone({ - done: "false", +const fetchInbox = (status: InboxStatus) => async () => { + const { data, errors } = await client.models.Inbox.listInboxByStatus({ + status, }); if (errors) throw errors; return data @@ -42,12 +72,12 @@ const fetchInbox = async () => { .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); }; -const useInbox = () => { +const useInbox = (status?: InboxStatus) => { const { data: inbox, error: errorInbox, mutate, - } = useSWR("/api/inbox", fetchInbox); + } = useSWR(`/api/inbox/${status}`, fetchInbox(status || "new")); const createInbox = async (note: EditorJsonContent) => { const updated: Inbox[] = [ @@ -55,6 +85,7 @@ const useInbox = () => { { id: crypto.randomUUID(), note, + status: "new", createdAt: new Date(), }, ]; @@ -63,25 +94,13 @@ const useInbox = () => { noteJson: JSON.stringify(note), note: null, formatVersion: 2, - done: "false", + status: "new", }); if (errors) handleApiErrors(errors, "Error creating inbox item"); mutate(updated); return data?.id; }; - const finishItem = async (id: string) => { - const updated = inbox?.filter((item) => item.id !== id); - mutate(updated, false); - const { data, errors } = await client.models.Inbox.update({ - id, - done: "true", - }); - if (errors) handleApiErrors(errors, "Error updating status of inbox item"); - mutate(updated); - return data?.id; - }; - const updateNote = async (id: string, note: EditorJsonContent) => { const updated = inbox?.map((item) => item.id !== id ? item : { ...item, note } @@ -98,7 +117,81 @@ const useInbox = () => { return data?.id; }; - return { inbox, errorInbox, createInbox, finishItem, updateNote }; + const moveInboxItemToProject = async ( + inboxId: string, + createdAt: Date, + notes: EditorJsonContent, + projectId: string + ) => { + const { data: activity, errors: activityErrors } = + await client.models.Activity.create({ + finishedOn: createdAt.toISOString(), + formatVersion: 2, + notes: null, + notesJson: JSON.stringify(notes), + }); + if (activityErrors) + return handleApiErrors( + activityErrors, + "Error creating activity with inbox notes" + ); + if (!activity) return; + + const { errors: projectActivityErrors } = + await client.models.ProjectActivity.create({ + activityId: activity.id, + projectsId: projectId, + }); + if (projectActivityErrors) + return handleApiErrors( + projectActivityErrors, + "Error linking activity with project" + ); + + const { data, errors } = await client.models.Inbox.update({ + id: inboxId, + status: "done", + movedToActivityId: activity.id, + }); + if (errors) + return handleApiErrors(errors, "Error updating status of inbox item"); + + return data?.id; + }; + + const updateStatus = async (id: string, status: InboxStatus) => { + const updated = inbox?.map((item) => + item.id !== id ? item : { ...item, status } + ); + mutate(updated, false); + const { data, errors } = await client.models.Inbox.update({ id, status }); + if (errors) handleApiErrors(errors, "Can't update status"); + mutate(updated); + return data?.id; + }; + + const makeActionable = (id: string) => updateStatus(id, "actionable"); + const makeNonActionable = (id: string) => updateStatus(id, "notActionable"); + const doNow = (id: string) => updateStatus(id, "doNow"); + const clarifyAction = (id: string) => updateStatus(id, "clarifyAction"); + const isDone = (id: string) => updateStatus(id, "done"); + const moveToProject = (id: string) => updateStatus(id, "moveToProject"); + const clarifyDeletion = (id: string) => updateStatus(id, "clarifyDeletion"); + + return { + inbox, + errorInbox, + createInbox, + updateNote, + makeActionable, + makeNonActionable, + doNow, + clarifyAction, + isDone, + moveToProject, + clarifyDeletion, + moveInboxItemToProject, + }; }; export default useInbox; diff --git a/components/CategoryTitle.tsx b/components/CategoryTitle.tsx index 3ca835886..479fe9d8b 100644 --- a/components/CategoryTitle.tsx +++ b/components/CategoryTitle.tsx @@ -1,8 +1,8 @@ -import { IoChevronBackOutline } from "react-icons/io5"; -import styles from "./CategoryTitle.module.css"; import { useRouter } from "next/router"; import { ChangeEvent, FC, useEffect, useRef, useState } from "react"; -import SubmitButton from "./ui-elements/submit-button"; +import { IoChevronBackOutline } from "react-icons/io5"; +import styles from "./CategoryTitle.module.css"; +import SubmitButton from "./ui-elements/buttons/submit-button"; export type CategoryTitleProps = { title?: string; diff --git a/components/dayplan/dayplan-form.tsx b/components/dayplan/dayplan-form.tsx index 95c247417..b2a2474c7 100644 --- a/components/dayplan/dayplan-form.tsx +++ b/components/dayplan/dayplan-form.tsx @@ -1,6 +1,6 @@ import { FC, FormEvent, useState } from "react"; +import SubmitButton from "../ui-elements/buttons/submit-button"; import DateSelector from "../ui-elements/date-selector"; -import SubmitButton from "../ui-elements/submit-button"; import styles from "./DayPlan.module.css"; type DayPlanFormProps = { diff --git a/components/dayplan/task.tsx b/components/dayplan/task.tsx index 52ad0e7ec..13ad75a7f 100644 --- a/components/dayplan/task.tsx +++ b/components/dayplan/task.tsx @@ -1,11 +1,11 @@ import { type Schema } from "@/amplify/data/resource"; +import { DayPlanTodo } from "@/api/useDayplans"; import { FC, FormEvent, useState } from "react"; -import styles from "./Task.module.css"; import { IoCheckboxSharp, IoSquareOutline } from "react-icons/io5"; -import ProjectName from "../ui-elements/tokens/project-name"; -import { DayPlanTodo } from "@/api/useDayplans"; +import SubmitButton from "../ui-elements/buttons/submit-button"; import ProjectSelector from "../ui-elements/project-selector"; -import SubmitButton from "../ui-elements/submit-button"; +import ProjectName from "../ui-elements/tokens/project-name"; +import styles from "./Task.module.css"; type TaskProps = { todo: DayPlanTodo; diff --git a/components/ui-elements/SubmitButton.module.css b/components/ui-elements/buttons/Button.module.css similarity index 80% rename from components/ui-elements/SubmitButton.module.css rename to components/ui-elements/buttons/Button.module.css index c5ffedbbd..a002e02f1 100644 --- a/components/ui-elements/SubmitButton.module.css +++ b/components/ui-elements/buttons/Button.module.css @@ -13,3 +13,8 @@ .button:hover { box-shadow: 1px 1px 6px rgba(40, 40, 40, 0.4); } + +.noDecoration { + text-decoration: none; + color: var(--text-color); +} diff --git a/components/ui-elements/buttons/link-button.tsx b/components/ui-elements/buttons/link-button.tsx new file mode 100644 index 000000000..e9570a555 --- /dev/null +++ b/components/ui-elements/buttons/link-button.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; +import { FC, ReactNode } from "react"; +import styles from "./Button.module.css"; + +type LinkButtonProps = { + children: ReactNode; + wrapperClassName?: string; + btnClassName?: string; + href: string; +}; + +const LinkButton: FC = ({ + children, + wrapperClassName, + btnClassName, + href, +}) => { + return ( +
+ + {children} + +
+ ); +}; +export default LinkButton; diff --git a/components/ui-elements/submit-button.tsx b/components/ui-elements/buttons/submit-button.tsx similarity index 93% rename from components/ui-elements/submit-button.tsx rename to components/ui-elements/buttons/submit-button.tsx index f5c27d5d1..864420a95 100644 --- a/components/ui-elements/submit-button.tsx +++ b/components/ui-elements/buttons/submit-button.tsx @@ -1,5 +1,5 @@ import { FC, ReactNode } from "react"; -import styles from "./SubmitButton.module.css"; +import styles from "./Button.module.css"; type OnClickType = { onClick: () => void; diff --git a/components/ui-elements/context-warning/context-warning.tsx b/components/ui-elements/context-warning/context-warning.tsx index cb4764b5c..0eaee4c3d 100644 --- a/components/ui-elements/context-warning/context-warning.tsx +++ b/components/ui-elements/context-warning/context-warning.tsx @@ -1,7 +1,7 @@ import { Context, useContextContext } from "@/contexts/ContextContext"; import { FC } from "react"; +import SubmitButton from "../buttons/submit-button"; import styles from "./ContextWarning.module.css"; -import SubmitButton from "../submit-button"; type ContextWarningProps = { recordContext?: Context; diff --git a/components/ui-elements/crm-project-details/crm-project-details.tsx b/components/ui-elements/crm-project-details/crm-project-details.tsx index 99358a855..4b2379f4a 100644 --- a/components/ui-elements/crm-project-details/crm-project-details.tsx +++ b/components/ui-elements/crm-project-details/crm-project-details.tsx @@ -1,15 +1,15 @@ import useCrmProject from "@/api/useCrmProject"; -import { FC, FormEvent, useState } from "react"; -import RecordDetails from "../record-details/record-details"; -import SubmitButton from "../submit-button"; +import { CrmProject } from "@/api/useCrmProjects"; import { addDaysToDate, makeRevenueString, toLocaleDateString, } from "@/helpers/functional"; -import CrmProjectForm, { CrmProjectOnChangeFields } from "./crm-project-form"; -import { CrmProject } from "@/api/useCrmProjects"; import Link from "next/link"; +import { FC, FormEvent, useState } from "react"; +import SubmitButton from "../buttons/submit-button"; +import RecordDetails from "../record-details/record-details"; +import CrmProjectForm, { CrmProjectOnChangeFields } from "./crm-project-form"; type CrmProjectDetailsProps = { projectId: string; diff --git a/components/ui-elements/list-items/ListItem.module.css b/components/ui-elements/list-items/ListItem.module.css index cd8c64395..04e1acb16 100644 --- a/components/ui-elements/list-items/ListItem.module.css +++ b/components/ui-elements/list-items/ListItem.module.css @@ -4,7 +4,7 @@ margin-bottom: 1rem; } -.postCheckbox { +.listItemIcon { font-size: var(--font-size-x-large); margin-right: 1rem; } diff --git a/components/ui-elements/list-items/checklist-item.tsx b/components/ui-elements/list-items/checklist-item.tsx index 23d06c832..c61fa3cd9 100644 --- a/components/ui-elements/list-items/checklist-item.tsx +++ b/components/ui-elements/list-items/checklist-item.tsx @@ -1,34 +1,29 @@ -import { FC, ReactNode } from "react"; -import styles from "./ListItem.module.css"; +import { FC } from "react"; import { IoCheckboxSharp, IoSquareOutline } from "react-icons/io5"; +import styles from "./ListItem.module.css"; +import ListItem, { ListItemProps } from "./list-item"; -type CheckListItemProps = { - title: ReactNode; - description?: ReactNode; +type CheckListItemProps = ListItemProps & { checked?: boolean; switchCheckbox: (checked: boolean) => any; }; const CheckListItem: FC = ({ - title, - description, switchCheckbox, checked, + ...props }) => ( -
-
+ switchCheckbox(!checked)} > {!checked ? : } -
-
{title}
-
{description}
-
-
-
+ } + /> ); export default CheckListItem; diff --git a/components/ui-elements/list-items/list-item.tsx b/components/ui-elements/list-items/list-item.tsx new file mode 100644 index 000000000..0ff7a8764 --- /dev/null +++ b/components/ui-elements/list-items/list-item.tsx @@ -0,0 +1,31 @@ +import { FC, ReactNode } from "react"; +import styles from "./ListItem.module.css"; + +export type ListItemProps = { + title: ReactNode; + description?: ReactNode; +}; + +type InternalListItemProps = ListItemProps & { + itemSelector?: ReactNode; +}; + +const ListItem: FC = ({ + title, + description, + itemSelector, +}) => { + return ( +
+
+ {itemSelector} +
+
{title}
+
{description}
+
+
+
+ ); +}; + +export default ListItem; diff --git a/components/ui-elements/list-items/to-process-item.tsx b/components/ui-elements/list-items/to-process-item.tsx new file mode 100644 index 000000000..401e19932 --- /dev/null +++ b/components/ui-elements/list-items/to-process-item.tsx @@ -0,0 +1,19 @@ +import { FC, ReactNode } from "react"; +import { IoAddCircleOutline } from "react-icons/io5"; +import styles from "./ListItem.module.css"; +import ListItem, { ListItemProps } from "./list-item"; + +type ToProcessItemProps = ListItemProps & { + actionStep?: ReactNode; +}; + +const ToProcessItem: FC = ({ actionStep, ...props }) => ( + + } + /> +); + +export default ToProcessItem; diff --git a/components/ui-elements/project-selector.tsx b/components/ui-elements/project-selector.tsx index aa0aea10c..4ec72a6df 100644 --- a/components/ui-elements/project-selector.tsx +++ b/components/ui-elements/project-selector.tsx @@ -1,13 +1,14 @@ +import { useProjectsContext } from "@/api/ContextProjects"; import { FC, ReactNode, useEffect, useState } from "react"; import Select from "react-select"; import CreatableSelect from "react-select/creatable"; import ProjectName from "./tokens/project-name"; -import { useProjectsContext } from "@/api/ContextProjects"; type ProjectSelectorProps = { allowCreateProjects?: boolean; clearAfterSelection?: boolean; onChange: (projectId: string | null) => void; + placeholder?: string; }; type ProjectListOption = { @@ -19,6 +20,7 @@ const ProjectSelector: FC = ({ allowCreateProjects, onChange, clearAfterSelection, + placeholder = "Add project…", }) => { const { projects, createProject, loadingProjects } = useProjectsContext(); const [mappedOptions, setMappedOptions] = useState< @@ -66,9 +68,7 @@ const ProjectSelector: FC = ({ filterOption={(candidate, input) => filterProjects(candidate.value, input) } - placeholder={ - loadingProjects ? "Loading projects..." : "Add project..." - } + placeholder={loadingProjects ? "Loading projects..." : placeholder} /> ) : ( void; +}; + +const StatusSelector: FC = ({ + options, + value, + onChange, +}) => { + const [mappedOptions] = useState( + options.map((o) => ({ + value: o, + label: o + .replace(/([A-Z])/g, " $1") + .replace(/^./, (str) => str.toUpperCase()), + })) + ); + const [selected, setSelected] = useState( + mappedOptions.find((o) => o.value === value) + ); + + useEffect(() => { + setSelected(mappedOptions.find((o) => o.value === value)); + }, [mappedOptions, value]); + + const selectStatus = (selectedOption: any) => { + onChange(selectedOption.value); + }; + + return ( +