feat: add boards and tasks management endpoints

This commit is contained in:
Abhimanyu Saharan
2026-02-04 02:28:51 +05:30
parent 23faa0865b
commit 1abc8f68f3
170 changed files with 6860 additions and 10706 deletions

View File

@@ -1,4 +1,7 @@
# When accessing the frontend from another device, the browser will call this URL.
# Use your machine IP (not 127.0.0.1).
NEXT_PUBLIC_API_URL=http://<YOUR_MACHINE_IP>:8000
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
CLERK_SECRET_KEY=YOUR_SECRET_KEY
NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/boards
NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/boards
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/boards

48
frontend/.gitignore vendored
View File

@@ -1,41 +1,9 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
node_modules/
.next/
.env.local
.env
out/
dist/
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# clerk configuration (can include secrets)
/.clerk/

6
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,7 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
allowedDevOrigins: [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://192.168.1.101:3000",
],
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,13 @@
"api:gen": "orval --config ./orval.config.ts"
},
"dependencies": {
"@clerk/nextjs": "^6.37.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/react-query": "^5.90.20",
"@tanstack/react-table": "^8.21.3",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 392 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 129 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 386 B

View File

@@ -1,237 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
import { useQuery } from "@tanstack/react-query";
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseQueryOptions,
UseQueryResult,
} from "@tanstack/react-query";
import type {
HTTPValidationError,
ListActivitiesActivitiesGetParams,
} from ".././model";
import { customFetch } from "../../mutator";
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* @summary List Activities
*/
export type listActivitiesActivitiesGetResponse200 = {
data: unknown;
status: 200;
};
export type listActivitiesActivitiesGetResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type listActivitiesActivitiesGetResponseSuccess =
listActivitiesActivitiesGetResponse200 & {
headers: Headers;
};
export type listActivitiesActivitiesGetResponseError =
listActivitiesActivitiesGetResponse422 & {
headers: Headers;
};
export type listActivitiesActivitiesGetResponse =
| listActivitiesActivitiesGetResponseSuccess
| listActivitiesActivitiesGetResponseError;
export const getListActivitiesActivitiesGetUrl = (
params?: ListActivitiesActivitiesGetParams,
) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/activities?${stringifiedParams}`
: `/activities`;
};
export const listActivitiesActivitiesGet = async (
params?: ListActivitiesActivitiesGetParams,
options?: RequestInit,
): Promise<listActivitiesActivitiesGetResponse> => {
return customFetch<listActivitiesActivitiesGetResponse>(
getListActivitiesActivitiesGetUrl(params),
{
...options,
method: "GET",
},
);
};
export const getListActivitiesActivitiesGetQueryKey = (
params?: ListActivitiesActivitiesGetParams,
) => {
return [`/activities`, ...(params ? [params] : [])] as const;
};
export const getListActivitiesActivitiesGetQueryOptions = <
TData = Awaited<ReturnType<typeof listActivitiesActivitiesGet>>,
TError = HTTPValidationError,
>(
params?: ListActivitiesActivitiesGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listActivitiesActivitiesGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListActivitiesActivitiesGetQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listActivitiesActivitiesGet>>
> = ({ signal }) =>
listActivitiesActivitiesGet(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listActivitiesActivitiesGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type ListActivitiesActivitiesGetQueryResult = NonNullable<
Awaited<ReturnType<typeof listActivitiesActivitiesGet>>
>;
export type ListActivitiesActivitiesGetQueryError = HTTPValidationError;
export function useListActivitiesActivitiesGet<
TData = Awaited<ReturnType<typeof listActivitiesActivitiesGet>>,
TError = HTTPValidationError,
>(
params: undefined | ListActivitiesActivitiesGetParams,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listActivitiesActivitiesGet>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listActivitiesActivitiesGet>>,
TError,
Awaited<ReturnType<typeof listActivitiesActivitiesGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useListActivitiesActivitiesGet<
TData = Awaited<ReturnType<typeof listActivitiesActivitiesGet>>,
TError = HTTPValidationError,
>(
params?: ListActivitiesActivitiesGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listActivitiesActivitiesGet>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listActivitiesActivitiesGet>>,
TError,
Awaited<ReturnType<typeof listActivitiesActivitiesGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useListActivitiesActivitiesGet<
TData = Awaited<ReturnType<typeof listActivitiesActivitiesGet>>,
TError = HTTPValidationError,
>(
params?: ListActivitiesActivitiesGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listActivitiesActivitiesGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary List Activities
*/
export function useListActivitiesActivitiesGet<
TData = Awaited<ReturnType<typeof listActivitiesActivitiesGet>>,
TError = HTTPValidationError,
>(
params?: ListActivitiesActivitiesGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listActivitiesActivitiesGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getListActivitiesActivitiesGetQueryOptions(
params,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}

View File

@@ -1,183 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
import { useQuery } from "@tanstack/react-query";
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseQueryOptions,
UseQueryResult,
} from "@tanstack/react-query";
import { customFetch } from "../../mutator";
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* @summary Health
*/
export type healthHealthGetResponse200 = {
data: unknown;
status: 200;
};
export type healthHealthGetResponseSuccess = healthHealthGetResponse200 & {
headers: Headers;
};
export type healthHealthGetResponse = healthHealthGetResponseSuccess;
export const getHealthHealthGetUrl = () => {
return `/health`;
};
export const healthHealthGet = async (
options?: RequestInit,
): Promise<healthHealthGetResponse> => {
return customFetch<healthHealthGetResponse>(getHealthHealthGetUrl(), {
...options,
method: "GET",
});
};
export const getHealthHealthGetQueryKey = () => {
return [`/health`] as const;
};
export const getHealthHealthGetQueryOptions = <
TData = Awaited<ReturnType<typeof healthHealthGet>>,
TError = unknown,
>(options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof healthHealthGet>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getHealthHealthGetQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof healthHealthGet>>> = ({
signal,
}) => healthHealthGet({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof healthHealthGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type HealthHealthGetQueryResult = NonNullable<
Awaited<ReturnType<typeof healthHealthGet>>
>;
export type HealthHealthGetQueryError = unknown;
export function useHealthHealthGet<
TData = Awaited<ReturnType<typeof healthHealthGet>>,
TError = unknown,
>(
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof healthHealthGet>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof healthHealthGet>>,
TError,
Awaited<ReturnType<typeof healthHealthGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useHealthHealthGet<
TData = Awaited<ReturnType<typeof healthHealthGet>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof healthHealthGet>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof healthHealthGet>>,
TError,
Awaited<ReturnType<typeof healthHealthGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useHealthHealthGet<
TData = Awaited<ReturnType<typeof healthHealthGet>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof healthHealthGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Health
*/
export function useHealthHealthGet<
TData = Awaited<ReturnType<typeof healthHealthGet>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof healthHealthGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getHealthHealthGetQueryOptions(options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface AgentOnboarding {
id?: number | null;
agent_name: string;
role_title: string;
prompt: string;
cron_interval_ms?: number | null;
tools_json?: string | null;
owner_hr_id?: number | null;
status?: string;
spawned_agent_id?: string | null;
session_key?: string | null;
notes?: string | null;
created_at?: string;
updated_at?: string;
}

View File

@@ -1,19 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface AgentOnboardingCreate {
agent_name: string;
role_title: string;
prompt: string;
cron_interval_ms?: number | null;
tools_json?: string | null;
owner_hr_id?: number | null;
status?: string;
spawned_agent_id?: string | null;
session_key?: string | null;
notes?: string | null;
}

View File

@@ -1,19 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface AgentOnboardingUpdate {
agent_name?: string | null;
role_title?: string | null;
prompt?: string | null;
cron_interval_ms?: number | null;
tools_json?: string | null;
owner_hr_id?: number | null;
status?: string | null;
spawned_agent_id?: string | null;
session_key?: string | null;
notes?: string | null;
}

View File

@@ -1,12 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface Department {
id?: number | null;
name: string;
head_employee_id?: number | null;
}

View File

@@ -1,11 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface DepartmentCreate {
name: string;
head_employee_id?: number | null;
}

View File

@@ -1,11 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface DepartmentUpdate {
name?: string | null;
head_employee_id?: number | null;
}

View File

@@ -1,19 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface Employee {
id?: number | null;
name: string;
employee_type: string;
department_id?: number | null;
team_id?: number | null;
manager_id?: number | null;
title?: string | null;
status?: string;
openclaw_session_key?: string | null;
notify_enabled?: boolean;
}

View File

@@ -1,18 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface EmployeeCreate {
name: string;
employee_type: string;
department_id?: number | null;
team_id?: number | null;
manager_id?: number | null;
title?: string | null;
status?: string;
openclaw_session_key?: string | null;
notify_enabled?: boolean;
}

View File

@@ -1,18 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface EmployeeUpdate {
name?: string | null;
employee_type?: string | null;
department_id?: number | null;
team_id?: number | null;
manager_id?: number | null;
title?: string | null;
status?: string | null;
openclaw_session_key?: string | null;
notify_enabled?: boolean | null;
}

View File

@@ -1,15 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface EmploymentAction {
id?: number | null;
employee_id: number;
issued_by_employee_id: number;
action_type: string;
notes?: string | null;
created_at?: string;
}

View File

@@ -1,13 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface EmploymentActionCreate {
employee_id: number;
issued_by_employee_id: number;
action_type: string;
notes?: string | null;
}

View File

@@ -1,11 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
import type { ValidationError } from "./validationError";
export interface HTTPValidationError {
detail?: ValidationError[];
}

View File

@@ -1,18 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface HeadcountRequest {
id?: number | null;
department_id: number;
requested_by_manager_id: number;
role_title: string;
employee_type: string;
quantity?: number;
justification?: string | null;
status?: string;
created_at?: string;
}

View File

@@ -1,15 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface HeadcountRequestCreate {
department_id: number;
requested_by_manager_id: number;
role_title: string;
employee_type: string;
quantity?: number;
justification?: string | null;
}

View File

@@ -1,11 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface HeadcountRequestUpdate {
status?: string | null;
justification?: string | null;
}

View File

@@ -1,40 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export * from "./agentOnboarding";
export * from "./agentOnboardingCreate";
export * from "./agentOnboardingUpdate";
export * from "./department";
export * from "./departmentCreate";
export * from "./departmentUpdate";
export * from "./employee";
export * from "./employeeCreate";
export * from "./employeeUpdate";
export * from "./employmentAction";
export * from "./employmentActionCreate";
export * from "./headcountRequest";
export * from "./headcountRequestCreate";
export * from "./headcountRequestUpdate";
export * from "./hTTPValidationError";
export * from "./listActivitiesActivitiesGetParams";
export * from "./listTaskCommentsTaskCommentsGetParams";
export * from "./listTasksTasksGetParams";
export * from "./listTeamsTeamsGetParams";
export * from "./project";
export * from "./projectCreate";
export * from "./projectMember";
export * from "./projectUpdate";
export * from "./task";
export * from "./taskComment";
export * from "./taskCommentCreate";
export * from "./taskCreate";
export * from "./taskReviewDecision";
export * from "./taskUpdate";
export * from "./team";
export * from "./teamCreate";
export * from "./teamUpdate";
export * from "./validationError";

View File

@@ -1,10 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export type ListActivitiesActivitiesGetParams = {
limit?: number;
};

View File

@@ -1,10 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export type ListTaskCommentsTaskCommentsGetParams = {
task_id: number;
};

View File

@@ -1,10 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export type ListTasksTasksGetParams = {
project_id?: number | null;
};

View File

@@ -1,10 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export type ListTeamsTeamsGetParams = {
department_id?: number | null;
};

View File

@@ -1,13 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface Project {
id?: number | null;
name: string;
status?: string;
team_id?: number | null;
}

View File

@@ -1,12 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface ProjectCreate {
name: string;
status?: string;
team_id?: number | null;
}

View File

@@ -1,13 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface ProjectMember {
id?: number | null;
project_id: number;
employee_id: number;
role?: string | null;
}

View File

@@ -1,12 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface ProjectUpdate {
name?: string | null;
status?: string | null;
team_id?: number | null;
}

View File

@@ -1,19 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface Task {
id?: number | null;
project_id: number;
title: string;
description?: string | null;
status?: string;
assignee_employee_id?: number | null;
reviewer_employee_id?: number | null;
created_by_employee_id?: number | null;
created_at?: string;
updated_at?: string;
}

View File

@@ -1,15 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface TaskComment {
id?: number | null;
task_id: number;
author_employee_id?: number | null;
reply_to_comment_id?: number | null;
body: string;
created_at?: string;
}

View File

@@ -1,13 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface TaskCommentCreate {
task_id: number;
author_employee_id?: number | null;
reply_to_comment_id?: number | null;
body: string;
}

View File

@@ -1,16 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface TaskCreate {
project_id: number;
title: string;
description?: string | null;
status?: string;
assignee_employee_id?: number | null;
reviewer_employee_id?: number | null;
created_by_employee_id?: number | null;
}

View File

@@ -1,11 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface TaskReviewDecision {
decision: string;
comment_body: string;
}

View File

@@ -1,14 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface TaskUpdate {
title?: string | null;
description?: string | null;
status?: string | null;
assignee_employee_id?: number | null;
reviewer_employee_id?: number | null;
}

View File

@@ -1,13 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface Team {
id?: number | null;
name: string;
department_id: number;
lead_employee_id?: number | null;
}

View File

@@ -1,12 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface TeamCreate {
name: string;
department_id: number;
lead_employee_id?: number | null;
}

View File

@@ -1,12 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface TeamUpdate {
name?: string | null;
department_id?: number | null;
lead_employee_id?: number | null;
}

View File

@@ -1,12 +0,0 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface ValidationError {
loc: (string | number)[];
msg: string;
type: string;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +1,23 @@
function getActorId(): string | undefined {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("actor_employee_id");
if (stored) return stored;
const env = process.env.NEXT_PUBLIC_ACTOR_EMPLOYEE_ID;
if (env) {
window.localStorage.setItem("actor_employee_id", env);
return env;
}
return undefined;
}
return process.env.NEXT_PUBLIC_ACTOR_EMPLOYEE_ID;
}
/**
* Orval-generated client expects the fetcher to return an object like:
* { data: <json>, status: <number>, headers: Headers }
*/
export async function customFetch<T>(
export const customFetch = async <T>(
url: string,
options: RequestInit,
): Promise<T> {
const base = process.env.NEXT_PUBLIC_API_URL;
if (!base) throw new Error("NEXT_PUBLIC_API_URL is not set");
const res = await fetch(`${base}${url}`, {
options: RequestInit
): Promise<T> => {
const baseUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
const response = await fetch(`${baseUrl}${url}`, {
...options,
headers: {
"Content-Type": "application/json",
...(getActorId() ? { "X-Actor-Employee-Id": String(getActorId()) } : {}),
...(options.headers ?? {}),
},
});
const text = await res.text().catch(() => "");
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
if (!response.ok) {
throw new Error("Request failed");
}
const json = text ? JSON.parse(text) : null;
// Match the types generated by Orval (status + headers + data)
return ({ data: json, status: res.status, headers: res.headers } as unknown) as T;
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
};

View File

@@ -1,24 +0,0 @@
.shell{min-height:100vh;display:grid;grid-template-columns:260px 1fr;background:var(--mc-bg)}
.sidebar{border-right:1px solid var(--mc-border);padding:20px 16px;position:sticky;top:0;height:100vh;display:flex;flex-direction:column;gap:16px;background:linear-gradient(180deg,var(--mc-surface) 0%, color-mix(in oklab,var(--mc-surface), var(--mc-bg) 40%) 100%)}
.brand{display:flex;flex-direction:column;gap:6px}
.brandTitle{font-family:var(--mc-font-serif);font-size:18px;letter-spacing:-0.2px}
.brandSub{font-size:12px;color:var(--mc-muted)}
.nav{display:flex;flex-direction:column;gap:6px}
.nav a{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:12px;color:var(--mc-text);text-decoration:none;border:1px solid transparent}
.nav a:hover{background:color-mix(in oklab,var(--mc-accent), transparent 92%);border-color:color-mix(in oklab,var(--mc-accent), transparent 80%)}
.active{background:color-mix(in oklab,var(--mc-accent), transparent 88%);border-color:color-mix(in oklab,var(--mc-accent), transparent 70%)}
.main{padding:28px 28px 48px}
.topbar{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:18px}
.h1{font-family:var(--mc-font-serif);font-size:30px;line-height:1.1;letter-spacing:-0.6px;margin:0}
.p{margin:8px 0 0;color:var(--mc-muted);max-width:72ch}
.btn{border:1px solid var(--mc-border);background:var(--mc-surface);padding:10px 12px;border-radius:12px;cursor:pointer}
.btnPrimary{border-color:color-mix(in oklab,var(--mc-accent), black 10%);background:var(--mc-accent);color:white}
.grid2{display:grid;grid-template-columns:1.4fr 1fr;gap:16px}
.card{background:var(--mc-surface);border:1px solid var(--mc-border);border-radius:16px;padding:14px}
.cardTitle{margin:0 0 10px;font-size:13px;color:var(--mc-muted);letter-spacing:0.06em;text-transform:uppercase}
.list{display:flex;flex-direction:column;gap:10px}
.item{border:1px solid var(--mc-border);border-radius:14px;padding:12px;background:color-mix(in oklab,var(--mc-surface), white 20%)}
.mono{font-family:var(--mc-font-mono);font-size:12px;color:var(--mc-muted)}
.badge{display:inline-flex;align-items:center;padding:4px 8px;border-radius:999px;font-size:12px;border:1px solid var(--mc-border);background:color-mix(in oklab,var(--mc-bg), var(--mc-surface) 40%)}
.kbd{font-family:var(--mc-font-mono);font-size:12px;background:color-mix(in oklab,var(--mc-bg), var(--mc-surface) 40%);border:1px solid var(--mc-border);border-bottom-width:2px;padding:2px 6px;border-radius:8px}
@media (max-width: 980px){.shell{grid-template-columns:1fr}.sidebar{position:relative;height:auto}.grid2{grid-template-columns:1fr}.main{padding:18px}}

View File

@@ -1,72 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import styles from "./Shell.module.css";
const NAV = [
{ href: "/", label: "Mission Control" },
{ href: "/projects", label: "Projects" },
{ href: "/kanban", label: "Kanban" },
{ href: "/departments", label: "Departments" },
{ href: "/teams", label: "Teams" },
{ href: "/people", label: "People" },
];
export function Shell({ children }: { children: React.ReactNode }) {
const path = usePathname();
const [actorId, setActorId] = useState(() => {
if (typeof window === "undefined") return "";
try {
return window.localStorage.getItem("actor_employee_id") ?? "";
} catch {
return "";
}
});
return (
<div className={styles.shell}>
<aside className={styles.sidebar}>
<div className={styles.brand}>
<div className={styles.brandTitle}>OpenClaw Agency</div>
<div className={styles.brandSub}>Company Mission Control (no-auth v1)</div>
</div>
<nav className={styles.nav}>
{NAV.map((n) => (
<Link
key={n.href}
href={n.href}
className={path === n.href ? styles.active : undefined}
>
{n.label}
</Link>
))}
</nav>
<div className={styles.mono} style={{ marginTop: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>Actor ID</div>
<input
value={actorId}
onChange={(e) => {
const v = e.target.value;
setActorId(v);
try {
if (v) window.localStorage.setItem("actor_employee_id", v);
else window.localStorage.removeItem("actor_employee_id");
} catch {
// ignore
}
}}
placeholder="e.g. 1"
style={{ width: "100%", padding: "6px 8px", borderRadius: 6, border: "1px solid #333", background: "transparent" }}
/>
</div>
<div className={styles.mono} style={{ marginTop: "auto" }}>
Tip: use your machine IP + ports<br />
<span className={styles.kbd}>:3000</span> UI &nbsp; <span className={styles.kbd}>:8000</span> API
</div>
</aside>
<div className={styles.main}>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,627 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { StatusPill } from "@/components/atoms/StatusPill";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type Agent = {
id: string;
name: string;
status: string;
last_seen_at: string;
};
type ActivityEvent = {
id: string;
event_type: string;
message?: string | null;
created_at: string;
};
type GatewayStatus = {
connected: boolean;
gateway_url: string;
sessions_count?: number;
sessions?: Record<string, unknown>[];
error?: string;
};
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const statusOptions = [
{ value: "online", label: "Online" },
{ value: "busy", label: "Busy" },
{ value: "offline", label: "Offline" },
];
const formatTimestamp = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatRelative = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
const diff = Date.now() - date.getTime();
const minutes = Math.round(diff / 60000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
};
const getSessionKey = (
session: Record<string, unknown>,
index: number
) => {
const key = session.key;
if (typeof key === "string" && key.length > 0) {
return key;
}
const sessionId = session.sessionId;
if (typeof sessionId === "string" && sessionId.length > 0) {
return sessionId;
}
return `session-${index}`;
};
export default function AgentsPage() {
const { getToken, isSignedIn } = useAuth();
const router = useRouter();
const [agents, setAgents] = useState<Agent[]>([]);
const [events, setEvents] = useState<ActivityEvent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [gatewayStatus, setGatewayStatus] = useState<GatewayStatus | null>(null);
const [gatewaySessions, setGatewaySessions] = useState<
Record<string, unknown>[]
>([]);
const [gatewayError, setGatewayError] = useState<string | null>(null);
const [selectedSession, setSelectedSession] = useState<
Record<string, unknown> | null
>(null);
const [sessionHistory, setSessionHistory] = useState<unknown[]>([]);
const [message, setMessage] = useState("");
const [isSending, setIsSending] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [name, setName] = useState("");
const [status, setStatus] = useState("online");
const [createError, setCreateError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const sortedAgents = useMemo(
() => [...agents].sort((a, b) => a.name.localeCompare(b.name)),
[agents],
);
const loadData = async () => {
if (!isSignedIn) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const [agentsResponse, activityResponse] = await Promise.all([
fetch(`${apiBase}/api/v1/agents`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
}),
fetch(`${apiBase}/api/v1/activity`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
}),
]);
if (!agentsResponse.ok || !activityResponse.ok) {
throw new Error("Unable to load operational data.");
}
const agentsData = (await agentsResponse.json()) as Agent[];
const eventsData = (await activityResponse.json()) as ActivityEvent[];
setAgents(agentsData);
setEvents(eventsData);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
const loadGateway = async () => {
if (!isSignedIn) return;
setGatewayError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/gateway/status`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
if (!response.ok) {
throw new Error("Unable to load gateway status.");
}
const statusData = (await response.json()) as GatewayStatus;
setGatewayStatus(statusData);
setGatewaySessions(statusData.sessions ?? []);
} catch (err) {
setGatewayError(err instanceof Error ? err.message : "Something went wrong.");
}
};
const loadSessionHistory = async (sessionId: string) => {
if (!isSignedIn) return;
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/gateway/sessions/${sessionId}/history`,
{
headers: { Authorization: token ? `Bearer ${token}` : "" },
}
);
if (!response.ok) {
throw new Error("Unable to load session history.");
}
const data = (await response.json()) as { history?: unknown[] };
setSessionHistory(data.history ?? []);
} catch (err) {
setGatewayError(err instanceof Error ? err.message : "Something went wrong.");
}
};
useEffect(() => {
loadData();
loadGateway();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);
const resetForm = () => {
setName("");
setStatus("online");
setCreateError(null);
};
const handleCreate = async () => {
if (!isSignedIn) return;
const trimmed = name.trim();
if (!trimmed) {
setCreateError("Agent name is required.");
return;
}
setIsCreating(true);
setCreateError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/agents`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ name: trimmed, status }),
});
if (!response.ok) {
throw new Error("Unable to create agent.");
}
const created = (await response.json()) as Agent;
setAgents((prev) => [created, ...prev]);
setIsDialogOpen(false);
resetForm();
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsCreating(false);
}
};
const handleSendMessage = async () => {
if (!isSignedIn || !selectedSession) return;
const content = message.trim();
if (!content) return;
setIsSending(true);
setGatewayError(null);
try {
const token = await getToken();
const sessionId = selectedSession.key as string | undefined;
if (!sessionId) {
throw new Error("Missing session id.");
}
const response = await fetch(
`${apiBase}/api/v1/gateway/sessions/${sessionId}/message`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ content }),
}
);
if (!response.ok) {
throw new Error("Unable to send message.");
}
setMessage("");
loadSessionHistory(sessionId);
} catch (err) {
setGatewayError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsSending(false);
}
};
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-xl border-2 border-gray-200 bg-white p-10 text-center shadow-lush">
<p className="text-sm text-gray-600">
Sign in to view operational status.
</p>
<SignInButton
mode="modal"
afterSignInUrl="/agents"
afterSignUpUrl="/agents"
forceRedirectUrl="/agents"
signUpForceRedirectUrl="/agents"
>
<Button className="border-2 border-gray-900 bg-gray-900 text-white">
Sign in
</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col gap-6 rounded-xl border-2 border-gray-200 bg-white p-8 shadow-lush">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-500">
Operations
</p>
<h1 className="text-2xl font-semibold text-gray-900">
Agents
</h1>
<p className="text-sm text-gray-600">
Live status and heartbeat activity across all agents.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="border-2 border-gray-200 text-gray-700"
onClick={() => loadData()}
disabled={isLoading}
>
Refresh
</Button>
<Button
className="border-2 border-gray-900 bg-gray-900 text-white"
onClick={() => setIsDialogOpen(true)}
>
New agent
</Button>
</div>
</div>
{error ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
{error}
</div>
) : null}
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<div className="overflow-hidden rounded-xl border border-gray-200">
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
Agents
</p>
<p className="text-xs text-gray-500">
{sortedAgents.length} total
</p>
</div>
<div className="divide-y divide-gray-200 text-sm">
{sortedAgents.length === 0 && !isLoading ? (
<div className="p-6 text-sm text-gray-500">
No agents yet. Add one or wait for a heartbeat.
</div>
) : (
sortedAgents.map((agent) => (
<div
key={agent.id}
className="flex flex-wrap items-center justify-between gap-3 px-4 py-3"
>
<div>
<p className="font-medium text-gray-900">
{agent.name}
</p>
<p className="text-xs text-gray-500">
Last seen {formatRelative(agent.last_seen_at)}
</p>
</div>
<div className="flex items-center gap-3">
<StatusPill status={agent.status} />
<Button
variant="outline"
className="border-2 border-gray-200 text-xs text-gray-700"
onClick={() => router.push(`/boards`)}
>
View work
</Button>
</div>
</div>
))
)}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-5">
<Tabs defaultValue="activity">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="gateway">Gateway</TabsTrigger>
</TabsList>
</div>
<TabsContent value="activity">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
Activity feed
</p>
<p className="text-xs text-gray-500">
{events.length} events
</p>
</div>
<div className="space-y-3">
{events.length === 0 && !isLoading ? (
<div className="rounded-lg border border-dashed border-gray-200 bg-white p-4 text-sm text-gray-500">
No activity yet.
</div>
) : (
events.map((event) => (
<div
key={event.id}
className="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-700"
>
<p className="font-medium text-gray-900">
{event.message ?? event.event_type}
</p>
<p className="mt-1 text-xs text-gray-500">
{formatTimestamp(event.created_at)}
</p>
</div>
))
)}
</div>
</TabsContent>
<TabsContent value="gateway">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
OpenClaw Gateway
</p>
<Button
variant="outline"
className="border-2 border-gray-200 text-xs text-gray-700"
onClick={() => loadGateway()}
>
Refresh
</Button>
</div>
<div className="space-y-4">
<div className="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-700">
<div className="flex items-center justify-between">
<p className="font-medium text-gray-900">
{gatewayStatus?.connected ? "Connected" : "Not connected"}
</p>
<StatusPill
status={gatewayStatus?.connected ? "online" : "offline"}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
{gatewayStatus?.gateway_url ?? "Gateway URL not set"}
</p>
{gatewayStatus?.error ? (
<p className="mt-2 text-xs text-red-500">
{gatewayStatus.error}
</p>
) : null}
</div>
<div className="rounded-lg border border-gray-200 bg-white">
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
<span>Sessions</span>
<span>{gatewaySessions.length}</span>
</div>
<div className="max-h-56 divide-y divide-gray-200 overflow-y-auto text-sm">
{gatewaySessions.length === 0 ? (
<div className="p-4 text-sm text-gray-500">
No sessions found.
</div>
) : (
gatewaySessions.map((session, index) => {
const sessionId = session.key as string | undefined;
const display =
(session.displayName as string | undefined) ??
(session.label as string | undefined) ??
sessionId ??
"Session";
return (
<button
key={getSessionKey(session, index)}
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-gray-50"
onClick={() => {
setSelectedSession(session);
if (sessionId) {
loadSessionHistory(sessionId);
}
}}
>
<div>
<p className="font-medium text-gray-900">{display}</p>
<p className="text-xs text-gray-500">
{session.status ?? "active"}
</p>
</div>
<span className="text-xs text-gray-400">Open</span>
</button>
);
})
)}
</div>
</div>
{selectedSession ? (
<div className="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-700">
<div className="mb-3 space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
Session details
</p>
<p className="font-medium text-gray-900">
{selectedSession.displayName ??
selectedSession.label ??
selectedSession.key ??
"Session"}
</p>
</div>
<div className="mb-4 max-h-40 space-y-2 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
{sessionHistory.length === 0 ? (
<p>No history loaded.</p>
) : (
sessionHistory.map((item, index) => (
<pre key={index} className="whitespace-pre-wrap">
{JSON.stringify(item, null, 2)}
</pre>
))
)}
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
Send message
</label>
<Input
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Type a message to the session"
className="h-10 rounded-lg border-2 border-gray-200 bg-white"
/>
<Button
className="w-full border-2 border-gray-900 bg-gray-900 text-white"
onClick={handleSendMessage}
disabled={isSending}
>
{isSending ? "Sending…" : "Send to session"}
</Button>
</div>
</div>
) : null}
{gatewayError ? (
<div className="rounded-lg border border-gray-200 bg-white p-3 text-xs text-red-500">
{gatewayError}
</div>
) : null}
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</SignedIn>
<Dialog
open={isDialogOpen}
onOpenChange={(nextOpen) => {
setIsDialogOpen(nextOpen);
if (!nextOpen) {
resetForm();
}
}}
>
<DialogContent aria-label="New agent">
<DialogHeader>
<DialogTitle>New agent</DialogTitle>
<DialogDescription>
Add a manual agent entry for tracking and monitoring.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-800">
Agent name
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Deployment bot"
className="h-11 rounded-lg border-2 border-gray-200 bg-white"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-800">
Status
</label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{createError ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
{createError}
</div>
) : null}
</div>
<DialogFooter>
<Button
variant="outline"
className="border-2 border-gray-200 text-gray-700"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button
className="border-2 border-gray-900 bg-gray-900 text-white"
onClick={handleCreate}
disabled={isCreating}
>
{isCreating ? "Creating…" : "Create agent"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardShell>
);
}

