Add project staffing endpoints and project detail Kanban UI
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,7 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.api.utils import log_activity
|
||||
from app.db.session import get_session
|
||||
from app.models.projects import Project
|
||||
from app.models.projects import Project, ProjectMember
|
||||
from app.schemas.projects import ProjectCreate, ProjectUpdate
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
@@ -43,3 +43,47 @@ def update_project(project_id: int, payload: ProjectUpdate, session: Session = D
|
||||
log_activity(session, actor_employee_id=None, entity_type="project", entity_id=proj.id, verb="updated", payload=data)
|
||||
session.commit()
|
||||
return proj
|
||||
|
||||
|
||||
@router.get("/{project_id}/members", response_model=list[ProjectMember])
|
||||
def list_project_members(project_id: int, session: Session = Depends(get_session)):
|
||||
return session.exec(
|
||||
select(ProjectMember).where(ProjectMember.project_id == project_id).order_by(ProjectMember.id.asc())
|
||||
).all()
|
||||
|
||||
|
||||
@router.post("/{project_id}/members", response_model=ProjectMember)
|
||||
def add_project_member(project_id: int, payload: ProjectMember, session: Session = Depends(get_session)):
|
||||
member = ProjectMember(project_id=project_id, employee_id=payload.employee_id, role=payload.role)
|
||||
session.add(member)
|
||||
session.commit()
|
||||
session.refresh(member)
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=None,
|
||||
entity_type="project_member",
|
||||
entity_id=member.id,
|
||||
verb="added",
|
||||
payload={"project_id": project_id, "employee_id": member.employee_id},
|
||||
)
|
||||
session.commit()
|
||||
return member
|
||||
|
||||
|
||||
@router.delete("/{project_id}/members/{member_id}")
|
||||
def remove_project_member(project_id: int, member_id: int, session: Session = Depends(get_session)):
|
||||
member = session.get(ProjectMember, member_id)
|
||||
if not member or member.project_id != project_id:
|
||||
raise HTTPException(status_code=404, detail="Project member not found")
|
||||
session.delete(member)
|
||||
session.commit()
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=None,
|
||||
entity_type="project_member",
|
||||
entity_id=member_id,
|
||||
verb="removed",
|
||||
payload={"project_id": project_id},
|
||||
)
|
||||
session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -12,6 +12,8 @@ from app.schemas.work import TaskCommentCreate, TaskCreate, TaskUpdate
|
||||
|
||||
router = APIRouter(tags=["work"])
|
||||
|
||||
ALLOWED_STATUSES = {"backlog", "ready", "in_progress", "review", "done", "blocked"}
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[Task])
|
||||
def list_tasks(project_id: int | None = None, session: Session = Depends(get_session)):
|
||||
@@ -24,11 +26,20 @@ def list_tasks(project_id: int | None = None, session: Session = Depends(get_ses
|
||||
@router.post("/tasks", response_model=Task)
|
||||
def create_task(payload: TaskCreate, session: Session = Depends(get_session)):
|
||||
task = Task(**payload.model_dump())
|
||||
if task.status not in ALLOWED_STATUSES:
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
task.updated_at = datetime.utcnow()
|
||||
session.add(task)
|
||||
session.commit()
|
||||
session.refresh(task)
|
||||
log_activity(session, actor_employee_id=task.created_by_employee_id, entity_type="task", entity_id=task.id, verb="created", payload={"project_id": task.project_id, "title": task.title})
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=task.created_by_employee_id,
|
||||
entity_type="task",
|
||||
entity_id=task.id,
|
||||
verb="created",
|
||||
payload={"project_id": task.project_id, "title": task.title},
|
||||
)
|
||||
session.commit()
|
||||
return task
|
||||
|
||||
@@ -40,6 +51,8 @@ def update_task(task_id: int, payload: TaskUpdate, session: Session = Depends(ge
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "status" in data and data["status"] not in ALLOWED_STATUSES:
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
for k, v in data.items():
|
||||
setattr(task, k, v)
|
||||
task.updated_at = datetime.utcnow()
|
||||
|
||||
@@ -22,6 +22,7 @@ export * from "./listTaskCommentsTaskCommentsGetParams";
|
||||
export * from "./listTasksTasksGetParams";
|
||||
export * from "./project";
|
||||
export * from "./projectCreate";
|
||||
export * from "./projectMember";
|
||||
export * from "./projectUpdate";
|
||||
export * from "./task";
|
||||
export * from "./taskComment";
|
||||
|
||||
13
frontend/src/api/generated/model/projectMember.ts
Normal file
13
frontend/src/api/generated/model/projectMember.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
HTTPValidationError,
|
||||
Project,
|
||||
ProjectCreate,
|
||||
ProjectMember,
|
||||
ProjectUpdate,
|
||||
} from ".././model";
|
||||
|
||||
@@ -440,3 +441,522 @@ export const useUpdateProjectProjectsProjectIdPatch = <
|
||||
queryClient,
|
||||
);
|
||||
};
|
||||
/**
|
||||
* @summary List Project Members
|
||||
*/
|
||||
export type listProjectMembersProjectsProjectIdMembersGetResponse200 = {
|
||||
data: ProjectMember[];
|
||||
status: 200;
|
||||
};
|
||||
|
||||
export type listProjectMembersProjectsProjectIdMembersGetResponse422 = {
|
||||
data: HTTPValidationError;
|
||||
status: 422;
|
||||
};
|
||||
|
||||
export type listProjectMembersProjectsProjectIdMembersGetResponseSuccess =
|
||||
listProjectMembersProjectsProjectIdMembersGetResponse200 & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type listProjectMembersProjectsProjectIdMembersGetResponseError =
|
||||
listProjectMembersProjectsProjectIdMembersGetResponse422 & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type listProjectMembersProjectsProjectIdMembersGetResponse =
|
||||
| listProjectMembersProjectsProjectIdMembersGetResponseSuccess
|
||||
| listProjectMembersProjectsProjectIdMembersGetResponseError;
|
||||
|
||||
export const getListProjectMembersProjectsProjectIdMembersGetUrl = (
|
||||
projectId: number,
|
||||
) => {
|
||||
return `/projects/${projectId}/members`;
|
||||
};
|
||||
|
||||
export const listProjectMembersProjectsProjectIdMembersGet = async (
|
||||
projectId: number,
|
||||
options?: RequestInit,
|
||||
): Promise<listProjectMembersProjectsProjectIdMembersGetResponse> => {
|
||||
return customFetch<listProjectMembersProjectsProjectIdMembersGetResponse>(
|
||||
getListProjectMembersProjectsProjectIdMembersGetUrl(projectId),
|
||||
{
|
||||
...options,
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getListProjectMembersProjectsProjectIdMembersGetQueryKey = (
|
||||
projectId: number,
|
||||
) => {
|
||||
return [`/projects/${projectId}/members`] as const;
|
||||
};
|
||||
|
||||
export const getListProjectMembersProjectsProjectIdMembersGetQueryOptions = <
|
||||
TData = Awaited<
|
||||
ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>
|
||||
>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
projectId: number,
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<
|
||||
ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>
|
||||
>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getListProjectMembersProjectsProjectIdMembersGetQueryKey(projectId);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>>
|
||||
> = ({ signal }) =>
|
||||
listProjectMembersProjectsProjectIdMembersGet(projectId, {
|
||||
signal,
|
||||
...requestOptions,
|
||||
});
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!projectId,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
};
|
||||
|
||||
export type ListProjectMembersProjectsProjectIdMembersGetQueryResult =
|
||||
NonNullable<
|
||||
Awaited<ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>>
|
||||
>;
|
||||
export type ListProjectMembersProjectsProjectIdMembersGetQueryError =
|
||||
HTTPValidationError;
|
||||
|
||||
export function useListProjectMembersProjectsProjectIdMembersGet<
|
||||
TData = Awaited<
|
||||
ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>
|
||||
>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
projectId: number,
|
||||
options: {
|
||||
query: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<
|
||||
ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>
|
||||
>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
> &
|
||||
Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<
|
||||
ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>
|
||||
>,
|
||||
TError,
|
||||
Awaited<
|
||||
ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>
|
||||
>
|
||||
>,
|
||||
"initialData"
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): DefinedUseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
export function useListProjectMembersProjectsProjectIdMembersGet<
|
||||
TData = Awaited<
|
||||
ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>
|
||||
>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
projectId: number,
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<
|
||||
ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>
|
||||
>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
> &
|
||||
Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<
|
||||
ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>
|
||||
>,
|
||||
TError,
|
||||
Awaited<
|
||||
ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>
|
||||
>
|
||||
>,
|
||||
"initialData"
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
export function useListProjectMembersProjectsProjectIdMembersGet<
|
||||
TData = Awaited<
|
||||
ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>
|
||||
>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
projectId: number,
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<
|
||||
ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>
|
||||
>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
/**
|
||||
* @summary List Project Members
|
||||
*/
|
||||
|
||||
export function useListProjectMembersProjectsProjectIdMembersGet<
|
||||
TData = Awaited<
|
||||
ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>
|
||||
>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
projectId: number,
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<
|
||||
ReturnType<typeof listProjectMembersProjectsProjectIdMembersGet>
|
||||
>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
} {
|
||||
const queryOptions =
|
||||
getListProjectMembersProjectsProjectIdMembersGetQueryOptions(
|
||||
projectId,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
|
||||
TData,
|
||||
TError
|
||||
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Add Project Member
|
||||
*/
|
||||
export type addProjectMemberProjectsProjectIdMembersPostResponse200 = {
|
||||
data: ProjectMember;
|
||||
status: 200;
|
||||
};
|
||||
|
||||
export type addProjectMemberProjectsProjectIdMembersPostResponse422 = {
|
||||
data: HTTPValidationError;
|
||||
status: 422;
|
||||
};
|
||||
|
||||
export type addProjectMemberProjectsProjectIdMembersPostResponseSuccess =
|
||||
addProjectMemberProjectsProjectIdMembersPostResponse200 & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type addProjectMemberProjectsProjectIdMembersPostResponseError =
|
||||
addProjectMemberProjectsProjectIdMembersPostResponse422 & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type addProjectMemberProjectsProjectIdMembersPostResponse =
|
||||
| addProjectMemberProjectsProjectIdMembersPostResponseSuccess
|
||||
| addProjectMemberProjectsProjectIdMembersPostResponseError;
|
||||
|
||||
export const getAddProjectMemberProjectsProjectIdMembersPostUrl = (
|
||||
projectId: number,
|
||||
) => {
|
||||
return `/projects/${projectId}/members`;
|
||||
};
|
||||
|
||||
export const addProjectMemberProjectsProjectIdMembersPost = async (
|
||||
projectId: number,
|
||||
projectMember: ProjectMember,
|
||||
options?: RequestInit,
|
||||
): Promise<addProjectMemberProjectsProjectIdMembersPostResponse> => {
|
||||
return customFetch<addProjectMemberProjectsProjectIdMembersPostResponse>(
|
||||
getAddProjectMemberProjectsProjectIdMembersPostUrl(projectId),
|
||||
{
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(projectMember),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getAddProjectMemberProjectsProjectIdMembersPostMutationOptions = <
|
||||
TError = HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof addProjectMemberProjectsProjectIdMembersPost>>,
|
||||
TError,
|
||||
{ projectId: number; data: ProjectMember },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof addProjectMemberProjectsProjectIdMembersPost>>,
|
||||
TError,
|
||||
{ projectId: number; data: ProjectMember },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["addProjectMemberProjectsProjectIdMembersPost"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof addProjectMemberProjectsProjectIdMembersPost>>,
|
||||
{ projectId: number; data: ProjectMember }
|
||||
> = (props) => {
|
||||
const { projectId, data } = props ?? {};
|
||||
|
||||
return addProjectMemberProjectsProjectIdMembersPost(
|
||||
projectId,
|
||||
data,
|
||||
requestOptions,
|
||||
);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type AddProjectMemberProjectsProjectIdMembersPostMutationResult =
|
||||
NonNullable<
|
||||
Awaited<ReturnType<typeof addProjectMemberProjectsProjectIdMembersPost>>
|
||||
>;
|
||||
export type AddProjectMemberProjectsProjectIdMembersPostMutationBody =
|
||||
ProjectMember;
|
||||
export type AddProjectMemberProjectsProjectIdMembersPostMutationError =
|
||||
HTTPValidationError;
|
||||
|
||||
/**
|
||||
* @summary Add Project Member
|
||||
*/
|
||||
export const useAddProjectMemberProjectsProjectIdMembersPost = <
|
||||
TError = HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(
|
||||
options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof addProjectMemberProjectsProjectIdMembersPost>>,
|
||||
TError,
|
||||
{ projectId: number; data: ProjectMember },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseMutationResult<
|
||||
Awaited<ReturnType<typeof addProjectMemberProjectsProjectIdMembersPost>>,
|
||||
TError,
|
||||
{ projectId: number; data: ProjectMember },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getAddProjectMemberProjectsProjectIdMembersPostMutationOptions(options),
|
||||
queryClient,
|
||||
);
|
||||
};
|
||||
/**
|
||||
* @summary Remove Project Member
|
||||
*/
|
||||
export type removeProjectMemberProjectsProjectIdMembersMemberIdDeleteResponse200 =
|
||||
{
|
||||
data: unknown;
|
||||
status: 200;
|
||||
};
|
||||
|
||||
export type removeProjectMemberProjectsProjectIdMembersMemberIdDeleteResponse422 =
|
||||
{
|
||||
data: HTTPValidationError;
|
||||
status: 422;
|
||||
};
|
||||
|
||||
export type removeProjectMemberProjectsProjectIdMembersMemberIdDeleteResponseSuccess =
|
||||
removeProjectMemberProjectsProjectIdMembersMemberIdDeleteResponse200 & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type removeProjectMemberProjectsProjectIdMembersMemberIdDeleteResponseError =
|
||||
removeProjectMemberProjectsProjectIdMembersMemberIdDeleteResponse422 & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type removeProjectMemberProjectsProjectIdMembersMemberIdDeleteResponse =
|
||||
| removeProjectMemberProjectsProjectIdMembersMemberIdDeleteResponseSuccess
|
||||
| removeProjectMemberProjectsProjectIdMembersMemberIdDeleteResponseError;
|
||||
|
||||
export const getRemoveProjectMemberProjectsProjectIdMembersMemberIdDeleteUrl = (
|
||||
projectId: number,
|
||||
memberId: number,
|
||||
) => {
|
||||
return `/projects/${projectId}/members/${memberId}`;
|
||||
};
|
||||
|
||||
export const removeProjectMemberProjectsProjectIdMembersMemberIdDelete = async (
|
||||
projectId: number,
|
||||
memberId: number,
|
||||
options?: RequestInit,
|
||||
): Promise<removeProjectMemberProjectsProjectIdMembersMemberIdDeleteResponse> => {
|
||||
return customFetch<removeProjectMemberProjectsProjectIdMembersMemberIdDeleteResponse>(
|
||||
getRemoveProjectMemberProjectsProjectIdMembersMemberIdDeleteUrl(
|
||||
projectId,
|
||||
memberId,
|
||||
),
|
||||
{
|
||||
...options,
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getRemoveProjectMemberProjectsProjectIdMembersMemberIdDeleteMutationOptions =
|
||||
<TError = HTTPValidationError, TContext = unknown>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<
|
||||
ReturnType<
|
||||
typeof removeProjectMemberProjectsProjectIdMembersMemberIdDelete
|
||||
>
|
||||
>,
|
||||
TError,
|
||||
{ projectId: number; memberId: number },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<
|
||||
ReturnType<
|
||||
typeof removeProjectMemberProjectsProjectIdMembersMemberIdDelete
|
||||
>
|
||||
>,
|
||||
TError,
|
||||
{ projectId: number; memberId: number },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = [
|
||||
"removeProjectMemberProjectsProjectIdMembersMemberIdDelete",
|
||||
];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<
|
||||
ReturnType<
|
||||
typeof removeProjectMemberProjectsProjectIdMembersMemberIdDelete
|
||||
>
|
||||
>,
|
||||
{ projectId: number; memberId: number }
|
||||
> = (props) => {
|
||||
const { projectId, memberId } = props ?? {};
|
||||
|
||||
return removeProjectMemberProjectsProjectIdMembersMemberIdDelete(
|
||||
projectId,
|
||||
memberId,
|
||||
requestOptions,
|
||||
);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type RemoveProjectMemberProjectsProjectIdMembersMemberIdDeleteMutationResult =
|
||||
NonNullable<
|
||||
Awaited<
|
||||
ReturnType<
|
||||
typeof removeProjectMemberProjectsProjectIdMembersMemberIdDelete
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
export type RemoveProjectMemberProjectsProjectIdMembersMemberIdDeleteMutationError =
|
||||
HTTPValidationError;
|
||||
|
||||
/**
|
||||
* @summary Remove Project Member
|
||||
*/
|
||||
export const useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete = <
|
||||
TError = HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(
|
||||
options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<
|
||||
ReturnType<
|
||||
typeof removeProjectMemberProjectsProjectIdMembersMemberIdDelete
|
||||
>
|
||||
>,
|
||||
TError,
|
||||
{ projectId: number; memberId: number },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseMutationResult<
|
||||
Awaited<
|
||||
ReturnType<typeof removeProjectMemberProjectsProjectIdMembersMemberIdDelete>
|
||||
>,
|
||||
TError,
|
||||
{ projectId: number; memberId: number },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getRemoveProjectMemberProjectsProjectIdMembersMemberIdDeleteMutationOptions(
|
||||
options,
|
||||
),
|
||||
queryClient,
|
||||
);
|
||||
};
|
||||
|
||||
143
frontend/src/app/departments/page.tsx
Normal file
143
frontend/src/app/departments/page.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"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 {
|
||||
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 employees = useListEmployeesEmployeesGet();
|
||||
|
||||
const createDepartment = useCreateDepartmentDepartmentsPost({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setHeadId("");
|
||||
departments.refetch();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const updateDepartment = useUpdateDepartmentDepartmentsDepartmentIdPatch({
|
||||
mutation: {
|
||||
onSuccess: () => departments.refetch(),
|
||||
},
|
||||
});
|
||||
|
||||
const sortedEmployees = useMemo(() => {
|
||||
return (employees.data ?? []).slice().sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
|
||||
}, [employees.data]);
|
||||
|
||||
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">
|
||||
<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}
|
||||
>
|
||||
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>{(departments.data ?? []).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">
|
||||
{(departments.data ?? []).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
|
||||
value={d.head_employee_id ? String(d.head_employee_id) : ""}
|
||||
onChange={(e) =>
|
||||
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>
|
||||
))}
|
||||
{(departments.data ?? []).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>
|
||||
);
|
||||
}
|
||||
206
frontend/src/app/hr/page.tsx
Normal file
206
frontend/src/app/hr/page.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
"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 { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import {
|
||||
useCreateHeadcountRequestHrHeadcountPost,
|
||||
useCreateEmploymentActionHrActionsPost,
|
||||
useListHeadcountRequestsHrHeadcountGet,
|
||||
useListEmploymentActionsHrActionsGet,
|
||||
} from "@/api/generated/hr/hr";
|
||||
import { useListDepartmentsDepartmentsGet, useListEmployeesEmployeesGet } from "@/api/generated/org/org";
|
||||
|
||||
export default function HRPage() {
|
||||
const departments = useListDepartmentsDepartmentsGet();
|
||||
const employees = useListEmployeesEmployeesGet();
|
||||
|
||||
const headcount = useListHeadcountRequestsHrHeadcountGet();
|
||||
const actions = useListEmploymentActionsHrActionsGet();
|
||||
|
||||
const [hcDeptId, setHcDeptId] = useState<string>("");
|
||||
const [hcManagerId, setHcManagerId] = useState<string>("");
|
||||
const [hcRole, setHcRole] = useState("");
|
||||
const [hcType, setHcType] = useState<"human" | "agent">("human");
|
||||
const [hcQty, setHcQty] = useState("1");
|
||||
const [hcJust, setHcJust] = useState("");
|
||||
|
||||
const [actEmployeeId, setActEmployeeId] = useState<string>("");
|
||||
const [actIssuerId, setActIssuerId] = useState<string>("");
|
||||
const [actType, setActType] = useState("praise");
|
||||
const [actNotes, setActNotes] = useState("");
|
||||
|
||||
const createHeadcount = useCreateHeadcountRequestHrHeadcountPost({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
setHcRole("");
|
||||
setHcJust("");
|
||||
setHcQty("1");
|
||||
headcount.refetch();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createAction = useCreateEmploymentActionHrActionsPost({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
setActNotes("");
|
||||
actions.refetch();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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">HR</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Headcount requests and employment actions.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => { headcount.refetch(); actions.refetch(); }}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Headcount request</CardTitle>
|
||||
<CardDescription>Managers request; HR fulfills later.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Select value={hcDeptId} onChange={(e) => setHcDeptId(e.target.value)}>
|
||||
<option value="">Select department</option>
|
||||
{(departments.data ?? []).map((d) => (
|
||||
<option key={d.id ?? d.name} value={d.id ?? ""}>{d.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select value={hcManagerId} onChange={(e) => setHcManagerId(e.target.value)}>
|
||||
<option value="">Requesting manager</option>
|
||||
{(employees.data ?? []).map((e) => (
|
||||
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input placeholder="Role title" value={hcRole} onChange={(e) => setHcRole(e.target.value)} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Select value={hcType} onChange={(e) => setHcType(e.target.value === "agent" ? "agent" : "human")}>
|
||||
<option value="human">human</option>
|
||||
<option value="agent">agent</option>
|
||||
</Select>
|
||||
<Input placeholder="Quantity" value={hcQty} onChange={(e) => setHcQty(e.target.value)} />
|
||||
</div>
|
||||
<Textarea placeholder="Justification (optional)" value={hcJust} onChange={(e) => setHcJust(e.target.value)} />
|
||||
<Button
|
||||
onClick={() =>
|
||||
createHeadcount.mutate({
|
||||
data: {
|
||||
department_id: Number(hcDeptId),
|
||||
requested_by_manager_id: Number(hcManagerId),
|
||||
role_title: hcRole,
|
||||
employee_type: hcType,
|
||||
quantity: Number(hcQty || "1"),
|
||||
justification: hcJust.trim() ? hcJust : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={!hcDeptId || !hcManagerId || !hcRole.trim() || createHeadcount.isPending}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
{createHeadcount.error ? (
|
||||
<div className="text-sm text-destructive">{(createHeadcount.error as Error).message}</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Employment action</CardTitle>
|
||||
<CardDescription>Log HR actions (praise/warning/pip/termination).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Select value={actEmployeeId} onChange={(e) => setActEmployeeId(e.target.value)}>
|
||||
<option value="">Employee</option>
|
||||
{(employees.data ?? []).map((e) => (
|
||||
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select value={actIssuerId} onChange={(e) => setActIssuerId(e.target.value)}>
|
||||
<option value="">Issued by</option>
|
||||
{(employees.data ?? []).map((e) => (
|
||||
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select value={actType} onChange={(e) => setActType(e.target.value)}>
|
||||
<option value="praise">praise</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="pip">pip</option>
|
||||
<option value="termination">termination</option>
|
||||
</Select>
|
||||
<Textarea placeholder="Notes (optional)" value={actNotes} onChange={(e) => setActNotes(e.target.value)} />
|
||||
<Button
|
||||
onClick={() =>
|
||||
createAction.mutate({
|
||||
data: {
|
||||
employee_id: Number(actEmployeeId),
|
||||
issued_by_employee_id: Number(actIssuerId),
|
||||
action_type: actType,
|
||||
notes: actNotes.trim() ? actNotes : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={!actEmployeeId || !actIssuerId || createAction.isPending}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
{createAction.error ? (
|
||||
<div className="text-sm text-destructive">{(createAction.error as Error).message}</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="sm:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent HR activity</CardTitle>
|
||||
<CardDescription>Latest headcount + actions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-medium">Headcount requests</div>
|
||||
<ul className="space-y-2">
|
||||
{(headcount.data ?? []).slice(0, 10).map((r) => (
|
||||
<li key={String(r.id)} className="rounded-md border p-3 text-sm">
|
||||
<div className="font-medium">{r.role_title} × {r.quantity} ({r.employee_type})</div>
|
||||
<div className="text-xs text-muted-foreground">dept #{r.department_id} · status: {r.status}</div>
|
||||
</li>
|
||||
))}
|
||||
{(headcount.data ?? []).length === 0 ? (
|
||||
<li className="text-sm text-muted-foreground">None yet.</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-medium">Employment actions</div>
|
||||
<ul className="space-y-2">
|
||||
{(actions.data ?? []).slice(0, 10).map((a) => (
|
||||
<li key={String(a.id)} className="rounded-md border p-3 text-sm">
|
||||
<div className="font-medium">{a.action_type} → employee #{a.employee_id}</div>
|
||||
<div className="text-xs text-muted-foreground">issued by #{a.issued_by_employee_id}</div>
|
||||
</li>
|
||||
))}
|
||||
{(actions.data ?? []).length === 0 ? (
|
||||
<li className="text-sm text-muted-foreground">None yet.</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
156
frontend/src/app/people/page.tsx
Normal file
156
frontend/src/app/people/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"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,
|
||||
} from "@/api/generated/org/org";
|
||||
|
||||
export default function PeoplePage() {
|
||||
const [name, setName] = useState("");
|
||||
const [employeeType, setEmployeeType] = useState<"human" | "agent">("human");
|
||||
const [title, setTitle] = useState("");
|
||||
const [departmentId, setDepartmentId] = useState<string>("");
|
||||
const [managerId, setManagerId] = useState<string>("");
|
||||
|
||||
const employees = useListEmployeesEmployeesGet();
|
||||
const departments = useListDepartmentsDepartmentsGet();
|
||||
|
||||
const createEmployee = useCreateEmployeeEmployeesPost({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setTitle("");
|
||||
setDepartmentId("");
|
||||
setManagerId("");
|
||||
employees.refetch();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const deptNameById = useMemo(() => {
|
||||
const m = new Map<number, string>();
|
||||
for (const d of departments.data ?? []) {
|
||||
if (d.id != null) m.set(d.id, d.name);
|
||||
}
|
||||
return m;
|
||||
}, [departments.data]);
|
||||
|
||||
const empNameById = useMemo(() => {
|
||||
const m = new Map<number, string>();
|
||||
for (const e of employees.data ?? []) {
|
||||
if (e.id != null) m.set(e.id, e.name);
|
||||
}
|
||||
return m;
|
||||
}, [employees.data]);
|
||||
|
||||
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>
|
||||
{(departments.data ?? []).map((d) => (
|
||||
<option key={d.id ?? d.name} value={d.id ?? ""}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select value={managerId} onChange={(e) => setManagerId(e.target.value)}>
|
||||
<option value="">(no manager)</option>
|
||||
{(employees.data ?? []).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,
|
||||
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>{(employees.data ?? []).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">
|
||||
{(employees.data ?? []).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.manager_id ? <span>Mgr: {empNameById.get(e.manager_id) ?? `Emp#${e.manager_id}`}</span> : <span>No manager</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{(employees.data ?? []).length === 0 ? (
|
||||
<li className="text-sm text-muted-foreground">No people yet.</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
276
frontend/src/app/projects/[id]/page.tsx
Normal file
276
frontend/src/app/projects/[id]/page.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
"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,
|
||||
useListTasksTasksGet,
|
||||
useUpdateTaskTasksTaskIdPatch,
|
||||
useDeleteTaskTasksTaskIdDelete,
|
||||
useCreateTaskCommentTaskCommentsPost,
|
||||
useListTaskCommentsTaskCommentsGet,
|
||||
} from "@/api/generated/work/work";
|
||||
import {
|
||||
useListProjectMembersProjectsProjectIdMembersGet,
|
||||
useAddProjectMemberProjectsProjectIdMembersPost,
|
||||
useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete,
|
||||
} from "@/api/generated/projects/projects";
|
||||
|
||||
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 project = (projects.data ?? []).find((p) => p.id === projectId);
|
||||
|
||||
const employees = useListEmployeesEmployeesGet();
|
||||
|
||||
const members = useListProjectMembersProjectsProjectIdMembersGet(projectId);
|
||||
const addMember = useAddProjectMemberProjectsProjectIdMembersPost({
|
||||
mutation: { onSuccess: () => members.refetch() },
|
||||
});
|
||||
const removeMember = useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete({
|
||||
mutation: { onSuccess: () => members.refetch() },
|
||||
});
|
||||
|
||||
const tasks = useListTasksTasksGet({ projectId });
|
||||
const createTask = useCreateTaskTasksPost({
|
||||
mutation: { onSuccess: () => tasks.refetch() },
|
||||
});
|
||||
const updateTask = useUpdateTaskTasksTaskIdPatch({
|
||||
mutation: { onSuccess: () => tasks.refetch() },
|
||||
});
|
||||
const deleteTask = useDeleteTaskTasksTaskIdDelete({
|
||||
mutation: { onSuccess: () => tasks.refetch() },
|
||||
});
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [assigneeId, setAssigneeId] = useState<string>("");
|
||||
const [reviewerId, setReviewerId] = useState<string>("");
|
||||
|
||||
const [commentTaskId, setCommentTaskId] = useState<number | null>(null);
|
||||
const [commentBody, setCommentBody] = useState("");
|
||||
|
||||
const comments = useListTaskCommentsTaskCommentsGet(
|
||||
{ taskId: commentTaskId ?? 0 },
|
||||
{ query: { enabled: Boolean(commentTaskId) } },
|
||||
);
|
||||
const addComment = useCreateTaskCommentTaskCommentsPost({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
comments.refetch();
|
||||
setCommentBody("");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tasksByStatus = (() => {
|
||||
const map = new Map<string, typeof tasks.data>();
|
||||
for (const s of STATUSES) map.set(s, []);
|
||||
for (const t of tasks.data ?? []) {
|
||||
map.get(t.status)?.push(t);
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
const employeeName = (id: number | null | undefined) =>
|
||||
employees.data?.find((e) => e.id === id)?.name ?? "—";
|
||||
|
||||
const projectMembers = members.data ?? [];
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl p-6">
|
||||
<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(); }}>
|
||||
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">
|
||||
<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-2 gap-2">
|
||||
<Select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)}>
|
||||
<option value="">Assignee</option>
|
||||
{(employees.data ?? []).map((e) => (
|
||||
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select value={reviewerId} onChange={(e) => setReviewerId(e.target.value)}>
|
||||
<option value="">Reviewer</option>
|
||||
{(employees.data ?? []).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,
|
||||
reviewer_employee_id: reviewerId ? Number(reviewerId) : 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: null } });
|
||||
e.currentTarget.value = "";
|
||||
}}>
|
||||
<option value="">Add member…</option>
|
||||
{(employees.data ?? []).map((e) => (
|
||||
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
<ul className="space-y-2">
|
||||
{projectMembers.map((m) => (
|
||||
<li key={m.id ?? `${m.project_id}-${m.employee_id}`} className="flex items-center justify-between rounded-md border p-2 text-sm">
|
||||
<div>{employeeName(m.employee_id)}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => removeMember.mutate({ projectId, memberId: Number(m.id) })}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</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) => (
|
||||
<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 gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setCommentTaskId(Number(t.id))}>
|
||||
Comments
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => deleteTask.mutate({ taskId: Number(t.id) })}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</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">
|
||||
<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: null,
|
||||
body: commentBody,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={!commentTaskId || !commentBody.trim() || addComment.isPending}
|
||||
>
|
||||
Add comment
|
||||
</Button>
|
||||
<ul className="space-y-2">
|
||||
{(comments.data ?? []).map((c) => (
|
||||
<li key={String(c.id)} className="rounded-md border p-2 text-sm">
|
||||
{c.body}
|
||||
</li>
|
||||
))}
|
||||
{(comments.data ?? []).length === 0 ? (
|
||||
<li className="text-sm text-muted-foreground">No comments yet.</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
93
frontend/src/app/projects/page.tsx
Normal file
93
frontend/src/app/projects/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"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 {
|
||||
useCreateProjectProjectsPost,
|
||||
useListProjectsProjectsGet,
|
||||
} from "@/api/generated/projects/projects";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const projects = useListProjectsProjectsGet();
|
||||
const createProject = useCreateProjectProjectsPost({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
projects.refetch();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return (projects.data ?? []).slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [projects.data]);
|
||||
|
||||
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">Projects</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Create and manage projects.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => projects.refetch()} disabled={projects.isFetching}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create project</CardTitle>
|
||||
<CardDescription>Minimal fields for v1</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
placeholder="Project name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => createProject.mutate({ data: { name, status: "active" } })}
|
||||
disabled={!name.trim() || createProject.isPending}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
{createProject.error ? (
|
||||
<div className="text-sm text-destructive">{(createProject.error as Error).message}</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All projects</CardTitle>
|
||||
<CardDescription>{sorted.length} total</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{projects.isLoading ? <div className="text-sm text-muted-foreground">Loading…</div> : null}
|
||||
{projects.error ? (
|
||||
<div className="text-sm text-destructive">{(projects.error as Error).message}</div>
|
||||
) : null}
|
||||
{!projects.isLoading && !projects.error ? (
|
||||
<ul className="space-y-2">
|
||||
{sorted.map((p) => (
|
||||
<li key={p.id ?? p.name} className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{p.status}</div>
|
||||
</li>
|
||||
))}
|
||||
{sorted.length === 0 ? <li className="text-sm text-muted-foreground">No projects yet.</li> : null}
|
||||
</ul>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/ui/badge.tsx
Normal file
32
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
24
frontend/src/components/ui/input.tsx
Normal file
24
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
20
frontend/src/components/ui/label.tsx
Normal file
20
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
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 };
|
||||
23
frontend/src/components/ui/select.tsx
Normal file
23
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SelectProps = React.SelectHTMLAttributes<HTMLSelectElement>
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<select
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
);
|
||||
Select.displayName = "Select";
|
||||
|
||||
export { Select };
|
||||
23
frontend/src/components/ui/textarea.tsx
Normal file
23
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
Reference in New Issue
Block a user