feat: implement task dependencies with validation and update handling
This commit is contained in:
11
frontend/src/api/generated/model/blockedTaskDetail.ts
Normal file
11
frontend/src/api/generated/model/blockedTaskDetail.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v8.2.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface BlockedTaskDetail {
|
||||
blocked_by_task_ids?: string[];
|
||||
message: string;
|
||||
}
|
||||
11
frontend/src/api/generated/model/blockedTaskError.ts
Normal file
11
frontend/src/api/generated/model/blockedTaskError.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v8.2.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { BlockedTaskDetail } from "./blockedTaskDetail";
|
||||
|
||||
export interface BlockedTaskError {
|
||||
detail: BlockedTaskDetail;
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export * from "./approvalReadPayload";
|
||||
export * from "./approvalReadRubricScores";
|
||||
export * from "./approvalReadStatus";
|
||||
export * from "./approvalUpdate";
|
||||
export * from "./blockedTaskDetail";
|
||||
export * from "./blockedTaskError";
|
||||
export * from "./boardCreate";
|
||||
export * from "./boardCreateSuccessMetrics";
|
||||
export * from "./boardMemoryCreate";
|
||||
|
||||
@@ -11,13 +11,16 @@ export interface TaskCardRead {
|
||||
approvals_pending_count?: number;
|
||||
assigned_agent_id?: string | null;
|
||||
assignee?: string | null;
|
||||
blocked_by_task_ids?: string[];
|
||||
board_id: string | null;
|
||||
created_at: string;
|
||||
created_by_user_id: string | null;
|
||||
depends_on_task_ids?: string[];
|
||||
description?: string | null;
|
||||
due_at?: string | null;
|
||||
id: string;
|
||||
in_progress_at: string | null;
|
||||
is_blocked?: boolean;
|
||||
priority?: string;
|
||||
status?: TaskCardReadStatus;
|
||||
title: string;
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { TaskCreateStatus } from "./taskCreateStatus";
|
||||
export interface TaskCreate {
|
||||
assigned_agent_id?: string | null;
|
||||
created_by_user_id?: string | null;
|
||||
depends_on_task_ids?: string[];
|
||||
description?: string | null;
|
||||
due_at?: string | null;
|
||||
priority?: string;
|
||||
|
||||
@@ -8,13 +8,16 @@ import type { TaskReadStatus } from "./taskReadStatus";
|
||||
|
||||
export interface TaskRead {
|
||||
assigned_agent_id?: string | null;
|
||||
blocked_by_task_ids?: string[];
|
||||
board_id: string | null;
|
||||
created_at: string;
|
||||
created_by_user_id: string | null;
|
||||
depends_on_task_ids?: string[];
|
||||
description?: string | null;
|
||||
due_at?: string | null;
|
||||
id: string;
|
||||
in_progress_at: string | null;
|
||||
is_blocked?: boolean;
|
||||
priority?: string;
|
||||
status?: TaskReadStatus;
|
||||
title: string;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
export interface TaskUpdate {
|
||||
assigned_agent_id?: string | null;
|
||||
comment?: string | null;
|
||||
depends_on_task_ids?: string[] | null;
|
||||
description?: string | null;
|
||||
due_at?: string | null;
|
||||
priority?: string | null;
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import type {
|
||||
BlockedTaskError,
|
||||
HTTPValidationError,
|
||||
LimitOffsetPageTypeVarCustomizedTaskCommentRead,
|
||||
LimitOffsetPageTypeVarCustomizedTaskRead,
|
||||
@@ -278,6 +279,11 @@ export type createTaskApiV1BoardsBoardIdTasksPostResponse200 = {
|
||||
status: 200;
|
||||
};
|
||||
|
||||
export type createTaskApiV1BoardsBoardIdTasksPostResponse409 = {
|
||||
data: BlockedTaskError;
|
||||
status: 409;
|
||||
};
|
||||
|
||||
export type createTaskApiV1BoardsBoardIdTasksPostResponse422 = {
|
||||
data: HTTPValidationError;
|
||||
status: 422;
|
||||
@@ -287,10 +293,12 @@ export type createTaskApiV1BoardsBoardIdTasksPostResponseSuccess =
|
||||
createTaskApiV1BoardsBoardIdTasksPostResponse200 & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type createTaskApiV1BoardsBoardIdTasksPostResponseError =
|
||||
createTaskApiV1BoardsBoardIdTasksPostResponse422 & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type createTaskApiV1BoardsBoardIdTasksPostResponseError = (
|
||||
| createTaskApiV1BoardsBoardIdTasksPostResponse409
|
||||
| createTaskApiV1BoardsBoardIdTasksPostResponse422
|
||||
) & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type createTaskApiV1BoardsBoardIdTasksPostResponse =
|
||||
| createTaskApiV1BoardsBoardIdTasksPostResponseSuccess
|
||||
@@ -319,7 +327,7 @@ export const createTaskApiV1BoardsBoardIdTasksPost = async (
|
||||
};
|
||||
|
||||
export const getCreateTaskApiV1BoardsBoardIdTasksPostMutationOptions = <
|
||||
TError = HTTPValidationError,
|
||||
TError = BlockedTaskError | HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
@@ -361,13 +369,14 @@ export type CreateTaskApiV1BoardsBoardIdTasksPostMutationResult = NonNullable<
|
||||
>;
|
||||
export type CreateTaskApiV1BoardsBoardIdTasksPostMutationBody = TaskCreate;
|
||||
export type CreateTaskApiV1BoardsBoardIdTasksPostMutationError =
|
||||
HTTPValidationError;
|
||||
| BlockedTaskError
|
||||
| HTTPValidationError;
|
||||
|
||||
/**
|
||||
* @summary Create Task
|
||||
*/
|
||||
export const useCreateTaskApiV1BoardsBoardIdTasksPost = <
|
||||
TError = HTTPValidationError,
|
||||
TError = BlockedTaskError | HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(
|
||||
options?: {
|
||||
@@ -776,6 +785,11 @@ export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse200 = {
|
||||
status: 200;
|
||||
};
|
||||
|
||||
export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse409 = {
|
||||
data: BlockedTaskError;
|
||||
status: 409;
|
||||
};
|
||||
|
||||
export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse422 = {
|
||||
data: HTTPValidationError;
|
||||
status: 422;
|
||||
@@ -785,10 +799,12 @@ export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseSuccess =
|
||||
updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse200 & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseError =
|
||||
updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse422 & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseError = (
|
||||
| updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse409
|
||||
| updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse422
|
||||
) & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse =
|
||||
| updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseSuccess
|
||||
@@ -819,7 +835,7 @@ export const updateTaskApiV1BoardsBoardIdTasksTaskIdPatch = async (
|
||||
};
|
||||
|
||||
export const getUpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationOptions = <
|
||||
TError = HTTPValidationError,
|
||||
TError = BlockedTaskError | HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
@@ -868,13 +884,14 @@ export type UpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationResult =
|
||||
export type UpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationBody =
|
||||
TaskUpdate;
|
||||
export type UpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationError =
|
||||
HTTPValidationError;
|
||||
| BlockedTaskError
|
||||
| HTTPValidationError;
|
||||
|
||||
/**
|
||||
* @summary Update Task
|
||||
*/
|
||||
export const useUpdateTaskApiV1BoardsBoardIdTasksTaskIdPatch = <
|
||||
TError = HTTPValidationError,
|
||||
TError = BlockedTaskError | HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(
|
||||
options?: {
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import DropdownSelect, {
|
||||
type DropdownSelectOption,
|
||||
} from "@/components/ui/dropdown-select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -406,6 +409,9 @@ export default function BoardDetailPage() {
|
||||
const [editStatus, setEditStatus] = useState<TaskStatus>("inbox");
|
||||
const [editPriority, setEditPriority] = useState("medium");
|
||||
const [editAssigneeId, setEditAssigneeId] = useState("");
|
||||
const [editDependsOnTaskIds, setEditDependsOnTaskIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [isSavingTask, setIsSavingTask] = useState(false);
|
||||
const [saveTaskError, setSaveTaskError] = useState<string | null>(null);
|
||||
|
||||
@@ -796,6 +802,7 @@ export default function BoardDetailPage() {
|
||||
setEditStatus("inbox");
|
||||
setEditPriority("medium");
|
||||
setEditAssigneeId("");
|
||||
setEditDependsOnTaskIds([]);
|
||||
setSaveTaskError(null);
|
||||
return;
|
||||
}
|
||||
@@ -804,6 +811,7 @@ export default function BoardDetailPage() {
|
||||
setEditStatus(selectedTask.status);
|
||||
setEditPriority(selectedTask.priority);
|
||||
setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
|
||||
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
|
||||
setSaveTaskError(null);
|
||||
}, [selectedTask]);
|
||||
|
||||
@@ -1165,6 +1173,14 @@ export default function BoardDetailPage() {
|
||||
return map;
|
||||
}, [tasks]);
|
||||
|
||||
const taskById = useMemo(() => {
|
||||
const map = new Map<string, Task>();
|
||||
tasks.forEach((task) => {
|
||||
map.set(task.id, task);
|
||||
});
|
||||
return map;
|
||||
}, [tasks]);
|
||||
|
||||
const orderedLiveFeed = useMemo(() => {
|
||||
return [...liveFeed].sort((a, b) => {
|
||||
const aTime = new Date(a.created_at).getTime();
|
||||
@@ -1178,21 +1194,51 @@ export default function BoardDetailPage() {
|
||||
[agents],
|
||||
);
|
||||
|
||||
const dependencyOptions = useMemo<DropdownSelectOption[]>(() => {
|
||||
if (!selectedTask) return [];
|
||||
const alreadySelected = new Set(editDependsOnTaskIds);
|
||||
return tasks
|
||||
.filter((task) => task.id !== selectedTask.id)
|
||||
.map((task) => ({
|
||||
value: task.id,
|
||||
label: `${task.title} (${task.status.replace(/_/g, " ")})`,
|
||||
disabled: alreadySelected.has(task.id),
|
||||
}));
|
||||
}, [editDependsOnTaskIds, selectedTask, tasks]);
|
||||
|
||||
const addTaskDependency = useCallback((dependencyId: string) => {
|
||||
setEditDependsOnTaskIds((prev) =>
|
||||
prev.includes(dependencyId) ? prev : [...prev, dependencyId],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const removeTaskDependency = useCallback((dependencyId: string) => {
|
||||
setEditDependsOnTaskIds((prev) =>
|
||||
prev.filter((value) => value !== dependencyId),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const hasTaskChanges = useMemo(() => {
|
||||
if (!selectedTask) return false;
|
||||
const normalizedTitle = editTitle.trim();
|
||||
const normalizedDescription = editDescription.trim();
|
||||
const currentDescription = (selectedTask.description ?? "").trim();
|
||||
const currentAssignee = selectedTask.assigned_agent_id ?? "";
|
||||
const currentDeps = [...(selectedTask.depends_on_task_ids ?? [])]
|
||||
.sort()
|
||||
.join("|");
|
||||
const nextDeps = [...editDependsOnTaskIds].sort().join("|");
|
||||
return (
|
||||
normalizedTitle !== selectedTask.title ||
|
||||
normalizedDescription !== currentDescription ||
|
||||
editStatus !== selectedTask.status ||
|
||||
editPriority !== selectedTask.priority ||
|
||||
editAssigneeId !== currentAssignee
|
||||
editAssigneeId !== currentAssignee ||
|
||||
currentDeps !== nextDeps
|
||||
);
|
||||
}, [
|
||||
editAssigneeId,
|
||||
editDependsOnTaskIds,
|
||||
editDescription,
|
||||
editPriority,
|
||||
editStatus,
|
||||
@@ -1348,18 +1394,49 @@ export default function BoardDetailPage() {
|
||||
setIsSavingTask(true);
|
||||
setSaveTaskError(null);
|
||||
try {
|
||||
const currentDeps = [...(selectedTask.depends_on_task_ids ?? [])]
|
||||
.sort()
|
||||
.join("|");
|
||||
const nextDeps = [...editDependsOnTaskIds].sort().join("|");
|
||||
const depsChanged = currentDeps !== nextDeps;
|
||||
|
||||
const updatePayload: Parameters<
|
||||
typeof updateTaskApiV1BoardsBoardIdTasksTaskIdPatch
|
||||
>[2] = {
|
||||
title: trimmedTitle,
|
||||
description: editDescription.trim() || null,
|
||||
status: editStatus,
|
||||
priority: editPriority,
|
||||
assigned_agent_id: editAssigneeId || null,
|
||||
};
|
||||
|
||||
if (depsChanged && selectedTask.status !== "done") {
|
||||
updatePayload.depends_on_task_ids = editDependsOnTaskIds;
|
||||
}
|
||||
|
||||
const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch(
|
||||
boardId,
|
||||
selectedTask.id,
|
||||
{
|
||||
title: trimmedTitle,
|
||||
description: editDescription.trim() || null,
|
||||
status: editStatus,
|
||||
priority: editPriority,
|
||||
assigned_agent_id: editAssigneeId || null,
|
||||
},
|
||||
updatePayload,
|
||||
);
|
||||
if (result.status !== 200) throw new Error("Unable to update task.");
|
||||
if (result.status === 409) {
|
||||
const blockedIds = result.data.detail.blocked_by_task_ids ?? [];
|
||||
const blockedTitles = blockedIds
|
||||
.map((id) => taskTitleById.get(id) ?? id)
|
||||
.join(", ");
|
||||
setSaveTaskError(
|
||||
blockedTitles
|
||||
? `${result.data.detail.message} Blocked by: ${blockedTitles}`
|
||||
: result.data.detail.message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (result.status === 422) {
|
||||
setSaveTaskError(
|
||||
result.data.detail?.[0]?.msg ?? "Validation error while saving task.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const previous =
|
||||
tasksRef.current.find((task) => task.id === selectedTask.id) ??
|
||||
selectedTask;
|
||||
@@ -1393,6 +1470,7 @@ export default function BoardDetailPage() {
|
||||
setEditStatus(selectedTask.status);
|
||||
setEditPriority(selectedTask.priority);
|
||||
setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
|
||||
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
|
||||
setSaveTaskError(null);
|
||||
};
|
||||
|
||||
@@ -1422,6 +1500,10 @@ export default function BoardDetailPage() {
|
||||
if (!isSignedIn || !boardId) return;
|
||||
const currentTask = tasksRef.current.find((task) => task.id === taskId);
|
||||
if (!currentTask || currentTask.status === status) return;
|
||||
if (currentTask.is_blocked && status !== "inbox") {
|
||||
setError("Task is blocked by incomplete dependencies.");
|
||||
return;
|
||||
}
|
||||
const previousTasks = tasksRef.current;
|
||||
setTasks((prev) =>
|
||||
prev.map((task) =>
|
||||
@@ -1442,7 +1524,22 @@ export default function BoardDetailPage() {
|
||||
taskId,
|
||||
{ status },
|
||||
);
|
||||
if (result.status !== 200) throw new Error("Unable to move task.");
|
||||
if (result.status === 409) {
|
||||
const blockedIds = result.data.detail.blocked_by_task_ids ?? [];
|
||||
const blockedTitles = blockedIds
|
||||
.map((id) => taskTitleById.get(id) ?? id)
|
||||
.join(", ");
|
||||
throw new Error(
|
||||
blockedTitles
|
||||
? `${result.data.detail.message} Blocked by: ${blockedTitles}`
|
||||
: result.data.detail.message,
|
||||
);
|
||||
}
|
||||
if (result.status === 422) {
|
||||
throw new Error(
|
||||
result.data.detail?.[0]?.msg ?? "Validation error while moving task.",
|
||||
);
|
||||
}
|
||||
const assignee = result.data.assigned_agent_id
|
||||
? agentsRef.current.find((agent) => agent.id === result.data.assigned_agent_id)
|
||||
?.name ?? null
|
||||
@@ -1461,7 +1558,7 @@ export default function BoardDetailPage() {
|
||||
setTasks(previousTasks);
|
||||
setError(err instanceof Error ? err.message : "Unable to move task.");
|
||||
}
|
||||
}, [boardId, isSignedIn]);
|
||||
}, [boardId, isSignedIn, taskTitleById]);
|
||||
|
||||
const agentInitials = (agent: Agent) =>
|
||||
agent.name
|
||||
@@ -1980,6 +2077,68 @@ export default function BoardDetailPage() {
|
||||
<p className="text-sm text-slate-500">No description provided.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Dependencies
|
||||
</p>
|
||||
{selectedTask?.depends_on_task_ids?.length ? (
|
||||
<div className="space-y-2">
|
||||
{selectedTask.depends_on_task_ids.map((depId) => {
|
||||
const depTask = taskById.get(depId);
|
||||
const title = depTask?.title ?? depId;
|
||||
const statusLabel = depTask?.status
|
||||
? depTask.status.replace(/_/g, " ")
|
||||
: "unknown";
|
||||
const isDone = depTask?.status === "done";
|
||||
const isBlocking = (
|
||||
selectedTask.blocked_by_task_ids ?? []
|
||||
).includes(depId);
|
||||
return (
|
||||
<button
|
||||
key={depId}
|
||||
type="button"
|
||||
onClick={() => openComments({ id: depId })}
|
||||
disabled={!depTask}
|
||||
className={cn(
|
||||
"w-full rounded-lg border px-3 py-2 text-left transition",
|
||||
isBlocking
|
||||
? "border-rose-200 bg-rose-50 hover:bg-rose-100/40"
|
||||
: isDone
|
||||
? "border-emerald-200 bg-emerald-50 hover:bg-emerald-100/40"
|
||||
: "border-slate-200 bg-white hover:bg-slate-50",
|
||||
!depTask && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
{title}
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-semibold uppercase tracking-wide",
|
||||
isBlocking
|
||||
? "text-rose-700"
|
||||
: isDone
|
||||
? "text-emerald-700"
|
||||
: "text-slate-500",
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">No dependencies.</p>
|
||||
)}
|
||||
{selectedTask?.is_blocked ? (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
|
||||
Blocked by incomplete dependencies.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
@@ -2333,6 +2492,73 @@ export default function BoardDetailPage() {
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Dependencies
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">
|
||||
Tasks stay blocked until every dependency is marked done.
|
||||
</p>
|
||||
<DropdownSelect
|
||||
ariaLabel="Add dependency"
|
||||
placeholder="Add dependency"
|
||||
options={dependencyOptions}
|
||||
onValueChange={addTaskDependency}
|
||||
disabled={
|
||||
!selectedTask ||
|
||||
isSavingTask ||
|
||||
selectedTask.status === "done"
|
||||
}
|
||||
emptyMessage="No other tasks found."
|
||||
/>
|
||||
{selectedTask?.status === "done" ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
Dependencies can only be edited until the task is done.
|
||||
</p>
|
||||
) : null}
|
||||
{editDependsOnTaskIds.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">No dependencies.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{editDependsOnTaskIds.map((depId) => {
|
||||
const depTask = taskById.get(depId);
|
||||
const label = depTask?.title ?? depId;
|
||||
const statusLabel = depTask?.status
|
||||
? depTask.status.replace(/_/g, " ")
|
||||
: null;
|
||||
const isDone = depTask?.status === "done";
|
||||
return (
|
||||
<span
|
||||
key={depId}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs",
|
||||
isDone
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
: "border-slate-200 bg-slate-50 text-slate-700",
|
||||
)}
|
||||
>
|
||||
<span className="max-w-[18rem] truncate">{label}</span>
|
||||
{statusLabel ? (
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
{statusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{selectedTask?.status !== "done" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTaskDependency(depId)}
|
||||
className="rounded-full p-0.5 text-slate-500 transition hover:bg-white hover:text-slate-700"
|
||||
aria-label="Remove dependency"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{saveTaskError ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3 text-xs text-slate-600">
|
||||
{saveTaskError}
|
||||
|
||||
@@ -8,6 +8,8 @@ interface TaskCardProps {
|
||||
assignee?: string;
|
||||
due?: string;
|
||||
approvalsPendingCount?: number;
|
||||
isBlocked?: boolean;
|
||||
blockedByCount?: number;
|
||||
onClick?: () => void;
|
||||
draggable?: boolean;
|
||||
isDragging?: boolean;
|
||||
@@ -21,6 +23,8 @@ export function TaskCard({
|
||||
assignee,
|
||||
due,
|
||||
approvalsPendingCount = 0,
|
||||
isBlocked = false,
|
||||
blockedByCount = 0,
|
||||
onClick,
|
||||
draggable = false,
|
||||
isDragging = false,
|
||||
@@ -28,6 +32,11 @@ export function TaskCard({
|
||||
onDragEnd,
|
||||
}: TaskCardProps) {
|
||||
const hasPendingApproval = approvalsPendingCount > 0;
|
||||
const leftBarClassName = isBlocked
|
||||
? "bg-rose-400"
|
||||
: hasPendingApproval
|
||||
? "bg-amber-400"
|
||||
: null;
|
||||
const priorityBadge = (value?: string) => {
|
||||
if (!value) return null;
|
||||
const normalized = value.toLowerCase();
|
||||
@@ -51,6 +60,7 @@ export function TaskCard({
|
||||
"group relative cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md",
|
||||
isDragging && "opacity-60 shadow-none",
|
||||
hasPendingApproval && "border-amber-200 bg-amber-50/40",
|
||||
isBlocked && "border-rose-200 bg-rose-50/50",
|
||||
)}
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
@@ -65,12 +75,23 @@ export function TaskCard({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasPendingApproval ? (
|
||||
<span className="absolute left-0 top-0 h-full w-1 rounded-l-lg bg-amber-400" />
|
||||
{leftBarClassName ? (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-0 top-0 h-full w-1 rounded-l-lg",
|
||||
leftBarClassName,
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-slate-900">{title}</p>
|
||||
{isBlocked ? (
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-rose-700">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-rose-500" />
|
||||
Blocked{blockedByCount > 0 ? ` · ${blockedByCount}` : ""}
|
||||
</div>
|
||||
) : null}
|
||||
{hasPendingApproval ? (
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||
|
||||
@@ -17,6 +17,9 @@ type Task = {
|
||||
assigned_agent_id?: string | null;
|
||||
assignee?: string | null;
|
||||
approvals_pending_count?: number;
|
||||
depends_on_task_ids?: string[];
|
||||
blocked_by_task_ids?: string[];
|
||||
is_blocked?: boolean;
|
||||
};
|
||||
|
||||
type TaskBoardProps = {
|
||||
@@ -253,6 +256,10 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
|
||||
const handleDragStart =
|
||||
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (task.is_blocked) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
setDraggingId(task.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData(
|
||||
@@ -342,8 +349,10 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
assignee={task.assignee ?? undefined}
|
||||
due={formatDueDate(task.due_at)}
|
||||
approvalsPendingCount={task.approvals_pending_count}
|
||||
isBlocked={task.is_blocked}
|
||||
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
|
||||
onClick={() => onTaskSelect?.(task)}
|
||||
draggable
|
||||
draggable={!task.is_blocked}
|
||||
isDragging={draggingId === task.id}
|
||||
onDragStart={handleDragStart(task)}
|
||||
onDragEnd={handleDragEnd}
|
||||
|
||||
Reference in New Issue
Block a user