feat: add boards and tasks management endpoints
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
# When accessing the frontend from another device, the browser will call this URL.
|
||||
# Use your machine IP (not 127.0.0.1).
|
||||
|
||||
NEXT_PUBLIC_API_URL=http://<YOUR_MACHINE_IP>:8000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
|
||||
CLERK_SECRET_KEY=YOUR_SECRET_KEY
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/boards
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/boards
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/boards
|
||||
|
||||
48
frontend/.gitignore
vendored
48
frontend/.gitignore
vendored
@@ -1,41 +1,9 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
.env
|
||||
out/
|
||||
dist/
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
# clerk configuration (can include secrets)
|
||||
/.clerk/
|
||||
|
||||
6
frontend/next-env.d.ts
vendored
Normal file
6
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
allowedDevOrigins: [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://192.168.1.101:3000",
|
||||
],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
1055
frontend/package-lock.json
generated
1055
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,13 @@
|
||||
"api:gen": "orval --config ./orval.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/nextjs": "^6.37.1",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 392 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 129 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 386 B |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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}}
|
||||
@@ -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 <span className={styles.kbd}>:8000</span> API
|
||||
</div>
|
||||
</aside>
|
||||
<div className={styles.main}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
627
frontend/src/app/agents/page.tsx
Normal file
627
frontend/src/app/agents/page.tsx
Normal 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
frontend/src/app/boards/[boardId]/page.tsx
Normal file
310
frontend/src/app/boards/[boardId]/page.tsx
Normal 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
frontend/src/app/boards/new/page.tsx
Normal file
135
frontend/src/app/boards/new/page.tsx
Normal 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
frontend/src/app/boards/page.tsx
Normal file
202
frontend/src/app/boards/page.tsx
Normal 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
frontend/src/app/dashboard/page.tsx
Normal file
50
frontend/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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. No‑auth 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/atoms/BrandMark.tsx
Normal file
17
frontend/src/components/atoms/BrandMark.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function BrandMark() {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg border-2 border-gray-200 bg-white text-sm font-bold text-gray-900 shadow-lush">
|
||||
<span className="font-heading tracking-[0.2em]">
|
||||
OC
|
||||
</span>
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="font-heading text-sm uppercase tracking-[0.28em] text-gray-600">
|
||||
OpenClaw
|
||||
</div>
|
||||
<div className="text-[11px] font-medium text-gray-500">Mission Control</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
frontend/src/components/atoms/HeroKicker.tsx
Normal file
9
frontend/src/components/atoms/HeroKicker.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function HeroKicker({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-4 py-1 text-[11px] font-semibold uppercase tracking-[0.35em] text-gray-600">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/atoms/StatusPill.tsx
Normal file
21
frontend/src/components/atoms/StatusPill.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const STATUS_STYLES: Record<string, "default" | "outline" | "ember"> = {
|
||||
inbox: "outline",
|
||||
assigned: "default",
|
||||
in_progress: "ember",
|
||||
testing: "outline",
|
||||
review: "default",
|
||||
done: "default",
|
||||
online: "default",
|
||||
busy: "ember",
|
||||
offline: "outline",
|
||||
};
|
||||
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
return (
|
||||
<Badge variant={STATUS_STYLES[status] ?? "default"}>
|
||||
{status.replace("_", " ")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/molecules/HeroCopy.tsx
Normal file
20
frontend/src/components/molecules/HeroCopy.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { HeroKicker } from "@/components/atoms/HeroKicker";
|
||||
|
||||
export function HeroCopy() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<HeroKicker>Mission Control</HeroKicker>
|
||||
<div className="space-y-4">
|
||||
<h1 className="font-heading text-4xl font-bold leading-tight text-gray-900 sm:text-5xl lg:text-6xl">
|
||||
Orchestrate work without
|
||||
<br />
|
||||
the daily status chase.
|
||||
</h1>
|
||||
<p className="max-w-xl text-base text-gray-600 sm:text-lg">
|
||||
OpenClaw keeps every task, agent, and delivery signal in one place so
|
||||
teams can spot momentum shifts fast.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/molecules/TaskCard.tsx
Normal file
38
frontend/src/components/molecules/TaskCard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { CalendarClock, UserCircle } from "lucide-react";
|
||||
|
||||
import { StatusPill } from "@/components/atoms/StatusPill";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface TaskCardProps {
|
||||
title: string;
|
||||
status: string;
|
||||
assignee?: string;
|
||||
due?: string;
|
||||
}
|
||||
|
||||
export function TaskCard({ title, status, assignee, due }: TaskCardProps) {
|
||||
return (
|
||||
<Card className="border-gray-200 bg-white">
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-gray-900">{title}</p>
|
||||
<StatusPill status={status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCircle className="h-4 w-4" />
|
||||
<span>{assignee ?? "Unassigned"}</span>
|
||||
</div>
|
||||
{due ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
<span>{due}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/organisms/DashboardSidebar.tsx
Normal file
42
frontend/src/components/organisms/DashboardSidebar.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="flex h-full flex-col gap-6 rounded-xl border-2 border-gray-200 bg-white p-6 shadow-lush">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-500">
|
||||
Work
|
||||
</p>
|
||||
<nav className="space-y-1 text-sm">
|
||||
<Link
|
||||
href="/agents"
|
||||
className={cn(
|
||||
"block rounded-lg border border-transparent px-3 py-2 font-medium text-gray-700 hover:border-gray-200 hover:bg-gray-50",
|
||||
pathname.startsWith("/agents") &&
|
||||
"border-gray-200 bg-gray-50 text-gray-900"
|
||||
)}
|
||||
>
|
||||
Agents
|
||||
</Link>
|
||||
<Link
|
||||
href="/boards"
|
||||
className={cn(
|
||||
"block rounded-lg border border-transparent px-3 py-2 font-medium text-gray-700 hover:border-gray-200 hover:bg-gray-50",
|
||||
pathname.startsWith("/boards") &&
|
||||
"border-gray-200 bg-gray-50 text-gray-900"
|
||||
)}
|
||||
>
|
||||
Boards
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/organisms/LandingHero.tsx
Normal file
88
frontend/src/components/organisms/LandingHero.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
|
||||
|
||||
import { HeroCopy } from "@/components/molecules/HeroCopy";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function LandingHero() {
|
||||
return (
|
||||
<section className="grid w-full items-center gap-10 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<div
|
||||
className="space-y-8 animate-fade-in-up"
|
||||
style={{ animationDelay: "0.05s" }}
|
||||
>
|
||||
<HeroCopy />
|
||||
<div
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-center animate-fade-in-up"
|
||||
style={{ animationDelay: "0.12s" }}
|
||||
>
|
||||
<SignedOut>
|
||||
<SignInButton
|
||||
mode="modal"
|
||||
afterSignInUrl="/boards"
|
||||
afterSignUpUrl="/boards"
|
||||
forceRedirectUrl="/boards"
|
||||
signUpForceRedirectUrl="/boards"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full sm:w-auto border-2 border-gray-900 bg-gray-900 text-white hover:bg-gray-900/90"
|
||||
>
|
||||
Sign in to open mission control
|
||||
</Button>
|
||||
</SignInButton>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<div className="text-sm text-gray-600">
|
||||
You're signed in. Open your boards when you'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>
|
||||
);
|
||||
}
|
||||
99
frontend/src/components/organisms/TaskBoard.tsx
Normal file
99
frontend/src/components/organisms/TaskBoard.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { TaskCard } from "@/components/molecules/TaskCard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
due_at?: string | null;
|
||||
};
|
||||
|
||||
type TaskBoardProps = {
|
||||
tasks: Task[];
|
||||
onCreateTask: () => void;
|
||||
isCreateDisabled?: boolean;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: "Inbox", status: "inbox" },
|
||||
{ title: "Assigned", status: "assigned" },
|
||||
{ title: "In Progress", status: "in_progress" },
|
||||
{ title: "Testing", status: "testing" },
|
||||
{ title: "Review", status: "review" },
|
||||
{ title: "Done", status: "done" },
|
||||
];
|
||||
|
||||
const formatDueDate = (value?: string | null) => {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
export function TaskBoard({
|
||||
tasks,
|
||||
onCreateTask,
|
||||
isCreateDisabled = false,
|
||||
}: TaskBoardProps) {
|
||||
const grouped = useMemo(() => {
|
||||
const buckets: Record<string, Task[]> = {};
|
||||
for (const column of columns) {
|
||||
buckets[column.status] = [];
|
||||
}
|
||||
tasks.forEach((task) => {
|
||||
const bucket = buckets[task.status] ?? buckets.inbox;
|
||||
bucket.push(task);
|
||||
});
|
||||
return buckets;
|
||||
}, [tasks]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-6 overflow-x-auto pb-2">
|
||||
{columns.map((column) => {
|
||||
const columnTasks = grouped[column.status] ?? [];
|
||||
return (
|
||||
<div key={column.title} className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
{column.title}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{columnTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{column.status === "inbox" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateTask}
|
||||
disabled={isCreateDisabled}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50 px-4 py-6 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 transition hover:border-gray-300 hover:bg-white",
|
||||
isCreateDisabled && "cursor-not-allowed opacity-60"
|
||||
)}
|
||||
>
|
||||
New task
|
||||
</button>
|
||||
) : null}
|
||||
{columnTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
title={task.title}
|
||||
status={column.status}
|
||||
due={formatDueDate(task.due_at)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/templates/DashboardShell.tsx
Normal file
31
frontend/src/components/templates/DashboardShell.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SignedIn, UserButton } from "@clerk/nextjs";
|
||||
|
||||
import { BrandMark } from "@/components/atoms/BrandMark";
|
||||
|
||||
export function DashboardShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="relative min-h-screen bg-white text-gray-900">
|
||||
<div
|
||||
className="absolute inset-0 bg-landing-grid opacity-[0.35] pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative flex min-h-screen w-full flex-col gap-8 px-6 pb-10 pt-8">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<BrandMark />
|
||||
<SignedIn>
|
||||
<div className="rounded-lg border-2 border-gray-200 bg-white px-2 py-1">
|
||||
<UserButton />
|
||||
</div>
|
||||
</SignedIn>
|
||||
</header>
|
||||
<div className="grid flex-1 gap-6 lg:grid-cols-[320px_1fr]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/components/templates/LandingShell.tsx
Normal file
40
frontend/src/components/templates/LandingShell.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SignedIn, UserButton } from "@clerk/nextjs";
|
||||
|
||||
import { BrandMark } from "@/components/atoms/BrandMark";
|
||||
|
||||
export function LandingShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="landing-page bg-white text-gray-900">
|
||||
<section className="relative overflow-hidden pt-24 pb-16 px-4 sm:px-6 lg:px-8">
|
||||
<div
|
||||
className="absolute inset-0 bg-landing-grid opacity-[0.35] pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-28 right-0 h-64 w-64 rounded-full bg-gray-100 blur-3xl pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-32 left-0 h-72 w-72 rounded-full bg-gray-100 blur-3xl pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative w-full">
|
||||
<header className="flex items-center justify-between pb-12">
|
||||
<BrandMark />
|
||||
<SignedIn>
|
||||
<div className="rounded-lg border-2 border-gray-200 bg-white px-2 py-1">
|
||||
<UserButton />
|
||||
</div>
|
||||
</SignedIn>
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
frontend/src/components/ui/dialog.tsx
Normal file
102
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
frontend/src/components/ui/tabs.tsx
Normal file
52
frontend/src/components/ui/tabs.tsx
Normal 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 };
|
||||
@@ -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
frontend/src/components/ui/tooltip.tsx
Normal file
28
frontend/src/components/ui/tooltip.tsx
Normal 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 };
|
||||
@@ -1,23 +0,0 @@
|
||||
// NOTE:
|
||||
// Orval-generated hooks already return strongly-typed arrays for most endpoints.
|
||||
// We keep only the Activity type + a tiny normalizer here because Activity is not
|
||||
// currently generated as a model.
|
||||
|
||||
export type Activity = {
|
||||
id?: number;
|
||||
actor_employee_id?: number | null;
|
||||
entity_type?: string;
|
||||
entity_id?: number | null;
|
||||
verb?: string;
|
||||
payload?: unknown;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export function normalizeActivities(data: unknown): Activity[] {
|
||||
if (Array.isArray(data)) return data as Activity[];
|
||||
if (data && typeof data === "object" && "data" in data) {
|
||||
const maybe = (data as { data?: unknown }).data;
|
||||
if (Array.isArray(maybe)) return maybe as Activity[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
10
frontend/src/proxy.ts
Normal file
10
frontend/src/proxy.ts
Normal 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)(.*)",
|
||||
],
|
||||
};
|
||||
@@ -1,48 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
content: ["./src/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
fontFamily: {
|
||||
heading: ["var(--font-heading)", "sans-serif"],
|
||||
body: ["var(--font-body)", "sans-serif"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user