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
@@ -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 };
}
@@ -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
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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[];
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
-40
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";
@@ -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;
};
@@ -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;
};
@@ -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;
};
@@ -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;
};
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
-19
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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
-13
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;
}
@@ -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;
}
@@ -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;
}
@@ -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
+13 -34
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;
};
@@ -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}}
-72
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>
);
}
+627
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>
);
}
+310
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>
);
}
+135
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>
);
}
+202
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>
);
}
+50
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>
);
}
-143
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

+84 -58
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;
}
-211
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>
);
}
+30 -15
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>
);
}
-141
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;
}
}
+6 -164
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>
);
}
-207
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>
);
}
-539
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>
);
}
-103
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>
);
}
-23
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>;
}
-150
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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
+5 -5
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
+20 -22
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";
+17 -33
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 };
+102
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,
};
+12 -16
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";
-20
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 };
+139 -11
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,
};
+52
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 };
+13 -16
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 };
+28
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 };
-23
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
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)(.*)",
],
};