refactor(tasks): separate board layout and realtime state
This commit is contained in:
parent
78c54aac0b
commit
0f8ae2095d
@ -9,6 +9,7 @@ import {
|
||||
closestCorners,
|
||||
pointerWithin,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
@ -83,10 +84,11 @@ import { CommentComposer, CommentDeleteButton } from "@/components/shared/Commen
|
||||
import { Modal } from "@/components/ui";
|
||||
import { useNotifications } from "@/contexts/NotificationsContext";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { useTaskBoardRealtime } from "@/hooks/useTaskBoardRealtime";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { displayUserName, formatDate, formatDateTime, formatMoney } from "@/lib/shared";
|
||||
import { buildTaskBoardLayout, taskPlacementKey } from "@/lib/tasks/boardLayout";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import wsManager from "@/lib/websocketManager";
|
||||
import type {
|
||||
CalendarEvent,
|
||||
Department,
|
||||
@ -197,17 +199,6 @@ type QuickTaskTarget = {
|
||||
rowId: number | null;
|
||||
};
|
||||
|
||||
type TaskBoardSocketEvent = {
|
||||
type: string;
|
||||
data?: {
|
||||
board_id?: number;
|
||||
event?: string;
|
||||
model?: string;
|
||||
object_id?: number | null;
|
||||
task_id?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const DESKTOP_WIDE_MODE_STORAGE_PREFIX = "tasks.desktop-wide-mode";
|
||||
const TASK_SUBCOLUMN_WIDTH = "18rem";
|
||||
const EMPTY_BOARD_VIEW_STATE: TaskBoardViewState = {
|
||||
@ -251,10 +242,6 @@ function updateBoardViewStateIds(
|
||||
else values.delete(objectId);
|
||||
return { ...state, [key]: Array.from(values) };
|
||||
}
|
||||
const taskPlacementKey = (columnId: number, rowId: number | null) => (
|
||||
`${columnId}:${rowId ?? "base"}`
|
||||
);
|
||||
|
||||
const emptyForm: TaskFormState = {
|
||||
id: null,
|
||||
title: "",
|
||||
@ -855,6 +842,67 @@ function QuickTaskComposer({
|
||||
);
|
||||
}
|
||||
|
||||
type TaskColumnCardsProps = {
|
||||
tasks: TaskCard[];
|
||||
onOpenTask: (task: TaskCard) => void;
|
||||
onEditTask: (task: TaskCard) => void;
|
||||
onDeleteTask: (task: TaskCard) => void;
|
||||
currentUserId?: number;
|
||||
claimingTaskId: number | null;
|
||||
onClaimTask: (task: TaskCard) => void;
|
||||
completingTaskId: number | null;
|
||||
onCompleteTask: (task: TaskCard) => void;
|
||||
openMenuTaskId: number | null;
|
||||
menuRef: RefObject<HTMLDivElement | null>;
|
||||
onToggleTaskMenu: (taskId: number) => void;
|
||||
quickTaskComposer?: ReactNode;
|
||||
};
|
||||
|
||||
function TaskColumnCards({
|
||||
tasks,
|
||||
onOpenTask,
|
||||
onEditTask,
|
||||
onDeleteTask,
|
||||
currentUserId,
|
||||
claimingTaskId,
|
||||
onClaimTask,
|
||||
completingTaskId,
|
||||
onCompleteTask,
|
||||
openMenuTaskId,
|
||||
menuRef,
|
||||
onToggleTaskMenu,
|
||||
quickTaskComposer,
|
||||
}: TaskColumnCardsProps) {
|
||||
return (
|
||||
<>
|
||||
{quickTaskComposer}
|
||||
{tasks.length > 0 ? (
|
||||
tasks.map((task) => (
|
||||
<TaskCardView
|
||||
key={task.id}
|
||||
task={task}
|
||||
onOpen={onOpenTask}
|
||||
onEdit={onEditTask}
|
||||
onDelete={onDeleteTask}
|
||||
currentUserId={currentUserId}
|
||||
claiming={claimingTaskId === task.id}
|
||||
onClaim={onClaimTask}
|
||||
completing={completingTaskId === task.id}
|
||||
onComplete={onCompleteTask}
|
||||
menuOpen={openMenuTaskId === task.id}
|
||||
menuRef={menuRef}
|
||||
onToggleMenu={() => onToggleTaskMenu(task.id)}
|
||||
/>
|
||||
))
|
||||
) : quickTaskComposer ? null : (
|
||||
<div className="app-surface rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-5 text-center">
|
||||
<p className="app-text-muted text-xs">Нет задач</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BoardColumn({
|
||||
column,
|
||||
tasks,
|
||||
@ -879,21 +927,12 @@ function BoardColumn({
|
||||
onCreateSubcolumn,
|
||||
onCreateRow,
|
||||
onColumnMount,
|
||||
taskRowId = null,
|
||||
columnSortable = true,
|
||||
showColumnActions = true,
|
||||
showHeader = true,
|
||||
showBody = true,
|
||||
showVisibilityToggle = false,
|
||||
contentVisible = true,
|
||||
onToggleContentVisibility,
|
||||
sharedGridCell = false,
|
||||
linkedColumnHighlight = false,
|
||||
synchronizedColumnDrag = false,
|
||||
fillAvailableHeight = false,
|
||||
hideOnMobile = false,
|
||||
nested = false,
|
||||
headerSticky = true,
|
||||
quickTaskComposer,
|
||||
}: {
|
||||
column: TaskColumn;
|
||||
@ -919,21 +958,12 @@ function BoardColumn({
|
||||
onCreateSubcolumn: (column: TaskColumn) => void;
|
||||
onCreateRow?: (column: TaskColumn) => void;
|
||||
onColumnMount: (columnId: number, node: HTMLElement | null) => void;
|
||||
taskRowId?: number | null;
|
||||
columnSortable?: boolean;
|
||||
showColumnActions?: boolean;
|
||||
showHeader?: boolean;
|
||||
showBody?: boolean;
|
||||
showVisibilityToggle?: boolean;
|
||||
contentVisible?: boolean;
|
||||
onToggleContentVisibility?: () => void;
|
||||
sharedGridCell?: boolean;
|
||||
linkedColumnHighlight?: boolean;
|
||||
synchronizedColumnDrag?: boolean;
|
||||
fillAvailableHeight?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
nested?: boolean;
|
||||
headerSticky?: boolean;
|
||||
quickTaskComposer?: ReactNode;
|
||||
}) {
|
||||
const {
|
||||
@ -946,26 +976,23 @@ function BoardColumn({
|
||||
isDragging,
|
||||
isOver,
|
||||
} = useSortable({
|
||||
id: columnSortable
|
||||
? `column-${column.id}`
|
||||
: `task-zone-${column.id}-row-${taskRowId ?? "base"}`,
|
||||
id: `column-${column.id}`,
|
||||
data: {
|
||||
type: columnSortable ? "column" : "task-zone",
|
||||
type: "column",
|
||||
columnId: column.id,
|
||||
rowId: taskRowId,
|
||||
rowId: null,
|
||||
acceptsTasks: true,
|
||||
},
|
||||
disabled: !columnSortable,
|
||||
});
|
||||
const style = {
|
||||
transform: columnSortable ? CSS.Transform.toString(transform) : undefined,
|
||||
transition: columnSortable ? transition : undefined,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 20 : undefined,
|
||||
};
|
||||
const setColumnNodeRef = useCallback((node: HTMLElement | null) => {
|
||||
setNodeRef(node);
|
||||
if (columnSortable) onColumnMount(column.id, node);
|
||||
}, [column.id, columnSortable, onColumnMount, setNodeRef]);
|
||||
onColumnMount(column.id, node);
|
||||
}, [column.id, onColumnMount, setNodeRef]);
|
||||
const columnBorderClass = linkedColumnHighlight || (isOver && !synchronizedColumnDrag)
|
||||
? "border-[var(--accent-primary)]"
|
||||
: "border-[var(--border-subtle)]";
|
||||
@ -975,26 +1002,24 @@ function BoardColumn({
|
||||
ref={setColumnNodeRef}
|
||||
style={style}
|
||||
className={`${hideOnMobile ? "hidden xl:flex" : "flex"} max-h-[calc(100dvh-15rem)] ${
|
||||
showBody && contentVisible ? (taskRowId ? "min-h-[10rem]" : "min-h-[28rem]") : ""
|
||||
} w-full min-w-0 flex-col transition xl:max-h-none ${
|
||||
sharedGridCell ? "xl:w-full xl:min-w-0" : "xl:w-auto xl:min-w-[18rem]"
|
||||
} ${fillAvailableHeight ? "xl:min-h-full" : ""} ${
|
||||
contentVisible ? "min-h-[28rem]" : ""
|
||||
} w-full min-w-0 flex-col transition xl:max-h-none xl:w-auto xl:min-w-[18rem] ${
|
||||
fillAvailableHeight ? "xl:min-h-full" : ""
|
||||
} ${
|
||||
isDragging ? "opacity-70" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`${showHeader ? "flex" : "hidden"} items-center justify-between gap-3 rounded-xl border bg-[var(--surface-primary)] px-3 py-3 shadow-sm transition ${headerSticky ? `xl:sticky ${nested ? "xl:top-[4.75rem]" : "xl:top-0"} xl:z-10` : ""} ${columnBorderClass} ${
|
||||
className={`flex items-center justify-between gap-3 rounded-xl border bg-[var(--surface-primary)] px-3 py-3 shadow-sm transition xl:sticky xl:top-0 xl:z-10 ${columnBorderClass} ${
|
||||
isDragging ? "shadow-xl" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
ref={setActivatorNodeRef}
|
||||
className={`min-w-0 flex-1 overflow-hidden ${
|
||||
columnSortable ? "cursor-grab touch-none active:cursor-grabbing" : ""
|
||||
}`}
|
||||
title={columnSortable ? "Перетащите, чтобы изменить порядок колонок" : undefined}
|
||||
{...(columnSortable ? attributes : {})}
|
||||
{...(columnSortable ? listeners : {})}
|
||||
className="min-w-0 flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
|
||||
title="Перетащите, чтобы изменить порядок колонок"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
@ -1010,30 +1035,28 @@ function BoardColumn({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{showVisibilityToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleContentVisibility?.();
|
||||
}}
|
||||
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
title={contentVisible ? "Скрыть содержимое колонки" : "Показать содержимое колонки"}
|
||||
aria-label={contentVisible
|
||||
? `Скрыть содержимое колонки ${column.name}`
|
||||
: `Показать содержимое колонки ${column.name}`}
|
||||
aria-pressed={!contentVisible}
|
||||
>
|
||||
{contentVisible ? <Eye size={15} /> : <EyeOff size={15} />}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onCreateTask(column.id, taskRowId);
|
||||
onToggleContentVisibility?.();
|
||||
}}
|
||||
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
title={contentVisible ? "Скрыть содержимое колонки" : "Показать содержимое колонки"}
|
||||
aria-label={contentVisible
|
||||
? `Скрыть содержимое колонки ${column.name}`
|
||||
: `Показать содержимое колонки ${column.name}`}
|
||||
aria-pressed={!contentVisible}
|
||||
>
|
||||
{contentVisible ? <Eye size={15} /> : <EyeOff size={15} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onCreateTask(column.id, null);
|
||||
}}
|
||||
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
title="Создать задачу"
|
||||
@ -1041,7 +1064,7 @@ function BoardColumn({
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
{showColumnActions ? <div
|
||||
<div
|
||||
ref={columnMenuOpen ? columnMenuRef : null}
|
||||
className="relative"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
@ -1101,41 +1124,230 @@ function BoardColumn({
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showBody && contentVisible ? <div
|
||||
className={`tasks-column-scroll ${showHeader ? "mt-2" : ""} min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border bg-[var(--surface-primary)] p-3 transition xl:flex-none xl:overflow-visible ${columnBorderClass}`}
|
||||
{contentVisible ? <div
|
||||
className={`tasks-column-scroll mt-2 min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border bg-[var(--surface-primary)] p-3 transition xl:flex-none xl:overflow-visible ${columnBorderClass}`}
|
||||
>
|
||||
{quickTaskComposer}
|
||||
{tasks.length > 0 ? (
|
||||
tasks.map((task) => (
|
||||
<TaskCardView
|
||||
key={task.id}
|
||||
task={task}
|
||||
onOpen={onOpenTask}
|
||||
onEdit={onEditTask}
|
||||
onDelete={onDeleteTask}
|
||||
currentUserId={currentUserId}
|
||||
claiming={claimingTaskId === task.id}
|
||||
onClaim={onClaimTask}
|
||||
completing={completingTaskId === task.id}
|
||||
onComplete={onCompleteTask}
|
||||
menuOpen={openMenuTaskId === task.id}
|
||||
menuRef={menuRef}
|
||||
onToggleMenu={() => onToggleTaskMenu(task.id)}
|
||||
/>
|
||||
))
|
||||
) : quickTaskComposer ? null : (
|
||||
<div className="app-surface rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-5 text-center">
|
||||
<p className="app-text-muted text-xs">Нет задач</p>
|
||||
</div>
|
||||
)}
|
||||
<TaskColumnCards
|
||||
tasks={tasks}
|
||||
onOpenTask={onOpenTask}
|
||||
onEditTask={onEditTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
currentUserId={currentUserId}
|
||||
claimingTaskId={claimingTaskId}
|
||||
onClaimTask={onClaimTask}
|
||||
completingTaskId={completingTaskId}
|
||||
onCompleteTask={onCompleteTask}
|
||||
openMenuTaskId={openMenuTaskId}
|
||||
menuRef={menuRef}
|
||||
onToggleTaskMenu={onToggleTaskMenu}
|
||||
quickTaskComposer={quickTaskComposer}
|
||||
/>
|
||||
</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type BoardSubcolumnHeaderProps = {
|
||||
column: TaskColumn;
|
||||
tasksCount: number;
|
||||
sortable: boolean;
|
||||
onCreateTask: () => void;
|
||||
columnMenuOpen: boolean;
|
||||
columnMenuRef: RefObject<HTMLDivElement | null>;
|
||||
onToggleColumnMenu: () => void;
|
||||
onEditColumn: (column: TaskColumn) => void;
|
||||
onDeleteColumn: (column: TaskColumn) => void;
|
||||
onColumnMount: (columnId: number, node: HTMLElement | null) => void;
|
||||
linkedColumnHighlight: boolean;
|
||||
synchronizedColumnDrag: boolean;
|
||||
};
|
||||
|
||||
function BoardSubcolumnHeader({
|
||||
column,
|
||||
tasksCount,
|
||||
sortable,
|
||||
onCreateTask,
|
||||
columnMenuOpen,
|
||||
columnMenuRef,
|
||||
onToggleColumnMenu,
|
||||
onEditColumn,
|
||||
onDeleteColumn,
|
||||
onColumnMount,
|
||||
linkedColumnHighlight,
|
||||
synchronizedColumnDrag,
|
||||
}: BoardSubcolumnHeaderProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setActivatorNodeRef,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
isOver,
|
||||
} = useSortable({
|
||||
id: `column-${column.id}`,
|
||||
data: {
|
||||
type: "column",
|
||||
columnId: column.id,
|
||||
acceptsTasks: true,
|
||||
},
|
||||
disabled: !sortable,
|
||||
});
|
||||
const setHeaderNodeRef = useCallback((node: HTMLElement | null) => {
|
||||
setNodeRef(node);
|
||||
if (sortable) onColumnMount(column.id, node);
|
||||
}, [column.id, onColumnMount, setNodeRef, sortable]);
|
||||
const columnBorderClass = linkedColumnHighlight || (isOver && !synchronizedColumnDrag)
|
||||
? "border-[var(--accent-primary)]"
|
||||
: "border-[var(--border-subtle)]";
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={setHeaderNodeRef}
|
||||
style={{
|
||||
transform: sortable ? CSS.Transform.toString(transform) : undefined,
|
||||
transition: sortable ? transition : undefined,
|
||||
zIndex: isDragging ? 20 : undefined,
|
||||
}}
|
||||
className={`flex w-full min-w-0 flex-col transition ${isDragging ? "opacity-70" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-between gap-3 rounded-xl border bg-[var(--surface-primary)] px-3 py-3 shadow-sm transition ${columnBorderClass} ${
|
||||
isDragging ? "shadow-xl" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
ref={setActivatorNodeRef}
|
||||
className={`min-w-0 flex-1 overflow-hidden ${
|
||||
sortable ? "cursor-grab touch-none active:cursor-grabbing" : ""
|
||||
}`}
|
||||
title={sortable ? "Перетащите, чтобы изменить порядок подколонок" : undefined}
|
||||
{...(sortable ? attributes : {})}
|
||||
{...(sortable ? listeners : {})}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: column.color || "#38bdf8" }}
|
||||
/>
|
||||
<h2 className="min-w-0 truncate text-sm font-semibold text-[var(--foreground)]">
|
||||
{column.name}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="app-text-muted mt-0.5 text-xs">{tasksCount} задач</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onCreateTask();
|
||||
}}
|
||||
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
title="Создать задачу"
|
||||
aria-label="Создать задачу"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
{sortable ? (
|
||||
<div
|
||||
ref={columnMenuOpen ? columnMenuRef : null}
|
||||
className="relative"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleColumnMenu}
|
||||
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
title="Действия с подколонкой"
|
||||
aria-label={`Действия с подколонкой ${column.name}`}
|
||||
aria-expanded={columnMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform ${columnMenuOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
</button>
|
||||
{columnMenuOpen ? (
|
||||
<div className="app-menu absolute right-0 top-full z-30 mt-2 w-44 rounded-lg p-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditColumn(column)}
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Pencil size={14} className="app-text-muted" />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteColumn(column)}
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type BoardTaskCellProps = TaskColumnCardsProps & {
|
||||
column: TaskColumn;
|
||||
rowId: number | null;
|
||||
linkedColumnHighlight: boolean;
|
||||
synchronizedColumnDrag: boolean;
|
||||
fillAvailableHeight: boolean;
|
||||
};
|
||||
|
||||
function BoardTaskCell({
|
||||
column,
|
||||
rowId,
|
||||
linkedColumnHighlight,
|
||||
synchronizedColumnDrag,
|
||||
fillAvailableHeight,
|
||||
...cardsProps
|
||||
}: BoardTaskCellProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `task-zone-${column.id}-row-${rowId ?? "base"}`,
|
||||
data: {
|
||||
type: "task-zone",
|
||||
columnId: column.id,
|
||||
rowId,
|
||||
acceptsTasks: true,
|
||||
},
|
||||
});
|
||||
const columnBorderClass = linkedColumnHighlight || (isOver && !synchronizedColumnDrag)
|
||||
? "border-[var(--accent-primary)]"
|
||||
: "border-[var(--border-subtle)]";
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={setNodeRef}
|
||||
className={`flex w-full min-w-0 flex-col transition ${
|
||||
rowId ? "min-h-[10rem]" : "min-h-[28rem]"
|
||||
} xl:w-full ${fillAvailableHeight ? "xl:min-h-full" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`tasks-column-scroll min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border bg-[var(--surface-primary)] p-3 transition xl:flex-none xl:overflow-visible ${columnBorderClass}`}
|
||||
>
|
||||
<TaskColumnCards {...cardsProps} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BoardColumnGroup({
|
||||
column,
|
||||
tasksCount,
|
||||
@ -3162,7 +3374,6 @@ function TasksPageContent() {
|
||||
const selectedBoardIdRef = useRef<number | null>(null);
|
||||
const boardViewStateRef = useRef<TaskBoardViewState>(EMPTY_BOARD_VIEW_STATE);
|
||||
const pendingBoardViewStateRef = useRef<Map<string, boolean>>(new Map());
|
||||
const taskBoardSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const boardScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const boardPanRef = useRef<{
|
||||
pointerId: number;
|
||||
@ -3693,73 +3904,16 @@ function TasksPageContent() {
|
||||
}
|
||||
}, [loadBoard, loadBoards]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
const unsubscribe = wsManager.subscribe((event) => {
|
||||
const data = event as TaskBoardSocketEvent;
|
||||
if (data.type !== "task_board_update") return;
|
||||
|
||||
const boardId = Number(data.data?.board_id);
|
||||
if (!Number.isFinite(boardId) || boardId <= 0) return;
|
||||
|
||||
const eventTaskId = Number(
|
||||
data.data?.task_id ?? (
|
||||
data.data?.model === "task" ? data.data?.object_id : undefined
|
||||
),
|
||||
);
|
||||
if (
|
||||
data.data?.model === "checklist_item" &&
|
||||
checklistOpen &&
|
||||
viewTaskId &&
|
||||
eventTaskId === viewTaskId
|
||||
) {
|
||||
void loadTaskChecklist(viewTaskId);
|
||||
}
|
||||
|
||||
if (
|
||||
commentsOpen &&
|
||||
viewTaskId &&
|
||||
eventTaskId === viewTaskId &&
|
||||
(
|
||||
data.data?.event === "commented" ||
|
||||
data.data?.event === "comment_edited" ||
|
||||
data.data?.event === "comment_deleted"
|
||||
)
|
||||
) {
|
||||
void loadTaskComments(viewTaskId);
|
||||
}
|
||||
|
||||
if (activityOpen && viewTaskId && eventTaskId === viewTaskId) {
|
||||
void loadTaskActivity(viewTaskId);
|
||||
}
|
||||
|
||||
if (taskBoardSyncTimerRef.current) {
|
||||
clearTimeout(taskBoardSyncTimerRef.current);
|
||||
}
|
||||
taskBoardSyncTimerRef.current = setTimeout(() => {
|
||||
taskBoardSyncTimerRef.current = null;
|
||||
void syncTaskBoardState(boardId);
|
||||
}, 250);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (taskBoardSyncTimerRef.current) {
|
||||
clearTimeout(taskBoardSyncTimerRef.current);
|
||||
taskBoardSyncTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
activityOpen,
|
||||
useTaskBoardRealtime({
|
||||
viewTaskId,
|
||||
checklistOpen,
|
||||
commentsOpen,
|
||||
loadTaskActivity,
|
||||
loadTaskChecklist,
|
||||
loadTaskComments,
|
||||
syncTaskBoardState,
|
||||
viewTaskId,
|
||||
]);
|
||||
activityOpen,
|
||||
onChecklistChange: (taskId) => void loadTaskChecklist(taskId),
|
||||
onCommentsChange: (taskId) => void loadTaskComments(taskId),
|
||||
onActivityChange: (taskId) => void loadTaskActivity(taskId),
|
||||
onBoardChange: (boardId) => void syncTaskBoardState(boardId),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@ -4015,88 +4169,19 @@ function TasksPageContent() {
|
||||
[onlyMine, searchedTasks, user?.id],
|
||||
);
|
||||
|
||||
const tasksByPlacement = useMemo(() => {
|
||||
const grouped = new Map<string, TaskCard[]>();
|
||||
for (const task of filteredTasks) {
|
||||
const key = taskPlacementKey(task.column, task.row || null);
|
||||
const list = grouped.get(key) || [];
|
||||
list.push(task);
|
||||
grouped.set(key, list);
|
||||
}
|
||||
for (const list of grouped.values()) {
|
||||
list.sort((left, right) => left.position - right.position || left.id - right.id);
|
||||
}
|
||||
return grouped;
|
||||
}, [filteredTasks]);
|
||||
|
||||
const activeColumns = useMemo(
|
||||
() => (board?.columns || []).filter((column) => !column.is_archived),
|
||||
[board?.columns],
|
||||
const {
|
||||
activeColumns,
|
||||
topLevelColumns,
|
||||
subcolumnsByParent,
|
||||
rowsByColumn,
|
||||
leafColumns,
|
||||
columnCounts,
|
||||
tasksByPlacement,
|
||||
} = useMemo(
|
||||
() => buildTaskBoardLayout(board?.columns, board?.column_rows, filteredTasks),
|
||||
[board?.column_rows, board?.columns, filteredTasks],
|
||||
);
|
||||
|
||||
const topLevelColumns = useMemo(
|
||||
() => activeColumns
|
||||
.filter((column) => !column.parent)
|
||||
.sort((left, right) => left.position - right.position || left.id - right.id),
|
||||
[activeColumns],
|
||||
);
|
||||
|
||||
const subcolumnsByParent = useMemo(() => {
|
||||
const grouped = new Map<number, TaskColumn[]>();
|
||||
for (const column of activeColumns) {
|
||||
if (!column.parent) continue;
|
||||
const siblings = grouped.get(column.parent) || [];
|
||||
siblings.push(column);
|
||||
grouped.set(column.parent, siblings);
|
||||
}
|
||||
for (const siblings of grouped.values()) {
|
||||
siblings.sort((left, right) => left.position - right.position || left.id - right.id);
|
||||
}
|
||||
return grouped;
|
||||
}, [activeColumns]);
|
||||
|
||||
const rowsByColumn = useMemo(() => {
|
||||
const grouped = new Map<number, TaskColumnRow[]>();
|
||||
for (const row of board?.column_rows || []) {
|
||||
const rows = grouped.get(row.column) || [];
|
||||
rows.push(row);
|
||||
grouped.set(row.column, rows);
|
||||
}
|
||||
for (const rows of grouped.values()) {
|
||||
rows.sort((left, right) => left.position - right.position || left.id - right.id);
|
||||
}
|
||||
return grouped;
|
||||
}, [board?.column_rows]);
|
||||
|
||||
const leafColumns = useMemo(
|
||||
() => topLevelColumns.flatMap((column) => {
|
||||
const subcolumns = subcolumnsByParent.get(column.id) || [];
|
||||
return subcolumns.length > 0 ? subcolumns : [column];
|
||||
}),
|
||||
[subcolumnsByParent, topLevelColumns],
|
||||
);
|
||||
|
||||
const columnCounts = useMemo(() => {
|
||||
const counts = new Map<number, number>();
|
||||
for (const column of activeColumns) counts.set(column.id, 0);
|
||||
for (const task of filteredTasks) {
|
||||
counts.set(task.column, (counts.get(task.column) || 0) + 1);
|
||||
}
|
||||
for (const column of topLevelColumns) {
|
||||
const subcolumns = subcolumnsByParent.get(column.id) || [];
|
||||
if (subcolumns.length > 0) {
|
||||
counts.set(
|
||||
column.id,
|
||||
(counts.get(column.id) || 0) + subcolumns.reduce(
|
||||
(total, subcolumn) => total + (counts.get(subcolumn.id) || 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [activeColumns, filteredTasks, subcolumnsByParent, topLevelColumns]);
|
||||
|
||||
const mineCount = useMemo(
|
||||
() => searchedTasks.filter((task) => task.assignee?.id === user?.id).length,
|
||||
[searchedTasks, user?.id],
|
||||
@ -6856,7 +6941,6 @@ function TasksPageContent() {
|
||||
onCreateSubcolumn={(parent) => openCreateColumn(parent.id)}
|
||||
onCreateRow={(parent) => openCreateColumnRow(parent.id)}
|
||||
onColumnMount={registerColumnNode}
|
||||
showVisibilityToggle
|
||||
contentVisible={!boardViewState.hidden_column_ids.includes(column.id)}
|
||||
onToggleContentVisibility={() => toggleBoardViewState("column", column.id)}
|
||||
linkedColumnHighlight={activeColumnDragId === column.id}
|
||||
@ -6911,20 +6995,15 @@ function TasksPageContent() {
|
||||
0,
|
||||
);
|
||||
const gridTemplateColumns = `repeat(${cellColumns.length}, ${TASK_SUBCOLUMN_WIDTH})`;
|
||||
const renderCell = (
|
||||
const renderTaskCell = (
|
||||
cellColumn: TaskColumn,
|
||||
rowId: number | null,
|
||||
sortable: boolean,
|
||||
showHeaderValue = rowId === null,
|
||||
showBodyValue = true,
|
||||
headerStickyValue = true,
|
||||
) => (
|
||||
<BoardColumn
|
||||
key={`${cellColumn.id}:${rowId ?? "base"}`}
|
||||
<BoardTaskCell
|
||||
key={`cell-${cellColumn.id}:${rowId ?? "base"}`}
|
||||
column={cellColumn}
|
||||
rowId={rowId}
|
||||
tasks={tasksByPlacement.get(taskPlacementKey(cellColumn.id, rowId)) || []}
|
||||
displayTasksCount={columnCounts.get(cellColumn.id) || 0}
|
||||
onCreateTask={startQuickTask}
|
||||
onOpenTask={openTaskView}
|
||||
onEditTask={openEditTask}
|
||||
onDeleteTask={deleteTask}
|
||||
@ -6936,25 +7015,30 @@ function TasksPageContent() {
|
||||
openMenuTaskId={cardMenuTaskId}
|
||||
menuRef={cardMenuRef}
|
||||
onToggleTaskMenu={toggleCardTaskMenu}
|
||||
linkedColumnHighlight={activeColumnDragId === cellColumn.id}
|
||||
synchronizedColumnDrag={activeColumnDragId !== null}
|
||||
fillAvailableHeight={desktopWideMode && rowId === null}
|
||||
quickTaskComposer={renderQuickTaskComposer(cellColumn, rowId)}
|
||||
/>
|
||||
);
|
||||
const renderSubcolumnHeader = (
|
||||
cellColumn: TaskColumn,
|
||||
sortable: boolean,
|
||||
) => (
|
||||
<BoardSubcolumnHeader
|
||||
key={`header-${cellColumn.id}`}
|
||||
column={cellColumn}
|
||||
tasksCount={columnCounts.get(cellColumn.id) || 0}
|
||||
sortable={sortable}
|
||||
onCreateTask={() => startQuickTask(cellColumn.id)}
|
||||
columnMenuOpen={sortable && columnMenuId === cellColumn.id}
|
||||
columnMenuRef={columnMenuRef}
|
||||
onToggleColumnMenu={() => toggleColumnMenu(cellColumn.id)}
|
||||
onEditColumn={openEditColumn}
|
||||
onDeleteColumn={deleteColumn}
|
||||
onCreateSubcolumn={(parent) => openCreateColumn(parent.id)}
|
||||
onColumnMount={registerColumnNode}
|
||||
taskRowId={rowId}
|
||||
columnSortable={sortable}
|
||||
showColumnActions={sortable}
|
||||
showHeader={showHeaderValue}
|
||||
showBody={showBodyValue}
|
||||
sharedGridCell
|
||||
linkedColumnHighlight={activeColumnDragId === cellColumn.id}
|
||||
synchronizedColumnDrag={activeColumnDragId !== null}
|
||||
fillAvailableHeight={desktopWideMode && rowId === null}
|
||||
nested={Boolean(cellColumn.parent)}
|
||||
headerSticky={headerStickyValue}
|
||||
quickTaskComposer={renderQuickTaskComposer(cellColumn, rowId)}
|
||||
/>
|
||||
);
|
||||
const baseColumns = (
|
||||
@ -6967,7 +7051,7 @@ function TasksPageContent() {
|
||||
attachedToHeaders
|
||||
>
|
||||
{previewCellColumns.map((item) => (
|
||||
renderCell(item, null, false, false, true)
|
||||
renderTaskCell(item, null)
|
||||
))}
|
||||
</BoardBaseColumnsView>
|
||||
);
|
||||
@ -6977,9 +7061,9 @@ function TasksPageContent() {
|
||||
items={subcolumns.map((item) => `column-${item.id}`)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{subcolumns.map((item) => renderCell(item, null, true, true, false, false))}
|
||||
{subcolumns.map((item) => renderSubcolumnHeader(item, true))}
|
||||
</SortableContext>
|
||||
) : renderCell(column, null, false, true, false, false);
|
||||
) : renderSubcolumnHeader(column, false);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col xl:w-max">
|
||||
@ -7006,7 +7090,7 @@ function TasksPageContent() {
|
||||
expanded={!boardViewState.collapsed_row_ids.includes(row.id)}
|
||||
onToggle={() => toggleBoardViewState("row", row.id)}
|
||||
>
|
||||
{previewCellColumns.map((item) => renderCell(item, row.id, false))}
|
||||
{previewCellColumns.map((item) => renderTaskCell(item, row.id))}
|
||||
</BoardColumnRowView>
|
||||
);
|
||||
})}
|
||||
|
||||
65
frontend/src/hooks/useTaskBoardRealtime.ts
Normal file
65
frontend/src/hooks/useTaskBoardRealtime.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { parseTaskBoardSocketUpdate } from "@/lib/tasks/taskBoardEvents";
|
||||
import wsManager from "@/lib/websocketManager";
|
||||
|
||||
type TaskBoardRealtimeOptions = {
|
||||
viewTaskId: number | null;
|
||||
checklistOpen: boolean;
|
||||
commentsOpen: boolean;
|
||||
activityOpen: boolean;
|
||||
onChecklistChange: (taskId: number) => void;
|
||||
onCommentsChange: (taskId: number) => void;
|
||||
onActivityChange: (taskId: number) => void;
|
||||
onBoardChange: (boardId: number) => void;
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
||||
const COMMENT_EVENTS = new Set([
|
||||
"commented",
|
||||
"comment_edited",
|
||||
"comment_deleted",
|
||||
]);
|
||||
|
||||
export function useTaskBoardRealtime(options: TaskBoardRealtimeOptions): void {
|
||||
const optionsRef = useRef(options);
|
||||
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
optionsRef.current = options;
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = wsManager.subscribe((event) => {
|
||||
const update = parseTaskBoardSocketUpdate(event);
|
||||
if (!update) return;
|
||||
|
||||
const current = optionsRef.current;
|
||||
if (update.taskId && update.taskId === current.viewTaskId) {
|
||||
if (update.model === "checklist_item" && current.checklistOpen) {
|
||||
current.onChecklistChange(update.taskId);
|
||||
}
|
||||
if (current.commentsOpen && COMMENT_EVENTS.has(update.event)) {
|
||||
current.onCommentsChange(update.taskId);
|
||||
}
|
||||
if (current.activityOpen) {
|
||||
current.onActivityChange(update.taskId);
|
||||
}
|
||||
}
|
||||
|
||||
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
||||
syncTimerRef.current = setTimeout(() => {
|
||||
syncTimerRef.current = null;
|
||||
optionsRef.current.onBoardChange(update.boardId);
|
||||
}, current.debounceMs ?? 250);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (syncTimerRef.current) {
|
||||
clearTimeout(syncTimerRef.current);
|
||||
syncTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
47
frontend/src/lib/task-board-events.test.js
Normal file
47
frontend/src/lib/task-board-events.test.js
Normal file
@ -0,0 +1,47 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { parseTaskBoardSocketUpdate } from "./tasks/taskBoardEvents.ts";
|
||||
|
||||
test("task board event parser normalizes task updates", () => {
|
||||
assert.deepEqual(parseTaskBoardSocketUpdate({
|
||||
type: "task_board_update",
|
||||
data: {
|
||||
board_id: "7",
|
||||
model: "task",
|
||||
object_id: "42",
|
||||
event: "updated",
|
||||
},
|
||||
}), {
|
||||
boardId: 7,
|
||||
taskId: 42,
|
||||
model: "task",
|
||||
event: "updated",
|
||||
});
|
||||
});
|
||||
|
||||
test("task board event parser keeps explicit parent task ids", () => {
|
||||
assert.deepEqual(parseTaskBoardSocketUpdate({
|
||||
type: "task_board_update",
|
||||
data: {
|
||||
board_id: 3,
|
||||
model: "checklist_item",
|
||||
object_id: 91,
|
||||
task_id: 12,
|
||||
event: "updated",
|
||||
},
|
||||
}), {
|
||||
boardId: 3,
|
||||
taskId: 12,
|
||||
model: "checklist_item",
|
||||
event: "updated",
|
||||
});
|
||||
});
|
||||
|
||||
test("task board event parser rejects unrelated and invalid events", () => {
|
||||
assert.equal(parseTaskBoardSocketUpdate({ type: "notification" }), null);
|
||||
assert.equal(parseTaskBoardSocketUpdate({
|
||||
type: "task_board_update",
|
||||
data: { board_id: 0 },
|
||||
}), null);
|
||||
});
|
||||
101
frontend/src/lib/task-board-layout.test.js
Normal file
101
frontend/src/lib/task-board-layout.test.js
Normal file
@ -0,0 +1,101 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildTaskBoardLayout,
|
||||
taskPlacementKey,
|
||||
} from "./tasks/boardLayout.ts";
|
||||
|
||||
const column = (id, position, parent = null, isArchived = false) => ({
|
||||
id,
|
||||
board: 1,
|
||||
parent,
|
||||
name: `column-${id}`,
|
||||
position,
|
||||
is_done: false,
|
||||
is_archived: isArchived,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
});
|
||||
|
||||
const row = (id, columnId, position) => ({
|
||||
id,
|
||||
column: columnId,
|
||||
name: `row-${id}`,
|
||||
position,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
});
|
||||
|
||||
const task = (id, columnId, rowId, position) => ({
|
||||
id,
|
||||
board: 1,
|
||||
column: columnId,
|
||||
row: rowId,
|
||||
title: `task-${id}`,
|
||||
priority: "medium",
|
||||
position,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
});
|
||||
|
||||
test("board layout orders columns, subcolumns and rows without mutating source arrays", () => {
|
||||
const columns = [
|
||||
column(2, 2000),
|
||||
column(1, 1000),
|
||||
column(12, 2000, 1),
|
||||
column(11, 1000, 1),
|
||||
column(99, 0, null, true),
|
||||
];
|
||||
const rows = [row(2, 1, 2000), row(1, 1, 1000)];
|
||||
|
||||
const layout = buildTaskBoardLayout(columns, rows, []);
|
||||
|
||||
assert.deepEqual(layout.activeColumns.map(({ id }) => id), [2, 1, 12, 11]);
|
||||
assert.deepEqual(layout.topLevelColumns.map(({ id }) => id), [1, 2]);
|
||||
assert.deepEqual(layout.subcolumnsByParent.get(1)?.map(({ id }) => id), [11, 12]);
|
||||
assert.deepEqual(layout.rowsByColumn.get(1)?.map(({ id }) => id), [1, 2]);
|
||||
assert.deepEqual(layout.leafColumns.map(({ id }) => id), [11, 12, 2]);
|
||||
assert.deepEqual(columns.map(({ id }) => id), [2, 1, 12, 11, 99]);
|
||||
assert.deepEqual(rows.map(({ id }) => id), [2, 1]);
|
||||
});
|
||||
|
||||
test("board layout groups and orders tasks by subcolumn and lane", () => {
|
||||
const tasks = [
|
||||
task(3, 11, null, 2000),
|
||||
task(2, 11, 7, 1000),
|
||||
task(1, 11, null, 1000),
|
||||
];
|
||||
|
||||
const layout = buildTaskBoardLayout(
|
||||
[column(1, 1000), column(11, 1000, 1)],
|
||||
[row(7, 1, 1000)],
|
||||
tasks,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
layout.tasksByPlacement.get(taskPlacementKey(11, null))?.map(({ id }) => id),
|
||||
[1, 3],
|
||||
);
|
||||
assert.deepEqual(
|
||||
layout.tasksByPlacement.get(taskPlacementKey(11, 7))?.map(({ id }) => id),
|
||||
[2],
|
||||
);
|
||||
});
|
||||
|
||||
test("top-level column count includes direct tasks and all subcolumn tasks", () => {
|
||||
const layout = buildTaskBoardLayout(
|
||||
[column(1, 1000), column(11, 1000, 1), column(12, 2000, 1)],
|
||||
[],
|
||||
[
|
||||
task(1, 1, null, 1000),
|
||||
task(2, 11, null, 1000),
|
||||
task(3, 12, null, 1000),
|
||||
task(4, 12, null, 2000),
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(layout.columnCounts.get(1), 4);
|
||||
assert.equal(layout.columnCounts.get(11), 1);
|
||||
assert.equal(layout.columnCounts.get(12), 2);
|
||||
});
|
||||
96
frontend/src/lib/tasks/boardLayout.ts
Normal file
96
frontend/src/lib/tasks/boardLayout.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import type { TaskCard, TaskColumn, TaskColumnRow } from "@/types/api";
|
||||
|
||||
export type TaskBoardLayout = {
|
||||
activeColumns: TaskColumn[];
|
||||
topLevelColumns: TaskColumn[];
|
||||
subcolumnsByParent: Map<number, TaskColumn[]>;
|
||||
rowsByColumn: Map<number, TaskColumnRow[]>;
|
||||
leafColumns: TaskColumn[];
|
||||
columnCounts: Map<number, number>;
|
||||
tasksByPlacement: Map<string, TaskCard[]>;
|
||||
};
|
||||
|
||||
function byPositionThenId<T extends { id: number; position: number }>(
|
||||
left: T,
|
||||
right: T,
|
||||
): number {
|
||||
return left.position - right.position || left.id - right.id;
|
||||
}
|
||||
|
||||
export function taskPlacementKey(columnId: number, rowId: number | null): string {
|
||||
return `${columnId}:${rowId ?? "base"}`;
|
||||
}
|
||||
|
||||
export function buildTaskBoardLayout(
|
||||
columns: TaskColumn[] | null | undefined,
|
||||
rows: TaskColumnRow[] | null | undefined,
|
||||
tasks: TaskCard[] | null | undefined,
|
||||
): TaskBoardLayout {
|
||||
const activeColumns = (columns || []).filter((column) => !column.is_archived);
|
||||
const topLevelColumns = activeColumns
|
||||
.filter((column) => !column.parent)
|
||||
.sort(byPositionThenId);
|
||||
|
||||
const subcolumnsByParent = new Map<number, TaskColumn[]>();
|
||||
for (const column of activeColumns) {
|
||||
if (!column.parent) continue;
|
||||
const siblings = subcolumnsByParent.get(column.parent) || [];
|
||||
siblings.push(column);
|
||||
subcolumnsByParent.set(column.parent, siblings);
|
||||
}
|
||||
for (const siblings of subcolumnsByParent.values()) {
|
||||
siblings.sort(byPositionThenId);
|
||||
}
|
||||
|
||||
const rowsByColumn = new Map<number, TaskColumnRow[]>();
|
||||
for (const row of rows || []) {
|
||||
const columnRows = rowsByColumn.get(row.column) || [];
|
||||
columnRows.push(row);
|
||||
rowsByColumn.set(row.column, columnRows);
|
||||
}
|
||||
for (const columnRows of rowsByColumn.values()) {
|
||||
columnRows.sort(byPositionThenId);
|
||||
}
|
||||
|
||||
const leafColumns = topLevelColumns.flatMap((column) => {
|
||||
const subcolumns = subcolumnsByParent.get(column.id) || [];
|
||||
return subcolumns.length > 0 ? subcolumns : [column];
|
||||
});
|
||||
|
||||
const tasksByPlacement = new Map<string, TaskCard[]>();
|
||||
const columnCounts = new Map<number, number>();
|
||||
for (const column of activeColumns) columnCounts.set(column.id, 0);
|
||||
|
||||
for (const task of tasks || []) {
|
||||
const key = taskPlacementKey(task.column, task.row || null);
|
||||
const placementTasks = tasksByPlacement.get(key) || [];
|
||||
placementTasks.push(task);
|
||||
tasksByPlacement.set(key, placementTasks);
|
||||
columnCounts.set(task.column, (columnCounts.get(task.column) || 0) + 1);
|
||||
}
|
||||
for (const placementTasks of tasksByPlacement.values()) {
|
||||
placementTasks.sort(byPositionThenId);
|
||||
}
|
||||
|
||||
for (const column of topLevelColumns) {
|
||||
const subcolumns = subcolumnsByParent.get(column.id) || [];
|
||||
if (subcolumns.length === 0) continue;
|
||||
columnCounts.set(
|
||||
column.id,
|
||||
(columnCounts.get(column.id) || 0) + subcolumns.reduce(
|
||||
(total, subcolumn) => total + (columnCounts.get(subcolumn.id) || 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
activeColumns,
|
||||
topLevelColumns,
|
||||
subcolumnsByParent,
|
||||
rowsByColumn,
|
||||
leafColumns,
|
||||
columnCounts,
|
||||
tasksByPlacement,
|
||||
};
|
||||
}
|
||||
44
frontend/src/lib/tasks/taskBoardEvents.ts
Normal file
44
frontend/src/lib/tasks/taskBoardEvents.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export type TaskBoardSocketUpdate = {
|
||||
boardId: number;
|
||||
taskId: number | null;
|
||||
event: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
type TaskBoardSocketEvent = {
|
||||
type?: unknown;
|
||||
data?: {
|
||||
board_id?: unknown;
|
||||
event?: unknown;
|
||||
model?: unknown;
|
||||
object_id?: unknown;
|
||||
task_id?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export function parseTaskBoardSocketUpdate(
|
||||
event: unknown,
|
||||
): TaskBoardSocketUpdate | null {
|
||||
const socketEvent = event as TaskBoardSocketEvent;
|
||||
if (socketEvent?.type !== "task_board_update") return null;
|
||||
|
||||
const boardId = Number(socketEvent.data?.board_id);
|
||||
if (!Number.isFinite(boardId) || boardId <= 0) return null;
|
||||
|
||||
const model = typeof socketEvent.data?.model === "string"
|
||||
? socketEvent.data.model
|
||||
: "";
|
||||
const eventName = typeof socketEvent.data?.event === "string"
|
||||
? socketEvent.data.event
|
||||
: "";
|
||||
const rawTaskId = socketEvent.data?.task_id
|
||||
?? (model === "task" ? socketEvent.data?.object_id : undefined);
|
||||
const taskId = Number(rawTaskId);
|
||||
|
||||
return {
|
||||
boardId,
|
||||
taskId: Number.isFinite(taskId) && taskId > 0 ? taskId : null,
|
||||
event: eventName,
|
||||
model,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user