View File

@@ -0,0 +1,310 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { TaskBoard } from "@/components/organisms/TaskBoard";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
type Board = {
id: string;
name: string;
slug: string;
};
type Task = {
id: string;
title: string;
description?: string | null;
status: string;
priority: string;
due_at?: string | null;
};
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const priorities = [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
];
export default function BoardDetailPage() {
const router = useRouter();
const params = useParams();
const boardIdParam = params?.boardId;
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
const { getToken, isSignedIn } = useAuth();
const [board, setBoard] = useState<Board | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState("medium");
const [createError, setCreateError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const titleLabel = useMemo(
() => (board ? `${board.name} board` : "Board"),
[board],
);
const loadBoard = async () => {
if (!isSignedIn || !boardId) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const [boardResponse, tasksResponse] = await Promise.all([
fetch(`${apiBase}/api/v1/boards/${boardId}`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
}),
fetch(`${apiBase}/api/v1/boards/${boardId}/tasks`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
}),
]);
if (!boardResponse.ok) {
throw new Error("Unable to load board.");
}
if (!tasksResponse.ok) {
throw new Error("Unable to load tasks.");
}
const boardData = (await boardResponse.json()) as Board;
const taskData = (await tasksResponse.json()) as Task[];
setBoard(boardData);
setTasks(taskData);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadBoard();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [boardId, isSignedIn]);
const resetForm = () => {
setTitle("");
setDescription("");
setPriority("medium");
setCreateError(null);
};
const handleCreateTask = async () => {
if (!isSignedIn || !boardId) return;
const trimmed = title.trim();
if (!trimmed) {
setCreateError("Add a task title to continue.");
return;
}
setIsCreating(true);
setCreateError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}/tasks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({
title: trimmed,
description: description.trim() || null,
status: "inbox",
priority,
}),
});
if (!response.ok) {
throw new Error("Unable to create task.");
}
const created = (await response.json()) as Task;
setTasks((prev) => [created, ...prev]);
setIsDialogOpen(false);
resetForm();
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsCreating(false);
}
};
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-xl border-2 border-gray-200 bg-white p-10 text-center shadow-lush">
<p className="text-sm text-gray-600">Sign in to view boards.</p>
<SignInButton
mode="modal"
afterSignInUrl="/boards"
afterSignUpUrl="/boards"
forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards"
>
<Button className="border-2 border-gray-900 bg-gray-900 text-white">
Sign in
</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col gap-6 rounded-xl border-2 border-gray-200 bg-white p-8 shadow-lush">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-500">
{board?.slug ?? "board"}
</p>
<h1 className="text-2xl font-semibold text-gray-900">
{board?.name ?? "Board"}
</h1>
<p className="text-sm text-gray-600">
Keep tasks moving through your workflow.
</p>
</div>
<Button
variant="outline"
className="border-2 border-gray-200 text-gray-700"
onClick={() => router.push("/boards")}
>
Back to boards
</Button>
</div>
{error && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
{error}
</div>
)}
{isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-gray-500">
Loading {titleLabel}
</div>
) : (
<TaskBoard
tasks={tasks}
onCreateTask={() => setIsDialogOpen(true)}
isCreateDisabled={isCreating}
/>
)}
</div>
</SignedIn>
<Dialog
open={isDialogOpen}
onOpenChange={(nextOpen) => {
setIsDialogOpen(nextOpen);
if (!nextOpen) {
resetForm();
}
}}
>
<DialogContent aria-label={titleLabel}>
<DialogHeader>
<DialogTitle>New task</DialogTitle>
<DialogDescription>
Add a task to the inbox and triage it when you are ready.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-800">Title</label>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="e.g. Prepare launch notes"
className="h-11 rounded-lg border-2 border-gray-200 bg-white"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-800">
Description
</label>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Optional details"
className="min-h-[120px] rounded-lg border-2 border-gray-200 bg-white"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-800">
Priority
</label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
{priorities.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{createError ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
{createError}
</div>
) : null}
</div>
<DialogFooter>
<Button
variant="outline"
className="border-2 border-gray-200 text-gray-700"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button
className="border-2 border-gray-900 bg-gray-900 text-white"
onClick={handleCreateTask}
disabled={isCreating}
>
{isCreating ? "Creating…" : "Create task"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardShell>
);
}

