refactor: 废弃flattenLevels拍平→递归TaskNode渲染—主干同缩进+SPAWN分支缩进紫色左边线

This commit is contained in:
2026-08-09 18:16:47 +08:00
parent f33396b9f1
commit d8623df7de

View File

@ -1,12 +1,17 @@
/**
* 任务流转卡片堆叠视图 — 与移动端 TaskSwipeCards.vue 核心逻辑完全对齐
* 任务流转树 — 递归树形渲染,与移动端 TaskSwipeCards 核心逻辑对齐
*
* 渲染规则:
* - 顶层任务 (!parent_task_id) = 主分支(蓝色)
* - TRANSFER/RECOVERY 子任务 = 主干下一环(同缩进,蓝色)
* - SPAWN 子任务 = 分支(缩进 + 紫色 + 左侧连接线)
*/
import { memo, useMemo, useState } from "react";
import { memo, useState } from "react";
import {
GitBranch, ArrowDown, AlertTriangle, Clock,
CheckCircle, Flag, FileText, X, Image,
GitBranch, AlertTriangle, Clock,
CheckCircle, Flag, FileText, X,
} from "lucide-react";
import type { TaskResponse, TaskRecordResponse } from "../../types/api";
import type { TaskResponse } from "../../types/api";
import { TASK_STATUS } from "../../types/api";
import { getStatusConfig } from "../../constants/task";
import type { ModalTarget } from "./TaskTreeViewer";
@ -22,18 +27,17 @@ function fmtTime(d: string | null) {
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
}
/** 主线判定:与移动端 branchLabelMap/isMain 完全一致 */
function isMainTask(t: TaskResponse): boolean {
return !t.parent_task_id || t.task_type === "TRANSFER" || t.task_type === "RECOVERY"
return !t.parent_task_id
|| t.task_type === "TRANSFER"
|| t.task_type === "RECOVERY"
|| (!t.task_type && !!t.parent_task_id);
}
// ============================================================
// 记录图片渲染
// ============================================================
function parseImages(imagesStr: string | null | undefined): string[] {
if (!imagesStr) return [];
try { return JSON.parse(imagesStr); } catch { return []; }
function parseImages(s: string | null | undefined): string[] {
if (!s) return [];
try { return JSON.parse(s); } catch { return []; }
}
function imageUrl(url: string) {
@ -42,59 +46,53 @@ function imageUrl(url: string) {
return import.meta.env.VITE_API_BASE_URL + (url.startsWith("/") ? url : "/" + url);
}
// ============================================================
// 类型
// ============================================================
interface LevelGroup { depth: number; tasks: TaskResponse[]; }
// ============================================================
// 任务树解析
// ============================================================
function flattenLevels(tasks: TaskResponse[], depth: number = 0): LevelGroup[] {
const result: LevelGroup[] = [];
if (!tasks || tasks.length === 0) return result;
result.push({ depth, tasks });
for (const t of tasks) {
if (t.child_tasks && t.child_tasks.length > 0) {
result.push(...flattenLevels(t.child_tasks, depth + 1));
}
}
return result;
/** 排序:主线优先,再按时间 */
function sortTasks(tasks: TaskResponse[]): TaskResponse[] {
return [...tasks].sort((a, b) => {
const aMain = isMainTask(a) ? -1 : 1;
const bMain = isMainTask(b) ? -1 : 1;
if (aMain !== bMain) return aMain - bMain;
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
});
}
function groupByDepth(levels: LevelGroup[]): Map<number, TaskResponse[]> {
const map = new Map<number, TaskResponse[]>();
for (const lvl of levels) {
if (!map.has(lvl.depth)) map.set(lvl.depth, []);
const existing = map.get(lvl.depth)!;
for (const t of lvl.tasks) {
if (!existing.find((e) => e.id === t.id)) existing.push(t);
}
}
return map;
// ============================================================
// 停留耗时
// ============================================================
function calcDwell(receivedAt: string | null, completedAt: string | null, status: string): { text: string; highlight: boolean } | null {
if (!receivedAt) return null;
const start = new Date(receivedAt).getTime();
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
const diffMs = end - start;
if (diffMs < 0) return null;
const m = Math.floor(diffMs / 60000);
if (m < 1) return { text: "< 1分钟", highlight: status === "WIP" };
if (m < 60) return { text: `${m}分钟`, highlight: status === "WIP" };
const h = Math.floor(m / 60); const rm = m % 60;
if (h < 24) return { text: `${h}小时${rm > 0 ? rm + "分钟" : ""}`, highlight: status === "WIP" };
const d = Math.floor(h / 24); const rh = h % 24;
return { text: `${d}${rh > 0 ? rh + "小时" : ""}`, highlight: status === "WIP" };
}
// ============================================================
// 单张任务卡片
// ============================================================
const FlowCard = memo(function FlowCard({
task, isActive, onAction, currentUser, assigneeName,
const TaskCard = memo(function TaskCard({
task, isActive, onAction, currentUser, assigneeName, isBranch,
onViewRecords,
}: {
task: TaskResponse;
isActive: boolean;
onAction: (target: ModalTarget) => void;
task: TaskResponse; isActive: boolean;
onAction: (t: ModalTarget) => void;
currentUser?: { username?: string; role?: string } | null;
assigneeName?: string;
onViewRecords?: (task: TaskResponse) => void;
assigneeName?: string; isBranch?: boolean;
onViewRecords?: (t: TaskResponse) => void;
}) {
const cfg = getStatusConfig(task.status);
const dwell = calcDwell(task.received_at, task.completed_at, task.status);
const isCompleted = task.status?.toUpperCase() === TASK_STATUS.COMPLETED;
const isArchived = task.status?.toUpperCase() === TASK_STATUS.ARCHIVED;
const isCompleted = task.status?.toUpperCase() === "COMPLETED";
const isArchived = task.status?.toUpperCase() === "ARCHIVED";
const isCanceled = task.status?.toUpperCase() === "CANCELED";
const main = isMainTask(task);
@ -103,35 +101,37 @@ const FlowCard = memo(function FlowCard({
return (
<div
className={`relative shrink-0 w-56 rounded-xl border-2 bg-white p-3.5 shadow-md transition-all ${
isActive
? `border-blue-400 ${cfg.ring} shadow-lg shadow-blue-100 scale-105 z-10`
: isCompleted || isArchived ? "border-gray-200 opacity-70"
: isCanceled ? "border-gray-200 opacity-50"
: "border-gray-200 hover:shadow-lg"
className={`relative w-56 shrink-0 rounded-xl border-2 bg-white p-3.5 shadow-md transition-all ${
isActive ? `border-blue-400 ${cfg.ring} shadow-lg shadow-blue-100 scale-105 z-10`
: isCompleted || isArchived ? "border-gray-200 opacity-70"
: isCanceled ? "border-gray-200 opacity-50"
: "border-gray-200 hover:shadow-lg"
} ${task.is_rework ? "border-l-red-500 border-l-4" : ""}`}
>
{/* 主线/分支 角标 */}
<div className={`absolute top-3 right-3 rounded px-2 py-0.5 text-[9px] font-bold text-white z-2 ${main ? "bg-blue-600" : "bg-purple-500"}`}>
{/* 角标:主分支(蓝) / 分支(紫) */}
<div className={`absolute top-3 right-3 rounded px-2 py-0.5 text-[9px] font-bold text-white ${main ? "bg-blue-600" : "bg-purple-500"}`}>
{main ? "主分支" : "分支"}
</div>
{/* 返工/入库 */}
{/* 返工 / 入库 */}
<div className="mb-1 flex flex-wrap gap-1">
{task.is_rework && <span className="inline-flex items-center rounded bg-red-600 px-1.5 py-0.5 text-[9px] font-bold text-white animate-pulse"><AlertTriangle className="mr-0.5 h-2.5 w-2.5" /></span>}
{task.status === "ARCHIVED" && <span className="inline-flex items-center rounded border border-dashed border-purple-300 bg-purple-50 px-1.5 py-0.5 text-[9px] font-bold text-purple-600">📦 </span>}
</div>
{/* 头部 */}
{/* 类型标签 + 状态 */}
<div className="flex items-center justify-between mt-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className={`rounded px-2 py-0.5 text-[9px] font-bold text-white ${main ? "bg-blue-600" : "bg-purple-100 text-purple-700"}`}>{main ? "主分支" : "分支"}</span>
{task.child_tasks.length > 1 && <span className="inline-flex items-center rounded bg-purple-100 px-1.5 py-0.5 text-[9px] font-medium text-purple-700"><GitBranch className="mr-0.5 h-2.5 w-2.5" />×{task.child_tasks.length}</span>}
</div>
<span className={`rounded px-2 py-0.5 text-[9px] font-bold ${main ? "bg-blue-600 text-white" : "bg-purple-100 text-purple-700"}`}>
{main ? "主分支" : "分支"}
</span>
{task.child_tasks.length > 1 && (
<span className="inline-flex items-center rounded bg-purple-100 px-1.5 py-0.5 text-[9px] font-medium text-purple-700">
<GitBranch className="mr-0.5 h-2.5 w-2.5" />×{task.child_tasks.length}
</span>
)}
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
</div>
{/* 任务名 */}
<p className="mt-2 text-base font-extrabold text-gray-800 leading-tight">{task.task_name}</p>
{/* 负责人 */}
@ -148,14 +148,12 @@ const FlowCard = memo(function FlowCard({
</div>
)}
{/* 提交记录 — 可点击 */}
{/* 提交记录 */}
{task.records && task.records.length > 0 && (
<div
onClick={(e) => { e.stopPropagation(); onViewRecords?.(task); }}
className="mt-2 flex cursor-pointer items-center gap-1.5 rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 px-2.5 py-2 transition-colors hover:from-blue-100 hover:to-indigo-100"
>
<div onClick={(e) => { e.stopPropagation(); onViewRecords?.(task); }}
className="mt-2 flex cursor-pointer items-center gap-1.5 rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 px-2.5 py-2 transition-colors hover:from-blue-100 hover:to-indigo-100">
<FileText className="h-3.5 w-3.5 text-blue-500" />
<span className="flex-1 text-[11px] font-bold text-blue-600"> {task.records.length} </span>
<span className="flex-1 text-[11px] font-bold text-blue-600"> {task.records.length} </span>
<span className="text-[10px] text-blue-400"> </span>
</div>
)}
@ -168,7 +166,6 @@ const FlowCard = memo(function FlowCard({
</p>
)}
{/* 驳回原因 */}
{task.reject_reason && <p className="mt-1 text-[10px] text-red-500 line-clamp-2">: {task.reject_reason}</p>}
{/* 状态标记 */}
@ -177,48 +174,38 @@ const FlowCard = memo(function FlowCard({
{task.completed_at && <span className="inline-flex items-center gap-1 rounded-md bg-green-50 px-1.5 py-0.5 text-[9px] font-semibold text-green-600"><Flag className="h-2.5 w-2.5" /></span>}
</div>
{/* 时间 */}
<div className="mt-2 border-t border-gray-100 pt-2">
<p className="text-[9px] text-gray-400">: {fmtTime(task.created_at)}</p>
{task.received_at && <p className="text-[9px] text-gray-400">: {fmtTime(task.received_at)}</p>}
{task.completed_at && <p className="text-[9px] text-gray-400">: {fmtTime(task.completed_at)}</p>}
</div>
{/* 🔧 BugFix #1: 权限严格互斥 */}
{/* 本人:正常操作按钮 */}
{/* 权限按钮 — 严格互斥 */}
{isActive && isOwner && (
<div className="mt-2 flex gap-1.5 border-t border-gray-100 pt-2">
{task.status?.toUpperCase() === TASK_STATUS.PENDING && (
<>
<button onClick={() => onAction({ task, action: "receive" })} className="flex-1 rounded border border-blue-200 bg-blue-50 py-1 text-[10px] font-medium text-blue-600 hover:bg-blue-100"></button>
<button onClick={() => onAction({ task, action: "reject" })} className="rounded border border-red-200 bg-red-50 px-2 py-1 text-[10px] font-medium text-red-500 hover:bg-red-100"></button>
<button onClick={() => onAction({ task, action: "transfer" })} className="rounded border border-green-200 bg-green-50 px-2 py-1 text-[10px] font-medium text-green-600 hover:bg-green-100"></button>
</>
)}
{task.status?.toUpperCase() === TASK_STATUS.WIP && (
<>
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-red-200 bg-red-50 py-1 text-[10px] font-medium text-red-500 hover:bg-red-100"></button>
<button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-green-200 bg-green-50 py-1 text-[10px] font-medium text-green-600 hover:bg-green-100"></button>
</>
)}
{task.status?.toUpperCase() === TASK_STATUS.PENDING && (<>
<button onClick={() => onAction({ task, action: "receive" })} className="flex-1 rounded border border-blue-200 bg-blue-50 py-1 text-[10px] font-medium text-blue-600 hover:bg-blue-100"></button>
<button onClick={() => onAction({ task, action: "reject" })} className="rounded border border-red-200 bg-red-50 px-2 py-1 text-[10px] font-medium text-red-500 hover:bg-red-100"></button>
<button onClick={() => onAction({ task, action: "transfer" })} className="rounded border border-green-200 bg-green-50 px-2 py-1 text-[10px] font-medium text-green-600 hover:bg-green-100"></button>
</>)}
{task.status?.toUpperCase() === TASK_STATUS.WIP && (<>
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-red-200 bg-red-50 py-1 text-[10px] font-medium text-red-500 hover:bg-red-100"></button>
<button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-green-200 bg-green-50 py-1 text-[10px] font-medium text-green-600 hover:bg-green-100"></button>
</>)}
</div>
)}
{/* 管理员看别人:仅干预按钮 */}
{isActive && !isOwner && isManager && (
<div className="mt-2 border-t border-orange-100 pt-2">
<p className="mb-1 text-[9px] text-orange-500"> </p>
<p className="mb-1 text-[9px] text-orange-500"> </p>
<div className="flex gap-1.5">
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-1 text-[10px] font-medium text-orange-600 hover:bg-orange-100"></button>
<button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-1 text-[10px] font-medium text-orange-600 hover:bg-orange-100"></button>
</div>
</div>
)}
{/* 普通人看别人:什么按钮都没有 */}
{isActive && !isOwner && !isManager && (
<div className="mt-2 border-t border-gray-100 pt-2">
<p className="text-center text-[9px] text-gray-400"></p>
<p className="text-center text-[9px] text-gray-400"></p>
</div>
)}
</div>
@ -226,61 +213,41 @@ const FlowCard = memo(function FlowCard({
});
// ============================================================
// 记录查看弹窗 — 与移动端 records 页面样式对齐
// 记录弹窗
// ============================================================
export const RecordsModal = memo(function RecordsModal({
const RecordsModal = memo(function RecordsModal({
open, task, onClose,
}: {
open: boolean; task: TaskResponse | null; onClose: () => void;
}) {
}: { open: boolean; task: TaskResponse | null; onClose: () => void }) {
if (!open || !task) return null;
const records = task.records || [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 mx-4 w-full max-w-md max-h-[80vh] overflow-y-auto rounded-xl bg-white shadow-2xl">
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-gray-100 bg-white px-5 py-4 rounded-t-xl">
<div>
<h3 className="text-base font-bold text-gray-800"></h3>
<p className="text-xs text-gray-400">{task.task_name}</p>
</div>
<div><h3 className="text-base font-bold text-gray-800"></h3><p className="text-xs text-gray-400">{task.task_name}</p></div>
<button onClick={onClose} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X className="h-5 w-5" /></button>
</div>
<div className="px-5 py-3">
{records.length === 0 ? (
<div className="flex flex-col items-center py-12 text-gray-400">
<span className="text-4xl mb-2">📭</span>
<p className="text-sm"></p>
</div>
<div className="flex flex-col items-center py-12 text-gray-400"><span className="text-4xl mb-2">📭</span><p className="text-sm"></p></div>
) : (
<div className="space-y-3">
{[...records].reverse().map((rec, i) => (
<div key={rec.id} className="relative pl-6">
{/* 时间轴竖线 */}
<div className="absolute left-2 top-0 bottom-0 flex flex-col items-center">
<div className={`h-3 w-3 rounded-full ${i === 0 ? "bg-blue-500 shadow-[0_0_0_4px_rgba(37,99,235,0.15)]" : "bg-gray-300"}`} />
{i < records.length - 1 && <div className="flex-1 w-0.5 bg-gray-200 min-h-[12px]" />}
</div>
{/* 内容卡片 */}
<div className="rounded-lg bg-gray-50 px-3 py-2.5">
<p className="text-[11px] text-gray-400">{fmtTime(rec.created_at)}</p>
{rec.note && <p className="mt-1 text-sm text-gray-700 leading-relaxed">{rec.note}</p>}
{rec.remark && <p className="mt-1 text-sm text-gray-700 leading-relaxed">{rec.remark}</p>}
{/* 图片 */}
{(() => {
const imgs = parseImages((rec as any).images);
if (!imgs.length) return null;
return (
<div className="mt-2 flex gap-1.5 flex-wrap">
{imgs.map((img, j) => (
<img key={j} src={imageUrl(img)} alt="" className="h-16 w-16 rounded-lg border border-gray-200 object-cover cursor-pointer hover:opacity-80"
onClick={() => window.open(imageUrl(img), "_blank")} />
))}
</div>
);
return <div className="mt-2 flex gap-1.5 flex-wrap">{imgs.map((img, j) => <img key={j} src={imageUrl(img)} alt="" className="h-16 w-16 rounded-lg border border-gray-200 object-cover cursor-pointer hover:opacity-80" onClick={() => window.open(imageUrl(img), "_blank")} />)}</div>;
})()}
</div>
</div>
@ -294,26 +261,87 @@ export const RecordsModal = memo(function RecordsModal({
});
// ============================================================
// 停留耗时
// 🚀 递归任务节点 — 替代 flattenLevels
// ============================================================
function calcDwell(receivedAt: string | null, completedAt: string | null, status: string): { text: string; highlight: boolean } | null {
if (!receivedAt) return null;
const start = new Date(receivedAt).getTime();
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
const diffMs = end - start;
if (diffMs < 0) return null;
const totalMin = Math.floor(diffMs / 60000);
if (totalMin < 1) return { text: "< 1分钟", highlight: status === "WIP" };
if (totalMin < 60) return { text: `${totalMin}分钟`, highlight: status === "WIP" };
const hours = Math.floor(totalMin / 60);
const remainMin = totalMin % 60;
if (hours < 24) return { text: `${hours}小时${remainMin > 0 ? remainMin + "分钟" : ""}`, highlight: status === "WIP" };
const days = Math.floor(hours / 24);
const remainHr = hours % 24;
return { text: `${days}${remainHr > 0 ? remainHr + "小时" : ""}`, highlight: status === "WIP" };
interface CommonProps {
onAction: (t: ModalTarget) => void;
currentUser?: { username?: string; role?: string } | null;
assigneeNames?: Record<string, string>;
onViewRecords: (t: TaskResponse) => void;
}
const TaskNode = memo(function TaskNode({
task, isBranch, common,
}: {
task: TaskResponse; isBranch: boolean;
common: CommonProps;
}) {
const isActive = task.status?.toUpperCase() === TASK_STATUS.PENDING
|| task.status?.toUpperCase() === TASK_STATUS.WIP;
if (!task.child_tasks || task.child_tasks.length === 0) {
return (
<TaskCard task={task} isActive={isActive} onAction={common.onAction}
currentUser={common.currentUser}
assigneeName={common.assigneeNames?.[task.assignee_id || ""]}
isBranch={isBranch}
onViewRecords={common.onViewRecords} />
);
}
// 分子任务TRANSFER = 主干同缩进SPAWN = 分支(缩进)
const sorted = sortTasks(task.child_tasks);
const mainChildren = sorted.filter(c => isMainTask(c));
const spawnChildren = sorted.filter(c => !isMainTask(c));
return (
<div className="relative">
{/* 主干连接线 */}
{mainChildren.length > 0 && (
<div className="absolute left-[108px] top-full w-0.5 bg-gray-200 z-0" style={{ height: "1.5rem" }} />
)}
{/* 当前节点卡片 */}
<TaskCard task={task} isActive={isActive} onAction={common.onAction}
currentUser={common.currentUser}
assigneeName={common.assigneeNames?.[task.assignee_id || ""]}
isBranch={isBranch}
onViewRecords={common.onViewRecords} />
{/* TRANSFER 子任务:主干下一环,同缩进,带竖线连接 */}
{mainChildren.length > 0 && (
<div className="relative ml-0 mt-1 pl-0">
{/* 竖线 */}
<div className="absolute left-[108px] top-0 bottom-0 w-0.5 bg-gray-200" />
<div className="flex flex-col gap-2">
{mainChildren.map((child) => (
<div key={child.id} className="relative ml-0 pl-0">
{/* 横线连接 */}
<div className="absolute left-[108px] top-6 w-6 h-0.5 bg-gray-200" />
<div className="ml-6">
<TaskNode task={child} isBranch={false} common={common} />
</div>
</div>
))}
</div>
</div>
)}
{/* SPAWN 子任务:分支,缩进 + 紫色左边线 */}
{spawnChildren.length > 0 && (
<div className="ml-6 mt-2 border-l-2 border-purple-200 pl-4">
{spawnChildren.map((child, idx) => (
<div key={child.id} className={idx > 0 ? "mt-2" : ""}>
<TaskNode task={child} isBranch={true} common={common} />
</div>
))}
</div>
)}
</div>
);
});
// ============================================================
// 主视图
// ============================================================
@ -327,48 +355,23 @@ interface TaskFlowViewProps {
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) {
const [recordsTask, setRecordsTask] = useState<TaskResponse | null>(null);
if (!tasks || tasks.length === 0) return null;
const depthMap = useMemo(() => {
if (!tasks || tasks.length === 0) return new Map<number, TaskResponse[]>();
return groupByDepth(flattenLevels(tasks));
}, [tasks]);
const depths = Array.from(depthMap.keys()).sort((a, b) => a - b);
if (depths.length === 0) return null;
const common: CommonProps = { onAction, currentUser, assigneeNames, onViewRecords: setRecordsTask };
return (
<>
<div className="space-y-4">
{depths.map((depth) => {
const levelTasks = depthMap.get(depth) || [];
return (
<div key={depth}>
<div className="mb-2 flex items-center gap-2">
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">{depth === 0 ? "顶层任务" : `${depth} 层子任务`}</span>
<div className="h-px flex-1 bg-gray-100" />
</div>
<div className="flex gap-3 overflow-x-auto pb-2 pl-2" style={{ scrollSnapType: "x mandatory" }}>
{levelTasks.map((task) => {
const isActive = task.status?.toUpperCase() === TASK_STATUS.PENDING || task.status?.toUpperCase() === TASK_STATUS.WIP;
return (
<div key={task.id} style={{ scrollSnapAlign: "start" }}>
<FlowCard task={task} isActive={isActive} onAction={onAction}
currentUser={currentUser}
assigneeName={assigneeNames?.[task.assignee_id || ""]}
onViewRecords={setRecordsTask} />
</div>
);
})}
</div>
{depth < depths.length - 1 && (
<div className="flex justify-center py-1"><ArrowDown className="h-4 w-4 text-gray-300" /></div>
)}
</div>
);
})}
<div className="space-y-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider"></span>
<div className="h-px flex-1 bg-gray-100" />
</div>
<div className="flex flex-col gap-3 overflow-x-auto pb-2">
{sortTasks(tasks).map((task) => (
<TaskNode key={task.id} task={task} isBranch={false} common={common} />
))}
</div>
<RecordsModal open={!!recordsTask} task={recordsTask} onClose={() => setRecordsTask(null)} />
</>
</div>
);
});