737 lines
23 KiB
TypeScript
737 lines
23 KiB
TypeScript
import { memo, useEffect, useMemo, useReducer, useState } from 'react';
|
|
|
|
import { useAppTranslation } from '@features/localization/renderer';
|
|
import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator';
|
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
|
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
|
import { Button } from '@renderer/components/ui/button';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
|
import { useTheme } from '@renderer/hooks/useTheme';
|
|
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
|
import { REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
|
import {
|
|
buildTaskChangeRequestOptions,
|
|
canDisplayTaskChangesForOptions,
|
|
} from '@renderer/utils/taskChangeRequest';
|
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
|
import {
|
|
isTeamTaskFinishedForDependency,
|
|
isTeamTaskNeedsFixActionable,
|
|
} from '@shared/utils/teamTaskState';
|
|
import {
|
|
ArrowLeftFromLine,
|
|
ArrowRightFromLine,
|
|
CheckCircle2,
|
|
Eye,
|
|
FileCode,
|
|
FilePenLine,
|
|
HelpCircle,
|
|
Play,
|
|
RotateCcw,
|
|
Trash2,
|
|
XCircle,
|
|
} from 'lucide-react';
|
|
|
|
import type {
|
|
KanbanColumnId,
|
|
KanbanTaskState,
|
|
TaskComment,
|
|
TeamTask,
|
|
TeamTaskWithKanban,
|
|
} from '@shared/types';
|
|
|
|
interface KanbanTaskCardProps {
|
|
task: TeamTaskWithKanban;
|
|
teamName: string;
|
|
columnId: KanbanColumnId;
|
|
kanbanTaskState?: KanbanTaskState;
|
|
hasReviewers: boolean;
|
|
compact?: boolean;
|
|
taskMap: Map<string, TeamTask>;
|
|
memberColorMap: Map<string, string>;
|
|
hasLiveTaskLogs?: boolean;
|
|
onRequestReview: (taskId: string) => void;
|
|
onApprove: (taskId: string) => void;
|
|
onRequestChanges: (taskId: string) => void;
|
|
onMoveBackToDone: (taskId: string) => void;
|
|
onStartTask: (taskId: string) => void;
|
|
onCompleteTask: (taskId: string) => void;
|
|
onCancelTask: (taskId: string) => void;
|
|
onScrollToTask?: (taskId: string) => void;
|
|
onTaskClick?: (task: TeamTask) => void;
|
|
onViewChanges?: (taskId: string) => void;
|
|
onDeleteTask?: (taskId: string) => void;
|
|
}
|
|
|
|
interface DependencyBadgeProps {
|
|
taskId: string;
|
|
taskMap: Map<string, TeamTask>;
|
|
onScrollToTask?: (taskId: string) => void;
|
|
}
|
|
|
|
interface CommentPulseState {
|
|
taskKey: string;
|
|
commentCount: number;
|
|
commentIds: Set<string>;
|
|
pulseKey: number;
|
|
}
|
|
|
|
interface CommentPulseSyncAction {
|
|
taskKey: string;
|
|
comments: readonly TaskComment[];
|
|
}
|
|
|
|
const EMPTY_TASK_COMMENTS: readonly TaskComment[] = [];
|
|
const taskCardSignatureCache = new WeakMap<TeamTaskWithKanban, string>();
|
|
|
|
function getTaskCardSignature(task: TeamTaskWithKanban): string {
|
|
const cached = taskCardSignatureCache.get(task);
|
|
if (cached !== undefined) return cached;
|
|
|
|
const signature = JSON.stringify(task);
|
|
taskCardSignatureCache.set(task, signature);
|
|
return signature;
|
|
}
|
|
|
|
function areKanbanTaskStatesEqual(
|
|
prev: KanbanTaskState | undefined,
|
|
next: KanbanTaskState | undefined
|
|
): boolean {
|
|
if (prev === next) return true;
|
|
if (!prev || !next) return !prev && !next;
|
|
return (
|
|
prev.column === next.column &&
|
|
prev.reviewer === next.reviewer &&
|
|
prev.errorDescription === next.errorDescription &&
|
|
prev.movedAt === next.movedAt
|
|
);
|
|
}
|
|
|
|
function getTaskDependencyIds(task: TeamTaskWithKanban): string[] {
|
|
return [...(task.blockedBy ?? []), ...(task.blocks ?? [])].filter((id) => id.length > 0);
|
|
}
|
|
|
|
function getDependencyTaskSignature(task: TeamTask | undefined): string {
|
|
if (!task) return '';
|
|
const kanbanTask = task as Partial<TeamTaskWithKanban>;
|
|
return [
|
|
task.id,
|
|
task.displayId ?? '',
|
|
task.subject,
|
|
task.status,
|
|
task.reviewState ?? '',
|
|
kanbanTask.kanbanColumn ?? '',
|
|
].join('\u001f');
|
|
}
|
|
|
|
function areTaskMapDependenciesEqual(
|
|
prevTask: TeamTaskWithKanban,
|
|
nextTask: TeamTaskWithKanban,
|
|
prevTaskMap: Map<string, TeamTask>,
|
|
nextTaskMap: Map<string, TeamTask>
|
|
): boolean {
|
|
const dependencyIds = new Set([
|
|
...getTaskDependencyIds(prevTask),
|
|
...getTaskDependencyIds(nextTask),
|
|
]);
|
|
for (const taskId of dependencyIds) {
|
|
if (
|
|
getDependencyTaskSignature(prevTaskMap.get(taskId)) !==
|
|
getDependencyTaskSignature(nextTaskMap.get(taskId))
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function createCommentPulseState(
|
|
taskKey: string,
|
|
comments: readonly TaskComment[],
|
|
pulseKey = 0
|
|
): CommentPulseState {
|
|
return {
|
|
taskKey,
|
|
commentCount: comments.length,
|
|
commentIds: new Set(comments.map((comment) => comment.id)),
|
|
pulseKey,
|
|
};
|
|
}
|
|
|
|
function hasSameCommentIds(state: CommentPulseState, comments: readonly TaskComment[]): boolean {
|
|
return (
|
|
comments.length === state.commentCount &&
|
|
comments.every((comment) => state.commentIds.has(comment.id))
|
|
);
|
|
}
|
|
|
|
function syncCommentPulseState(
|
|
state: CommentPulseState,
|
|
action: CommentPulseSyncAction
|
|
): CommentPulseState {
|
|
if (state.taskKey !== action.taskKey) {
|
|
return createCommentPulseState(action.taskKey, action.comments);
|
|
}
|
|
|
|
const hasNewIncomingComment =
|
|
action.comments.length > state.commentCount &&
|
|
action.comments.some(
|
|
(comment) => !state.commentIds.has(comment.id) && comment.author !== 'user'
|
|
);
|
|
|
|
if (!hasNewIncomingComment && hasSameCommentIds(state, action.comments)) {
|
|
return state;
|
|
}
|
|
|
|
return createCommentPulseState(
|
|
action.taskKey,
|
|
action.comments,
|
|
hasNewIncomingComment ? state.pulseKey + 1 : state.pulseKey
|
|
);
|
|
}
|
|
|
|
const DependencyBadge = ({
|
|
taskId,
|
|
taskMap,
|
|
onScrollToTask,
|
|
}: DependencyBadgeProps): React.JSX.Element => {
|
|
const depTask = taskMap.get(taskId);
|
|
const isCompleted = depTask ? isTeamTaskFinishedForDependency(depTask) : false;
|
|
const label = depTask
|
|
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
|
: `#${deriveTaskDisplayId(taskId)}`;
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
|
isCompleted
|
|
? 'bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/25 dark:text-emerald-400'
|
|
: 'bg-yellow-500/15 text-yellow-700 hover:bg-yellow-500/25 dark:text-yellow-300'
|
|
} ${onScrollToTask ? 'cursor-pointer' : ''}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onScrollToTask?.(taskId);
|
|
}}
|
|
>
|
|
{depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(taskId)}`}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{label}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
const TruncatedTitle = ({
|
|
text,
|
|
className,
|
|
}: {
|
|
text: string;
|
|
className?: string;
|
|
}): React.JSX.Element => (
|
|
<h5
|
|
className={`line-clamp-2 text-xs font-medium text-[var(--color-text)] ${className ?? ''}`}
|
|
title={text}
|
|
>
|
|
{text}
|
|
</h5>
|
|
);
|
|
|
|
const CancelTaskButton = ({
|
|
taskId,
|
|
onConfirm,
|
|
}: {
|
|
taskId: string;
|
|
onConfirm: (taskId: string) => void;
|
|
}): React.JSX.Element => {
|
|
const { t } = useAppTranslation('team');
|
|
const [open, setOpen] = useState(false);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="destructive"
|
|
size="icon"
|
|
className="size-6 rounded-full shadow-sm"
|
|
aria-label={t('kanban.taskCard.cancelTask', { taskId })}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<XCircle size={11} />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">{t('kanban.taskCard.cancel')}</TooltipContent>
|
|
</Tooltip>
|
|
{open ? (
|
|
<PopoverContent
|
|
className="w-56 p-3"
|
|
side="top"
|
|
align="start"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<p className="mb-3 text-xs text-[var(--color-text-secondary)]">
|
|
{t('kanban.taskCard.moveBackToTodoConfirm')}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
className="flex-1"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
onConfirm(taskId);
|
|
}}
|
|
>
|
|
{t('kanban.taskCard.confirm')}
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="flex-1" onClick={() => setOpen(false)}>
|
|
{t('kanban.taskCard.keep')}
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
) : null}
|
|
</Popover>
|
|
);
|
|
};
|
|
|
|
interface TaskActionIconButtonProps {
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
|
className: string;
|
|
variant?: 'outline' | 'ghost' | 'destructive';
|
|
disabled?: boolean;
|
|
}
|
|
|
|
const TaskActionIconButton = ({
|
|
label,
|
|
icon,
|
|
onClick,
|
|
className,
|
|
variant = 'outline',
|
|
disabled = false,
|
|
}: TaskActionIconButtonProps): React.JSX.Element => (
|
|
<Button
|
|
variant={variant}
|
|
size="icon"
|
|
className={`size-6 shrink-0 rounded-full shadow-sm ${className}`}
|
|
aria-label={label}
|
|
title={label}
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
>
|
|
{icon}
|
|
</Button>
|
|
);
|
|
|
|
interface TaskMetaActionsProps {
|
|
taskId: string;
|
|
unreadCount: number;
|
|
commentCount: number;
|
|
pulseKey: number;
|
|
canOpenChanges: boolean;
|
|
changesNeedAttention: boolean;
|
|
onViewChanges?: (taskId: string) => void;
|
|
onDeleteTask?: (taskId: string) => void;
|
|
}
|
|
|
|
const TaskMetaActions = memo(function TaskMetaActions({
|
|
taskId,
|
|
unreadCount,
|
|
commentCount,
|
|
pulseKey,
|
|
canOpenChanges,
|
|
changesNeedAttention,
|
|
onViewChanges,
|
|
onDeleteTask,
|
|
}: TaskMetaActionsProps): React.JSX.Element {
|
|
const { t } = useAppTranslation('team');
|
|
|
|
return (
|
|
<>
|
|
{canOpenChanges && onViewChanges ? (
|
|
<TaskActionIconButton
|
|
label={
|
|
changesNeedAttention
|
|
? t('kanban.taskCard.changesNeedAttention')
|
|
: t('kanban.taskCard.changes')
|
|
}
|
|
icon={<FileCode className="size-2.5" />}
|
|
variant="ghost"
|
|
className={
|
|
changesNeedAttention
|
|
? 'text-amber-400 hover:bg-amber-500/10 hover:text-amber-300'
|
|
: 'text-sky-400 hover:bg-sky-500/10 hover:text-sky-300'
|
|
}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onViewChanges(taskId);
|
|
}}
|
|
/>
|
|
) : null}
|
|
<UnreadCommentsBadge
|
|
unreadCount={unreadCount}
|
|
totalCount={commentCount}
|
|
pulseKey={pulseKey}
|
|
/>
|
|
{onDeleteTask ? (
|
|
<TaskActionIconButton
|
|
label={t('kanban.taskCard.deleteTask')}
|
|
icon={<Trash2 size={11} />}
|
|
variant="ghost"
|
|
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDeleteTask(taskId);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
});
|
|
|
|
interface TaskPrimaryActionsProps {
|
|
taskId: string;
|
|
columnId: KanbanColumnId;
|
|
isReviewManual: boolean;
|
|
onRequestReview: (taskId: string) => void;
|
|
onApprove: (taskId: string) => void;
|
|
onRequestChanges: (taskId: string) => void;
|
|
onMoveBackToDone: (taskId: string) => void;
|
|
onStartTask: (taskId: string) => void;
|
|
onCompleteTask: (taskId: string) => void;
|
|
onCancelTask: (taskId: string) => void;
|
|
}
|
|
|
|
const TaskPrimaryActions = memo(function TaskPrimaryActions({
|
|
taskId,
|
|
columnId,
|
|
isReviewManual,
|
|
onRequestReview,
|
|
onApprove,
|
|
onRequestChanges,
|
|
onMoveBackToDone,
|
|
onStartTask,
|
|
onCompleteTask,
|
|
onCancelTask,
|
|
}: TaskPrimaryActionsProps): React.JSX.Element {
|
|
const { t } = useAppTranslation('team');
|
|
|
|
return (
|
|
<div className="flex min-w-0 flex-nowrap gap-2">
|
|
{columnId === 'todo' ? (
|
|
<>
|
|
<TaskActionIconButton
|
|
label={t('kanban.taskCard.start')}
|
|
icon={<Play size={11} />}
|
|
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onStartTask(taskId);
|
|
}}
|
|
/>
|
|
<TaskActionIconButton
|
|
label={t('kanban.taskCard.complete')}
|
|
icon={<CheckCircle2 size={11} />}
|
|
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onCompleteTask(taskId);
|
|
}}
|
|
/>
|
|
</>
|
|
) : null}
|
|
|
|
{columnId === 'in_progress' ? (
|
|
<>
|
|
<TaskActionIconButton
|
|
label={t('kanban.taskCard.complete')}
|
|
icon={<CheckCircle2 size={11} />}
|
|
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onCompleteTask(taskId);
|
|
}}
|
|
/>
|
|
<CancelTaskButton taskId={taskId} onConfirm={onCancelTask} />
|
|
</>
|
|
) : null}
|
|
|
|
{columnId === 'done' ? (
|
|
<>
|
|
<TaskActionIconButton
|
|
label={t('kanban.taskCard.approve')}
|
|
icon={<CheckCircle2 size={11} />}
|
|
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onApprove(taskId);
|
|
}}
|
|
/>
|
|
<TaskActionIconButton
|
|
label={t('kanban.taskCard.requestReview')}
|
|
icon={<Eye size={11} />}
|
|
className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRequestReview(taskId);
|
|
}}
|
|
/>
|
|
</>
|
|
) : null}
|
|
|
|
{columnId === 'review' ? (
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
{isReviewManual ? (
|
|
<div className="whitespace-nowrap text-[11px] text-[var(--color-text-muted)]">
|
|
{t('kanban.taskCard.manualReview')}
|
|
</div>
|
|
) : null}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<TaskActionIconButton
|
|
label={t('kanban.taskCard.approve')}
|
|
icon={<CheckCircle2 size={11} />}
|
|
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onApprove(taskId);
|
|
}}
|
|
/>
|
|
<TaskActionIconButton
|
|
label={t('kanban.taskCard.requestChanges')}
|
|
icon={<FilePenLine size={11} />}
|
|
variant="destructive"
|
|
className="bg-red-500/90 text-white hover:bg-red-500"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRequestChanges(taskId);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{columnId === 'approved' ? (
|
|
<TaskActionIconButton
|
|
label="Disapprove"
|
|
icon={<RotateCcw size={11} />}
|
|
className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onMoveBackToDone(taskId);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export const KanbanTaskCard = memo(
|
|
function KanbanTaskCard({
|
|
task,
|
|
teamName,
|
|
columnId,
|
|
kanbanTaskState,
|
|
hasReviewers,
|
|
compact,
|
|
taskMap,
|
|
memberColorMap,
|
|
hasLiveTaskLogs = false,
|
|
onRequestReview,
|
|
onApprove,
|
|
onRequestChanges,
|
|
onMoveBackToDone,
|
|
onStartTask,
|
|
onCompleteTask,
|
|
onCancelTask,
|
|
onScrollToTask,
|
|
onTaskClick,
|
|
onViewChanges,
|
|
onDeleteTask,
|
|
}: KanbanTaskCardProps): React.JSX.Element {
|
|
const { t } = useAppTranslation('team');
|
|
const { isLight } = useTheme();
|
|
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
|
|
const commentPulseTaskKey = `${teamName}/${task.id}`;
|
|
const comments = task.comments ?? EMPTY_TASK_COMMENTS;
|
|
const commentCount = comments.length;
|
|
const [commentPulse, syncCommentPulse] = useReducer(
|
|
syncCommentPulseState,
|
|
{ taskKey: commentPulseTaskKey, comments },
|
|
({ taskKey, comments: initialComments }) => createCommentPulseState(taskKey, initialComments)
|
|
);
|
|
const visibleCommentPulseKey =
|
|
commentPulse.taskKey === commentPulseTaskKey ? commentPulse.pulseKey : 0;
|
|
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
|
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
|
const hasBlockedBy = blockedByIds.length > 0;
|
|
const hasBlocks = blocksIds.length > 0;
|
|
const shouldHighlightBlocked = hasBlockedBy && columnId !== 'done' && columnId !== 'approved';
|
|
const cardSurfaceClass = isLight ? 'bg-white' : 'bg-[var(--color-surface-raised)]';
|
|
|
|
const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]);
|
|
const canDisplay = useMemo(
|
|
() => canDisplayTaskChangesForOptions(taskChangeRequestOptions) && !!onViewChanges,
|
|
[taskChangeRequestOptions, onViewChanges]
|
|
);
|
|
|
|
const effectiveReviewer = (kanbanTaskState?.reviewer ?? task.reviewer ?? '').trim();
|
|
const isReviewManual = columnId === 'review' && !hasReviewers && effectiveReviewer.length === 0;
|
|
const canOpenChanges =
|
|
canDisplay &&
|
|
(task.changePresence === 'has_changes' || task.changePresence === 'needs_attention');
|
|
const changesNeedAttention = task.changePresence === 'needs_attention';
|
|
|
|
useEffect(() => {
|
|
syncCommentPulse({ taskKey: commentPulseTaskKey, comments });
|
|
}, [commentCount, commentPulseTaskKey, comments]);
|
|
|
|
return (
|
|
<div
|
|
data-task-id={task.id}
|
|
className={`kanban-task-card relative cursor-pointer rounded-md border px-1.5 py-3 hover:border-[var(--color-border-emphasis)] ${
|
|
shouldHighlightBlocked
|
|
? `border-yellow-500/30 ${cardSurfaceClass}`
|
|
: `border-[var(--color-border)] ${cardSurfaceClass}`
|
|
}`}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => onTaskClick?.(task)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onTaskClick?.(task);
|
|
}
|
|
}}
|
|
>
|
|
<span className="absolute left-[3px] top-[2px] flex max-w-[calc(100%-72px)] items-center gap-1 text-[9px] leading-none text-[var(--color-text-muted)]">
|
|
<span className="truncate">{formatTaskDisplayLabel(task)}</span>
|
|
{hasLiveTaskLogs ? (
|
|
<span aria-label={t('kanban.taskCard.taskLogsActive')} className="inline-flex">
|
|
<OngoingIndicator size="sm" title={t('kanban.taskCard.newTaskLogsArriving')} />
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
{task.owner ? (
|
|
<span className="absolute right-[6px] top-[2px]">
|
|
<MemberBadge name={task.owner} color={memberColorMap.get(task.owner)} size="xs" />
|
|
</span>
|
|
) : null}
|
|
<div className="mb-2 pt-[11px]">
|
|
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
|
|
{task.needsClarification ? (
|
|
<span
|
|
className={`mt-1 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
|
task.needsClarification === 'user'
|
|
? 'bg-red-500/15 text-red-400'
|
|
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400'
|
|
}`}
|
|
>
|
|
<HelpCircle size={10} />
|
|
{task.needsClarification === 'user'
|
|
? t('kanban.taskCard.awaitingUser')
|
|
: t('kanban.taskCard.awaitingLead')}
|
|
</span>
|
|
) : null}
|
|
{isTeamTaskNeedsFixActionable(task) ? (
|
|
<span
|
|
className={`mt-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
|
>
|
|
{REVIEW_STATE_DISPLAY.needsFix.label}
|
|
</span>
|
|
) : null}
|
|
{compact && <TruncatedTitle text={task.subject} className="mt-1" />}
|
|
</div>
|
|
|
|
{hasBlockedBy ? (
|
|
<div className="mb-2 flex flex-wrap items-center gap-1">
|
|
<span className="inline-flex items-center gap-0.5 text-[10px] text-yellow-700 dark:text-yellow-300">
|
|
<ArrowLeftFromLine size={10} />
|
|
{t('kanban.taskCard.blockedBy')}
|
|
</span>
|
|
{blockedByIds.map((id) => (
|
|
<DependencyBadge
|
|
key={id}
|
|
taskId={id}
|
|
taskMap={taskMap}
|
|
onScrollToTask={onScrollToTask}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{hasBlocks ? (
|
|
<div className="mb-2 flex flex-wrap items-center gap-1">
|
|
<span className="inline-flex items-center gap-0.5 text-[10px] text-blue-600 dark:text-blue-400">
|
|
<ArrowRightFromLine size={10} />
|
|
{t('kanban.taskCard.blocks')}
|
|
</span>
|
|
{blocksIds.map((id) => (
|
|
<DependencyBadge
|
|
key={id}
|
|
taskId={id}
|
|
taskMap={taskMap}
|
|
onScrollToTask={onScrollToTask}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
<TaskPrimaryActions
|
|
taskId={task.id}
|
|
columnId={columnId}
|
|
isReviewManual={isReviewManual}
|
|
onRequestReview={onRequestReview}
|
|
onApprove={onApprove}
|
|
onRequestChanges={onRequestChanges}
|
|
onMoveBackToDone={onMoveBackToDone}
|
|
onStartTask={onStartTask}
|
|
onCompleteTask={onCompleteTask}
|
|
onCancelTask={onCancelTask}
|
|
/>
|
|
|
|
<div className="flex shrink-0 flex-nowrap items-center gap-1.5">
|
|
<TaskMetaActions
|
|
taskId={task.id}
|
|
unreadCount={unreadCount}
|
|
commentCount={commentCount}
|
|
pulseKey={visibleCommentPulseKey}
|
|
canOpenChanges={canOpenChanges}
|
|
changesNeedAttention={changesNeedAttention}
|
|
onViewChanges={onViewChanges}
|
|
onDeleteTask={onDeleteTask}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
(prev, next) =>
|
|
getTaskCardSignature(prev.task) === getTaskCardSignature(next.task) &&
|
|
prev.teamName === next.teamName &&
|
|
prev.columnId === next.columnId &&
|
|
areKanbanTaskStatesEqual(prev.kanbanTaskState, next.kanbanTaskState) &&
|
|
prev.hasReviewers === next.hasReviewers &&
|
|
prev.compact === next.compact &&
|
|
areTaskMapDependenciesEqual(prev.task, next.task, prev.taskMap, next.taskMap) &&
|
|
prev.memberColorMap === next.memberColorMap &&
|
|
prev.hasLiveTaskLogs === next.hasLiveTaskLogs &&
|
|
prev.onRequestReview === next.onRequestReview &&
|
|
prev.onApprove === next.onApprove &&
|
|
prev.onRequestChanges === next.onRequestChanges &&
|
|
prev.onMoveBackToDone === next.onMoveBackToDone &&
|
|
prev.onStartTask === next.onStartTask &&
|
|
prev.onCompleteTask === next.onCompleteTask &&
|
|
prev.onCancelTask === next.onCancelTask &&
|
|
prev.onScrollToTask === next.onScrollToTask &&
|
|
prev.onTaskClick === next.onTaskClick &&
|
|
prev.onViewChanges === next.onViewChanges &&
|
|
prev.onDeleteTask === next.onDeleteTask
|
|
);
|