Add project staffing endpoints and project detail Kanban UI

This commit is contained in:
Abhimanyu Saharan
2026-02-01 23:46:14 +05:30
parent 5b4257ef33
commit f0e065abcd
17 changed files with 1589 additions and 2 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}