View File

@@ -0,0 +1,135 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type Board = {
id: string;
name: string;
slug: string;
};
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const slugify = (value: string) =>
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "board";
export default function NewBoardPage() {
const router = useRouter();
const { getToken, isSignedIn } = useAuth();
const [name, setName] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn) return;
const trimmed = name.trim();
if (!trimmed) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/boards`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ name: trimmed, slug: slugify(trimmed) }),
});
if (!response.ok) {
throw new Error("Unable to create board.");
}
const created = (await response.json()) as Board;
router.push(`/boards/${created.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-xl border-2 border-gray-200 bg-white p-10 text-center shadow-lush lg:col-span-2">
<p className="text-sm text-gray-600">Sign in to create a board.</p>
<SignInButton
mode="modal"
afterSignInUrl="/boards/new"
afterSignUpUrl="/boards/new"
forceRedirectUrl="/boards/new"
signUpForceRedirectUrl="/boards/new"
>
<Button className="border-2 border-gray-900 bg-gray-900 text-white">
Sign in
</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col justify-center rounded-xl border-2 border-gray-200 bg-white p-8 shadow-lush">
<div className="mb-6 space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-500">
New board
</p>
<h1 className="text-2xl font-semibold text-gray-900">
Spin up a board.
</h1>
<p className="text-sm text-gray-600">
Boards are where tasks live and move through your workflow.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-800">
Board name
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Product ops"
className="h-11 rounded-lg border-2 border-gray-200 bg-white"
disabled={isLoading}
/>
</div>
{error ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
{error}
</div>
) : null}
<Button
type="submit"
className="w-full border-2 border-gray-900 bg-gray-900 text-white"
disabled={isLoading}
>
{isLoading ? "Creating…" : "Create board"}
</Button>
</form>
<Button
variant="outline"
className="mt-4 border-2 border-gray-200 text-gray-700"
onClick={() => router.push("/boards")}
>
Back to boards
</Button>
</div>
</SignedIn>
</DashboardShell>
);
}

