feat(ui): add Task Trigger button + backend dispatch endpoint
This commit is contained in:
@@ -102,6 +102,32 @@ def create_task(
|
|||||||
return Task.model_validate(task)
|
return Task.model_validate(task)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/dispatch")
|
||||||
|
def dispatch_task(
|
||||||
|
task_id: int,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||||
|
):
|
||||||
|
task = session.get(Task, task_id)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
|
if task.assignee_employee_id is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Task has no assignee")
|
||||||
|
|
||||||
|
_validate_task_assignee(session, task.assignee_employee_id)
|
||||||
|
|
||||||
|
# Best-effort: enqueue an agent dispatch. This does not mutate the task.
|
||||||
|
background.add_task(
|
||||||
|
notify_openclaw,
|
||||||
|
session,
|
||||||
|
NotifyContext(event="task.assigned", actor_employee_id=actor_employee_id, task=task),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/tasks/{task_id}", response_model=Task)
|
@router.patch("/tasks/{task_id}", response_model=Task)
|
||||||
def update_task(
|
def update_task(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
|
|||||||
@@ -351,6 +351,122 @@ export const useCreateTaskTasksPost = <
|
|||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* @summary Dispatch Task
|
||||||
|
*/
|
||||||
|
export type dispatchTaskTasksTaskIdDispatchPostResponse200 = {
|
||||||
|
data: unknown;
|
||||||
|
status: 200;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type dispatchTaskTasksTaskIdDispatchPostResponse422 = {
|
||||||
|
data: HTTPValidationError;
|
||||||
|
status: 422;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type dispatchTaskTasksTaskIdDispatchPostResponseSuccess =
|
||||||
|
dispatchTaskTasksTaskIdDispatchPostResponse200 & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
export type dispatchTaskTasksTaskIdDispatchPostResponseError =
|
||||||
|
dispatchTaskTasksTaskIdDispatchPostResponse422 & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type dispatchTaskTasksTaskIdDispatchPostResponse =
|
||||||
|
| dispatchTaskTasksTaskIdDispatchPostResponseSuccess
|
||||||
|
| dispatchTaskTasksTaskIdDispatchPostResponseError;
|
||||||
|
|
||||||
|
export const getDispatchTaskTasksTaskIdDispatchPostUrl = (taskId: number) => {
|
||||||
|
return `/tasks/${taskId}/dispatch`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dispatchTaskTasksTaskIdDispatchPost = async (
|
||||||
|
taskId: number,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<dispatchTaskTasksTaskIdDispatchPostResponse> => {
|
||||||
|
return customFetch<dispatchTaskTasksTaskIdDispatchPostResponse>(
|
||||||
|
getDispatchTaskTasksTaskIdDispatchPostUrl(taskId),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDispatchTaskTasksTaskIdDispatchPostMutationOptions = <
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
|
||||||
|
TError,
|
||||||
|
{ taskId: number },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
|
||||||
|
TError,
|
||||||
|
{ taskId: number },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationKey = ["dispatchTaskTasksTaskIdDispatchPost"];
|
||||||
|
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 dispatchTaskTasksTaskIdDispatchPost>>,
|
||||||
|
{ taskId: number }
|
||||||
|
> = (props) => {
|
||||||
|
const { taskId } = props ?? {};
|
||||||
|
|
||||||
|
return dispatchTaskTasksTaskIdDispatchPost(taskId, requestOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DispatchTaskTasksTaskIdDispatchPostMutationResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type DispatchTaskTasksTaskIdDispatchPostMutationError =
|
||||||
|
HTTPValidationError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Dispatch Task
|
||||||
|
*/
|
||||||
|
export const useDispatchTaskTasksTaskIdDispatchPost = <
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
TContext = unknown,
|
||||||
|
>(
|
||||||
|
options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
|
||||||
|
TError,
|
||||||
|
{ taskId: number },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
|
||||||
|
TError,
|
||||||
|
{ taskId: number },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(
|
||||||
|
getDispatchTaskTasksTaskIdDispatchPostMutationOptions(options),
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* @summary Update Task
|
* @summary Update Task
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { useState } from "react";
|
|||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
import { Select } from "@/components/ui/select";
|
import { Select } from "@/components/ui/select";
|
||||||
@@ -14,15 +20,16 @@ import { useListProjectsProjectsGet } from "@/api/generated/projects/projects";
|
|||||||
import { useListEmployeesEmployeesGet } from "@/api/generated/org/org";
|
import { useListEmployeesEmployeesGet } from "@/api/generated/org/org";
|
||||||
import {
|
import {
|
||||||
useCreateTaskTasksPost,
|
useCreateTaskTasksPost,
|
||||||
|
useDeleteTaskTasksTaskIdDelete,
|
||||||
|
useDispatchTaskTasksTaskIdDispatchPost,
|
||||||
|
useListTaskCommentsTaskCommentsGet,
|
||||||
useListTasksTasksGet,
|
useListTasksTasksGet,
|
||||||
useUpdateTaskTasksTaskIdPatch,
|
useUpdateTaskTasksTaskIdPatch,
|
||||||
useDeleteTaskTasksTaskIdDelete,
|
|
||||||
useCreateTaskCommentTaskCommentsPost,
|
useCreateTaskCommentTaskCommentsPost,
|
||||||
useListTaskCommentsTaskCommentsGet,
|
|
||||||
} from "@/api/generated/work/work";
|
} from "@/api/generated/work/work";
|
||||||
import {
|
import {
|
||||||
useListProjectMembersProjectsProjectIdMembersGet,
|
|
||||||
useAddProjectMemberProjectsProjectIdMembersPost,
|
useAddProjectMemberProjectsProjectIdMembersPost,
|
||||||
|
useListProjectMembersProjectsProjectIdMembersGet,
|
||||||
useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete,
|
useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete,
|
||||||
useUpdateProjectMemberProjectsProjectIdMembersMemberIdPatch,
|
useUpdateProjectMemberProjectsProjectIdMembersMemberIdPatch,
|
||||||
} from "@/api/generated/projects/projects";
|
} from "@/api/generated/projects/projects";
|
||||||
@@ -51,7 +58,10 @@ export default function ProjectDetailPage() {
|
|||||||
|
|
||||||
const employees = useListEmployeesEmployeesGet();
|
const employees = useListEmployeesEmployeesGet();
|
||||||
const employeeList = employees.data?.status === 200 ? employees.data.data : [];
|
const employeeList = employees.data?.status === 200 ? employees.data.data : [];
|
||||||
const eligibleAssignees = employeeList.filter((e) => e.employee_type !== "agent" || !!e.openclaw_session_key);
|
|
||||||
|
const eligibleAssignees = employeeList.filter(
|
||||||
|
(e) => e.employee_type !== "agent" || !!e.openclaw_session_key,
|
||||||
|
);
|
||||||
|
|
||||||
const members = useListProjectMembersProjectsProjectIdMembersGet(projectId);
|
const members = useListProjectMembersProjectsProjectIdMembersGet(projectId);
|
||||||
const memberList = members.data?.status === 200 ? members.data.data : [];
|
const memberList = members.data?.status === 200 ? members.data.data : [];
|
||||||
@@ -76,6 +86,11 @@ export default function ProjectDetailPage() {
|
|||||||
const deleteTask = useDeleteTaskTasksTaskIdDelete({
|
const deleteTask = useDeleteTaskTasksTaskIdDelete({
|
||||||
mutation: { onSuccess: () => tasks.refetch() },
|
mutation: { onSuccess: () => tasks.refetch() },
|
||||||
});
|
});
|
||||||
|
const dispatchTask = useDispatchTaskTasksTaskIdDispatchPost({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: () => tasks.refetch(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
@@ -110,6 +125,11 @@ export default function ProjectDetailPage() {
|
|||||||
return map;
|
return map;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const employeeById = new Map<number, (typeof employeeList)[number]>();
|
||||||
|
for (const e of employeeList) {
|
||||||
|
if (e.id != null) employeeById.set(Number(e.id), e);
|
||||||
|
}
|
||||||
|
|
||||||
const employeeName = (id: number | null | undefined) =>
|
const employeeName = (id: number | null | undefined) =>
|
||||||
employeeList.find((e) => e.id === id)?.name ?? "—";
|
employeeList.find((e) => e.id === id)?.name ?? "—";
|
||||||
|
|
||||||
@@ -128,16 +148,40 @@ export default function ProjectDetailPage() {
|
|||||||
{projects.isLoading || employees.isLoading || members.isLoading || tasks.isLoading ? (
|
{projects.isLoading || employees.isLoading || members.isLoading || tasks.isLoading ? (
|
||||||
<div className="mb-4 text-sm text-muted-foreground">Loading…</div>
|
<div className="mb-4 text-sm text-muted-foreground">Loading…</div>
|
||||||
) : null}
|
) : null}
|
||||||
{projects.error ? <div className="mb-4 text-sm text-destructive">{(projects.error as Error).message}</div> : null}
|
{projects.error ? (
|
||||||
{employees.error ? <div className="mb-4 text-sm text-destructive">{(employees.error as Error).message}</div> : null}
|
<div className="mb-4 text-sm text-destructive">
|
||||||
{members.error ? <div className="mb-4 text-sm text-destructive">{(members.error as Error).message}</div> : null}
|
{(projects.error as Error).message}
|
||||||
{tasks.error ? <div className="mb-4 text-sm text-destructive">{(tasks.error as Error).message}</div> : null}
|
</div>
|
||||||
|
) : null}
|
||||||
|
{employees.error ? (
|
||||||
|
<div className="mb-4 text-sm text-destructive">
|
||||||
|
{(employees.error as Error).message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{members.error ? (
|
||||||
|
<div className="mb-4 text-sm text-destructive">{(members.error as Error).message}</div>
|
||||||
|
) : null}
|
||||||
|
{tasks.error ? (
|
||||||
|
<div className="mb-4 text-sm text-destructive">{(tasks.error as Error).message}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{project?.name ?? `Project #${projectId}`}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Project detail: staffing + tasks.</p>
|
{project?.name ?? `Project #${projectId}`}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Project detail: staffing + tasks.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={() => { tasks.refetch(); members.refetch(); }} disabled={tasks.isFetching || members.isFetching}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
tasks.refetch();
|
||||||
|
members.refetch();
|
||||||
|
}}
|
||||||
|
disabled={tasks.isFetching || members.isFetching}
|
||||||
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,14 +193,31 @@ export default function ProjectDetailPage() {
|
|||||||
<CardDescription>Project-scoped tasks</CardDescription>
|
<CardDescription>Project-scoped tasks</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{createTask.error ? <div className="text-sm text-destructive">{(createTask.error as Error).message}</div> : null}
|
{createTask.error ? (
|
||||||
<Input placeholder="Title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
<div className="text-sm text-destructive">
|
||||||
<Textarea placeholder="Description" value={description} onChange={(e) => setDescription(e.target.value)} />
|
{(createTask.error as Error).message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Input
|
||||||
|
placeholder="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
<Select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)}>
|
<Select
|
||||||
|
value={assigneeId}
|
||||||
|
onChange={(e) => setAssigneeId(e.target.value)}
|
||||||
|
>
|
||||||
<option value="">Assignee</option>
|
<option value="">Assignee</option>
|
||||||
{eligibleAssignees.map((e) => (
|
{eligibleAssignees.map((e) => (
|
||||||
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
|
<option key={e.id ?? e.name} value={e.id ?? ""}>
|
||||||
|
{e.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,28 +246,43 @@ export default function ProjectDetailPage() {
|
|||||||
<CardDescription>Project members</CardDescription>
|
<CardDescription>Project members</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<Select onChange={(e) => {
|
<Select
|
||||||
|
onChange={(e) => {
|
||||||
const empId = e.target.value;
|
const empId = e.target.value;
|
||||||
if (!empId) return;
|
if (!empId) return;
|
||||||
addMember.mutate({ projectId, data: { project_id: projectId, employee_id: Number(empId), role: "member" } });
|
addMember.mutate({
|
||||||
|
projectId,
|
||||||
|
data: { project_id: projectId, employee_id: Number(empId), role: "member" },
|
||||||
|
});
|
||||||
e.currentTarget.value = "";
|
e.currentTarget.value = "";
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<option value="">Add member…</option>
|
<option value="">Add member…</option>
|
||||||
{eligibleAssignees.map((e) => (
|
{eligibleAssignees.map((e) => (
|
||||||
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
|
<option key={e.id ?? e.name} value={e.id ?? ""}>
|
||||||
|
{e.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
{addMember.error ? (
|
{addMember.error ? (
|
||||||
<div className="text-xs text-destructive">{(addMember.error as Error).message}</div>
|
<div className="text-xs text-destructive">
|
||||||
|
{(addMember.error as Error).message}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{projectMembers.map((m) => (
|
{projectMembers.map((m) => (
|
||||||
<li key={m.id ?? `${m.project_id}-${m.employee_id}`} className="rounded-md border p-2 text-sm">
|
<li
|
||||||
|
key={m.id ?? `${m.project_id}-${m.employee_id}`}
|
||||||
|
className="rounded-md border p-2 text-sm"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div>{employeeName(m.employee_id)}</div>
|
<div>{employeeName(m.employee_id)}</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => { if (m.id == null) return; removeMember.mutate({ projectId, memberId: Number(m.id) }); }}
|
onClick={() => {
|
||||||
|
if (m.id == null) return;
|
||||||
|
removeMember.mutate({ projectId, memberId: Number(m.id) });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
@@ -216,17 +292,25 @@ export default function ProjectDetailPage() {
|
|||||||
placeholder="Role (e.g., PM, QA, Dev)"
|
placeholder="Role (e.g., PM, QA, Dev)"
|
||||||
defaultValue={m.role ?? ""}
|
defaultValue={m.role ?? ""}
|
||||||
onBlur={(e) =>
|
onBlur={(e) =>
|
||||||
m.id == null ? undefined : updateMember.mutate({
|
m.id == null
|
||||||
|
? undefined
|
||||||
|
: updateMember.mutate({
|
||||||
projectId,
|
projectId,
|
||||||
memberId: Number(m.id),
|
memberId: Number(m.id),
|
||||||
data: { project_id: projectId, employee_id: m.employee_id, role: e.currentTarget.value || null },
|
data: {
|
||||||
|
project_id: projectId,
|
||||||
|
employee_id: m.employee_id,
|
||||||
|
role: e.currentTarget.value || null,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{projectMembers.length === 0 ? <li className="text-sm text-muted-foreground">No members yet.</li> : null}
|
{projectMembers.length === 0 ? (
|
||||||
|
<li className="text-sm text-muted-foreground">No members yet.</li>
|
||||||
|
) : null}
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -237,36 +321,93 @@ export default function ProjectDetailPage() {
|
|||||||
{STATUSES.map((s) => (
|
{STATUSES.map((s) => (
|
||||||
<Card key={s}>
|
<Card key={s}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm uppercase tracking-wide">{s.replace("_", " ")}</CardTitle>
|
<CardTitle className="text-sm uppercase tracking-wide">
|
||||||
|
{s.replace("_", " ")}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>{tasksByStatus.get(s)?.length ?? 0} tasks</CardDescription>
|
<CardDescription>{tasksByStatus.get(s)?.length ?? 0} tasks</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{(tasksByStatus.get(s) ?? []).map((t) => (
|
{(tasksByStatus.get(s) ?? []).map((t) => {
|
||||||
|
const assignee =
|
||||||
|
t.assignee_employee_id != null
|
||||||
|
? employeeById.get(Number(t.assignee_employee_id))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const canTrigger = Boolean(
|
||||||
|
t.id != null &&
|
||||||
|
assignee &&
|
||||||
|
assignee.employee_type === "agent" &&
|
||||||
|
assignee.openclaw_session_key,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={t.id ?? t.title} className="rounded-md border p-2 text-sm">
|
<div key={t.id ?? t.title} className="rounded-md border p-2 text-sm">
|
||||||
<div className="font-medium">{t.title}</div>
|
<div className="font-medium">{t.title}</div>
|
||||||
<div className="text-xs text-muted-foreground">Assignee: {employeeName(t.assignee_employee_id)}</div>
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Assignee: {employeeName(t.assignee_employee_id)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
{STATUSES.filter((x) => x !== s).map((x) => (
|
{STATUSES.filter((x) => x !== s).map((x) => (
|
||||||
<Button
|
<Button
|
||||||
key={x}
|
key={x}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => updateTask.mutate({ taskId: Number(t.id), data: { status: x } })}
|
onClick={() =>
|
||||||
|
updateTask.mutate({
|
||||||
|
taskId: Number(t.id),
|
||||||
|
data: { status: x },
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{x}
|
{x}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => { setCommentTaskId(Number(t.id)); setReplyToCommentId(null); }}>
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setCommentTaskId(Number(t.id));
|
||||||
|
setReplyToCommentId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
Comments
|
Comments
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" size="sm" onClick={() => deleteTask.mutate({ taskId: Number(t.id) })}>
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => dispatchTask.mutate({ taskId: Number(t.id) })}
|
||||||
|
disabled={!canTrigger || dispatchTask.isPending}
|
||||||
|
title={
|
||||||
|
canTrigger
|
||||||
|
? "Send a dispatch message to the assigned agent"
|
||||||
|
: "Only available when the assignee is a provisioned agent"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Trigger
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteTask.mutate({ taskId: Number(t.id) })}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{dispatchTask.error ? (
|
||||||
|
<div className="mt-2 text-xs text-destructive">
|
||||||
|
{(dispatchTask.error as Error).message}
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{(tasksByStatus.get(s) ?? []).length === 0 ? (
|
{(tasksByStatus.get(s) ?? []).length === 0 ? (
|
||||||
<div className="text-xs text-muted-foreground">No tasks</div>
|
<div className="text-xs text-muted-foreground">No tasks</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -283,12 +424,20 @@ export default function ProjectDetailPage() {
|
|||||||
<CardDescription>{commentTaskId ? `Task #${commentTaskId}` : "Select a task"}</CardDescription>
|
<CardDescription>{commentTaskId ? `Task #${commentTaskId}` : "Select a task"}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{addComment.error ? <div className="text-sm text-destructive">{(addComment.error as Error).message}</div> : null}
|
{addComment.error ? (
|
||||||
|
<div className="text-sm text-destructive">{(addComment.error as Error).message}</div>
|
||||||
|
) : null}
|
||||||
{replyToCommentId ? (
|
{replyToCommentId ? (
|
||||||
<div className="rounded-md border bg-muted/40 p-2 text-sm">
|
<div className="rounded-md border bg-muted/40 p-2 text-sm">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="text-xs text-muted-foreground">Replying to comment #{replyToCommentId}</div>
|
<div className="text-xs text-muted-foreground">
|
||||||
<Button variant="outline" size="sm" onClick={() => setReplyToCommentId(null)}>
|
Replying to comment #{replyToCommentId}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setReplyToCommentId(null)}
|
||||||
|
>
|
||||||
Cancel reply
|
Cancel reply
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -324,17 +473,25 @@ export default function ProjectDetailPage() {
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{employeeName(c.author_employee_id)}</div>
|
<div className="font-medium">{employeeName(c.author_employee_id)}</div>
|
||||||
<div className="text-xs text-muted-foreground">{(c.created_at ? new Date(c.created_at).toLocaleString() : "—")}</div>
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{c.created_at ? new Date(c.created_at).toLocaleString() : "—"}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => setReplyToCommentId(Number(c.id))}>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setReplyToCommentId(Number(c.id))}
|
||||||
|
>
|
||||||
Reply
|
Reply
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{(c.reply_to_comment_id ? (
|
{c.reply_to_comment_id ? (
|
||||||
<div className="mt-2 rounded-md border bg-muted/40 p-2 text-xs">
|
<div className="mt-2 rounded-md border bg-muted/40 p-2 text-xs">
|
||||||
<div className="text-muted-foreground">Replying to #{c.reply_to_comment_id}: {commentById.get(Number(c.reply_to_comment_id))?.body ?? "—"}</div>
|
<div className="text-muted-foreground">
|
||||||
|
Replying to #{c.reply_to_comment_id}: {commentById.get(Number(c.reply_to_comment_id))?.body ?? "—"}
|
||||||
</div>
|
</div>
|
||||||
) : null)}
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="mt-2">{c.body}</div>
|
<div className="mt-2">{c.body}</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user