diff --git a/amplify/data/account-schema.ts b/amplify/data/account-schema.ts index 6c0ed154c..a806c8570 100644 --- a/amplify/data/account-schema.ts +++ b/amplify/data/account-schema.ts @@ -7,6 +7,8 @@ export const tablesWithDeleteProtection = [ "AccountPayerAccount", "PayerAccount", "Account", + // "AccountLearning", + // "AccountLearningPerson", ]; const accountSchema = { diff --git a/api/useAccountLearnings.ts b/api/useAccountLearnings.ts new file mode 100644 index 000000000..614db6ed6 --- /dev/null +++ b/api/useAccountLearnings.ts @@ -0,0 +1,226 @@ +import { type Schema } from "@/amplify/data/resource"; +import { emptyDocument } from "@/components/ui-elements/editors/helpers/document"; +import { toast } from "@/components/ui/use-toast"; +import { invertSign, not, toISODateString } from "@/helpers/functional"; +import { transformNotesVersion } from "@/helpers/ui-notes-writer"; +import { JSONContent } from "@tiptap/core"; +import { generateClient } from "aws-amplify/data"; +import { getTime } from "date-fns"; +import { + filter, + flatMap, + flow, + get, + identity, + includes, + map, + sortBy, + uniq, +} from "lodash/fp"; +import useSWR from "swr"; +import { handleApiErrors } from "./globals"; + +const client = generateClient(); + +export type AccountLearning = { + id: string; + learning: JSONContent; + learnedOn: Date; + updatedAt: Date; +}; + +type AccountLearningData = Schema["AccountLearning"]["type"]; + +const mapLearning = ({ + id, + createdAt, + updatedAt, + learnedOn, + learning, +}: AccountLearningData): AccountLearning => ({ + id, + learning: transformNotesVersion({ + formatVersion: 2, + notes: "", + notesJson: learning, + }), + learnedOn: new Date(learnedOn || createdAt), + updatedAt: new Date(updatedAt), +}); + +const fetchLearnings = (accountId?: string) => async () => { + if (!accountId) return; + const { data, errors } = + await client.models.AccountLearning.listAccountLearningByAccountId( + { accountId }, + { filter: { status: { eq: "new" } } } + ); + if (errors) { + handleApiErrors(errors, "Loading learnings about account failed"); + throw errors; + } + try { + return flow( + map(mapLearning), + sortBy( + flow(identity, get("learnedOn"), getTime, invertSign) + ) + )(data); + } catch (error) { + console.error("fetchLearnings", error); + throw error; + } +}; + +const useAccountLearnings = (accountId?: string) => { + const { + data: learnings, + isLoading, + error, + mutate, + } = useSWR(`/api/accounts/${accountId}/learnings`, fetchLearnings(accountId)); + + const createLearning = async () => { + if (!accountId) return; + const updated = [ + { + id: crypto.randomUUID(), + learnedOn: new Date(), + learning: emptyDocument, + updatedAt: new Date(), + } as AccountLearning, + ...(learnings || []), + ]; + mutate(updated, false); + const { data, errors } = await client.models.AccountLearning.create({ + accountId, + learnedOn: toISODateString(new Date()), + status: "new", + }); + if (errors) handleApiErrors(errors, "Creating learning on account failed"); + mutate(updated); + return data?.id; + }; + + const deleteLearning = async (learningId: string) => { + const updated = learnings?.filter((l) => l.id !== learningId); + if (updated) mutate(updated, false); + const { data, errors } = await client.models.AccountLearning.delete({ + id: learningId, + }); + if (errors) handleApiErrors(errors, "Deleting learning failed"); + if (updated) mutate(updated); + if (!data) return; + toast({ title: "Learning about account deleted" }); + return data.id; + }; + + const updateDate = async (learningId: string, date: Date) => { + const updated = learnings?.map((l) => + l.id !== learningId ? l : { ...l, learnedOn: date } + ); + if (updated) mutate(updated, false); + const { data, errors } = await client.models.AccountLearning.update({ + id: learningId, + learnedOn: toISODateString(date), + }); + if (errors) handleApiErrors(errors, "Updating learning's date failed"); + if (updated) mutate(updated); + return data?.id; + }; + + const getMentionedPeople = async (learningId: string) => { + const { data, errors } = + await client.models.AccountLearningPerson.listAccountLearningPersonByLearningId( + { learningId }, + { selectionSet: ["id", "personId"] } + ); + if (errors) handleApiErrors(errors, "Loading mentioned people failed"); + return data; + }; + + const addMentionedPerson = async (learningId: string, personId: string) => { + const { data, errors } = await client.models.AccountLearningPerson.create({ + learningId, + personId, + }); + if (errors) handleApiErrors(errors, "Adding mentioned person failed"); + return data?.personId; + }; + + const removeMentionedPerson = async (recordId: string) => { + const { data, errors } = await client.models.AccountLearningPerson.delete({ + id: recordId, + }); + if (errors) handleApiErrors(errors, "Removing mentioned person failed"); + return data?.personId; + }; + + const updateMentionedPeople = async ( + learningId: string, + learning: JSONContent + ) => { + const peopleIds = flow( + identity, + get("content"), + flatMap("content"), + filter({ type: "mention" }), + map("attrs.id"), + uniq + )(learning) as string[]; + const existingPeople = await getMentionedPeople(learningId); + const toAdd = peopleIds.filter((id) => + flow( + identity, + map("personId"), + includes(id), + not + )(existingPeople) + ); + const toRemove = flow( + identity, + filter(({ personId }) => !peopleIds.includes(personId)), + map("id") + )(existingPeople); + const added = await Promise.all( + toAdd.map((personId) => addMentionedPerson(learningId, personId)) + ); + const removed = await Promise.all(toRemove.map(removeMentionedPerson)); + return { added, removed }; + }; + + const updateLearning = async (learningId: string, learning: JSONContent) => { + if (!learnings) return; + const updated = learnings.map((l) => + l.id !== learningId + ? l + : ({ + ...l, + learning, + updatedAt: new Date(), + } as AccountLearning) + ); + mutate(updated, false); + const { data, errors } = await client.models.AccountLearning.update({ + id: learningId, + learning: JSON.stringify(learning), + }); + if (errors) + handleApiErrors(errors, "Error updating learning about account"); + mutate(updated); + await updateMentionedPeople(learningId, learning); + return data?.id; + }; + + return { + learnings, + isLoading, + error, + createLearning, + deleteLearning, + updateLearning, + updateDate, + }; +}; + +export default useAccountLearnings; diff --git a/api/useInbox.ts b/api/useInbox.ts index 73c602eb6..d8d08c831 100644 --- a/api/useInbox.ts +++ b/api/useInbox.ts @@ -189,6 +189,7 @@ const useInbox = () => { learnedOn: toISODateString(item.createdAt), learning: stringifyBlock(item.note), prayer: !withPrayer ? undefined : "PRAYING", + status: "new", }); if (errors) handleApiErrors(errors, "Error moving inbox item to person"); if (updated) mutate(updated); diff --git a/api/usePersonLearnings.ts b/api/usePersonLearnings.ts index 3900c3167..74221e5f1 100644 --- a/api/usePersonLearnings.ts +++ b/api/usePersonLearnings.ts @@ -2,11 +2,12 @@ import { type Schema } from "@/amplify/data/resource"; import { TPrayerStatus } from "@/components/prayer/PrayerStatus"; import { emptyDocument } from "@/components/ui-elements/editors/helpers/document"; import { toast } from "@/components/ui/use-toast"; -import { toISODateString } from "@/helpers/functional"; +import { invertSign, toISODateString } from "@/helpers/functional"; import { transformNotesVersion } from "@/helpers/ui-notes-writer"; import { JSONContent } from "@tiptap/core"; import { generateClient } from "aws-amplify/data"; -import { flow, map, sortBy } from "lodash/fp"; +import { getTime } from "date-fns"; +import { flow, get, identity, map, sortBy } from "lodash/fp"; import useSWR from "swr"; import { handleApiErrors } from "./globals"; const client = generateClient(); @@ -41,9 +42,12 @@ const mapLearning = ({ const fetchLearnings = (personId?: string) => async () => { if (!personId) return; const { data, errors } = - await client.models.PersonLearning.listPersonLearningByPersonId({ - personId, - }); + await client.models.PersonLearning.listPersonLearningByPersonId( + { + personId, + }, + { filter: { status: { eq: "new" } } } + ); if (errors) { handleApiErrors(errors, "Error loading person learnings"); throw errors; @@ -52,7 +56,9 @@ const fetchLearnings = (personId?: string) => async () => { try { return flow( map(mapLearning), - sortBy((l) => -l.learnedOn.getTime()) + sortBy( + flow(identity, get("learnedOn"), getTime, invertSign) + ) )(data); } catch (error) { console.error("fetchLearnings", { error }); @@ -83,6 +89,7 @@ const usePersonLearnings = (personId?: string) => { personId, learnedOn: toISODateString(new Date()), prayer: "NONE", + status: "new", }); if (errors) handleApiErrors(errors, "Creating learning on person failed"); mutate(updated); diff --git a/components/accounts/AccountDetails.tsx b/components/accounts/AccountDetails.tsx index 797f5b616..040256471 100644 --- a/components/accounts/AccountDetails.tsx +++ b/components/accounts/AccountDetails.tsx @@ -4,6 +4,7 @@ import CrmLink from "../crm/CrmLink"; import { Accordion } from "../ui/accordion"; import AccountFinancials from "./AccountFinancials"; import AccountIntroduction from "./AccountIntroduction"; +import AccountLearnings from "./AccountLearnings"; import AccountNotes from "./AccountNotes"; import AccountPayerAccounts from "./AccountPayerAccounts"; import AccountPeople from "./AccountPeople"; @@ -73,6 +74,7 @@ const AccountDetails: FC = ({ /> + diff --git a/components/accounts/AccountLearnings.tsx b/components/accounts/AccountLearnings.tsx new file mode 100644 index 000000000..13d3c9864 --- /dev/null +++ b/components/accounts/AccountLearnings.tsx @@ -0,0 +1,100 @@ +import useAccountLearnings from "@/api/useAccountLearnings"; +import { Editor, JSONContent } from "@tiptap/core"; +import { debounce } from "lodash"; +import { flow, map } from "lodash/fp"; +import { PlusCircle } from "lucide-react"; +import { FC, useState } from "react"; +import LearningComponent from "../learnings/LearningComponent"; +import DefaultAccordionItem from "../ui-elements/accordion/DefaultAccordionItem"; +import LoadingAccordionItem from "../ui-elements/accordion/LoadingAccordionItem"; +import { getTextFromJsonContent } from "../ui-elements/editors/helpers/text-generation"; +import { Button } from "../ui/button"; + +interface AccountLearningsProps { + accountId?: string; +} + +type DebouncedUpdateLearningsProps = { + learningId: string; + updateLearning: (learningId: string, learning: JSONContent) => void; + editor: Editor; +}; + +const debouncedUpdateLearnings = debounce( + async ({ + learningId, + updateLearning, + editor, + }: DebouncedUpdateLearningsProps) => { + await updateLearning(learningId, editor.getJSON()); + }, + 1500 +); + +const AccountLearnings: FC = ({ accountId }) => { + const { + learnings, + createLearning, + deleteLearning, + updateLearning, + updateDate, + } = useAccountLearnings(accountId); + const [editId, setEditId] = useState(null); + + const handleCreate = async () => { + const newId = await createLearning(); + if (newId) setEditId(newId); + }; + + const handleLearningUpdate = (learningId: string) => (editor: Editor) => { + debouncedUpdateLearnings({ + learningId, + updateLearning, + editor, + }); + }; + + return !accountId ? ( + + ) : ( + l.slice(0, 2), + map("learning"), + map(getTextFromJsonContent) + )(learnings) + } + > +
+ + + {learnings?.map((learning) => ( + + setEditId(editId === learning.id ? null : learning.id) + } + onDelete={() => deleteLearning(learning.id)} + onChange={handleLearningUpdate(learning.id)} + onDateChange={(date) => updateDate(learning.id, date)} + /> + ))} +
+
+ ); +}; + +export default AccountLearnings; diff --git a/components/learnings/LearningComponent.tsx b/components/learnings/LearningComponent.tsx index 7c9662cf7..9b7700a48 100644 --- a/components/learnings/LearningComponent.tsx +++ b/components/learnings/LearningComponent.tsx @@ -1,6 +1,5 @@ -import { PersonLearning } from "@/api/usePersonLearnings"; import { cn } from "@/lib/utils"; -import { Editor } from "@tiptap/core"; +import { Editor, JSONContent } from "@tiptap/core"; import { format } from "date-fns"; import { Check, Edit, Trash2 } from "lucide-react"; import { FC, useState } from "react"; @@ -9,14 +8,22 @@ import NotesWriter from "../ui-elements/notes-writer/NotesWriter"; import DateSelector from "../ui-elements/selectors/date-selector"; import { Button } from "../ui/button"; +interface ILearning { + id: string; + learning: JSONContent; + learnedOn: Date; + updatedAt: Date; + prayerStatus?: TPrayerStatus; +} + type LearningComponentProps = { - learning: PersonLearning; + learning: ILearning; editable?: boolean; onMakeEditable?: () => void; onDelete?: () => void; onChange: (editor: Editor) => void; - onStatusChange: (val: TPrayerStatus) => void; onDateChange: (newDate: Date) => Promise; + onStatusChange?: (val: TPrayerStatus) => void; }; const LearningComponent: FC = ({ @@ -74,11 +81,15 @@ const LearningComponent: FC = ({ )} - + + {onStatusChange && learning.prayerStatus && ( + + )} +
(); type PersonLearning = { learning: string; learnedOn: Date; - prayerStatus: TPrayerStatus; + prayerStatus?: TPrayerStatus; }; type MeetingNotes = { @@ -109,6 +109,11 @@ const selectionSet = [ "learnings.createdAt", "learnings.learning", "learnings.prayer", + "learnings.status", + "accountLearnings.learning.learnedOn", + "accountLearnings.learning.createdAt", + "accountLearnings.learning.learning", + "accountLearnings.learning.status", "noteBlocks.noteBlock.todo.todo", "noteBlocks.noteBlock.todo.status", "noteBlocks.noteBlock.todo.updatedAt", @@ -134,22 +139,46 @@ const selectionSet = [ ] as const; type PersonData = SelectionSet; -type PersonLearningData = PersonData["learnings"][number]; type Activity = PersonData["meetings"][number]["meeting"]["activities"][number]; +type LearningData = { + learnedOn: string | null; + learning: Schema["AccountLearning"]["type"]["learning"]; + createdAt: string; + status: Schema["LearningStatus"]["type"]; + prayer?: Schema["PrayerStatus"]["type"]; +}; + const mapLearning = ({ learnedOn, createdAt, learning, prayer, -}: PersonLearningData): PersonLearning => ({ +}: LearningData): PersonLearning => ({ learning: !learning ? "" : getTextFromJsonContent(JSON.parse(learning as any)), learnedOn: new Date(learnedOn || createdAt), - prayerStatus: prayer || "NONE", + prayerStatus: prayer, }); +const learningIsNew = ({ + status, +}: { + status: Schema["LearningStatus"]["type"]; +}) => status === "new"; + +const filterAndMapLearnings = ( + learnings: PersonData["learnings"], + accountLearnings: PersonData["accountLearnings"] +) => { + const allLearning = [ + ...learnings.filter(learningIsNew), + ...accountLearnings.map((al) => al.learning).filter(learningIsNew), + ]; + return allLearning.map(mapLearning); +}; + const mapOpenTodos = (noteBlocks: PersonData["noteBlocks"]): string[] => flow( identity, @@ -229,6 +258,7 @@ const mapPerson = ({ relationshipsFrom, relationshipsTo, learnings, + accountLearnings, noteBlocks, meetings, }: PersonData): MentionedPerson => ({ @@ -243,7 +273,7 @@ const mapPerson = ({ relatedPerson: relatedPerson?.name ?? "", })) )(relationshipsFrom, relationshipsTo), - learnings: map(mapLearning)(learnings), + learnings: filterAndMapLearnings(learnings, accountLearnings), openTodos: mapOpenTodos(noteBlocks), notesFromMeetings: flow( identity,