diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index 9b5b1578b..1d55c5e3e 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -53,6 +53,7 @@ const schema = a ...planningSchema, ...prayerSchema, ...projectSchema, + InboxStatus: a.enum(["new", "done"]), Inbox: a .model({ owner: a @@ -61,10 +62,14 @@ const schema = a note: a.string(), formatVersion: a.integer().default(1), noteJson: a.json(), - status: a.id().required(), + status: a.ref("InboxStatus").required(), movedToActivityId: a.string(), + movedToPersonLearningId: a.string(), + createdAt: a.datetime().required(), }) - .secondaryIndexes((inbox) => [inbox("status")]) + .secondaryIndexes((inbox) => [ + inbox("status").sortKeys(["createdAt"]).queryField("byStatus"), + ]) .authorization((allow) => [allow.owner()]), Meeting: a .model({ diff --git a/api/helpers/activity.ts b/api/helpers/activity.ts index 81bf151cd..a4c77bc6d 100644 --- a/api/helpers/activity.ts +++ b/api/helpers/activity.ts @@ -1,14 +1,16 @@ import { type Schema } from "@/amplify/data/resource"; -import { newDateTimeString } from "@/helpers/functional"; +import { newDateTimeString, toISODateTimeString } from "@/helpers/functional"; import { generateClient } from "aws-amplify/data"; import { handleApiErrors } from "../globals"; const client = generateClient(); -export const createActivityApi = async () => { +export const createActivityApi = async (createdAt?: Date) => { const { data, errors } = await client.models.Activity.create({ formatVersion: 3, noteBlockIds: [], - finishedOn: newDateTimeString(), + finishedOn: !createdAt + ? newDateTimeString() + : toISODateTimeString(createdAt), notes: null, notesJson: null, }); diff --git a/api/useInbox.ts b/api/useInbox.ts index b7243ca1a..73c602eb6 100644 --- a/api/useInbox.ts +++ b/api/useInbox.ts @@ -1,31 +1,29 @@ import { type Schema } from "@/amplify/data/resource"; +import { + addActivityIdToInbox, + createNoteBlock, + updateMovedItemToPersonId, +} from "@/components/inbox/helpers"; +import { + LIST_TYPES, + stringifyBlock, +} from "@/components/ui-elements/editors/helpers/blocks"; +import { newDateTimeString, toISODateString } from "@/helpers/functional"; import { transformNotesVersion } from "@/helpers/ui-notes-writer"; -import { JSONContent } from "@tiptap/core"; +import { Editor, JSONContent } from "@tiptap/core"; import { generateClient } from "aws-amplify/data"; +import { debounce } from "lodash"; +import { compact } from "lodash/fp"; import useSWR from "swr"; import { handleApiErrors } from "./globals"; +import { + createActivityApi, + createProjectActivityApi, + updateActivityBlockIds, +} from "./helpers/activity"; const client = generateClient(); -export type HandleMutationFn = (item: Inbox, callApi?: boolean) => void; - -const STATUS_LIST = [ - "new", - "actionable", - "notActionable", - "doNow", - "moveToProject", - "clarifyAction", - "clarifyDeletion", - "done", -] as const; - -export type InboxStatus = (typeof STATUS_LIST)[number]; - -const isValidInboxStatus = (status: string): status is InboxStatus => - STATUS_LIST.includes(status as InboxStatus); - -const mapStatus = (status: string): InboxStatus => - isValidInboxStatus(status) ? status : "new"; +type InboxStatus = Schema["InboxStatus"]["type"]; export type Inbox = { id: string; @@ -35,9 +33,20 @@ export type Inbox = { updatedAt: Date; }; +type ApiResponse = Promise; +type UpdateInboxFn = (id: string, editor: Editor) => ApiResponse; + +export const debouncedOnChangeInboxNote = debounce( + async (id: string, editor: Editor, updateNote: UpdateInboxFn) => { + const data = await updateNote(id, editor); + if (!data) return; + }, + 1500 +); + type MapInboxFn = (data: Schema["Inbox"]["type"]) => Inbox; -export const mapInbox: MapInboxFn = ({ +const mapInbox: MapInboxFn = ({ id, note, createdAt, @@ -47,7 +56,7 @@ export const mapInbox: MapInboxFn = ({ updatedAt, }) => ({ id, - status: mapStatus(status), + status, note: transformNotesVersion({ formatVersion, notes: note, @@ -58,17 +67,18 @@ export const mapInbox: MapInboxFn = ({ }); const fetchInbox = async () => { - const { data, errors } = await client.models.Inbox.listInboxByStatus({ - status: "new", - }); + const { data, errors } = await client.models.Inbox.byStatus( + { + status: "new", + }, + { sortDirection: "ASC" } + ); if (errors) { handleApiErrors(errors, "Error loading inbox items"); throw errors; } try { - return data - .map(mapInbox) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + return data.map(mapInbox); } catch (error) { console.error("fetchInbox", { error }); throw error; @@ -83,18 +93,119 @@ const useInbox = () => { mutate, } = useSWR("/api/inbox", fetchInbox); - const handleMutation: HandleMutationFn = (item, callApi = true) => { - const updated: Inbox[] | undefined = inbox?.map((i) => - i.id !== item.id ? i : item + const createInboxItem = async (note: JSONContent) => { + const updated = [ + ...(inbox ?? []), + { + id: "new", + status: "new", + note, + createdAt: new Date(), + } as Inbox, + ]; + if (updated) mutate(updated, false); + const { data, errors } = await client.models.Inbox.create({ + noteJson: JSON.stringify(note), + note: null, + formatVersion: 2, + status: "new", + createdAt: newDateTimeString(), + }); + if (errors) handleApiErrors(errors, "Error creating inbox item"); + if (updated) mutate(updated); + return data; + }; + + const setInboxItemDone = async (itemId: string) => { + const updated = inbox?.filter((i) => i.id !== itemId); + if (updated) mutate(updated, false); + const { data, errors } = await client.models.Inbox.update({ + id: itemId, + status: "done", + }); + if (errors) handleApiErrors(errors, "Error updating inbox item"); + if (updated) mutate(updated); + return data; + }; + + const updateNote = async (id: string, editor: Editor) => { + const note = editor.getJSON(); + const updated = inbox?.map((item) => + item.id !== id + ? item + : { + ...item, + note, + } ); - if (updated) mutate(updated, callApi); + if (updated) mutate(updated, false); + const { data, errors } = await client.models.Inbox.update({ + id, + note: null, + formatVersion: 2, + noteJson: JSON.stringify(note), + }); + if (errors) handleApiErrors(errors, "Error updating inbox item"); + if (updated) mutate(updated); + return data?.id; + }; + + const moveItemToProject = async (item: Inbox, projectId: string) => { + const activity = await createActivityApi(item.createdAt); + if (!activity) return; + + const updated = inbox?.filter((i) => i.id !== item.id); + if (updated) mutate(updated, false); + const blocks = item.note.content; + if (blocks) { + const blockIds = await Promise.all( + blocks.flatMap((block) => + LIST_TYPES.includes(block.type ?? "") + ? block.content?.map((subBlock) => + createNoteBlock(activity.id, subBlock, block.type) + ) + : createNoteBlock(activity.id, block) + ) + ); + await updateActivityBlockIds(activity.id, compact(blockIds)); + } + + await createProjectActivityApi(projectId, activity.id); + const itemId = await addActivityIdToInbox(item.id, activity.id); + + if (updated) mutate(updated); + return itemId; + }; + + const moveItemToPerson = async ( + item: Inbox, + personId: string, + withPrayer?: boolean + ) => { + const updated = inbox?.filter((i) => i.id !== item.id); + if (updated) mutate(updated, false); + const { data, errors } = await client.models.PersonLearning.create({ + personId, + learnedOn: toISODateString(item.createdAt), + learning: stringifyBlock(item.note), + prayer: !withPrayer ? undefined : "PRAYING", + }); + if (errors) handleApiErrors(errors, "Error moving inbox item to person"); + if (updated) mutate(updated); + if (!data) return; + await updateMovedItemToPersonId(item.id, data.id); + return data.id; }; return { inbox, error, isLoading, - mutate: handleMutation, + createInboxItem, + updateNote, + setInboxItemDone, + moveItemToProject, + moveItemToPerson, }; }; diff --git a/api/useInboxItem.ts b/api/useInboxItem.ts deleted file mode 100644 index b2eef2d8a..000000000 --- a/api/useInboxItem.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { type Schema } from "@/amplify/data/resource"; -import { generateClient } from "aws-amplify/data"; -import useSWR from "swr"; -import { handleApiErrors } from "./globals"; -import { HandleMutationFn, mapInbox } from "./useInbox"; -const client = generateClient(); - -const fetchInboxItem = (itemId?: string) => async () => { - if (!itemId) return; - const { data, errors } = await client.models.Inbox.get({ id: itemId }); - if (errors) { - handleApiErrors(errors, "Error loading inbox item"); - throw errors; - } - if (!data) throw new Error("fetchInboxItem didn't retrieve data"); - try { - return mapInbox(data); - } catch (error) { - console.error("fetchInboxItem", { error }); - throw error; - } -}; - -const useInboxItem = (itemId?: string) => { - const { - data: inboxItem, - error, - isLoading, - mutate, - } = useSWR(`/api/inbox/${itemId}`, fetchInboxItem(itemId)); - - const handleMutation: HandleMutationFn = (item, callApi = true) => { - mutate(item, callApi); - }; - - return { - inboxItem, - error, - isLoading, - mutate: handleMutation, - }; -}; - -export default useInboxItem; diff --git a/api/useInboxWorkflow.ts b/api/useInboxWorkflow.ts deleted file mode 100644 index 80dbe6b4b..000000000 --- a/api/useInboxWorkflow.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { type Schema } from "@/amplify/data/resource"; -import { - LIST_TYPES, - stringifyBlock, -} from "@/components/ui-elements/editors/helpers/blocks"; -import { - getPeopleMentioned, - getPersonId, -} from "@/components/ui-elements/editors/helpers/mentioned-people-cud"; -import { getTextFromJsonContent } from "@/components/ui-elements/editors/helpers/text-generation"; -import { useToast } from "@/components/ui/use-toast"; -import { newDateString, toISODateTimeString } from "@/helpers/functional"; -import { Editor, JSONContent } from "@tiptap/core"; -import { generateClient } from "aws-amplify/data"; -import { compact } from "lodash"; -import { flow, map } from "lodash/fp"; -import { handleApiErrors } from "./globals"; -import { HandleMutationFn, Inbox, InboxStatus, mapInbox } from "./useInbox"; -const client = generateClient(); - -export const createInboxItemApi = async (note: JSONContent) => { - const { data, errors } = await client.models.Inbox.create({ - noteJson: JSON.stringify(note), - note: null, - formatVersion: 2, - status: "new", - }); - if (errors) handleApiErrors(errors, "Error creating inbox item"); - return data; -}; - -const useInboxWorkflow = (mutate: HandleMutationFn) => { - const { toast } = useToast(); - - const createInboxItem = async (editor: Editor) => { - const note = editor.getJSON(); - const data = await createInboxItemApi(note); - if (!data) return; - toast({ - title: "New Inbox Item Created", - description: getTextFromJsonContent(note), - }); - mutate({ - id: crypto.randomUUID(), - createdAt: new Date(), - updatedAt: new Date(), - status: "new", - note, - }); - return data.id; - }; - - const updateNote = - (inboxItem: Inbox) => async (id: string, editor: Editor) => { - const note = editor.getJSON(); - const updated: Inbox = { - ...inboxItem, - note, - }; - mutate(updated, false); - const { data, errors } = await client.models.Inbox.update({ - id, - note: null, - formatVersion: 2, - noteJson: JSON.stringify(note), - }); - if (errors) handleApiErrors(errors, "Error updating inbox item"); - mutate(updated); - return data?.id; - }; - - const createActivity = async (createdAt: Date) => { - const { data, errors } = await client.models.Activity.create({ - finishedOn: toISODateTimeString(createdAt), - formatVersion: 3, - notes: null, - notesJson: null, - }); - if (errors) - handleApiErrors(errors, "Error creating activity with inbox notes"); - return data?.id; - }; - - const createTodo = async (block: JSONContent) => { - if (block.type !== "taskItem") return; - const { data, errors } = await client.models.Todo.create({ - status: block.attrs?.checked ? "DONE" : "OPEN", - todo: stringifyBlock(block), - doneOn: block.attrs?.checked ? newDateString() : null, - }); - if (errors) handleApiErrors(errors, "Creating todo failed"); - if (!data) return; - return data.id; - }; - - const createNoteBlock = async ( - activityId: string, - block: JSONContent, - parentType?: string - ) => { - const todoId = await createTodo(block); - const { data, errors } = await client.models.NoteBlock.create({ - activityId, - formatVersion: 3, - type: !block.type - ? "paragraph" - : parentType === "orderedList" - ? "listItemOrdered" - : block.type, - content: !todoId ? stringifyBlock(block) : null, - ...(!todoId ? {} : { todoId }), - }); - if (errors) handleApiErrors(errors, "Creating note block failed"); - if (!data) return; - - const peopleIds = flow( - getPeopleMentioned, - map(getPersonId), - compact - )(block); - - if (peopleIds.length > 0) { - await Promise.all( - peopleIds.map((id) => createNoteBlockPerson(data.id, id)) - ); - } - - return data.id; - }; - - const updateActivityBlockIds = async ( - activityId: string, - blockIds: (string | undefined)[] - ) => { - const noteBlockIds = compact(blockIds); - if (noteBlockIds.length === 0) return; - const { errors } = await client.models.Activity.update({ - id: activityId, - noteBlockIds, - }); - if (errors) handleApiErrors(errors, "Updating activity's block ids failed"); - }; - - const createActivityProject = async ( - activityId: string, - projectId: string - ) => { - const { errors } = await client.models.ProjectActivity.create({ - activityId, - projectsId: projectId, - }); - if (errors) handleApiErrors(errors, "Linking activity/project failed"); - }; - - const addActivityIdToInbox = async ( - inboxItemId: string, - activityId: string - ) => { - const { data, errors } = await client.models.Inbox.update({ - id: inboxItemId, - status: "done", - movedToActivityId: activityId, - }); - if (errors) handleApiErrors(errors, "Linking inbox item/activity failed"); - return data?.id; - }; - - const createNoteBlockPerson = async (blockId: string, personId: string) => { - const { errors } = await client.models.NoteBlockPerson.create({ - noteBlockId: blockId, - personId, - }); - if (errors) handleApiErrors(errors, "Linking note block/person failed"); - }; - - const moveInboxItemToProject = async ( - inboxItem: Inbox, - projectId: string - ) => { - const activityId = await createActivity(inboxItem.createdAt); - if (!activityId) return; - - const blocks = inboxItem.note.content; - if (blocks) { - const blockIds = await Promise.all( - blocks.flatMap((block) => - LIST_TYPES.includes(block.type ?? "") - ? block.content?.map((subBlock) => - createNoteBlock(activityId, subBlock, block.type) - ) - : createNoteBlock(activityId, block) - ) - ); - await updateActivityBlockIds(activityId, blockIds); - } - - await createActivityProject(activityId, projectId); - const inboxItemId = await addActivityIdToInbox(inboxItem.id, activityId); - - return inboxItemId; - }; - - const updateStatus = async (inboxItem: Inbox, status: InboxStatus) => { - if (inboxItem) mutate({ ...inboxItem, status }, false); - const { data, errors } = await client.models.Inbox.update({ - id: inboxItem.id, - status, - }); - if (errors) handleApiErrors(errors, "Can't update status"); - if (!data) return; - mutate(mapInbox(data)); - return data.id; - }; - - return { createInboxItem, updateNote, updateStatus, moveInboxItemToProject }; -}; - -export default useInboxWorkflow; diff --git a/api/useLatestUploadsWork.tsx b/api/useLatestUploadsWork.tsx new file mode 100644 index 000000000..0af6aefc1 --- /dev/null +++ b/api/useLatestUploadsWork.tsx @@ -0,0 +1,90 @@ +import { type Schema } from "@/amplify/data/resource"; +import { generateClient, SelectionSet } from "aws-amplify/data"; +import { differenceInCalendarDays } from "date-fns"; +import { first, flow, get, identity } from "lodash/fp"; +import useSWR from "swr"; +import { handleApiErrors } from "./globals"; +const client = generateClient(); + +const selectionSet = ["createdAt"] as const; + +type MrrLatestUploadData = SelectionSet< + Schema["MrrDataUpload"]["type"], + typeof selectionSet +>; + +type SfdcLatestUploadData = SelectionSet< + Schema["CrmProjectImport"]["type"], + typeof selectionSet +>; + +const isTooOld = (dateStr: string | undefined) => + !dateStr || differenceInCalendarDays(new Date(), dateStr) >= 7; + +const fetchSfdcLatestUpload = async () => { + const { data, errors } = + await client.models.CrmProjectImport.listByImportStatus( + { + status: "DONE", + }, + { + sortDirection: "DESC", + limit: 1, + selectionSet, + } + ); + if (errors) { + handleApiErrors(errors, "Loading SfdcLatestUpload failed"); + throw errors; + } + try { + return flow( + identity, + first, + get("createdAt"), + isTooOld + )(data); + } catch (error) { + console.error("fetchSfdcLatestUpload", error); + throw error; + } +}; + +const fetchMrrLatestUpload = async () => { + const { data, errors } = + await client.models.MrrDataUpload.listMrrDataUploadByStatusAndCreatedAt( + { status: "DONE" }, + { sortDirection: "DESC", limit: 1, selectionSet } + ); + if (errors) { + handleApiErrors(errors, "Loading MrrLatestUpload failed"); + throw errors; + } + try { + return flow( + identity, + first, + get("createdAt"), + isTooOld + )(data); + } catch (error) { + console.error("fetchMrrLatestUpload", error); + throw error; + } +}; + +const useLatestUploadsWork = () => { + const { data: mrrUploadTooOld, mutate: mutateMrr } = useSWR( + "/api/mrr-latest", + fetchMrrLatestUpload + ); + + const { data: sfdcUploadTooOld, mutate: mutateSfdc } = useSWR( + "/api/sfdc-latest", + fetchSfdcLatestUpload + ); + + return { mrrUploadTooOld, mutateMrr, sfdcUploadTooOld, mutateSfdc }; +}; + +export default useLatestUploadsWork; diff --git a/components/analytics/instructions/instructions-upload-mrr.tsx b/components/analytics/instructions/instructions-upload-mrr.tsx index 35bee42cd..20f4b51ab 100644 --- a/components/analytics/instructions/instructions-upload-mrr.tsx +++ b/components/analytics/instructions/instructions-upload-mrr.tsx @@ -1,8 +1,10 @@ +import { MrrMutator } from "@/api/useMrr"; import imgDownload from "@/public/images/analytics/mrr-download.png"; import imgFilter from "@/public/images/analytics/mrr-filters.png"; import { ExternalLink } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; +import { FC } from "react"; import DefaultAccordionItem from "../../ui-elements/accordion/DefaultAccordionItem"; import BulletList from "../../ui-elements/list-items/bullet-list"; import { useMrrFilter } from "../useMrrFilter"; @@ -11,9 +13,22 @@ import ImportMrrData from "./import-data"; const mrrTableauLink = "https://awstableau.corp.amazon.com/t/WWSalesInsights/views/MonthlyRevenueDeep/DeepMonthlyRevenue?%3Aembed=yes&%3Alinktarget=_blank&%3Aoriginal_view=yes#1"; -const InstructionsUploadMrr = () => { +type InstructionsUploadMrrProps = { + reloader?: () => void; +}; + +const InstructionsUploadMrr: FC = ({ + reloader, +}) => { const { mrr, mutateMrr } = useMrrFilter(); + const reload: MrrMutator = (data) => { + if (reloader) { + reloader(); + } + return mutateMrr(data); + }; + return (
@@ -67,7 +82,7 @@ const InstructionsUploadMrr = () => {
Then upload the file here:
- +
); diff --git a/components/analytics/useMrrFilter.tsx b/components/analytics/useMrrFilter.tsx index 1c422cfee..2836e9395 100644 --- a/components/analytics/useMrrFilter.tsx +++ b/components/analytics/useMrrFilter.tsx @@ -31,7 +31,7 @@ interface MrrFilterProviderProps { children: React.ReactNode; } -const MrrFilterProvider: FC = ({ children }) => { +export const MrrFilterProvider: FC = ({ children }) => { const [mrrFilter, setMrrFilter] = useState("6"); const { mrr, isLoading, error, mutate } = useMrr("DONE", mrrFilter); diff --git a/components/crm/import-project-data.tsx b/components/crm/import-project-data.tsx index 9009d2e77..62642cf74 100644 --- a/components/crm/import-project-data.tsx +++ b/components/crm/import-project-data.tsx @@ -7,8 +7,9 @@ import { } from "@/helpers/crm/filters"; import { cn } from "@/lib/utils"; import { flow, map, sum } from "lodash/fp"; -import { Loader2 } from "lucide-react"; -import { useEffect, useState } from "react"; +import { ExternalLink, Loader2 } from "lucide-react"; +import Link from "next/link"; +import { FC, useEffect, useState } from "react"; import ApiLoadingError from "../layouts/ApiLoadingError"; import { Accordion } from "../ui/accordion"; import { Button } from "../ui/button"; @@ -19,7 +20,14 @@ import ChangedCrmProjects from "./changed-projects"; import MissingCrmProjects from "./missing-projects"; import NewCrmProjects from "./new-projects"; -const ImportProjectData = () => { +const linkSfdcReport = + "https://aws-crm.lightning.force.com/lightning/r/Report/00ORU000000oksT2AQ/view"; + +type ImportProjectDataProps = { + reloader?: () => void; +}; + +const ImportProjectData: FC = ({ reloader }) => { const { crmProjects, mutate } = useCrmProjects(); const { crmProjectsImport, @@ -78,12 +86,25 @@ const ImportProjectData = () => { }); }, [crmProjects, processedData]); + const handleClose = async () => { + await closeImportFile(); + reloader?.(); + }; + return (
{!crmProjectsImport && (
+
+ +
+ Link to SFDC opportunity report + +
+ +
{loadingImports ? (
Loading status of imported data… @@ -137,7 +158,7 @@ const ImportProjectData = () => { )} {crmProjectsImport && ( - + )}
); diff --git a/components/header/CreateInboxItem.tsx b/components/header/CreateInboxItem.tsx deleted file mode 100644 index e8dd36f86..000000000 --- a/components/header/CreateInboxItem.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Plus } from "lucide-react"; -import { useCreateInboxItemContext } from "../inbox/CreateInboxItemDialog"; -import { Button } from "../ui/button"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "../ui/tooltip"; - -const CreateInboxItem = () => { - const { open } = useCreateInboxItemContext(); - - return ( - - - - - - -

Create a new inbox item

-
-
-
- ); -}; - -export default CreateInboxItem; diff --git a/components/header/Header.tsx b/components/header/Header.tsx index 58d740782..fd71a68fa 100644 --- a/components/header/Header.tsx +++ b/components/header/Header.tsx @@ -1,6 +1,8 @@ import { Context } from "@/contexts/ContextContext"; +import { Plus } from "lucide-react"; import { FC } from "react"; -import CreateInboxItem from "./CreateInboxItem"; +import CreateInboxItemDialog from "../inbox/CreateInboxItemDialog"; +import { Button } from "../ui/button"; import Logo from "./Logo"; import ProfilePicture from "./ProfilePicture"; @@ -12,7 +14,13 @@ const Header: FC = ({ context }) => (
- + + + + } + />
diff --git a/components/inbox/ClarifyAction.tsx b/components/inbox/ClarifyAction.tsx deleted file mode 100644 index 2d7dfd70a..000000000 --- a/components/inbox/ClarifyAction.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { FC, useState } from "react"; -import ProjectDetails from "../ui-elements/project-details/project-details"; -import ProjectSelector from "../ui-elements/selectors/project-selector"; -import ProjectName from "../ui-elements/tokens/project-name"; -import { Button } from "../ui/button"; -import { WorkflowStepComponentProps } from "./workflow"; - -const ClarifyAction: FC = ({ - responses, - action, -}) => { - const [selectedProject, setSelectedProject] = useState(null); - - const respondProjectSelected = async (projectId: string | null) => { - if (!projectId) return; - if (!responses) return; - if (responses.length !== 1 && responses[0].response !== "next") return; - await action(responses[0], projectId); - }; - - return ( -
- - {selectedProject && ( -
- - - -
- )} -
- Inbox Notes (will be moved to selected project): -
-
- ); -}; - -export default ClarifyAction; diff --git a/components/inbox/ConfirmContent.tsx b/components/inbox/ConfirmContent.tsx new file mode 100644 index 000000000..3fb9015d4 --- /dev/null +++ b/components/inbox/ConfirmContent.tsx @@ -0,0 +1,21 @@ +import { cn } from "@/lib/utils"; +import { FC } from "react"; +import { WorkflowStatus } from "./inboxWorkflow"; + +type ConfirmContentProps = { + status: WorkflowStatus; + className?: string; +}; + +const relevantStatuses: WorkflowStatus[] = ["addToProject", "addToPerson"]; + +const ConfirmContent: FC = ({ status, className }) => + relevantStatuses.includes(status) && ( +
+ Make sure you have updated the content before confirming where to move it: +
+ ); + +export default ConfirmContent; diff --git a/components/inbox/CreateInboxItemDialog.tsx b/components/inbox/CreateInboxItemDialog.tsx index 9d459a940..db0c69fe8 100644 --- a/components/inbox/CreateInboxItemDialog.tsx +++ b/components/inbox/CreateInboxItemDialog.tsx @@ -1,6 +1,6 @@ -import { createInboxItemApi } from "@/api/useInboxWorkflow"; +import useInbox from "@/api/useInbox"; import { JSONContent } from "@tiptap/core"; -import { createContext, FC, ReactNode, useContext, useState } from "react"; +import { Dispatch, FC, ReactNode, SetStateAction, useState } from "react"; import { emptyDocument } from "../ui-elements/editors/helpers/document"; import InboxEditor from "../ui-elements/editors/inbox-editor/InboxEditor"; import { Button } from "../ui/button"; @@ -12,78 +12,52 @@ import { DialogFooter, DialogHeader, DialogTitle, + DialogTrigger, } from "../ui/dialog"; import { ScrollArea } from "../ui/scroll-area"; -interface CreateInboxItemContextType { - state: boolean; - open: () => void; - close: () => void; - editorContent: JSONContent; - setEditorContent: (val: JSONContent) => void; - createInboxItem: () => Promise; -} +type CreateInboxItemDialogProps = + | { + dialogTrigger: ReactNode; + open?: never; + onOpenChange?: never; + } + | { + dialogTrigger?: never; + open: boolean; + onOpenChange: Dispatch>; + }; -interface CreateInobxItemProviderProps { - children: ReactNode; -} - -export const CreateInboxItemProvider: FC = ({ - children, +const CreateInboxItemDialog: FC = ({ + dialogTrigger, + open, + onOpenChange, }) => { - const [isOpen, setIsOpen] = useState(false); + const { createInboxItem } = useInbox(); + const [isOpen, setIsOpen] = useState(!!open); const [editorContent, setEditorContent] = useState(emptyDocument); - const handleCreateInboxItem = async () => { - if (!editorContent) return; - const result = await createInboxItemApi(editorContent); + const handleOpenChange = (open: boolean) => { + const change = onOpenChange ?? setIsOpen; + change(open); + }; + + const handleCreateInboxItem = async (content: JSONContent) => { + const result = await createInboxItem(content); setEditorContent(emptyDocument); - setIsOpen(false); + handleOpenChange(false); return result?.id; }; return ( - setIsOpen(true), - close: () => setIsOpen(false), - editorContent, - setEditorContent, - createInboxItem: handleCreateInboxItem, - }} + - {children} - - ); -}; - -const CreateInboxItemContext = createContext< - CreateInboxItemContextType | undefined ->(undefined); - -export const useCreateInboxItemContext = () => { - const context = useContext(CreateInboxItemContext); - if (!context) - throw new Error( - "useCreateInboxItemContext must be used within CreateInboxItemProvider" - ); - return context; -}; - -const CreateInboxItemDialog = () => { - const { - state, - open, - close, - editorContent, - setEditorContent, - createInboxItem, - } = useCreateInboxItemContext(); - - return ( - (state ? close() : open())}> + +
{dialogTrigger}
+
Create a New Inbox Item @@ -98,12 +72,17 @@ const CreateInboxItemDialog = () => { saveNotes={(editor) => { setEditorContent(editor.getJSON()); }} + saveAtCmdEnter={(editor) => { + handleCreateInboxItem(editor.getJSON()); + }} showSaveStatus={false} autoFocus /> - + +); + +export default InboxDecisionBtn; diff --git a/components/inbox/InboxDecisionHeader.tsx b/components/inbox/InboxDecisionHeader.tsx new file mode 100644 index 000000000..7af3bfc2e --- /dev/null +++ b/components/inbox/InboxDecisionHeader.tsx @@ -0,0 +1,18 @@ +import { FC } from "react"; +import { WorkflowStep } from "./inboxWorkflow"; + +type InboxDecisionHeaderProps = { + step: WorkflowStep; +}; + +const InboxDecisionHeader: FC = ({ step }) => ( + <> +
+ + {step.statusName} +
+
{step.question}
+ +); + +export default InboxDecisionHeader; diff --git a/components/inbox/InboxDecisionMenu.tsx b/components/inbox/InboxDecisionMenu.tsx new file mode 100644 index 000000000..c8884bb0d --- /dev/null +++ b/components/inbox/InboxDecisionMenu.tsx @@ -0,0 +1,110 @@ +import { flow, identity } from "lodash/fp"; +import { Undo2 } from "lucide-react"; +import { FC, useEffect, useState } from "react"; +import PeopleSelector from "../ui-elements/selectors/people-selector"; +import ProjectSelector from "../ui-elements/selectors/project-selector"; +import ConfirmContent from "./ConfirmContent"; +import InboxDecisionBtn from "./InboxDecisionBtn"; +import InboxDecisionHeader from "./InboxDecisionHeader"; +import { + findStepByStatus, + statusWithAction, + workflow, + WorkflowResponse, + WorkflowStatus, + WorkflowStatusWithActions, + WorkflowStep, +} from "./inboxWorkflow"; + +type InboxDecisionMenuProps = { + setInboxItemDone: () => void; + addToProject: (projectId: string) => void; + addToPerson: (personId: string, withPrayer?: boolean) => void; +}; + +const InboxDecisionMenu: FC = ({ + setInboxItemDone, + addToProject, + addToPerson, +}) => { + const [status, setStatus] = useState("new"); + const [step, setStep] = useState(null); + const [selectedProject, setSelectedProject] = useState(null); + const [selectedPerson, setSelectedPerson] = useState(null); + + useEffect(() => { + flow(identity, findStepByStatus(status), setStep)(workflow); + }, [status]); + + const takeAction = (response: WorkflowResponse) => () => { + if (!response.takeAction) { + setStatus(response.status); + return; + } + const actionStatus = response.status as WorkflowStatusWithActions; + if (!statusWithAction.includes(actionStatus)) return; + const actions: Record void> = { + done: setInboxItemDone, + addToProject: () => { + if (!selectedProject) return; + addToProject(selectedProject); + }, + addToPerson: () => { + if (!selectedPerson) return; + addToPerson(selectedPerson); + }, + addToPersonWithPrayer: () => { + if (!selectedPerson) return; + addToPerson(selectedPerson, true); + }, + }; + actions[actionStatus](); + }; + + return ( + step && ( +
+ + + {step.status === "addToProject" && ( + + )} + + {step.status === "addToPerson" && ( + + )} + +
+ {step.responses?.map((response) => ( + + ))} + + {step.status !== "new" && ( + setStatus("new")} + Icon={Undo2} + label="Resume" + /> + )} +
+ + +
+ ) + ); +}; + +export default InboxDecisionMenu; diff --git a/components/inbox/ProcessInboxItem.tsx b/components/inbox/ProcessInboxItem.tsx new file mode 100644 index 000000000..4a1fb2176 --- /dev/null +++ b/components/inbox/ProcessInboxItem.tsx @@ -0,0 +1,65 @@ +import useInbox, { debouncedOnChangeInboxNote, Inbox } from "@/api/useInbox"; +import InboxDecisionMenu from "@/components/inbox/InboxDecisionMenu"; +import ApiLoadingError from "@/components/layouts/ApiLoadingError"; +import InboxEditor from "@/components/ui-elements/editors/inbox-editor/InboxEditor"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Editor } from "@tiptap/core"; +import { first, flow, identity } from "lodash/fp"; +import { useEffect, useState } from "react"; + +const ProcessInboxItem = () => { + const { + inbox, + error, + isLoading, + updateNote, + setInboxItemDone, + moveItemToProject, + moveItemToPerson, + } = useInbox(); + const [firstItem, setFirstItem] = useState(); + + useEffect(() => { + flow(identity, first, setFirstItem)(inbox); + }, [inbox]); + + const handleUpdate = (editor: Editor) => { + if (!firstItem) return; + debouncedOnChangeInboxNote(firstItem.id, editor, updateNote); + }; + + return ( + <> + + + {isLoading && } + + {firstItem && ( + <> + setInboxItemDone(firstItem.id)} + addToProject={(projectId) => + moveItemToProject(firstItem, projectId) + } + addToPerson={(personId, withPrayer) => + moveItemToPerson(firstItem, personId, withPrayer) + } + /> + +
+ +
+ + )} + + ); +}; + +export default ProcessInboxItem; diff --git a/components/inbox/Question.tsx b/components/inbox/Question.tsx deleted file mode 100644 index 69046fc1d..000000000 --- a/components/inbox/Question.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { FC } from "react"; -import { Button } from "../ui/button"; -import { WorkflowStepComponentProps } from "./workflow"; - -const Question: FC = ({ - question, - responses, - action, -}) => ( -
- {question} -
- {responses?.map((response) => ( - - ))} -
-
-); - -export default Question; diff --git a/components/inbox/WorkflowItem.tsx b/components/inbox/WorkflowItem.tsx deleted file mode 100644 index dda31d386..000000000 --- a/components/inbox/WorkflowItem.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { HandleMutationFn, Inbox } from "@/api/useInbox"; -import useInboxWorkflow from "@/api/useInboxWorkflow"; -import { debouncedOnChangeInboxNote } from "@/pages/inbox"; -import { Editor } from "@tiptap/core"; -import { useRouter } from "next/router"; -import { FC, useEffect, useState } from "react"; -import InboxEditor from "../ui-elements/editors/inbox-editor/InboxEditor"; -import { Button } from "../ui/button"; -import { Skeleton } from "../ui/skeleton"; -import { - getPreviousStatusByStatus, - getWorkflowStepByStatus, - workflow, - WorkflowStepResponse, -} from "./workflow"; - -type WorkFlowItemProps = { - inboxItem?: Inbox; - isLoading?: boolean; - forwardUrl?: string; - mutate: HandleMutationFn; -}; - -const WorkFlowItem: FC = ({ - inboxItem, - forwardUrl, - isLoading, - mutate, -}) => { - const router = useRouter(); - const [step, setStep] = useState( - getWorkflowStepByStatus(workflow, inboxItem?.status) - ); - const [prevStatus, setPrevStatus] = useState( - getPreviousStatusByStatus(workflow, inboxItem?.status || "new") - ); - const { updateNote, updateStatus, moveInboxItemToProject } = - useInboxWorkflow(mutate); - - useEffect(() => { - if (!inboxItem) return; - setStep(getWorkflowStepByStatus(workflow, inboxItem.status)); - setPrevStatus(getPreviousStatusByStatus(workflow, inboxItem.status)); - }, [inboxItem]); - - const handleUpdate = (editor: Editor) => { - if (!inboxItem) return; - debouncedOnChangeInboxNote(inboxItem.id, editor, updateNote(inboxItem)); - }; - - const goBack = async () => { - if (!prevStatus) return; - if (!inboxItem) return; - await updateStatus(inboxItem, prevStatus); - }; - - const startProcessingItem = async ( - response: WorkflowStepResponse, - projectId?: string - ) => { - if (!inboxItem) return; - if (!response.nextStep) return; - if (projectId) { - const result = await moveInboxItemToProject(inboxItem, projectId); - if (!result) return; - } else { - const result = await updateStatus(inboxItem, response.nextStep.status); - if (!result) return; - } - - if (forwardUrl) router.replace(forwardUrl); - if (response.nextStep.toHome) router.replace("/inbox"); - }; - - return isLoading ? ( - - ) : !step ? ( - "Loading workflow…" - ) : ( - inboxItem && ( -
- {inboxItem.status !== "new" && ( - - )} - {step.component && ( - - )} - -
- ) - ); -}; - -export default WorkFlowItem; diff --git a/components/inbox/helpers.ts b/components/inbox/helpers.ts new file mode 100644 index 000000000..1c0305dd5 --- /dev/null +++ b/components/inbox/helpers.ts @@ -0,0 +1,85 @@ +import { type Schema } from "@/amplify/data/resource"; +import { handleApiErrors } from "@/api/globals"; +import { createMentionedPersonApi } from "@/api/helpers/people"; +import { createBlockApi, createTodoApi } from "@/api/helpers/todo"; +import { stringifyBlock } from "@/components/ui-elements/editors/helpers/blocks"; +import { + getPeopleMentioned, + getPersonId, +} from "@/components/ui-elements/editors/helpers/mentioned-people-cud"; +import { JSONContent } from "@tiptap/core"; +import { generateClient } from "aws-amplify/data"; +import { compact, flow, map } from "lodash/fp"; +const client = generateClient(); + +const createTodo = async (block: JSONContent) => { + if (block.type !== "taskItem") return; + const { data, errors } = await createTodoApi( + stringifyBlock(block), + block.attrs?.checked + ); + if (errors) handleApiErrors(errors, "Creating todo failed"); + if (!data) return; + return data.id; +}; + +export const createNoteBlock = async ( + activityId: string, + block: JSONContent, + parentType?: string +) => { + const todoId = await createTodo(block); + const { data, errors } = await createBlockApi( + activityId, + !todoId ? stringifyBlock(block) : null, + todoId, + !block.type + ? "paragraph" + : parentType === "orderedList" + ? "listItemOrdered" + : block.type + ); + if (errors) handleApiErrors(errors, "Creating note block failed"); + if (!data) return; + + const peopleIds = flow(getPeopleMentioned, map(getPersonId), compact)(block); + + if (peopleIds.length > 0) { + await Promise.all( + peopleIds.map((id) => createNoteBlockPerson(data.id, id)) + ); + } + + return data.id; +}; + +const createNoteBlockPerson = async (blockId: string, personId: string) => { + const { errors } = await createMentionedPersonApi(blockId, personId); + if (errors) handleApiErrors(errors, "Linking note block/person failed"); +}; + +export const addActivityIdToInbox = async ( + inboxItemId: string, + activityId: string +) => { + const { data, errors } = await client.models.Inbox.update({ + id: inboxItemId, + status: "done", + movedToActivityId: activityId, + }); + if (errors) handleApiErrors(errors, "Linking inbox item/activity failed"); + return data?.id; +}; + +export const updateMovedItemToPersonId = async ( + inboxItemId: string, + personLearningId: string +) => { + const { data, errors } = await client.models.Inbox.update({ + id: inboxItemId, + movedToPersonLearningId: personLearningId, + status: "done", + }); + if (errors) handleApiErrors(errors, "Linking inbox item/person failed"); + return data?.id; +}; diff --git a/components/inbox/inboxWorkflow.ts b/components/inbox/inboxWorkflow.ts new file mode 100644 index 000000000..10f25ac71 --- /dev/null +++ b/components/inbox/inboxWorkflow.ts @@ -0,0 +1,178 @@ +import { + BookOpenCheck, + Check, + CloudLightning, + HandHelping, + Stars, + StepForward, + Trash2, + User, + X, +} from "lucide-react"; + +export type WorkflowStatus = + | "new" + | "actionable" + | "notActionable" + | "doNow" + | "addToProject" + | "addToPerson" + | "addToPersonWithPrayer" + | "confirmDeletion" + | "done"; +export type WorkflowStepIcon = typeof Stars; + +export type WorkflowStatusWithActions = + | "addToProject" + | "addToPerson" + | "addToPersonWithPrayer" + | "done"; + +export const statusWithAction = [ + "addToProject", + "addToPerson", + "addToPersonWithPrayer", + "done", +] as const; + +type WorkflowStart = { + status: WorkflowStatus; + statusName: string; + question: string; + StepIcon: WorkflowStepIcon; + responses?: WorkflowResponse[]; +}; +export type WorkflowResponse = WorkflowStart & { + decisionName: string; + takeAction?: true; +}; + +export type WorkflowStep = WorkflowStart | WorkflowResponse; + +const makeAddToProjectDecision = (decisionName: string): WorkflowResponse => ({ + decisionName, + status: "addToProject", + statusName: "Add to project", + StepIcon: BookOpenCheck, + question: "Select Project:", + responses: [ + { + decisionName: "Done", + status: "addToProject", + statusName: "Done", + StepIcon: Check, + question: "Done!", + takeAction: true, + }, + ], +}); + +export const workflow: WorkflowStart = { + status: "new", + statusName: "New Item", + question: "Is it actionable?", + StepIcon: Stars, + responses: [ + { + decisionName: "Yes", + status: "actionable", + statusName: "Actionable", + StepIcon: StepForward, + question: "Doable in 2 minutes?", + responses: [ + { + decisionName: "Yes", + status: "doNow", + statusName: "Do Now", + StepIcon: CloudLightning, + question: "Done?", + responses: [ + { + decisionName: "Yes", + status: "done", + statusName: "Done", + StepIcon: Check, + question: "Done!", + takeAction: true, + }, + ], + }, + makeAddToProjectDecision("No"), + ], + }, + { + decisionName: "No", + status: "notActionable", + statusName: "Not Actionable", + StepIcon: X, + question: "Where does it belong to?", + responses: [ + makeAddToProjectDecision("Project"), + { + decisionName: "Person", + status: "addToPerson", + statusName: "Add to person", + StepIcon: User, + question: "Relevant for prayer?", + responses: [ + { + decisionName: "Yes", + status: "addToPersonWithPrayer", + statusName: "Done", + StepIcon: HandHelping, + question: "Done!", + takeAction: true, + }, + { + decisionName: "No", + status: "addToPerson", + statusName: "Done", + StepIcon: X, + question: "Done!", + takeAction: true, + }, + ], + }, + { + decisionName: "Trash", + status: "confirmDeletion", + statusName: "Confirm deletion", + StepIcon: Trash2, + question: "Are you sure you want to delete the item?", + responses: [ + { + decisionName: "Yes", + status: "done", + statusName: "Trashed", + StepIcon: Check, + question: "Done!", + takeAction: true, + }, + { + decisionName: "No", + status: "notActionable", + statusName: "Not Trashed", + StepIcon: X, + question: "Done!", + }, + ], + }, + ], + }, + ], +}; + +export const findStepByStatus = + (status: WorkflowStatus) => + (workflow: WorkflowStep): WorkflowStep | null => { + if (workflow.status === status) return workflow; + + if (workflow.responses) { + for (const response of workflow.responses) { + const foundStep = findStepByStatus(status)(response); + if (foundStep) return foundStep; + } + } + + return null; + }; diff --git a/components/inbox/workflow.tsx b/components/inbox/workflow.tsx deleted file mode 100644 index d14841902..000000000 --- a/components/inbox/workflow.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { InboxStatus } from "@/api/useInbox"; -import { type VariantProps } from "class-variance-authority"; -import { buttonVariants } from "../ui/button"; -import ClarifyAction from "./ClarifyAction"; -import Question from "./Question"; - -export type WorkflowStepComponentProps = { - question?: string; - responses?: WorkflowStepResponse[]; - action: (response: WorkflowStepResponse, projectId?: string) => void; -}; - -export type WorkflowStepResponse = { - response: string; - btnVariant?: VariantProps; - nextStep?: WorkflowStep; -}; - -type WorkflowStep = { - status: InboxStatus; - question?: string; - toHome?: boolean; - action?: (id: string, setting?: any) => void; - component?: React.FC; - responses?: WorkflowStepResponse[]; -}; - -export const workflow: WorkflowStep = { - status: "new", - question: "Is it actionable?", - component: Question, - responses: [ - { - response: "Yes", - btnVariant: { variant: "constructive" }, - nextStep: { - status: "actionable", - question: "Doable in 2 minutes?", - component: Question, - responses: [ - { - response: "Yes", - btnVariant: { variant: "constructive" }, - nextStep: { - status: "doNow", - question: "Done?", - component: Question, - responses: [ - { - response: "Yes", - btnVariant: { variant: "constructive" }, - nextStep: { - status: "done", - toHome: true, - }, - }, - ], - }, - }, - { - response: "No", - btnVariant: { variant: "destructive" }, - nextStep: { - status: "clarifyAction", - component: ClarifyAction, - responses: [ - { - response: "next", - nextStep: { status: "done", toHome: true }, - }, - ], - }, - }, - ], - }, - }, - { - response: "No", - btnVariant: { variant: "destructive" }, - nextStep: { - status: "notActionable", - question: "Move to a project?", - component: Question, - responses: [ - { - response: "Yes", - btnVariant: { variant: "constructive" }, - nextStep: { - status: "moveToProject", - component: ClarifyAction, - responses: [ - { - response: "next", - nextStep: { status: "done", toHome: true }, - }, - ], - }, - }, - { - response: "No", - btnVariant: { variant: "destructive" }, - nextStep: { - status: "clarifyDeletion", - question: "Confirm deletion:", - component: Question, - responses: [ - { - response: "Yes", - btnVariant: { variant: "destructive" }, - nextStep: { status: "done", toHome: true }, - }, - ], - }, - }, - ], - }, - }, - ], -}; - -export const getWorkflowStepByStatus = ( - workflowStep: WorkflowStep, - desiredStatus?: InboxStatus -): WorkflowStep | null => { - if (!desiredStatus) { - return workflowStep; - } - - if (workflowStep.status === desiredStatus) { - return workflowStep; - } - - // If there are responses, iterate over them to find the next steps - if (workflowStep.responses) { - for (const response of workflowStep.responses) { - if (response.nextStep) { - // Recursively search in the next steps - const foundStep = getWorkflowStepByStatus( - response.nextStep, - desiredStatus - ); - if (foundStep) { - return foundStep; - } - } - } - } - - // Return null if the step is not found - return null; -}; - -// Function to find the previous status by a given status -export const getPreviousStatusByStatus = ( - workflowStep: WorkflowStep, - currentStatus: InboxStatus, - parentStatus: InboxStatus | null = null -): InboxStatus | null => { - // Check if the current step matches the desired status - if (workflowStep.status === currentStatus) { - return parentStatus; - } - - // If there are responses, iterate over them to find the next steps - if (workflowStep.responses) { - for (const response of workflowStep.responses) { - if (response.nextStep) { - // Recursively search in the next steps, passing the current status as parentStatus - const foundStatus = getPreviousStatusByStatus( - response.nextStep, - currentStatus, - workflowStep.status - ); - if (foundStatus) { - return foundStatus; - } - } - } - } - - // Return null if the status is not found - return null; -}; diff --git a/components/layouts/MainLayout.tsx b/components/layouts/MainLayout.tsx index 1e0dee500..b6083334f 100644 --- a/components/layouts/MainLayout.tsx +++ b/components/layouts/MainLayout.tsx @@ -9,11 +9,8 @@ import { import { addKeyDownListener } from "@/helpers/keyboard-events/main-layout"; import Head from "next/head"; import { useRouter } from "next/router"; -import { FC, ReactNode, useEffect } from "react"; -import CreateInboxItemDialog, { - CreateInboxItemProvider, - useCreateInboxItemContext, -} from "../inbox/CreateInboxItemDialog"; +import { FC, ReactNode, useEffect, useState } from "react"; +import CreateInboxItemDialog from "../inbox/CreateInboxItemDialog"; import { Toaster } from "../ui/toaster"; type MainLayoutProps = CategoryTitleProps & { @@ -31,20 +28,15 @@ const MainLayoutInner: FC = ({ ...categoryTitleProps }) => { const { toggleMenu } = useNavMenuContext(); - const { open: openCreateInboxItemDialog } = useCreateInboxItemContext(); + const [isOpen, setIsOpen] = useState(false); const { context: storedContext, setContext } = useContextContext(); const context = propsContext || storedContext || "family"; const router = useRouter(); useEffect( () => - addKeyDownListener( - router, - setContext, - toggleMenu, - openCreateInboxItemDialog - ), - [openCreateInboxItemDialog, router, setContext, toggleMenu] + addKeyDownListener(router, setContext, toggleMenu, () => setIsOpen(true)), + [router, setContext, toggleMenu] ); return ( @@ -67,7 +59,7 @@ const MainLayoutInner: FC = ({
- +
@@ -76,9 +68,7 @@ const MainLayoutInner: FC = ({ const MainLayout: FC = (props) => ( - - - + ); diff --git a/components/navigation-menu/NavigationMenu.tsx b/components/navigation-menu/NavigationMenu.tsx index a1dc83eec..c952abbd2 100644 --- a/components/navigation-menu/NavigationMenu.tsx +++ b/components/navigation-menu/NavigationMenu.tsx @@ -16,7 +16,6 @@ import { } from "react-icons/bi"; import { GoTasklist } from "react-icons/go"; import { IconType } from "react-icons/lib"; -import { useCreateInboxItemContext } from "../inbox/CreateInboxItemDialog"; import { CommandDialog, CommandEmpty, @@ -53,7 +52,6 @@ type NavigationItem = (UrlNavigationItem | ActionNavigationItem) & { const NavigationMenu = () => { const { isWorkContext, isFamilyContext, context } = useContextContext(); const { menuIsOpen, toggleMenu } = useNavMenuContext(); - const { open: openCreateInboxItemDialog } = useCreateInboxItemContext(); const { projects, createProject } = useProjectsContext(); const { accounts } = useAccountsContext(); const { bible } = useBible(); @@ -144,10 +142,6 @@ const NavigationMenu = () => { ); const createItemsNavigation: NavigationItem[] = [ - { - label: "Inbox Item", - action: openCreateInboxItemDialog, - }, { label: "Meeting", action: createAndOpenMeeting }, { label: "Person", action: createAndOpenPerson }, { label: "Project", action: createAndOpenProject }, diff --git a/components/planning/week/PlanWeekAction.tsx b/components/planning/week/PlanWeekAction.tsx new file mode 100644 index 000000000..f98a1d250 --- /dev/null +++ b/components/planning/week/PlanWeekAction.tsx @@ -0,0 +1,13 @@ +import { FC } from "react"; + +type PlanWeekActionProps = { + label: string; +}; + +const PlanWeekAction: FC = ({ label }) => ( +
+ Next Action: {label} +
+); + +export default PlanWeekAction; diff --git a/components/planning/week/PlanWeekStatistics.tsx b/components/planning/week/PlanWeekStatistics.tsx index ad3104080..f38b64a12 100644 --- a/components/planning/week/PlanWeekStatistics.tsx +++ b/components/planning/week/PlanWeekStatistics.tsx @@ -8,14 +8,8 @@ const PlanWeekStatistics = () => { return (
- {!weekPlan ? ( - "Start Week Planning to review a list of projects for the current context." - ) : ( + {weekPlan && (
-
- Review each project and decide if you can make progress here during - the next week. -
Projects to be reviewed: {openProjectsCount}
Projects on hold: {onholdProjectsCount}
Projects in focus: {focusProjectsCount}
diff --git a/components/ui-elements/editors/helpers/editor-effects.ts b/components/ui-elements/editors/helpers/editor-effects.ts index 26c2c5821..2a7808a37 100644 --- a/components/ui-elements/editors/helpers/editor-effects.ts +++ b/components/ui-elements/editors/helpers/editor-effects.ts @@ -47,7 +47,7 @@ export const applyPastePropsAndUiAttrs = ( }), attributes: { class: cn( - "prose w-full max-w-full text-notesEditor rounded-md p-2 bg-inherit transition duration-1000 ease", + "prose w-full max-w-full text-notesEditor rounded-md border p-2 bg-inherit transition duration-1000 ease", showSaveStatus && content && !readonly && diff --git a/components/ui-elements/editors/inbox-editor/InboxEditor.tsx b/components/ui-elements/editors/inbox-editor/InboxEditor.tsx index 1824b4d80..312731896 100644 --- a/components/ui-elements/editors/inbox-editor/InboxEditor.tsx +++ b/components/ui-elements/editors/inbox-editor/InboxEditor.tsx @@ -8,6 +8,7 @@ import { updateEditorContent, } from "../helpers/editor-effects"; import MetaData from "../meta-data"; +import { isCmdEnter } from "./helpers"; import useExtensions from "./useExtensions"; type InboxEditorProps = { @@ -15,6 +16,7 @@ type InboxEditorProps = { createdAt?: Date; updatedAt?: Date; saveNotes?: (editor: Editor) => void; + saveAtCmdEnter?: (editor: Editor) => void; autoFocus?: boolean; readonly?: boolean; showSaveStatus?: boolean; @@ -23,6 +25,7 @@ type InboxEditorProps = { const InboxEditor: FC = ({ notes, saveNotes, + saveAtCmdEnter, autoFocus, readonly, createdAt, @@ -40,6 +43,18 @@ const InboxEditor: FC = ({ if (!saveNotes) return; saveNotes(editor); }, + editorProps: { + ...(!saveAtCmdEnter + ? {} + : { + handleKeyDown: (_view, event) => { + if (!editor) return false; + if (!isCmdEnter(event)) return false; + saveAtCmdEnter(editor); + return true; + }, + }), + }, }); /** Handle changes on activity.notes, editor's content, extensions, or readonly state */ diff --git a/components/ui-elements/editors/inbox-editor/helpers.ts b/components/ui-elements/editors/inbox-editor/helpers.ts new file mode 100644 index 000000000..b30fd0558 --- /dev/null +++ b/components/ui-elements/editors/inbox-editor/helpers.ts @@ -0,0 +1,3 @@ +export const isCmdEnter = (event: KeyboardEvent) => { + return event.key === "Enter" && (event.metaKey || event.ctrlKey); +}; diff --git a/components/ui-elements/editors/todo-editor/TodoEditor.tsx b/components/ui-elements/editors/todo-editor/TodoEditor.tsx index b90bad40e..f716f4b6c 100644 --- a/components/ui-elements/editors/todo-editor/TodoEditor.tsx +++ b/components/ui-elements/editors/todo-editor/TodoEditor.tsx @@ -3,8 +3,9 @@ import { cn } from "@/lib/utils"; import { JSONContent } from "@tiptap/core"; import { EditorContent, useEditor } from "@tiptap/react"; import { Loader2, Save } from "lucide-react"; -import { FC, useEffect, useState } from "react"; +import { FC, useCallback, useEffect, useState } from "react"; import LinkBubbleMenu from "../extensions/link-bubble-menu/LinkBubbleMenu"; +import { isCmdEnter } from "../inbox-editor/helpers"; import useTodoEditorExtensions from "./useTodoEditorExtensions"; type TodoEditorProps = { @@ -20,23 +21,32 @@ const TodoEditor: FC = ({ onSave }) => { immediatelyRender: false, }); + const saveItem = useCallback( + async (json: JSONContent) => { + setIsSaving(true); + const todoId = await onSave(json); + if (!todoId) return; + setIsSaving(false); + }, + [onSave] + ); + useEffect(() => { if (!editor) return; editor.setOptions({ editorProps: { attributes: { - class: "prose w-full max-w-full px-2 bg-inherit border rounded-md", + class: "prose w-full max-w-full p-2 bg-inherit border rounded-md", + }, + handleKeyDown: (_view, event) => { + if (!editor) return false; + if (!isCmdEnter(event)) return false; + saveItem(editor.getJSON()); + return true; }, }, }); - }, [editor]); - - const saveItem = async (json: JSONContent) => { - setIsSaving(true); - const todoId = await onSave(json); - if (!todoId) return; - setIsSaving(false); - }; + }, [editor, saveItem]); return ( <> diff --git a/components/ui-elements/list-items/list-item.tsx b/components/ui-elements/list-items/list-item.tsx deleted file mode 100644 index 99ce8dcf9..000000000 --- a/components/ui-elements/list-items/list-item.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { FC, ReactNode } from "react"; - -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 deleted file mode 100644 index 249c26bc6..000000000 --- a/components/ui-elements/list-items/to-process-item.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { FC, ReactNode } from "react"; -import { IoAddCircleOutline } from "react-icons/io5"; -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/tokens/account-name.tsx b/components/ui-elements/tokens/account-name.tsx deleted file mode 100644 index 82fdc4e3a..000000000 --- a/components/ui-elements/tokens/account-name.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useAccountsContext } from "@/api/ContextAccounts"; -import { FC, useEffect, useState } from "react"; - -type AccountNameProps = { - accountId: string; - noLinks?: boolean; -}; - -const AccountName: FC = ({ accountId, noLinks }) => { - const { getAccountById } = useAccountsContext(); - const [account, setAccount] = useState(() => getAccountById(accountId)); - - useEffect(() => { - setAccount(getAccountById(accountId)); - }, [accountId, getAccountById]); - - return !account ? ( - "…" - ) : ( -
- {noLinks ? ( - account.name - ) : ( - - {account.name} - - )} -
- ); -}; - -export default AccountName; diff --git a/components/ui-elements/tokens/project-name.tsx b/components/ui-elements/tokens/project-name.tsx deleted file mode 100644 index 12add7a97..000000000 --- a/components/ui-elements/tokens/project-name.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useProjectsContext } from "@/api/ContextProjects"; -import { FC, useEffect, useState } from "react"; -import { IoCheckboxSharp } from "react-icons/io5"; -import AccountName from "./account-name"; - -type ProjectNameProps = { - projectId: string; - noLinks?: boolean; -}; - -const ProjectName: FC = ({ projectId, noLinks }) => { - const { getProjectById } = useProjectsContext(); - const [project, setProject] = useState(() => getProjectById(projectId)); - - useEffect(() => { - setProject(getProjectById(projectId)); - }, [getProjectById, projectId]); - - return !project ? ( - "Loading…" - ) : ( -
- {project.done && } - {noLinks ? ( - project.project - ) : ( - - {!project ? "..." : project.project} - - )} - {project.accountIds.map((accountId) => ( - - - - ))} -
- ); -}; - -export default ProjectName; diff --git a/docs/releases/next.md b/docs/releases/next.md index 03061ac5b..17684ce36 100644 --- a/docs/releases/next.md +++ b/docs/releases/next.md @@ -1,9 +1,14 @@ -# Schnelles Hinzufügen von Aufgaben ermöglichen (Version :VERSION) +# Verarbeiten der Inbox optimieren (Version :VERSION) -- Einen Button hinzugefügt, um schnell ein neues Todo hinzufügen zu können. -- Dieser Button erscheint bei der täglichen Aufgabenliste. -- Dieser Button erscheint bei der Projekt-Aufgabenliste. -- Die Liste der nächsten Aufgaben im Projekt wird nun besser aktuell gehalten, wenn neue Todos hinzu kommen oder sich deren Status ändert. +- Die Inbox zeigt jetzt immer nur den ersten offenen Eintrag an, so dass die Inbox ganz konzentriert bearbeitet werden kann. +- Neue Inbox Einträge können nun mit Cmd+Enter gespeichert werden. +- Der Workflow für Inbox-Einträge ist vollständig überarbeitet und insgesamt schlüssiger und schneller. Die getroffene Entscheidung wird ausschließlich am Ende gespeichert und nicht mehr zwischendurch. +- Inbox-Einträge können nun auch als Gelerntes über Personen gespeichert werden. +- In der Wochenplanung ist nun eine kleine Checkliste eingeführt. Zunächst werden offene Inbox-Einträge verarbeitet, dann aktuelle Umsätze und Projekte der Kunden geladen und schließlich – wie zuvor auch – die Projekte überprüft. + +## Kleinere Verbesserungen + +- Im Dialog zum Erstellen neuer Aufgaben muss nun nicht mehr der Speichern-Button gedrückt werden; es kann nun auch mit der Tastenkombination Cmd+Enter gespeichert werden. ## In Arbeit @@ -32,13 +37,7 @@ - In Wochenplanung persönliche Termine mit berücksichtigen (Geburtstage, Jahrestage). - Ich möchte einfache Todos haben, die keinem Projekt zugeordnet sind. -- Eine Checkliste einführen für das wöchentliche oder tägliche Planen. - -### Inbox - -- Die Verarbeitung in der Inbox soll auch ermöglichen Gelerntes zu Personen abzulegen. -- Wenn die Internetverbindung gerade nicht so stabil ist und ein neues Inbox Item erstellt wird, kann es eine Weile dauern und in der Zeit ist für den Anwender nicht sichtbar, dass der Eintrag gerade gespeichert wird. -- Die Inobx ist nicht wirklich toll und schnell. Das muss vom Ablauf her besser werden. +- Eine Checkliste einführen für das tägliche Planen. ### Projekte diff --git a/pages/inbox.tsx b/pages/inbox.tsx new file mode 100644 index 000000000..823d84786 --- /dev/null +++ b/pages/inbox.tsx @@ -0,0 +1,29 @@ +import CreateInboxItemDialog from "@/components/inbox/CreateInboxItemDialog"; +import ProcessInboxItem from "@/components/inbox/ProcessInboxItem"; +import MainLayout from "@/components/layouts/MainLayout"; +import ContextSwitcher from "@/components/navigation-menu/ContextSwitcher"; +import { Button } from "@/components/ui/button"; +import { PlusCircle } from "lucide-react"; + +const InboxPage = () => { + return ( + +
+ + + Create Item + + } + /> + + + + +
+
+ ); +}; + +export default InboxPage; diff --git a/pages/inbox/[id].tsx b/pages/inbox/[id].tsx deleted file mode 100644 index 42aa997d6..000000000 --- a/pages/inbox/[id].tsx +++ /dev/null @@ -1,40 +0,0 @@ -import useInboxItem from "@/api/useInboxItem"; -import WorkFlowItem from "@/components/inbox/WorkflowItem"; -import ApiLoadingError from "@/components/layouts/ApiLoadingError"; -import MainLayout from "@/components/layouts/MainLayout"; -import ContextSwitcher from "@/components/navigation-menu/ContextSwitcher"; -import ToProcessItem from "@/components/ui-elements/list-items/to-process-item"; -import { useRouter } from "next/router"; -import { GrCycle } from "react-icons/gr"; - -const InboxDetailPage = () => { - const router = useRouter(); - const { id } = router.query; - const itemId = Array.isArray(id) ? id[0] : id; - const { inboxItem, error, isLoading, mutate } = useInboxItem(itemId); - - return ( - -
- - - - - - -
- } - actionStep={} - /> -
- - ); -}; - -export default InboxDetailPage; diff --git a/pages/inbox/index.tsx b/pages/inbox/index.tsx deleted file mode 100644 index d5a0c733f..000000000 --- a/pages/inbox/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import useInbox from "@/api/useInbox"; -import { useCreateInboxItemContext } from "@/components/inbox/CreateInboxItemDialog"; -import WorkFlowItem from "@/components/inbox/WorkflowItem"; -import ApiLoadingError from "@/components/layouts/ApiLoadingError"; -import MainLayout from "@/components/layouts/MainLayout"; -import ContextSwitcher from "@/components/navigation-menu/ContextSwitcher"; -import ToProcessItem from "@/components/ui-elements/list-items/to-process-item"; -import { Button } from "@/components/ui/button"; -import { Editor } from "@tiptap/core"; -import { debounce } from "lodash"; -import { Plus } from "lucide-react"; -import { GrCycle } from "react-icons/gr"; - -type ApiResponse = Promise; -type UpdateInboxFn = (id: string, editor: Editor) => ApiResponse; - -export const debouncedOnChangeInboxNote = debounce( - async (id: string, editor: Editor, updateNote: UpdateInboxFn) => { - const data = await updateNote(id, editor); - if (!data) return; - }, - 1500 -); - -const CreateItemButton = () => { - const { open } = useCreateInboxItemContext(); - return ( -
- -
- ); -}; - -const InboxPage = () => { - const { inbox, error, mutate, isLoading } = useInbox(); - - return ( - -
- - - - - - - {inbox?.map((item) => ( -
- - } - actionStep={} - /> -
-
- ))} -
-
- ); -}; - -export default InboxPage; diff --git a/pages/planweek.tsx b/pages/planweek.tsx index d424cbc96..03f4b6029 100644 --- a/pages/planweek.tsx +++ b/pages/planweek.tsx @@ -1,3 +1,9 @@ +import useInbox from "@/api/useInbox"; +import useMrrLatestUpload from "@/api/useLatestUploadsWork"; +import InstructionsUploadMrr from "@/components/analytics/instructions/instructions-upload-mrr"; +import { MrrFilterProvider } from "@/components/analytics/useMrrFilter"; +import ImportProjectData from "@/components/crm/import-project-data"; +import ProcessInboxItem from "@/components/inbox/ProcessInboxItem"; import ApiLoadingError from "@/components/layouts/ApiLoadingError"; import MainLayout from "@/components/layouts/MainLayout"; import ContextSwitcher from "@/components/navigation-menu/ContextSwitcher"; @@ -6,16 +12,21 @@ import { useWeekPlanContext, withWeekPlan, } from "@/components/planning/useWeekPlanContext"; +import PlanWeekAction from "@/components/planning/week/PlanWeekAction"; import PlanWeekContextNotWork from "@/components/planning/week/PlanWeekContextNotWork"; import PlanWeekContextWork from "@/components/planning/week/PlanWeekContextWork"; import PlanWeekFilter from "@/components/planning/week/PlanWeekFilter"; import PlanWeekForm from "@/components/planning/week/PlanWeekForm"; import PlanWeekStatistics from "@/components/planning/week/PlanWeekStatistics"; +import { Accordion } from "@/components/ui/accordion"; import { useContextContext } from "@/contexts/ContextContext"; const WeeklyPlanningPage = () => { const { context } = useContextContext(); const { error } = useWeekPlanContext(); + const { inbox } = useInbox(); + const { mrrUploadTooOld, mutateMrr, sfdcUploadTooOld, mutateSfdc } = + useMrrLatestUpload(); return ( @@ -28,14 +39,38 @@ const WeeklyPlanningPage = () => {
- - + {inbox && inbox.length > 0 ? ( + <> + + + + ) : context === "work" && mrrUploadTooOld ? ( + <> + + + + + + + + ) : context === "work" && sfdcUploadTooOld ? ( + <> + + + + ) : ( + <> + + + - + - {context !== "work" && } - {context === "work" && } - + {context !== "work" && } + {context === "work" && } + + + )}
);