diff --git a/frontend/packages/app/src/pages/project-details/about/index.tsx b/frontend/packages/app/src/pages/project-details/about/index.tsx index 340d9f3d6..5e47a6a1e 100644 --- a/frontend/packages/app/src/pages/project-details/about/index.tsx +++ b/frontend/packages/app/src/pages/project-details/about/index.tsx @@ -2,7 +2,7 @@ * External dependencies. */ import { useMemo, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useMatch, useParams } from "react-router-dom"; import { Accordion } from "@base-ui/react/accordion"; import { mergeClassNames } from "@next-pms/design-system"; import { Github } from "@next-pms/design-system/components"; @@ -18,6 +18,7 @@ import { useFrappeGetCall } from "frappe-react-sdk"; /** * Internal dependencies. */ +import { ROUTES } from "@/lib/constant"; import { currencyFormat, pickAllowed, toKebabCase } from "@/lib/utils"; import { RAG_STATUS } from "@/pages/projects/constants"; import { Dot } from "@/pages/projects/list/cells/dot"; @@ -54,7 +55,13 @@ const DEFAULT_SIDEBAR: ProjectSidebar = { customers: [], }; -export function AboutThisProject({ className }: { className: string }) { +export function AboutThisProject(props: { className: string }) { + const editorMatch = useMatch(`${ROUTES.project}/:projectId/notes/*`); + if (editorMatch) return null; + return ; +} + +function AboutThisProjectContent({ className }: { className: string }) { const { projectId = "" } = useParams<{ projectId: string }>(); const [addMemberOpen, setAddMemberOpen] = useState(false); const [addCustomerOpen, setAddCustomerOpen] = useState(false); diff --git a/frontend/packages/app/src/pages/project-details/index.tsx b/frontend/packages/app/src/pages/project-details/index.tsx index e9f0eed26..0f310e728 100644 --- a/frontend/packages/app/src/pages/project-details/index.tsx +++ b/frontend/packages/app/src/pages/project-details/index.tsx @@ -1,16 +1,18 @@ /** * External dependencies. */ -import { useParams, useSearchParams } from "react-router-dom"; +import { Outlet, useMatch, useParams, useSearchParams } from "react-router-dom"; import { Tabs } from "@rtcamp/frappe-ui-react"; /** * Internal dependencies. */ +import { ROUTES } from "@/lib/constant"; import { AboutThisProject } from "./about"; import { ProjectDetailHeader } from "./header"; import { ProjectDetailProvider } from "./provider"; import { TAB_KEYS, TABS, type TabKey } from "./tabs"; +import { NotesProvider } from "./tabs/notes/provider"; const TAB_PARAM = "tab"; const DEFAULT_TAB: TabKey = TAB_KEYS[0]; @@ -18,6 +20,7 @@ const DEFAULT_TAB: TabKey = TAB_KEYS[0]; function ProjectDetail() { const { projectId = "" } = useParams<{ projectId: string }>(); const [searchParams, setSearchParams] = useSearchParams(); + const editorMatch = useMatch(`${ROUTES.project}/:projectId/notes/*`); const paramTab = searchParams.get(TAB_PARAM) as TabKey | null; const activeKey: TabKey = @@ -35,20 +38,28 @@ function ProjectDetail() { return ( -
- -
- - + +
+ +
+ {editorMatch ? ( +
+ +
+ ) : ( + + )} + +
-
+ ); } diff --git a/frontend/packages/app/src/pages/project-details/tabs/index.tsx b/frontend/packages/app/src/pages/project-details/tabs/index.tsx index df8e6580e..d80e2fc30 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/index.tsx +++ b/frontend/packages/app/src/pages/project-details/tabs/index.tsx @@ -11,8 +11,8 @@ export const TAB_KEYS = [ "overview", "calendar", "tracking", - "notes", "risks", + "notes", "email", "to-do", "feedback", @@ -25,10 +25,7 @@ export const TABS: ComponentProps["tabs"] = [ { label: "Calendar", content: }, { label: "Tracking", content: }, { label: "Risks", content: }, - { - label: "Notes", - content: , - }, + { label: "Notes", content: }, { label: "Email", content: }, { label: "To-do", content: }, { label: "Feedback", content: }, diff --git a/frontend/packages/app/src/pages/project-details/tabs/notes/editor/index.tsx b/frontend/packages/app/src/pages/project-details/tabs/notes/editor/index.tsx new file mode 100644 index 000000000..bcdbc70e3 --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/tabs/notes/editor/index.tsx @@ -0,0 +1,198 @@ +/** + * External dependencies. + */ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { Spinner } from "@next-pms/design-system/components"; +import { + Avatar, + Button, + ErrorMessage, + TextEditor, + useToasts, +} from "@rtcamp/frappe-ui-react"; +import { useForm } from "@tanstack/react-form"; +import { + FrappeError, + useFrappeGetCall, + useFrappePostCall, +} from "frappe-react-sdk"; + +/** + * Internal dependencies. + */ +import { ROUTES } from "@/lib/constant"; +import { parseFrappeErrorMsg } from "@/lib/utils"; +import { useProjectDetail } from "@/pages/project-details/context"; +import { useUser } from "@/providers/user"; +import { noteFormSchema } from "./schema"; +import { useNotes } from "../context"; + +export function NoteEditor() { + const navigate = useNavigate(); + const { projectId: routeProjectId = "", noteId } = useParams<{ + projectId: string; + noteId?: string; + }>(); + const mode: "edit" | "new" = noteId ? "edit" : "new"; + const userName = useUser((s) => s.state.userName); + const userImage = useUser((s) => s.state.image); + const projectId = useProjectDetail((s) => s.projectId); + const refresh = useNotes((s) => s.actions.refresh); + const toast = useToasts(); + const [isFormInitialized, setIsFormInitialized] = useState(false); + + const { call: createNote, loading: isCreating } = useFrappePostCall( + "next_pms.timesheet.api.project_status_update.create_project_status_update", + ); + const { call: updateNote, loading: isUpdating } = useFrappePostCall( + "next_pms.timesheet.api.project_status_update.update_project_status_update", + ); + const { data: noteData, isLoading: isNoteLoading } = useFrappeGetCall( + "next_pms.timesheet.api.project_status_update.get_project_status_update", + { name: noteId }, + mode === "edit" && noteId ? undefined : null, + ); + + const form = useForm({ + defaultValues: { + project: projectId, + title: "", + description: "", + status: "Publish", + }, + validators: { + onSubmit: noteFormSchema, + }, + onSubmit: async ({ value }) => { + try { + const payload = { + title: value.title, + description: value.description, + status: value.status, + }; + + if (mode === "new") { + await createNote({ + project: value.project, + ...payload, + }); + } else { + await updateNote({ + name: noteId, + ...payload, + }); + } + + toast.success("Note saved"); + await refresh(); + navigate(`${ROUTES.project}/${routeProjectId}?tab=notes`); + } catch (err) { + const error = parseFrappeErrorMsg(err as FrappeError); + toast.error(error); + } + }, + }); + + useEffect(() => { + if (mode === "edit" && noteData?.message) { + form.setFieldValue("title", noteData.message.title); + form.setFieldValue("description", noteData.message.description); + setIsFormInitialized(true); + } else if (mode === "new") { + form.reset({ + project: projectId, + title: "", + description: "", + status: "Publish", + }); + setIsFormInitialized(true); + } + }, [noteData, mode, form, projectId]); + + const isInputDisabled = isCreating || isUpdating || isNoteLoading; + + return ( +
+ {isNoteLoading || !isFormInitialized ? ( + + ) : ( +
+
+
+ + + {userName} + +
+ state.isDirty}> + {(isDirty) => ( +
+
+ { + return ( + <> + field.handleChange(e.target.value)} + disabled={isInputDisabled} + placeholder="Add note title" + aria-label="Note title" + className="w-full resize-none border-0 bg-transparent text-3xl font-semibold leading-tight text-ink-gray-8 placeholder:text-ink-gray-4 focus:outline-none" + /> + {!field.state.meta.isValid && ( + + )} + + ); + }} + /> + + { + return ( + <> + field.handleChange(value)} + placeholder="Type a note description..." + editable={!isInputDisabled} + fixedMenu={false} + editorClass="prose prose-sm max-w-none min-h-[400px] text-ink-gray-8 focus:outline-none" + /> + {!field.state.meta.isValid && ( + + )} + + ); + }} + /> +
+
+ )} +
+ ); +} diff --git a/frontend/packages/app/src/pages/project-details/tabs/notes/editor/schema.ts b/frontend/packages/app/src/pages/project-details/tabs/notes/editor/schema.ts new file mode 100644 index 000000000..e63e39907 --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/tabs/notes/editor/schema.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; +import { NOTE_STATUS } from "../types"; + +export const noteFormSchema = z.object({ + project: z + .string({ required_error: "Project Id is required" }) + .trim() + .min(1, { message: "Project Id is required" }), + title: z + .string({ required_error: "Title is required" }) + .trim() + .min(1, { message: "Title is required" }), + description: z + .string({ required_error: "Description is required" }) + .trim() + .min(1, { message: "Description is required" }), + status: z.enum(NOTE_STATUS, { required_error: "Status is required" }), +}); + +export type NoteFormValues = z.infer; diff --git a/frontend/packages/app/src/pages/project-details/tabs/notes/index.tsx b/frontend/packages/app/src/pages/project-details/tabs/notes/index.tsx index 4eb042c4b..11ca262b8 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/notes/index.tsx +++ b/frontend/packages/app/src/pages/project-details/tabs/notes/index.tsx @@ -8,10 +8,9 @@ import { ErrorFallback, Spinner } from "@next-pms/design-system/components"; */ import { useNotes } from "./context"; import { NoteCard } from "./noteCard"; -import { NotesProvider } from "./provider"; import { NotesSubHeader } from "./subHeader"; -function NotesContent() { +function NotesGrid() { const notes = useNotes((s) => s.state.notes); const isLoading = useNotes((s) => s.state.isLoading); const error = useNotes((s) => s.state.error); @@ -47,9 +46,7 @@ function NotesContent() { export function Notes() { return ( - - - + ); } diff --git a/frontend/packages/app/src/pages/project-details/tabs/notes/noteCard.tsx b/frontend/packages/app/src/pages/project-details/tabs/notes/noteCard.tsx index e3471f48b..219f301ab 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/notes/noteCard.tsx +++ b/frontend/packages/app/src/pages/project-details/tabs/notes/noteCard.tsx @@ -1,13 +1,22 @@ /** * External dependencies. */ -import { Avatar } from "@rtcamp/frappe-ui-react"; +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { Avatar, Dropdown, useToasts } from "@rtcamp/frappe-ui-react"; import { DotHorizontal } from "@rtcamp/frappe-ui-react/icons"; +import { FrappeError, useFrappeDeleteDoc } from "frappe-react-sdk"; /** * Internal dependencies. */ -import { formatRelativeTimeShort, stripTags } from "@/lib/utils"; +import { ROUTES } from "@/lib/constant"; +import { + formatRelativeTimeShort, + parseFrappeErrorMsg, + stripTags, +} from "@/lib/utils"; +import { useNotes } from "./context"; import type { Note } from "./types"; type NoteCardProps = { @@ -15,9 +24,28 @@ type NoteCardProps = { }; export function NoteCard({ note }: NoteCardProps) { + const navigate = useNavigate(); + const { deleteDoc } = useFrappeDeleteDoc(); + const [deleting, setDeleting] = useState(false); + const { refresh } = useNotes((s) => s.actions); + const { projectId = "" } = useParams<{ projectId: string }>(); const excerpt = stripTags(note.description); const relativeDate = formatRelativeTimeShort(note.creation); const authorHref = `/desk/user/${encodeURIComponent(note.owner)}`; + const toast = useToasts(); + + const handleDelete = async () => { + setDeleting(true); + try { + await deleteDoc("Project Status Update", note.name); + toast.success("Note deleted"); + await refresh(); + } catch (err) { + toast.error(parseFrappeErrorMsg(err as FrappeError)); + } finally { + setDeleting(false); + } + }; return (
@@ -25,10 +53,30 @@ export function NoteCard({ note }: NoteCardProps) {

{note.title}

- {/* TODO: Add actions after requirement clarification */} - + navigate( + `${ROUTES.project}/${projectId}/notes/${note.name}/edit`, + ), + }, + { + label: "Delete", + key: "delete", + theme: "red", + disabled: deleting, + onClick: handleDelete, + }, + ]} />

diff --git a/frontend/packages/app/src/pages/project-details/tabs/notes/subHeader.tsx b/frontend/packages/app/src/pages/project-details/tabs/notes/subHeader.tsx index 421a4c153..6b4231d6d 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/notes/subHeader.tsx +++ b/frontend/packages/app/src/pages/project-details/tabs/notes/subHeader.tsx @@ -21,8 +21,8 @@ export function NotesSubHeader({ filters, onFiltersChange, }: NotesSubHeaderProps) { - const { projectId = "" } = useParams<{ projectId: string }>(); const navigate = useNavigate(); + const { projectId = "" } = useParams<{ projectId: string }>(); return (

diff --git a/frontend/packages/app/src/pages/project-details/tabs/notes/types.ts b/frontend/packages/app/src/pages/project-details/tabs/notes/types.ts index 23fae84bb..35ac0787a 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/notes/types.ts +++ b/frontend/packages/app/src/pages/project-details/tabs/notes/types.ts @@ -10,11 +10,14 @@ export type NoteComment = { modified_by: string; }; +export const NOTE_STATUS = ["Draft", "Review", "Publish"] as const; +export type NoteStatus = (typeof NOTE_STATUS)[number]; + export type Note = { name: string; title: string; description: string; - status: string; + status: NoteStatus; project: string; owner: string; owner_full_name: string; diff --git a/frontend/packages/app/src/route.tsx b/frontend/packages/app/src/route.tsx index 4f38cbcbb..1883d3949 100644 --- a/frontend/packages/app/src/route.tsx +++ b/frontend/packages/app/src/route.tsx @@ -6,7 +6,6 @@ import { Route, Outlet } from "react-router-dom"; /** * Internal dependencies. */ -import { UnderConstruction } from "@/components/under-construction"; import { ROUTES } from "@/lib/constant"; import LayoutWithSidebar from "./layout"; import { useUser } from "./providers/user"; @@ -18,6 +17,11 @@ const Task = lazy(() => import("@/pages/task")); const ProjectList = lazy(() => import("@/pages/projects/list")); const ProjectKanban = lazy(() => import("@/pages/projects/kanban")); const ProjectDetail = lazy(() => import("@/pages/project-details")); +const NoteEditor = lazy(() => + import("@/pages/project-details/tabs/notes/editor").then((m) => ({ + default: m.NoteEditor, + })), +); const PersonalTimesheetLayout = lazy( () => import("@/pages/timesheet/personal/layout"), ); @@ -52,11 +56,10 @@ export function Router() { } - /> - } - /> + > + } /> + } /> + }>