diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index 7a57cab48..c01fb7782 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -87,6 +87,7 @@ const schema = a.schema({ projectsId: a.id().required(), projects: a.belongsTo("Projects", "projectsId"), }) + .secondaryIndexes((index) => [index("projectsId")]) .authorization((allow) => [allow.owner()]), Activity: a .model({ @@ -150,6 +151,7 @@ const schema = a.schema({ projectsId: a.id().required(), projects: a.belongsTo("Projects", "projectsId"), }) + .secondaryIndexes((index) => [index("projectsId"), index("accountId")]) .authorization((allow) => [allow.owner()]), AccountResponsibilities: a .model({ diff --git a/api/ContextAccounts.tsx b/api/ContextAccounts.tsx index f422127f1..04588cf25 100644 --- a/api/ContextAccounts.tsx +++ b/api/ContextAccounts.tsx @@ -1,8 +1,10 @@ import { type Schema } from "@/amplify/data/resource"; +import { Responsibility } from "@/components/accounts/ResponsibilityRecord"; import { EditorJsonContent, transformNotesVersion, } from "@/components/ui-elements/notes-writer/NotesWriter"; +import { toISODateString } from "@/helpers/functional"; import { SelectionSet, generateClient } from "aws-amplify/data"; import { FC, ReactNode, createContext, useContext } from "react"; import useSWR from "swr"; @@ -24,25 +26,38 @@ interface AccountsContextType { ) => Promise; getAccountById: (accountId: string) => Account | undefined; updateAccount: (props: UpdateAccountProps) => Promise; + addResponsibility: (newResp: Responsibility) => Promise; + assignController: ( + accountId: string, + controllerId: string | null + ) => Promise; + updateOrder: (accounts: Account[]) => Promise<(string | undefined)[]>; } export type Account = { id: string; name: string; introduction?: EditorJsonContent | string; - controllerId?: string; + controller?: { + id: string; + name: string; + }; order: number; - responsibilities: { startDate: Date; endDate?: Date }[]; + responsibilities: Responsibility[]; + createdAt: Date; }; const selectionSet = [ "id", "name", - "accountSubsidiariesId", + "controller.id", + "controller.name", "introduction", "introductionJson", "formatVersion", "order", + "createdAt", + "responsibilities.id", "responsibilities.startDate", "responsibilities.endDate", ] as const; @@ -52,12 +67,13 @@ type AccountData = SelectionSet; export const mapAccount: (account: AccountData) => Account = ({ id, name, - accountSubsidiariesId, + controller, introduction, introductionJson, formatVersion, order, responsibilities, + createdAt, }) => ({ id, name, @@ -66,10 +82,12 @@ export const mapAccount: (account: AccountData) => Account = ({ notes: introduction, notesJson: introductionJson, }), - controllerId: accountSubsidiariesId || undefined, + controller, order: order || 0, + createdAt: new Date(createdAt), responsibilities: responsibilities - .map(({ startDate, endDate }) => ({ + .map(({ id, startDate, endDate }) => ({ + id, startDate: new Date(startDate), endDate: !endDate ? undefined : new Date(endDate), })) @@ -97,7 +115,7 @@ export const AccountsContextProvider: FC = ({ error: errorAccounts, isLoading: loadingAccounts, mutate, - } = useSWR(`/api/accounts/`, fetchAccounts); + } = useSWR("/api/accounts/", fetchAccounts); const createAccount = async ( accountName: string @@ -109,6 +127,7 @@ export const AccountsContextProvider: FC = ({ name: accountName, order: 0, responsibilities: [], + createdAt: new Date(), }; const updatedAccounts = [...(accounts || []), newAccount]; @@ -162,6 +181,86 @@ export const AccountsContextProvider: FC = ({ return data?.id; }; + const assignController = async ( + accountId: string, + controllerId: string | null + ) => { + const updated: Account[] | undefined = accounts?.map((account) => + account.id !== accountId + ? account + : { + ...account, + controller: !controllerId + ? undefined + : { + id: controllerId, + name: + accounts.find(({ id }) => id === controllerId)?.name || "", + }, + } + ); + if (updated) mutate(updated, false); + const { data, errors } = await client.models.Account.update({ + id: accountId, + accountSubsidiariesId: controllerId, + }); + if (errors) handleApiErrors(errors, "Error updating parent company"); + if (!data) return; + if (updated) mutate(updated); + return data.id; + }; + + const addResponsibility = async ({ + id, + startDate, + endDate, + }: Responsibility) => { + const updated = accounts?.map((account) => + account.id !== id + ? account + : { + ...account, + responsibilities: [{ id: crypto.randomUUID(), startDate, endDate }], + } + ); + if (accounts) mutate(updated, false); + + const { data, errors } = await client.models.AccountResponsibilities.create( + { + accountId: id, + startDate: toISODateString(startDate), + endDate: !endDate ? undefined : toISODateString(endDate), + } + ); + if (errors) handleApiErrors(errors, "Error creating new responsibility"); + if (accounts) mutate(updated); + if (!data) return; + return data.id; + }; + + const updateAccountOrderNo = async ( + id: string, + order: number + ): Promise => { + const { data, errors } = await client.models.Account.update({ id, order }); + if (errors) handleApiErrors(errors, "Error updating the order of accounts"); + return data?.id; + }; + + const updateOrder = async (items: Account[]) => { + const updated: Account[] | undefined = accounts?.map(({ id, ...rest }) => ({ + id, + ...rest, + order: items.find((item) => item.id === id)?.order || rest.order, + })); + if (updated) mutate(updated, false); + const result = await Promise.all( + items.map(({ id, order }) => updateAccountOrderNo(id, order)) + ); + if (updated) mutate(updated); + return result; + }; + return ( = ({ createAccount, getAccountById, updateAccount, + addResponsibility, + assignController, + updateOrder, }} > {children} diff --git a/api/ContextProjects.tsx b/api/ContextProjects.tsx index df5b89493..39c9e2b9d 100644 --- a/api/ContextProjects.tsx +++ b/api/ContextProjects.tsx @@ -3,11 +3,13 @@ import { EditorJsonContent, transformNotesVersion, } from "@/components/ui-elements/notes-writer/NotesWriter"; +import { toast } from "@/components/ui/use-toast"; import { Context } from "@/contexts/ContextContext"; -import { addDaysToDate } from "@/helpers/functional"; +import { addDaysToDate, toISODateString } from "@/helpers/functional"; import { SelectionSet, generateClient } from "aws-amplify/data"; +import { flow } from "lodash/fp"; import { FC, ReactNode, createContext, useContext } from "react"; -import useSWR, { KeyedMutator } from "swr"; +import useSWR, { KeyedMutator, mutate } from "swr"; import { handleApiErrors } from "./globals"; const client = generateClient(); @@ -46,6 +48,12 @@ interface ProjectsContextType { projectId: string, accountId: string ) => Promise; + removeAccountFromProject: ( + projectId: string, + projectName: string, + accountId: string, + accountName: string + ) => Promise; updateProjectContext: ( projectId: string, context: Context @@ -155,7 +163,7 @@ const fetchProjects = (context?: Context) => async () => { { done: { ne: true } }, { doneOn: { - ge: addDaysToDate(-90)(new Date()).toISOString().split("T")[0], + ge: flow(addDaysToDate(-90), toISODateString)(new Date()), }, }, ], @@ -354,6 +362,46 @@ export const ProjectsContextProvider: FC = ({ return data?.id; }; + const removeAccountFromProject = async ( + projectId: string, + projectName: string, + accountId: string, + accountName: string + ) => { + const updated: Project[] | undefined = projects?.map((p) => + p.id !== projectId + ? p + : { + ...p, + accountIds: p.accountIds.filter((id) => id !== accountId), + } + ); + if (updated) mutate(updated, false); + + const accProj = + await client.models.AccountProjects.listAccountProjectsByProjectsId( + { projectsId: projectId }, + { filter: { accountId: { eq: accountId } } } + ); + if (accProj.errors) + handleApiErrors(accProj.errors, "Error fetching account/project link"); + if (!accProj.data) return; + const result = await client.models.AccountProjects.delete({ + id: accProj.data[0].id, + }); + if (result.errors) + handleApiErrors(result.errors, "Error deleting account/project link"); + + if (updated) mutate(updated); + + toast({ + title: "Removed account from project", + description: `Removed account ${accountName} from project ${projectName}.`, + }); + + return result.data?.id; + }; + const updateProjectContext = async ( projectId: string, newContext: Context @@ -383,6 +431,7 @@ export const ProjectsContextProvider: FC = ({ saveProjectDates, updateProjectState, addAccountToProject, + removeAccountFromProject, updateProjectContext, mutateProjects, }} diff --git a/api/useAccountActivities.tsx b/api/useAccountActivities.tsx new file mode 100644 index 000000000..3248054c4 --- /dev/null +++ b/api/useAccountActivities.tsx @@ -0,0 +1,98 @@ +import { type Schema } from "@/amplify/data/resource"; +import { transformNotesVersion } from "@/components/ui-elements/notes-writer/NotesWriter"; +import { generateClient } from "aws-amplify/data"; +import { filter, flatten, flow, map } from "lodash/fp"; +import useSWR from "swr"; +import { Activity } from "./useActivity"; +const client = generateClient(); + +const mapActivity = ({ + id, + notes, + formatVersion, + notesJson, + meetingActivitiesId, + finishedOn, + createdAt, + updatedAt, +}: Schema["Activity"]["type"]): Activity => ({ + id, + notes: transformNotesVersion({ version: formatVersion, notes, notesJson }), + meetingId: meetingActivitiesId || undefined, + finishedOn: new Date(finishedOn || createdAt), + updatedAt: new Date(updatedAt), + projectIds: [], +}); + +const fetchActivity = async (projAct: Schema["ProjectActivity"]["type"]) => { + const { data, errors } = await client.models.Activity.get({ + id: projAct.activityId, + }); + if (errors) throw errors; + if (!data) + throw new Error( + `Error fetching activity with id ${projAct.activityId} from project with id ${projAct.projectsId}` + ); + return mapActivity(data); +}; + +const fetchActivities = async (project: Schema["Projects"]["type"]) => { + const { data, errors } = + await client.models.ProjectActivity.listProjectActivityByProjectsId({ + projectsId: project.id, + }); + if (errors) throw errors; + if (!data) + throw new Error( + `Error fetching activities from project ${project.project} with id ${project.id}` + ); + return await Promise.all(data.map(fetchActivity)); +}; + +type ActivityProject = Omit & { + projectId: string; +}; + +const fetchProject = async ( + accProj: Schema["AccountProjects"]["type"] +): Promise => { + const { data, errors } = await client.models.Projects.get({ + id: accProj.projectsId, + }); + if (errors) throw errors; + if (!data) throw new Error("Couldn't fetch projects from account"); + const activities = await fetchActivities(data); + return activities.map((a) => ({ ...a, projectIds: [accProj.projectsId] })); +}; + +const fetchData = + (accountId: string) => async (): Promise => { + const { data, errors } = + await client.models.AccountProjects.listAccountProjectsByAccountId({ + accountId, + }); + if (errors) throw errors; + if (!data) + throw new Error(`Didn't receive projects from account ${accountId}`); + const activities = await Promise.all(data.map(fetchProject)); + return flow( + filter((a) => !!a), + flatten, + map( + ({ projectIds, ...rest }: Activity): ActivityProject => ({ + ...rest, + projectId: projectIds[0], + }) + ) + )(activities); + }; + +const useAccountActivities = (accountId: string) => { + const { data: activities } = useSWR( + `/api/projects/account/${accountId}`, + fetchData(accountId) + ); + return { activities }; +}; + +export default useAccountActivities; diff --git a/components/accounts/AccountDetails.tsx b/components/accounts/AccountDetails.tsx new file mode 100644 index 000000000..4fea2b462 --- /dev/null +++ b/components/accounts/AccountDetails.tsx @@ -0,0 +1,111 @@ +import { Account, useAccountsContext } from "@/api/ContextAccounts"; +import { FC } from "react"; +import { debouncedUpdateAccountDetails } from "../ui-elements/account-details/account-updates-helpers"; +import NotesWriter, { + SerializerOutput, +} from "../ui-elements/notes-writer/NotesWriter"; +import { Accordion } from "../ui/accordion"; +import AccountNotes from "./AccountNotes"; +import AccountsList from "./AccountsList"; +import AddControllerDialog from "./AddControllerDialog"; +import LeanAccordianItem from "./LeanAccordionItem"; +import ProjectList from "./ProjectList"; +import ResponsibilitiesList from "./ResponsibilitiesList"; +import { Responsibility } from "./ResponsibilityRecord"; +import ResponsibilitiesDialog from "./responsibilities-dialog"; + +type AccountDetailsProps = { + account: Account; + addResponsibility: (resp: Responsibility) => void; + showResponsibilities?: boolean; + showSubsidaries?: boolean; + showIntroduction?: boolean; + showProjects?: boolean; + showContacts?: boolean; + showNotes?: boolean; +}; + +const AccountDetails: FC = ({ + account, + addResponsibility, + showContacts, + showIntroduction, + showProjects, + showNotes, + showResponsibilities = true, + showSubsidaries = true, +}) => { + const { accounts, updateAccount } = useAccountsContext(); + + const handleUpdateIntroduction = (serializer: () => SerializerOutput) => { + if (!account) return; + debouncedUpdateAccountDetails({ + id: account.id, + serializeIntroduction: serializer, + updateAccountFn: updateAccount, + }); + }; + + return ( + <> + +
+ + {accounts && + accounts.filter(({ controller }) => controller?.id === account.id) + .length > 0 && ( + + + +
+ + + + + + + + + + + + + + + + + + + Test + + + + + + + )} + + ); +}; + +export default AccountDetails; diff --git a/components/accounts/AccountNotes.tsx b/components/accounts/AccountNotes.tsx new file mode 100644 index 000000000..ab1c76a0a --- /dev/null +++ b/components/accounts/AccountNotes.tsx @@ -0,0 +1,35 @@ +import { useProjectsContext } from "@/api/ContextProjects"; +import useAccountActivities from "@/api/useAccountActivities"; +import { format } from "date-fns"; +import { FC } from "react"; +import NotesWriter from "../ui-elements/notes-writer/NotesWriter"; + +type AccountNotesProps = { + accountId: string; +}; + +const AccountNotes: FC = ({ accountId }) => { + const { activities } = useAccountActivities(accountId); + const { projects } = useProjectsContext(); + + return activities + ?.sort((a, b) => b.finishedOn.getTime() - a.finishedOn.getTime()) + .map(({ id, projectId, notes, finishedOn }) => ( +
+
+ {format(finishedOn, "PPP")} +
+ {projectId && ( + + On: {projects?.find((p) => p.id === projectId)?.project} + + )} + +
+ )); +}; + +export default AccountNotes; diff --git a/components/accounts/AccountsList.tsx b/components/accounts/AccountsList.tsx new file mode 100644 index 000000000..909cfa4d4 --- /dev/null +++ b/components/accounts/AccountsList.tsx @@ -0,0 +1,179 @@ +import { Account, useAccountsContext } from "@/api/ContextAccounts"; +import { + DndContext, + DragEndEvent, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { flow } from "lodash/fp"; +import { FC, useEffect, useState } from "react"; +import { Responsibility } from "./ResponsibilityRecord"; +import AccountRecord from "./account-record"; + +type ShowInvalidOnly = { + showCurrentOnly?: false; + showInvalidOnly: true; + controllerId?: never; +}; + +type ShowCurrentOnly = { + showCurrentOnly: true; + showInvalidOnly?: false; + controllerId?: never; +}; + +type ShowSubsidaries = { + showCurrentOnly?: false; + showInvalidOnly?: false; + controllerId: string; +}; + +type AccountsListProps = ( + | ShowInvalidOnly + | ShowCurrentOnly + | ShowSubsidaries +) & { + accounts: Account[]; + addResponsibility: (resp: Responsibility) => void; +}; + +type GetSortedAccountsProps = { + accounts: Account[]; + controllerId?: string; + showCurrentOnly?: boolean; + showInvalidOnly?: boolean; +}; + +const getSortedAccounts = ({ + accounts, + controllerId, + showCurrentOnly, + showInvalidOnly, +}: GetSortedAccountsProps) => + accounts + .filter(({ controller, responsibilities }) => { + if (controllerId && controller?.id === controllerId) return true; + if (controller) return false; + const currentResponsibility = + responsibilities.filter( + ({ startDate, endDate }) => + startDate <= new Date() && (!endDate || endDate >= new Date()) + ).length > 0; + return ( + (showCurrentOnly && currentResponsibility) || + (showInvalidOnly && !currentResponsibility) + ); + }) + .sort((a, b) => + a.order === b.order + ? a.createdAt.getTime() - b.createdAt.getTime() + : b.order - a.order + ); + +const AccountsList: FC = ({ + accounts, + showCurrentOnly, + showInvalidOnly, + controllerId, + addResponsibility, +}) => { + const { updateOrder } = useAccountsContext(); + const [items, setItems] = useState( + getSortedAccounts({ + accounts, + controllerId, + showCurrentOnly, + showInvalidOnly, + }) + ); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { delay: 250, tolerance: 5 }, + }) + ); + + useEffect( + () => + setItems( + getSortedAccounts({ + accounts, + controllerId, + showCurrentOnly, + showInvalidOnly, + }) + ), + [accounts, controllerId, showCurrentOnly, showInvalidOnly] + ); + + const updateOrderNumbers = + (newIndex: number) => + (list: Account[]): Account[] => { + const current = list[newIndex]; + if (newIndex === 0) return [{ ...current, order: list[1].order + 1000 }]; + if (newIndex === list.length - 1) + return [{ ...current, order: list[newIndex - 1].order - 1000 }]; + + const orderPrev = list[newIndex - 1].order; + const orderNext = list[newIndex + 1].order; + const orderBetween = + orderPrev > orderNext + 1 + ? Math.round((orderPrev + orderNext) / 2) + : undefined; + if (orderBetween) return [{ ...current, order: orderBetween }]; + + return list + .filter((_, index) => index <= newIndex) + .map((account, idx) => ({ + ...account, + order: orderNext + 1000 * (newIndex - idx + 1), + })); + }; + + const moveItem = (items: Account[], oldIndex: number) => (newIndex: number) => + arrayMove(items, oldIndex, newIndex); + + const handleDragEnd = async ({ active, over }: DragEndEvent) => { + if (!over) return; + if (active.id === over.id) return; + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over.id); + + flow( + moveItem(items, oldIndex), + updateOrderNumbers(newIndex), + updateOrder + )(newIndex); + }; + + return items.length === 0 ? ( + "No accounts" + ) : ( + + +
+ {items.map((account) => ( + + ))} +
+
+
+ ); +}; + +export default AccountsList; diff --git a/components/accounts/AddControllerDialog.tsx b/components/accounts/AddControllerDialog.tsx new file mode 100644 index 000000000..26d132bcb --- /dev/null +++ b/components/accounts/AddControllerDialog.tsx @@ -0,0 +1,118 @@ +import { Account, useAccountsContext } from "@/api/ContextAccounts"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Trash2 } from "lucide-react"; +import { FC, useState } from "react"; +import { ToastAction } from "../ui/toast"; +import { useToast } from "../ui/use-toast"; + +type AddControllerDialogProps = { + account: Account; +}; + +const AddControllerDialog: FC = ({ + account: { controller, ...account }, +}) => { + const { accounts, assignController } = useAccountsContext(); + const [open, setOpen] = useState(false); + const { toast } = useToast(); + + const handleUndoParentAssignment = async (controllerId: string | null) => { + const parent = await assignController(account.id, controllerId); + if (!parent) return; + toast({ + title: "Revised assignment of parent", + description: `${account.name}'s parent company is now ${ + accounts?.find(({ id }) => id === controllerId)?.name + } again.`, + }); + }; + + const assignParent = async (parentId: string | null) => { + if (!accounts) return; + const previousControllerId: string | null = controller?.id || null; + const parent = await assignController(account.id, parentId); + if (!parent) return; + toast({ + title: !parentId ? "Parent company removed" : "Parent company assigned", + description: !parentId + ? `${account.name} has no parent company anymore.` + : `${account.name}'s parent company is now ${ + accounts.find(({ id }) => id === parentId)?.name + }.`, + action: ( + handleUndoParentAssignment(previousControllerId)} + > + Undo + + ), + }); + setOpen(false); + }; + + return !accounts ? ( + "Loading accounts..." + ) : ( +
+
+ Parent company: +
+ +
+ + + + {controller && ( + + )} +
+ + + + + No accounts found. + + {accounts + .filter(({ id }) => id !== account.id) + .map(({ id, name }) => ( + + {name} + + ))} + + + + +
+
+ ); +}; + +export default AddControllerDialog; diff --git a/components/accounts/LeanAccordionItem.tsx b/components/accounts/LeanAccordionItem.tsx new file mode 100644 index 000000000..01a136124 --- /dev/null +++ b/components/accounts/LeanAccordionItem.tsx @@ -0,0 +1,46 @@ +import { cn } from "@/lib/utils"; +import Link from "next/link"; +import { FC, ReactNode } from "react"; +import { + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "../ui/accordion"; + +type LeanAccordianItemProps = { + title: string; + value?: string; + isVisible?: boolean; + children: ReactNode; + className?: string; + link?: string; +}; + +const LeanAccordianItem: FC = ({ + title, + value, + children, + isVisible, + className, + link, +}) => ( + + +
+
{title}
+ {link && ( +
+ + Open + +
+ )} +
+
+ {children} +
+); + +export default LeanAccordianItem; diff --git a/components/accounts/ProjectList.tsx b/components/accounts/ProjectList.tsx new file mode 100644 index 000000000..e36065db4 --- /dev/null +++ b/components/accounts/ProjectList.tsx @@ -0,0 +1,66 @@ +import { useAccountsContext } from "@/api/ContextAccounts"; +import { useProjectsContext } from "@/api/ContextProjects"; +import { Trash2 } from "lucide-react"; +import { FC, useEffect, useState } from "react"; +import { Accordion } from "../ui/accordion"; +import LeanAccordianItem from "./LeanAccordionItem"; + +type ProjectListProps = { + accountId: string; +}; + +const ProjectList: FC = ({ accountId }) => { + const { projects, removeAccountFromProject } = useProjectsContext(); + const { accounts } = useAccountsContext(); + const [items, setItems] = useState( + projects?.filter((p) => p.accountIds.includes(accountId)) + ); + + useEffect( + () => setItems(projects?.filter((p) => p.accountIds.includes(accountId))), + [accountId, projects] + ); + + return !items ? ( + "Loading…" + ) : ( + + {items.map(({ id: projectId, project, accountIds }) => ( + +
+ Accounts + {accountIds.map((accountId) => + accounts + ?.filter(({ id }) => id === accountId) + .map(({ name, id: accountId }) => ( +
+
{name}
+ + removeAccountFromProject( + projectId, + project, + accountId, + name + ) + } + /> +
+ )) + )} +
+
+ ))} +
+ ); +}; + +export default ProjectList; diff --git a/components/accounts/ResponsibilitiesList.tsx b/components/accounts/ResponsibilitiesList.tsx new file mode 100644 index 000000000..a9c549f8e --- /dev/null +++ b/components/accounts/ResponsibilitiesList.tsx @@ -0,0 +1,32 @@ +import { addDaysToDate } from "@/helpers/functional"; +import { FC } from "react"; +import ResponsibilityRecord, { Responsibility } from "./ResponsibilityRecord"; + +type ResponsibilitiesListProps = { + responsibilities: Responsibility[]; + onlyCurrent?: boolean; +}; + +const ResponsibilitiesList: FC = ({ + responsibilities, + onlyCurrent, +}) => { + return ( +
+ {responsibilities + .filter( + ({ startDate, endDate }) => + !onlyCurrent || + startDate >= new Date() || + !endDate || + endDate >= addDaysToDate(-60)(new Date()) + ) + .sort((a, b) => a.startDate.getTime() - b.startDate.getTime()) + .map((resp) => ( + + ))} +
+ ); +}; + +export default ResponsibilitiesList; diff --git a/components/accounts/ResponsibilityRecord.tsx b/components/accounts/ResponsibilityRecord.tsx new file mode 100644 index 000000000..367a945aa --- /dev/null +++ b/components/accounts/ResponsibilityRecord.tsx @@ -0,0 +1,28 @@ +import { format } from "date-fns"; +import { FC } from "react"; + +export type Responsibility = { + id: string; + startDate: Date; + endDate?: Date; +}; + +type ResponsibilityRecordProps = { + responsibility: Responsibility; +}; + +const ResponsibilityRecord: FC = ({ + responsibility: { startDate, endDate }, +}) => { + return ( +
+ Since{" "} + {[startDate, ...(endDate ? [endDate] : [])] + .map((date) => format(date, "PPP")) + .join(" to ")} + . +
+ ); +}; + +export default ResponsibilityRecord; diff --git a/components/accounts/account-record.tsx b/components/accounts/account-record.tsx new file mode 100644 index 000000000..a0e633e75 --- /dev/null +++ b/components/accounts/account-record.tsx @@ -0,0 +1,60 @@ +import { Account } from "@/api/ContextAccounts"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import Link from "next/link"; +import { FC } from "react"; +import { + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "../ui/accordion"; +import AccountDetails from "./AccountDetails"; +import { Responsibility } from "./ResponsibilityRecord"; + +type AccountRecordProps = { + account: Account; + className?: string; + addResponsibility: (resp: Responsibility) => void; +}; + +const AccountRecord: FC = ({ + account, + className, + addResponsibility, +}) => { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: account.id }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + return ( + + +
+
{account.name}
+
+ + Open + +
+
+
+ + + +
+ ); +}; + +export default AccountRecord; diff --git a/components/accounts/responsibilities-dialog.tsx b/components/accounts/responsibilities-dialog.tsx new file mode 100644 index 000000000..1d431f4ae --- /dev/null +++ b/components/accounts/responsibilities-dialog.tsx @@ -0,0 +1,211 @@ +import { Account } from "@/api/ContextAccounts"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { toLocaleDateString } from "@/helpers/functional"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { CalendarIcon } from "lucide-react"; +import { FC, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "../ui/button"; +import { Calendar } from "../ui/calendar"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { useToast } from "../ui/use-toast"; +import ResponsibilitiesList from "./ResponsibilitiesList"; +import { Responsibility } from "./ResponsibilityRecord"; + +const FormSchema = z + .object({ + startDate: z.date({ + required_error: "Please provide a start date for this responsibility", + }), + endDate: z.date().optional(), + }) + .refine(({ startDate, endDate }) => !endDate || endDate > startDate, { + message: "End date cannot be before start date", + path: ["endDate"], + }); + +type ResponsibilitiesDialogProps = { + account: Account; + addResponsibility: (newResp: Responsibility) => void; +}; + +const ResponsibilitiesDialog: FC = ({ + account, + addResponsibility, +}) => { + const [open, setOpen] = useState(false); + const { toast } = useToast(); + const form = useForm>({ + resolver: zodResolver(FormSchema), + }); + + const onSubmit = ({ startDate, endDate }: z.infer) => { + toast({ + title: "Responsibility created", + description: `Responsibility created for account ${account.name} from ${[ + startDate, + ...(endDate ? [endDate] : []), + ] + .map(toLocaleDateString) + .join(" to ")}.`, + }); + setOpen(false); + addResponsibility({ + id: account.id, + startDate, + endDate, + }); + }; + + return ( +
+ + + + + + + + Responsibilities {account.name} + + Set the date range for your responsibility for the account{" "} + {account.name}. + + +
+ +
+ Existing responsibilities: +
+
+ +
+
+
+
+ ( + + Start date + + + + + + + + + + + + When is your responsibility for this account beginning? + + + + )} + /> + ( + + End date + + + + + + + + date <= form.getValues().startDate + } + initialFocus + /> + + + + + When will your responsibility end for this account? + + + + )} + /> +
+ + + + + + +
+
+
+ + ); +}; + +export default ResponsibilitiesDialog; diff --git a/components/header/ProfilePicture.tsx b/components/header/ProfilePicture.tsx index ccbc8986f..44f4a051d 100644 --- a/components/header/ProfilePicture.tsx +++ b/components/header/ProfilePicture.tsx @@ -9,30 +9,32 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu"; +import Version from "../version/version"; -const ProfilePicture = () => { - return ( - - - - - CK - - - - -

Carsten Koch

-

m@example.com

-
- - signOut()}> - - Log out - {/* ⇧⌘Q */} - -
-
- ); -}; +const ProfilePicture = () => ( + + + + + CK + + + + +

Carsten Koch

+

m@example.com

+

+ +

+
+ + signOut()}> + + Log out + {/* ⇧⌘Q */} + +
+
+); export default ProfilePicture; diff --git a/components/navigation-menu/NavigationMenu.tsx b/components/navigation-menu/NavigationMenu.tsx index 12067aa00..7bdf6123c 100644 --- a/components/navigation-menu/NavigationMenu.tsx +++ b/components/navigation-menu/NavigationMenu.tsx @@ -14,6 +14,7 @@ import { CommandSeparator, CommandShortcut, } from "../ui/command"; +import Version from "../version/version"; import ContextSwitcher from "./ContextSwitcher"; type NavigationItem = { @@ -96,6 +97,11 @@ const NavigationMenu = () => { ))} + {}}> +
+ +
+
); diff --git a/components/ui-elements/account-details/account-updates-helpers.ts b/components/ui-elements/account-details/account-updates-helpers.ts index 0a27e2b87..0fc6a2d30 100644 --- a/components/ui-elements/account-details/account-updates-helpers.ts +++ b/components/ui-elements/account-details/account-updates-helpers.ts @@ -13,18 +13,21 @@ type UpdateFnProps = { type UpdateAccountDetailsProps = UpdateFnProps & { serializeIntroduction?: () => SerializerOutput; updateAccountFn: (props: UpdateFnProps) => Promise; + updateSavedState?: (state: boolean) => void; }; export const debouncedUpdateAccountDetails = debounce( async ({ updateAccountFn, serializeIntroduction, + updateSavedState, ...props }: UpdateAccountDetailsProps) => { await updateAccountFn({ ...props, introduction: serializeIntroduction?.().json, }); + updateSavedState && updateSavedState(true); }, 1500 ); diff --git a/components/ui-elements/notes-writer/NotesWriter.tsx b/components/ui-elements/notes-writer/NotesWriter.tsx index 66c321f4a..46862ebe1 100644 --- a/components/ui-elements/notes-writer/NotesWriter.tsx +++ b/components/ui-elements/notes-writer/NotesWriter.tsx @@ -66,6 +66,7 @@ type NotesWriterProps = { autoFocus?: boolean; placeholder?: string; onSubmit?: (item: EditorJsonContent) => void; + readonly?: boolean; }; const NotesWriter: FC = ({ @@ -74,6 +75,7 @@ const NotesWriter: FC = ({ autoFocus, placeholder = "Start taking notes...", onSubmit, + readonly, }) => { const editor = useEditor({ extensions: [ @@ -86,6 +88,7 @@ const NotesWriter: FC = ({ Link, ], autofocus: autoFocus, + editable: !readonly, editorProps: { handleKeyDown: (view, event) => { if (!onSubmit) return false; diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 000000000..2853e3991 --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; diff --git a/components/version/version.tsx b/components/version/version.tsx new file mode 100644 index 000000000..cc1e8fda9 --- /dev/null +++ b/components/version/version.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; + +const fetchVersion = async (setVersion: (version: string) => void) => { + try { + const response = await fetch("/api/version"); + const data = await response.json(); + setVersion(data.version); + } catch (error) { + console.error("Error fetching version", error); + } +}; + +const Version = () => { + const [version, setVersion] = useState(undefined); + + useEffect(() => { + fetchVersion(setVersion); + }, []); + + return version && `App version: ${version}`; +}; + +export default Version; diff --git a/docs/releases/next.md b/docs/releases/next.md index a4da9d25f..3e44286ad 100644 --- a/docs/releases/next.md +++ b/docs/releases/next.md @@ -1,5 +1,14 @@ -# UI auf shadcn/ui und Tailwind CSS umstellen (Version :VERSION) +# Oberfläche für Accounts implementiert (Version :VERSION) -Die Formatierungen sind auf [shadcn/ui](https://ui.shadcn.com/docs) und [Tailwind CSS](https://tailwindcss.com/) umgestellt. -Das ist mehr eine interne Änderung in der Applikation. Für den Anwender wird die Applikation insgesamt stabiler und ansehnlicher und kommt mit ein paar schönen neuen Funktionen. Mit der Tastenkombination `Cmd + K` kann nun das Navigationsmenü geöffnet werden, in dem wir in der Zukunft auch die Suche integrieren werden. -Zusätzlich haben wir auch einen Toaster implementiert. Wenn zum Beispiel ein Tagesplan abgeschlossen wird, erscheint eine Meldung, die auch ein Undo anbietet. +In der Account Liste sehe ich nun alle Accounts, für die ich im Moment zuständig bin. Ich kann zusätzlich eine Liste aller Accounts aufklappen, für die ich im Moment nicht zuständig bin. + +Ich kann jedem Account ein Mutterunternehmen zuordnen. + +Ich kann die Reihenfolge der Accounts nun nach Wichtigkeit ordnen und Zeiträume für Zuständigkeiten definieren. + +In der Account-Detailansicht kann ich mir Zuständigkeiten, Tochterunternehmen, eine Einleitung zum Account, aktuelle Projekte und Notizen anschauen. + +## Geschlossene Issues + +Fixes [#36](https://github.com/cabcookie/personal-crm/issues/36) +Fixes [#33](https://github.com/cabcookie/personal-crm/issues/33) diff --git a/package-lock.json b/package-lock.json index 2d4cc5aa9..87ef77e18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,11 @@ "version": "1.10.0", "dependencies": { "@aws-amplify/ui-react": "^6.1.2", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", + "@dnd-kit/sortable": "^8.0.0", "@hookform/resolvers": "^3.4.2", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -12435,6 +12439,68 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", + "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz", + "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.0", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz", + "integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", @@ -14542,6 +14608,180 @@ "@babel/runtime": "^7.13.10" } }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.2.tgz", + "integrity": "sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collapsible": "1.0.3", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collection": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", + "integrity": "sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-direction": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", + "integrity": "sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz", @@ -14688,6 +14928,153 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz", + "integrity": "sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.0.tgz", diff --git a/package.json b/package.json index 75997dfa5..07234267a 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,11 @@ }, "dependencies": { "@aws-amplify/ui-react": "^6.1.2", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", + "@dnd-kit/sortable": "^8.0.0", "@hookform/resolvers": "^3.4.2", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", diff --git a/pages/accounts/[id].tsx b/pages/accounts/[id].tsx index 7d2d42409..e7f3f14b2 100644 --- a/pages/accounts/[id].tsx +++ b/pages/accounts/[id].tsx @@ -1,11 +1,8 @@ import { Account, useAccountsContext } from "@/api/ContextAccounts"; +import AccountDetails from "@/components/accounts/AccountDetails"; import MainLayout from "@/components/layouts/MainLayout"; import { debouncedUpdateAccountDetails } from "@/components/ui-elements/account-details/account-updates-helpers"; -import NotesWriter, { - SerializerOutput, -} from "@/components/ui-elements/notes-writer/NotesWriter"; import SavedState from "@/components/ui-elements/project-notes-form/saved-state"; -import RecordDetails from "@/components/ui-elements/record-details/record-details"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; @@ -13,11 +10,12 @@ const AccountDetailPage = () => { const router = useRouter(); const { id } = router.query; const accountId = Array.isArray(id) ? id[0] : id; - const { getAccountById, updateAccount } = useAccountsContext(); + const { getAccountById, updateAccount, addResponsibility } = + useAccountsContext(); const [account, setAccount] = useState( accountId ? getAccountById(accountId) : undefined ); - const [accountDetailsSaved, setAccountDetailsSaved] = useState(true); + const [accountNameSaved, setAccountNameSaved] = useState(true); useEffect(() => { if (accountId) { @@ -32,44 +30,36 @@ const AccountDetailPage = () => { const handleUpdateName = (newName: string) => { if (!account) return; if (newName.trim() === account.name.trim()) return; - setAccountDetailsSaved(false); + setAccountNameSaved(false); debouncedUpdateAccountDetails({ id: account.id, name: newName, updateAccountFn: updateAccount, + updateSavedState: setAccountNameSaved, }); }; - const handleUpdateIntroduction = (serializer: () => SerializerOutput) => { - if (!account) return; - debouncedUpdateAccountDetails({ - id: account.id, - serializeIntroduction: serializer, - updateAccountFn: updateAccount, - }); - }; return ( setAccountDetailsSaved(false)} + onTitleChange={() => setAccountNameSaved(false)} saveTitle={handleUpdateName} > {!account ? ( "Loading account..." ) : (
- - {JSON.stringify(account)} - - - + +
)}
diff --git a/pages/accounts/index.tsx b/pages/accounts/index.tsx index 40f94888d..a28500e18 100644 --- a/pages/accounts/index.tsx +++ b/pages/accounts/index.tsx @@ -1,10 +1,16 @@ import { useAccountsContext } from "@/api/ContextAccounts"; +import AccountsList from "@/components/accounts/AccountsList"; import MainLayout from "@/components/layouts/MainLayout"; -import AccountName from "@/components/ui-elements/tokens/account-name"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; import { useRouter } from "next/router"; const AccountsListPage = () => { - const { accounts, createAccount } = useAccountsContext(); + const { accounts, createAccount, addResponsibility } = useAccountsContext(); const router = useRouter(); const createAndOpenNewAccount = async () => { @@ -19,14 +25,39 @@ const AccountsListPage = () => { sectionName="Accounts" addButton={{ label: "New", onClick: createAndOpenNewAccount }} > - {!accounts - ? "Loading accounts..." - : accounts.map((account) => ( -
- - {JSON.stringify(account)} -
- ))} + {!accounts ? ( + "Loading accounts..." + ) : ( +
+
+ Drag to change the priority of your accounts. +
+ + + +
+ + + + Show accounts with no current responsibility + + + + + + + + +
+ )} ); }; diff --git a/pages/api/version.ts b/pages/api/version.ts new file mode 100644 index 000000000..53f489cab --- /dev/null +++ b/pages/api/version.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "fs"; +import { NextApiRequest, NextApiResponse } from "next"; +import { join } from "path"; + +type Data = { version: string }; + +const handler = (req: NextApiRequest, res: NextApiResponse) => { + const packageJsonPath = join(process.cwd(), "package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + res.status(200).json({ version: packageJson.version }); +}; + +export default handler;