View File

@@ -0,0 +1,202 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
type Board = {
id: string;
name: string;
slug: string;
};
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
export default function BoardsPage() {
const { getToken, isSignedIn } = useAuth();
const router = useRouter();
const [boards, setBoards] = useState<Board[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const sortedBoards = useMemo(
() => [...boards].sort((a, b) => a.name.localeCompare(b.name)),
[boards]
);
const loadBoards = async () => {
if (!isSignedIn) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/boards`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
});
if (!response.ok) {
throw new Error("Unable to load boards.");
}
const data = (await response.json()) as Board[];
setBoards(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadBoards();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);
const columns = useMemo<ColumnDef<Board>[]>(
() => [
{
accessorKey: "name",
header: "Board",
cell: ({ row }) => (
<div>
<p className="font-medium text-gray-900">{row.original.name}</p>
<p className="text-xs text-gray-500">{row.original.slug}</p>
</div>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div
className="flex items-center justify-end"
onClick={(event) => event.stopPropagation()}
>
<Link
href={`/boards/${row.original.id}`}
className="inline-flex h-8 items-center justify-center rounded-lg border-2 border-gray-200 px-3 text-xs font-medium text-gray-700"
>
Open
</Link>
</div>
),
},
],
[]
);
const table = useReactTable({
data: sortedBoards,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-xl border-2 border-gray-200 bg-white p-10 text-center shadow-lush lg:col-span-2">
<p className="text-sm text-gray-600">Sign in to view boards.</p>
<SignInButton
mode="modal"
afterSignInUrl="/boards"
afterSignUpUrl="/boards"
forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards"
>
<Button className="border-2 border-gray-900 bg-gray-900 text-white">
Sign in
</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col gap-4 rounded-xl border-2 border-gray-200 bg-white p-8 shadow-lush">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-gray-900">Boards</h2>
<p className="text-sm text-gray-500">
{sortedBoards.length} board
{sortedBoards.length === 1 ? "" : "s"} total.
</p>
</div>
<Button
className="border-2 border-gray-900 bg-gray-900 text-white"
onClick={() => router.push("/boards/new")}
>
New board
</Button>
</div>
{error && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
{error}
</div>
)}
{sortedBoards.length === 0 && !isLoading ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-500">
No boards yet. Create your first board to get started.
</div>
) : (
<div className="overflow-hidden rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.2em] text-gray-500"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="cursor-pointer hover:bg-gray-50"
onClick={() => router.push(`/boards/${row.original.id}`)}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3 align-top">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</SignedIn>
</DashboardShell>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
export default function DashboardPage() {
const router = useRouter();
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-xl border-2 border-gray-200 bg-white p-10 text-center shadow-lush">
<p className="text-sm text-gray-600">
Sign in to access your dashboard.
</p>
<SignInButton
mode="modal"
afterSignInUrl="/boards"
afterSignUpUrl="/boards"
forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards"
>
<Button className="border-2 border-gray-900 bg-gray-900 text-white">
Sign in
</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-xl border-2 border-gray-200 bg-white p-10 text-center shadow-lush">
<p className="text-sm text-gray-600">
Your work lives in boards. Jump in to manage tasks.
</p>
<Button
className="border-2 border-gray-900 bg-gray-900 text-white"
onClick={() => router.push("/boards")}
>
Go to boards
</Button>
</div>
</SignedIn>
</DashboardShell>
);
}

View File

@@ -1,143 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import {
useCreateDepartmentDepartmentsPost,
useListDepartmentsDepartmentsGet,
useUpdateDepartmentDepartmentsDepartmentIdPatch,
} from "@/api/generated/org/org";
import { useListEmployeesEmployeesGet } from "@/api/generated/org/org";
export default function DepartmentsPage() {
const [name, setName] = useState("");
const [headId, setHeadId] = useState<string>("");
const departments = useListDepartmentsDepartmentsGet();
const departmentList = departments.data?.status === 200 ? departments.data.data : [];
const employees = useListEmployeesEmployeesGet();
const employeeList = employees.data?.status === 200 ? employees.data.data : [];
const createDepartment = useCreateDepartmentDepartmentsPost({
mutation: {
onSuccess: () => {
setName("");
setHeadId("");
departments.refetch();
},
},
});
const updateDepartment = useUpdateDepartmentDepartmentsDepartmentIdPatch({
mutation: {
onSuccess: () => departments.refetch(),
},
});
const sortedEmployees = employeeList.slice().sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
return (
<main className="mx-auto max-w-5xl p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Departments</h1>
<p className="mt-1 text-sm text-muted-foreground">Create departments and assign department heads.</p>
</div>
<Button variant="outline" onClick={() => departments.refetch()} disabled={departments.isFetching}>
Refresh
</Button>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Create department</CardTitle>
<CardDescription>Optional head</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{employees.isLoading ? <div className="text-sm text-muted-foreground">Loading employees</div> : null}
{employees.error ? <div className="text-sm text-destructive">{(employees.error as Error).message}</div> : null}
<Input placeholder="Department name" value={name} onChange={(e) => setName(e.target.value)} />
<Select value={headId} onChange={(e) => setHeadId(e.target.value)}>
<option value="">(no head)</option>
{sortedEmployees.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name} ({e.employee_type})
</option>
))}
</Select>
<Button
onClick={() =>
createDepartment.mutate({
data: {
name,
head_employee_id: headId ? Number(headId) : null,
},
})
}
disabled={!name.trim() || createDepartment.isPending || employees.isFetching}
>
Create
</Button>
{createDepartment.error ? (
<div className="text-sm text-destructive">{(createDepartment.error as Error).message}</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>All departments</CardTitle>
<CardDescription>{departmentList.length} total</CardDescription>
</CardHeader>
<CardContent>
{departments.isLoading ? <div className="text-sm text-muted-foreground">Loading</div> : null}
{departments.error ? (
<div className="text-sm text-destructive">{(departments.error as Error).message}</div>
) : null}
{!departments.isLoading && !departments.error ? (
<ul className="space-y-2">
{departmentList.map((d) => (
<li key={d.id ?? d.name} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-medium">{d.name}</div>
<div className="text-xs text-muted-foreground">id: {d.id}</div>
</div>
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-muted-foreground">Head:</span>
<Select
disabled={d.id == null}
value={d.head_employee_id ? String(d.head_employee_id) : ""}
onBlur={(e) => { if (d.id == null) return; updateDepartment.mutate({ departmentId: Number(d.id), data: { head_employee_id: e.target.value ? Number(e.target.value) : null } }); }}
>
<option value="">(none)</option>
{sortedEmployees.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
</div>
</li>
))}
{departmentList.length === 0 ? (
<li className="text-sm text-muted-foreground">No departments yet.</li>
) : null}
</ul>
) : null}
{updateDepartment.error ? (
<div className="mt-3 text-sm text-destructive">{(updateDepartment.error as Error).message}</div>
) : null}
</CardContent>
</Card>
</div>
</main>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -3,65 +3,91 @@
@tailwind utilities;
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--radius: 12px;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
}
* {
@apply border-border;
color-scheme: light;
}
body {
@apply bg-background text-foreground;
@apply bg-white text-gray-900 font-body;
}
* {
@apply border-black/10;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes float-slow {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes progress-shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@layer utilities {
.animate-fade-in-up {
animation: fade-in-up 0.6s ease-out both;
}
.animate-fade-in {
animation: fade-in 0.4s ease-out both;
}
.animate-float {
animation: float-slow 6s ease-in-out infinite;
}
.animate-progress-shimmer {
animation: progress-shimmer 1.8s linear infinite;
}
.glass-panel {
background: #ffffff;
border: 1px solid #0b0b0b;
box-shadow: 4px 4px 0 #0b0b0b;
}
.shadow-lush {
box-shadow: 6px 6px 0 #0b0b0b;
}
.soft-shadow {
box-shadow: 0 24px 60px rgba(11, 11, 11, 0.12);
}
.soft-shadow-sm {
box-shadow: 0 16px 32px rgba(11, 11, 11, 0.08);
}
.bg-landing-grid {
background-image:
linear-gradient(to right, rgba(11, 11, 11, 0.08) 1px, transparent 1px),
linear-gradient(to bottom, rgba(11, 11, 11, 0.08) 1px, transparent 1px);
background-size: 80px 80px;
}
}
.landing-page {
font-family: var(--font-body), sans-serif;
}

View File

@@ -1,211 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select } from "@/components/ui/select";
import { useListProjectsProjectsGet } from "@/api/generated/projects/projects";
import { useListEmployeesEmployeesGet } from "@/api/generated/org/org";
import { useListTasksTasksGet, useUpdateTaskTasksTaskIdPatch } from "@/api/generated/work/work";
const STATUSES = ["backlog", "ready", "in_progress", "review", "blocked", "done"] as const;
export default function KanbanPage() {
const projects = useListProjectsProjectsGet();
const projectList = projects.data?.data ?? [];
const employees = useListEmployeesEmployeesGet();
const employeeList = useMemo(() => employees.data?.data ?? [], [employees.data]);
const [projectId, setProjectId] = useState<string>("");
const [assigneeId, setAssigneeId] = useState<string>("");
const [live, setLive] = useState(false);
const tasks = useListTasksTasksGet(
{
...(projectId ? { project_id: Number(projectId) } : {}),
},
{
query: {
enabled: true,
refetchInterval: live ? 5000 : false,
refetchIntervalInBackground: false,
},
},
);
const taskList = useMemo(() => (tasks.data?.status === 200 ? tasks.data.data : []), [tasks.data]);
const updateTask = useUpdateTaskTasksTaskIdPatch({
mutation: {
onSuccess: () => tasks.refetch(),
},
});
const employeeNameById = useMemo(() => {
const m = new Map<number, string>();
for (const e of employeeList) {
if (e.id != null) m.set(e.id, e.name);
}
return m;
}, [employeeList]);
const filtered = useMemo(() => {
return taskList.filter((t) => {
if (assigneeId && String(t.assignee_employee_id ?? "") !== assigneeId) return false;
return true;
});
}, [taskList, assigneeId]);
const tasksByStatus = useMemo(() => {
const map = new Map<(typeof STATUSES)[number], typeof filtered>();
for (const s of STATUSES) map.set(s, []);
for (const t of filtered) {
const s = (t.status ?? "backlog") as (typeof STATUSES)[number];
(map.get(s) ?? map.get("backlog"))?.push(t);
}
// stable sort inside each column
for (const s of STATUSES) {
const arr = map.get(s) ?? [];
arr.sort((a, b) => String(a.id ?? 0).localeCompare(String(b.id ?? 0)));
}
return map;
}, [filtered]);
return (
<main className="mx-auto max-w-screen-2xl p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Kanban</h1>
<p className="mt-1 text-sm text-muted-foreground">Board view for tasks (quick triage + status moves).</p>
</div>
<Button
variant="outline"
onClick={() => {
tasks.refetch();
projects.refetch();
employees.refetch();
}}
disabled={tasks.isFetching || projects.isFetching || employees.isFetching}
>
Refresh
</Button>
</div>
{tasks.error ? (
<div className="mt-4 text-sm text-destructive">{(tasks.error as Error).message}</div>
) : null}
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="text-base">Filters</CardTitle>
<CardDescription>Scope the board.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Select value={projectId} onChange={(e) => setProjectId(e.target.value)}>
<option value="">All projects</option>
{projectList.map((p) => (
<option key={p.id ?? p.name} value={p.id ?? ""}>
{p.name}
</option>
))}
</Select>
<Select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)}>
<option value="">All assignees</option>
{employeeList.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
<div className="flex items-center justify-between gap-2 rounded-md border p-2 text-sm">
<div>
<div className="font-medium">Live updates</div>
<div className="text-xs text-muted-foreground">Auto-refresh tasks every 5s on this page.</div>
</div>
<Button variant="outline" size="sm" onClick={() => setLive((v) => !v)}>
{live ? "On" : "Off"}
</Button>
</div>
<div className="text-xs text-muted-foreground">
Showing {filtered.length} / {taskList.length} tasks
</div>
</CardContent>
</Card>
</div>
<div className="mt-6 grid gap-4" style={{ gridTemplateColumns: `repeat(${STATUSES.length}, minmax(260px, 1fr))` }}>
{STATUSES.map((status) => (
<Card key={status} className="min-w-[260px]">
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wide">{status.replaceAll("_", " ")}</CardTitle>
<CardDescription>{tasksByStatus.get(status)?.length ?? 0} tasks</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{(tasksByStatus.get(status) ?? []).map((t) => (
<div key={t.id ?? t.title} className="rounded-md border p-2 text-sm">
<div className="font-medium">{t.title}</div>
{t.description ? (
<div className="mt-1 text-xs text-muted-foreground line-clamp-3">{t.description}</div>
) : null}
<div className="mt-2 text-xs text-muted-foreground">
#{t.id} · {t.project_id ? `proj ${t.project_id}` : "no project"}
{t.assignee_employee_id != null ? ` · assignee ${employeeNameById.get(t.assignee_employee_id) ?? t.assignee_employee_id}` : ""}
</div>
<div className="mt-2 flex gap-2">
<Select
value={t.status ?? "backlog"}
onChange={(e) =>
updateTask.mutate({
taskId: Number(t.id),
data: {
status: e.target.value,
},
})
}
disabled={!t.id || updateTask.isPending}
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</Select>
<Button
variant="outline"
onClick={() => {
// quick move right
const idx = STATUSES.indexOf(status);
const next = STATUSES[Math.min(STATUSES.length - 1, idx + 1)];
if (!t.id) return;
updateTask.mutate({ taskId: Number(t.id), data: { status: next } });
}}
disabled={!t.id || updateTask.isPending}
>
</Button>
</div>
</div>
))}
{(tasksByStatus.get(status) ?? []).length === 0 ? (
<div className="text-xs text-muted-foreground">No tasks</div>
) : null}
</CardContent>
</Card>
))}
</div>
<div className="mt-4 text-xs text-muted-foreground">
Tip: set Actor ID in the left sidebar so changes are attributed correctly.
</div>
</main>
);
}

View File

@@ -1,23 +1,38 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "./providers";
import { Shell } from "./_components/Shell";
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { ClerkProvider } from "@clerk/nextjs";
import { Inter, Space_Grotesk } from "next/font/google";
export const metadata: Metadata = {
title: "OpenClaw Agency — Mission Control",
description: "Company OS for projects, departments, people, and HR.",
title: "OpenClaw Mission Control",
description: "A calm command center for every task.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const bodyFont = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-body",
});
const headingFont = Space_Grotesk({
subsets: ["latin"],
display: "swap",
variable: "--font-heading",
});
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<Shell><Providers>{children}</Providers></Shell>
</body>
</html>
<ClerkProvider>
<html lang="en">
<body
className={`${bodyFont.variable} ${headingFont.variable} min-h-screen bg-white text-gray-900 antialiased`}
>
{children}
</body>
</html>
</ClerkProvider>
);
}

View File

@@ -1,141 +0,0 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
}
.main {
display: flex;
min-height: 100vh;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
}
.intro a {
font-weight: 500;
color: var(--text-primary);
}
.ctas {
display: flex;
flex-direction: row;
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--button-secondary-border);
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
}
.intro {
gap: 16px;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}

View File

@@ -1,170 +1,12 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import styles from "@/app/_components/Shell.module.css";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { normalizeActivities } from "@/lib/normalize";
import { Select } from "@/components/ui/select";
import { useCreateProjectProjectsPost, useListProjectsProjectsGet } from "@/api/generated/projects/projects";
import { useCreateDepartmentDepartmentsPost, useListDepartmentsDepartmentsGet } from "@/api/generated/org/org";
import { useCreateEmployeeEmployeesPost, useListEmployeesEmployeesGet } from "@/api/generated/org/org";
import { useListActivitiesActivitiesGet } from "@/api/generated/activities/activities";
export default function Home() {
const projects = useListProjectsProjectsGet();
const projectList = projects.data?.status === 200 ? projects.data.data : [];
const departments = useListDepartmentsDepartmentsGet();
const departmentList = departments.data?.status === 200 ? departments.data.data : [];
const employees = useListEmployeesEmployeesGet();
const activities = useListActivitiesActivitiesGet({ limit: 20 });
const employeeList = employees.data?.status === 200 ? employees.data.data : [];
const activityList = normalizeActivities(activities.data);
const [projectName, setProjectName] = useState("");
const [deptName, setDeptName] = useState("");
const [personName, setPersonName] = useState("");
const [personType, setPersonType] = useState<"human" | "agent">("human");
const createProject = useCreateProjectProjectsPost({
mutation: { onSuccess: () => { setProjectName(""); projects.refetch(); } },
});
const createDepartment = useCreateDepartmentDepartmentsPost({
mutation: { onSuccess: () => { setDeptName(""); departments.refetch(); } },
});
const createEmployee = useCreateEmployeeEmployeesPost({
mutation: { onSuccess: () => { setPersonName(""); employees.refetch(); } },
});
import { LandingHero } from "@/components/organisms/LandingHero";
import { LandingShell } from "@/components/templates/LandingShell";
export default function Page() {
return (
<main>
<div className={styles.topbar}>
<div>
<h1 className={styles.h1}>Company Mission Control</h1>
<p className={styles.p}>Command center for projects, people, and operations. Noauth v1.</p>
</div>
<Button variant="outline" onClick={() => { projects.refetch(); departments.refetch(); employees.refetch(); activities.refetch(); }} disabled={projects.isFetching || departments.isFetching || employees.isFetching || activities.isFetching}>
Refresh
</Button>
</div>
<div className={styles.grid2}>
<div className={styles.card}>
<div className={styles.cardTitle}>Quick create</div>
<div className={styles.list}>
<div className={styles.item}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>Project</div>
<div style={{ display: "grid", gap: 8 }}>
<Input placeholder="Project name" value={projectName} onChange={(e) => setProjectName(e.target.value)} />
<Button onClick={() => createProject.mutate({ data: { name: projectName, status: "active" } })} disabled={!projectName.trim() || createProject.isPending}>Create</Button>
{createProject.error ? <div className={styles.mono}>{(createProject.error as Error).message}</div> : null}
</div>
</div>
<div className={styles.item}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>Department</div>
<div style={{ display: "grid", gap: 8 }}>
<Input placeholder="Department name" value={deptName} onChange={(e) => setDeptName(e.target.value)} />
<Button onClick={() => createDepartment.mutate({ data: { name: deptName } })} disabled={!deptName.trim() || createDepartment.isPending}>Create</Button>
{createDepartment.error ? <div className={styles.mono}>{(createDepartment.error as Error).message}</div> : null}
</div>
</div>
<div className={styles.item}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>Person</div>
<div style={{ display: "grid", gap: 8 }}>
<Input placeholder="Name" value={personName} onChange={(e) => setPersonName(e.target.value)} />
<Select value={personType} onChange={(e) => setPersonType(e.target.value === "agent" ? "agent" : "human")}>
<option value="human">human</option>
<option value="agent">agent</option>
</Select>
<Button onClick={() => createEmployee.mutate({ data: { name: personName, employee_type: personType, status: "active" } })} disabled={!personName.trim() || createEmployee.isPending}>Create</Button>
{createEmployee.error ? <div className={styles.mono}>{(createEmployee.error as Error).message}</div> : null}
</div>
</div>
</div>
</div>
<div className={styles.card}>
<div className={styles.cardTitle}>Live activity</div>
<div className={styles.list}>
{activityList.map((a) => (
<div key={String(a.id)} className={styles.item}>
<div style={{ fontWeight: 600 }}>{a.entity_type} · {a.verb}</div>
<div className={styles.mono}>id {a.entity_id ?? "—"}</div>
</div>
))}
{activityList.length === 0 ? (
<div className={styles.mono}>No activity yet.</div>
) : null}
</div>
</div>
</div>
<div style={{ marginTop: 18, display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 16 }}>
<Card>
<CardHeader>
<CardTitle>Projects</CardTitle>
<CardDescription>{projectList.length} total</CardDescription>
</CardHeader>
<CardContent>
<div className={styles.list}>
{projectList.slice(0, 8).map((p) => (
<div key={p.id ?? p.name} className={styles.item}>
<div style={{ fontWeight: 600 }}>{p.name}</div>
<div className={styles.mono} style={{ display: "flex", gap: 10, alignItems: "center" }}>
<span>{p.status}</span>
{p.id ? (
<Link href={
"/projects/" + p.id
} className={styles.badge}>Open</Link>
) : null}
</div>
</div>
))}
{projectList.length === 0 ? <div className={styles.mono}>No projects yet.</div> : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Departments</CardTitle>
<CardDescription>{departmentList.length} total</CardDescription>
</CardHeader>
<CardContent>
<div className={styles.list}>
{departmentList.slice(0, 8).map((d) => (
<div key={d.id ?? d.name} className={styles.item}>
<div style={{ fontWeight: 600 }}>{d.name}</div>
<div className={styles.mono}>id {d.id}</div>
</div>
))}
{departmentList.length === 0 ? <div className={styles.mono}>No departments yet.</div> : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>People</CardTitle>
<CardDescription>{employeeList.length} total</CardDescription>
</CardHeader>
<CardContent>
<div className={styles.list}>
{employeeList.slice(0, 8).map((e) => (
<div key={e.id ?? e.name} className={styles.item}>
<div style={{ fontWeight: 600 }}>{e.name}</div>
<div className={styles.mono}>{e.employee_type}</div>
</div>
))}
{employeeList.length === 0 ? <div className={styles.mono}>No people yet.</div> : null}
</div>
</CardContent>
</Card>
</div>
</main>
<LandingShell>
<LandingHero />
</LandingShell>
);
}

View File

@@ -1,207 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import {
useCreateEmployeeEmployeesPost,
useListDepartmentsDepartmentsGet,
useListEmployeesEmployeesGet,
useListTeamsTeamsGet,
useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost,
useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost,
} from "@/api/generated/org/org";
export default function PeoplePage() {
const [actorId] = useState(() => {
if (typeof window === "undefined") return "";
try {
return window.localStorage.getItem("actor_employee_id") ?? "";
} catch {
return "";
}
});
const [name, setName] = useState("");
const [employeeType, setEmployeeType] = useState<"human" | "agent">("human");
const [title, setTitle] = useState("");
const [departmentId, setDepartmentId] = useState<string>("");
const [teamId, setTeamId] = useState<string>("");
const [managerId, setManagerId] = useState<string>("");
const employees = useListEmployeesEmployeesGet();
const departments = useListDepartmentsDepartmentsGet();
const teams = useListTeamsTeamsGet({ department_id: undefined });
const departmentList = useMemo(() => (departments.data?.status === 200 ? departments.data.data : []), [departments.data]);
const employeeList = useMemo(() => (employees.data?.status === 200 ? employees.data.data : []), [employees.data]);
const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]);
const provisionEmployee = useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost();
const deprovisionEmployee = useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost();
const createEmployee = useCreateEmployeeEmployeesPost({
mutation: {
onSuccess: async (res) => {
setName("");
setTitle("");
setDepartmentId("");
setTeamId("");
setManagerId("");
// If an agent was created but not yet provisioned, provision immediately so it can receive tasks.
try {
const e = (res as any)?.data?.data ?? (res as any)?.data ?? null;
if (e?.employee_type === "agent" && !e.openclaw_session_key) {
await provisionEmployee.mutateAsync({ employeeId: e.id! });
}
} catch {
// ignore; UI will show unprovisioned state
}
employees.refetch();
teams.refetch();
},
},
});
const deptNameById = useMemo(() => {
const m = new Map<number, string>();
for (const d of departmentList) {
if (d.id != null) m.set(d.id, d.name);
}
return m;
}, [departmentList]);
const teamNameById = useMemo(() => {
const m = new Map<number, string>();
for (const t of teamList) {
if (t.id != null) m.set(t.id, t.name);
}
return m;
}, [teamList]);
const empNameById = useMemo(() => {
const m = new Map<number, string>();
for (const e of employeeList) {
if (e.id != null) m.set(e.id, e.name);
}
return m;
}, [employeeList]);
return (
<main className="mx-auto max-w-5xl p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">People</h1>
<p className="mt-1 text-sm text-muted-foreground">Employees and agents share the same table.</p>
</div>
<Button variant="outline" onClick={() => employees.refetch()} disabled={employees.isFetching}>
Refresh
</Button>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Add person</CardTitle>
<CardDescription>Create an employee (human) or an agent.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Select value={employeeType} onChange={(e) => setEmployeeType(e.target.value === "agent" ? "agent" : "human")}>
<option value="human">human</option>
<option value="agent">agent</option>
</Select>
<Input placeholder="Title (optional)" value={title} onChange={(e) => setTitle(e.target.value)} />
<Select value={departmentId} onChange={(e) => setDepartmentId(e.target.value)}>
<option value="">(no department)</option>
{departmentList.map((d) => (
<option key={d.id ?? d.name} value={d.id ?? ""}>
{d.name}
</option>
))}
</Select>
<Select value={teamId} onChange={(e) => setTeamId(e.target.value)}>
<option value="">(no team)</option>
{teamList.map((t) => (
<option key={t.id ?? t.name} value={t.id ?? ""}>
{t.name}
</option>
))}
</Select>
<Select value={managerId} onChange={(e) => setManagerId(e.target.value)}>
<option value="">(no manager)</option>
{employeeList.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
<Button
onClick={() =>
createEmployee.mutate({
data: {
name,
employee_type: employeeType,
title: title.trim() ? title : null,
department_id: departmentId ? Number(departmentId) : null,
team_id: teamId ? Number(teamId) : null,
manager_id: managerId ? Number(managerId) : null,
status: "active",
},
})
}
disabled={!name.trim() || createEmployee.isPending}
>
Create
</Button>
{createEmployee.error ? (
<div className="text-sm text-destructive">{(createEmployee.error as Error).message}</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Directory</CardTitle>
<CardDescription>{employeeList.length} total</CardDescription>
</CardHeader>
<CardContent>
{employees.isLoading ? <div className="text-sm text-muted-foreground">Loading</div> : null}
{employees.error ? (
<div className="text-sm text-destructive">{(employees.error as Error).message}</div>
) : null}
{!employees.isLoading && !employees.error ? (
<ul className="space-y-2">
{employeeList.map((e) => (
<li key={e.id ?? e.name} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-medium">{e.name}</div>
<Badge variant={e.employee_type === "agent" ? "secondary" : "outline"}>
{e.employee_type}
</Badge>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{e.title ? <span>{e.title} · </span> : null}
{e.department_id ? <span>{deptNameById.get(e.department_id) ?? `Dept#${e.department_id}`} · </span> : null}
{e.team_id ? <span>Team: {teamNameById.get(e.team_id) ?? `Team#${e.team_id}`} · </span> : null}
{e.manager_id ? <span>Mgr: {empNameById.get(e.manager_id) ?? `Emp#${e.manager_id}`}</span> : <span>No manager</span>}
</div>
</li>
))}
{employeeList.length === 0 ? (
<li className="text-sm text-muted-foreground">No people yet.</li>
) : null}
</ul>
) : null}
</CardContent>
</Card>
</div>
</main>
);
}

