feat: enhance task management with due date handling and mention support
This commit is contained in:
@@ -1,27 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const MENTION_MAX_OPTIONS = 8;
|
||||
const MENTION_PATTERN = /(?:^|\s)@([A-Za-z0-9_-]{0,31})$/;
|
||||
|
||||
type MentionTarget = {
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
type BoardChatComposerProps = {
|
||||
placeholder?: string;
|
||||
isSending?: boolean;
|
||||
disabled?: boolean;
|
||||
mentionSuggestions?: string[];
|
||||
onSend: (content: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const normalizeMentionHandle = (raw: string): string | null => {
|
||||
const trimmed = raw.trim().replace(/^@+/, "");
|
||||
if (!trimmed) return null;
|
||||
const token = trimmed.split(/\s+/)[0]?.replace(/[^A-Za-z0-9_-]/g, "") ?? "";
|
||||
if (!token) return null;
|
||||
if (!/^[A-Za-z]/.test(token)) return null;
|
||||
return token.slice(0, 32).toLowerCase();
|
||||
};
|
||||
|
||||
const findMentionTarget = (
|
||||
text: string,
|
||||
caret: number,
|
||||
): MentionTarget | null => {
|
||||
if (caret < 0 || caret > text.length) return null;
|
||||
const prefix = text.slice(0, caret);
|
||||
const match = prefix.match(MENTION_PATTERN);
|
||||
if (!match) return null;
|
||||
const query = (match[1] ?? "").toLowerCase();
|
||||
const start = caret - query.length - 1;
|
||||
return { start, end: caret, query };
|
||||
};
|
||||
|
||||
function BoardChatComposerImpl({
|
||||
placeholder = "Message the board lead. Tag agents with @name.",
|
||||
isSending = false,
|
||||
disabled = false,
|
||||
mentionSuggestions,
|
||||
onSend,
|
||||
}: BoardChatComposerProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const [mentionTarget, setMentionTarget] = useState<MentionTarget | null>(
|
||||
null,
|
||||
);
|
||||
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const closeMenuTimeoutRef = useRef<number | null>(null);
|
||||
const shouldFocusAfterSendRef = useRef(false);
|
||||
|
||||
const mentionOptions = useMemo(() => {
|
||||
const handles = new Set<string>(["lead"]);
|
||||
(mentionSuggestions ?? []).forEach((candidate) => {
|
||||
const handle = normalizeMentionHandle(candidate);
|
||||
if (handle) {
|
||||
handles.add(handle);
|
||||
}
|
||||
});
|
||||
return [...handles];
|
||||
}, [mentionSuggestions]);
|
||||
|
||||
const filteredMentionOptions = useMemo(() => {
|
||||
if (!mentionTarget) return [];
|
||||
const query = mentionTarget.query;
|
||||
const startsWithMatches = mentionOptions.filter((option) =>
|
||||
option.startsWith(query),
|
||||
);
|
||||
return startsWithMatches.slice(0, MENTION_MAX_OPTIONS);
|
||||
}, [mentionOptions, mentionTarget]);
|
||||
|
||||
const activeIndex =
|
||||
filteredMentionOptions.length > 0
|
||||
? Math.min(activeMentionIndex, filteredMentionOptions.length - 1)
|
||||
: 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSending) return;
|
||||
if (!shouldFocusAfterSendRef.current) return;
|
||||
@@ -29,6 +92,43 @@ function BoardChatComposerImpl({
|
||||
textareaRef.current?.focus();
|
||||
}, [isSending]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeMenuTimeoutRef.current !== null) {
|
||||
window.clearTimeout(closeMenuTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshMentionTarget = useCallback(
|
||||
(nextValue: string, caret: number) => {
|
||||
const nextTarget = findMentionTarget(nextValue, caret);
|
||||
setMentionTarget(nextTarget);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const applyMentionSelection = useCallback(
|
||||
(handle: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || !mentionTarget) return;
|
||||
const replacement = `@${handle} `;
|
||||
const nextValue =
|
||||
value.slice(0, mentionTarget.start) +
|
||||
replacement +
|
||||
value.slice(mentionTarget.end);
|
||||
setValue(nextValue);
|
||||
setMentionTarget(null);
|
||||
setActiveMentionIndex(0);
|
||||
window.requestAnimationFrame(() => {
|
||||
const nextCaret = mentionTarget.start + replacement.length;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(nextCaret, nextCaret);
|
||||
});
|
||||
},
|
||||
[mentionTarget, value],
|
||||
);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
if (isSending || disabled) return;
|
||||
const trimmed = value.trim();
|
||||
@@ -37,26 +137,120 @@ function BoardChatComposerImpl({
|
||||
shouldFocusAfterSendRef.current = true;
|
||||
if (ok) {
|
||||
setValue("");
|
||||
setMentionTarget(null);
|
||||
setActiveMentionIndex(0);
|
||||
}
|
||||
}, [disabled, isSending, onSend, value]);
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[120px]"
|
||||
disabled={isSending || disabled}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setValue(nextValue);
|
||||
refreshMentionTarget(
|
||||
nextValue,
|
||||
event.target.selectionStart ?? nextValue.length,
|
||||
);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
refreshMentionTarget(
|
||||
value,
|
||||
event.currentTarget.selectionStart ?? value.length,
|
||||
);
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
refreshMentionTarget(
|
||||
value,
|
||||
event.currentTarget.selectionStart ?? value.length,
|
||||
);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (closeMenuTimeoutRef.current !== null) {
|
||||
window.clearTimeout(closeMenuTimeoutRef.current);
|
||||
}
|
||||
closeMenuTimeoutRef.current = window.setTimeout(() => {
|
||||
setMentionTarget(null);
|
||||
setActiveMentionIndex(0);
|
||||
}, 120);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
refreshMentionTarget(
|
||||
value,
|
||||
event.currentTarget.selectionStart ?? value.length,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (filteredMentionOptions.length > 0 && mentionTarget) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveMentionIndex(
|
||||
(prev) => (prev + 1) % filteredMentionOptions.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveMentionIndex(
|
||||
(prev) =>
|
||||
(prev - 1 + filteredMentionOptions.length) %
|
||||
filteredMentionOptions.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
const selected = filteredMentionOptions[activeIndex];
|
||||
if (selected) {
|
||||
applyMentionSelection(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setMentionTarget(null);
|
||||
setActiveMentionIndex(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key !== "Enter") return;
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[120px]"
|
||||
disabled={isSending || disabled}
|
||||
/>
|
||||
{mentionTarget && filteredMentionOptions.length > 0 ? (
|
||||
<div className="absolute bottom-full left-0 z-20 mb-2 w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
<div className="max-h-52 overflow-y-auto py-1">
|
||||
{filteredMentionOptions.map((option, index) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
applyMentionSelection(option);
|
||||
}}
|
||||
className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm transition ${
|
||||
index === activeIndex
|
||||
? "bg-slate-100 text-slate-900"
|
||||
: "text-slate-700 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono">@{option}</span>
|
||||
<span className="text-xs text-slate-400">mention</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => void send()}
|
||||
|
||||
@@ -67,7 +67,10 @@ const renderMentionsInText = (text: string, keyPrefix: string): ReactNode => {
|
||||
return nodes;
|
||||
};
|
||||
|
||||
const renderMentions = (content: ReactNode, keyPrefix = "mention"): ReactNode => {
|
||||
const renderMentions = (
|
||||
content: ReactNode,
|
||||
keyPrefix = "mention",
|
||||
): ReactNode => {
|
||||
if (typeof content === "string") {
|
||||
return renderMentionsInText(content, keyPrefix);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface TaskCardProps {
|
||||
priority?: string;
|
||||
assignee?: string;
|
||||
due?: string;
|
||||
isOverdue?: boolean;
|
||||
approvalsPendingCount?: number;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
isBlocked?: boolean;
|
||||
@@ -27,6 +28,7 @@ export function TaskCard({
|
||||
priority,
|
||||
assignee,
|
||||
due,
|
||||
isOverdue = false,
|
||||
approvalsPendingCount = 0,
|
||||
tags = [],
|
||||
isBlocked = false,
|
||||
@@ -157,8 +159,18 @@ export function TaskCard({
|
||||
<span>{assignee ?? "Unassigned"}</span>
|
||||
</div>
|
||||
{due ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarClock className="h-4 w-4 text-slate-400" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
isOverdue && "font-semibold text-rose-600",
|
||||
)}
|
||||
>
|
||||
<CalendarClock
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
isOverdue ? "text-rose-500" : "text-slate-400",
|
||||
)}
|
||||
/>
|
||||
<span>{due}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -82,14 +82,20 @@ const columns: Array<{
|
||||
},
|
||||
];
|
||||
|
||||
const formatDueDate = (value?: string | null) => {
|
||||
if (!value) return undefined;
|
||||
const date = parseApiDatetime(value);
|
||||
if (!date) return undefined;
|
||||
return date.toLocaleDateString(undefined, {
|
||||
const resolveDueState = (
|
||||
task: Task,
|
||||
): { due: string | undefined; isOverdue: boolean } => {
|
||||
const date = parseApiDatetime(task.due_at);
|
||||
if (!date) return { due: undefined, isOverdue: false };
|
||||
const dueLabel = date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
const isOverdue = task.status !== "done" && date.getTime() < Date.now();
|
||||
return {
|
||||
due: isOverdue ? `Overdue · ${dueLabel}` : dueLabel,
|
||||
isOverdue,
|
||||
};
|
||||
};
|
||||
|
||||
type CardPosition = { left: number; top: number };
|
||||
@@ -330,146 +336,156 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
ref={boardRef}
|
||||
className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-4 overflow-x-auto pb-6"
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const columnTasks = grouped[column.status] ?? [];
|
||||
const reviewCounts =
|
||||
column.status === "review"
|
||||
? columnTasks.reduce(
|
||||
(acc, task) => {
|
||||
if (task.is_blocked) {
|
||||
acc.blocked += 1;
|
||||
{columns.map((column) => {
|
||||
const columnTasks = grouped[column.status] ?? [];
|
||||
const reviewCounts =
|
||||
column.status === "review"
|
||||
? columnTasks.reduce(
|
||||
(acc, task) => {
|
||||
if (task.is_blocked) {
|
||||
acc.blocked += 1;
|
||||
return acc;
|
||||
}
|
||||
if ((task.approvals_pending_count ?? 0) > 0) {
|
||||
acc.approval_needed += 1;
|
||||
return acc;
|
||||
}
|
||||
acc.waiting_lead += 1;
|
||||
return acc;
|
||||
}
|
||||
if ((task.approvals_pending_count ?? 0) > 0) {
|
||||
acc.approval_needed += 1;
|
||||
return acc;
|
||||
}
|
||||
acc.waiting_lead += 1;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
all: columnTasks.length,
|
||||
approval_needed: 0,
|
||||
waiting_lead: 0,
|
||||
blocked: 0,
|
||||
},
|
||||
)
|
||||
: null;
|
||||
},
|
||||
{
|
||||
all: columnTasks.length,
|
||||
approval_needed: 0,
|
||||
waiting_lead: 0,
|
||||
blocked: 0,
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
const filteredTasks =
|
||||
column.status === "review" && reviewBucket !== "all"
|
||||
? columnTasks.filter((task) => {
|
||||
if (reviewBucket === "blocked") return Boolean(task.is_blocked);
|
||||
if (reviewBucket === "approval_needed")
|
||||
return (
|
||||
(task.approvals_pending_count ?? 0) > 0 && !task.is_blocked
|
||||
);
|
||||
if (reviewBucket === "waiting_lead")
|
||||
return (
|
||||
!task.is_blocked &&
|
||||
(task.approvals_pending_count ?? 0) === 0
|
||||
);
|
||||
return true;
|
||||
})
|
||||
: columnTasks;
|
||||
const filteredTasks =
|
||||
column.status === "review" && reviewBucket !== "all"
|
||||
? columnTasks.filter((task) => {
|
||||
if (reviewBucket === "blocked")
|
||||
return Boolean(task.is_blocked);
|
||||
if (reviewBucket === "approval_needed")
|
||||
return (
|
||||
(task.approvals_pending_count ?? 0) > 0 &&
|
||||
!task.is_blocked
|
||||
);
|
||||
if (reviewBucket === "waiting_lead")
|
||||
return (
|
||||
!task.is_blocked &&
|
||||
(task.approvals_pending_count ?? 0) === 0
|
||||
);
|
||||
return true;
|
||||
})
|
||||
: columnTasks;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.title}
|
||||
className={cn(
|
||||
"kanban-column min-h-[calc(100vh-260px)]",
|
||||
activeColumn === column.status &&
|
||||
!readOnly &&
|
||||
"ring-2 ring-slate-200",
|
||||
)}
|
||||
onDrop={readOnly ? undefined : handleDrop(column.status)}
|
||||
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
|
||||
onDragLeave={readOnly ? undefined : handleDragLeave(column.status)}
|
||||
>
|
||||
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("h-2 w-2 rounded-full", column.dot)} />
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
{column.title}
|
||||
</h3>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
|
||||
column.badge,
|
||||
)}
|
||||
>
|
||||
{filteredTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
{column.status === "review" && reviewCounts ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
{(
|
||||
[
|
||||
{ key: "all", label: "All", count: reviewCounts.all },
|
||||
{
|
||||
key: "approval_needed",
|
||||
label: "Approval needed",
|
||||
count: reviewCounts.approval_needed,
|
||||
},
|
||||
{
|
||||
key: "waiting_lead",
|
||||
label: "Lead review",
|
||||
count: reviewCounts.waiting_lead,
|
||||
},
|
||||
{
|
||||
key: "blocked",
|
||||
label: "Blocked",
|
||||
count: reviewCounts.blocked,
|
||||
},
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => setReviewBucket(option.key)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 transition",
|
||||
reviewBucket === option.key
|
||||
? "border-slate-900 bg-slate-900 text-white"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50",
|
||||
)}
|
||||
aria-pressed={reviewBucket === option.key}
|
||||
>
|
||||
{option.label} · {option.count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
|
||||
<div className="space-y-3">
|
||||
{filteredTasks.map((task) => (
|
||||
<div key={task.id} ref={setCardRef(task.id)}>
|
||||
<TaskCard
|
||||
title={task.title}
|
||||
status={task.status}
|
||||
priority={task.priority}
|
||||
assignee={task.assignee ?? undefined}
|
||||
due={formatDueDate(task.due_at)}
|
||||
approvalsPendingCount={task.approvals_pending_count}
|
||||
tags={task.tags}
|
||||
isBlocked={task.is_blocked}
|
||||
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
|
||||
onClick={() => onTaskSelect?.(task)}
|
||||
draggable={!readOnly && !task.is_blocked}
|
||||
isDragging={draggingId === task.id}
|
||||
onDragStart={readOnly ? undefined : handleDragStart(task)}
|
||||
onDragEnd={readOnly ? undefined : handleDragEnd}
|
||||
/>
|
||||
return (
|
||||
<div
|
||||
key={column.title}
|
||||
className={cn(
|
||||
"kanban-column min-h-[calc(100vh-260px)]",
|
||||
activeColumn === column.status &&
|
||||
!readOnly &&
|
||||
"ring-2 ring-slate-200",
|
||||
)}
|
||||
onDrop={readOnly ? undefined : handleDrop(column.status)}
|
||||
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
|
||||
onDragLeave={
|
||||
readOnly ? undefined : handleDragLeave(column.status)
|
||||
}
|
||||
>
|
||||
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("h-2 w-2 rounded-full", column.dot)} />
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
{column.title}
|
||||
</h3>
|
||||
</div>
|
||||
))}
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
|
||||
column.badge,
|
||||
)}
|
||||
>
|
||||
{filteredTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
{column.status === "review" && reviewCounts ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
{(
|
||||
[
|
||||
{ key: "all", label: "All", count: reviewCounts.all },
|
||||
{
|
||||
key: "approval_needed",
|
||||
label: "Approval needed",
|
||||
count: reviewCounts.approval_needed,
|
||||
},
|
||||
{
|
||||
key: "waiting_lead",
|
||||
label: "Lead review",
|
||||
count: reviewCounts.waiting_lead,
|
||||
},
|
||||
{
|
||||
key: "blocked",
|
||||
label: "Blocked",
|
||||
count: reviewCounts.blocked,
|
||||
},
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => setReviewBucket(option.key)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 transition",
|
||||
reviewBucket === option.key
|
||||
? "border-slate-900 bg-slate-900 text-white"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50",
|
||||
)}
|
||||
aria-pressed={reviewBucket === option.key}
|
||||
>
|
||||
{option.label} · {option.count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
|
||||
<div className="space-y-3">
|
||||
{filteredTasks.map((task) => {
|
||||
const dueState = resolveDueState(task);
|
||||
return (
|
||||
<div key={task.id} ref={setCardRef(task.id)}>
|
||||
<TaskCard
|
||||
title={task.title}
|
||||
status={task.status}
|
||||
priority={task.priority}
|
||||
assignee={task.assignee ?? undefined}
|
||||
due={dueState.due}
|
||||
isOverdue={dueState.isOverdue}
|
||||
approvalsPendingCount={task.approvals_pending_count}
|
||||
tags={task.tags}
|
||||
isBlocked={task.is_blocked}
|
||||
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
|
||||
onClick={() => onTaskSelect?.(task)}
|
||||
draggable={!readOnly && !task.is_blocked}
|
||||
isDragging={draggingId === task.id}
|
||||
onDragStart={
|
||||
readOnly ? undefined : handleDragStart(task)
|
||||
}
|
||||
onDragEnd={readOnly ? undefined : handleDragEnd}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user