diff --git a/changelog.d/2379-projects-lists-tab.md b/changelog.d/2379-projects-lists-tab.md new file mode 100644 index 000000000..35d2d893c --- /dev/null +++ b/changelog.d/2379-projects-lists-tab.md @@ -0,0 +1 @@ +- Added a Lists tab to the Projects app: a rail of the project's lists beside an entry panel with quick-add, done toggles, category and status pills, a status selector, and the original text behind any entry an agent tidied. diff --git a/desktop/src/apps/ProjectsApp/ProjectLists.tsx b/desktop/src/apps/ProjectsApp/ProjectLists.tsx new file mode 100644 index 000000000..20265f5f6 --- /dev/null +++ b/desktop/src/apps/ProjectsApp/ProjectLists.tsx @@ -0,0 +1,356 @@ +import { useEffect, useState, useRef, useCallback } from "react"; +import { projectsApi, type Project, type ProjectList, type ProjectListEntry } from "@/lib/projects"; +import styles from "./ProjectsApp.module.css"; + +const STATUS_STYLE: Record = { + new: "bg-blue-500/15 text-blue-400", + seen: "bg-shell-bg-deep text-shell-text-tertiary", + actioned: "bg-green-500/15 text-green-400", + discuss: "bg-amber-500/15 text-amber-400", +}; + +const EMPTY_LIST: ProjectList = { id: "", project_id: "", title: "", description: "", status: "active", created_by: "", created_at: 0, updated_at: 0 }; + +export function ProjectLists({ project }: { project: Project }) { + const [lists, setLists] = useState([]); + const [selectedListId, setSelectedListId] = useState(null); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [quickText, setQuickText] = useState(""); + const [submitting, setSubmitting] = useState(false); + const quickInputRef = useRef(null); + // Current selection, readable from inside an async closure that captured an + // older one. Assigned during render so it is correct before any effect runs. + const selectedListIdRef = useRef(selectedListId); + selectedListIdRef.current = selectedListId; + + // Drop the previous project's selection DURING RENDER, not in an effect. + // ProjectWorkspace renders unkeyed and is + // itself unkeyed, so a project switch reuses this component. Resetting in an + // effect is too late: the entries effect re-runs in the same commit (its deps + // include project.id) and would fetch + // /api/projects//lists//entries first. This is React's + // adjust-state-on-prop-change pattern and it runs before any effect. + const [prevProjectId, setPrevProjectId] = useState(project.id); + if (prevProjectId !== project.id) { + setPrevProjectId(project.id); + setSelectedListId(null); + setEntries([]); + } + + const refreshLists = useCallback(() => { + // Sets state, like refreshEntries below. It used to only RETURN the lists, + // so createList/deleteList awaited a fetch whose result was discarded -- a + // created list never appeared in the rail and a deleted one never left it, + // because the mount effect was the only caller of setLists. + return projectsApi.lists.list(project.id) + .then((ls) => { + setLists(ls); + return ls; + }) + .catch(() => { + setError("Could not load lists."); + return [] as ProjectList[]; + }); + }, [project.id]); + + const refreshEntries = useCallback(() => { + if (!selectedListId) return Promise.resolve([] as ProjectListEntry[]); + const forListId = selectedListId; + return projectsApi.lists.entries.list(project.id, forListId) + .then((ents) => { + // Drop a response whose list is no longer selected. Two quick clicks + // race, and the slower fetch used to land last and show one list's + // entries under another's heading. The ref is the CURRENT selection; + // this closure's own copy is stale by definition. + if (forListId !== selectedListIdRef.current) return ents; + setEntries(ents); + return ents; + }) + .catch(() => { + setError("Could not load entries."); + return [] as ProjectListEntry[]; + }); + }, [project.id, selectedListId]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + refreshLists() + .then((ls) => { + if (!cancelled) { + setLists(ls); + // Keep the selection only if it belongs to THIS project's lists. + // ProjectWorkspace renders with no + // key and is itself unkeyed, so switching project does not remount: + // the previous project's selectedListId survived and the entries + // effect then fetched /api/projects//lists//entries. + // The functional form also avoids depending on a stale closure value. + setSelectedListId((prev) => + prev && ls.some((l) => l.id === prev) ? prev : (ls[0]?.id ?? null)); + } + }) + .catch(() => { + if (!cancelled) setError("Could not load lists for this project."); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [project.id, refreshLists]); + + useEffect(() => { + if (!selectedListId) { setEntries([]); return; } + let cancelled = false; + setLoading(true); + refreshEntries().finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [selectedListId, refreshEntries]); + + const createList = async () => { + // Trim BEFORE the empty check: " " is truthy, so a whitespace-only + // answer used to reach the API as an empty title. + const title = prompt("New list name")?.trim(); + if (!title) return; + try { + const created = await projectsApi.lists.create(project.id, { title }); + await refreshLists(); + setSelectedListId(created.id); + } catch (err) { + setError(String(err)); + } + }; + + const deleteList = async (listId: string) => { + if (!confirm("Delete this list and all its entries?")) return; + try { + await projectsApi.lists.remove(project.id, listId); + if (selectedListId === listId) setSelectedListId(null); + await refreshLists(); + } catch (err) { + setError(String(err)); + } + }; + + const createEntry = async (e: React.FormEvent) => { + e.preventDefault(); + const text = quickText.trim(); + if (!text || submitting || !selectedListId) return; + setSubmitting(true); + try { + await projectsApi.lists.entries.create(project.id, selectedListId, { text }); + setQuickText(""); + await refreshEntries(); + quickInputRef.current?.focus(); + } catch (err) { + setError(String(err)); + } finally { + setSubmitting(false); + } + }; + + const toggleDone = async (entry: ProjectListEntry) => { + try { + await projectsApi.lists.entries.update(project.id, entry.list_id, entry.id, { done: entry.done ? 0 : 1 }); + await refreshEntries(); + } catch (err) { + setError(String(err)); + } + }; + + const updateEntryStatus = async (entry: ProjectListEntry, status: string) => { + try { + await projectsApi.lists.entries.update(project.id, entry.list_id, entry.id, { status }); + await refreshEntries(); + } catch (err) { + setError(String(err)); + } + }; + + const removeEntry = async (entry: ProjectListEntry) => { + if (!confirm("Remove this entry?")) return; + try { + await projectsApi.lists.entries.remove(project.id, entry.list_id, entry.id); + await refreshEntries(); + } catch (err) { + setError(String(err)); + } + }; + + const selectedList = lists.find((l) => l.id === selectedListId) ?? EMPTY_LIST; + + return ( +
+
+ + +
+ {!selectedListId ? ( +

Select or create a list.

+ ) : ( +
+
+

{selectedList.title || "Untitled list"}

+ {selectedList.description && ( + {selectedList.description} + )} +
+ +
+ + setQuickText(e.target.value)} + placeholder="Add entry…" + disabled={submitting} + aria-label={`Quick add entry to ${selectedList.title || "Untitled list"}`} + className="flex-1 rounded-lg border border-shell-border bg-shell-surface px-3 py-1.5 text-sm text-shell-text outline-none focus:ring-2 focus:ring-accent-line disabled:opacity-50" + /> + +
+ + {error && ( +

{error}

+ )} + + {loading ? ( +

Loading entries…

+ ) : entries.length === 0 ? ( +

No entries yet. Type above and press Enter to add one.

+ ) : ( +
    + {entries.map((entry) => { + const tidied = entry.text !== entry.original_text; + return ( +
  • +
    + + {entry.category && ( + + {entry.category} + + )} + + {entry.status} + + {tidied && ( + { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); alert(entry.original_text); } }} + onClick={() => alert(entry.original_text)} + > + + + + + original + + )} +
    + + +
    +
    +

    + {entry.text} +

    +
  • + ); + })} +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx b/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx index 9d7071aa6..70aeefa28 100644 --- a/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx +++ b/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx @@ -22,9 +22,10 @@ import { ElementCreateDialog } from "./elements/ElementCreateDialog"; import styles from "./ProjectsApp.module.css"; import { CommunityView } from "./CommunityView"; +import { ProjectLists } from "./ProjectLists"; -export type Tab = "workspace" | "board" | "canvas" | "tasks" | "files" | "messages" | "members" | "activity" | "decisions" | "routines" | "community"; -const TABS: Tab[] = ["workspace", "board", "canvas", "tasks", "files", "messages", "members", "activity", "decisions", "routines", "community"]; +export type Tab = "workspace" | "board" | "canvas" | "tasks" | "files" | "messages" | "members" | "activity" | "decisions" | "routines" | "community" | "lists"; +const TABS: Tab[] = ["workspace", "board", "canvas", "tasks", "files", "messages", "members", "activity", "decisions", "routines", "community", "lists"]; function isTab(value: string | undefined): value is Tab { return value != null && (TABS as string[]).includes(value); @@ -105,7 +106,7 @@ export function ProjectWorkspace({ project, onChanged, initialTab, filePath }: { // Mobile pill order: surface Messages right after Workspace so it is reachable // without scrolling (on mobile Messages is its own full page, not a squeezed // pane inside Workspace). - const mobileTabOrder: Tab[] = ["workspace", "messages", "board", "tasks", "canvas", "files", "members", "activity", "decisions", "routines", "community"]; + const mobileTabOrder: Tab[] = ["workspace", "messages", "board", "tasks", "canvas", "files", "members", "activity", "decisions", "routines", "community", "lists"]; const tabPills = mobileTabOrder.map((t) => ({ id: t, label: t.charAt(0).toUpperCase() + t.slice(1), @@ -390,6 +391,7 @@ export function ProjectWorkspace({ project, onChanged, initialTab, filePath }: { {tab === "decisions" && } {tab === "routines" && } {tab === "community" && } + {tab === "lists" && } {isMobile && (tab === "tasks" || tab === "board") && ( diff --git a/desktop/src/apps/ProjectsApp/ProjectsApp.module.css b/desktop/src/apps/ProjectsApp/ProjectsApp.module.css index 8f28e89c6..8b298105a 100644 --- a/desktop/src/apps/ProjectsApp/ProjectsApp.module.css +++ b/desktop/src/apps/ProjectsApp/ProjectsApp.module.css @@ -870,3 +870,37 @@ font-size: 0.7rem; white-space: nowrap; } + +/* Lists Tab */ +.listsRail { + width: 220px; + flex: none; + display: flex; + flex-direction: column; + background: var(--color-shell-surface); + border: 1px solid var(--color-shell-border); + border-radius: 12px; + padding: 12px; + overflow: auto; + min-height: 0; +} + +.listsEntriesPanel { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: 12px; +} + +@media (max-width: 767px) { + .listsRail { + width: 100%; + min-height: auto; + max-height: 40vh; + } + .listsEntriesPanel { + width: 100%; + } +} diff --git a/desktop/src/apps/ProjectsApp/__tests__/ProjectLists.test.tsx b/desktop/src/apps/ProjectsApp/__tests__/ProjectLists.test.tsx new file mode 100644 index 000000000..256448047 --- /dev/null +++ b/desktop/src/apps/ProjectsApp/__tests__/ProjectLists.test.tsx @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, act, fireEvent, waitFor, within } from "@testing-library/react"; +import type { Project } from "@/lib/projects"; +import { ProjectLists } from "../ProjectLists"; + +const fakeProject: Project = { + id: "p1", + slug: "p1", + name: "P1", + description: "", + status: "active", + created_by: "u1", + created_at: 0, + updated_at: 0, +}; + +function ok(data: unknown) { + return { ok: true, status: 200, json: async () => data }; +} + +describe("ProjectLists", () => { + let fetchMock: ReturnType; + let listsData: { id: string; project_id: string; title: string; description: string; status: string; created_by: string; created_at: number; updated_at: number }[]; + let entriesData: { id: string; list_id: string; project_id: string; text: string; original_text: string; category: string | null; status: string; done: number; author_kind: string; author_id: string; edited_by: string | null; position: number; created_at: number; updated_at: number }[]; + + beforeEach(() => { + listsData = [ + { id: "lst-1", project_id: "p1", title: "Shopping", description: "", status: "active", created_by: "u1", created_at: 0, updated_at: 0 }, + ]; + entriesData = [ + { id: "ent-1", list_id: "lst-1", project_id: "p1", text: "Milk", original_text: "Milk", category: "groceries", status: "new", done: 0, author_kind: "user", author_id: "u1", edited_by: null, position: 0, created_at: 0, updated_at: 0 }, + { id: "ent-2", list_id: "lst-1", project_id: "p1", text: "Bread", original_text: "Whole grain bread", category: null, status: "actioned", done: 1, author_kind: "user", author_id: "u1", edited_by: "u1", position: 1, created_at: 0, updated_at: 0 }, + { id: "ent-3", list_id: "lst-1", project_id: "p1", text: "Call plumber", original_text: "Call plumber", category: null, status: "discuss", done: 0, author_kind: "user", author_id: "u1", edited_by: null, position: 2, created_at: 0, updated_at: 0 }, + { id: "ent-4", list_id: "lst-1", project_id: "p1", text: "Review PR", original_text: "Review PR", category: null, status: "seen", done: 0, author_kind: "user", author_id: "u1", edited_by: null, position: 3, created_at: 0, updated_at: 0 }, + ]; + + fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (url === "/api/projects/p2/lists") { + return Promise.resolve(ok({ items: [{ id: "lst-p2", project_id: "p2", title: "Other project list", description: "", status: "active", created_by: "u1", created_at: 0, updated_at: 0 }] })); + } + if (url === "/api/projects/p2/lists/lst-p2/entries") { + return Promise.resolve(ok({ items: [] })); + } + if (url === "/api/projects/p1/lists") { + if (init?.method === "POST") { + // The mock has to behave like the server: a created list is in the + // NEXT list response. It used to return a fixed array, so a test + // could not tell a refreshed rail from a stale one. + const created = { id: "lst-new", project_id: "p1", title: "New list", description: "", status: "active", created_by: "u1", created_at: 0, updated_at: 0 }; + listsData.push(created); + return Promise.resolve(ok(created)); + } + return Promise.resolve(ok({ items: [...listsData] })); + } + // A freshly created list serves an empty entry set, like the real route. + if (url === "/api/projects/p1/lists/lst-new/entries") { + return Promise.resolve(ok({ items: [] })); + } + if (url === "/api/projects/p1/lists/lst-1/entries") { + if (init?.method === "POST") { + const newEntry = { id: "ent-new", list_id: "lst-1", project_id: "p1", text: "new entry", original_text: "new entry", category: null as string | null, status: "new" as string, done: 0 as number, author_kind: "user" as string, author_id: "u1" as string, edited_by: null as string | null, position: entriesData.length as number, created_at: 0 as number, updated_at: 0 as number }; + entriesData.push(newEntry); + return Promise.resolve(ok(newEntry)); + } + return Promise.resolve(ok({ items: [...entriesData] })); + } + if (url.startsWith("/api/projects/p1/lists/lst-1/entries/") && init?.method === "PATCH") { + const entryId = url.split("/").pop()!; + const entry = entriesData.find((e) => e.id === entryId); + if (!entry) return Promise.resolve(ok({})); + const patch = JSON.parse(init.body as string); + Object.assign(entry, patch); + return Promise.resolve(ok({ ...entry })); + } + if (url === "/api/projects/p1/lists/lst-1" && init?.method === "DELETE") { + listsData = listsData.filter((l) => l.id !== "lst-1"); + return Promise.resolve(ok({ ok: true })); + } + if (url.startsWith("/api/projects/p1/lists/lst-1/entries/") && init?.method === "DELETE") { + const entryId = url.split("/").pop()!; + entriesData = entriesData.filter((e) => e.id !== entryId); + return Promise.resolve(ok({ ok: true })); + } + return Promise.resolve(ok({})); + }); + vi.stubGlobal("fetch", fetchMock); + }); + + it("renders lists rail and entries for the first list", async () => { + await act(async () => { + render(); + }); + const shoppingItems = screen.getAllByText("Shopping"); + expect(shoppingItems.length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Milk")).toBeInTheDocument(); + expect(screen.getByText("Bread")).toBeInTheDocument(); + }); + + it("renders status pills with correct colors", async () => { + await act(async () => { + render(); + }); + const newPills = screen.getAllByText("new"); + expect(newPills.length).toBeGreaterThanOrEqual(1); + expect(newPills[0]!.className).toContain("bg-blue-500/15"); + const actionedPills = screen.getAllByText("actioned"); + expect(actionedPills.length).toBeGreaterThanOrEqual(1); + const discussPills = screen.getAllByText("discuss"); + expect(discussPills.length).toBeGreaterThanOrEqual(1); + const seenPills = screen.getAllByText("seen"); + expect(seenPills.length).toBeGreaterThanOrEqual(1); + }); + + it("shows the original text indicator when text is tidied", async () => { + await act(async () => { + render(); + }); + expect(screen.getByText("original")).toBeInTheDocument(); + }); + + it("quick-add creates a new entry on Enter", async () => { + await act(async () => { + render(); + }); + const input = screen.getByLabelText(/quick add/i); + fireEvent.change(input, { target: { value: "new entry" } }); + await act(async () => { + fireEvent.submit(input.closest("form")!); + }); + await waitFor(() => expect(screen.getByText("new entry")).toBeInTheDocument()); + }); + + it("done toggle marks entry as done and strikes through text", async () => { + await act(async () => { + render(); + }); + const checkbox = screen.getByLabelText(/mark milk as done/i); + fireEvent.click(checkbox); + await waitFor(() => expect(screen.getByText("Milk").className).toContain("line-through")); + }); + + // The rail is what the user reads to know their list exists. Both of these + // failed before the fix: refreshLists() fetched and threw the result away + // (only the mount effect ever called setLists), so a created list never + // appeared and a deleted one never left. + it("a created list appears in the rail", async () => { + vi.stubGlobal("prompt", vi.fn(() => "New list")); + await act(async () => { + render(); + }); + await act(async () => { + fireEvent.click(screen.getByLabelText("Create new list")); + }); + // Scope to the rail: the entries header renders the selected list's title + // too, so a whole-document match could pass while the rail stayed stale. + await waitFor(() => { + const rail = screen.getByLabelText("Project lists"); + expect(within(rail).getByText("New list")).toBeInTheDocument(); + }); + }); + + it("switching project does not keep the previous project's selected list", async () => { + // Neither ProjectWorkspace nor ProjectLists is keyed by project, so the + // component is reused across a switch and the old selection survived. + let view: ReturnType; + await act(async () => { + view = render(); + }); + await act(async () => { + view!.rerender(); + }); + await waitFor(() => expect(screen.getAllByText("Other project list").length).toBeGreaterThanOrEqual(1)); + // the old project's entries must not still be on screen + expect(screen.queryByText("Milk")).not.toBeInTheDocument(); + const badFetch = fetchMock.mock.calls.find(([u]) => String(u) === "/api/projects/p2/lists/lst-1/entries"); + expect(badFetch).toBeUndefined(); + }); + + it("a whitespace-only list name creates nothing", async () => { + vi.stubGlobal("prompt", vi.fn(() => " ")); + await act(async () => { + render(); + }); + await act(async () => { + fireEvent.click(screen.getByLabelText("Create new list")); + }); + const posted = fetchMock.mock.calls.find(([u, i]) => String(u) === "/api/projects/p1/lists" && (i as RequestInit | undefined)?.method === "POST"); + expect(posted).toBeUndefined(); + }); + + it("a slow entries response for a deselected list does not overwrite the current one", async () => { + // Deterministic race: lst-1's fetch is held open, the user picks lst-2, + // then lst-1's response lands last. Without the guard it wins and shows + // one list's entries under the other's heading. + let releaseSlow: (() => void) | null = null; + const slow = new Promise((res) => { releaseSlow = () => res(); }); + const raceFetch = vi.fn((url: string) => { + if (url === "/api/projects/p1/lists") { + return Promise.resolve(ok({ items: [ + { id: "lst-1", project_id: "p1", title: "Shopping", description: "", status: "active", created_by: "u1", created_at: 0, updated_at: 0 }, + { id: "lst-2", project_id: "p1", title: "Chores", description: "", status: "active", created_by: "u1", created_at: 0, updated_at: 0 }, + ] })); + } + if (url === "/api/projects/p1/lists/lst-1/entries") { + return slow.then(() => ok({ items: [{ id: "e-slow", list_id: "lst-1", project_id: "p1", text: "STALE MILK", original_text: "STALE MILK", category: null, status: "new", done: 0, author_kind: "user", author_id: "u1", edited_by: null, position: 0, created_at: 0, updated_at: 0 }] })); + } + if (url === "/api/projects/p1/lists/lst-2/entries") { + return Promise.resolve(ok({ items: [{ id: "e-fast", list_id: "lst-2", project_id: "p1", text: "Sweep floor", original_text: "Sweep floor", category: null, status: "new", done: 0, author_kind: "user", author_id: "u1", edited_by: null, position: 0, created_at: 0, updated_at: 0 }] })); + } + return Promise.resolve(ok({ items: [] })); + }); + vi.stubGlobal("fetch", raceFetch); + + await act(async () => { + render(); + }); + await act(async () => { + fireEvent.click(screen.getByText("Chores")); + }); + await waitFor(() => expect(screen.getByText("Sweep floor")).toBeInTheDocument()); + await act(async () => { + releaseSlow!(); + await slow; + }); + expect(screen.queryByText("STALE MILK")).not.toBeInTheDocument(); + expect(screen.getByText("Sweep floor")).toBeInTheDocument(); + }); + + it("a deleted list disappears from the rail", async () => { + vi.stubGlobal("confirm", vi.fn(() => true)); + await act(async () => { + render(); + }); + expect(screen.getAllByText("Shopping").length).toBeGreaterThanOrEqual(1); + await act(async () => { + fireEvent.click(screen.getByLabelText("Delete Shopping")); + }); + await waitFor(() => expect(screen.queryByText("Shopping")).not.toBeInTheDocument()); + }); +}); diff --git a/desktop/src/lib/projects.ts b/desktop/src/lib/projects.ts index d094b187d..7b02964d1 100644 --- a/desktop/src/lib/projects.ts +++ b/desktop/src/lib/projects.ts @@ -139,6 +139,34 @@ export type DocReview = { updated_at: number; }; +export type ProjectList = { + id: string; + project_id: string; + title: string; + description: string; + status: string; + created_by: string; + created_at: number; + updated_at: number; +}; + +export type ProjectListEntry = { + id: string; + list_id: string; + project_id: string; + text: string; + original_text: string; + category: string | null; + status: string; + done: number; + author_kind: string; + author_id: string; + edited_by: string | null; + position: number; + created_at: number; + updated_at: number; +}; + export type DocReviewMissing = { project_id: string; doc_path: string; @@ -358,6 +386,46 @@ export const projectsApi = { activity: (pid: string) => http<{ items: ProjectActivity[] }>(`/api/projects/${pid}/activity`).then((r) => r.items), + lists: { + list: (pid: string) => + http<{ items: ProjectList[] }>(`/api/projects/${pid}/lists`).then((r) => r.items), + create: (pid: string, input: { title: string; description?: string }) => + http(`/api/projects/${pid}/lists`, { + method: "POST", + body: JSON.stringify(input), + }), + get: (pid: string, listId: string) => + http(`/api/projects/${pid}/lists/${listId}`), + update: (pid: string, listId: string, patch: Partial>) => + http(`/api/projects/${pid}/lists/${listId}`, { + method: "PATCH", + body: JSON.stringify(patch), + }), + remove: (pid: string, listId: string) => + http<{ ok: boolean }>(`/api/projects/${pid}/lists/${listId}`, { method: "DELETE" }), + entries: { + list: (pid: string, listId: string) => + http<{ items: ProjectListEntry[] }>(`/api/projects/${pid}/lists/${listId}/entries`).then((r) => r.items), + create: (pid: string, listId: string, input: { text: string; category?: string | null; position?: number | null }) => + http(`/api/projects/${pid}/lists/${listId}/entries`, { + method: "POST", + body: JSON.stringify(input), + }), + update: (pid: string, listId: string, entryId: string, patch: Partial>) => + http(`/api/projects/${pid}/lists/${listId}/entries/${entryId}`, { + method: "PATCH", + body: JSON.stringify(patch), + }), + remove: (pid: string, listId: string, entryId: string) => + http<{ ok: boolean }>(`/api/projects/${pid}/lists/${listId}/entries/${entryId}`, { method: "DELETE" }), + reorder: (pid: string, listId: string, entries: { id: string; position: number }[]) => + http<{ ok: boolean }>(`/api/projects/${pid}/lists/${listId}/entries/reorder`, { + method: "POST", + body: JSON.stringify({ entries }), + }), + }, + }, + docReviews: { list: (pid: string, state?: string) => http<{ items: DocReview[] }>(