View File

@@ -1,539 +0,0 @@
"use client";
import { useState } from "react";
import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useListProjectsProjectsGet } from "@/api/generated/projects/projects";
import { useListEmployeesEmployeesGet } from "@/api/generated/org/org";
import {
useCreateTaskTasksPost,
useDeleteTaskTasksTaskIdDelete,
useDispatchTaskTasksTaskIdDispatchPost,
useListTaskCommentsTaskCommentsGet,
useListTasksTasksGet,
useUpdateTaskTasksTaskIdPatch,
useCreateTaskCommentTaskCommentsPost,
} from "@/api/generated/work/work";
import {
useAddProjectMemberProjectsProjectIdMembersPost,
useListProjectMembersProjectsProjectIdMembersGet,
useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete,
useUpdateProjectMemberProjectsProjectIdMembersMemberIdPatch,
} from "@/api/generated/projects/projects";
function getActorEmployeeId(): number | null {
if (typeof window === "undefined") return null;
try {
const v = window.localStorage.getItem("actor_employee_id");
if (!v) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
const STATUSES = ["backlog", "ready", "in_progress", "review", "done", "blocked"] as const;
export default function ProjectDetailPage() {
const params = useParams();
const projectId = Number(params?.id);
const projects = useListProjectsProjectsGet();
const projectList = projects.data?.status === 200 ? projects.data.data : [];
const project = projectList.find((p) => p.id === projectId);
const employees = useListEmployeesEmployeesGet();
const employeeList = employees.data?.status === 200 ? employees.data.data : [];
const eligibleAssignees = employeeList.filter(
(e) => e.employee_type !== "agent" || !!e.openclaw_session_key,
);
const members = useListProjectMembersProjectsProjectIdMembersGet(projectId);
const memberList = members.data?.status === 200 ? members.data.data : [];
const addMember = useAddProjectMemberProjectsProjectIdMembersPost({
mutation: { onSuccess: () => members.refetch() },
});
const removeMember = useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete({
mutation: { onSuccess: () => members.refetch() },
});
const updateMember = useUpdateProjectMemberProjectsProjectIdMembersMemberIdPatch({
mutation: { onSuccess: () => members.refetch() },
});
const tasks = useListTasksTasksGet({ project_id: projectId });
const taskList = tasks.data?.status === 200 ? tasks.data.data : [];
const createTask = useCreateTaskTasksPost({
mutation: { onSuccess: () => tasks.refetch() },
});
const updateTask = useUpdateTaskTasksTaskIdPatch({
mutation: { onSuccess: () => tasks.refetch() },
});
const deleteTask = useDeleteTaskTasksTaskIdDelete({
mutation: { onSuccess: () => tasks.refetch() },
});
const dispatchTask = useDispatchTaskTasksTaskIdDispatchPost({
mutation: {
onSuccess: () => tasks.refetch(),
},
});
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [assigneeId, setAssigneeId] = useState<string>("");
const [commentTaskId, setCommentTaskId] = useState<number | null>(null);
const [replyToCommentId, setReplyToCommentId] = useState<number | null>(null);
const [commentBody, setCommentBody] = useState("");
const comments = useListTaskCommentsTaskCommentsGet(
{ task_id: commentTaskId ?? 0 },
{ query: { enabled: Boolean(commentTaskId) } },
);
const commentList = comments.data?.status === 200 ? comments.data.data : [];
const addComment = useCreateTaskCommentTaskCommentsPost({
mutation: {
onSuccess: () => {
comments.refetch();
setCommentBody("");
setReplyToCommentId(null);
},
},
});
const tasksByStatus = (() => {
const map = new Map<string, typeof taskList>();
for (const s of STATUSES) map.set(s, []);
for (const t of taskList) {
const status = t.status ?? "backlog";
map.get(status)?.push(t);
}
return map;
})();
const employeeById = new Map<number, (typeof employeeList)[number]>();
for (const e of employeeList) {
if (e.id != null) employeeById.set(Number(e.id), e);
}
const employeeName = (id: number | null | undefined) =>
employeeList.find((e) => e.id === id)?.name ?? "—";
const projectMembers = memberList;
const commentById = new Map<number, (typeof commentList)[number]>();
for (const c of commentList) {
if (c.id != null) commentById.set(Number(c.id), c);
}
return (
<main className="mx-auto max-w-6xl p-6">
{!Number.isFinite(projectId) ? (
<div className="mb-4 text-sm text-destructive">Invalid project id in URL.</div>
) : null}
{projects.isLoading || employees.isLoading || members.isLoading || tasks.isLoading ? (
<div className="mb-4 text-sm text-muted-foreground">Loading</div>
) : null}
{projects.error ? (
<div className="mb-4 text-sm text-destructive">
{(projects.error as Error).message}
</div>
) : null}
{employees.error ? (
<div className="mb-4 text-sm text-destructive">
{(employees.error as Error).message}
</div>
) : null}
{members.error ? (
<div className="mb-4 text-sm text-destructive">{(members.error as Error).message}</div>
) : null}
{tasks.error ? (
<div className="mb-4 text-sm text-destructive">{(tasks.error as Error).message}</div>
) : null}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{project?.name ?? `Project #${projectId}`}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Project detail: staffing + tasks.
</p>
</div>
<Button
variant="outline"
onClick={() => {
tasks.refetch();
members.refetch();
}}
disabled={tasks.isFetching || members.isFetching}
>
Refresh
</Button>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Create task</CardTitle>
<CardDescription>Project-scoped tasks</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{createTask.error ? (
<div className="text-sm text-destructive">
{(createTask.error as Error).message}
</div>
) : null}
<Input
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Textarea
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="grid grid-cols-1 gap-2">
<Select
value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)}
>
<option value="">Assignee</option>
{eligibleAssignees.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
</div>
<Button
onClick={() =>
createTask.mutate({
data: {
project_id: projectId,
title,
description: description.trim() ? description : null,
status: "backlog",
assignee_employee_id: assigneeId ? Number(assigneeId) : null,
},
})
}
disabled={!title.trim() || createTask.isPending}
>
Add task
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Staffing</CardTitle>
<CardDescription>Project members</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Select
onChange={(e) => {
const empId = e.target.value;
if (!empId) return;
addMember.mutate({
projectId,
data: { project_id: projectId, employee_id: Number(empId), role: "member" },
});
e.currentTarget.value = "";
}}
>
<option value="">Add member</option>
{eligibleAssignees.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
{addMember.error ? (
<div className="text-xs text-destructive">
{(addMember.error as Error).message}
</div>
) : null}
<ul className="space-y-2">
{projectMembers.map((m) => (
<li
key={m.id ?? `${m.project_id}-${m.employee_id}`}
className="rounded-md border p-2 text-sm"
>
<div className="flex items-center justify-between gap-2">
<div>{employeeName(m.employee_id)}</div>
<Button
variant="outline"
onClick={() => {
if (m.id == null) return;
removeMember.mutate({ projectId, memberId: Number(m.id) });
}}
>
Remove
</Button>
</div>
<div className="mt-2">
<Input
placeholder="Role (e.g., PM, QA, Dev)"
defaultValue={m.role ?? ""}
onBlur={(e) =>
m.id == null
? undefined
: updateMember.mutate({
projectId,
memberId: Number(m.id),
data: {
project_id: projectId,
employee_id: m.employee_id,
role: e.currentTarget.value || null,
},
})
}
/>
</div>
</li>
))}
{projectMembers.length === 0 ? (
<li className="text-sm text-muted-foreground">No members yet.</li>
) : null}
</ul>
</CardContent>
</Card>
</div>
<div className="mt-6 grid gap-4">
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-6">
{STATUSES.map((s) => (
<Card key={s}>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wide">
{s.replace("_", " ")}
</CardTitle>
<CardDescription>{tasksByStatus.get(s)?.length ?? 0} tasks</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{(tasksByStatus.get(s) ?? []).map((t) => {
const assignee =
t.assignee_employee_id != null
? employeeById.get(Number(t.assignee_employee_id))
: undefined;
const canTrigger = Boolean(
t.id != null &&
assignee &&
assignee.employee_type === "agent" &&
assignee.openclaw_session_key,
);
const actorId = getActorEmployeeId();
const isReviewer = Boolean(actorId && t.reviewer_employee_id && Number(t.reviewer_employee_id) === actorId);
const canReviewActions = Boolean(t.id != null && isReviewer && (t.status ?? "") === "review");
return (
<div key={t.id ?? t.title} className="rounded-md border p-2 text-sm">
<div className="font-medium">{t.title}</div>
<div className="text-xs text-muted-foreground">
Assignee: {employeeName(t.assignee_employee_id)}
</div>
<div className="mt-2 flex flex-wrap gap-1">
{STATUSES.filter((x) => x !== s).map((x) => (
<Button
key={x}
variant="outline"
size="sm"
onClick={() =>
updateTask.mutate({
taskId: Number(t.id),
data: { status: x },
})
}
>
{x}
</Button>
))}
</div>
<div className="mt-2 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setCommentTaskId(Number(t.id));
setReplyToCommentId(null);
}}
>
Comments
</Button>
<Button
variant="outline"
size="sm"
onClick={() => dispatchTask.mutate({ taskId: Number(t.id) })}
disabled={!canTrigger || dispatchTask.isPending}
title={
canTrigger
? "Send a dispatch message to the assigned agent"
: "Only available when the assignee is a provisioned agent"
}
>
Trigger
</Button>
{canReviewActions ? (
<>
<Button
variant="outline"
size="sm"
onClick={() =>
updateTask.mutate({
taskId: Number(t.id),
data: { status: "done" },
})
}
>
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setCommentTaskId(Number(t.id));
setReplyToCommentId(null);
}}
title="Leave a comment asking for changes, then move status back to in_progress"
>
Request changes
</Button>
</>
) : null}
<Button
variant="destructive"
size="sm"
onClick={() => deleteTask.mutate({ taskId: Number(t.id) })}
>
Delete
</Button>
</div>
{dispatchTask.error ? (
<div className="mt-2 text-xs text-destructive">
{(dispatchTask.error as Error).message}
</div>
) : null}
</div>
);
})}
{(tasksByStatus.get(s) ?? []).length === 0 ? (
<div className="text-xs text-muted-foreground">No tasks</div>
) : null}
</CardContent>
</Card>
))}
</div>
</div>
<div className="mt-6">
<Card>
<CardHeader>
<CardTitle>Task comments</CardTitle>
<CardDescription>{commentTaskId ? `Task #${commentTaskId}` : "Select a task"}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{addComment.error ? (
<div className="text-sm text-destructive">{(addComment.error as Error).message}</div>
) : null}
{replyToCommentId ? (
<div className="rounded-md border bg-muted/40 p-2 text-sm">
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">
Replying to comment #{replyToCommentId}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setReplyToCommentId(null)}
>
Cancel reply
</Button>
</div>
<div className="mt-1 text-xs text-muted-foreground line-clamp-2">
{commentById.get(replyToCommentId)?.body ?? "—"}
</div>
</div>
) : null}
<Textarea
placeholder="Write a comment"
value={commentBody}
onChange={(e) => setCommentBody(e.target.value)}
disabled={!commentTaskId}
/>
<Button
onClick={() =>
addComment.mutate({
data: {
task_id: Number(commentTaskId),
author_employee_id: getActorEmployeeId(),
body: commentBody,
reply_to_comment_id: replyToCommentId,
},
})
}
disabled={!commentTaskId || !commentBody.trim() || addComment.isPending}
>
Add comment
</Button>
<ul className="space-y-2">
{commentList.map((c) => (
<li key={String(c.id)} className="rounded-md border p-2 text-sm">
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-medium">{employeeName(c.author_employee_id)}</div>
<div className="text-xs text-muted-foreground">
{c.created_at ? new Date(c.created_at).toLocaleString() : "—"}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setReplyToCommentId(Number(c.id))}
>
Reply
</Button>
</div>
{c.reply_to_comment_id ? (
<div className="mt-2 rounded-md border bg-muted/40 p-2 text-xs">
<div className="text-muted-foreground">
Replying to #{c.reply_to_comment_id}: {commentById.get(Number(c.reply_to_comment_id))?.body ?? "—"}
</div>
</div>
) : null}
<div className="mt-2">{c.body}</div>
</li>
))}
{commentList.length === 0 ? (
<li className="text-sm text-muted-foreground">No comments yet.</li>
) : null}
</ul>
</CardContent>
</Card>
</div>
</main>
);
}

