feat: add board group models and update related interfaces
This commit is contained in:
@@ -69,7 +69,7 @@ const getBoardOptions = (boards: BoardRead[]): SearchableSelectOption[] =>
|
||||
}));
|
||||
|
||||
const normalizeIdentityProfile = (
|
||||
profile: IdentityProfile
|
||||
profile: IdentityProfile,
|
||||
): IdentityProfile | null => {
|
||||
const normalized: IdentityProfile = {
|
||||
role: profile.role.trim(),
|
||||
@@ -81,11 +81,12 @@ const normalizeIdentityProfile = (
|
||||
};
|
||||
|
||||
const withIdentityDefaults = (
|
||||
profile: Partial<IdentityProfile> | null | undefined
|
||||
profile: Partial<IdentityProfile> | null | undefined,
|
||||
): IdentityProfile => ({
|
||||
role: profile?.role ?? DEFAULT_IDENTITY_PROFILE.role,
|
||||
communication_style:
|
||||
profile?.communication_style ?? DEFAULT_IDENTITY_PROFILE.communication_style,
|
||||
profile?.communication_style ??
|
||||
DEFAULT_IDENTITY_PROFILE.communication_style,
|
||||
emoji: profile?.emoji ?? DEFAULT_IDENTITY_PROFILE.emoji,
|
||||
});
|
||||
|
||||
@@ -150,8 +151,10 @@ export default function EditAgentPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const boards =
|
||||
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [];
|
||||
const boards = useMemo<BoardRead[]>(() => {
|
||||
if (boardsQuery.data?.status !== 200) return [];
|
||||
return boardsQuery.data.data.items ?? [];
|
||||
}, [boardsQuery.data]);
|
||||
const loadedAgent: AgentRead | null =
|
||||
agentQuery.data?.status === 200 ? agentQuery.data.data : null;
|
||||
|
||||
@@ -226,7 +229,7 @@ export default function EditAgentPage() {
|
||||
!loadedAgent.board_id
|
||||
) {
|
||||
setError(
|
||||
"Select a board once so we can resolve the gateway main session key."
|
||||
"Select a board once so we can resolve the gateway main session key.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -238,10 +241,9 @@ export default function EditAgentPage() {
|
||||
every: resolvedHeartbeatEvery.trim() || "10m",
|
||||
target: resolvedHeartbeatTarget,
|
||||
} as unknown as Record<string, unknown>,
|
||||
identity_profile: normalizeIdentityProfile(resolvedIdentityProfile) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
identity_profile: normalizeIdentityProfile(
|
||||
resolvedIdentityProfile,
|
||||
) as unknown as Record<string, unknown> | null,
|
||||
soul_template: resolvedSoulTemplate.trim() || null,
|
||||
};
|
||||
if (!resolvedIsGatewayMain) {
|
||||
@@ -278,7 +280,9 @@ export default function EditAgentPage() {
|
||||
<div className="border-b border-slate-200 bg-white px-8 py-6">
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-semibold text-slate-900 tracking-tight">
|
||||
{resolvedName.trim() ? resolvedName : loadedAgent?.name ?? "Edit agent"}
|
||||
{resolvedName.trim()
|
||||
? resolvedName
|
||||
: (loadedAgent?.name ?? "Edit agent")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Status is controlled by agent heartbeat.
|
||||
@@ -356,7 +360,11 @@ export default function EditAgentPage() {
|
||||
value={resolvedBoardId}
|
||||
onValueChange={(value) => setBoardId(value)}
|
||||
options={getBoardOptions(boards)}
|
||||
placeholder={resolvedIsGatewayMain ? "No board (main agent)" : "Select board"}
|
||||
placeholder={
|
||||
resolvedIsGatewayMain
|
||||
? "No board (main agent)"
|
||||
: "Select board"
|
||||
}
|
||||
searchPlaceholder="Search boards..."
|
||||
emptyMessage="No matching boards."
|
||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
@@ -410,7 +418,9 @@ export default function EditAgentPage() {
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-200"
|
||||
checked={resolvedIsGatewayMain}
|
||||
onChange={(event) => setIsGatewayMain(event.target.checked)}
|
||||
onChange={(event) =>
|
||||
setIsGatewayMain(event.target.checked)
|
||||
}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span>
|
||||
@@ -471,7 +481,9 @@ export default function EditAgentPage() {
|
||||
</label>
|
||||
<Input
|
||||
value={resolvedHeartbeatEvery}
|
||||
onChange={(event) => setHeartbeatEvery(event.target.value)}
|
||||
onChange={(event) =>
|
||||
setHeartbeatEvery(event.target.value)
|
||||
}
|
||||
placeholder="e.g. 10m"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
type listBoardsApiV1BoardsGetResponse,
|
||||
useListBoardsApiV1BoardsGet,
|
||||
} from "@/api/generated/boards/boards";
|
||||
import type { ActivityEventRead, AgentRead, BoardRead } from "@/api/generated/model";
|
||||
import type {
|
||||
ActivityEventRead,
|
||||
AgentRead,
|
||||
BoardRead,
|
||||
} from "@/api/generated/model";
|
||||
import { StatusPill } from "@/components/atoms/StatusPill";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
@@ -119,12 +123,14 @@ export default function AgentDetailPage() {
|
||||
|
||||
const agent: AgentRead | null =
|
||||
agentQuery.data?.status === 200 ? agentQuery.data.data : null;
|
||||
const events: ActivityEventRead[] =
|
||||
activityQuery.data?.status === 200
|
||||
? activityQuery.data.data.items ?? []
|
||||
: [];
|
||||
const boards: BoardRead[] =
|
||||
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [];
|
||||
const events = useMemo<ActivityEventRead[]>(() => {
|
||||
if (activityQuery.data?.status !== 200) return [];
|
||||
return activityQuery.data.data.items ?? [];
|
||||
}, [activityQuery.data]);
|
||||
const boards = useMemo<BoardRead[]>(() => {
|
||||
if (boardsQuery.data?.status !== 200) return [];
|
||||
return boardsQuery.data.data.items ?? [];
|
||||
}, [boardsQuery.data]);
|
||||
|
||||
const agentEvents = useMemo(() => {
|
||||
if (!agent) return [];
|
||||
@@ -133,7 +139,7 @@ export default function AgentDetailPage() {
|
||||
const linkedBoard =
|
||||
!agent?.board_id || agent?.is_gateway_main
|
||||
? null
|
||||
: boards.find((board) => board.id === agent.board_id) ?? null;
|
||||
: (boards.find((board) => board.id === agent.board_id) ?? null);
|
||||
|
||||
const deleteMutation = useDeleteAgentApiV1AgentsAgentIdDelete<ApiError>({
|
||||
mutation: {
|
||||
@@ -194,8 +200,7 @@ export default function AgentDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => router.push("/agents")}
|
||||
>
|
||||
<Button variant="outline" onClick={() => router.push("/agents")}>
|
||||
Back to agents
|
||||
</Button>
|
||||
{agent ? (
|
||||
@@ -259,7 +264,9 @@ export default function AgentDetailPage() {
|
||||
Board
|
||||
</p>
|
||||
{agent.is_gateway_main ? (
|
||||
<p className="mt-1 text-sm text-strong">Gateway main (no board)</p>
|
||||
<p className="mt-1 text-sm text-strong">
|
||||
Gateway main (no board)
|
||||
</p>
|
||||
) : linkedBoard ? (
|
||||
<Link
|
||||
href={`/boards/${linkedBoard.id}`}
|
||||
@@ -315,7 +322,9 @@ export default function AgentDetailPage() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Session binding</span>
|
||||
<span>{agent.openclaw_session_id ? "Bound" : "Unbound"}</span>
|
||||
<span>
|
||||
{agent.openclaw_session_id ? "Bound" : "Unbound"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Status</span>
|
||||
|
||||
@@ -65,7 +65,7 @@ const getBoardOptions = (boards: BoardRead[]): SearchableSelectOption[] =>
|
||||
}));
|
||||
|
||||
const normalizeIdentityProfile = (
|
||||
profile: IdentityProfile
|
||||
profile: IdentityProfile,
|
||||
): IdentityProfile | null => {
|
||||
const normalized: IdentityProfile = {
|
||||
role: profile.role.trim(),
|
||||
@@ -114,7 +114,7 @@ export default function NewAgentPage() {
|
||||
});
|
||||
|
||||
const boards =
|
||||
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [];
|
||||
boardsQuery.data?.status === 200 ? (boardsQuery.data.data.items ?? []) : [];
|
||||
const displayBoardId = boardId || boards[0]?.id || "";
|
||||
const isLoading = boardsQuery.isLoading || createAgentMutation.isPending;
|
||||
const errorMessage = error ?? boardsQuery.error?.message ?? null;
|
||||
@@ -141,10 +141,9 @@ export default function NewAgentPage() {
|
||||
every: heartbeatEvery.trim() || "10m",
|
||||
target: heartbeatTarget,
|
||||
},
|
||||
identity_profile: normalizeIdentityProfile(identityProfile) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
identity_profile: normalizeIdentityProfile(
|
||||
identityProfile,
|
||||
) as unknown as Record<string, unknown> | null,
|
||||
soul_template: soulTemplate.trim() || null,
|
||||
},
|
||||
});
|
||||
@@ -155,7 +154,9 @@ export default function NewAgentPage() {
|
||||
<SignedOut>
|
||||
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
|
||||
<p className="text-sm text-slate-600">Sign in to create an agent.</p>
|
||||
<p className="text-sm text-slate-600">
|
||||
Sign in to create an agent.
|
||||
</p>
|
||||
<SignInButton
|
||||
mode="modal"
|
||||
forceRedirectUrl="/agents/new"
|
||||
@@ -318,7 +319,9 @@ export default function NewAgentPage() {
|
||||
</label>
|
||||
<Input
|
||||
value={heartbeatEvery}
|
||||
onChange={(event) => setHeartbeatEvery(event.target.value)}
|
||||
onChange={(event) =>
|
||||
setHeartbeatEvery(event.target.value)
|
||||
}
|
||||
placeholder="e.g. 10m"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
getListBoardsApiV1BoardsGetQueryKey,
|
||||
useListBoardsApiV1BoardsGet,
|
||||
} from "@/api/generated/boards/boards";
|
||||
import type { AgentRead, BoardRead } from "@/api/generated/model";
|
||||
import type { AgentRead } from "@/api/generated/model";
|
||||
|
||||
const parseTimestamp = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
@@ -121,13 +121,17 @@ export default function AgentsPage() {
|
||||
|
||||
const boards = useMemo(
|
||||
() =>
|
||||
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [],
|
||||
[boardsQuery.data]
|
||||
boardsQuery.data?.status === 200
|
||||
? (boardsQuery.data.data.items ?? [])
|
||||
: [],
|
||||
[boardsQuery.data],
|
||||
);
|
||||
const agents = useMemo(
|
||||
() =>
|
||||
agentsQuery.data?.status === 200 ? agentsQuery.data.data.items ?? [] : [],
|
||||
[agentsQuery.data]
|
||||
agentsQuery.data?.status === 200
|
||||
? (agentsQuery.data.data.items ?? [])
|
||||
: [],
|
||||
[agentsQuery.data],
|
||||
);
|
||||
|
||||
const deleteMutation = useDeleteAgentApiV1AgentsAgentIdDelete<
|
||||
@@ -139,20 +143,25 @@ export default function AgentsPage() {
|
||||
onMutate: async ({ agentId }) => {
|
||||
await queryClient.cancelQueries({ queryKey: agentsKey });
|
||||
const previous =
|
||||
queryClient.getQueryData<listAgentsApiV1AgentsGetResponse>(agentsKey);
|
||||
queryClient.getQueryData<listAgentsApiV1AgentsGetResponse>(
|
||||
agentsKey,
|
||||
);
|
||||
if (previous && previous.status === 200) {
|
||||
const nextItems = previous.data.items.filter(
|
||||
(agent) => agent.id !== agentId
|
||||
(agent) => agent.id !== agentId,
|
||||
);
|
||||
const removedCount = previous.data.items.length - nextItems.length;
|
||||
queryClient.setQueryData<listAgentsApiV1AgentsGetResponse>(agentsKey, {
|
||||
...previous,
|
||||
data: {
|
||||
...previous.data,
|
||||
items: nextItems,
|
||||
total: Math.max(0, previous.data.total - removedCount),
|
||||
queryClient.setQueryData<listAgentsApiV1AgentsGetResponse>(
|
||||
agentsKey,
|
||||
{
|
||||
...previous,
|
||||
data: {
|
||||
...previous.data,
|
||||
items: nextItems,
|
||||
total: Math.max(0, previous.data.total - removedCount),
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
}
|
||||
return { previous };
|
||||
},
|
||||
@@ -170,103 +179,99 @@ export default function AgentsPage() {
|
||||
},
|
||||
},
|
||||
},
|
||||
queryClient
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const sortedAgents = useMemo(() => [...agents], [agents]);
|
||||
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteTarget) return;
|
||||
deleteMutation.mutate({ agentId: deleteTarget.id });
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnDef<AgentRead>[]>(
|
||||
() => {
|
||||
const resolveBoardName = (agent: AgentRead) =>
|
||||
boards.find((board) => board.id === agent.board_id)?.name ?? "—";
|
||||
const columns = useMemo<ColumnDef<AgentRead>[]>(() => {
|
||||
const resolveBoardName = (agent: AgentRead) =>
|
||||
boards.find((board) => board.id === agent.board_id)?.name ?? "—";
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Agent",
|
||||
cell: ({ row }) => (
|
||||
<Link href={`/agents/${row.original.id}`} className="group block">
|
||||
<p className="text-sm font-medium text-slate-900 group-hover:text-blue-600">
|
||||
{row.original.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">ID {row.original.id}</p>
|
||||
return [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Agent",
|
||||
cell: ({ row }) => (
|
||||
<Link href={`/agents/${row.original.id}`} className="group block">
|
||||
<p className="text-sm font-medium text-slate-900 group-hover:text-blue-600">
|
||||
{row.original.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">ID {row.original.id}</p>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<StatusPill status={row.original.status ?? "unknown"} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "openclaw_session_id",
|
||||
header: "Session",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{truncate(row.original.openclaw_session_id)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "board_id",
|
||||
header: "Board",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{resolveBoardName(row.original)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "last_seen_at",
|
||||
header: "Last seen",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{formatRelative(row.original.last_seen_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{formatTimestamp(row.original.updated_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link
|
||||
href={`/agents/${row.original.id}/edit`}
|
||||
className={buttonVariants({ variant: "ghost", size: "sm" })}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<StatusPill status={row.original.status ?? "unknown"} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "openclaw_session_id",
|
||||
header: "Session",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{truncate(row.original.openclaw_session_id)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "board_id",
|
||||
header: "Board",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{resolveBoardName(row.original)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "last_seen_at",
|
||||
header: "Last seen",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{formatRelative(row.original.last_seen_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{formatTimestamp(row.original.updated_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link
|
||||
href={`/agents/${row.original.id}/edit`}
|
||||
className={buttonVariants({ variant: "ghost", size: "sm" })}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(row.original)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
},
|
||||
[boards]
|
||||
);
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(row.original)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [boards]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
@@ -330,7 +335,7 @@ export default function AgentsPage() {
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
header.getContext(),
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
@@ -341,7 +346,9 @@ export default function AgentsPage() {
|
||||
{agentsQuery.isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-6 py-8">
|
||||
<span className="text-sm text-slate-500">Loading…</span>
|
||||
<span className="text-sm text-slate-500">
|
||||
Loading…
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : table.getRowModel().rows.length ? (
|
||||
@@ -351,7 +358,7 @@ export default function AgentsPage() {
|
||||
<td key={cell.id} className="px-6 py-4">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
cell.getContext(),
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
@@ -386,7 +393,10 @@ export default function AgentsPage() {
|
||||
</p>
|
||||
<Link
|
||||
href="/agents/new"
|
||||
className={buttonVariants({ size: "md", variant: "primary" })}
|
||||
className={buttonVariants({
|
||||
size: "md",
|
||||
variant: "primary",
|
||||
})}
|
||||
>
|
||||
Create your first agent
|
||||
</Link>
|
||||
@@ -420,7 +430,8 @@ export default function AgentsPage() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will remove {deleteTarget?.name}. This action cannot be undone.
|
||||
This will remove {deleteTarget?.name}. This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{deleteMutation.error ? (
|
||||
|
||||
483
frontend/src/app/board-groups/[groupId]/edit/page.tsx
Normal file
483
frontend/src/app/board-groups/[groupId]/edit/page.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
"use client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import {
|
||||
type listBoardsApiV1BoardsGetResponse,
|
||||
updateBoardApiV1BoardsBoardIdPatch,
|
||||
useListBoardsApiV1BoardsGet,
|
||||
} from "@/api/generated/boards/boards";
|
||||
import {
|
||||
type getBoardGroupApiV1BoardGroupsGroupIdGetResponse,
|
||||
useGetBoardGroupApiV1BoardGroupsGroupIdGet,
|
||||
useUpdateBoardGroupApiV1BoardGroupsGroupIdPatch,
|
||||
} from "@/api/generated/board-groups/board-groups";
|
||||
import type {
|
||||
BoardGroupRead,
|
||||
BoardGroupUpdate,
|
||||
BoardRead,
|
||||
} from "@/api/generated/model";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "") || "group";
|
||||
|
||||
export default function EditBoardGroupPage() {
|
||||
const { isSignedIn } = useAuth();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const params = useParams();
|
||||
const groupIdParam = params?.groupId;
|
||||
const groupId = Array.isArray(groupIdParam) ? groupIdParam[0] : groupIdParam;
|
||||
|
||||
const [name, setName] = useState<string | undefined>(undefined);
|
||||
const [description, setDescription] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [boardSearch, setBoardSearch] = useState("");
|
||||
const [selectedBoardIds, setSelectedBoardIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [isAssignmentsSaving, setIsAssignmentsSaving] = useState(false);
|
||||
const [assignmentsError, setAssignmentsError] = useState<string | null>(null);
|
||||
const [assignmentsResult, setAssignmentsResult] = useState<{
|
||||
updated: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
|
||||
const assignFailedParam = searchParams.get("assign_failed");
|
||||
const assignFailedCount = assignFailedParam
|
||||
? Number.parseInt(assignFailedParam, 10)
|
||||
: null;
|
||||
|
||||
const groupQuery = useGetBoardGroupApiV1BoardGroupsGroupIdGet<
|
||||
getBoardGroupApiV1BoardGroupsGroupIdGetResponse,
|
||||
ApiError
|
||||
>(groupId ?? "", {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn && groupId),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
|
||||
const loadedGroup: BoardGroupRead | null =
|
||||
groupQuery.data?.status === 200 ? groupQuery.data.data : null;
|
||||
const baseGroup = loadedGroup;
|
||||
|
||||
const resolvedName = name ?? baseGroup?.name ?? "";
|
||||
const resolvedDescription = description ?? baseGroup?.description ?? "";
|
||||
|
||||
const allBoardsQuery = useListBoardsApiV1BoardsGet<
|
||||
listBoardsApiV1BoardsGetResponse,
|
||||
ApiError
|
||||
>(
|
||||
{ limit: 200 },
|
||||
{
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const groupBoardsQuery = useListBoardsApiV1BoardsGet<
|
||||
listBoardsApiV1BoardsGetResponse,
|
||||
ApiError
|
||||
>(
|
||||
{ limit: 200, board_group_id: groupId ?? null },
|
||||
{
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn && groupId),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const allBoards = useMemo<BoardRead[]>(() => {
|
||||
if (allBoardsQuery.data?.status !== 200) return [];
|
||||
return allBoardsQuery.data.data.items ?? [];
|
||||
}, [allBoardsQuery.data]);
|
||||
|
||||
const groupBoards = useMemo<BoardRead[]>(() => {
|
||||
if (groupBoardsQuery.data?.status !== 200) return [];
|
||||
return groupBoardsQuery.data.data.items ?? [];
|
||||
}, [groupBoardsQuery.data]);
|
||||
|
||||
const boards = useMemo<BoardRead[]>(() => {
|
||||
const byId = new Map<string, BoardRead>();
|
||||
for (const board of allBoards) {
|
||||
byId.set(board.id, board);
|
||||
}
|
||||
for (const board of groupBoards) {
|
||||
byId.set(board.id, board);
|
||||
}
|
||||
const merged = Array.from(byId.values());
|
||||
merged.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return merged;
|
||||
}, [allBoards, groupBoards]);
|
||||
|
||||
const initializedSelectionRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId) return;
|
||||
if (initializedSelectionRef.current) return;
|
||||
if (groupBoardsQuery.data?.status !== 200) return;
|
||||
initializedSelectionRef.current = true;
|
||||
setSelectedBoardIds(new Set(groupBoards.map((board) => board.id)));
|
||||
}, [groupBoards, groupBoardsQuery.data, groupId]);
|
||||
|
||||
const updateMutation =
|
||||
useUpdateBoardGroupApiV1BoardGroupsGroupIdPatch<ApiError>({
|
||||
mutation: {
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
|
||||
const isGroupSaving = groupQuery.isLoading || updateMutation.isPending;
|
||||
const boardsLoading = allBoardsQuery.isLoading || groupBoardsQuery.isLoading;
|
||||
const boardsError = groupBoardsQuery.error ?? allBoardsQuery.error ?? null;
|
||||
const isBoardsBusy = boardsLoading || isAssignmentsSaving;
|
||||
const isLoading = isGroupSaving || isBoardsBusy;
|
||||
const errorMessage = error ?? groupQuery.error?.message ?? null;
|
||||
const isFormReady = Boolean(resolvedName.trim());
|
||||
|
||||
const handleSaveAssignments = async (): Promise<{
|
||||
updated: number;
|
||||
failed: number;
|
||||
} | null> => {
|
||||
if (!isSignedIn || !groupId) return null;
|
||||
if (groupBoardsQuery.data?.status !== 200) {
|
||||
setAssignmentsError("Group boards are not loaded yet.");
|
||||
return null;
|
||||
}
|
||||
|
||||
setAssignmentsError(null);
|
||||
setAssignmentsResult(null);
|
||||
|
||||
const desired = selectedBoardIds;
|
||||
const current = new Set(groupBoards.map((board) => board.id));
|
||||
const toAdd = Array.from(desired).filter((id) => !current.has(id));
|
||||
const toRemove = Array.from(current).filter((id) => !desired.has(id));
|
||||
|
||||
const failures: string[] = [];
|
||||
let updated = 0;
|
||||
|
||||
for (const boardId of toAdd) {
|
||||
try {
|
||||
const result = await updateBoardApiV1BoardsBoardIdPatch(boardId, {
|
||||
board_group_id: groupId,
|
||||
});
|
||||
if (result.status === 200) {
|
||||
updated += 1;
|
||||
} else {
|
||||
failures.push(boardId);
|
||||
}
|
||||
} catch {
|
||||
failures.push(boardId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const boardId of toRemove) {
|
||||
try {
|
||||
const result = await updateBoardApiV1BoardsBoardIdPatch(boardId, {
|
||||
board_group_id: null,
|
||||
});
|
||||
if (result.status === 200) {
|
||||
updated += 1;
|
||||
} else {
|
||||
failures.push(boardId);
|
||||
}
|
||||
} catch {
|
||||
failures.push(boardId);
|
||||
}
|
||||
}
|
||||
|
||||
setAssignmentsResult({ updated, failed: failures.length });
|
||||
if (failures.length > 0) {
|
||||
setAssignmentsError(
|
||||
`Failed to update ${failures.length} board assignment${
|
||||
failures.length === 1 ? "" : "s"
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
|
||||
void groupBoardsQuery.refetch();
|
||||
void allBoardsQuery.refetch();
|
||||
|
||||
return { updated, failed: failures.length };
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isSignedIn || !groupId) return;
|
||||
const trimmedName = resolvedName.trim();
|
||||
if (!trimmedName) {
|
||||
setError("Group name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setAssignmentsError(null);
|
||||
setAssignmentsResult(null);
|
||||
|
||||
const payload: BoardGroupUpdate = {
|
||||
name: trimmedName,
|
||||
slug: slugify(trimmedName),
|
||||
description: resolvedDescription.trim() || null,
|
||||
};
|
||||
|
||||
setIsAssignmentsSaving(true);
|
||||
try {
|
||||
const result = await updateMutation.mutateAsync({
|
||||
groupId,
|
||||
data: payload,
|
||||
});
|
||||
if (result.status !== 200) {
|
||||
setError("Something went wrong.");
|
||||
return;
|
||||
}
|
||||
|
||||
const assignments = await handleSaveAssignments();
|
||||
if (!assignments || assignments.failed > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/board-groups/${result.data.id}`);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: null;
|
||||
setError(message || "Something went wrong.");
|
||||
} finally {
|
||||
setIsAssignmentsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = useMemo(
|
||||
() => baseGroup?.name ?? "Edit group",
|
||||
[baseGroup?.name],
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<SignedOut>
|
||||
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
|
||||
<p className="text-sm text-slate-600">
|
||||
Sign in to edit board groups.
|
||||
</p>
|
||||
<SignInButton
|
||||
mode="modal"
|
||||
forceRedirectUrl={`/board-groups/${groupId ?? ""}/edit`}
|
||||
>
|
||||
<Button className="mt-4">Sign in</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
</div>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<DashboardSidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<div className="border-b border-slate-200 bg-white px-8 py-6">
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-semibold text-slate-900 tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Update the shared context that connects boards in this group.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
{assignFailedCount && Number.isFinite(assignFailedCount) ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 shadow-sm">
|
||||
Group was created, but {assignFailedCount} board assignment
|
||||
{assignFailedCount === 1 ? "" : "s"} failed. You can retry
|
||||
below.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Group name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={resolvedName}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Group name"
|
||||
disabled={isLoading || !baseGroup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={resolvedDescription}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="What ties these boards together?"
|
||||
className="min-h-[120px]"
|
||||
disabled={isLoading || !baseGroup}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-100 pt-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">Boards</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Assign boards to this group to share context across
|
||||
related work.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">
|
||||
{selectedBoardIds.size} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
value={boardSearch}
|
||||
onChange={(event) => setBoardSearch(event.target.value)}
|
||||
placeholder="Search boards..."
|
||||
disabled={isLoading || !baseGroup}
|
||||
/>
|
||||
|
||||
<div className="max-h-64 overflow-auto rounded-xl border border-slate-200 bg-slate-50/40">
|
||||
{boardsLoading && boards.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-slate-500">
|
||||
Loading boards…
|
||||
</div>
|
||||
) : boardsError ? (
|
||||
<div className="px-4 py-6 text-sm text-rose-700">
|
||||
{boardsError.message}
|
||||
</div>
|
||||
) : boards.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-slate-500">
|
||||
No boards found.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-200">
|
||||
{boards
|
||||
.filter((board) => {
|
||||
const q = boardSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (
|
||||
board.name.toLowerCase().includes(q) ||
|
||||
board.slug.toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
.map((board) => {
|
||||
const checked = selectedBoardIds.has(board.id);
|
||||
const isInThisGroup =
|
||||
board.board_group_id === groupId;
|
||||
const isAlreadyGrouped =
|
||||
Boolean(board.board_group_id) && !isInThisGroup;
|
||||
return (
|
||||
<li key={board.id} className="px-4 py-3">
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedBoardIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(board.id)) {
|
||||
next.delete(board.id);
|
||||
} else {
|
||||
next.add(board.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
disabled={isLoading || !baseGroup}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
{board.name}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span className="font-mono text-[11px] text-slate-400">
|
||||
{board.id}
|
||||
</span>
|
||||
{isAlreadyGrouped ? (
|
||||
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-900">
|
||||
in another group
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{assignmentsError ? (
|
||||
<p className="text-sm text-rose-700">{assignmentsError}</p>
|
||||
) : null}
|
||||
{assignmentsResult ? (
|
||||
<p className="text-sm text-slate-700">
|
||||
Updated {assignmentsResult.updated} board
|
||||
{assignmentsResult.updated === 1 ? "" : "s"}, failed{" "}
|
||||
{assignmentsResult.failed}.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push(`/board-groups/${groupId ?? ""}`)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !baseGroup || !isFormReady}
|
||||
>
|
||||
{isLoading ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
1160
frontend/src/app/board-groups/[groupId]/page.tsx
Normal file
1160
frontend/src/app/board-groups/[groupId]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
311
frontend/src/app/board-groups/new/page.tsx
Normal file
311
frontend/src/app/board-groups/new/page.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import {
|
||||
type listBoardsApiV1BoardsGetResponse,
|
||||
updateBoardApiV1BoardsBoardIdPatch,
|
||||
useListBoardsApiV1BoardsGet,
|
||||
} from "@/api/generated/boards/boards";
|
||||
import { useCreateBoardGroupApiV1BoardGroupsPost } from "@/api/generated/board-groups/board-groups";
|
||||
import type { BoardRead } from "@/api/generated/model";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "") || "group";
|
||||
|
||||
export default function NewBoardGroupPage() {
|
||||
const router = useRouter();
|
||||
const { isSignedIn } = useAuth();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [boardSearch, setBoardSearch] = useState("");
|
||||
const [selectedBoardIds, setSelectedBoardIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const boardsQuery = useListBoardsApiV1BoardsGet<
|
||||
listBoardsApiV1BoardsGetResponse,
|
||||
ApiError
|
||||
>(
|
||||
{ limit: 200 },
|
||||
{
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const boards: BoardRead[] =
|
||||
boardsQuery.data?.status === 200 ? (boardsQuery.data.data.items ?? []) : [];
|
||||
|
||||
const createMutation = useCreateBoardGroupApiV1BoardGroupsPost<ApiError>({
|
||||
mutation: {
|
||||
onError: (err) => {
|
||||
setError(err.message || "Something went wrong.");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isCreating = createMutation.isPending;
|
||||
const isFormReady = Boolean(name.trim());
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isSignedIn) return;
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setError("Group name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
const created = await createMutation.mutateAsync({
|
||||
data: {
|
||||
name: trimmedName,
|
||||
slug: slugify(trimmedName),
|
||||
description: description.trim() || null,
|
||||
},
|
||||
});
|
||||
if (created.status !== 200) {
|
||||
throw new Error("Unable to create group.");
|
||||
}
|
||||
|
||||
const groupId = created.data.id;
|
||||
const boardIds = Array.from(selectedBoardIds);
|
||||
if (boardIds.length) {
|
||||
const failures: string[] = [];
|
||||
for (const boardId of boardIds) {
|
||||
try {
|
||||
const result = await updateBoardApiV1BoardsBoardIdPatch(boardId, {
|
||||
board_group_id: groupId,
|
||||
});
|
||||
if (result.status !== 200) {
|
||||
failures.push(boardId);
|
||||
}
|
||||
} catch {
|
||||
failures.push(boardId);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
router.push(
|
||||
`/board-groups/${groupId}/edit?assign_failed=${failures.length}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`/board-groups/${groupId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<SignedOut>
|
||||
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
|
||||
<p className="text-sm text-slate-600">
|
||||
Sign in to create a board group.
|
||||
</p>
|
||||
<SignInButton mode="modal" forceRedirectUrl="/board-groups/new">
|
||||
<Button className="mt-4">Sign in</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
</div>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<DashboardSidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<div className="border-b border-slate-200 bg-white px-8 py-6">
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-semibold text-slate-900 tracking-tight">
|
||||
Create board group
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Groups help agents discover related work across boards.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Group name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="e.g. Release hardening"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="What ties these boards together? What should agents coordinate on?"
|
||||
className="min-h-[120px]"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Boards
|
||||
</label>
|
||||
<span className="text-xs text-slate-500">
|
||||
{selectedBoardIds.size} selected
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
value={boardSearch}
|
||||
onChange={(event) => setBoardSearch(event.target.value)}
|
||||
placeholder="Search boards..."
|
||||
disabled={isCreating}
|
||||
/>
|
||||
<div className="max-h-64 overflow-auto rounded-xl border border-slate-200 bg-slate-50/40">
|
||||
{boardsQuery.isLoading ? (
|
||||
<div className="px-4 py-6 text-sm text-slate-500">
|
||||
Loading boards…
|
||||
</div>
|
||||
) : boardsQuery.error ? (
|
||||
<div className="px-4 py-6 text-sm text-rose-700">
|
||||
{boardsQuery.error.message}
|
||||
</div>
|
||||
) : boards.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-slate-500">
|
||||
No boards found.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-200">
|
||||
{boards
|
||||
.filter((board) => {
|
||||
const q = boardSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (
|
||||
board.name.toLowerCase().includes(q) ||
|
||||
board.slug.toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
.map((board) => {
|
||||
const checked = selectedBoardIds.has(board.id);
|
||||
const isAlreadyGrouped = Boolean(
|
||||
board.board_group_id,
|
||||
);
|
||||
return (
|
||||
<li key={board.id} className="px-4 py-3">
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedBoardIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(board.id)) {
|
||||
next.delete(board.id);
|
||||
} else {
|
||||
next.add(board.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
{board.name}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span className="font-mono text-[11px] text-slate-400">
|
||||
{board.id}
|
||||
</span>
|
||||
{isAlreadyGrouped ? (
|
||||
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-900">
|
||||
currently grouped
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Optional. Selected boards will be assigned to this group after
|
||||
creation. You can change membership later in group edit or
|
||||
board settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push("/board-groups")}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating || !isFormReady}>
|
||||
{isCreating ? "Creating…" : "Create group"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-100 pt-4 text-xs text-slate-500">
|
||||
Want to assign boards later? Update each board in{" "}
|
||||
<Link
|
||||
href="/boards"
|
||||
className="font-medium text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Boards
|
||||
</Link>{" "}
|
||||
and pick this group.
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
365
frontend/src/app/board-groups/page.tsx
Normal file
365
frontend/src/app/board-groups/page.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
"use client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import {
|
||||
type listBoardGroupsApiV1BoardGroupsGetResponse,
|
||||
getListBoardGroupsApiV1BoardGroupsGetQueryKey,
|
||||
useDeleteBoardGroupApiV1BoardGroupsGroupIdDelete,
|
||||
useListBoardGroupsApiV1BoardGroupsGet,
|
||||
} from "@/api/generated/board-groups/board-groups";
|
||||
import type { BoardGroupRead } from "@/api/generated/model";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
const formatTimestamp = (value?: string | null) => {
|
||||
if (!value) return "—";
|
||||
const date = new Date(`${value}${value.endsWith("Z") ? "" : "Z"}`);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return date.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
export default function BoardGroupsPage() {
|
||||
const { isSignedIn } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteTarget, setDeleteTarget] = useState<BoardGroupRead | null>(null);
|
||||
|
||||
const groupsKey = getListBoardGroupsApiV1BoardGroupsGetQueryKey();
|
||||
const groupsQuery = useListBoardGroupsApiV1BoardGroupsGet<
|
||||
listBoardGroupsApiV1BoardGroupsGetResponse,
|
||||
ApiError
|
||||
>(undefined, {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: "always",
|
||||
},
|
||||
});
|
||||
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
groupsQuery.data?.status === 200
|
||||
? (groupsQuery.data.data.items ?? [])
|
||||
: [],
|
||||
[groupsQuery.data],
|
||||
);
|
||||
|
||||
const deleteMutation = useDeleteBoardGroupApiV1BoardGroupsGroupIdDelete<
|
||||
ApiError,
|
||||
{ previous?: listBoardGroupsApiV1BoardGroupsGetResponse }
|
||||
>(
|
||||
{
|
||||
mutation: {
|
||||
onMutate: async ({ groupId }) => {
|
||||
await queryClient.cancelQueries({ queryKey: groupsKey });
|
||||
const previous =
|
||||
queryClient.getQueryData<listBoardGroupsApiV1BoardGroupsGetResponse>(
|
||||
groupsKey,
|
||||
);
|
||||
if (previous && previous.status === 200) {
|
||||
const nextItems = previous.data.items.filter(
|
||||
(group) => group.id !== groupId,
|
||||
);
|
||||
const removedCount = previous.data.items.length - nextItems.length;
|
||||
queryClient.setQueryData<listBoardGroupsApiV1BoardGroupsGetResponse>(
|
||||
groupsKey,
|
||||
{
|
||||
...previous,
|
||||
data: {
|
||||
...previous.data,
|
||||
items: nextItems,
|
||||
total: Math.max(0, previous.data.total - removedCount),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _group, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(groupsKey, context.previous);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: groupsKey });
|
||||
},
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteTarget) return;
|
||||
deleteMutation.mutate({ groupId: deleteTarget.id });
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnDef<BoardGroupRead>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Group",
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/board-groups/${row.original.id}`}
|
||||
className="group block"
|
||||
>
|
||||
<p className="text-sm font-medium text-slate-900 group-hover:text-blue-600">
|
||||
{row.original.name}
|
||||
</p>
|
||||
{row.original.description ? (
|
||||
<p className="mt-1 text-xs text-slate-500 line-clamp-2">
|
||||
{row.original.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-slate-400">No description</p>
|
||||
)}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{formatTimestamp(row.original.updated_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/board-groups/${row.original.id}/edit`}
|
||||
className={buttonVariants({ variant: "ghost", size: "sm" })}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(row.original)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: groups,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<SignedOut>
|
||||
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
|
||||
<p className="text-sm text-slate-600">
|
||||
Sign in to view board groups.
|
||||
</p>
|
||||
<SignInButton mode="modal" forceRedirectUrl="/board-groups">
|
||||
<Button className="mt-4">Sign in</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
</div>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<DashboardSidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<div className="sticky top-0 z-30 border-b border-slate-200 bg-white">
|
||||
<div className="px-8 py-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
|
||||
Board groups
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Group boards so agents can see related work. {groups.length}{" "}
|
||||
group{groups.length === 1 ? "" : "s"} total.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/board-groups/new"
|
||||
className={buttonVariants({ size: "md", variant: "primary" })}
|
||||
>
|
||||
Create group
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="px-6 py-3 text-left font-semibold"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{groupsQuery.isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-6 py-8">
|
||||
<span className="text-sm text-slate-500">
|
||||
Loading…
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="transition hover:bg-slate-50"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-6 py-4 align-top">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-6 py-16">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<div className="mb-4 rounded-full bg-slate-50 p-4">
|
||||
<svg
|
||||
className="h-16 w-16 text-slate-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 7h8" />
|
||||
<path d="M3 17h8" />
|
||||
<path d="M13 7h8" />
|
||||
<path d="M13 17h8" />
|
||||
<path d="M3 12h18" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||
No groups yet
|
||||
</h3>
|
||||
<p className="mb-6 max-w-md text-sm text-slate-500">
|
||||
Create a board group to increase cross-board
|
||||
visibility for agents.
|
||||
</p>
|
||||
<Link
|
||||
href="/board-groups/new"
|
||||
className={buttonVariants({
|
||||
size: "md",
|
||||
variant: "primary",
|
||||
})}
|
||||
>
|
||||
Create your first group
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groupsQuery.error ? (
|
||||
<p className="mt-4 text-sm text-red-500">
|
||||
{groupsQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
|
||||
<Dialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent aria-label="Delete board group">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete board group</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will remove {deleteTarget?.name}. Boards will be ungrouped.
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{deleteMutation.error ? (
|
||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
|
||||
{deleteMutation.error.message}
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDelete} disabled={deleteMutation.isPending}>
|
||||
{deleteMutation.isPending ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,19 @@ import {
|
||||
useGetBoardApiV1BoardsBoardIdGet,
|
||||
useUpdateBoardApiV1BoardsBoardIdPatch,
|
||||
} from "@/api/generated/boards/boards";
|
||||
import {
|
||||
type listBoardGroupsApiV1BoardGroupsGetResponse,
|
||||
useListBoardGroupsApiV1BoardGroupsGet,
|
||||
} from "@/api/generated/board-groups/board-groups";
|
||||
import {
|
||||
type listGatewaysApiV1GatewaysGetResponse,
|
||||
useListGatewaysApiV1GatewaysGet,
|
||||
} from "@/api/generated/gateways/gateways";
|
||||
import type { BoardRead, BoardUpdate } from "@/api/generated/model";
|
||||
import type {
|
||||
BoardGroupRead,
|
||||
BoardRead,
|
||||
BoardUpdate,
|
||||
} from "@/api/generated/model";
|
||||
import { BoardOnboardingChat } from "@/components/BoardOnboardingChat";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
@@ -56,6 +64,9 @@ export default function EditBoardPage() {
|
||||
const [board, setBoard] = useState<BoardRead | null>(null);
|
||||
const [name, setName] = useState<string | undefined>(undefined);
|
||||
const [gatewayId, setGatewayId] = useState<string | undefined>(undefined);
|
||||
const [boardGroupId, setBoardGroupId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [boardType, setBoardType] = useState<string | undefined>(undefined);
|
||||
const [objective, setObjective] = useState<string | undefined>(undefined);
|
||||
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
|
||||
@@ -74,7 +85,9 @@ export default function EditBoardPage() {
|
||||
onboardingParam !== "0" &&
|
||||
onboardingParam.toLowerCase() !== "false";
|
||||
|
||||
const [isOnboardingOpen, setIsOnboardingOpen] = useState(shouldAutoOpenOnboarding);
|
||||
const [isOnboardingOpen, setIsOnboardingOpen] = useState(
|
||||
shouldAutoOpenOnboarding,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnboardingOpen) return;
|
||||
@@ -107,7 +120,9 @@ export default function EditBoardPage() {
|
||||
const nextParams = new URLSearchParams(searchParamsString);
|
||||
nextParams.delete("onboarding");
|
||||
const qs = nextParams.toString();
|
||||
router.replace(qs ? `/boards/${boardId}/edit?${qs}` : `/boards/${boardId}/edit`);
|
||||
router.replace(
|
||||
qs ? `/boards/${boardId}/edit?${qs}` : `/boards/${boardId}/edit`,
|
||||
);
|
||||
}, [boardId, router, searchParamsString, shouldAutoOpenOnboarding]);
|
||||
|
||||
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
|
||||
@@ -121,6 +136,17 @@ export default function EditBoardPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const groupsQuery = useListBoardGroupsApiV1BoardGroupsGet<
|
||||
listBoardGroupsApiV1BoardGroupsGetResponse,
|
||||
ApiError
|
||||
>(undefined, {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
|
||||
const boardQuery = useGetBoardApiV1BoardsBoardIdGet<
|
||||
getBoardApiV1BoardsBoardIdGetResponse,
|
||||
ApiError
|
||||
@@ -145,16 +171,18 @@ export default function EditBoardPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const gateways =
|
||||
gatewaysQuery.data?.status === 200
|
||||
? gatewaysQuery.data.data.items ?? []
|
||||
: [];
|
||||
const gateways = useMemo(() => {
|
||||
if (gatewaysQuery.data?.status !== 200) return [];
|
||||
return gatewaysQuery.data.data.items ?? [];
|
||||
}, [gatewaysQuery.data]);
|
||||
const loadedBoard: BoardRead | null =
|
||||
boardQuery.data?.status === 200 ? boardQuery.data.data : null;
|
||||
const baseBoard = board ?? loadedBoard;
|
||||
|
||||
const resolvedName = name ?? baseBoard?.name ?? "";
|
||||
const resolvedGatewayId = gatewayId ?? baseBoard?.gateway_id ?? "";
|
||||
const resolvedBoardGroupId =
|
||||
boardGroupId ?? baseBoard?.board_group_id ?? "none";
|
||||
const resolvedBoardType = boardType ?? baseBoard?.board_type ?? "goal";
|
||||
const resolvedObjective = objective ?? baseBoard?.objective ?? "";
|
||||
const resolvedSuccessMetrics =
|
||||
@@ -168,28 +196,48 @@ export default function EditBoardPage() {
|
||||
const displayGatewayId = resolvedGatewayId || gateways[0]?.id || "";
|
||||
|
||||
const isLoading =
|
||||
gatewaysQuery.isLoading || boardQuery.isLoading || updateBoardMutation.isPending;
|
||||
gatewaysQuery.isLoading ||
|
||||
groupsQuery.isLoading ||
|
||||
boardQuery.isLoading ||
|
||||
updateBoardMutation.isPending;
|
||||
const errorMessage =
|
||||
error ??
|
||||
gatewaysQuery.error?.message ??
|
||||
groupsQuery.error?.message ??
|
||||
boardQuery.error?.message ??
|
||||
null;
|
||||
|
||||
const isFormReady = Boolean(resolvedName.trim() && displayGatewayId);
|
||||
|
||||
const gatewayOptions = useMemo(
|
||||
() => gateways.map((gateway) => ({ value: gateway.id, label: gateway.name })),
|
||||
() =>
|
||||
gateways.map((gateway) => ({ value: gateway.id, label: gateway.name })),
|
||||
[gateways],
|
||||
);
|
||||
|
||||
const groups = useMemo<BoardGroupRead[]>(() => {
|
||||
if (groupsQuery.data?.status !== 200) return [];
|
||||
return groupsQuery.data.data.items ?? [];
|
||||
}, [groupsQuery.data]);
|
||||
const groupOptions = useMemo(
|
||||
() => [
|
||||
{ value: "none", label: "No group" },
|
||||
...groups.map((group) => ({ value: group.id, label: group.name })),
|
||||
],
|
||||
[groups],
|
||||
);
|
||||
|
||||
const handleOnboardingConfirmed = (updated: BoardRead) => {
|
||||
setBoard(updated);
|
||||
setBoardType(updated.board_type ?? "goal");
|
||||
setObjective(updated.objective ?? "");
|
||||
setSuccessMetrics(
|
||||
updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) : "",
|
||||
updated.success_metrics
|
||||
? JSON.stringify(updated.success_metrics, null, 2)
|
||||
: "",
|
||||
);
|
||||
setTargetDate(toLocalDateInput(updated.target_date));
|
||||
setBoardGroupId(updated.board_group_id ?? "none");
|
||||
setIsOnboardingOpen(false);
|
||||
};
|
||||
|
||||
@@ -213,7 +261,10 @@ export default function EditBoardPage() {
|
||||
let parsedMetrics: Record<string, unknown> | null = null;
|
||||
if (resolvedSuccessMetrics.trim()) {
|
||||
try {
|
||||
parsedMetrics = JSON.parse(resolvedSuccessMetrics) as Record<string, unknown>;
|
||||
parsedMetrics = JSON.parse(resolvedSuccessMetrics) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch {
|
||||
setMetricsError("Success metrics must be valid JSON.");
|
||||
return;
|
||||
@@ -224,6 +275,8 @@ export default function EditBoardPage() {
|
||||
name: trimmedName,
|
||||
slug: slugify(trimmedName),
|
||||
gateway_id: resolvedGatewayId || null,
|
||||
board_group_id:
|
||||
resolvedBoardGroupId === "none" ? null : resolvedBoardGroupId,
|
||||
board_type: resolvedBoardType,
|
||||
objective: resolvedObjective.trim() || null,
|
||||
success_metrics: parsedMetrics,
|
||||
@@ -236,182 +289,215 @@ export default function EditBoardPage() {
|
||||
return (
|
||||
<>
|
||||
<DashboardShell>
|
||||
<SignedOut>
|
||||
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
|
||||
<p className="text-sm text-slate-600">Sign in to edit boards.</p>
|
||||
<SignInButton
|
||||
mode="modal"
|
||||
forceRedirectUrl={`/boards/${boardId}/edit`}
|
||||
signUpForceRedirectUrl={`/boards/${boardId}/edit`}
|
||||
>
|
||||
<Button className="mt-4">Sign in</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
</div>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<DashboardSidebar />
|
||||
<main ref={mainRef} className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<div className="border-b border-slate-200 bg-white px-8 py-6">
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-semibold text-slate-900 tracking-tight">
|
||||
Edit board
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Update board settings and gateway.
|
||||
</p>
|
||||
<SignedOut>
|
||||
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
|
||||
<p className="text-sm text-slate-600">Sign in to edit boards.</p>
|
||||
<SignInButton
|
||||
mode="modal"
|
||||
forceRedirectUrl={`/boards/${boardId}/edit`}
|
||||
signUpForceRedirectUrl={`/boards/${boardId}/edit`}
|
||||
>
|
||||
<Button className="mt-4">Sign in</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
</div>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<DashboardSidebar />
|
||||
<main ref={mainRef} className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<div className="border-b border-slate-200 bg-white px-8 py-6">
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-semibold text-slate-900 tracking-tight">
|
||||
Edit board
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Update board settings and gateway.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="space-y-6">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
{resolvedBoardType !== "general" &&
|
||||
baseBoard &&
|
||||
!(baseBoard.goal_confirmed ?? false) ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-amber-900">
|
||||
Goal needs confirmation
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-amber-800/80">
|
||||
Start onboarding to draft an objective and success
|
||||
metrics.
|
||||
<div className="p-8">
|
||||
<div className="space-y-6">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
{resolvedBoardType !== "general" &&
|
||||
baseBoard &&
|
||||
!(baseBoard.goal_confirmed ?? false) ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-amber-900">
|
||||
Goal needs confirmation
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-amber-800/80">
|
||||
Start onboarding to draft an objective and success
|
||||
metrics.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setIsOnboardingOpen(true)}
|
||||
disabled={isLoading || !baseBoard}
|
||||
>
|
||||
Start onboarding
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Board name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={resolvedName}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Board name"
|
||||
disabled={isLoading || !baseBoard}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<SearchableSelect
|
||||
ariaLabel="Select gateway"
|
||||
value={displayGatewayId}
|
||||
onValueChange={setGatewayId}
|
||||
options={gatewayOptions}
|
||||
placeholder="Select gateway"
|
||||
searchPlaceholder="Search gateways..."
|
||||
emptyMessage="No gateways found."
|
||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Board type
|
||||
</label>
|
||||
<Select
|
||||
value={resolvedBoardType}
|
||||
onValueChange={setBoardType}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select board type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="goal">Goal</SelectItem>
|
||||
<SelectItem value="general">General</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Board group
|
||||
</label>
|
||||
<SearchableSelect
|
||||
ariaLabel="Select board group"
|
||||
value={resolvedBoardGroupId}
|
||||
onValueChange={setBoardGroupId}
|
||||
options={groupOptions}
|
||||
placeholder="No group"
|
||||
searchPlaceholder="Search groups..."
|
||||
emptyMessage="No groups found."
|
||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Boards in the same group can share cross-board context
|
||||
for agents.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setIsOnboardingOpen(true)}
|
||||
disabled={isLoading || !baseBoard}
|
||||
>
|
||||
Start onboarding
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Target date
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={resolvedTargetDate}
|
||||
onChange={(event) => setTargetDate(event.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Board name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={resolvedName}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Board name"
|
||||
disabled={isLoading || !baseBoard}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<SearchableSelect
|
||||
ariaLabel="Select gateway"
|
||||
value={displayGatewayId}
|
||||
onValueChange={setGatewayId}
|
||||
options={gatewayOptions}
|
||||
placeholder="Select gateway"
|
||||
searchPlaceholder="Search gateways..."
|
||||
emptyMessage="No gateways found."
|
||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Board type
|
||||
Objective
|
||||
</label>
|
||||
<Select value={resolvedBoardType} onValueChange={setBoardType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select board type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="goal">Goal</SelectItem>
|
||||
<SelectItem value="general">General</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Target date
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={resolvedTargetDate}
|
||||
onChange={(event) => setTargetDate(event.target.value)}
|
||||
<Textarea
|
||||
value={resolvedObjective}
|
||||
onChange={(event) => setObjective(event.target.value)}
|
||||
placeholder="What should this board achieve?"
|
||||
className="min-h-[120px]"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Objective
|
||||
</label>
|
||||
<Textarea
|
||||
value={resolvedObjective}
|
||||
onChange={(event) => setObjective(event.target.value)}
|
||||
placeholder="What should this board achieve?"
|
||||
className="min-h-[120px]"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Success metrics (JSON)
|
||||
</label>
|
||||
<Textarea
|
||||
value={resolvedSuccessMetrics}
|
||||
onChange={(event) => setSuccessMetrics(event.target.value)}
|
||||
placeholder='e.g. { "target": "Launch by week 2" }'
|
||||
className="min-h-[140px] font-mono text-xs"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Add key outcomes so the lead agent can measure progress.
|
||||
</p>
|
||||
{metricsError ? (
|
||||
<p className="text-xs text-red-500">{metricsError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{gateways.length === 0 ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<p>No gateways available. Create one in Gateways to continue.</p>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Success metrics (JSON)
|
||||
</label>
|
||||
<Textarea
|
||||
value={resolvedSuccessMetrics}
|
||||
onChange={(event) =>
|
||||
setSuccessMetrics(event.target.value)
|
||||
}
|
||||
placeholder='e.g. { "target": "Launch by week 2" }'
|
||||
className="min-h-[140px] font-mono text-xs"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Add key outcomes so the lead agent can measure progress.
|
||||
</p>
|
||||
{metricsError ? (
|
||||
<p className="text-xs text-red-500">{metricsError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||
) : null}
|
||||
{gateways.length === 0 ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<p>
|
||||
No gateways available. Create one in Gateways to
|
||||
continue.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push(`/boards/${boardId}`)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || !baseBoard || !isFormReady}>
|
||||
{isLoading ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push(`/boards/${boardId}`)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !baseBoard || !isFormReady}
|
||||
>
|
||||
{isLoading ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
</main>
|
||||
</SignedIn>
|
||||
</DashboardShell>
|
||||
<Dialog open={isOnboardingOpen} onOpenChange={setIsOnboardingOpen}>
|
||||
<DialogContent
|
||||
|
||||
@@ -54,7 +54,10 @@ import {
|
||||
listTaskCommentFeedApiV1ActivityTaskCommentsGet,
|
||||
streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet,
|
||||
} from "@/api/generated/activity/activity";
|
||||
import { getBoardSnapshotApiV1BoardsBoardIdSnapshotGet } from "@/api/generated/boards/boards";
|
||||
import {
|
||||
getBoardGroupSnapshotApiV1BoardsBoardIdGroupSnapshotGet,
|
||||
getBoardSnapshotApiV1BoardsBoardIdSnapshotGet,
|
||||
} from "@/api/generated/boards/boards";
|
||||
import {
|
||||
createBoardMemoryApiV1BoardsBoardIdMemoryPost,
|
||||
streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet,
|
||||
@@ -70,6 +73,7 @@ import {
|
||||
import type {
|
||||
AgentRead,
|
||||
ApprovalRead,
|
||||
BoardGroupSnapshot,
|
||||
BoardMemoryRead,
|
||||
BoardRead,
|
||||
TaskCardRead,
|
||||
@@ -321,6 +325,12 @@ export default function BoardDetailPage() {
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [groupSnapshot, setGroupSnapshot] = useState<BoardGroupSnapshot | null>(
|
||||
null,
|
||||
);
|
||||
const [groupSnapshotError, setGroupSnapshotError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
@@ -379,7 +389,9 @@ export default function BoardDetailPage() {
|
||||
const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false);
|
||||
const isLiveFeedOpenRef = useRef(false);
|
||||
const pushLiveFeed = useCallback((comment: TaskComment) => {
|
||||
const alreadySeen = liveFeedRef.current.some((item) => item.id === comment.id);
|
||||
const alreadySeen = liveFeedRef.current.some(
|
||||
(item) => item.id === comment.id,
|
||||
);
|
||||
setLiveFeed((prev) => {
|
||||
if (prev.some((item) => item.id === comment.id)) {
|
||||
return prev;
|
||||
@@ -588,6 +600,7 @@ export default function BoardDetailPage() {
|
||||
setError(null);
|
||||
setApprovalsError(null);
|
||||
setChatError(null);
|
||||
setGroupSnapshotError(null);
|
||||
try {
|
||||
const snapshotResult =
|
||||
await getBoardSnapshotApiV1BoardsBoardIdSnapshotGet(boardId);
|
||||
@@ -600,12 +613,38 @@ export default function BoardDetailPage() {
|
||||
setAgents((snapshot.agents ?? []).map(normalizeAgent));
|
||||
setApprovals((snapshot.approvals ?? []).map(normalizeApproval));
|
||||
setChatMessages(snapshot.chat_messages ?? []);
|
||||
|
||||
try {
|
||||
const groupResult =
|
||||
await getBoardGroupSnapshotApiV1BoardsBoardIdGroupSnapshotGet(
|
||||
boardId,
|
||||
{
|
||||
include_self: false,
|
||||
include_done: false,
|
||||
per_board_task_limit: 5,
|
||||
},
|
||||
);
|
||||
if (groupResult.status === 200) {
|
||||
setGroupSnapshot(groupResult.data);
|
||||
} else {
|
||||
setGroupSnapshot(null);
|
||||
}
|
||||
} catch (groupErr) {
|
||||
const message =
|
||||
groupErr instanceof Error
|
||||
? groupErr.message
|
||||
: "Unable to load board group snapshot.";
|
||||
setGroupSnapshotError(message);
|
||||
setGroupSnapshot(null);
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Something went wrong.";
|
||||
setError(message);
|
||||
setApprovalsError(message);
|
||||
setChatError(message);
|
||||
setGroupSnapshotError(message);
|
||||
setGroupSnapshot(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsApprovalsLoading(false);
|
||||
@@ -1381,9 +1420,7 @@ export default function BoardDetailPage() {
|
||||
};
|
||||
|
||||
const postBoardChatMessage = useCallback(
|
||||
async (
|
||||
content: string,
|
||||
): Promise<{ ok: boolean; error: string | null }> => {
|
||||
async (content: string): Promise<{ ok: boolean; error: string | null }> => {
|
||||
if (!isSignedIn || !boardId) {
|
||||
return { ok: false, error: "Sign in to send messages." };
|
||||
}
|
||||
@@ -2163,10 +2200,15 @@ export default function BoardDetailPage() {
|
||||
)
|
||||
}
|
||||
disabled={!isSignedIn || !boardId || isAgentsControlSending}
|
||||
className={cn("h-9 w-9 p-0", isAgentsPaused
|
||||
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
|
||||
: "")}
|
||||
aria-label={isAgentsPaused ? "Resume agents" : "Pause agents"}
|
||||
className={cn(
|
||||
"h-9 w-9 p-0",
|
||||
isAgentsPaused
|
||||
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
|
||||
: "",
|
||||
)}
|
||||
aria-label={
|
||||
isAgentsPaused ? "Resume agents" : "Pause agents"
|
||||
}
|
||||
title={isAgentsPaused ? "Resume agents" : "Pause agents"}
|
||||
>
|
||||
{isAgentsPaused ? (
|
||||
@@ -2284,6 +2326,204 @@ export default function BoardDetailPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{viewMode === "list" ? (
|
||||
<>
|
||||
{groupSnapshotError ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 shadow-sm">
|
||||
{groupSnapshotError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groupSnapshot?.group ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="border-b border-slate-200 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Related boards
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-slate-900">
|
||||
{groupSnapshot.group.name}
|
||||
</p>
|
||||
{groupSnapshot.group.description ? (
|
||||
<p className="mt-1 max-w-3xl text-xs text-slate-500 line-clamp-2">
|
||||
{groupSnapshot.group.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/board-groups/${groupSnapshot.group?.id}`,
|
||||
)
|
||||
}
|
||||
disabled={!groupSnapshot.group?.id}
|
||||
>
|
||||
View group
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
router.push(`/boards/${boardId}/edit`)
|
||||
}
|
||||
disabled={!boardId}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
{groupSnapshot.boards &&
|
||||
groupSnapshot.boards.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{groupSnapshot.boards.map((item) => (
|
||||
<div
|
||||
key={item.board.id}
|
||||
className="rounded-xl border border-slate-200 bg-slate-50/40 p-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-start justify-between gap-3 text-left"
|
||||
onClick={() =>
|
||||
router.push(`/boards/${item.board.id}`)
|
||||
}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-slate-900 group-hover:text-blue-600">
|
||||
{item.board.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Updated{" "}
|
||||
{formatTaskTimestamp(
|
||||
item.board.updated_at,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowUpRight className="mt-0.5 h-4 w-4 flex-shrink-0 text-slate-400 group-hover:text-blue-600" />
|
||||
</button>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-slate-700">
|
||||
Inbox {item.task_counts?.inbox ?? 0}
|
||||
</span>
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-slate-700">
|
||||
In progress{" "}
|
||||
{item.task_counts?.in_progress ?? 0}
|
||||
</span>
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-slate-700">
|
||||
Review {item.task_counts?.review ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.tasks && item.tasks.length ? (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{item.tasks.slice(0, 3).map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className="rounded-lg border border-slate-200 bg-white p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
|
||||
statusBadgeClass(
|
||||
task.status,
|
||||
),
|
||||
)}
|
||||
>
|
||||
{task.status.replace(
|
||||
/_/g,
|
||||
" ",
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
|
||||
priorityBadgeClass(
|
||||
task.priority,
|
||||
),
|
||||
)}
|
||||
>
|
||||
{task.priority}
|
||||
</span>
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
{task.title}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatTaskTimestamp(
|
||||
task.updated_at,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-xs text-slate-600">
|
||||
Assignee:{" "}
|
||||
<span className="font-medium text-slate-900">
|
||||
{task.assignee ?? "Unassigned"}
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
{item.tasks.length > 3 ? (
|
||||
<li className="text-xs text-slate-500">
|
||||
+{item.tasks.length - 3} more…
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-slate-500">
|
||||
No tasks in this snapshot.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">
|
||||
No other boards in this group yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : groupSnapshot ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 text-sm text-slate-600 shadow-sm">
|
||||
<p className="font-semibold text-slate-900">
|
||||
No board group configured
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Assign this board to a group to give agents
|
||||
visibility into related work.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
router.push(`/boards/${boardId}/edit`)
|
||||
}
|
||||
disabled={!boardId}
|
||||
>
|
||||
Open settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/board-groups")}
|
||||
>
|
||||
View groups
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{viewMode === "board" ? (
|
||||
<TaskBoard
|
||||
tasks={tasks}
|
||||
@@ -3113,7 +3353,9 @@ export default function BoardDetailPage() {
|
||||
<DialogContent aria-label="Agent controls">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{agentsControlAction === "pause" ? "Pause agents" : "Resume agents"}
|
||||
{agentsControlAction === "pause"
|
||||
? "Pause agents"
|
||||
: "Resume agents"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{agentsControlAction === "pause"
|
||||
@@ -3150,7 +3392,10 @@ export default function BoardDetailPage() {
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirmAgentsControl} disabled={isAgentsControlSending}>
|
||||
<Button
|
||||
onClick={handleConfirmAgentsControl}
|
||||
disabled={isAgentsControlSending}
|
||||
>
|
||||
{isAgentsControlSending
|
||||
? "Sending…"
|
||||
: agentsControlAction === "pause"
|
||||
|
||||
@@ -10,10 +10,15 @@ import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import { useCreateBoardApiV1BoardsPost } from "@/api/generated/boards/boards";
|
||||
import {
|
||||
type listBoardGroupsApiV1BoardGroupsGetResponse,
|
||||
useListBoardGroupsApiV1BoardGroupsGet,
|
||||
} from "@/api/generated/board-groups/board-groups";
|
||||
import {
|
||||
type listGatewaysApiV1GatewaysGetResponse,
|
||||
useListGatewaysApiV1GatewaysGet,
|
||||
} from "@/api/generated/gateways/gateways";
|
||||
import type { BoardGroupRead } from "@/api/generated/model";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -33,6 +38,7 @@ export default function NewBoardPage() {
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [gatewayId, setGatewayId] = useState<string>("");
|
||||
const [boardGroupId, setBoardGroupId] = useState<string>("none");
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -47,6 +53,17 @@ export default function NewBoardPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const groupsQuery = useListBoardGroupsApiV1BoardGroupsGet<
|
||||
listBoardGroupsApiV1BoardGroupsGetResponse,
|
||||
ApiError
|
||||
>(undefined, {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
|
||||
const createBoardMutation = useCreateBoardApiV1BoardsPost<ApiError>({
|
||||
mutation: {
|
||||
onSuccess: (result) => {
|
||||
@@ -60,19 +77,36 @@ export default function NewBoardPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const gateways =
|
||||
gatewaysQuery.data?.status === 200
|
||||
? gatewaysQuery.data.data.items ?? []
|
||||
: [];
|
||||
const gateways = useMemo(() => {
|
||||
if (gatewaysQuery.data?.status !== 200) return [];
|
||||
return gatewaysQuery.data.data.items ?? [];
|
||||
}, [gatewaysQuery.data]);
|
||||
const groups = useMemo<BoardGroupRead[]>(() => {
|
||||
if (groupsQuery.data?.status !== 200) return [];
|
||||
return groupsQuery.data.data.items ?? [];
|
||||
}, [groupsQuery.data]);
|
||||
const displayGatewayId = gatewayId || gateways[0]?.id || "";
|
||||
const isLoading = gatewaysQuery.isLoading || createBoardMutation.isPending;
|
||||
const errorMessage = error ?? gatewaysQuery.error?.message ?? null;
|
||||
const isLoading =
|
||||
gatewaysQuery.isLoading ||
|
||||
groupsQuery.isLoading ||
|
||||
createBoardMutation.isPending;
|
||||
const errorMessage =
|
||||
error ?? gatewaysQuery.error?.message ?? groupsQuery.error?.message ?? null;
|
||||
|
||||
const isFormReady = Boolean(name.trim() && displayGatewayId);
|
||||
|
||||
const gatewayOptions = useMemo(
|
||||
() => gateways.map((gateway) => ({ value: gateway.id, label: gateway.name })),
|
||||
[gateways]
|
||||
() =>
|
||||
gateways.map((gateway) => ({ value: gateway.id, label: gateway.name })),
|
||||
[gateways],
|
||||
);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() => [
|
||||
{ value: "none", label: "No group" },
|
||||
...groups.map((group) => ({ value: group.id, label: group.name })),
|
||||
],
|
||||
[groups],
|
||||
);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -96,6 +130,7 @@ export default function NewBoardPage() {
|
||||
name: trimmedName,
|
||||
slug: slugify(trimmedName),
|
||||
gateway_id: resolvedGatewayId,
|
||||
board_group_id: boardGroupId === "none" ? null : boardGroupId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -167,6 +202,29 @@ export default function NewBoardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Board group
|
||||
</label>
|
||||
<SearchableSelect
|
||||
ariaLabel="Select board group"
|
||||
value={boardGroupId}
|
||||
onValueChange={setBoardGroupId}
|
||||
options={groupOptions}
|
||||
placeholder="No group"
|
||||
searchPlaceholder="Search groups..."
|
||||
emptyMessage="No groups found."
|
||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Optional. Groups increase cross-board visibility.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gateways.length === 0 ? (
|
||||
|
||||
@@ -65,8 +65,10 @@ export default function BoardsPage() {
|
||||
|
||||
const boards = useMemo(
|
||||
() =>
|
||||
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [],
|
||||
[boardsQuery.data]
|
||||
boardsQuery.data?.status === 200
|
||||
? (boardsQuery.data.data.items ?? [])
|
||||
: [],
|
||||
[boardsQuery.data],
|
||||
);
|
||||
|
||||
const deleteMutation = useDeleteBoardApiV1BoardsBoardIdDelete<
|
||||
@@ -78,20 +80,25 @@ export default function BoardsPage() {
|
||||
onMutate: async ({ boardId }) => {
|
||||
await queryClient.cancelQueries({ queryKey: boardsKey });
|
||||
const previous =
|
||||
queryClient.getQueryData<listBoardsApiV1BoardsGetResponse>(boardsKey);
|
||||
queryClient.getQueryData<listBoardsApiV1BoardsGetResponse>(
|
||||
boardsKey,
|
||||
);
|
||||
if (previous && previous.status === 200) {
|
||||
const nextItems = previous.data.items.filter(
|
||||
(board) => board.id !== boardId
|
||||
(board) => board.id !== boardId,
|
||||
);
|
||||
const removedCount = previous.data.items.length - nextItems.length;
|
||||
queryClient.setQueryData<listBoardsApiV1BoardsGetResponse>(boardsKey, {
|
||||
...previous,
|
||||
data: {
|
||||
...previous.data,
|
||||
items: nextItems,
|
||||
total: Math.max(0, previous.data.total - removedCount),
|
||||
queryClient.setQueryData<listBoardsApiV1BoardsGetResponse>(
|
||||
boardsKey,
|
||||
{
|
||||
...previous,
|
||||
data: {
|
||||
...previous.data,
|
||||
items: nextItems,
|
||||
total: Math.max(0, previous.data.total - removedCount),
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
}
|
||||
return { previous };
|
||||
},
|
||||
@@ -108,7 +115,7 @@ export default function BoardsPage() {
|
||||
},
|
||||
},
|
||||
},
|
||||
queryClient
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
@@ -160,7 +167,7 @@ export default function BoardsPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
@@ -204,7 +211,10 @@ export default function BoardsPage() {
|
||||
{boards.length > 0 ? (
|
||||
<Link
|
||||
href="/boards/new"
|
||||
className={buttonVariants({ size: "md", variant: "primary" })}
|
||||
className={buttonVariants({
|
||||
size: "md",
|
||||
variant: "primary",
|
||||
})}
|
||||
>
|
||||
Create board
|
||||
</Link>
|
||||
@@ -229,7 +239,7 @@ export default function BoardsPage() {
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
header.getContext(),
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
@@ -240,7 +250,9 @@ export default function BoardsPage() {
|
||||
{boardsQuery.isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-6 py-8">
|
||||
<span className="text-sm text-slate-500">Loading…</span>
|
||||
<span className="text-sm text-slate-500">
|
||||
Loading…
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : table.getRowModel().rows.length ? (
|
||||
@@ -253,7 +265,7 @@ export default function BoardsPage() {
|
||||
<td key={cell.id} className="px-6 py-4 align-top">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
cell.getContext(),
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
@@ -273,30 +285,10 @@ export default function BoardsPage() {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="3"
|
||||
width="7"
|
||||
height="7"
|
||||
/>
|
||||
<rect
|
||||
x="14"
|
||||
y="3"
|
||||
width="7"
|
||||
height="7"
|
||||
/>
|
||||
<rect
|
||||
x="14"
|
||||
y="14"
|
||||
width="7"
|
||||
height="7"
|
||||
/>
|
||||
<rect
|
||||
x="3"
|
||||
y="14"
|
||||
width="7"
|
||||
height="7"
|
||||
/>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||
@@ -308,7 +300,10 @@ export default function BoardsPage() {
|
||||
</p>
|
||||
<Link
|
||||
href="/boards/new"
|
||||
className={buttonVariants({ size: "md", variant: "primary" })}
|
||||
className={buttonVariants({
|
||||
size: "md",
|
||||
variant: "primary",
|
||||
})}
|
||||
>
|
||||
Create your first board
|
||||
</Link>
|
||||
@@ -342,7 +337,8 @@ export default function BoardsPage() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete board</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will remove {deleteTarget?.name}. This action cannot be undone.
|
||||
This will remove {deleteTarget?.name}. This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{deleteMutation.error ? (
|
||||
|
||||
@@ -58,31 +58,6 @@ type WipRangeSeries = {
|
||||
points: WipPoint[];
|
||||
};
|
||||
|
||||
type SeriesSet = {
|
||||
primary: RangeSeries;
|
||||
comparison: RangeSeries;
|
||||
};
|
||||
|
||||
type WipSeriesSet = {
|
||||
primary: WipRangeSeries;
|
||||
comparison: WipRangeSeries;
|
||||
};
|
||||
|
||||
type DashboardMetrics = {
|
||||
range: RangeKey;
|
||||
generated_at: string;
|
||||
kpis: {
|
||||
active_agents: number;
|
||||
tasks_in_progress: number;
|
||||
error_rate_pct: number;
|
||||
median_cycle_time_hours_7d: number | null;
|
||||
};
|
||||
throughput: SeriesSet;
|
||||
cycle_time: SeriesSet;
|
||||
error_rate: SeriesSet;
|
||||
wip: WipSeriesSet;
|
||||
};
|
||||
|
||||
const hourFormatter = new Intl.DateTimeFormat("en-US", { hour: "numeric" });
|
||||
const dayFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
@@ -96,7 +71,9 @@ const updatedFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
const formatPeriod = (value: string, bucket: BucketKey) => {
|
||||
const date = parseApiDatetime(value);
|
||||
if (!date) return "";
|
||||
return bucket === "hour" ? hourFormatter.format(date) : dayFormatter.format(date);
|
||||
return bucket === "hour"
|
||||
? hourFormatter.format(date)
|
||||
: dayFormatter.format(date);
|
||||
};
|
||||
|
||||
const formatNumber = (value: number) => value.toLocaleString("en-US");
|
||||
@@ -130,14 +107,18 @@ function buildWipSeries(series: WipRangeSeries) {
|
||||
function buildSparkline(series: RangeSeries) {
|
||||
return {
|
||||
values: series.points.map((point) => Number(point.value ?? 0)),
|
||||
labels: series.points.map((point) => formatPeriod(point.period, series.bucket)),
|
||||
labels: series.points.map((point) =>
|
||||
formatPeriod(point.period, series.bucket),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildWipSparkline(series: WipRangeSeries, key: keyof WipPoint) {
|
||||
return {
|
||||
values: series.points.map((point) => Number(point[key] ?? 0)),
|
||||
labels: series.points.map((point) => formatPeriod(point.period, series.bucket)),
|
||||
labels: series.points.map((point) =>
|
||||
formatPeriod(point.period, series.bucket),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,7 +136,10 @@ function TooltipCard({ active, payload, label, formatter }: TooltipProps) {
|
||||
<div className="text-slate-400">{label}</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{payload.map((entry) => (
|
||||
<div key={entry.name} className="flex items-center justify-between gap-3">
|
||||
<div
|
||||
key={entry.name}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
@@ -164,7 +148,9 @@ function TooltipCard({ active, payload, label, formatter }: TooltipProps) {
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="font-semibold text-slate-900">
|
||||
{formatter ? formatter(Number(entry.value ?? 0), entry.name) : entry.value}
|
||||
{formatter
|
||||
? formatter(Number(entry.value ?? 0), entry.name)
|
||||
: entry.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -192,12 +178,12 @@ function KpiCard({
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
{label}
|
||||
</p>
|
||||
<div className="rounded-lg bg-blue-50 p-2 text-blue-600">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="rounded-lg bg-blue-50 p-2 text-blue-600">{icon}</div>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<h3 className="font-heading text-4xl font-bold text-slate-900">{value}</h3>
|
||||
<h3 className="font-heading text-4xl font-bold text-slate-900">
|
||||
{value}
|
||||
</h3>
|
||||
</div>
|
||||
{sublabel ? (
|
||||
<p className="mt-2 text-xs text-slate-500">{sublabel}</p>
|
||||
@@ -304,7 +290,8 @@ export default function DashboardPage() {
|
||||
[metrics],
|
||||
);
|
||||
const wipSpark = useMemo(
|
||||
() => (metrics ? buildWipSparkline(metrics.wip.comparison, "in_progress") : null),
|
||||
() =>
|
||||
metrics ? buildWipSparkline(metrics.wip.comparison, "in_progress") : null,
|
||||
[metrics],
|
||||
);
|
||||
|
||||
@@ -312,10 +299,7 @@ export default function DashboardPage() {
|
||||
() => (metrics ? Math.min(100, metrics.kpis.active_agents * 12.5) : 0),
|
||||
[metrics],
|
||||
);
|
||||
const wipProgress = useMemo(
|
||||
() => calcProgress(wipSpark?.values),
|
||||
[wipSpark],
|
||||
);
|
||||
const wipProgress = useMemo(() => calcProgress(wipSpark?.values), [wipSpark]);
|
||||
const errorProgress = useMemo(
|
||||
() => calcProgress(errorSpark?.values),
|
||||
[errorSpark],
|
||||
@@ -372,7 +356,6 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-8">
|
||||
|
||||
{metricsQuery.error ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-600 shadow-sm">
|
||||
{metricsQuery.error.message}
|
||||
@@ -425,33 +408,10 @@ export default function DashboardPage() {
|
||||
sparkline={throughputSpark ?? undefined}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={throughputSeries} margin={{ left: 4, right: 12 }}>
|
||||
<CartesianGrid vertical={false} stroke="#e2e8f0" />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
width={40}
|
||||
/>
|
||||
<Tooltip content={<TooltipCard formatter={(v) => formatNumber(v)} />} />
|
||||
<Bar dataKey="value" name="Completed" fill="#2563eb" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Avg Hours to Review"
|
||||
subtitle="Cycle time"
|
||||
sparkline={cycleSpark ?? undefined}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={cycleSeries} margin={{ left: 4, right: 12 }}>
|
||||
<BarChart
|
||||
data={throughputSeries}
|
||||
margin={{ left: 4, right: 12 }}
|
||||
>
|
||||
<CartesianGrid vertical={false} stroke="#e2e8f0" />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
@@ -466,7 +426,49 @@ export default function DashboardPage() {
|
||||
width={40}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<TooltipCard formatter={(v) => `${v.toFixed(1)}h`} />}
|
||||
content={
|
||||
<TooltipCard formatter={(v) => formatNumber(v)} />
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
name="Completed"
|
||||
fill="#2563eb"
|
||||
radius={[6, 6, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Avg Hours to Review"
|
||||
subtitle="Cycle time"
|
||||
sparkline={cycleSpark ?? undefined}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={cycleSeries}
|
||||
margin={{ left: 4, right: 12 }}
|
||||
>
|
||||
<CartesianGrid vertical={false} stroke="#e2e8f0" />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
width={40}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
<TooltipCard
|
||||
formatter={(v) => `${v.toFixed(1)}h`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
@@ -486,7 +488,10 @@ export default function DashboardPage() {
|
||||
sparkline={errorSpark ?? undefined}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={errorSeries} margin={{ left: 4, right: 12 }}>
|
||||
<LineChart
|
||||
data={errorSeries}
|
||||
margin={{ left: 4, right: 12 }}
|
||||
>
|
||||
<CartesianGrid vertical={false} stroke="#e2e8f0" />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
@@ -501,7 +506,9 @@ export default function DashboardPage() {
|
||||
width={40}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<TooltipCard formatter={(v) => formatPercent(v)} />}
|
||||
content={
|
||||
<TooltipCard formatter={(v) => formatPercent(v)} />
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
@@ -521,7 +528,10 @@ export default function DashboardPage() {
|
||||
sparkline={wipSpark ?? undefined}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={wipSeries} margin={{ left: 4, right: 12 }}>
|
||||
<AreaChart
|
||||
data={wipSeries}
|
||||
margin={{ left: 4, right: 12 }}
|
||||
>
|
||||
<CartesianGrid vertical={false} stroke="#e2e8f0" />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
@@ -535,7 +545,11 @@ export default function DashboardPage() {
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
width={40}
|
||||
/>
|
||||
<Tooltip content={<TooltipCard formatter={(v) => formatNumber(v)} />} />
|
||||
<Tooltip
|
||||
content={
|
||||
<TooltipCard formatter={(v) => formatNumber(v)} />
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="inbox"
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function EditGatewayPage() {
|
||||
"idle" | "checking" | "success" | "error"
|
||||
>("idle");
|
||||
const [gatewayCheckMessage, setGatewayCheckMessage] = useState<string | null>(
|
||||
null
|
||||
null,
|
||||
);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -156,7 +156,7 @@ export default function EditGatewayPage() {
|
||||
} catch (err) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(
|
||||
err instanceof Error ? err.message : "Unable to reach gateway."
|
||||
err instanceof Error ? err.message : "Unable to reach gateway.",
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -343,7 +343,6 @@ export default function EditGatewayPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
@@ -92,7 +92,9 @@ export default function GatewayDetailPage() {
|
||||
|
||||
const agents = useMemo(
|
||||
() =>
|
||||
agentsQuery.data?.status === 200 ? agentsQuery.data.data.items ?? [] : [],
|
||||
agentsQuery.data?.status === 200
|
||||
? (agentsQuery.data.data.items ?? [])
|
||||
: [],
|
||||
[agentsQuery.data],
|
||||
);
|
||||
|
||||
@@ -102,7 +104,7 @@ export default function GatewayDetailPage() {
|
||||
|
||||
const title = useMemo(
|
||||
() => (gateway?.name ? gateway.name : "Gateway"),
|
||||
[gateway?.name]
|
||||
[gateway?.name],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -111,7 +113,10 @@ export default function GatewayDetailPage() {
|
||||
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
|
||||
<p className="text-sm text-slate-600">Sign in to view a gateway.</p>
|
||||
<SignInButton mode="modal" forceRedirectUrl={`/gateways/${gatewayId}`}>
|
||||
<SignInButton
|
||||
mode="modal"
|
||||
forceRedirectUrl={`/gateways/${gatewayId}`}
|
||||
>
|
||||
<Button className="mt-4">Sign in</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
@@ -138,7 +143,9 @@ export default function GatewayDetailPage() {
|
||||
Back to gateways
|
||||
</Button>
|
||||
{gatewayId ? (
|
||||
<Button onClick={() => router.push(`/gateways/${gatewayId}/edit`)}>
|
||||
<Button
|
||||
onClick={() => router.push(`/gateways/${gatewayId}/edit`)}
|
||||
>
|
||||
Edit gateway
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -184,13 +191,17 @@ export default function GatewayDetailPage() {
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 text-sm text-slate-700">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-400">Gateway URL</p>
|
||||
<p className="text-xs uppercase text-slate-400">
|
||||
Gateway URL
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
{gateway.url}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-400">Token</p>
|
||||
<p className="text-xs uppercase text-slate-400">
|
||||
Token
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
{maskToken(gateway.token)}
|
||||
</p>
|
||||
@@ -212,20 +223,26 @@ export default function GatewayDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-400">Workspace root</p>
|
||||
<p className="text-xs uppercase text-slate-400">
|
||||
Workspace root
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
{gateway.workspace_root}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-400">Created</p>
|
||||
<p className="text-xs uppercase text-slate-400">
|
||||
Created
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
{formatTimestamp(gateway.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-400">Updated</p>
|
||||
<p className="text-xs uppercase text-slate-400">
|
||||
Updated
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
{formatTimestamp(gateway.updated_at)}
|
||||
</p>
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function NewGatewayPage() {
|
||||
const [gatewayUrl, setGatewayUrl] = useState("");
|
||||
const [gatewayToken, setGatewayToken] = useState("");
|
||||
const [mainSessionKey, setMainSessionKey] = useState(
|
||||
DEFAULT_MAIN_SESSION_KEY
|
||||
DEFAULT_MAIN_SESSION_KEY,
|
||||
);
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState(DEFAULT_WORKSPACE_ROOT);
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function NewGatewayPage() {
|
||||
"idle" | "checking" | "success" | "error"
|
||||
>("idle");
|
||||
const [gatewayCheckMessage, setGatewayCheckMessage] = useState<string | null>(
|
||||
null
|
||||
null,
|
||||
);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -121,7 +121,7 @@ export default function NewGatewayPage() {
|
||||
} catch (err) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(
|
||||
err instanceof Error ? err.message : "Unable to reach gateway."
|
||||
err instanceof Error ? err.message : "Unable to reach gateway.",
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -167,7 +167,9 @@ export default function NewGatewayPage() {
|
||||
<SignedOut>
|
||||
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
|
||||
<p className="text-sm text-slate-600">Sign in to create a gateway.</p>
|
||||
<p className="text-sm text-slate-600">
|
||||
Sign in to create a gateway.
|
||||
</p>
|
||||
<SignInButton mode="modal" forceRedirectUrl="/gateways/new">
|
||||
<Button className="mt-4">Sign in</Button>
|
||||
</SignInButton>
|
||||
@@ -302,7 +304,6 @@ export default function NewGatewayPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
|
||||
@@ -78,9 +78,9 @@ export default function GatewaysPage() {
|
||||
const gateways = useMemo(
|
||||
() =>
|
||||
gatewaysQuery.data?.status === 200
|
||||
? gatewaysQuery.data.data.items ?? []
|
||||
? (gatewaysQuery.data.data.items ?? [])
|
||||
: [],
|
||||
[gatewaysQuery.data]
|
||||
[gatewaysQuery.data],
|
||||
);
|
||||
const sortedGateways = useMemo(() => [...gateways], [gateways]);
|
||||
|
||||
@@ -93,20 +93,25 @@ export default function GatewaysPage() {
|
||||
onMutate: async ({ gatewayId }) => {
|
||||
await queryClient.cancelQueries({ queryKey: gatewaysKey });
|
||||
const previous =
|
||||
queryClient.getQueryData<listGatewaysApiV1GatewaysGetResponse>(gatewaysKey);
|
||||
queryClient.getQueryData<listGatewaysApiV1GatewaysGetResponse>(
|
||||
gatewaysKey,
|
||||
);
|
||||
if (previous && previous.status === 200) {
|
||||
const nextItems = previous.data.items.filter(
|
||||
(gateway) => gateway.id !== gatewayId
|
||||
(gateway) => gateway.id !== gatewayId,
|
||||
);
|
||||
const removedCount = previous.data.items.length - nextItems.length;
|
||||
queryClient.setQueryData<listGatewaysApiV1GatewaysGetResponse>(gatewaysKey, {
|
||||
...previous,
|
||||
data: {
|
||||
...previous.data,
|
||||
items: nextItems,
|
||||
total: Math.max(0, previous.data.total - removedCount),
|
||||
queryClient.setQueryData<listGatewaysApiV1GatewaysGetResponse>(
|
||||
gatewaysKey,
|
||||
{
|
||||
...previous,
|
||||
data: {
|
||||
...previous.data,
|
||||
items: nextItems,
|
||||
total: Math.max(0, previous.data.total - removedCount),
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
}
|
||||
return { previous };
|
||||
},
|
||||
@@ -123,7 +128,7 @@ export default function GatewaysPage() {
|
||||
},
|
||||
},
|
||||
},
|
||||
queryClient
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
@@ -137,10 +142,7 @@ export default function GatewaysPage() {
|
||||
accessorKey: "name",
|
||||
header: "Gateway",
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/gateways/${row.original.id}`}
|
||||
className="group block"
|
||||
>
|
||||
<Link href={`/gateways/${row.original.id}`} className="group block">
|
||||
<p className="text-sm font-medium text-slate-900 group-hover:text-blue-600">
|
||||
{row.original.name}
|
||||
</p>
|
||||
@@ -181,25 +183,25 @@ export default function GatewaysPage() {
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link
|
||||
href={`/gateways/${row.original.id}/edit`}
|
||||
className={buttonVariants({ variant: "ghost", size: "sm" })}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(row.original)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link
|
||||
href={`/gateways/${row.original.id}/edit`}
|
||||
className={buttonVariants({ variant: "ghost", size: "sm" })}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(row.original)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
@@ -238,17 +240,20 @@ export default function GatewaysPage() {
|
||||
Manage OpenClaw gateway connections used by boards
|
||||
</p>
|
||||
</div>
|
||||
{gateways.length > 0 ? (
|
||||
<Link
|
||||
href="/gateways/new"
|
||||
className={buttonVariants({ size: "md", variant: "primary" })}
|
||||
>
|
||||
Create gateway
|
||||
</Link>
|
||||
) : null}
|
||||
{gateways.length > 0 ? (
|
||||
<Link
|
||||
href="/gateways/new"
|
||||
className={buttonVariants({
|
||||
size: "md",
|
||||
variant: "primary",
|
||||
})}
|
||||
>
|
||||
Create gateway
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
@@ -263,7 +268,7 @@ export default function GatewaysPage() {
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
header.getContext(),
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
@@ -274,7 +279,9 @@ export default function GatewaysPage() {
|
||||
{gatewaysQuery.isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-6 py-8">
|
||||
<span className="text-sm text-slate-500">Loading…</span>
|
||||
<span className="text-sm text-slate-500">
|
||||
Loading…
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : table.getRowModel().rows.length ? (
|
||||
@@ -284,7 +291,7 @@ export default function GatewaysPage() {
|
||||
<td key={cell.id} className="px-6 py-4">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
cell.getContext(),
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
@@ -324,7 +331,10 @@ export default function GatewaysPage() {
|
||||
</p>
|
||||
<Link
|
||||
href="/gateways/new"
|
||||
className={buttonVariants({ size: "md", variant: "primary" })}
|
||||
className={buttonVariants({
|
||||
size: "md",
|
||||
variant: "primary",
|
||||
})}
|
||||
>
|
||||
Create your first gateway
|
||||
</Link>
|
||||
@@ -342,12 +352,14 @@ export default function GatewaysPage() {
|
||||
{gatewaysQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<Dialog
|
||||
open={Boolean(deleteTarget)}
|
||||
onOpenChange={() => setDeleteTarget(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete gateway?</DialogTitle>
|
||||
|
||||
@@ -20,11 +20,9 @@
|
||||
--warning: #d97706;
|
||||
--danger: #dc2626;
|
||||
--shadow-panel:
|
||||
0 1px 3px rgba(15, 23, 42, 0.12),
|
||||
0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
0 1px 3px rgba(15, 23, 42, 0.12), 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
--shadow-card:
|
||||
0 1px 2px rgba(15, 23, 42, 0.08),
|
||||
0 2px 6px rgba(15, 23, 42, 0.06);
|
||||
0 1px 2px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -148,7 +146,10 @@ body {
|
||||
--warning: #f59e0b;
|
||||
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-body), -apple-system, sans-serif;
|
||||
font-family:
|
||||
var(--font-body),
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background: var(--neutral-100);
|
||||
color: var(--neutral-800);
|
||||
line-height: 1.6;
|
||||
@@ -220,7 +221,11 @@ body {
|
||||
.landing-enterprise .logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(135deg, var(--primary-navy), var(--secondary-navy));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--primary-navy),
|
||||
var(--secondary-navy)
|
||||
);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -332,7 +337,11 @@ body {
|
||||
.landing-enterprise .hero-label {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: linear-gradient(135deg, rgba(10, 22, 40, 0.05), rgba(45, 212, 191, 0.08));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(10, 22, 40, 0.05),
|
||||
rgba(45, 212, 191, 0.08)
|
||||
);
|
||||
border: 1px solid rgba(45, 212, 191, 0.2);
|
||||
border-radius: 50px;
|
||||
font-size: 13px;
|
||||
@@ -455,7 +464,11 @@ body {
|
||||
|
||||
.landing-enterprise .surface-header {
|
||||
padding: 1.5rem 2rem;
|
||||
background: linear-gradient(135deg, var(--primary-navy), var(--secondary-navy));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--primary-navy),
|
||||
var(--secondary-navy)
|
||||
);
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -702,7 +715,11 @@ body {
|
||||
.landing-enterprise .feature-number {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, rgba(10, 22, 40, 0.05), rgba(45, 212, 191, 0.08));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(10, 22, 40, 0.05),
|
||||
rgba(45, 212, 191, 0.08)
|
||||
);
|
||||
border: 1px solid var(--neutral-200);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
@@ -730,7 +747,11 @@ body {
|
||||
|
||||
.landing-enterprise .cta-section {
|
||||
padding: 5rem 2.5rem;
|
||||
background: linear-gradient(135deg, var(--primary-navy), var(--secondary-navy));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--primary-navy),
|
||||
var(--secondary-navy)
|
||||
);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,13 @@ export const dynamic = "force-dynamic";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut, useAuth, useUser } from "@/auth/clerk";
|
||||
import {
|
||||
SignInButton,
|
||||
SignedIn,
|
||||
SignedOut,
|
||||
useAuth,
|
||||
useUser,
|
||||
} from "@/auth/clerk";
|
||||
import { Globe, Info, RotateCcw, Save, User } from "lucide-react";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
@@ -35,15 +41,16 @@ export default function OnboardingPage() {
|
||||
const [timezone, setTimezone] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const meQuery = useGetMeApiV1UsersMeGet<getMeApiV1UsersMeGetResponse, ApiError>(
|
||||
{
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
retry: false,
|
||||
refetchOnMount: "always",
|
||||
},
|
||||
const meQuery = useGetMeApiV1UsersMeGet<
|
||||
getMeApiV1UsersMeGetResponse,
|
||||
ApiError
|
||||
>({
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
retry: false,
|
||||
refetchOnMount: "always",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const updateMeMutation = useUpdateMeApiV1UsersMePatch<ApiError>({
|
||||
mutation: {
|
||||
@@ -63,12 +70,12 @@ export default function OnboardingPage() {
|
||||
|
||||
const clerkFallbackName =
|
||||
user?.fullName ?? user?.firstName ?? user?.username ?? "";
|
||||
const resolvedName =
|
||||
name.trim()
|
||||
? name
|
||||
: profile?.preferred_name ?? profile?.name ?? clerkFallbackName ?? "";
|
||||
const resolvedTimezone =
|
||||
timezone.trim() ? timezone : profile?.timezone ?? "";
|
||||
const resolvedName = name.trim()
|
||||
? name
|
||||
: (profile?.preferred_name ?? profile?.name ?? clerkFallbackName ?? "");
|
||||
const resolvedTimezone = timezone.trim()
|
||||
? timezone
|
||||
: (profile?.timezone ?? "");
|
||||
|
||||
const requiredMissing = useMemo(
|
||||
() => [resolvedName, resolvedTimezone].some((value) => !value.trim()),
|
||||
@@ -77,7 +84,9 @@ export default function OnboardingPage() {
|
||||
|
||||
const timezones = useMemo(() => {
|
||||
if (typeof Intl !== "undefined" && "supportedValuesOf" in Intl) {
|
||||
return (Intl as typeof Intl & { supportedValuesOf: (key: string) => string[] })
|
||||
return (
|
||||
Intl as typeof Intl & { supportedValuesOf: (key: string) => string[] }
|
||||
)
|
||||
.supportedValuesOf("timeZone")
|
||||
.sort();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user