diff --git a/backend/app/api/__pycache__/projects.cpython-312.pyc b/backend/app/api/__pycache__/projects.cpython-312.pyc index 6b231b8..00467cf 100644 Binary files a/backend/app/api/__pycache__/projects.cpython-312.pyc and b/backend/app/api/__pycache__/projects.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/work.cpython-312.pyc b/backend/app/api/__pycache__/work.cpython-312.pyc index 2eba431..e81425b 100644 Binary files a/backend/app/api/__pycache__/work.cpython-312.pyc and b/backend/app/api/__pycache__/work.cpython-312.pyc differ diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 16ad2c8..48b7249 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -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} diff --git a/backend/app/api/work.py b/backend/app/api/work.py index ae17b2d..02790d4 100644 --- a/backend/app/api/work.py +++ b/backend/app/api/work.py @@ -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() diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts index 7a5e58b..6e2da78 100644 --- a/frontend/src/api/generated/model/index.ts +++ b/frontend/src/api/generated/model/index.ts @@ -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"; diff --git a/frontend/src/api/generated/model/projectMember.ts b/frontend/src/api/generated/model/projectMember.ts new file mode 100644 index 0000000..2557248 --- /dev/null +++ b/frontend/src/api/generated/model/projectMember.ts @@ -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; +} diff --git a/frontend/src/api/generated/projects/projects.ts b/frontend/src/api/generated/projects/projects.ts index 489b332..440a31f 100644 --- a/frontend/src/api/generated/projects/projects.ts +++ b/frontend/src/api/generated/projects/projects.ts @@ -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 => { + return customFetch( + getListProjectMembersProjectsProjectIdMembersGetUrl(projectId), + { + ...options, + method: "GET", + }, + ); +}; + +export const getListProjectMembersProjectsProjectIdMembersGetQueryKey = ( + projectId: number, +) => { + return [`/projects/${projectId}/members`] as const; +}; + +export const getListProjectMembersProjectsProjectIdMembersGetQueryOptions = < + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + projectId: number, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getListProjectMembersProjectsProjectIdMembersGetQueryKey(projectId); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + listProjectMembersProjectsProjectIdMembersGet(projectId, { + signal, + ...requestOptions, + }); + + return { + queryKey, + queryFn, + enabled: !!projectId, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListProjectMembersProjectsProjectIdMembersGetQueryResult = + NonNullable< + Awaited> + >; +export type ListProjectMembersProjectsProjectIdMembersGetQueryError = + HTTPValidationError; + +export function useListProjectMembersProjectsProjectIdMembersGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + projectId: number, + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListProjectMembersProjectsProjectIdMembersGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + projectId: number, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListProjectMembersProjectsProjectIdMembersGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + projectId: number, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List Project Members + */ + +export function useListProjectMembersProjectsProjectIdMembersGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + projectId: number, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getListProjectMembersProjectsProjectIdMembersGetQueryOptions( + projectId, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + 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 => { + return customFetch( + 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>, + TError, + { projectId: number; data: ProjectMember }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { projectId: number; data: ProjectMember } + > = (props) => { + const { projectId, data } = props ?? {}; + + return addProjectMemberProjectsProjectIdMembersPost( + projectId, + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type AddProjectMemberProjectsProjectIdMembersPostMutationResult = + NonNullable< + Awaited> + >; +export type AddProjectMemberProjectsProjectIdMembersPostMutationBody = + ProjectMember; +export type AddProjectMemberProjectsProjectIdMembersPostMutationError = + HTTPValidationError; + +/** + * @summary Add Project Member + */ +export const useAddProjectMemberProjectsProjectIdMembersPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { projectId: number; data: ProjectMember }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + 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 => { + return customFetch( + getRemoveProjectMemberProjectsProjectIdMembersMemberIdDeleteUrl( + projectId, + memberId, + ), + { + ...options, + method: "DELETE", + }, + ); +}; + +export const getRemoveProjectMemberProjectsProjectIdMembersMemberIdDeleteMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof removeProjectMemberProjectsProjectIdMembersMemberIdDelete + > + >, + TError, + { projectId: number; memberId: number }, + TContext + >; + request?: SecondParameter; + }): 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; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited< + ReturnType + >, + TError, + { projectId: number; memberId: number }, + TContext +> => { + return useMutation( + getRemoveProjectMemberProjectsProjectIdMembersMemberIdDeleteMutationOptions( + options, + ), + queryClient, + ); +}; diff --git a/frontend/src/app/departments/page.tsx b/frontend/src/app/departments/page.tsx new file mode 100644 index 0000000..3ba5a6d --- /dev/null +++ b/frontend/src/app/departments/page.tsx @@ -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(""); + + 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 ( +
+
+
+

Departments

+

Create departments and assign department heads.

+
+ +
+ +
+ + + Create department + Optional head + + + setName(e.target.value)} /> + + + {createDepartment.error ? ( +
{(createDepartment.error as Error).message}
+ ) : null} +
+
+ + + + All departments + {(departments.data ?? []).length} total + + + {departments.isLoading ?
Loading…
: null} + {departments.error ? ( +
{(departments.error as Error).message}
+ ) : null} + {!departments.isLoading && !departments.error ? ( +
    + {(departments.data ?? []).map((d) => ( +
  • +
    +
    {d.name}
    +
    id: {d.id}
    +
    +
    + Head: + +
    +
  • + ))} + {(departments.data ?? []).length === 0 ? ( +
  • No departments yet.
  • + ) : null} +
+ ) : null} + {updateDepartment.error ? ( +
{(updateDepartment.error as Error).message}
+ ) : null} +
+
+
+
+ ); +} diff --git a/frontend/src/app/hr/page.tsx b/frontend/src/app/hr/page.tsx new file mode 100644 index 0000000..d5f35ce --- /dev/null +++ b/frontend/src/app/hr/page.tsx @@ -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(""); + const [hcManagerId, setHcManagerId] = useState(""); + const [hcRole, setHcRole] = useState(""); + const [hcType, setHcType] = useState<"human" | "agent">("human"); + const [hcQty, setHcQty] = useState("1"); + const [hcJust, setHcJust] = useState(""); + + const [actEmployeeId, setActEmployeeId] = useState(""); + const [actIssuerId, setActIssuerId] = useState(""); + 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 ( +
+
+
+

HR

+

Headcount requests and employment actions.

+
+ +
+ +
+ + + Headcount request + Managers request; HR fulfills later. + + + + + setHcRole(e.target.value)} /> +
+ + setHcQty(e.target.value)} /> +
+