View File

@@ -1,103 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import styles from "@/app/_components/Shell.module.css";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
useCreateProjectProjectsPost,
useListProjectsProjectsGet,
} from "@/api/generated/projects/projects";
import { useListTeamsTeamsGet } from "@/api/generated/org/org";
export default function ProjectsPage() {
const [name, setName] = useState("");
const [teamId, setTeamId] = useState<string>("");
const projects = useListProjectsProjectsGet();
const teams = useListTeamsTeamsGet({ department_id: undefined });
const projectList = projects.data?.status === 200 ? projects.data.data : [];
const teamList = teams.data?.status === 200 ? teams.data.data : [];
const createProject = useCreateProjectProjectsPost({
mutation: {
onSuccess: () => {
setName("");
setTeamId("");
projects.refetch();
},
},
});
const sorted = projectList.slice().sort((a, b) => a.name.localeCompare(b.name));
return (
<main>
<div className={styles.topbar}>
<div>
<h1 className={styles.h1}>Projects</h1>
<p className={styles.p}>Create, view, and drill into projects.</p>
</div>
<Button variant="outline" onClick={() => projects.refetch()} disabled={projects.isFetching}>
Refresh
</Button>
</div>
<div className={styles.grid2}>
<div className={styles.card}>
<div className={styles.cardTitle}>Create project</div>
{projects.isLoading ? <div className={styles.mono}>Loading</div> : null}
{projects.error ? <div className={styles.mono}>{(projects.error as Error).message}</div> : null}
<div className={styles.list}>
<Input placeholder="Project name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ fontSize: 12, opacity: 0.8 }}>Owning team</span>
<select value={teamId} onChange={(e) => setTeamId(e.target.value)} style={{ flex: 1, padding: '6px 8px', borderRadius: 6, border: '1px solid #333', background: 'transparent' }}>
<option value="">(none)</option>
{teamList.map((t) => (
<option key={t.id ?? t.name} value={t.id ?? ''}>{t.name}</option>
))}
</select>
</div>
<Button
onClick={() => createProject.mutate({ data: { name, status: "active", team_id: teamId ? Number(teamId) : null } })}
disabled={!name.trim() || createProject.isPending || projects.isFetching}
>
Create
</Button>
{createProject.error ? (
<div className={styles.mono}>{(createProject.error as Error).message}</div>
) : null}
</div>
</div>
<Card>
<CardHeader>
<CardTitle>All projects</CardTitle>
<CardDescription>{sorted.length} total</CardDescription>
</CardHeader>
<CardContent>
<div className={styles.list}>
{sorted.map((p) => (
<div key={p.id ?? p.name} className={styles.item}>
<div style={{ fontWeight: 600 }}>{p.name}</div>
<div className={styles.mono} style={{ display: "flex", gap: 10, alignItems: "center" }}>
<span>{p.status}</span>
{p.id ? (
<Link href={`/projects/${p.id}`} className={styles.badge}>Open</Link>
) : null}
</div>
</div>
))}
{sorted.length === 0 ? <div className={styles.mono}>No projects yet.</div> : null}
</div>
</CardContent>
</Card>
</div>
</main>
);
}

