diff --git a/src/app/actions/cards.test.ts b/src/app/actions/cards.test.ts index 850fd41..b73122b 100644 --- a/src/app/actions/cards.test.ts +++ b/src/app/actions/cards.test.ts @@ -1,15 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createCard } from "@/app/actions/cards"; +import { acceptCard, createCard } from "@/app/actions/cards"; import { requireSession } from "@/lib/auth/session"; import { getServerPb } from "@/lib/pb/server"; import { resolveWorkArea } from "@/lib/work-areas/server"; +import { relinkProject } from "@/lib/linking/engine"; vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("@/lib/auth/session", () => ({ requireSession: vi.fn() })); vi.mock("@/lib/pb/server", () => ({ getServerPb: vi.fn() })); vi.mock("@/lib/work-areas/server", () => ({ resolveWorkArea: vi.fn() })); vi.mock("@/app/actions/activity", () => ({ logActivity: vi.fn() })); +vi.mock("@/lib/linking/engine", () => ({ relinkProject: vi.fn() })); describe("owner card creation", () => { beforeEach(() => { @@ -48,3 +50,44 @@ describe("owner card creation", () => { })); }); }); + +describe("accepting a client request", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireSession).mockResolvedValue({ userId: "owner-1", role: "owner", clientId: "" }); + vi.mocked(relinkProject).mockResolvedValue({} as never); + }); + + function setup(column: string) { + const update = vi.fn(async (_id: string, _patch: Record) => ({})); + const getFirstListItem = vi.fn(async () => ({ position: 2000 })); + const card = { id: "card-1", project: "project-1", column, start_date: "", accepted: false }; + const columns = [ + { id: "inbox", project: "project-1", is_inbox: true, name: "Inbox" }, + { id: "backlog", project: "project-1", is_inbox: false, name: "Backlog" }, + { id: "todo", project: "project-1", is_inbox: false, name: "To Do" }, + ]; + vi.mocked(getServerPb).mockResolvedValue({ + collection: (name: string) => name === "cards" + ? { getOne: async () => card, getFirstListItem, update } + : { getFullList: async () => columns }, + } as never); + return { update, getFirstListItem }; + } + + it("keeps To Do when the client already chose it", async () => { + const { update, getFirstListItem } = setup("todo"); + await acceptCard({ cardId: "card-1", clientSlug: "client", projectSlug: "project" }); + expect(update).toHaveBeenCalledWith("card-1", expect.objectContaining({ accepted: true, start_date: expect.any(String) })); + expect(update.mock.calls[0][1]).not.toHaveProperty("column"); + expect(getFirstListItem).not.toHaveBeenCalled(); + }); + + it("still moves an Inbox request to Backlog", async () => { + const { update } = setup("inbox"); + await acceptCard({ cardId: "card-1", clientSlug: "client", projectSlug: "project" }); + expect(update).toHaveBeenCalledWith("card-1", expect.objectContaining({ + accepted: true, column: "backlog", position: 3000, + })); + }); +}); diff --git a/src/app/actions/cards.ts b/src/app/actions/cards.ts index b50961c..ac82da4 100644 --- a/src/app/actions/cards.ts +++ b/src/app/actions/cards.ts @@ -148,9 +148,9 @@ export async function updateCard(input: { * Phase 2 uses it to bound which commits a scope rule may claim, so a new card * cannot retroactively swallow a year of old commits sharing its scope. * - * The card moves to the column immediately after the Inbox — Backlog in the - * default layout — rather than to a hardcoded name, so a renamed board still - * works. + * An Inbox card moves to the column immediately after Inbox. If the client + * already placed their request in Backlog or To Do, accepting it keeps the + * chosen lane while still recording the owner's acceptance and start date. */ export async function acceptCard(input: { cardId: string; @@ -168,23 +168,28 @@ export async function acceptCard(input: { }); const inboxIndex = columns.findIndex((c) => c.is_inbox); - const destination = columns[inboxIndex + 1] ?? columns[inboxIndex]; + const source = columns.find((c) => c.id === card.column); + if (!source) throw new Error("This card has no column in its project"); + const destination = source.is_inbox + ? columns[inboxIndex + 1] ?? source + : source; if (!destination) throw new Error("This project has no column to accept into"); - const last = await pb - .collection("cards") - .getFirstListItem(`column = "${destination.id}"`, { - sort: "-position", - }) - .catch(() => null); + const last = source.is_inbox + ? await pb.collection("cards") + .getFirstListItem(`column = "${destination.id}"`, { sort: "-position" }) + .catch(() => null) + : null; const today = new Date().toISOString().slice(0, 10); await pb.collection("cards").update(input.cardId, { accepted: true, start_date: card.start_date || today, - column: destination.id, - position: positionBetween(last ? last.position : null, null), + ...(source.is_inbox ? { + column: destination.id, + position: positionBetween(last ? last.position : null, null), + } : {}), }); await relinkProject(card.project); diff --git a/src/app/api/client/cards/[id]/move/route.ts b/src/app/api/client/cards/[id]/move/route.ts new file mode 100644 index 0000000..4aac70f --- /dev/null +++ b/src/app/api/client/cards/[id]/move/route.ts @@ -0,0 +1,125 @@ +import { revalidatePath } from "next/cache"; + +import { getAdminPb } from "@/lib/pb/admin"; +import { requireSession } from "@/lib/auth/session"; +import { AccessError, assertClientOwnsProject, canWriteClientFields } from "@/lib/auth/access"; +import { clientMoveTargets, isClientReviewReturn } from "@/lib/board/client-moves"; +import { auditRow, writeCardWithAudit } from "@/lib/cards/write-with-audit"; +import { jsonError, jsonOk } from "@/lib/http/json"; +import { positionBetween } from "@/lib/ordering/fractional"; +import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types"; + +/** Clients may arrange queued work and resolve Review, but never start work for the owner. */ +export async function PATCH( + request: Request, + context: { params: Promise<{ id: string }> } +): Promise { + try { + const session = await requireSession(); + const { id } = await context.params; + + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonError("Invalid request body", 400); + } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return jsonError("Invalid request body", 400); + } + const input = body as Record; + if (Object.keys(input).some((key) => key !== "column" && key !== "comment")) { + return jsonError("Unsupported field in request", 400); + } + if (typeof input.column !== "string" || !input.column) { + return jsonError("A destination column is required", 400); + } + if (input.comment !== undefined && typeof input.comment !== "string") { + return jsonError("Invalid feedback", 400); + } + + const pb = await getAdminPb(); + const card = await pb.collection("cards").getOne(id).catch(() => null); + if (!card || card.archived) return jsonError("Card not found", 404); + const project = await pb.collection("projects").getOne(card.project).catch(() => null); + if (!project) return jsonError("Card not found", 404); + assertClientOwnsProject(session, project); + if (!canWriteClientFields(session, project.client)) return jsonError("Card not found", 404); + + const columns = await pb.collection("columns").getFullList({ + filter: `project = "${project.id}"`, + sort: "position", + }); + const source = columns.find((column) => column.id === card.column); + if (!source) return jsonError("Card is not in a movable column", 409); + const destination = clientMoveTargets(columns, source.id) + .find((column) => column.id === input.column); + if (!destination) return jsonError("That move is not allowed", 409); + + const reviewReturn = isClientReviewReturn(source, destination); + const comment = (input.comment as string | undefined)?.trim().slice(0, 20000) ?? ""; + if (comment && !reviewReturn) return jsonError("Feedback is only available when sending Review back", 400); + + const last = await pb.collection("cards").getList>(1, 1, { + filter: `column = "${destination.id}" && archived = false`, + sort: "-position", + fields: "position", + }); + const position = positionBetween(last.items[0]?.position ?? null, null); + const updated = await writeCardWithAudit(pb, { + cardId: card.id, + patch: { column: destination.id, position }, + rows: [auditRow({ + project: card.project, + card: card.id, + actor: session.userId, + action: "column_changed", + field: "column", + oldValue: source.name, + newValue: destination.name, + })], + }); + + let warning = ""; + if (comment) { + try { + await pb.collection("comments").create({ + card: card.id, + author: session.userId, + body: comment, + }); + } catch (error) { + console.error("[client-move] feedback failed after move", error); + warning = "Card moved, but feedback could not be saved. Please add it on the card."; + } + if (!warning) { + await pb.collection("activity").create(auditRow({ + project: card.project, + card: card.id, + actor: session.userId, + action: "comment_added", + field: "", + oldValue: "", + newValue: "", + })).catch((error) => { + console.error("[client-move] feedback saved but activity failed", error); + }); + } + } + + revalidatePath("/"); + revalidatePath("/plan"); + return jsonOk({ + card: { + id: card.id, + column: updated?.column ?? destination.id, + position: updated?.position ?? position, + }, + warning, + }); + } catch (error) { + if (error instanceof AccessError) return jsonError(error.message, error.status); + console.error("[client-move] unexpected error", error); + return jsonError("Could not move the card", 500); + } +} diff --git a/src/app/api/client/cards/client-move-route.test.ts b/src/app/api/client/cards/client-move-route.test.ts new file mode 100644 index 0000000..55f5332 --- /dev/null +++ b/src/app/api/client/cards/client-move-route.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { PATCH } from "@/app/api/client/cards/[id]/move/route"; +import { requireSession } from "@/lib/auth/session"; +import { getAdminPb } from "@/lib/pb/admin"; + +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/auth/session", () => ({ requireSession: vi.fn() })); +vi.mock("@/lib/pb/admin", () => ({ getAdminPb: vi.fn() })); + +const columns = [ + { id: "inbox", project: "p1", name: "Inbox", is_inbox: true, is_done: false }, + { id: "backlog", project: "p1", name: "Backlog", is_inbox: false, is_done: false }, + { id: "todo", project: "p1", name: "To Do", is_inbox: false, is_done: false }, + { id: "progress", project: "p1", name: "In Progress", is_inbox: false, is_done: false }, + { id: "review", project: "p1", name: "Review", is_inbox: false, is_done: false }, + { id: "done", project: "p1", name: "Done", is_inbox: false, is_done: true }, +]; + +function setup(source: string) { + let card = { id: "card-1", project: "p1", column: source, archived: false, accepted: false, position: 1000 }; + const update = vi.fn(async (_id: string, patch: Record) => { + card = { ...card, ...patch } as typeof card; + return card; + }); + const commentCreate = vi.fn(async () => ({ id: "comment-1" })); + const activityCreate = vi.fn(async () => ({ id: "activity-1" })); + const pb = { + collection: vi.fn((name: string) => { + if (name === "cards") return { + getOne: vi.fn(async () => card), + getList: vi.fn(async () => ({ items: [] })), + update, + }; + if (name === "projects") return { getOne: vi.fn(async () => ({ id: "p1", client: "c1" })) }; + if (name === "columns") return { getFullList: vi.fn(async () => columns) }; + if (name === "comments") return { create: commentCreate }; + if (name === "activity") return { create: activityCreate }; + throw new Error(`Unexpected collection ${name}`); + }), + }; + vi.mocked(getAdminPb).mockResolvedValue(pb as never); + return { update, commentCreate, activityCreate }; +} + +function move(column: string, comment?: string, extras: Record = {}) { + return PATCH(new Request("http://localhost/api/client/cards/card-1/move", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ column, ...(comment === undefined ? {} : { comment }), ...extras }), + }), { params: Promise.resolve({ id: "card-1" }) }); +} + +describe("client card move route", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireSession).mockResolvedValue({ userId: "client-user", role: "client_manager", clientId: "c1" }); + }); + + it("moves a request to To Do without claiming the owner accepted it", async () => { + const { update, activityCreate } = setup("inbox"); + const response = await move("todo"); + expect(response.status).toBe(200); + expect(update).toHaveBeenCalledWith("card-1", { column: "todo", position: 1000 }); + expect(activityCreate).toHaveBeenCalledWith(expect.objectContaining({ + action: "column_changed", old_value: "Inbox", new_value: "To Do", + })); + }); + + it("saves feedback when a client sends Review back", async () => { + const { commentCreate, activityCreate } = setup("review"); + const response = await move("backlog", " Please adjust the layout "); + expect(response.status).toBe(200); + expect(commentCreate).toHaveBeenCalledWith({ + card: "card-1", author: "client-user", body: "Please adjust the layout", + }); + expect(activityCreate).toHaveBeenCalledWith(expect.objectContaining({ action: "comment_added" })); + }); + + it("keeps a successful move when optional feedback fails to save and reports it", async () => { + const { update, commentCreate } = setup("review"); + commentCreate.mockRejectedValueOnce(new Error("storage unavailable")); + const log = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const response = await move("todo", "Please adjust the layout"); + expect(response.status).toBe(200); + expect(update).toHaveBeenCalledWith("card-1", expect.objectContaining({ column: "todo" })); + expect((await response.json()).warning).toMatch(/feedback could not be saved/); + } finally { + log.mockRestore(); + } + }); + + it("allows Review to Done but rejects Done from the queue and entry into developer lanes", async () => { + setup("review"); + expect((await move("done")).status).toBe(200); + const { update } = setup("todo"); + expect((await move("done")).status).toBe(409); + expect((await move("progress")).status).toBe(409); + expect((await move("review")).status).toBe(409); + setup("progress"); + expect((await move("backlog")).status).toBe(409); + expect(update).not.toHaveBeenCalled(); + }); + + it("does not attach review feedback to an unrelated queue move", async () => { + const { update, commentCreate } = setup("inbox"); + expect((await move("backlog", "Please change this")).status).toBe(400); + expect(update).not.toHaveBeenCalled(); + expect(commentCreate).not.toHaveBeenCalled(); + }); + + it("rejects cross-client and owner requests before writing", async () => { + const { update } = setup("inbox"); + vi.mocked(requireSession).mockResolvedValue({ userId: "other-client", role: "client_manager", clientId: "c2" }); + expect((await move("backlog")).status).toBe(404); + vi.mocked(requireSession).mockResolvedValue({ userId: "owner", role: "owner", clientId: "" }); + expect((await move("backlog")).status).toBe(404); + expect(update).not.toHaveBeenCalled(); + }); + + it("does not accept a client-supplied position or other card fields", async () => { + const { update } = setup("inbox"); + expect((await move("backlog", undefined, { position: 999999 })).status).toBe(400); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/owner/cards/[id]/move/route.ts b/src/app/api/owner/cards/[id]/move/route.ts index 67c31ea..073f4a1 100644 --- a/src/app/api/owner/cards/[id]/move/route.ts +++ b/src/app/api/owner/cards/[id]/move/route.ts @@ -1,8 +1,10 @@ import { getServerPb } from "@/lib/pb/server"; import { requireSession } from "@/lib/auth/session"; +import { logActivity } from "@/app/actions/activity"; import { AccessError } from "@/lib/auth/access"; import { jsonError, jsonOk } from "@/lib/http/json"; -import type { CardRecord } from "@/lib/pb/types"; +import { relinkProject } from "@/lib/linking/engine"; +import type { CardRecord, ColumnRecord } from "@/lib/pb/types"; /** * PATCH /api/owner/cards/:id/move — the owner drops a card in a new place. @@ -12,9 +14,9 @@ import type { CardRecord } from "@/lib/pb/types"; * authorises the update, so a client manager reaching this route is refused by * the database even though the handler itself is simple. * - * That is why there is no field whitelist here: PocketBase is already - * enforcing who may write. Only the two positional fields are forwarded - * regardless, so a stray body field cannot ride along. + * Only the two positional fields come from the request. When the owner moves + * a pending request into work, the server also records acceptance and its + * start date so a client-selected To Do position does not skip that step. */ export async function PATCH( request: Request, @@ -47,16 +49,44 @@ export async function PATCH( } const pb = await getServerPb(); + const card = await pb.collection("cards").getOne(id).catch(() => null); + if (!card || card.archived) return jsonError("Card not found", 404); + const destination = await pb.collection("columns").getOne(column).catch(() => null); + if (!destination || destination.project !== card.project) { + return jsonError("Destination column not found", 400); + } + const accepting = !card.accepted && + card.column !== destination.id && !destination.is_inbox; const updated = await pb .collection("cards") - .update(id, { column, position }); + .update(id, { + column, + position, + ...(accepting ? { accepted: true, start_date: card.start_date || new Date().toISOString().slice(0, 10) } : {}), + }); + if (accepting) { + await logActivity({ + project: card.project, + card: card.id, + actor: session.userId, + action: "card_accepted", + newValue: destination.name, + }).catch((error) => { + console.error("[owner-move] accepted card but activity failed", error); + }); + await relinkProject(card.project).catch((error) => { + console.error("[owner-move] accepted card but relinking failed", error); + }); + } return jsonOk({ card: { id: updated.id, column: updated.column, position: updated.position, + accepted: updated.accepted, + start_date: updated.start_date, }, }); } catch (error) { @@ -66,4 +96,4 @@ export async function PATCH( console.error("[owner-move] failed", error); return jsonError("Could not move the card", 500); } -} \ No newline at end of file +} diff --git a/src/app/api/owner/cards/owner-move.test.ts b/src/app/api/owner/cards/owner-move.test.ts new file mode 100644 index 0000000..e4d1db0 --- /dev/null +++ b/src/app/api/owner/cards/owner-move.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { PATCH } from "@/app/api/owner/cards/[id]/move/route"; +import { requireSession } from "@/lib/auth/session"; +import { getServerPb } from "@/lib/pb/server"; +import { relinkProject } from "@/lib/linking/engine"; +import { logActivity } from "@/app/actions/activity"; + +vi.mock("@/lib/auth/session", () => ({ requireSession: vi.fn() })); +vi.mock("@/lib/pb/server", () => ({ getServerPb: vi.fn() })); +vi.mock("@/lib/linking/engine", () => ({ relinkProject: vi.fn() })); +vi.mock("@/app/actions/activity", () => ({ logActivity: vi.fn() })); + +function setup(destinationProject = "p1") { + const card = { id: "card-1", project: "p1", column: "todo", position: 1000, + accepted: false, start_date: "", archived: false, created_by_role: "client" }; + const update = vi.fn(async (_id: string, patch: Record) => ({ ...card, ...patch })); + vi.mocked(getServerPb).mockResolvedValue({ collection: (name: string) => name === "cards" + ? { getOne: async () => card, update } + : { getOne: async () => ({ id: "progress", project: destinationProject, name: "In Progress", is_inbox: false }) }, + } as never); + return { update }; +} + +function move() { + return PATCH(new Request("http://localhost/api/owner/cards/card-1/move", { + method: "PATCH", headers: { "content-type": "application/json" }, + body: JSON.stringify({ column: "progress", position: 2000 }), + }), { params: Promise.resolve({ id: "card-1" }) }); +} + +describe("owner board moves", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireSession).mockResolvedValue({ userId: "owner", role: "owner", clientId: "" }); + vi.mocked(relinkProject).mockResolvedValue({} as never); + vi.mocked(logActivity).mockResolvedValue(undefined); + }); + + it("accepts a client-requested card when the owner advances it to work", async () => { + const { update } = setup(); + const response = await move(); + expect(response.status).toBe(200); + expect(update).toHaveBeenCalledWith("card-1", expect.objectContaining({ + column: "progress", position: 2000, accepted: true, start_date: expect.any(String), + })); + expect(relinkProject).toHaveBeenCalledWith("p1"); + expect(logActivity).toHaveBeenCalledWith(expect.objectContaining({ + project: "p1", card: "card-1", action: "card_accepted", newValue: "In Progress", + })); + }); + + it("rejects a destination from another project", async () => { + const { update } = setup("p2"); + expect((await move()).status).toBe(400); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/board/board.tsx b/src/components/board/board.tsx index 7e622d6..56d7d86 100644 --- a/src/components/board/board.tsx +++ b/src/components/board/board.tsx @@ -34,6 +34,7 @@ function SortableCard({ card, area, role, + columns, clientSlug, projectSlug, onPatched, @@ -41,6 +42,7 @@ function SortableCard({ card: CardRecord; area: WorkAreaRecord | null; role: Role; + columns: ColumnRecord[]; clientSlug: string; projectSlug: string; onPatched: (card: CardRecord) => void; @@ -61,8 +63,8 @@ function SortableCard({ {...(draggable ? attributes : {})} {...(draggable ? listeners : {})} > - {role === "client_manager" && ( - + {role === "client_manager" && !columns.find((column) => column.id === card.column)?.is_done && ( + )} ); @@ -243,6 +245,10 @@ export function Board({ console.error("[board] move failed", response.status); setCards(previous); } else { + const result = await response.json(); + if (result.card) { + setCards((current) => current.map((item) => item.id === card.id ? { ...item, ...result.card } : item)); + } // No router.refresh(): it raced with the optimistic state and could // revert the move before the user saw it land. The next page visit // loads the fresh snapshot from loadBoard. @@ -281,6 +287,7 @@ export function Board({ card={card} area={workAreaForCard(card, areas)} role={role} + columns={columns} clientSlug={clientSlug} projectSlug={projectSlug} onPatched={onPatched} diff --git a/src/components/board/client-card-controls.tsx b/src/components/board/client-card-controls.tsx index 3628691..f167d7b 100644 --- a/src/components/board/client-card-controls.tsx +++ b/src/components/board/client-card-controls.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { ChevronDown } from "lucide-react"; import { Dropdown, @@ -9,7 +10,9 @@ import { DropdownLabel, DropdownTrigger, } from "@/components/ui/dropdown"; -import type { CardRecord, Priority } from "@/lib/pb/types"; +import { useToast } from "@/components/ui/toast"; +import { clientMoveTargets, isClientReviewReturn } from "@/lib/board/client-moves"; +import type { CardRecord, ColumnRecord, Priority } from "@/lib/pb/types"; const PRIORITIES: Array<{ value: Priority; label: string }> = [ { value: "critical", label: "Critical" }, @@ -19,21 +22,28 @@ const PRIORITIES: Array<{ value: Priority; label: string }> = [ ]; /** - * The only two fields a client manager may change. + * Clients can edit priority and date, and move cards through the guarded + * queue/review workflow. Every write goes through a client API route. * - * Both go through PATCH /api/client/cards/:id, never straight to PocketBase. - * Their token cannot write to the cards collection at all — see Plan 1a — so - * a direct SDK call from here would simply fail. The route is the path. + * Their token cannot write to the cards collection at all, so neither route + * writes directly from the browser. */ export function ClientCardControls({ card, + columns, onPatched, }: { card: CardRecord; + columns: ColumnRecord[]; onPatched: (card: CardRecord) => void; }) { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [returnTo, setReturnTo] = useState(null); + const [feedback, setFeedback] = useState(""); + const { showError, showToast } = useToast(); + const source = columns.find((column) => column.id === card.column); + const destinations = clientMoveTargets(columns, card.column); async function patch(body: Record) { setSaving(true); @@ -60,11 +70,70 @@ export function ClientCardControls({ onPatched({ ...card, ...updated } as CardRecord); } + async function move(destination: ColumnRecord, comment = "") { + if (saving) return; + setSaving(true); + setError(null); + try { + const response = await fetch(`/api/client/cards/${card.id}/move`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ column: destination.id, comment }), + }); + if (!response.ok) { + showError("Could not move this card. Please try again."); + return; + } + const result = await response.json(); + onPatched({ ...card, ...result.card } as CardRecord); + setReturnTo(null); + setFeedback(""); + if (result.warning) showError(result.warning); + else showToast(destination.is_done ? "Work approved and moved to Done." : `Moved to ${destination.name}.`); + } catch { + showError("Could not move this card. Please try again."); + } finally { + setSaving(false); + } + } + const currentPriority = PRIORITIES.find((p) => p.value === card.priority) ?? PRIORITIES[2]; return ( -
+
+ {destinations.length > 0 && ( + + + + + + {destinations.some((destination) => destination.is_done) ? "Review decision" : "Move card to"} + {destinations.map((destination) => ( + { + if (source && isClientReviewReturn(source, destination)) { + setFeedback(""); + setReturnTo(destination); + } else { + void move(destination); + } + }} + > + {destination.is_done ? "Approve → Done" : destination.name} + + ))} + + + )}