View File

@@ -1,23 +0,0 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: 1,
// Mission Control is a live ops surface; keeping data fresh on focus/reconnect
// gives a near-realtime feel without aggressive polling.
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
},
})
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

View File

@@ -1,150 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import {
useCreateTeamTeamsPost,
useListDepartmentsDepartmentsGet,
useListEmployeesEmployeesGet,
useListTeamsTeamsGet,
} from "@/api/generated/org/org";
export default function TeamsPage() {
const [name, setName] = useState("");
const [departmentId, setDepartmentId] = useState<string>("");
const [leadEmployeeId, setLeadEmployeeId] = useState<string>("");
const departments = useListDepartmentsDepartmentsGet();
const employees = useListEmployeesEmployeesGet();
const teams = useListTeamsTeamsGet({ department_id: undefined });
const departmentList = useMemo(
() => (departments.data?.status === 200 ? departments.data.data : []),
[departments.data],
);
const employeeList = useMemo(
() => (employees.data?.status === 200 ? employees.data.data : []),
[employees.data],
);
const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]);
const deptNameById = useMemo(() => {
const m = new Map<number, string>();
for (const d of departmentList) {
if (d.id != null) m.set(d.id, d.name);
}
return m;
}, [departmentList]);
const empNameById = useMemo(() => {
const m = new Map<number, string>();
for (const e of employeeList) {
if (e.id != null) m.set(e.id, e.name);
}
return m;
}, [employeeList]);
const createTeam = useCreateTeamTeamsPost({
mutation: {
onSuccess: () => {
setName("");
setDepartmentId("");
setLeadEmployeeId("");
teams.refetch();
},
},
});
const sorted = teamList
.slice()
.sort((a, b) => `${deptNameById.get(a.department_id) ?? ""}::${a.name}`.localeCompare(`${deptNameById.get(b.department_id) ?? ""}::${b.name}`));
return (
<main className="mx-auto max-w-5xl p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Teams</h1>
<p className="mt-1 text-sm text-muted-foreground">Teams live under departments. Projects are owned by teams.</p>
</div>
<Button variant="outline" onClick={() => teams.refetch()} disabled={teams.isFetching}>
Refresh
</Button>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Create team</CardTitle>
<CardDescription>Define a team and attach it to a department.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Input placeholder="Team name" value={name} onChange={(e) => setName(e.target.value)} />
<Select value={departmentId} onChange={(e) => setDepartmentId(e.target.value)}>
<option value="">(select department)</option>
{departmentList.map((d) => (
<option key={d.id ?? d.name} value={d.id ?? ""}>
{d.name}
</option>
))}
</Select>
<Select value={leadEmployeeId} onChange={(e) => setLeadEmployeeId(e.target.value)}>
<option value="">(no lead)</option>
{employeeList.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
<Button
onClick={() =>
createTeam.mutate({
data: {
name: name.trim(),
department_id: Number(departmentId),
lead_employee_id: leadEmployeeId ? Number(leadEmployeeId) : null,
},
})
}
disabled={!name.trim() || !departmentId || createTeam.isPending}
>
Create
</Button>
{createTeam.error ? <div className="text-sm text-destructive">{(createTeam.error as Error).message}</div> : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>All teams</CardTitle>
<CardDescription>{sorted.length} total</CardDescription>
</CardHeader>
<CardContent>
{teams.isLoading ? <div className="text-sm text-muted-foreground">Loading</div> : null}
{teams.error ? <div className="text-sm text-destructive">{(teams.error as Error).message}</div> : null}
{!teams.isLoading && !teams.error ? (
<ul className="space-y-2">
{sorted.map((t) => (
<li key={t.id ?? `${t.department_id}:${t.name}`} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-medium">{t.name}</div>
<div className="text-sm text-muted-foreground">{deptNameById.get(t.department_id) ?? `Dept#${t.department_id}`}</div>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{t.lead_employee_id ? <span>Lead: {empNameById.get(t.lead_employee_id) ?? `Emp#${t.lead_employee_id}`}</span> : <span>No lead</span>}
</div>
</li>
))}
{sorted.length === 0 ? <li className="text-sm text-muted-foreground">No teams yet.</li> : null}
</ul>
) : null}
</CardContent>
</Card>
</div>
</main>
);
}

View File

@@ -0,0 +1,17 @@
export function BrandMark() {
return (
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-lg border-2 border-gray-200 bg-white text-sm font-bold text-gray-900 shadow-lush">
<span className="font-heading tracking-[0.2em]">
OC
</span>
</div>
<div className="leading-tight">
<div className="font-heading text-sm uppercase tracking-[0.28em] text-gray-600">
OpenClaw
</div>
<div className="text-[11px] font-medium text-gray-500">Mission Control</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
export function HeroKicker({ children }: { children: ReactNode }) {
return (
<span className="inline-flex items-center rounded-full bg-gray-100 px-4 py-1 text-[11px] font-semibold uppercase tracking-[0.35em] text-gray-600">
{children}
</span>
);
}

View File

@@ -0,0 +1,21 @@
import { Badge } from "@/components/ui/badge";
const STATUS_STYLES: Record<string, "default" | "outline" | "ember"> = {
inbox: "outline",
assigned: "default",
in_progress: "ember",
testing: "outline",
review: "default",
done: "default",
online: "default",
busy: "ember",
offline: "outline",
};
export function StatusPill({ status }: { status: string }) {
return (
<Badge variant={STATUS_STYLES[status] ?? "default"}>
{status.replace("_", " ")}
</Badge>
);
}

View File

@@ -0,0 +1,20 @@
import { HeroKicker } from "@/components/atoms/HeroKicker";
export function HeroCopy() {
return (
<div className="space-y-6">
<HeroKicker>Mission Control</HeroKicker>
<div className="space-y-4">
<h1 className="font-heading text-4xl font-bold leading-tight text-gray-900 sm:text-5xl lg:text-6xl">
Orchestrate work without
<br />
the daily status chase.
</h1>
<p className="max-w-xl text-base text-gray-600 sm:text-lg">
OpenClaw keeps every task, agent, and delivery signal in one place so
teams can spot momentum shifts fast.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { CalendarClock, UserCircle } from "lucide-react";
import { StatusPill } from "@/components/atoms/StatusPill";
import { Card, CardContent } from "@/components/ui/card";
interface TaskCardProps {
title: string;
status: string;
assignee?: string;
due?: string;
}
export function TaskCard({ title, status, assignee, due }: TaskCardProps) {
return (
<Card className="border-gray-200 bg-white">
<CardContent className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-gray-900">{title}</p>
<StatusPill status={status} />
</div>
</div>
<div className="flex items-center justify-between text-xs text-gray-600">
<div className="flex items-center gap-2">
<UserCircle className="h-4 w-4" />
<span>{assignee ?? "Unassigned"}</span>
</div>
{due ? (
<div className="flex items-center gap-2">
<CalendarClock className="h-4 w-4" />
<span>{due}</span>
</div>
) : null}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
export function DashboardSidebar() {
const pathname = usePathname();
return (
<aside className="flex h-full flex-col gap-6 rounded-xl border-2 border-gray-200 bg-white p-6 shadow-lush">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-500">
Work
</p>
<nav className="space-y-1 text-sm">
<Link
href="/agents"
className={cn(
"block rounded-lg border border-transparent px-3 py-2 font-medium text-gray-700 hover:border-gray-200 hover:bg-gray-50",
pathname.startsWith("/agents") &&
"border-gray-200 bg-gray-50 text-gray-900"
)}
>
Agents
</Link>
<Link
href="/boards"
className={cn(
"block rounded-lg border border-transparent px-3 py-2 font-medium text-gray-700 hover:border-gray-200 hover:bg-gray-50",
pathname.startsWith("/boards") &&
"border-gray-200 bg-gray-50 text-gray-900"
)}
>
Boards
</Link>
</nav>
</div>
</aside>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
import { HeroCopy } from "@/components/molecules/HeroCopy";
import { Button } from "@/components/ui/button";
export function LandingHero() {
return (
<section className="grid w-full items-center gap-10 lg:grid-cols-[1.05fr_0.95fr]">
<div
className="space-y-8 animate-fade-in-up"
style={{ animationDelay: "0.05s" }}
>
<HeroCopy />
<div
className="flex flex-col gap-3 sm:flex-row sm:items-center animate-fade-in-up"
style={{ animationDelay: "0.12s" }}
>
<SignedOut>
<SignInButton
mode="modal"
afterSignInUrl="/boards"
afterSignUpUrl="/boards"
forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards"
>
<Button
size="lg"
className="w-full sm:w-auto border-2 border-gray-900 bg-gray-900 text-white hover:bg-gray-900/90"
>
Sign in to open mission control
</Button>
</SignInButton>
</SignedOut>
<SignedIn>
<div className="text-sm text-gray-600">
You&apos;re signed in. Open your boards when you&apos;re ready.
</div>
</SignedIn>
</div>
<p
className="text-xs uppercase tracking-[0.3em] text-gray-500 animate-fade-in-up"
style={{ animationDelay: "0.18s" }}
>
One login · clear ownership · faster decisions
</p>
</div>
<div
className="relative animate-fade-in-up"
style={{ animationDelay: "0.3s" }}
>
<div className="glass-panel rounded-2xl bg-white p-6">
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.3em] text-gray-500">
<span>Status</span>
<span className="rounded-full border border-gray-200 px-2 py-1 text-[10px]">
Live
</span>
</div>
<div className="space-y-2">
<p className="text-lg font-semibold text-gray-900">
Tasks claimed automatically
</p>
<p className="text-sm text-gray-600">
Agents pick the next task in queue, report progress, and ship
deliverables back to you.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
{["Assignments", "In review", "Delivered", "Signals"].map(
(label) => (
<div
key={label}
className="rounded-xl border-2 border-gray-200 bg-white p-4 text-sm font-semibold text-gray-900 soft-shadow-sm"
>
{label}
</div>
)
)}
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import { useMemo } from "react";
import { TaskCard } from "@/components/molecules/TaskCard";
import { cn } from "@/lib/utils";
type Task = {
id: string;
title: string;
status: string;
due_at?: string | null;
};
type TaskBoardProps = {
tasks: Task[];
onCreateTask: () => void;
isCreateDisabled?: boolean;
};
const columns = [
{ title: "Inbox", status: "inbox" },
{ title: "Assigned", status: "assigned" },
{ title: "In Progress", status: "in_progress" },
{ title: "Testing", status: "testing" },
{ title: "Review", status: "review" },
{ title: "Done", status: "done" },
];
const formatDueDate = (value?: string | null) => {
if (!value) return undefined;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return undefined;
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
};
export function TaskBoard({
tasks,
onCreateTask,
isCreateDisabled = false,
}: TaskBoardProps) {
const grouped = useMemo(() => {
const buckets: Record<string, Task[]> = {};
for (const column of columns) {
buckets[column.status] = [];
}
tasks.forEach((task) => {
const bucket = buckets[task.status] ?? buckets.inbox;
bucket.push(task);
});
return buckets;
}, [tasks]);
return (
<div className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-6 overflow-x-auto pb-2">
{columns.map((column) => {
const columnTasks = grouped[column.status] ?? [];
return (
<div key={column.title} className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900">
{column.title}
</h3>
<span className="text-xs text-gray-500">
{columnTasks.length}
</span>
</div>
<div className="space-y-3">
{column.status === "inbox" ? (
<button
type="button"
onClick={onCreateTask}
disabled={isCreateDisabled}
className={cn(
"flex w-full items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50 px-4 py-6 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 transition hover:border-gray-300 hover:bg-white",
isCreateDisabled && "cursor-not-allowed opacity-60"
)}
>
New task
</button>
) : null}
{columnTasks.map((task) => (
<TaskCard
key={task.id}
title={task.title}
status={column.status}
due={formatDueDate(task.due_at)}
/>
))}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import type { ReactNode } from "react";
import { SignedIn, UserButton } from "@clerk/nextjs";
import { BrandMark } from "@/components/atoms/BrandMark";
export function DashboardShell({ children }: { children: ReactNode }) {
return (
<div className="relative min-h-screen bg-white text-gray-900">
<div
className="absolute inset-0 bg-landing-grid opacity-[0.35] pointer-events-none"
aria-hidden="true"
/>
<div className="relative flex min-h-screen w-full flex-col gap-8 px-6 pb-10 pt-8">
<header className="flex flex-wrap items-center justify-between gap-4">
<BrandMark />
<SignedIn>
<div className="rounded-lg border-2 border-gray-200 bg-white px-2 py-1">
<UserButton />
</div>
</SignedIn>
</header>
<div className="grid flex-1 gap-6 lg:grid-cols-[320px_1fr]">
{children}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import type { ReactNode } from "react";
import { SignedIn, UserButton } from "@clerk/nextjs";
import { BrandMark } from "@/components/atoms/BrandMark";
export function LandingShell({ children }: { children: ReactNode }) {
return (
<div className="landing-page bg-white text-gray-900">
<section className="relative overflow-hidden pt-24 pb-16 px-4 sm:px-6 lg:px-8">
<div
className="absolute inset-0 bg-landing-grid opacity-[0.35] pointer-events-none"
aria-hidden="true"
/>
<div
className="absolute -top-28 right-0 h-64 w-64 rounded-full bg-gray-100 blur-3xl pointer-events-none"
aria-hidden="true"
/>
<div
className="absolute -bottom-32 left-0 h-72 w-72 rounded-full bg-gray-100 blur-3xl pointer-events-none"
aria-hidden="true"
/>
<div className="relative w-full">
<header className="flex items-center justify-between pb-12">
<BrandMark />
<SignedIn>
<div className="rounded-lg border-2 border-gray-200 bg-white px-2 py-1">
<UserButton />
</div>
</SignedIn>
</header>
<main>{children}</main>
</div>
</section>
</div>
);
}

View File

@@ -4,19 +4,19 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
"inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
outline: "text-foreground",
default: "bg-gray-100 text-gray-800",
outline: "border border-gray-300 text-gray-800",
ember: "bg-gray-900 text-white",
},
},
defaultVariants: {
variant: "default",
},
},
}
);
export interface BadgeProps

View File

@@ -1,30 +1,30 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center gap-2 rounded-lg text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:opacity-90",
secondary: "bg-secondary text-secondary-foreground hover:opacity-90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
destructive: "bg-destructive text-destructive-foreground hover:opacity-90",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
primary:
"border-2 border-gray-900 bg-gray-900 text-white hover:bg-gray-900/90",
secondary:
"border-2 border-gray-200 bg-white text-gray-900 hover:border-gray-900 hover:bg-gray-900 hover:text-white",
ghost: "bg-transparent text-gray-900 hover:bg-black/5",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
sm: "h-9 px-4",
md: "h-11 px-5",
lg: "h-12 px-6 text-base",
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: "primary",
size: "md",
},
}
);
@@ -34,15 +34,13 @@ export interface ButtonProps
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
({ className, variant, size, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
Button.displayName = "Button";

View File

@@ -6,46 +6,30 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
className={cn(
"rounded-xl border-2 border-gray-200 bg-white soft-shadow-sm",
className
)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("px-6 pt-6", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("px-6 pb-6", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
export { Card, CardHeader, CardContent };

View File

@@ -0,0 +1,102 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 w-[90vw] max-w-2xl translate-x-[-50%] translate-y-[-50%] rounded-2xl border-2 border-gray-200 bg-white p-6 shadow-lush focus:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col gap-2", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-wrap items-center justify-end gap-2", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-gray-900", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-gray-600", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -2,22 +2,18 @@ import * as React from "react";
import { cn } from "@/lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
ref={ref}
type={type}
className={cn(
"flex h-11 w-full rounded-lg border-2 border-gray-200 bg-white px-4 text-sm text-gray-900 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black",
className
)}
{...props}
/>
)
);
Input.displayName = "Input";

View File

@@ -1,20 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = "Label";
export { Label };

View File

@@ -1,23 +1,151 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
export type SelectProps = React.SelectHTMLAttributes<HTMLSelectElement>
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, children, ...props }, ref) => (
<select
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
type="button"
className={cn(
"flex h-11 w-full cursor-pointer items-center justify-between rounded-lg border-2 border-gray-200 bg-white px-4 text-sm text-gray-900 shadow-sm focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 text-gray-500" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = "SelectTrigger";
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-pointer items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = "SelectScrollUpButton";
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-pointer items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = "SelectScrollDownButton";
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border-2 border-gray-200 bg-white shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
{children}
</select>
),
);
Select.displayName = "Select";
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = "SelectContent";
export { Select };
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = "SelectLabel";
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-gray-800 outline-none focus:bg-black/5 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = "SelectItem";
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-gray-200", className)}
{...props}
/>
));
SelectSeparator.displayName = "SelectSeparator";
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,52 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex items-center gap-2 rounded-full border-2 border-gray-200 bg-white p-1",
className
)}
{...props}
/>
));
TabsList.displayName = "TabsList";
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"rounded-full px-4 py-2 text-xs font-semibold text-gray-600 transition data-[state=active]:bg-gray-900 data-[state=active]:text-white",
className
)}
{...props}
/>
));
TabsTrigger.displayName = "TabsTrigger";
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn("mt-4 focus-visible:outline-none", className)}
{...props}
/>
));
TabsContent.displayName = "TabsContent";
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -2,22 +2,19 @@ import * as React from "react";
import { cn } from "@/lib/utils";
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"min-h-[120px] w-full rounded-lg border-2 border-gray-200 bg-white px-4 py-3 text-sm text-gray-900 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black",
className
)}
{...props}
/>
));
Textarea.displayName = "Textarea";
export { Textarea };

View File

@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 6, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"rounded-lg bg-gray-900 px-3 py-2 text-xs font-semibold text-white soft-shadow-sm",
className
)}
{...props}
/>
));
TooltipContent.displayName = "TooltipContent";
export { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent };

View File

@@ -1,23 +0,0 @@
// NOTE:
// Orval-generated hooks already return strongly-typed arrays for most endpoints.
// We keep only the Activity type + a tiny normalizer here because Activity is not
// currently generated as a model.
export type Activity = {
id?: number;
actor_employee_id?: number | null;
entity_type?: string;
entity_id?: number | null;
verb?: string;
payload?: unknown;
created_at?: string;
};
export function normalizeActivities(data: unknown): Activity[] {
if (Array.isArray(data)) return data as Activity[];
if (data && typeof data === "object" && "data" in data) {
const maybe = (data as { data?: unknown }).data;
if (Array.isArray(maybe)) return maybe as Activity[];
}
return [];
}

10
frontend/src/proxy.ts Normal file
View File

@@ -0,0 +1,10 @@
import { clerkMiddleware } from "@clerk/nextjs/server";
export default clerkMiddleware();
export const config = {
matcher: [
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
],
};

View File

@@ -1,48 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
content: ["./src/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}"],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
fontFamily: {
heading: ["var(--font-heading)", "sans-serif"],
body: ["var(--font-body)", "sans-serif"],
},
},
},