From 6a87f8132d128f7cc732ff196a572debe0be6ba5 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 28 May 2026 22:37:21 +0300 Subject: [PATCH 01/26] feature: install Kanban from reui, replace kanban --- components.json | 3 +- src/pages/project/model/boards-mock.ts | 110 ++- src/pages/project/ui/boards/ProjectKanban.tsx | 34 +- src/pages/project/ui/boards/TaskCard.tsx | 26 + src/pages/project/ui/boards/TaskColumn.tsx | 47 + src/shared/ui/Kanban.tsx | 878 +++++++++++++----- 6 files changed, 769 insertions(+), 329 deletions(-) create mode 100644 src/pages/project/ui/boards/TaskCard.tsx create mode 100644 src/pages/project/ui/boards/TaskColumn.tsx diff --git a/components.json b/components.json index 0f116f6..3acd8e1 100644 --- a/components.json +++ b/components.json @@ -19,6 +19,7 @@ "hooks": "shared/hooks/shadcn" }, "registries": { - "@kibo-ui": "https://www.kibo-ui.com/r/{name}.json" + "@kibo-ui": "https://www.kibo-ui.com/r/{name}.json", + "@reui": "https://reui.io/r/{style}/{name}.json" } } diff --git a/src/pages/project/model/boards-mock.ts b/src/pages/project/model/boards-mock.ts index ae15903..95ae85a 100644 --- a/src/pages/project/model/boards-mock.ts +++ b/src/pages/project/model/boards-mock.ts @@ -1,7 +1,4 @@ -export type MockBoardColumn = { - id: string; - name: string; -}; +export type MockBoardColumn = Record; export type MockBoardCard = { id: string; @@ -12,58 +9,69 @@ export type MockBoardCard = { export type MockBoard = { id: string; name: string; - columns: MockBoardColumn[]; - cards: MockBoardCard[]; + columns: MockBoardColumn; + columnTitles: Record; +}; + +const COLUMN_TITLES: Record = { + ideas: 'Идеи', + plan: 'План', + docs: 'Документы', }; export const MOCK_BOARDS: MockBoard[] = [ { id: 'planning', name: 'Планирование проекта', - columns: [ - { id: 'ideas', name: 'Идеи' }, - { id: 'plan', name: 'План' }, - { id: 'docs', name: 'Документация' }, - ], - cards: [ - { id: 'planning-1', name: 'Сбор вдохновения', column: 'ideas' }, - { id: 'planning-2', name: 'Цели проекта', column: 'ideas' }, - { id: 'planning-3', name: 'Дорожная карта', column: 'plan' }, - { id: 'planning-4', name: 'Ресурсы', column: 'plan' }, - { id: 'planning-5', name: 'Техническая', column: 'docs' }, - { id: 'planning-6', name: 'Коммуникация', column: 'docs' }, - ], - }, - { - id: 'in-progress', - name: 'Задачи в работе', - columns: [ - { id: 'todo', name: 'Ожидает выполнения' }, - { id: 'in-progress', name: 'В работе' }, - { id: 'review', name: 'На проверке' }, - { id: 'bank-review', name: 'На проверке в банке' }, - { id: 'done', name: 'Завершено' }, - ], - cards: [ - { id: 'work-1', name: 'Подготовить бриф', column: 'todo' }, - { id: 'work-2', name: 'Дизайн макета', column: 'in-progress' }, - { id: 'work-3', name: 'Проверка текстов', column: 'review' }, - { id: 'work-4', name: 'Проверить реквизиты', column: 'bank-review' }, - { id: 'work-5', name: 'Готово к запуску', column: 'done' }, - ], - }, - { - id: 'results', - name: 'Фиксация результатов', - columns: [ - { id: 'reports', name: 'Отчёты' }, - { id: 'insights', name: 'Выводы' }, - { id: 'documentation', name: 'Документация' }, - ], - cards: [ - { id: 'results-1', name: 'Итоговый отчёт', column: 'reports' }, - { id: 'results-2', name: 'Рефлексия', column: 'insights' }, - { id: 'results-3', name: 'Запись результатов', column: 'documentation' }, - ], + columnTitles: COLUMN_TITLES, + columns: { + ideas: [ + { id: '1', name: 'Сбор вдохновения', column: 'ideas' }, + { id: '2', name: 'Цели проекта', column: 'ideas' }, + ], + plan: [ + { id: '3', name: 'Дорожная карта', column: 'plan' }, + { id: '4', name: 'Ресурсы', column: 'plan' }, + ], + docs: [ + { id: '5', name: 'Техническая', column: 'docs' }, + { id: '6', name: 'Коммуникация', column: 'docs' }, + ], + }, + // { id: 'ideas', name: 'Идеи' }, + // { id: 'plan', name: 'План' }, + // { id: 'docs', name: 'Документация' }, }, + // { + // id: 'in-progress', + // name: 'Задачи в работе', + // columns: [ + // { id: 'todo', name: 'Ожидает выполнения' }, + // { id: 'in-progress', name: 'В работе' }, + // { id: 'review', name: 'На проверке' }, + // { id: 'bank-review', name: 'На проверке в банке' }, + // { id: 'done', name: 'Завершено' }, + // ], + // cards: [ + // { id: 'work-1', name: 'Подготовить бриф', column: 'todo' }, + // { id: 'work-2', name: 'Дизайн макета', column: 'in-progress' }, + // { id: 'work-3', name: 'Проверка текстов', column: 'review' }, + // { id: 'work-4', name: 'Проверить реквизиты', column: 'bank-review' }, + // { id: 'work-5', name: 'Готово к запуску', column: 'done' }, + // ], + // }, + // { + // id: 'results', + // name: 'Фиксация результатов', + // columns: [ + // { id: 'reports', name: 'Отчёты' }, + // { id: 'insights', name: 'Выводы' }, + // { id: 'documentation', name: 'Документация' }, + // ], + // cards: [ + // { id: 'results-1', name: 'Итоговый отчёт', column: 'reports' }, + // { id: 'results-2', name: 'Рефлексия', column: 'insights' }, + // { id: 'results-3', name: 'Запись результатов', column: 'documentation' }, + // ], + // }, ]; diff --git a/src/pages/project/ui/boards/ProjectKanban.tsx b/src/pages/project/ui/boards/ProjectKanban.tsx index 0de94d8..437e515 100644 --- a/src/pages/project/ui/boards/ProjectKanban.tsx +++ b/src/pages/project/ui/boards/ProjectKanban.tsx @@ -1,31 +1,27 @@ 'use client'; import { useState } from 'react'; -import { KanbanBoard, KanbanCard, KanbanCards, KanbanHeader, KanbanProvider } from 'shared/ui'; -import type { MockBoard, MockBoardCard } from '../../model/boards-mock'; +import type { MockBoard } from '../../model/boards-mock'; +import { Kanban, KanbanBoard, KanbanOverlay } from 'shared/ui'; +import { TaskColumn } from './TaskColumn'; interface ProjectKanbanProps { - board: MockBoard; + board: Pick; } export function ProjectKanban({ board }: ProjectKanbanProps) { - const [cards, setCards] = useState(board.cards); + const [columns, setColumns] = useState(board.columns); return ( - - {(column) => ( - - {column.name} - - {(item) => } - - - )} - + setColumns(v)} getItemValue={(item) => item.id}> + + {Object.entries(columns).map(([id, items]) => ( + + ))} + + +
+ + ); } diff --git a/src/pages/project/ui/boards/TaskCard.tsx b/src/pages/project/ui/boards/TaskCard.tsx new file mode 100644 index 0000000..056133e --- /dev/null +++ b/src/pages/project/ui/boards/TaskCard.tsx @@ -0,0 +1,26 @@ +import { MockBoardCard } from 'pages/project/model/boards-mock'; +import { ComponentProps } from 'react'; +import { Card, CardContent, KanbanItem, KanbanItemHandle } from 'shared/ui'; + +interface TaskCardProps extends Omit, 'value' | 'children'> { + task: MockBoardCard; + asHandle?: boolean; + isOverlay?: boolean; +} + +export function TaskCard({ task, asHandle, isOverlay, ...props }: TaskCardProps) { + const cardContent = ( + + +
+ {task.name} +
+
+
+ ); + return ( + + {asHandle && !isOverlay ? {cardContent} : cardContent} + + ); +} diff --git a/src/pages/project/ui/boards/TaskColumn.tsx b/src/pages/project/ui/boards/TaskColumn.tsx new file mode 100644 index 0000000..41320bd --- /dev/null +++ b/src/pages/project/ui/boards/TaskColumn.tsx @@ -0,0 +1,47 @@ +import { GripVerticalIcon } from 'lucide-react'; +import { MockBoard, MockBoardCard } from 'pages/project/model/boards-mock'; +import { ComponentProps } from 'react'; +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + KanbanColumn, + KanbanColumnContent, + KanbanColumnHandle, +} from 'shared/ui'; +import { TaskCard } from './TaskCard'; + +interface TaskColumnProps extends Omit, 'children'> { + tasks: MockBoardCard[]; + columnTitles: MockBoard['columnTitles']; + isOverlay?: boolean; +} + +export function TaskColumn({ value, tasks, columnTitles, isOverlay, ...props }: TaskColumnProps) { + return ( + + + +
+ {columnTitles[value]} + {tasks.length} +
+ + + +
+ + + {tasks.map((task) => ( + + ))} + + +
+
+ ); +} diff --git a/src/shared/ui/Kanban.tsx b/src/shared/ui/Kanban.tsx index 59dcd0d..69d0b2b 100644 --- a/src/shared/ui/Kanban.tsx +++ b/src/shared/ui/Kanban.tsx @@ -1,322 +1,684 @@ -'use client'; - -import type { - Announcements, - DndContextProps, - DragEndEvent, - DragOverEvent, - DragStartEvent, -} from '@dnd-kit/core'; +import * as React from 'react'; import { - closestCenter, + createContext, + CSSProperties, + HTMLAttributes, + ReactNode, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import { + defaultDropAnimationSideEffects, DndContext, + DragEndEvent, + DragOverEvent, DragOverlay, + DragStartEvent, + DropAnimation, KeyboardSensor, + MeasuringStrategy, + Modifiers, MouseSensor, TouchSensor, - useDroppable, + UniqueIdentifier, useSensor, useSensors, + type DraggableAttributes, + type DraggableSyntheticListeners, } from '@dnd-kit/core'; -import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable'; +import { + arrayMove, + defaultAnimateLayoutChanges, + rectSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, + type AnimateLayoutChanges, +} from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { createContext, type HTMLAttributes, type ReactNode, useContext, useState } from 'react'; +import { Slot } from 'radix-ui'; import { createPortal } from 'react-dom'; + import { cn } from 'shared/lib/utils'; -import tunnel from 'tunnel-rat'; -import { Card } from './Card'; -import { ScrollArea, ScrollBar } from './ScrollArea'; - -const t = tunnel(); - -export type { DragEndEvent } from '@dnd-kit/core'; - -type KanbanItemProps = { - id: string; - name: string; - column: string; -} & Record; - -type KanbanColumnProps = { - id: string; - name: string; -} & Record; - -type KanbanContextProps< - T extends KanbanItemProps = KanbanItemProps, - C extends KanbanColumnProps = KanbanColumnProps, -> = { - columns: C[]; - data: T[]; - activeCardId: string | null; -}; -const KanbanContext = createContext({ - columns: [], - data: [], - activeCardId: null, +interface KanbanContextProps { + columns: Record; + setColumns: (columns: Record) => void; + getItemId: (item: T) => string; + columnIds: string[]; + activeId: UniqueIdentifier | null; + setActiveId: (id: UniqueIdentifier | null) => void; + findContainer: (id: UniqueIdentifier) => string | undefined; + isColumn: (id: UniqueIdentifier) => boolean; + modifiers?: Modifiers; +} + +const KanbanContext = createContext>({ + columns: {}, + setColumns: () => {}, + getItemId: () => '', + columnIds: [], + activeId: null, + setActiveId: () => {}, + findContainer: () => undefined, + isColumn: () => false, + modifiers: undefined, }); -export type KanbanBoardProps = { - id: string; - children: ReactNode; - className?: string; -}; +const ColumnContext = createContext<{ + attributes: DraggableAttributes; + listeners: DraggableSyntheticListeners | undefined; + isDragging?: boolean; + disabled?: boolean; +}>({ + attributes: {} as DraggableAttributes, + listeners: undefined, + isDragging: false, + disabled: false, +}); -export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => { - const { isOver, setNodeRef } = useDroppable({ - id, - }); +const ItemContext = createContext<{ + listeners: DraggableSyntheticListeners | undefined; + isDragging?: boolean; + disabled?: boolean; +}>({ + listeners: undefined, + isDragging: false, + disabled: false, +}); - return ( -
- {children} -
- ); -}; +const IsOverlayContext = createContext(false); -export type KanbanCardProps = T & { - children?: ReactNode; - className?: string; +const animateLayoutChanges: AnimateLayoutChanges = (args) => + defaultAnimateLayoutChanges({ ...args, wasDragging: true }); + +const dropAnimationConfig: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: '0.4', + }, + }, + }), }; -export const KanbanCard = ({ - id, - name, +export interface KanbanMoveEvent { + event: DragEndEvent; + activeContainer: string; + activeIndex: number; + overContainer: string; + overIndex: number; +} + +export interface KanbanRootProps extends HTMLAttributes { + value: Record; + onValueChange: (value: Record) => void; + getItemValue: (item: T) => string; + children: ReactNode; + onMove?: (event: KanbanMoveEvent) => void; + asChild?: boolean; + modifiers?: Modifiers; +} + +function Kanban({ + value, + onValueChange, + getItemValue, children, className, -}: KanbanCardProps) => { - const { attributes, listeners, setNodeRef, transition, transform, isDragging } = useSortable({ - id, - }); - const { activeCardId } = useContext(KanbanContext) as KanbanContextProps; + asChild = false, + onMove, + modifiers, + ...props +}: KanbanRootProps) { + const columns = value; + const setColumns = onValueChange; + const [activeId, setActiveId] = useState(null); - const style = { - transition, - transform: CSS.Transform.toString(transform), - }; + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { + distance: 10, + }, + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 250, + tolerance: 5, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const columnIds = useMemo(() => Object.keys(columns), [columns]); + + const isColumn = useCallback( + (id: UniqueIdentifier) => columnIds.includes(id as string), + [columnIds] + ); + + const findContainer = useCallback( + (id: UniqueIdentifier) => { + if (isColumn(id)) return id as string; + return columnIds.find((key) => columns[key].some((item) => getItemValue(item) === id)); + }, + [columns, columnIds, getItemValue, isColumn] + ); + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveId(event.active.id); + }, []); + + const handleDragOver = useCallback( + (event: DragOverEvent) => { + if (onMove) { + return; + } + + const { active, over } = event; + if (!over) return; + + if (isColumn(active.id)) return; + + const activeContainer = findContainer(active.id); + const overContainer = findContainer(over.id); + + if (!activeContainer || !overContainer) { + return; + } + + if (activeContainer !== overContainer) { + const activeItems = columns[activeContainer]; + const overItems = columns[overContainer]; + + const activeIndex = activeItems.findIndex((item: T) => getItemValue(item) === active.id); + let overIndex = overItems.findIndex((item: T) => getItemValue(item) === over.id); + + // If dropping on the column itself, not an item + if (isColumn(over.id)) { + overIndex = overItems.length; + } + + const newActiveItems = [...activeItems]; + const newOverItems = [...overItems]; + const [movedItem] = newActiveItems.splice(activeIndex, 1); + newOverItems.splice(overIndex, 0, movedItem); + + setColumns({ + ...columns, + [activeContainer]: newActiveItems, + [overContainer]: newOverItems, + }); + } else { + const container = activeContainer; + const activeIndex = columns[container].findIndex( + (item: T) => getItemValue(item) === active.id + ); + const overIndex = columns[container].findIndex((item: T) => getItemValue(item) === over.id); + + if (activeIndex !== overIndex) { + setColumns({ + ...columns, + [container]: arrayMove(columns[container], activeIndex, overIndex), + }); + } + } + }, + [findContainer, getItemValue, isColumn, setColumns, columns, onMove] + ); + + const handleDragCancel = useCallback(() => { + setActiveId(null); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + + if (!over) return; + + // Handle item move callback + if (onMove && !isColumn(active.id)) { + const activeContainer = findContainer(active.id); + const overContainer = findContainer(over.id); + + if (activeContainer && overContainer) { + const activeIndex = columns[activeContainer].findIndex( + (item: T) => getItemValue(item) === active.id + ); + const overIndex = isColumn(over.id) + ? columns[overContainer].length + : columns[overContainer].findIndex((item: T) => getItemValue(item) === over.id); + + onMove({ + event, + activeContainer, + activeIndex, + overContainer, + overIndex, + }); + } + return; + } + + // Handle column reordering + if (isColumn(active.id) && isColumn(over.id)) { + const activeIndex = columnIds.indexOf(active.id as string); + const overIndex = columnIds.indexOf(over.id as string); + if (activeIndex !== overIndex) { + const newOrder = arrayMove(Object.keys(columns), activeIndex, overIndex); + const newColumns: Record = {}; + newOrder.forEach((key) => { + newColumns[key] = columns[key]; + }); + setColumns(newColumns); + } + return; + } + + const activeContainer = findContainer(active.id); + const overContainer = findContainer(over.id); + + // Handle item reordering within the same column + if (activeContainer && overContainer && activeContainer === overContainer) { + const container = activeContainer; + const activeIndex = columns[container].findIndex( + (item: T) => getItemValue(item) === active.id + ); + const overIndex = columns[container].findIndex((item: T) => getItemValue(item) === over.id); + + if (activeIndex !== overIndex) { + setColumns({ + ...columns, + [container]: arrayMove(columns[container], activeIndex, overIndex), + }); + } + } + }, + [columnIds, columns, findContainer, getItemValue, isColumn, setColumns, onMove] + ); + + const contextValue = useMemo( + () => ({ + columns, + setColumns, + getItemId: getItemValue, + columnIds, + activeId, + setActiveId, + findContainer, + isColumn, + modifiers, + }), + [columns, setColumns, getItemValue, columnIds, activeId, findContainer, isColumn, modifiers] + ); + + const Comp = asChild ? Slot.Root : 'div'; return ( - <> -
- }> + + - {children ??

{name}

} -
-
- {activeCardId === id && ( - - - {children ??

{name}

} -
-
- )} - + {children} + + + ); -}; +} -export type KanbanCardsProps = Omit< - HTMLAttributes, - 'children' | 'id' -> & { - children: (item: T) => ReactNode; - id: string; -}; +export interface KanbanBoardProps extends HTMLAttributes { + asChild?: boolean; +} -export const KanbanCards = ({ - children, - className, - ...props -}: KanbanCardsProps) => { - const { data } = useContext(KanbanContext) as KanbanContextProps; - const filteredData = data.filter((item) => item.column === props.id); - const items = filteredData.map((item) => item.id); +function KanbanBoard({ className, asChild = false, children, ...props }: KanbanBoardProps) { + const { columnIds } = useContext(KanbanContext); + const Comp = asChild ? Slot.Root : 'div'; return ( - - -
- {filteredData.map(children)} -
-
- -
+ + + {children} + + ); -}; +} -export type KanbanHeaderProps = HTMLAttributes; - -export const KanbanHeader = ({ className, ...props }: KanbanHeaderProps) => ( -
-); - -export type KanbanProviderProps< - T extends KanbanItemProps = KanbanItemProps, - C extends KanbanColumnProps = KanbanColumnProps, -> = Omit & { - children: (column: C) => ReactNode; - className?: string; - columns: C[]; - data: T[]; - onDataChange?: (data: T[]) => void; - onDragStart?: (event: DragStartEvent) => void; - onDragEnd?: (event: DragEndEvent) => void; - onDragOver?: (event: DragOverEvent) => void; -}; +export interface KanbanColumnProps extends HTMLAttributes { + value: string; + disabled?: boolean; + asChild?: boolean; +} -export const KanbanProvider = < - T extends KanbanItemProps = KanbanItemProps, - C extends KanbanColumnProps = KanbanColumnProps, ->({ - children, - onDragStart, - onDragEnd, - onDragOver, +function KanbanColumn({ + value, className, - columns, - data, - onDataChange, + asChild = false, + disabled, + children, ...props -}: KanbanProviderProps) => { - const [activeCardId, setActiveCardId] = useState(null); - - const sensors = useSensors( - useSensor(MouseSensor), - useSensor(TouchSensor), - useSensor(KeyboardSensor) - ); - - const handleDragStart = (event: DragStartEvent) => { - const card = data.find((item) => item.id === event.active.id); - if (card) { - setActiveCardId(event.active.id as string); - } - onDragStart?.(event); - }; +}: KanbanColumnProps) { + const isOverlay = useContext(IsOverlayContext); - const handleDragOver = (event: DragOverEvent) => { - const { active, over } = event; + const { + setNodeRef, + transform, + transition, + attributes, + listeners, + isDragging: isSortableDragging, + } = useSortable({ + id: value, + disabled: disabled || isOverlay, + animateLayoutChanges, + }); - if (!over) { - return; - } + const { activeId, isColumn } = useContext(KanbanContext); + const isColumnDragging = activeId ? isColumn(activeId) : false; - const activeItem = data.find((item) => item.id === active.id); - const overItem = data.find((item) => item.id === over.id); + const style = { + transition, + transform: CSS.Transform.toString(transform), + } as CSSProperties; + + const Comp = asChild ? Slot.Root : 'div'; + + if (isOverlay) { + return ( + + + {children} + + + ); + } - if (!activeItem) { - return; - } + return ( + + + {children} + + + ); +} - const activeColumn = activeItem.column; - const overColumn = - overItem?.column || columns.find((col) => col.id === over.id)?.id || columns[0]?.id; +export interface KanbanColumnHandleProps extends HTMLAttributes { + cursor?: boolean; + asChild?: boolean; +} - if (activeColumn !== overColumn) { - let newData = [...data]; - const activeIndex = newData.findIndex((item) => item.id === active.id); - const overIndex = newData.findIndex((item) => item.id === over.id); +function KanbanColumnHandle({ + className, + asChild = false, + cursor = true, + children, + ...props +}: KanbanColumnHandleProps) { + const { attributes, listeners, isDragging, disabled } = useContext(ColumnContext); - newData[activeIndex].column = overColumn; - newData = arrayMove(newData, activeIndex, overIndex); + const Comp = asChild ? Slot.Root : 'div'; - onDataChange?.(newData); - } + return ( + + {children} + + ); +} - onDragOver?.(event); - }; +export interface KanbanItemProps extends HTMLAttributes { + value: string; + disabled?: boolean; + asChild?: boolean; +} - const handleDragEnd = (event: DragEndEvent) => { - setActiveCardId(null); +function KanbanItem({ + value, + className, + asChild = false, + disabled, + children, + ...props +}: KanbanItemProps) { + const isOverlay = useContext(IsOverlayContext); - onDragEnd?.(event); + const { + setNodeRef, + transform, + transition, + attributes, + listeners, + isDragging: isSortableDragging, + } = useSortable({ + id: value, + disabled: disabled || isOverlay, + animateLayoutChanges, + }); - const { active, over } = event; + const { activeId, isColumn } = useContext(KanbanContext); + const isItemDragging = activeId ? !isColumn(activeId) : false; - if (!over || active.id === over.id) { - return; - } + const style = { + transition, + transform: CSS.Transform.toString(transform), + } as CSSProperties; + + const Comp = asChild ? Slot.Root : 'div'; + + if (isOverlay) { + return ( + + + {children} + + + ); + } - let newData = [...data]; + return ( + + + {children} + + + ); +} - const oldIndex = newData.findIndex((item) => item.id === active.id); - const newIndex = newData.findIndex((item) => item.id === over.id); +export interface KanbanItemHandleProps extends HTMLAttributes { + cursor?: boolean; + asChild?: boolean; +} - newData = arrayMove(newData, oldIndex, newIndex); +function KanbanItemHandle({ + className, + asChild = false, + cursor = true, + children, + ...props +}: KanbanItemHandleProps) { + const { listeners, isDragging, disabled } = useContext(ItemContext); - onDataChange?.(newData); - }; + const Comp = asChild ? Slot.Root : 'div'; - const announcements: Announcements = { - onDragStart({ active }) { - const { name, column } = data.find((item) => item.id === active.id) ?? {}; + return ( + + {children} + + ); +} - return `Picked up the card "${name}" from the "${column}" column`; - }, - onDragOver({ active, over }) { - const { name } = data.find((item) => item.id === active.id) ?? {}; - const newColumn = columns.find((column) => column.id === over?.id)?.name; +export interface KanbanColumnContentProps extends HTMLAttributes { + value: string; + asChild?: boolean; +} - return `Dragged the card "${name}" over the "${newColumn}" column`; - }, - onDragEnd({ active, over }) { - const { name } = data.find((item) => item.id === active.id) ?? {}; - const newColumn = columns.find((column) => column.id === over?.id)?.name; +function KanbanColumnContent({ + value, + className, + asChild = false, + children, + ...props +}: KanbanColumnContentProps) { + const { columns, getItemId } = useContext(KanbanContext); - return `Dropped the card "${name}" into the "${newColumn}" column`; - }, - onDragCancel({ active }) { - const { name } = data.find((item) => item.id === active.id) ?? {}; + const itemIds = useMemo(() => columns[value].map(getItemId), [columns, getItemId, value]); - return `Cancelled dragging the card "${name}"`; - }, - }; + const Comp = asChild ? Slot.Root : 'div'; return ( - - + -
- {columns.map((column) => children(column))} -
- {typeof window !== 'undefined' && - createPortal( - - - , - document.body - )} -
-
+ {children} + + + ); +} + +export interface KanbanOverlayProps extends Omit< + React.ComponentProps, + 'children' +> { + children?: + | ReactNode + | ((params: { value: UniqueIdentifier; variant: 'column' | 'item' }) => ReactNode); +} + +function KanbanOverlay({ children, className, ...props }: KanbanOverlayProps) { + const { activeId, isColumn, modifiers } = useContext(KanbanContext); + + // Заменил useLayoutEffect на seSyncExternalStore + const emptySubscribe = () => () => {}; + + const isMounted = React.useSyncExternalStore( + emptySubscribe, + () => true, + () => false + ); + + const variant = activeId ? (isColumn(activeId) ? 'column' : 'item') : 'item'; + + const content = + activeId && children + ? typeof children === 'function' + ? children({ value: activeId, variant }) + : children + : null; + + if (!isMounted) return null; + + return createPortal( + + {content} + , + document.body ); +} + +export { + Kanban, + KanbanBoard, + KanbanColumn, + KanbanColumnHandle, + KanbanItem, + KanbanItemHandle, + KanbanColumnContent, + KanbanOverlay, }; From 4a0090709ce8279a01c5af5b65aeb183d37492cf Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 28 May 2026 22:52:42 +0300 Subject: [PATCH 02/26] fix: fix gidratation error --- src/shared/ui/Kanban.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/shared/ui/Kanban.tsx b/src/shared/ui/Kanban.tsx index 69d0b2b..b30875d 100644 --- a/src/shared/ui/Kanban.tsx +++ b/src/shared/ui/Kanban.tsx @@ -1,3 +1,4 @@ +'use client'; import * as React from 'react'; import { createContext, @@ -324,6 +325,7 @@ function Kanban({ return ( }> Date: Fri, 29 May 2026 01:24:42 +0300 Subject: [PATCH 03/26] feat: add checkbox --- src/shared/ui/checkbox/Checkbox.tsx | 32 +++++++++++++++++++++++++++++ src/shared/ui/index.ts | 1 + 2 files changed, 33 insertions(+) create mode 100644 src/shared/ui/checkbox/Checkbox.tsx diff --git a/src/shared/ui/checkbox/Checkbox.tsx b/src/shared/ui/checkbox/Checkbox.tsx new file mode 100644 index 0000000..384a81e --- /dev/null +++ b/src/shared/ui/checkbox/Checkbox.tsx @@ -0,0 +1,32 @@ +import { ComponentProps } from 'react'; +import { cn } from 'shared/lib/utils'; + +function Checkbox({ className, ...props }: Omit, 'type'>) { + return ( +
+ + + + +
+ ); +} + +export { Checkbox }; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 8a9ae79..656e015 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -39,3 +39,4 @@ export * from './Select'; export * from './Empty'; export * from './ScrollArea'; export * from './Kanban'; +export * from './checkbox/Checkbox'; From c63b1071f05878ca97b9b33b24042c80d344cbb3 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Fri, 29 May 2026 01:25:42 +0300 Subject: [PATCH 04/26] refactor: update kanban board and mock data --- src/pages/project/model/boards-mock.ts | 74 +++++++++++++++++-- src/pages/project/ui/boards/ProjectKanban.tsx | 5 +- src/pages/project/ui/boards/TaskCard.tsx | 61 +++++++++++++-- src/pages/project/ui/boards/TaskColumn.tsx | 64 ++++++++-------- 4 files changed, 153 insertions(+), 51 deletions(-) diff --git a/src/pages/project/model/boards-mock.ts b/src/pages/project/model/boards-mock.ts index 95ae85a..56ad9c3 100644 --- a/src/pages/project/model/boards-mock.ts +++ b/src/pages/project/model/boards-mock.ts @@ -1,9 +1,19 @@ -export type MockBoardColumn = Record; +export type MockBoardColumn = Record; -export type MockBoardCard = { +export type MockAuthor = { + id: string; + name: string; + avatarUrl?: string; +}; + +export type MockBoardTask = { id: string; name: string; column: string; + assignee: MockAuthor; + dueDate: string; + priority: 'high' | 'medium' | 'low'; + description?: string; }; export type MockBoard = { @@ -26,16 +36,64 @@ export const MOCK_BOARDS: MockBoard[] = [ columnTitles: COLUMN_TITLES, columns: { ideas: [ - { id: '1', name: 'Сбор вдохновения', column: 'ideas' }, - { id: '2', name: 'Цели проекта', column: 'ideas' }, + { + id: '1', + name: 'Сбор вдохновения', + column: 'ideas', + priority: 'high', + assignee: { id: '1', name: 'Андрей' }, + dueDate: '2023-10-05', + description: 'Description', + }, + { + id: '2', + name: 'Цели проекта', + column: 'ideas', + priority: 'medium', + assignee: { id: '2', name: 'Мария' }, + dueDate: '2023-10-06', + description: 'Description', + }, ], plan: [ - { id: '3', name: 'Дорожная карта', column: 'plan' }, - { id: '4', name: 'Ресурсы', column: 'plan' }, + { + id: '3', + name: 'Дорожная карта', + column: 'plan', + priority: 'low', + assignee: { id: '3', name: 'Иван' }, + dueDate: '2023-10-07', + description: 'Description', + }, + { + id: '4', + name: 'Ресурсы', + column: 'plan', + priority: 'medium', + assignee: { id: '4', name: 'Сергей' }, + dueDate: '2023-10-08', + description: 'Description', + }, ], docs: [ - { id: '5', name: 'Техническая', column: 'docs' }, - { id: '6', name: 'Коммуникация', column: 'docs' }, + { + id: '5', + name: 'Техническая', + column: 'docs', + priority: 'high', + assignee: { id: '5', name: 'Дмитрий' }, + dueDate: '2023-10-09', + description: 'Description', + }, + { + id: '6', + name: 'Коммуникация', + column: 'docs', + priority: 'medium', + assignee: { id: '6', name: 'Анна' }, + dueDate: '2023-10-10', + description: 'Description', + }, ], }, // { id: 'ideas', name: 'Идеи' }, diff --git a/src/pages/project/ui/boards/ProjectKanban.tsx b/src/pages/project/ui/boards/ProjectKanban.tsx index 437e515..e23a848 100644 --- a/src/pages/project/ui/boards/ProjectKanban.tsx +++ b/src/pages/project/ui/boards/ProjectKanban.tsx @@ -11,7 +11,6 @@ interface ProjectKanbanProps { export function ProjectKanban({ board }: ProjectKanbanProps) { const [columns, setColumns] = useState(board.columns); - return ( setColumns(v)} getItemValue={(item) => item.id}> @@ -19,9 +18,7 @@ export function ProjectKanban({ board }: ProjectKanbanProps) { ))} - -
- + ); } diff --git a/src/pages/project/ui/boards/TaskCard.tsx b/src/pages/project/ui/boards/TaskCard.tsx index 056133e..8f01785 100644 --- a/src/pages/project/ui/boards/TaskCard.tsx +++ b/src/pages/project/ui/boards/TaskCard.tsx @@ -1,9 +1,23 @@ -import { MockBoardCard } from 'pages/project/model/boards-mock'; +import { MockBoardTask } from 'pages/project/model/boards-mock'; import { ComponentProps } from 'react'; -import { Card, CardContent, KanbanItem, KanbanItemHandle } from 'shared/ui'; +import { + Avatar, + AvatarFallback, + AvatarImage, + Card, + CardContent, + KanbanItem, + KanbanItemHandle, + Label, + Tooltip, + TooltipContent, + TooltipTrigger, + Checkbox, + Badge, +} from 'shared/ui'; interface TaskCardProps extends Omit, 'value' | 'children'> { - task: MockBoardCard; + task: MockBoardTask; asHandle?: boolean; isOverlay?: boolean; } @@ -12,8 +26,45 @@ export function TaskCard({ task, asHandle, isOverlay, ...props }: TaskCardProps) const cardContent = ( -
- {task.name} +
+ +
+

{task.name}

+ {task.description} +
+
+
+ {task.assignee && ( +
+ + + + + {task.assignee.name.charAt(0)} + + + {task.assignee.name} + + {task.dueDate && ( + + )} +
+ )} + {task.priority && ( + + {task.priority} + + )}
diff --git a/src/pages/project/ui/boards/TaskColumn.tsx b/src/pages/project/ui/boards/TaskColumn.tsx index 41320bd..c5ec34f 100644 --- a/src/pages/project/ui/boards/TaskColumn.tsx +++ b/src/pages/project/ui/boards/TaskColumn.tsx @@ -1,47 +1,43 @@ import { GripVerticalIcon } from 'lucide-react'; -import { MockBoard, MockBoardCard } from 'pages/project/model/boards-mock'; +import { MockBoard, MockBoardTask } from 'pages/project/model/boards-mock'; import { ComponentProps } from 'react'; -import { - Badge, - Button, - Card, - CardContent, - CardHeader, - KanbanColumn, - KanbanColumnContent, - KanbanColumnHandle, -} from 'shared/ui'; +import { Button, KanbanColumn, KanbanColumnContent, KanbanColumnHandle } from 'shared/ui'; import { TaskCard } from './TaskCard'; interface TaskColumnProps extends Omit, 'children'> { - tasks: MockBoardCard[]; + tasks: MockBoardTask[]; columnTitles: MockBoard['columnTitles']; isOverlay?: boolean; } -export function TaskColumn({ value, tasks, columnTitles, isOverlay, ...props }: TaskColumnProps) { +export function TaskColumn({ + value, + tasks, + columnTitles, + className, + isOverlay, + ...props +}: TaskColumnProps) { return ( - - - -
- {columnTitles[value]} - {tasks.length} -
- - - -
- - - {tasks.map((task) => ( - - ))} - - -
+ +
+
+ + {columnTitles[value]} ({tasks.length}) + +
+ + + +
+ + + {tasks.map((task) => ( + + ))} +
); } From beb3c4edb69256c5b932289641c90876b48874cd Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Fri, 29 May 2026 01:34:48 +0300 Subject: [PATCH 05/26] refactor: add link highligting for projects --- src/widgets/app-sidebar/ui/Projects.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/widgets/app-sidebar/ui/Projects.tsx b/src/widgets/app-sidebar/ui/Projects.tsx index 653a3be..7d544bc 100644 --- a/src/widgets/app-sidebar/ui/Projects.tsx +++ b/src/widgets/app-sidebar/ui/Projects.tsx @@ -48,7 +48,11 @@ export function Projects() { return ( - + {projectIconCodeToEmoji(project.icon)} {project.name} From 83b14fea171e7800ee08505082327c43372f6f2d Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Fri, 29 May 2026 17:34:07 +0300 Subject: [PATCH 06/26] refactor: enhance KanbanBoard layout and improve column handle visibility --- src/shared/ui/Kanban.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shared/ui/Kanban.tsx b/src/shared/ui/Kanban.tsx index b30875d..89c8a3b 100644 --- a/src/shared/ui/Kanban.tsx +++ b/src/shared/ui/Kanban.tsx @@ -358,12 +358,12 @@ export interface KanbanBoardProps extends HTMLAttributes { function KanbanBoard({ className, asChild = false, children, ...props }: KanbanBoardProps) { const { columnIds } = useContext(KanbanContext); const Comp = asChild ? Slot.Root : 'div'; - + const classNameRaw = `grid-cols-[repeat(${columnIds.length},300px)]`; return ( {children} @@ -483,7 +483,7 @@ function KanbanColumnHandle({ {...attributes} {...listeners} className={cn( - 'opacity-0 transition-opacity group-hover/kanban-column:opacity-100', + 'max-w-0 opacity-0 transition-[opacity,max-width] group-hover/kanban-column:max-w-7 group-hover/kanban-column:opacity-100', cursor && (isDragging ? 'cursor-grabbing!' : 'cursor-grab!'), className )} From 67e31288a627308fa4063c6bccaf647836747909 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Fri, 29 May 2026 17:41:10 +0300 Subject: [PATCH 07/26] refactor: update MockBoard structure and enhance TaskColumn with icons --- src/pages/project/model/boards-mock.ts | 169 ++++++++++++------ src/pages/project/ui/boards/ProjectKanban.tsx | 1 + src/pages/project/ui/boards/TaskColumn.tsx | 32 +++- 3 files changed, 144 insertions(+), 58 deletions(-) diff --git a/src/pages/project/model/boards-mock.ts b/src/pages/project/model/boards-mock.ts index 56ad9c3..137afa6 100644 --- a/src/pages/project/model/boards-mock.ts +++ b/src/pages/project/model/boards-mock.ts @@ -1,3 +1,6 @@ +// TODO: вынести функцию и иконки в shared или сделать свои +import { PROJECT_ICONS } from 'entities/project'; + export type MockBoardColumn = Record; export type MockAuthor = { @@ -9,37 +12,37 @@ export type MockAuthor = { export type MockBoardTask = { id: string; name: string; - column: string; + columnId: string; assignee: MockAuthor; dueDate: string; priority: 'high' | 'medium' | 'low'; description?: string; }; +export type ColumnTitles = Record; + export type MockBoard = { id: string; name: string; columns: MockBoardColumn; - columnTitles: Record; -}; - -const COLUMN_TITLES: Record = { - ideas: 'Идеи', - plan: 'План', - docs: 'Документы', + columnTitles: ColumnTitles; }; export const MOCK_BOARDS: MockBoard[] = [ { id: 'planning', name: 'Планирование проекта', - columnTitles: COLUMN_TITLES, + columnTitles: { + ideas: { title: 'Идеи', icon: PROJECT_ICONS[0] }, + plan: { title: 'План' }, + docs: { title: 'Документы' }, + }, columns: { ideas: [ { id: '1', name: 'Сбор вдохновения', - column: 'ideas', + columnId: 'ideas', priority: 'high', assignee: { id: '1', name: 'Андрей' }, dueDate: '2023-10-05', @@ -48,7 +51,7 @@ export const MOCK_BOARDS: MockBoard[] = [ { id: '2', name: 'Цели проекта', - column: 'ideas', + columnId: 'ideas', priority: 'medium', assignee: { id: '2', name: 'Мария' }, dueDate: '2023-10-06', @@ -59,7 +62,7 @@ export const MOCK_BOARDS: MockBoard[] = [ { id: '3', name: 'Дорожная карта', - column: 'plan', + columnId: 'plan', priority: 'low', assignee: { id: '3', name: 'Иван' }, dueDate: '2023-10-07', @@ -68,7 +71,7 @@ export const MOCK_BOARDS: MockBoard[] = [ { id: '4', name: 'Ресурсы', - column: 'plan', + columnId: 'plan', priority: 'medium', assignee: { id: '4', name: 'Сергей' }, dueDate: '2023-10-08', @@ -79,7 +82,7 @@ export const MOCK_BOARDS: MockBoard[] = [ { id: '5', name: 'Техническая', - column: 'docs', + columnId: 'docs', priority: 'high', assignee: { id: '5', name: 'Дмитрий' }, dueDate: '2023-10-09', @@ -88,48 +91,114 @@ export const MOCK_BOARDS: MockBoard[] = [ { id: '6', name: 'Коммуникация', - column: 'docs', + columnId: 'docs', + priority: 'medium', + assignee: { id: '6', name: 'Анна' }, + dueDate: '2023-10-10', + description: 'Description', + }, + ], + }, + }, + { + id: 'in-progress', + name: 'Задачи в работе', + columnTitles: { + todo: { title: 'Ожидает выполнения' }, + inProgress: { title: 'В работе' }, + review: { title: 'На проверке' }, + bankReview: { title: 'На проверке в банке' }, + done: { title: 'Завершено' }, + }, + columns: { + todo: [ + { + id: '11', + name: 'Дизайн макета', + columnId: 'ideas', + priority: 'high', + assignee: { id: '1', name: 'Андрей' }, + dueDate: '2023-10-05', + description: 'Description', + }, + ], + inProgress: [ + { + id: '31', + name: 'Подготовить бриф', + columnId: 'plan', + priority: 'low', + assignee: { id: '3', name: 'Иван' }, + dueDate: '2023-10-07', + description: 'Description', + }, + ], + review: [ + { + id: '51', + name: 'Техническая', + columnId: 'docs', + priority: 'high', + assignee: { id: '5', name: 'Дмитрий' }, + dueDate: '2023-10-09', + description: 'Description', + }, + { + id: '61', + name: 'Коммуникация', + columnId: 'docs', priority: 'medium', assignee: { id: '6', name: 'Анна' }, dueDate: '2023-10-10', description: 'Description', }, ], + bankReview: [], + done: [], + }, + }, + { + id: 'results', + name: 'Фиксация результатов', + columnTitles: { + reports: { title: 'Отчёты', icon: '📊' }, + insights: { title: 'Выводы', icon: '💡' }, + documentation: { title: 'Документация', icon: '📄' }, + }, + columns: { + reports: [ + { + id: 'results-1', + name: 'Итоговый отчёт', + columnId: 'reports', + priority: 'high', + assignee: { id: '6', name: 'Аналитик' }, + dueDate: '2023-10-20', + description: 'Собрать все метрики и графики', + }, + ], + insights: [ + { + id: 'results-2', + name: 'Рефлексия', + columnId: 'insights', + priority: 'medium', + assignee: { id: '7', name: 'Тимлид' }, + dueDate: '2023-10-22', + description: 'Что получилось, а что нет', + }, + ], + documentation: [ + { + id: 'results-3', + name: 'Запись результатов', + columnId: 'documentation', + priority: 'low', + assignee: { id: '8', name: 'Документалист' }, + dueDate: '2023-10-25', + description: 'Задокументировать выводы в Confluence', + }, + ], }, - // { id: 'ideas', name: 'Идеи' }, - // { id: 'plan', name: 'План' }, - // { id: 'docs', name: 'Документация' }, }, - // { - // id: 'in-progress', - // name: 'Задачи в работе', - // columns: [ - // { id: 'todo', name: 'Ожидает выполнения' }, - // { id: 'in-progress', name: 'В работе' }, - // { id: 'review', name: 'На проверке' }, - // { id: 'bank-review', name: 'На проверке в банке' }, - // { id: 'done', name: 'Завершено' }, - // ], - // cards: [ - // { id: 'work-1', name: 'Подготовить бриф', column: 'todo' }, - // { id: 'work-2', name: 'Дизайн макета', column: 'in-progress' }, - // { id: 'work-3', name: 'Проверка текстов', column: 'review' }, - // { id: 'work-4', name: 'Проверить реквизиты', column: 'bank-review' }, - // { id: 'work-5', name: 'Готово к запуску', column: 'done' }, - // ], - // }, - // { - // id: 'results', - // name: 'Фиксация результатов', - // columns: [ - // { id: 'reports', name: 'Отчёты' }, - // { id: 'insights', name: 'Выводы' }, - // { id: 'documentation', name: 'Документация' }, - // ], - // cards: [ - // { id: 'results-1', name: 'Итоговый отчёт', column: 'reports' }, - // { id: 'results-2', name: 'Рефлексия', column: 'insights' }, - // { id: 'results-3', name: 'Запись результатов', column: 'documentation' }, - // ], - // }, ]; diff --git a/src/pages/project/ui/boards/ProjectKanban.tsx b/src/pages/project/ui/boards/ProjectKanban.tsx index e23a848..27884c1 100644 --- a/src/pages/project/ui/boards/ProjectKanban.tsx +++ b/src/pages/project/ui/boards/ProjectKanban.tsx @@ -11,6 +11,7 @@ interface ProjectKanbanProps { export function ProjectKanban({ board }: ProjectKanbanProps) { const [columns, setColumns] = useState(board.columns); + return ( setColumns(v)} getItemValue={(item) => item.id}> diff --git a/src/pages/project/ui/boards/TaskColumn.tsx b/src/pages/project/ui/boards/TaskColumn.tsx index c5ec34f..5cd6b78 100644 --- a/src/pages/project/ui/boards/TaskColumn.tsx +++ b/src/pages/project/ui/boards/TaskColumn.tsx @@ -1,9 +1,12 @@ -import { GripVerticalIcon } from 'lucide-react'; +import { Ellipsis, GripVertical, Plus } from 'lucide-react'; import { MockBoard, MockBoardTask } from 'pages/project/model/boards-mock'; import { ComponentProps } from 'react'; import { Button, KanbanColumn, KanbanColumnContent, KanbanColumnHandle } from 'shared/ui'; import { TaskCard } from './TaskCard'; +// TODO: вынести функцию и иконки в shared или сделать свои +import { projectIconCodeToEmoji } from 'entities/project'; + interface TaskColumnProps extends Omit, 'children'> { tasks: MockBoardTask[]; columnTitles: MockBoard['columnTitles']; @@ -22,15 +25,28 @@ export function TaskColumn({
- - {columnTitles[value]} ({tasks.length}) - + {columnTitles[value].icon && ( + {projectIconCodeToEmoji(columnTitles[value].icon)} + )} +

+ {columnTitles[value].title} ({tasks.length}) +

- - + {/* TODO: добавить dialog/popover */} + - + + + +
From 648975ca2cb304782cd22ba38e87b7520cc7de84 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Fri, 29 May 2026 18:20:53 +0300 Subject: [PATCH 08/26] refactor: create Task component and update TaskCard integration in TaskColumn --- src/pages/project/ui/boards/Task.tsx | 22 ++++++++++++++++++++++ src/pages/project/ui/boards/TaskCard.tsx | 16 +++------------- src/pages/project/ui/boards/TaskColumn.tsx | 4 ++-- 3 files changed, 27 insertions(+), 15 deletions(-) create mode 100644 src/pages/project/ui/boards/Task.tsx diff --git a/src/pages/project/ui/boards/Task.tsx b/src/pages/project/ui/boards/Task.tsx new file mode 100644 index 0000000..b43ea4c --- /dev/null +++ b/src/pages/project/ui/boards/Task.tsx @@ -0,0 +1,22 @@ +import { MockBoardTask } from 'pages/project/model/boards-mock'; +import { ComponentProps } from 'react'; +import { KanbanItem, KanbanItemHandle } from 'shared/ui'; +import { TaskCard } from './TaskCard'; + +interface TaskCardProps extends Omit, 'value' | 'children'> { + task: MockBoardTask; + asHandle?: boolean; + isOverlay?: boolean; +} + +export function Task({ task, asHandle, isOverlay, ...props }: TaskCardProps) { + return ( + + {asHandle && !isOverlay ? ( + {} + ) : ( + + )} + + ); +} diff --git a/src/pages/project/ui/boards/TaskCard.tsx b/src/pages/project/ui/boards/TaskCard.tsx index 8f01785..7fec124 100644 --- a/src/pages/project/ui/boards/TaskCard.tsx +++ b/src/pages/project/ui/boards/TaskCard.tsx @@ -1,13 +1,10 @@ import { MockBoardTask } from 'pages/project/model/boards-mock'; -import { ComponentProps } from 'react'; import { Avatar, AvatarFallback, AvatarImage, Card, CardContent, - KanbanItem, - KanbanItemHandle, Label, Tooltip, TooltipContent, @@ -16,14 +13,12 @@ import { Badge, } from 'shared/ui'; -interface TaskCardProps extends Omit, 'value' | 'children'> { +interface TaskCardProps { task: MockBoardTask; - asHandle?: boolean; - isOverlay?: boolean; } -export function TaskCard({ task, asHandle, isOverlay, ...props }: TaskCardProps) { - const cardContent = ( +export function TaskCard({ task }: TaskCardProps) { + return (
@@ -69,9 +64,4 @@ export function TaskCard({ task, asHandle, isOverlay, ...props }: TaskCardProps) ); - return ( - - {asHandle && !isOverlay ? {cardContent} : cardContent} - - ); } diff --git a/src/pages/project/ui/boards/TaskColumn.tsx b/src/pages/project/ui/boards/TaskColumn.tsx index 5cd6b78..c1bbbc2 100644 --- a/src/pages/project/ui/boards/TaskColumn.tsx +++ b/src/pages/project/ui/boards/TaskColumn.tsx @@ -2,10 +2,10 @@ import { Ellipsis, GripVertical, Plus } from 'lucide-react'; import { MockBoard, MockBoardTask } from 'pages/project/model/boards-mock'; import { ComponentProps } from 'react'; import { Button, KanbanColumn, KanbanColumnContent, KanbanColumnHandle } from 'shared/ui'; -import { TaskCard } from './TaskCard'; // TODO: вынести функцию и иконки в shared или сделать свои import { projectIconCodeToEmoji } from 'entities/project'; +import { Task } from './Task'; interface TaskColumnProps extends Omit, 'children'> { tasks: MockBoardTask[]; @@ -51,7 +51,7 @@ export function TaskColumn({ {tasks.map((task) => ( - + ))} From 1a50435fe0067133d38fff47e0fb8dc7983d10c2 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Sun, 31 May 2026 23:53:45 +0300 Subject: [PATCH 09/26] refactor: enhance ProjectKanban and TaskColumn components with improved layout and header integration --- app/layout.tsx | 8 ++- src/pages/project/ui/boards/ProjectKanban.tsx | 9 ++- src/pages/project/ui/boards/TaskColumn.tsx | 55 ++++++++++++------- src/shared/ui/Kanban.tsx | 17 +++--- 4 files changed, 60 insertions(+), 29 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index ffc6dec..6643dfc 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,9 +1,15 @@ import 'app/styles/global.css'; import { AppProviders } from 'app/providers/AppProviders'; +import { Inter } from 'next/font/google'; + +const inter = Inter({ + subsets: ['cyrillic'], +}); + export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} diff --git a/src/pages/project/ui/boards/ProjectKanban.tsx b/src/pages/project/ui/boards/ProjectKanban.tsx index 27884c1..f561fdc 100644 --- a/src/pages/project/ui/boards/ProjectKanban.tsx +++ b/src/pages/project/ui/boards/ProjectKanban.tsx @@ -13,8 +13,13 @@ export function ProjectKanban({ board }: ProjectKanbanProps) { const [columns, setColumns] = useState(board.columns); return ( - setColumns(v)} getItemValue={(item) => item.id}> - + setColumns(v)} + getItemValue={(item) => item.id} + > + {Object.entries(columns).map(([id, items]) => ( ))} diff --git a/src/pages/project/ui/boards/TaskColumn.tsx b/src/pages/project/ui/boards/TaskColumn.tsx index c1bbbc2..9a960e7 100644 --- a/src/pages/project/ui/boards/TaskColumn.tsx +++ b/src/pages/project/ui/boards/TaskColumn.tsx @@ -1,6 +1,6 @@ import { Ellipsis, GripVertical, Plus } from 'lucide-react'; import { MockBoard, MockBoardTask } from 'pages/project/model/boards-mock'; -import { ComponentProps } from 'react'; +import React, { ComponentProps } from 'react'; import { Button, KanbanColumn, KanbanColumnContent, KanbanColumnHandle } from 'shared/ui'; // TODO: вынести функцию и иконки в shared или сделать свои @@ -13,6 +13,12 @@ interface TaskColumnProps extends Omit, 'chi isOverlay?: boolean; } +interface TaskColumnHeaderProps { + title: string; + icon?: string; + tasksLength: number; +} + export function TaskColumn({ value, tasks, @@ -21,15 +27,35 @@ export function TaskColumn({ isOverlay, ...props }: TaskColumnProps) { + const headerColumnData: TaskColumnHeaderProps = { + title: columnTitles[value].title, + icon: projectIconCodeToEmoji(columnTitles[value].icon), + tasksLength: tasks.length, + }; + + return ( + + + + + {tasks.map((task) => ( + + ))} + + + ); +} + +function _TaskColumnHeader({ data }: { data: TaskColumnHeaderProps }) { + const { tasksLength, title, icon } = data; return ( - +
- {columnTitles[value].icon && ( - {projectIconCodeToEmoji(columnTitles[value].icon)} - )} -

- {columnTitles[value].title} ({tasks.length}) + {icon && {icon}} +

+ {title} + ({tasksLength})

@@ -41,19 +67,10 @@ export function TaskColumn({ - - -
- - - {tasks.map((task) => ( - - ))} - -
+ ); } + +const TaskColumnHeader = React.memo(_TaskColumnHeader); diff --git a/src/shared/ui/Kanban.tsx b/src/shared/ui/Kanban.tsx index 89c8a3b..d28eedc 100644 --- a/src/shared/ui/Kanban.tsx +++ b/src/shared/ui/Kanban.tsx @@ -358,14 +358,9 @@ export interface KanbanBoardProps extends HTMLAttributes { function KanbanBoard({ className, asChild = false, children, ...props }: KanbanBoardProps) { const { columnIds } = useContext(KanbanContext); const Comp = asChild ? Slot.Root : 'div'; - const classNameRaw = `grid-cols-[repeat(${columnIds.length},300px)]`; return ( - + {children} @@ -459,9 +454,16 @@ function KanbanColumn({ ); } +const variant = { + default: + 'max-w-0 opacity-0 transition-[opacity,max-width] group-hover/kanban-column:max-w-7 group-hover/kanban-column:opacity-100', + visible: '', +}; + export interface KanbanColumnHandleProps extends HTMLAttributes { cursor?: boolean; asChild?: boolean; + variant?: keyof typeof variant; } function KanbanColumnHandle({ @@ -469,6 +471,7 @@ function KanbanColumnHandle({ asChild = false, cursor = true, children, + variant = 'default', ...props }: KanbanColumnHandleProps) { const { attributes, listeners, isDragging, disabled } = useContext(ColumnContext); @@ -483,7 +486,7 @@ function KanbanColumnHandle({ {...attributes} {...listeners} className={cn( - 'max-w-0 opacity-0 transition-[opacity,max-width] group-hover/kanban-column:max-w-7 group-hover/kanban-column:opacity-100', + variant, cursor && (isDragging ? 'cursor-grabbing!' : 'cursor-grab!'), className )} From a7b7664d54ef9a7d90ba1761c07c385d118a61c7 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Tue, 2 Jun 2026 19:13:15 +0300 Subject: [PATCH 10/26] feat(task): add entitry 'task' --- src/entities/task/api/http.ts | 17 ++++++++ src/entities/task/index.ts | 5 +++ src/entities/task/model/const.ts | 6 +++ src/entities/task/model/schemas.ts | 27 ++++++++++++ src/entities/task/model/types.ts | 8 ++++ src/entities/task/ui/TaskCard.tsx | 67 ++++++++++++++++++++++++++++++ steiger.config.ts | 6 +++ 7 files changed, 136 insertions(+) create mode 100644 src/entities/task/api/http.ts create mode 100644 src/entities/task/index.ts create mode 100644 src/entities/task/model/const.ts create mode 100644 src/entities/task/model/schemas.ts create mode 100644 src/entities/task/model/types.ts create mode 100644 src/entities/task/ui/TaskCard.tsx diff --git a/src/entities/task/api/http.ts b/src/entities/task/api/http.ts new file mode 100644 index 0000000..0319bb7 --- /dev/null +++ b/src/entities/task/api/http.ts @@ -0,0 +1,17 @@ +import { api } from 'shared/api'; +import * as STask from '../model/schemas'; +import * as TTask from '../model/types'; + +export class TaskHttp { + static createTask(data: TTask.CreateTaskBody) { + return api({ + url: `/teams/projects/`, + method: 'POST', + data, + contracts: { + response: STask.CreateTaskResponse, + body: STask.CreateTaskBody, + }, + }); + } +} diff --git a/src/entities/task/index.ts b/src/entities/task/index.ts new file mode 100644 index 0000000..1285342 --- /dev/null +++ b/src/entities/task/index.ts @@ -0,0 +1,5 @@ +export type * as TTask from './model/types'; +export * as STask from './model/schemas'; +export { TaskHttp } from './api/http'; +export { TaskCard } from './ui/TaskCard'; +export { taskFabricKeys } from './model/const'; diff --git a/src/entities/task/model/const.ts b/src/entities/task/model/const.ts new file mode 100644 index 0000000..90e87ad --- /dev/null +++ b/src/entities/task/model/const.ts @@ -0,0 +1,6 @@ +import { createEntityKeys } from 'shared/lib/utils'; + +export const taskFabricKeys = createEntityKeys('task', { + list: () => ['teams', 'tasks'], + detail: (taskId: string) => ['teams', 'projects', taskId], +}); diff --git a/src/entities/task/model/schemas.ts b/src/entities/task/model/schemas.ts new file mode 100644 index 0000000..5726f31 --- /dev/null +++ b/src/entities/task/model/schemas.ts @@ -0,0 +1,27 @@ +import { DateTimeString, GlobalSuccess } from 'shared/api'; +import { z } from 'zod/v4'; + +export const Task = z.object({ + id: z.string(), + boardId: z.string(), + columnId: z.string(), + title: z.string(), + description: z.string().optional(), + priority: z.enum(['low', 'medium', 'high', 'urgent']), + assigneeId: z.string().optional(), + assignee: z.object({ + name: z.string(), + avatarUrl: z.string().nullable(), + }), + dueDate: z.string().optional(), + position: z.number(), + createdAt: DateTimeString, + updatedAt: DateTimeString, +}); + +export const CreateTaskBody = z.object({ + title: z.string().min(1).max(300), + boardId: z.string(), + columnId: z.string(), +}); +export const CreateTaskResponse = GlobalSuccess; diff --git a/src/entities/task/model/types.ts b/src/entities/task/model/types.ts new file mode 100644 index 0000000..9e3eaa9 --- /dev/null +++ b/src/entities/task/model/types.ts @@ -0,0 +1,8 @@ +import { z } from 'zod/v4'; +import * as STask from './schemas'; + +export type Task = z.infer; + +export type CreateTaskBody = z.infer; + +export type CreateTaskResponse = Task; diff --git a/src/entities/task/ui/TaskCard.tsx b/src/entities/task/ui/TaskCard.tsx new file mode 100644 index 0000000..fad89ea --- /dev/null +++ b/src/entities/task/ui/TaskCard.tsx @@ -0,0 +1,67 @@ +import { TTask } from 'entities/task'; +import { + Avatar, + AvatarFallback, + AvatarImage, + Card, + CardContent, + Label, + Tooltip, + TooltipContent, + TooltipTrigger, + Checkbox, + Badge, +} from 'shared/ui'; + +interface TaskCardProps { + task: TTask.Task; +} + +export function TaskCard({ task }: TaskCardProps) { + return ( + + +
+ +
+

{task.title}

+ {task.description} +
+
+
+ {task.assignee && ( +
+ + + + + {task.assignee.name.charAt(0)} + + + {task.assignee.name} + + {task.dueDate && ( + + )} +
+ )} + {task.priority && ( + + {task.priority} + + )} +
+
+
+ ); +} diff --git a/steiger.config.ts b/steiger.config.ts index 536b98e..15c8e8f 100644 --- a/steiger.config.ts +++ b/steiger.config.ts @@ -10,4 +10,10 @@ export default defineConfig([ 'fsd/public-api': 'off', }, }, + // TODO: заглушка + { + rules: { + 'fsd/insignificant-slice': 'off', + }, + }, ]); From 936aab70ef937e30611012f85c78c4bb4be041f9 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Tue, 2 Jun 2026 19:14:56 +0300 Subject: [PATCH 11/26] feat(board): add entitry 'board' --- src/entities/board/api/http.ts | 27 ++ src/entities/board/api/queries.ts | 20 ++ src/entities/board/index.ts | 7 + src/entities/board/model/conts.ts | 5 + src/entities/board/model/mapper.ts | 42 +++ src/entities/board/model/mock-data.ts | 395 ++++++++++++++++++++++++++ src/entities/board/model/schemas.ts | 63 ++++ src/entities/board/model/types.ts | 10 + 8 files changed, 569 insertions(+) create mode 100644 src/entities/board/api/http.ts create mode 100644 src/entities/board/api/queries.ts create mode 100644 src/entities/board/index.ts create mode 100644 src/entities/board/model/conts.ts create mode 100644 src/entities/board/model/mapper.ts create mode 100644 src/entities/board/model/mock-data.ts create mode 100644 src/entities/board/model/schemas.ts create mode 100644 src/entities/board/model/types.ts diff --git a/src/entities/board/api/http.ts b/src/entities/board/api/http.ts new file mode 100644 index 0000000..403eab0 --- /dev/null +++ b/src/entities/board/api/http.ts @@ -0,0 +1,27 @@ +import { api } from 'shared/api'; +import * as SBoard from '../model/schemas'; +import * as TBoard from '../model/types'; + +export class BoardHttp { + static getBoardList(projectId: string, signal?: AbortSignal) { + return api({ + url: `/projects/${projectId}/boards`, + method: 'GET', + contracts: { + // response: SBoard.BoardListResponse, // TODO + }, + signal, + }); + } + static createBoard(projectId: string, data: TBoard.CreateBoardBody) { + return api({ + url: `/projects/${projectId}/boards`, + method: 'POST', + contracts: { + response: SBoard.CreateBoardResponse, + body: SBoard.CreateBoardBody, + }, + data, + }); + } +} diff --git a/src/entities/board/api/queries.ts b/src/entities/board/api/queries.ts new file mode 100644 index 0000000..e0111c9 --- /dev/null +++ b/src/entities/board/api/queries.ts @@ -0,0 +1,20 @@ +import { queryOptions } from '@tanstack/react-query'; +import { boardFabricKeys } from '../model/conts'; +import { BoardHttp } from './http'; + +export class BoardQueries { + // static getTeam(slug: string) { + // return queryOptions({ + // queryKey: boardFabricKeys.byId(slug), + // queryFn: async ({ signal }) => BoardHttp.getTeam(slug, signal), + // staleTime: 60_000, + // }); + // } + static getBoardList(projectId: string) { + return queryOptions({ + queryKey: boardFabricKeys.list(projectId), + queryFn: async ({ signal }) => BoardHttp.getBoardList(projectId, signal), + staleTime: 60_000, + }); + } +} diff --git a/src/entities/board/index.ts b/src/entities/board/index.ts new file mode 100644 index 0000000..84b995b --- /dev/null +++ b/src/entities/board/index.ts @@ -0,0 +1,7 @@ +export type * as TBoard from './model/types'; +export * as SBoard from './model/schemas'; +export { boardFabricKeys } from './model/conts'; +export { BoardHttp } from './api/http'; +export { BoardQueries } from './api/queries'; +export { mockBoard } from './model/mock-data'; +export { BoardMapper, type BoardWithTasks } from './model/mapper'; diff --git a/src/entities/board/model/conts.ts b/src/entities/board/model/conts.ts new file mode 100644 index 0000000..16efa4d --- /dev/null +++ b/src/entities/board/model/conts.ts @@ -0,0 +1,5 @@ +import { createEntityKeys } from 'shared/lib/utils'; + +export const boardFabricKeys = createEntityKeys('board', { + byId: (id: string) => ['board', id], +}); diff --git a/src/entities/board/model/mapper.ts b/src/entities/board/model/mapper.ts new file mode 100644 index 0000000..98f7797 --- /dev/null +++ b/src/entities/board/model/mapper.ts @@ -0,0 +1,42 @@ +import { BoardColumnResponse, BoardResponse } from './types'; + +// TODO: добавить таски в типы, когда они появятся в API + +export type BoardWithTasks = { + board: BoardResponse; + columns: BoardColumnResponse[]; + tasksByColumn: Record; + columnTitles: Record; +}; + +export class BoardMapper { + static toBoardWithTasks(board: BoardResponse): BoardWithTasks { + const sortedColumns = [...board.boardColumns].sort((a, b) => a.position - b.position); + const columnTitles: Record = {}; + const tasksByColumn: Record = {}; + + sortedColumns.forEach((column) => { + tasksByColumn[column.id] = []; + columnTitles[column.id] = column.name; + }); + + // tasks?.forEach((task) => { + // if (tasksByColumn[task.columnId]) { + // tasksByColumn[task.columnId].push(task); + // } else { + // console.warn(`Task ${task.id} references unknown column ${task.columnId}`); + // } + // }); + + // Object.keys(tasksByColumn).forEach((columnId) => { + // tasksByColumn[columnId].sort((a, b) => a.position - b.position); + // }); + + return { + board, + columnTitles, + columns: sortedColumns, + tasksByColumn, + }; + } +} diff --git a/src/entities/board/model/mock-data.ts b/src/entities/board/model/mock-data.ts new file mode 100644 index 0000000..3e6e7b1 --- /dev/null +++ b/src/entities/board/model/mock-data.ts @@ -0,0 +1,395 @@ +import { BoardResponse } from '../model/types'; + +export const mockTasks = [ + { + id: '1', + boardId: 'planning', + columnId: 'ideas', + title: 'Сбор вдохновения', + description: 'Изучить лучшие практики и собрать референсы', + priority: 'high', + assigneeId: '1', + dueDate: '2023-10-05', + position: 0, + createdAt: '2023-09-25T10:00:00Z', + updatedAt: '2023-09-25T10:00:00Z', + assignee: { name: 'Андрей', avatarUrl: null }, + }, + { + id: '2', + boardId: 'planning', + columnId: 'ideas', + title: 'Цели проекта', + description: 'Определить ключевые цели и метрики успеха', + priority: 'medium', + assigneeId: '2', + dueDate: '2023-10-06', + position: 1, + createdAt: '2023-09-26T11:00:00Z', + updatedAt: '2023-09-26T11:00:00Z', + assignee: { name: 'Мария', avatarUrl: null }, + }, + { + id: '3', + boardId: 'planning', + columnId: 'plan', + title: 'Дорожная карта', + description: 'Создать план реализации проекта по этапам', + priority: 'low', + assigneeId: '3', + dueDate: '2023-10-07', + position: 0, + createdAt: '2023-09-27T09:00:00Z', + updatedAt: '2023-09-27T09:00:00Z', + assignee: { name: 'Иван', avatarUrl: null }, + }, + { + id: '4', + boardId: 'planning', + columnId: 'plan', + title: 'Ресурсы', + description: 'Оценить необходимые ресурсы и бюджет', + priority: 'medium', + assigneeId: '4', + dueDate: '2023-10-08', + position: 1, + createdAt: '2023-09-28T14:00:00Z', + updatedAt: '2023-09-28T14:00:00Z', + assignee: { name: 'Сергей', avatarUrl: null }, + }, + { + id: '5', + boardId: 'planning', + columnId: 'docs', + title: 'Техническая документация', + description: 'Подготовить техническое задание', + priority: 'high', + assigneeId: '5', + dueDate: '2023-10-09', + position: 0, + createdAt: '2023-09-29T08:00:00Z', + updatedAt: '2023-09-29T08:00:00Z', + assignee: { name: 'Дмитрий', avatarUrl: null }, + }, + { + id: '6', + boardId: 'planning', + columnId: 'docs', + title: 'План коммуникации', + description: 'Разработать коммуникационную стратегию', + priority: 'medium', + assigneeId: '6', + dueDate: '2023-10-10', + position: 1, + createdAt: '2023-09-30T12:00:00Z', + updatedAt: '2023-09-30T12:00:00Z', + assignee: { name: 'Анна', avatarUrl: null }, + }, + { + id: '11', + boardId: 'in-progress', + columnId: 'todo', + title: 'Дизайн макета', + description: 'Разработать основной макет приложения', + priority: 'high', + assigneeId: '1', + dueDate: '2023-10-05', + position: 0, + createdAt: '2023-10-01T10:00:00Z', + updatedAt: '2023-10-01T10:00:00Z', + assignee: { name: 'Андрей', avatarUrl: null }, + }, + { + id: '31', + boardId: 'in-progress', + columnId: 'inProgress', + title: 'Подготовить бриф', + description: 'Подготовить бриф для дизайнеров', + priority: 'low', + assigneeId: '3', + dueDate: '2023-10-07', + position: 0, + createdAt: '2023-10-02T09:00:00Z', + updatedAt: '2023-10-02T09:00:00Z', + assignee: { name: 'Иван', avatarUrl: null }, + }, + { + id: '51', + boardId: 'in-progress', + columnId: 'review', + title: 'Техническая документация', + description: 'Проверить техническую документацию', + priority: 'high', + assigneeId: '5', + dueDate: '2023-10-09', + position: 0, + createdAt: '2023-10-03T08:00:00Z', + updatedAt: '2023-10-03T08:00:00Z', + assignee: { name: 'Дмитрий', avatarUrl: null }, + }, + { + id: '61', + boardId: 'in-progress', + columnId: 'review', + title: 'Коммуникация', + description: 'Проверить план коммуникации', + priority: 'medium', + assigneeId: '6', + dueDate: '2023-10-10', + position: 1, + createdAt: '2023-10-04T12:00:00Z', + updatedAt: '2023-10-04T12:00:00Z', + assignee: { name: 'Анна', avatarUrl: null }, + }, + { + id: 'results-1', + boardId: 'results', + columnId: 'reports', + title: 'Итоговый отчёт', + description: 'Собрать все метрики и графики', + priority: 'high', + assigneeId: '6', + dueDate: '2023-10-20', + position: 0, + createdAt: '2023-10-05T10:00:00Z', + updatedAt: '2023-10-05T10:00:00Z', + assignee: { name: 'Аналитик', avatarUrl: null }, + }, + { + id: 'results-2', + boardId: 'results', + columnId: 'insights', + title: 'Рефлексия', + description: 'Что получилось, а что нет', + priority: 'medium', + assigneeId: '7', + dueDate: '2023-10-22', + position: 0, + createdAt: '2023-10-06T11:00:00Z', + updatedAt: '2023-10-06T11:00:00Z', + assignee: { name: 'Тимлид', avatarUrl: null }, + }, + { + id: 'results-3', + boardId: 'results', + columnId: 'documentation', + title: 'Запись результатов', + description: 'Задокументировать выводы в Confluence', + priority: 'low', + assigneeId: '8', + dueDate: '2023-10-25', + position: 0, + createdAt: '2023-10-07T14:00:00Z', + updatedAt: '2023-10-07T14:00:00Z', + assignee: { name: 'Документалист', avatarUrl: null }, + }, +]; + +export const mockBoard: BoardResponse = { + id: 'planning', + name: 'Планирование проекта', + projectId: 'project-001', + settings: { + defaultView: 'kanban', + theme: 'light', + timezone: 'UTC+3', + }, + position: 1, + ownerId: 'user-001', + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-03-20T14:30:00Z', + boardColumns: [ + { + id: 'ideas', + boardId: 'planning', + name: 'Идеи', + position: 0, + status: 'backlog', + color: '#9CA3AF', + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + }, + { + id: 'plan', + boardId: 'planning', + name: 'План', + position: 1, + status: 'todo', + color: '#F59E0B', + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + }, + { + id: 'docs', + boardId: 'planning', + name: 'Документы', + position: 2, + status: 'in_progress', + color: '#3B82F6', + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + }, + ], + boardViews: [ + { + id: 'view-001', + boardId: 'planning', + type: 'kanban', + name: 'Kanban Board', + settings: { + showEmptyColumns: 'true', + cardSize: 'medium', + }, + position: 0, + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + }, + ], +}; + +export const mockBoardInProgress: BoardResponse = { + id: 'in-progress', + name: 'Задачи в работе', + projectId: 'project-001', + settings: { + defaultView: 'kanban', + theme: 'light', + timezone: 'UTC+3', + }, + position: 2, + ownerId: 'user-001', + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-03-25T09:00:00Z', + boardColumns: [ + { + id: 'todo', + boardId: 'in-progress', + name: 'Ожидает выполнения', + position: 0, + status: 'todo', + color: '#F59E0B', + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-02-01T10:00:00Z', + }, + { + id: 'inProgress', + boardId: 'in-progress', + name: 'В работе', + position: 1, + status: 'in_progress', + color: '#3B82F6', + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-02-01T10:00:00Z', + }, + { + id: 'review', + boardId: 'in-progress', + name: 'На проверке', + position: 2, + status: 'in_progress', + color: '#8B5CF6', + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-02-01T10:00:00Z', + }, + { + id: 'bankReview', + boardId: 'in-progress', + name: 'На проверке в банке', + position: 3, + status: 'in_progress', + color: '#EC4899', + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-02-01T10:00:00Z', + }, + { + id: 'done', + boardId: 'in-progress', + name: 'Завершено', + position: 4, + status: 'done', + color: '#10B981', + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-02-01T10:00:00Z', + }, + ], + boardViews: [ + { + id: 'view-002', + boardId: 'in-progress', + type: 'kanban', + name: 'Kanban Board', + settings: { + showEmptyColumns: 'true', + cardSize: 'medium', + }, + position: 0, + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-02-01T10:00:00Z', + }, + ], +}; + +export const mockBoardResults: BoardResponse = { + id: 'results', + name: 'Фиксация результатов', + projectId: 'project-001', + settings: { + defaultView: 'list', + theme: 'dark', + timezone: 'UTC+3', + }, + position: 3, + ownerId: 'user-002', + createdAt: '2024-03-01T10:00:00Z', + updatedAt: '2024-03-28T16:00:00Z', + boardColumns: [ + { + id: 'reports', + boardId: 'results', + name: 'Отчёты', + position: 0, + status: 'todo', + color: '#F59E0B', + createdAt: '2024-03-01T10:00:00Z', + updatedAt: '2024-03-01T10:00:00Z', + }, + { + id: 'insights', + boardId: 'results', + name: 'Выводы', + position: 1, + status: 'in_progress', + color: '#3B82F6', + createdAt: '2024-03-01T10:00:00Z', + updatedAt: '2024-03-01T10:00:00Z', + }, + { + id: 'documentation', + boardId: 'results', + name: 'Документация', + position: 2, + status: 'done', + color: '#10B981', + createdAt: '2024-03-01T10:00:00Z', + updatedAt: '2024-03-01T10:00:00Z', + }, + ], + boardViews: [ + { + id: 'view-003', + boardId: 'results', + type: 'kanban', + name: 'List View', + settings: { + groupBy: 'status', + sortBy: 'position', + }, + position: 0, + createdAt: '2024-03-01T10:00:00Z', + updatedAt: '2024-03-01T10:00:00Z', + }, + ], +}; + +// Массив всех досок для удобства +export const mockBoards: BoardResponse[] = [mockBoard, mockBoardInProgress, mockBoardResults]; diff --git a/src/entities/board/model/schemas.ts b/src/entities/board/model/schemas.ts new file mode 100644 index 0000000..4212a2a --- /dev/null +++ b/src/entities/board/model/schemas.ts @@ -0,0 +1,63 @@ +import { DateTimeString, GlobalSuccess, PaginatedResponseSchema } from 'shared/api'; +import { z } from 'zod/v4'; + +const ColumnStatusEnum = z.enum(['backlog', 'todo', 'in_progress', 'done', 'cancelled']); +const ViewTypeEnum = z.enum(['kanban', 'calendar', 'gantt_matrix']); +const Settings = z.record(z.string(), z.string()).default({}); + +export const BoardColumn = z.object({ + id: z.string(), + boardId: z.string(), + name: z.string(), + position: z.number(), + status: ColumnStatusEnum, + color: z.string(), + createdAt: DateTimeString, + updatedAt: DateTimeString, +}); + +export const BoardView = z.object({ + id: z.string(), + boardId: z.string(), + type: ViewTypeEnum, + name: z.string(), + settings: z.record(z.string(), z.string()), + position: z.number(), + createdAt: DateTimeString, + updatedAt: DateTimeString, +}); + +export const Board = z.object({ + id: z.string(), + name: z.string(), + projectId: z.string(), + settings: z.record(z.string(), z.string()), + position: z.number(), + ownerId: z.string(), + createdAt: DateTimeString, + updatedAt: DateTimeString, + boardColumns: z.array(BoardColumn), + boardViews: z.array(BoardView), +}); + +// Board Columns +// export const BoardColumnsResponse = z.object(); +// export const BoardColumnByIdResponse = z.object(); + +// export const CreateBoardColumnsBody = z.object(); +// export const UpdateBoardColumnByIdBody = z.object(); + +// export const DeleteBoardColumnByIdResponse = z.object(); + +// Boards + +export const BoardListResponse = PaginatedResponseSchema(Board); +// export const BoardByIdResponse = z.object(); + +// export const UpdateBoardBody = z.object(); +export const CreateBoardBody = z.object({ + name: z.string(), + position: z.number(), + settings: Settings, +}); +export const CreateBoardResponse = GlobalSuccess.extend({ boardId: z.string() }); diff --git a/src/entities/board/model/types.ts b/src/entities/board/model/types.ts new file mode 100644 index 0000000..46ee4f5 --- /dev/null +++ b/src/entities/board/model/types.ts @@ -0,0 +1,10 @@ +import { z } from 'zod/v4'; +import * as SBoard from './schemas'; + +export type BoardColumnResponse = z.infer; + +export type BoardResponse = z.infer; +export type BoardListResponse = z.infer; + +export type CreateBoardResponse = z.infer; +export type CreateBoardBody = z.infer; From 922b2151b49c1dbfe0f5e2ed7645b22af3274ba7 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Tue, 2 Jun 2026 19:15:44 +0300 Subject: [PATCH 12/26] feat: add useProjectStore --- src/entities/project/index.ts | 2 ++ src/entities/project/lib/useInitProjectId.ts | 16 ++++++++++++++++ src/entities/project/model/store.ts | 17 +++++++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 src/entities/project/lib/useInitProjectId.ts create mode 100644 src/entities/project/model/store.ts diff --git a/src/entities/project/index.ts b/src/entities/project/index.ts index 190e163..6ce7f95 100644 --- a/src/entities/project/index.ts +++ b/src/entities/project/index.ts @@ -7,3 +7,5 @@ export { PROJECT_ICONS } from './config/icons'; export { PROJECT_COLORS } from './config/colors'; export { projectIconCodeToEmoji } from './lib/emoji'; export { buildProjectShareUrl } from './lib/share-url'; +export { useProjectStore } from './model/store'; +export { useInitProjectId } from './lib/useInitProjectId'; diff --git a/src/entities/project/lib/useInitProjectId.ts b/src/entities/project/lib/useInitProjectId.ts new file mode 100644 index 0000000..c115e5b --- /dev/null +++ b/src/entities/project/lib/useInitProjectId.ts @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; +import { useProjectStore } from '../model/store'; + +export function useInitProjectId(projectId: string) { + const setProjectid = useProjectStore((s) => s.setProjectId); + const clearProjectId = useProjectStore((s) => s.clearProjectId); + + useEffect(() => { + setProjectid(projectId); + + return () => { + clearProjectId(); + }; + }, [clearProjectId, projectId, setProjectid]); + return null; +} diff --git a/src/entities/project/model/store.ts b/src/entities/project/model/store.ts new file mode 100644 index 0000000..d90c368 --- /dev/null +++ b/src/entities/project/model/store.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand'; + +interface ProjectStore { + projectId: string | null; + setProjectId: (id: string) => void; + clearProjectId: () => void; +} + +export const useProjectStore = create((set) => ({ + projectId: null, + setProjectId(id) { + set({ projectId: id }); + }, + clearProjectId() { + set({ projectId: null }); + }, +})); From f435c5e868caffe6cfba8df0bdad4d006521d8d9 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Wed, 3 Jun 2026 00:28:50 +0300 Subject: [PATCH 13/26] feat(board): add all schemas, types, api routes and queires --- src/entities/board/api/http.ts | 150 +++++++++++++++++++++++++++- src/entities/board/api/queries.ts | 47 +++++++-- src/entities/board/index.ts | 1 + src/entities/board/lib/colors.ts | 14 +++ src/entities/board/model/conts.ts | 6 +- src/entities/board/model/mapper.ts | 10 +- src/entities/board/model/schemas.ts | 89 +++++++++++++---- src/entities/board/model/types.ts | 21 +++- 8 files changed, 301 insertions(+), 37 deletions(-) create mode 100644 src/entities/board/lib/colors.ts diff --git a/src/entities/board/api/http.ts b/src/entities/board/api/http.ts index 403eab0..ca6097d 100644 --- a/src/entities/board/api/http.ts +++ b/src/entities/board/api/http.ts @@ -8,20 +8,166 @@ export class BoardHttp { url: `/projects/${projectId}/boards`, method: 'GET', contracts: { - // response: SBoard.BoardListResponse, // TODO + response: SBoard.BoardListResponse, }, signal, }); } + + static getBoard(projectId: string, id: string, signal?: AbortSignal) { + return api({ + url: `/projects/${projectId}/boards/${id}`, + method: 'GET', + contracts: { + response: SBoard.Board, + }, + signal, + }); + } + static createBoard(projectId: string, data: TBoard.CreateBoardBody) { return api({ url: `/projects/${projectId}/boards`, method: 'POST', + data, contracts: { - response: SBoard.CreateBoardResponse, body: SBoard.CreateBoardBody, + response: SBoard.CreateBoardResponse, }, + }); + } + + static updateBoard(projectId: string, id: string, data: TBoard.UpdateBoardBody) { + return api({ + url: `/projects/${projectId}/boards/${id}`, + method: 'PATCH', data, + contracts: { + body: SBoard.UpdateBoardBody, + response: SBoard.ActionResponse, + }, + }); + } + + static removeBoard(projectId: string, id: string) { + return api({ + url: `/projects/${projectId}/boards/${id}`, + method: 'DELETE', + contracts: { + response: SBoard.ActionResponse, + }, + }); + } + + static getBoardColumnList(boardId: string, signal?: AbortSignal) { + return api({ + url: `/boards/${boardId}/columns`, + method: 'GET', + contracts: { + response: SBoard.BoardColumnListResponse, + }, + signal, + }); + } + + static getBoardColumn(boardId: string, id: string, signal?: AbortSignal) { + return api({ + url: `/boards/${boardId}/columns/${id}`, + method: 'GET', + contracts: { + response: SBoard.BoardColumn, + }, + signal, + }); + } + + static createBoardColumn(boardId: string, data: TBoard.CreateBoardColumnBody) { + return api({ + url: `/boards/${boardId}/columns`, + method: 'POST', + data, + contracts: { + body: SBoard.CreateBoardColumnBody, + response: SBoard.CreateBoardColumnResponse, + }, + }); + } + + static updateBoardColumn(boardId: string, id: string, data: TBoard.UpdateBoardColumnBody) { + return api({ + url: `/boards/${boardId}/columns/${id}`, + method: 'PATCH', + data, + contracts: { + body: SBoard.UpdateBoardColumnBody, + response: SBoard.ActionResponse, + }, + }); + } + + static removeBoardColumn(boardId: string, id: string) { + return api({ + url: `/boards/${boardId}/columns/${id}`, + method: 'DELETE', + contracts: { + response: SBoard.ActionResponse, + }, + }); + } + + static getBoardViewList(boardId: string, signal?: AbortSignal) { + return api({ + url: `/boards/${boardId}/views`, + method: 'GET', + contracts: { + response: SBoard.BoardViewListResponse, + }, + signal, + }); + } + + static getBoardView(boardId: string, id: string, signal?: AbortSignal) { + return api({ + url: `/boards/${boardId}/views/${id}`, + method: 'GET', + contracts: { + response: SBoard.BoardView, + }, + signal, + }); + } + + static createBoardView(boardId: string, data: TBoard.CreateBoardViewBody) { + return api({ + url: `/boards/${boardId}/views`, + method: 'POST', + data, + contracts: { + body: SBoard.CreateBoardViewBody, + response: SBoard.CreateBoardViewResponse, + }, + }); + } + + static updateBoardView(boardId: string, id: string, data: TBoard.UpdateBoardViewBody) { + return api({ + url: `/boards/${boardId}/views/${id}`, + method: 'PATCH', + data, + contracts: { + body: SBoard.UpdateBoardViewBody, + response: SBoard.ActionResponse, + }, + }); + } + + static removeBoardView(boardId: string, id: string) { + return api({ + url: `/boards/${boardId}/views/${id}`, + method: 'DELETE', + contracts: { + response: SBoard.ActionResponse, + }, }); } } diff --git a/src/entities/board/api/queries.ts b/src/entities/board/api/queries.ts index e0111c9..9154c47 100644 --- a/src/entities/board/api/queries.ts +++ b/src/entities/board/api/queries.ts @@ -3,13 +3,6 @@ import { boardFabricKeys } from '../model/conts'; import { BoardHttp } from './http'; export class BoardQueries { - // static getTeam(slug: string) { - // return queryOptions({ - // queryKey: boardFabricKeys.byId(slug), - // queryFn: async ({ signal }) => BoardHttp.getTeam(slug, signal), - // staleTime: 60_000, - // }); - // } static getBoardList(projectId: string) { return queryOptions({ queryKey: boardFabricKeys.list(projectId), @@ -17,4 +10,44 @@ export class BoardQueries { staleTime: 60_000, }); } + + static getBoard(projectId: string, id: string) { + return queryOptions({ + queryKey: boardFabricKeys.detail(projectId, id), + queryFn: async ({ signal }) => BoardHttp.getBoard(projectId, id, signal), + staleTime: 60_000, + }); + } + + static getBoardColumnList(boardId: string) { + return queryOptions({ + queryKey: boardFabricKeys.columns(boardId), + queryFn: async ({ signal }) => BoardHttp.getBoardColumnList(boardId, signal), + staleTime: 60_000, + }); + } + + static getBoardColumn(boardId: string, id: string) { + return queryOptions({ + queryKey: boardFabricKeys.column(boardId, id), + queryFn: async ({ signal }) => BoardHttp.getBoardColumn(boardId, id, signal), + staleTime: 60_000, + }); + } + + static getBoardViewList(boardId: string) { + return queryOptions({ + queryKey: boardFabricKeys.views(boardId), + queryFn: async ({ signal }) => BoardHttp.getBoardViewList(boardId, signal), + staleTime: 60_000, + }); + } + + static getBoardView(boardId: string, id: string) { + return queryOptions({ + queryKey: boardFabricKeys.view(boardId, id), + queryFn: async ({ signal }) => BoardHttp.getBoardView(boardId, id, signal), + staleTime: 60_000, + }); + } } diff --git a/src/entities/board/index.ts b/src/entities/board/index.ts index 84b995b..b063b65 100644 --- a/src/entities/board/index.ts +++ b/src/entities/board/index.ts @@ -5,3 +5,4 @@ export { BoardHttp } from './api/http'; export { BoardQueries } from './api/queries'; export { mockBoard } from './model/mock-data'; export { BoardMapper, type BoardWithTasks } from './model/mapper'; +export { BOARD_COLUMN_COLORS } from './lib/colors'; diff --git a/src/entities/board/lib/colors.ts b/src/entities/board/lib/colors.ts new file mode 100644 index 0000000..b6bdfab --- /dev/null +++ b/src/entities/board/lib/colors.ts @@ -0,0 +1,14 @@ +export const BOARD_COLUMN_COLORS = [ + '#9FA8DA', + '#7E57C2', + '#9575CD', + '#AB47BC', + '#F06292', + '#FF8A65', + '#4FC3F7', + '#4DB6AC', + '#81C784', + '#DCE775', + '#FFF176', + '#FFB74D', +] as const; diff --git a/src/entities/board/model/conts.ts b/src/entities/board/model/conts.ts index 16efa4d..cbfe1d7 100644 --- a/src/entities/board/model/conts.ts +++ b/src/entities/board/model/conts.ts @@ -1,5 +1,9 @@ import { createEntityKeys } from 'shared/lib/utils'; export const boardFabricKeys = createEntityKeys('board', { - byId: (id: string) => ['board', id], + detail: (projectId: string, id: string) => ['projects', projectId, 'boards', id], + columns: (boardId: string) => ['boards', boardId, 'columns'], + column: (boardId: string, id: string) => ['boards', boardId, 'columns', id], + views: (boardId: string) => ['boards', boardId, 'views'], + view: (boardId: string, id: string) => ['boards', boardId, 'views', id], }); diff --git a/src/entities/board/model/mapper.ts b/src/entities/board/model/mapper.ts index 98f7797..d6407bc 100644 --- a/src/entities/board/model/mapper.ts +++ b/src/entities/board/model/mapper.ts @@ -4,20 +4,19 @@ import { BoardColumnResponse, BoardResponse } from './types'; export type BoardWithTasks = { board: BoardResponse; - columns: BoardColumnResponse[]; + columns: Record; tasksByColumn: Record; - columnTitles: Record; }; export class BoardMapper { static toBoardWithTasks(board: BoardResponse): BoardWithTasks { const sortedColumns = [...board.boardColumns].sort((a, b) => a.position - b.position); - const columnTitles: Record = {}; const tasksByColumn: Record = {}; + const columns: Record = {}; sortedColumns.forEach((column) => { tasksByColumn[column.id] = []; - columnTitles[column.id] = column.name; + columns[column.id] = column; }); // tasks?.forEach((task) => { @@ -34,8 +33,7 @@ export class BoardMapper { return { board, - columnTitles, - columns: sortedColumns, + columns, tasksByColumn, }; } diff --git a/src/entities/board/model/schemas.ts b/src/entities/board/model/schemas.ts index 4212a2a..2af7ea0 100644 --- a/src/entities/board/model/schemas.ts +++ b/src/entities/board/model/schemas.ts @@ -1,9 +1,11 @@ import { DateTimeString, GlobalSuccess, PaginatedResponseSchema } from 'shared/api'; import { z } from 'zod/v4'; -const ColumnStatusEnum = z.enum(['backlog', 'todo', 'in_progress', 'done', 'cancelled']); -const ViewTypeEnum = z.enum(['kanban', 'calendar', 'gantt_matrix']); -const Settings = z.record(z.string(), z.string()).default({}); +export const ActionResponse = GlobalSuccess; + +export const ColumnStatusEnum = z.enum(['backlog', 'todo', 'in_progress', 'done', 'canceled']); +export const ViewTypeEnum = z.enum(['kanban', 'calendar', 'gantt_matrix']); +export const Settings = z.record(z.string(), z.unknown()); export const BoardColumn = z.object({ id: z.string(), @@ -21,7 +23,7 @@ export const BoardView = z.object({ boardId: z.string(), type: ViewTypeEnum, name: z.string(), - settings: z.record(z.string(), z.string()), + settings: Settings, position: z.number(), createdAt: DateTimeString, updatedAt: DateTimeString, @@ -31,33 +33,80 @@ export const Board = z.object({ id: z.string(), name: z.string(), projectId: z.string(), - settings: z.record(z.string(), z.string()), + settings: Settings, position: z.number(), - ownerId: z.string(), + ownerId: z.string().nullable(), createdAt: DateTimeString, updatedAt: DateTimeString, boardColumns: z.array(BoardColumn), boardViews: z.array(BoardView), }); -// Board Columns -// export const BoardColumnsResponse = z.object(); -// export const BoardColumnByIdResponse = z.object(); +export const BoardListResponse = PaginatedResponseSchema(Board); +export const BoardColumnListResponse = PaginatedResponseSchema(BoardColumn); +export const BoardViewListResponse = PaginatedResponseSchema(BoardView); -// export const CreateBoardColumnsBody = z.object(); -// export const UpdateBoardColumnByIdBody = z.object(); +export const CreateBoardBody = z.object({ + name: z + .string() + .min(1, 'Название доски не может быть пустым') + .max(100, 'Название доски не должно превышать 100 символов'), + position: z.number(), + settings: Settings.optional(), +}); -// export const DeleteBoardColumnByIdResponse = z.object(); +export const UpdateBoardBody = CreateBoardBody.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + } +); -// Boards +export const CreateBoardResponse = GlobalSuccess.extend({ boardId: z.string() }); -export const BoardListResponse = PaginatedResponseSchema(Board); -// export const BoardByIdResponse = z.object(); +export const CreateBoardViewBody = z.object({ + type: ViewTypeEnum, + name: z + .string() + .min(1, 'Название представления не может быть пустым') + .max(100, 'Название представления не должно превышать 100 символов'), + settings: Settings.optional(), + position: z.number(), +}); -// export const UpdateBoardBody = z.object(); -export const CreateBoardBody = z.object({ - name: z.string(), +export const UpdateBoardViewBody = CreateBoardViewBody.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + } +); + +export const CreateBoardViewResponse = GlobalSuccess.extend({ + viewId: z.string(), +}); + +export const CreateBoardColumnBody = z.object({ + name: z + .string() + .min(1, 'Название колонки не может быть пустым') + .max(50, 'Название колонки не должно превышать 50 символов'), position: z.number(), - settings: Settings, + color: z + .string() + .regex(/^#[A-Fa-f0-9]{6}$/) + .optional(), +}); + +export const UpdateBoardColumnBody = CreateBoardColumnBody.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + } +); + +export const CreateBoardColumnResponse = GlobalSuccess.extend({ + columnId: z.string(), }); -export const CreateBoardResponse = GlobalSuccess.extend({ boardId: z.string() }); diff --git a/src/entities/board/model/types.ts b/src/entities/board/model/types.ts index 46ee4f5..ef88b13 100644 --- a/src/entities/board/model/types.ts +++ b/src/entities/board/model/types.ts @@ -1,10 +1,29 @@ import { z } from 'zod/v4'; import * as SBoard from './schemas'; +export type BoardSettings = z.infer; +export type BoardColumnStatus = z.infer; +export type BoardViewType = z.infer; + export type BoardColumnResponse = z.infer; +export type BoardColumnListResponse = z.infer; + +export type BoardViewResponse = z.infer; +export type BoardViewListResponse = z.infer; export type BoardResponse = z.infer; export type BoardListResponse = z.infer; -export type CreateBoardResponse = z.infer; export type CreateBoardBody = z.infer; +export type UpdateBoardBody = z.infer; +export type CreateBoardResponse = z.infer; + +export type CreateBoardViewBody = z.infer; +export type UpdateBoardViewBody = z.infer; +export type CreateBoardViewResponse = z.infer; + +export type CreateBoardColumnBody = z.infer; +export type UpdateBoardColumnBody = z.infer; +export type CreateBoardColumnResponse = z.infer; + +export type ActionResponse = z.infer; From 45cd5b15c071cbad23f7b694807a365e94cde5fa Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Wed, 3 Jun 2026 09:51:59 +0300 Subject: [PATCH 14/26] refactor(task): remove TaskCard component and clean up exports --- src/entities/task/index.ts | 1 - src/entities/task/ui/TaskCard.tsx | 67 ------------------------------- 2 files changed, 68 deletions(-) delete mode 100644 src/entities/task/ui/TaskCard.tsx diff --git a/src/entities/task/index.ts b/src/entities/task/index.ts index 1285342..d810ae8 100644 --- a/src/entities/task/index.ts +++ b/src/entities/task/index.ts @@ -1,5 +1,4 @@ export type * as TTask from './model/types'; export * as STask from './model/schemas'; export { TaskHttp } from './api/http'; -export { TaskCard } from './ui/TaskCard'; export { taskFabricKeys } from './model/const'; diff --git a/src/entities/task/ui/TaskCard.tsx b/src/entities/task/ui/TaskCard.tsx deleted file mode 100644 index fad89ea..0000000 --- a/src/entities/task/ui/TaskCard.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { TTask } from 'entities/task'; -import { - Avatar, - AvatarFallback, - AvatarImage, - Card, - CardContent, - Label, - Tooltip, - TooltipContent, - TooltipTrigger, - Checkbox, - Badge, -} from 'shared/ui'; - -interface TaskCardProps { - task: TTask.Task; -} - -export function TaskCard({ task }: TaskCardProps) { - return ( - - -
- -
-

{task.title}

- {task.description} -
-
-
- {task.assignee && ( -
- - - - - {task.assignee.name.charAt(0)} - - - {task.assignee.name} - - {task.dueDate && ( - - )} -
- )} - {task.priority && ( - - {task.priority} - - )} -
-
-
- ); -} From 66a3785b69446e1a594b91619f864f1bd29dd006 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Wed, 3 Jun 2026 09:52:58 +0300 Subject: [PATCH 15/26] feat(color-picker): add ColorPicker component and color constants --- src/shared/ui/color-picker/ColorPicker.tsx | 77 ++++++++++++++++++++++ src/shared/ui/color-picker/const.ts | 14 ++++ src/shared/ui/index.ts | 1 + 3 files changed, 92 insertions(+) create mode 100644 src/shared/ui/color-picker/ColorPicker.tsx create mode 100644 src/shared/ui/color-picker/const.ts diff --git a/src/shared/ui/color-picker/ColorPicker.tsx b/src/shared/ui/color-picker/ColorPicker.tsx new file mode 100644 index 0000000..e6024e3 --- /dev/null +++ b/src/shared/ui/color-picker/ColorPicker.tsx @@ -0,0 +1,77 @@ +'use client'; +import { ComponentProps, CSSProperties, MouseEvent } from 'react'; +import { COLORS } from './const'; +import { cn } from 'shared/lib/utils'; +import { Check } from 'lucide-react'; + +type ColorPickerProps = ComponentProps<'button'> & { + activeColor: string; + setActiveColor: (color: string) => void; + size?: keyof typeof variant.size; + colors?: string[]; +}; + +const variant = { + size: { + default: 'size-8', + sm: 'size-5', + xs: 'size-3', + }, +}; + +export function ColorPicker({ + disabled, + className, + onClick, + setActiveColor, + activeColor, + size = 'default', + colors, + ...props +}: ColorPickerProps) { + const handlePickColor = (e: MouseEvent, color: string) => { + onClick?.(e); + setActiveColor?.(color); + }; + return ( +
+ {(colors ?? COLORS).map((item) => { + const isSelected = activeColor === item; + const isVeryLight = item.toLowerCase() === '#ffffff'; + + return ( + + ); + })} +
+ ); +} diff --git a/src/shared/ui/color-picker/const.ts b/src/shared/ui/color-picker/const.ts new file mode 100644 index 0000000..3ee1d78 --- /dev/null +++ b/src/shared/ui/color-picker/const.ts @@ -0,0 +1,14 @@ +export const COLORS = [ + '#9FA8DA', + '#7E57C2', + '#9575CD', + '#AB47BC', + '#F06292', + '#FF8A65', + '#4FC3F7', + '#4DB6AC', + '#81C784', + '#DCE775', + '#FFF176', + '#FFB74D', +] as const; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 656e015..50ef18f 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -40,3 +40,4 @@ export * from './Empty'; export * from './ScrollArea'; export * from './Kanban'; export * from './checkbox/Checkbox'; +export * from './color-picker/ColorPicker'; From a0eb4a9da2dde46cdcbf961bfc2f5f422566bbdc Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Wed, 3 Jun 2026 09:54:01 +0300 Subject: [PATCH 16/26] feat(task): implement CreateTask components and hooks for task creation --- src/features/task/create/index.ts | 2 + .../task/create/lib/useClickOutside.tsx | 16 +++++ .../task/create/model/useActiveFieldStore.ts | 13 ++++ .../task/create/model/useCreateTask.tsx | 25 +++++++ .../task/create/ui/CreateTaskButton.tsx | 12 ++++ .../task/create/ui/CreateTaskDialog.tsx | 3 + .../task/create/ui/CreateTaskField.tsx | 72 +++++++++++++++++++ 7 files changed, 143 insertions(+) create mode 100644 src/features/task/create/index.ts create mode 100644 src/features/task/create/lib/useClickOutside.tsx create mode 100644 src/features/task/create/model/useActiveFieldStore.ts create mode 100644 src/features/task/create/model/useCreateTask.tsx create mode 100644 src/features/task/create/ui/CreateTaskButton.tsx create mode 100644 src/features/task/create/ui/CreateTaskDialog.tsx create mode 100644 src/features/task/create/ui/CreateTaskField.tsx diff --git a/src/features/task/create/index.ts b/src/features/task/create/index.ts new file mode 100644 index 0000000..d01684f --- /dev/null +++ b/src/features/task/create/index.ts @@ -0,0 +1,2 @@ +export { CreateTaskField } from './ui/CreateTaskField'; +export { CreateTaskButton } from './ui/CreateTaskButton'; diff --git a/src/features/task/create/lib/useClickOutside.tsx b/src/features/task/create/lib/useClickOutside.tsx new file mode 100644 index 0000000..a21887f --- /dev/null +++ b/src/features/task/create/lib/useClickOutside.tsx @@ -0,0 +1,16 @@ +/* eslint-disable check-file/filename-naming-convention */ +import { useLayoutEffect } from 'react'; + +export function useClickOutside(id: string, handler: () => void) { + useLayoutEffect(() => { + const listener = (e: MouseEvent) => { + const elem = document.getElementById(id); + if (!elem) return; + console.log(elem); + + if (!elem?.contains(e.target as Node)) handler?.(); + }; + document.addEventListener('mouseup', listener); + return () => document.removeEventListener('mouseup', listener); + }, [handler, id]); +} diff --git a/src/features/task/create/model/useActiveFieldStore.ts b/src/features/task/create/model/useActiveFieldStore.ts new file mode 100644 index 0000000..e2b1ccd --- /dev/null +++ b/src/features/task/create/model/useActiveFieldStore.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +interface ActiveFieldStore { + activeId: string | null; + open: (id: string) => void; + close: () => void; +} + +export const useActiveFieldStore = create((set) => ({ + activeId: null, + open: (id) => set({ activeId: id }), + close: () => set({ activeId: null }), +})); diff --git a/src/features/task/create/model/useCreateTask.tsx b/src/features/task/create/model/useCreateTask.tsx new file mode 100644 index 0000000..257654c --- /dev/null +++ b/src/features/task/create/model/useCreateTask.tsx @@ -0,0 +1,25 @@ +/* eslint-disable check-file/filename-naming-convention */ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { type TTask } from 'entities/task'; +import { TaskHttp } from 'entities/task'; + +type CreateTaskVariables = { + body: TTask.CreateTaskBody; +}; + +export type UseCreateProjectOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useCreateTask({ onSuccess, ...rest }: UseCreateProjectOptions = {}) { + // TODO + return useMutation({ + ...rest, + mutationFn: ({ body }) => TaskHttp.createTask(body), + onMutate: (data, ctx) => {}, + onSuccess: async (res, variables, _r, context) => { + onSuccess?.(res, variables, _r, context); + }, + }); +} diff --git a/src/features/task/create/ui/CreateTaskButton.tsx b/src/features/task/create/ui/CreateTaskButton.tsx new file mode 100644 index 0000000..7b6ddd7 --- /dev/null +++ b/src/features/task/create/ui/CreateTaskButton.tsx @@ -0,0 +1,12 @@ +import { Plus } from 'lucide-react'; +import { Button } from 'shared/ui'; +import { useActiveFieldStore } from '../model/useActiveFieldStore'; + +export function CreateTaskButton({ id }: { id: string }) { + const open = useActiveFieldStore((s) => s.open); + return ( + + ); +} diff --git a/src/features/task/create/ui/CreateTaskDialog.tsx b/src/features/task/create/ui/CreateTaskDialog.tsx new file mode 100644 index 0000000..3198d56 --- /dev/null +++ b/src/features/task/create/ui/CreateTaskDialog.tsx @@ -0,0 +1,3 @@ +export function CreateTaskDialog() { + return 'dialog'; +} diff --git a/src/features/task/create/ui/CreateTaskField.tsx b/src/features/task/create/ui/CreateTaskField.tsx new file mode 100644 index 0000000..4388674 --- /dev/null +++ b/src/features/task/create/ui/CreateTaskField.tsx @@ -0,0 +1,72 @@ +'use client'; +import React, { ComponentProps, InputEvent, KeyboardEvent, useRef } from 'react'; +import { Card, CardContent, Checkbox } from 'shared/ui'; +import { useActiveFieldStore } from '../model/useActiveFieldStore'; +import { useClickOutside } from '../lib/useClickOutside'; +import { useCreateTask } from '../model/useCreateTask'; +import { TTask } from 'entities/task'; + +interface Props + extends + Omit, 'children' | 'id'>, + Pick {} + +function CreateTaskField_({ boardId, columnId, ...props }: Props) { + const ref = useRef(null); + const refTextArea = useRef(null); + const { close, activeId } = useActiveFieldStore(); + const { mutateAsync } = useCreateTask(); + + const clearTextArea = () => { + const elem = refTextArea.current; + if (elem) { + elem.value = ''; + } + }; + + const handleSubmit = (body: TTask.CreateTaskBody) => { + // TODO: что-то сделать с данными + clearTextArea(); + mutateAsync({ body }); + }; + + const updateHeight = (e: InputEvent) => { + const textarea = e.target as HTMLTextAreaElement; + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; + }; + const onInput = (e: InputEvent) => { + updateHeight(e); + }; + + const onKeyDown = (e: KeyboardEvent) => { + const textarea = e.target as HTMLTextAreaElement; + + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit({ title: textarea.value, columnId, boardId }); + } + }; + + useClickOutside(`create-task-${columnId}`, close); + + if (activeId !== columnId) return null; + + return ( + + + + + + + ); +} + +export const CreateTaskField = React.memo(CreateTaskField_); From 596f7081a6146a0e2a6572b609e1f687e991c55c Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Wed, 3 Jun 2026 09:54:39 +0300 Subject: [PATCH 17/26] feat(board): add CreateBoard components, form, and hooks for board creation --- src/features/boards/create/index.ts | 2 + .../boards/create/model/default-values.ts | 9 +++ src/features/boards/create/model/schemas.ts | 6 ++ src/features/boards/create/model/type.ts | 6 ++ .../boards/create/model/useCreateBoard.ts | 28 +++++++ .../boards/create/model/useCreateBoardForm.ts | 45 ++++++++++++ .../boards/create/ui/CreateBoardDialog.tsx | 68 +++++++++++++++++ .../boards/create/ui/CreateBoardForm.tsx | 73 +++++++++++++++++++ 8 files changed, 237 insertions(+) create mode 100644 src/features/boards/create/index.ts create mode 100644 src/features/boards/create/model/default-values.ts create mode 100644 src/features/boards/create/model/schemas.ts create mode 100644 src/features/boards/create/model/type.ts create mode 100644 src/features/boards/create/model/useCreateBoard.ts create mode 100644 src/features/boards/create/model/useCreateBoardForm.ts create mode 100644 src/features/boards/create/ui/CreateBoardDialog.tsx create mode 100644 src/features/boards/create/ui/CreateBoardForm.tsx diff --git a/src/features/boards/create/index.ts b/src/features/boards/create/index.ts new file mode 100644 index 0000000..34bc906 --- /dev/null +++ b/src/features/boards/create/index.ts @@ -0,0 +1,2 @@ +export { CreateBoardDialog } from './ui/CreateBoardDialog'; +export { CreateBoardForm } from './ui/CreateBoardForm'; diff --git a/src/features/boards/create/model/default-values.ts b/src/features/boards/create/model/default-values.ts new file mode 100644 index 0000000..66c28fb --- /dev/null +++ b/src/features/boards/create/model/default-values.ts @@ -0,0 +1,9 @@ +import { CreateBoardFormValues } from './type'; + +export function getDefaultCreateBoardValues(): CreateBoardFormValues { + return { + name: '', + position: 0, + settings: {}, + }; +} diff --git a/src/features/boards/create/model/schemas.ts b/src/features/boards/create/model/schemas.ts new file mode 100644 index 0000000..fae521c --- /dev/null +++ b/src/features/boards/create/model/schemas.ts @@ -0,0 +1,6 @@ +import { SBoard } from 'entities/board'; +import { z } from 'zod/v4'; + +export const CreateBoardFormSchema = SBoard.CreateBoardBody.extend({ + position: z.union([z.string(), z.number()]).transform(Number), +}); diff --git a/src/features/boards/create/model/type.ts b/src/features/boards/create/model/type.ts new file mode 100644 index 0000000..529d29f --- /dev/null +++ b/src/features/boards/create/model/type.ts @@ -0,0 +1,6 @@ +import { z } from 'zod/v4'; +import { CreateBoardFormSchema } from './schemas'; + +export type CreateBoardFormValues = z.input; + +export type CreateBoardFormOutput = z.output; diff --git a/src/features/boards/create/model/useCreateBoard.ts b/src/features/boards/create/model/useCreateBoard.ts new file mode 100644 index 0000000..8f182b7 --- /dev/null +++ b/src/features/boards/create/model/useCreateBoard.ts @@ -0,0 +1,28 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { boardFabricKeys, BoardHttp, TBoard } from 'entities/board'; +import { toast } from 'sonner'; + +type CreateBoardVariables = { + projectId: string; + body: TBoard.CreateBoardBody; +}; + +export type UseCreateBoardOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useCreateBoard({ onSuccess, ...rest }: UseCreateBoardOptions = {}) { + return useMutation({ + ...rest, + mutationFn: ({ projectId, body }) => BoardHttp.createBoard(projectId, body), + onSuccess: async (res, variables, _r, context) => { + onSuccess?.(res, variables, _r, context); + toast.success(res.message ?? 'Доска создана'); + + await context.client.invalidateQueries({ + queryKey: boardFabricKeys.list(variables.projectId), + }); + }, + }); +} diff --git a/src/features/boards/create/model/useCreateBoardForm.ts b/src/features/boards/create/model/useCreateBoardForm.ts new file mode 100644 index 0000000..ce62017 --- /dev/null +++ b/src/features/boards/create/model/useCreateBoardForm.ts @@ -0,0 +1,45 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { getDefaultCreateBoardValues } from './default-values'; +import { useCreateBoard, UseCreateBoardOptions } from './useCreateBoard'; +import { CreateBoardFormSchema } from './schemas'; +import { CreateBoardFormValues } from './type'; +import { setFormErrors } from 'shared/lib/utils'; +import { extractValidationIssues } from 'shared/api'; +import { TBoard } from 'entities/board'; +import { useProjectStore } from 'entities/project'; + +export function useCreateBoardForm(options: UseCreateBoardOptions = {}) { + const projectId = useProjectStore((s) => s.projectId!); + const form = useForm({ + resolver: zodResolver(CreateBoardFormSchema), + defaultValues: getDefaultCreateBoardValues(), + }); + + const createBoard = useCreateBoard({ + ...options, + meta: { + skipGlobalValidationToast: true, + }, + onError: (err, ...args) => { + options.onError?.(err, ...args); + setFormErrors(extractValidationIssues(err), form); + }, + }); + + const onSubmit = (data: CreateBoardFormValues) => { + const body: TBoard.CreateBoardBody = { + ...data, + position: Number(data.position), + }; + + createBoard.mutate({ projectId, body }); + }; + + return { + form, + projectId, + isPending: createBoard.isPending, + handleSubmit: form.handleSubmit(onSubmit), + }; +} diff --git a/src/features/boards/create/ui/CreateBoardDialog.tsx b/src/features/boards/create/ui/CreateBoardDialog.tsx new file mode 100644 index 0000000..47e76e4 --- /dev/null +++ b/src/features/boards/create/ui/CreateBoardDialog.tsx @@ -0,0 +1,68 @@ +import { ComponentProps, useId, useState } from 'react'; +import { useControllableState } from 'shared/lib/hooks'; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + Spinner, +} from 'shared/ui'; +import { CreateBoardForm } from './CreateBoardForm'; + +interface CreateProjectDialogProps extends ComponentProps { + dialog?: ComponentProps; +} + +export function CreateBoardDialog({ dialog = {}, ...props }: CreateProjectDialogProps) { + const [open, setOpen] = useControllableState({ + defaultValue: dialog.defaultOpen, + value: dialog.open, + onChange: dialog.onOpenChange, + }); + const formId = useId(); + const [pending, setPending] = useState(false); + + return ( + + + + + Новая доска + + + + { + setPending(true); + }, + onSuccess: () => { + setOpen(false); + }, + onSettled: () => { + setPending(false); + }, + }} + /> + + + + + + + + + + ); +} diff --git a/src/features/boards/create/ui/CreateBoardForm.tsx b/src/features/boards/create/ui/CreateBoardForm.tsx new file mode 100644 index 0000000..7471e0d --- /dev/null +++ b/src/features/boards/create/ui/CreateBoardForm.tsx @@ -0,0 +1,73 @@ +import { Controller, FormProvider } from 'react-hook-form'; +import { cn } from 'shared/lib/utils'; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input } from 'shared/ui'; +import { useCreateBoardForm } from '../model/useCreateBoardForm'; +import { UseCreateBoardOptions } from '../model/useCreateBoard'; +import { ComponentProps } from 'react'; + +interface CreateBoardFormProps extends Omit, 'children' | 'onSubmit'> { + mutateOptions?: UseCreateBoardOptions; +} + +export function CreateBoardForm({ className, mutateOptions, ...props }: CreateBoardFormProps) { + const { form, isPending, handleSubmit } = useCreateBoardForm(mutateOptions); + + return ( + +
+ + ( + + Название + + {fieldState.invalid && } + + )} + /> + ( + + Настройки + Не реализовано + {fieldState.invalid && } + + )} + /> + ( + + Позиция доски + + {fieldState.invalid && } + + )} + /> + +
+
+ ); +} From ac2b134314939fa503e6131d4e887eb4da9ff73f Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Wed, 3 Jun 2026 09:56:57 +0300 Subject: [PATCH 18/26] feat(board/remove/column): add RemoveColumn components, form, and hooks for board column removing --- src/features/boards/column/remove/index.ts | 1 + .../column/remove/model/useRemoveColumn.ts | 28 ++++++++++++ .../column/remove/ui/RemoveColumnDialog.tsx | 43 +++++++++++++++++++ src/features/boards/remove/index.ts | 1 + .../boards/remove/model/useRemoveBoard.ts | 28 ++++++++++++ .../boards/remove/ui/RemoveBoardDialog.tsx | 39 +++++++++++++++++ 6 files changed, 140 insertions(+) create mode 100644 src/features/boards/column/remove/index.ts create mode 100644 src/features/boards/column/remove/model/useRemoveColumn.ts create mode 100644 src/features/boards/column/remove/ui/RemoveColumnDialog.tsx create mode 100644 src/features/boards/remove/index.ts create mode 100644 src/features/boards/remove/model/useRemoveBoard.ts create mode 100644 src/features/boards/remove/ui/RemoveBoardDialog.tsx diff --git a/src/features/boards/column/remove/index.ts b/src/features/boards/column/remove/index.ts new file mode 100644 index 0000000..56e318c --- /dev/null +++ b/src/features/boards/column/remove/index.ts @@ -0,0 +1 @@ +export { RemoveColumnDialog } from './ui/RemoveColumnDialog'; diff --git a/src/features/boards/column/remove/model/useRemoveColumn.ts b/src/features/boards/column/remove/model/useRemoveColumn.ts new file mode 100644 index 0000000..b93399f --- /dev/null +++ b/src/features/boards/column/remove/model/useRemoveColumn.ts @@ -0,0 +1,28 @@ +import { DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { boardFabricKeys, BoardHttp, TBoard } from 'entities/board'; +import { toast } from 'sonner'; + +export type RemoveColunmnVariables = { + columnId: string; + boardId: string; +}; + +export type UseDeleteColumnOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useRemoveColumn({ onSuccess, onSettled, ...rest }: UseDeleteColumnOptions = {}) { + return useMutation({ + ...rest, + mutationFn: (args) => BoardHttp.removeBoardColumn(args.boardId, args.columnId), + onSuccess: async (res, ...args) => { + onSuccess?.(res, ...args); + toast.success(res.message ?? 'Колонка удалена'); + }, + onSettled: async (_d, _e, _v, _m, context) => { + onSettled?.(_d, _e, _v, _m, context); + context.client.invalidateQueries({ queryKey: boardFabricKeys.columns(_v.boardId) }); + }, + }); +} diff --git a/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx b/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx new file mode 100644 index 0000000..762ac63 --- /dev/null +++ b/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx @@ -0,0 +1,43 @@ +import { ComponentProps } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from 'shared/ui'; +import { RemoveColunmnVariables, useRemoveColumn } from '../model/useRemoveColumn'; + +type Props = ComponentProps & RemoveColunmnVariables; + +export function RemoveColumnDialog({ columnId, boardId, ...props }: Props) { + const removeBoard = useRemoveColumn(); + + const onRemove = () => { + removeBoard.mutate({ columnId, boardId }); + }; + + return ( + + + + + Удалить колонку? + + При удалении колонки будут удалены все задачи в ней + + + + Отмена + + Удалить + + + + + ); +} diff --git a/src/features/boards/remove/index.ts b/src/features/boards/remove/index.ts new file mode 100644 index 0000000..fdcbe9b --- /dev/null +++ b/src/features/boards/remove/index.ts @@ -0,0 +1 @@ +export { RemoveBoardDialog } from './ui/RemoveBoardDialog'; diff --git a/src/features/boards/remove/model/useRemoveBoard.ts b/src/features/boards/remove/model/useRemoveBoard.ts new file mode 100644 index 0000000..3d10635 --- /dev/null +++ b/src/features/boards/remove/model/useRemoveBoard.ts @@ -0,0 +1,28 @@ +import { DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { boardFabricKeys, BoardHttp, TBoard } from 'entities/board'; +import { toast } from 'sonner'; + +export type RemoveBoardVariables = { + projectId: string; + boardId: string; +}; + +export type UseDeleteBoardOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useRemoveBoard({ onSuccess, onSettled, ...rest }: UseDeleteBoardOptions = {}) { + return useMutation({ + ...rest, + mutationFn: (args) => BoardHttp.removeBoard(args.projectId, args.boardId), + onSuccess: async (res, ...args) => { + onSuccess?.(res, ...args); + toast.success(res.message ?? 'Доска удалена'); + }, + onSettled: async (_d, _e, _v, _m, context) => { + onSettled?.(_d, _e, _v, _m, context); + context.client.invalidateQueries({ queryKey: boardFabricKeys.list(_v.projectId) }); + }, + }); +} diff --git a/src/features/boards/remove/ui/RemoveBoardDialog.tsx b/src/features/boards/remove/ui/RemoveBoardDialog.tsx new file mode 100644 index 0000000..2ca7a08 --- /dev/null +++ b/src/features/boards/remove/ui/RemoveBoardDialog.tsx @@ -0,0 +1,39 @@ +import { ComponentProps } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from 'shared/ui'; +import { RemoveBoardVariables, useRemoveBoard } from '../model/useRemoveBoard'; + +type Props = ComponentProps & RemoveBoardVariables; + +export function RemoveBoardDialog({ projectId, boardId, ...props }: Props) { + const removeBoard = useRemoveBoard(); + + const onRemove = () => { + removeBoard.mutate({ projectId, boardId }); + }; + + return ( + + + + + Удалить доску? + + + Отмена + + Удалить + + + + + ); +} From 5bd82a2e86a5a3ca66c97a79cc3993e4011b1125 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Wed, 3 Jun 2026 09:57:29 +0300 Subject: [PATCH 19/26] feat(board): implement board management with Zustand store and Kanban UI components --- src/pages/project/model/store.ts | 29 ++++++ src/pages/project/model/useActiveBoard.ts | 19 ++++ src/pages/project/model/useBoardsPage.ts | 10 ++ src/pages/project/ui/boards/ProjectBoards.tsx | 97 +++++++++++++++++++ .../project/ui/boards/ProjectBoardsPage.tsx | 34 +------ src/pages/project/ui/boards/ProjectKanban.tsx | 23 +++-- src/pages/project/ui/boards/Task.tsx | 10 +- src/pages/project/ui/boards/TaskCard.tsx | 8 +- src/pages/project/ui/boards/TaskColumn.tsx | 76 --------------- .../ui/boards/task-column/TaskColumn.tsx | 49 ++++++++++ .../boards/task-column/TaskColumnHeader.tsx | 75 ++++++++++++++ 11 files changed, 306 insertions(+), 124 deletions(-) create mode 100644 src/pages/project/model/store.ts create mode 100644 src/pages/project/model/useActiveBoard.ts create mode 100644 src/pages/project/model/useBoardsPage.ts create mode 100644 src/pages/project/ui/boards/ProjectBoards.tsx delete mode 100644 src/pages/project/ui/boards/TaskColumn.tsx create mode 100644 src/pages/project/ui/boards/task-column/TaskColumn.tsx create mode 100644 src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx diff --git a/src/pages/project/model/store.ts b/src/pages/project/model/store.ts new file mode 100644 index 0000000..ad4e6ec --- /dev/null +++ b/src/pages/project/model/store.ts @@ -0,0 +1,29 @@ +import { create } from 'zustand'; + +interface BordStore { + activeBoardId: string | null; + activeColumnId: string | null; + projectId: string | null; + setProjectId: (id: string) => void; + clearProjectId: () => void; + setBoardId: (id: string | null) => void; + setColumnId: (id: string | null) => void; +} + +export const useBoardStore = create((set) => ({ + activeBoardId: null, + activeColumnId: null, + projectId: null, + setProjectId(id) { + set({ projectId: id }); + }, + clearProjectId() { + set({ projectId: null }); + }, + setBoardId(id) { + set({ activeBoardId: id }); + }, + setColumnId(id) { + set({ activeColumnId: id }); + }, +})); diff --git a/src/pages/project/model/useActiveBoard.ts b/src/pages/project/model/useActiveBoard.ts new file mode 100644 index 0000000..2adbaed --- /dev/null +++ b/src/pages/project/model/useActiveBoard.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; +import { useBoardStore } from './store'; +import { BoardWithTasks } from 'entities/board'; + +export const useActiveBoards = (boards: BoardWithTasks[]) => { + const activeBoardId = useBoardStore((s) => s.activeBoardId); + const setActiveBoardId = useBoardStore((s) => s.setBoardId); + + const activeBoard: BoardWithTasks | undefined = + boards?.find((v) => v.board.id === activeBoardId) ?? boards[0]; + + useEffect(() => { + if (!activeBoardId && boards.length > 0) { + setActiveBoardId(boards[0].board.id); + } + }, [activeBoardId, boards, setActiveBoardId]); + + return { activeBoardId, setActiveBoardId, activeBoard }; +}; diff --git a/src/pages/project/model/useBoardsPage.ts b/src/pages/project/model/useBoardsPage.ts new file mode 100644 index 0000000..71cff0a --- /dev/null +++ b/src/pages/project/model/useBoardsPage.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; +import { BoardQueries } from 'entities/board'; +import { BoardMapper } from 'entities/board'; + +export const useBoardsPage = (projectId: string) => { + const { data: dto, isLoading, isError } = useQuery(BoardQueries.getBoardList(projectId)); + const data = dto?.items.map(BoardMapper.toBoardWithTasks) ?? []; + + return { data, isLoading, isError }; +}; diff --git a/src/pages/project/ui/boards/ProjectBoards.tsx b/src/pages/project/ui/boards/ProjectBoards.tsx new file mode 100644 index 0000000..5b17418 --- /dev/null +++ b/src/pages/project/ui/boards/ProjectBoards.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { + Button, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenu, + buttonVariants, +} from 'shared/ui'; +import { CreateBoardDialog } from 'features/boards/create'; +import { useInitProjectId } from 'entities/project'; +import { ComponentProps, PropsWithChildren } from 'react'; +import { TBoard } from 'entities/board'; +import { useBoardStore } from 'pages/project/model/store'; +import { EllipsisVertical } from 'lucide-react'; +import { ProjectKanban } from './ProjectKanban'; +import { useActiveBoards } from 'pages/project/model/useActiveBoard'; +import { useBoardsPage } from 'pages/project/model/useBoardsPage'; +import { cn } from 'shared/lib/utils'; +import { VariantProps } from 'class-variance-authority'; +import { RemoveBoardDialog } from 'features/boards/remove'; + +export function ProjectBoards({ projectId }: PropsWithChildren<{ projectId: string }>) { + useInitProjectId(projectId); + + const { data, isLoading, isError } = useBoardsPage(projectId); + const { activeBoard, activeBoardId } = useActiveBoards(data); + + if (isLoading) return 'Загружаем доски'; + if (isError) return 'Ошибка загрузки'; + return ( +
+
+ {data?.map((item) => ( + + ))} + + + +
+
+ {activeBoard ? : null} +
+
+ ); +} + +type BoardButtonProps = ComponentProps<'div'> & + VariantProps & { + board: TBoard.BoardResponse; + }; + +function BoardButton({ + board, + className, + variant = 'outline', + size = 'default', + ...props +}: BoardButtonProps) { + const setActiveBoardId = useBoardStore((s) => s.setBoardId); + const activeBoardId = useBoardStore((s) => s.activeBoardId); + return ( +
+ + + + + + + + + e.preventDefault()}> + Удалить + + + + +
+ ); +} diff --git a/src/pages/project/ui/boards/ProjectBoardsPage.tsx b/src/pages/project/ui/boards/ProjectBoardsPage.tsx index c6f185f..065d33d 100644 --- a/src/pages/project/ui/boards/ProjectBoardsPage.tsx +++ b/src/pages/project/ui/boards/ProjectBoardsPage.tsx @@ -1,33 +1,7 @@ -'use client'; +import { ProjectBoards } from './ProjectBoards'; -import { useState } from 'react'; -import { Button } from 'shared/ui'; -import { MOCK_BOARDS } from '../../model/boards-mock'; -import { ProjectKanban } from './ProjectKanban'; +export async function ProjectBoardsPage({ params }: PageProps<'/team/projects/[projectId]'>) { + const { projectId } = await params; -export function ProjectBoardsPage() { - const [activeBoardId, setActiveBoardId] = useState(MOCK_BOARDS[0].id); - const activeBoard = MOCK_BOARDS.find((board) => board.id === activeBoardId) ?? MOCK_BOARDS[0]; - - return ( -
-
- {MOCK_BOARDS.map((board) => ( - - ))} -
- -
- -
-
- ); + return ; } diff --git a/src/pages/project/ui/boards/ProjectKanban.tsx b/src/pages/project/ui/boards/ProjectKanban.tsx index f561fdc..9746977 100644 --- a/src/pages/project/ui/boards/ProjectKanban.tsx +++ b/src/pages/project/ui/boards/ProjectKanban.tsx @@ -1,28 +1,31 @@ 'use client'; import { useState } from 'react'; -import type { MockBoard } from '../../model/boards-mock'; import { Kanban, KanbanBoard, KanbanOverlay } from 'shared/ui'; -import { TaskColumn } from './TaskColumn'; +import { TaskColumn } from './task-column/TaskColumn'; +import { BoardWithTasks } from 'entities/board'; +import { TTask } from 'entities/task'; interface ProjectKanbanProps { - board: Pick; + board: BoardWithTasks; } export function ProjectKanban({ board }: ProjectKanbanProps) { - const [columns, setColumns] = useState(board.columns); - + const [columns, setColumns] = useState(board.tasksByColumn); return ( setColumns(v)} - getItemValue={(item) => item.id} + // TODO: as TTask.Task - заглушка, пока нет тасок + getItemValue={(item) => (item as TTask.Task).id} > - - {Object.entries(columns).map(([id, items]) => ( - - ))} + + {Object.entries(columns).map(([id, items]) => { + const column = board.columns[id]; + // TODO: as TTask.Task - заглушка, пока нет тасок + return ; + })} diff --git a/src/pages/project/ui/boards/Task.tsx b/src/pages/project/ui/boards/Task.tsx index b43ea4c..2ecad2a 100644 --- a/src/pages/project/ui/boards/Task.tsx +++ b/src/pages/project/ui/boards/Task.tsx @@ -1,15 +1,15 @@ -import { MockBoardTask } from 'pages/project/model/boards-mock'; -import { ComponentProps } from 'react'; +import React, { ComponentProps } from 'react'; import { KanbanItem, KanbanItemHandle } from 'shared/ui'; import { TaskCard } from './TaskCard'; +import { TTask } from 'entities/task/'; interface TaskCardProps extends Omit, 'value' | 'children'> { - task: MockBoardTask; + task: TTask.Task; asHandle?: boolean; isOverlay?: boolean; } -export function Task({ task, asHandle, isOverlay, ...props }: TaskCardProps) { +export function TaskComponent({ task, asHandle, isOverlay, ...props }: TaskCardProps) { return ( {asHandle && !isOverlay ? ( @@ -20,3 +20,5 @@ export function Task({ task, asHandle, isOverlay, ...props }: TaskCardProps) { ); } + +export const Task = React.memo(TaskComponent); diff --git a/src/pages/project/ui/boards/TaskCard.tsx b/src/pages/project/ui/boards/TaskCard.tsx index 7fec124..fad89ea 100644 --- a/src/pages/project/ui/boards/TaskCard.tsx +++ b/src/pages/project/ui/boards/TaskCard.tsx @@ -1,4 +1,4 @@ -import { MockBoardTask } from 'pages/project/model/boards-mock'; +import { TTask } from 'entities/task'; import { Avatar, AvatarFallback, @@ -14,7 +14,7 @@ import { } from 'shared/ui'; interface TaskCardProps { - task: MockBoardTask; + task: TTask.Task; } export function TaskCard({ task }: TaskCardProps) { @@ -26,7 +26,7 @@ export function TaskCard({ task }: TaskCardProps) {
-

{task.name}

+

{task.title}

{task.description}
@@ -36,7 +36,7 @@ export function TaskCard({ task }: TaskCardProps) { - + {task.assignee.name.charAt(0)} diff --git a/src/pages/project/ui/boards/TaskColumn.tsx b/src/pages/project/ui/boards/TaskColumn.tsx deleted file mode 100644 index 9a960e7..0000000 --- a/src/pages/project/ui/boards/TaskColumn.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Ellipsis, GripVertical, Plus } from 'lucide-react'; -import { MockBoard, MockBoardTask } from 'pages/project/model/boards-mock'; -import React, { ComponentProps } from 'react'; -import { Button, KanbanColumn, KanbanColumnContent, KanbanColumnHandle } from 'shared/ui'; - -// TODO: вынести функцию и иконки в shared или сделать свои -import { projectIconCodeToEmoji } from 'entities/project'; -import { Task } from './Task'; - -interface TaskColumnProps extends Omit, 'children'> { - tasks: MockBoardTask[]; - columnTitles: MockBoard['columnTitles']; - isOverlay?: boolean; -} - -interface TaskColumnHeaderProps { - title: string; - icon?: string; - tasksLength: number; -} - -export function TaskColumn({ - value, - tasks, - columnTitles, - className, - isOverlay, - ...props -}: TaskColumnProps) { - const headerColumnData: TaskColumnHeaderProps = { - title: columnTitles[value].title, - icon: projectIconCodeToEmoji(columnTitles[value].icon), - tasksLength: tasks.length, - }; - - return ( - - - - - {tasks.map((task) => ( - - ))} - - - ); -} - -function _TaskColumnHeader({ data }: { data: TaskColumnHeaderProps }) { - const { tasksLength, title, icon } = data; - return ( - -
-
- {icon && {icon}} -

- {title} - ({tasksLength}) -

-
-
- {/* TODO: добавить dialog/popover */} - - {/* TODO: добавить dialog/popover */} - -
-
-
- ); -} - -const TaskColumnHeader = React.memo(_TaskColumnHeader); diff --git a/src/pages/project/ui/boards/task-column/TaskColumn.tsx b/src/pages/project/ui/boards/task-column/TaskColumn.tsx new file mode 100644 index 0000000..3804da1 --- /dev/null +++ b/src/pages/project/ui/boards/task-column/TaskColumn.tsx @@ -0,0 +1,49 @@ +import { KanbanColumn, KanbanColumnContent } from 'shared/ui'; + +import { Task } from '../Task'; +import { CreateTaskField } from 'features/task/create'; +import { useBoardStore } from '../../../model/store'; +import { TBoard } from 'entities/board'; +import { ComponentProps } from 'react'; +import { TTask } from 'entities/task'; +import { TaskColumnHeader, TaskColumnHeaderProps } from './TaskColumnHeader'; + +interface TaskColumnProps extends Omit, 'children'> { + tasks: TTask.Task[]; + column: TBoard.BoardColumnResponse; + isOverlay?: boolean; +} + +export function TaskColumn({ + value, + tasks, + column, + className = '', + isOverlay, + ...props +}: TaskColumnProps) { + const columnId = value; + const boardId = useBoardStore((s) => s.activeBoardId!); + + const headerColumnData: TaskColumnHeaderProps = { + ...column, + tasksLength: tasks.length, + }; + + return ( + + + + + + {tasks.map((task) => ( + + ))} + + + ); +} diff --git a/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx b/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx new file mode 100644 index 0000000..1bf412c --- /dev/null +++ b/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx @@ -0,0 +1,75 @@ +import { BOARD_COLUMN_COLORS, TBoard } from 'entities/board'; +import { RemoveColumnDialog } from 'features/boards/column/remove'; +import { CreateTaskButton } from 'features/task/create'; +import { Ellipsis } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { + Button, + ColorPicker, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + KanbanColumnHandle, +} from 'shared/ui'; + +export interface TaskColumnHeaderProps extends TBoard.BoardColumnResponse { + tasksLength: number; +} + +export function TaskColumnHeader({ data }: { data: TaskColumnHeaderProps }) { + const { tasksLength, name, id, boardId, color } = data; + const [activeColor, setActiveColor] = useState(color ?? BOARD_COLUMN_COLORS[0]); + + const isExistColor = BOARD_COLUMN_COLORS.find((v) => v.toLowerCase() === data.color); + const newColors = isExistColor ? [...BOARD_COLUMN_COLORS] : [color, ...BOARD_COLUMN_COLORS]; + + return ( + +
+
+
+
+ {/* {icon && {icon}} */} +

{`${name} (${tasksLength})`}

+
+
+ {/* TODO: добавить dialog/popover */} + + {/* TODO: добавить dialog/popover */} + + + + + + + + e.preventDefault()} variant="destructive"> + Удалить + + + + + + Цвет колонки + + + + +
+
+
+ + ); +} From 67a1b5805d382993ae9bb146e0e3dbb0bdc232a6 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Wed, 3 Jun 2026 10:03:28 +0300 Subject: [PATCH 20/26] refactor(config): update steiger configuration to disable public-api rule for board, task features --- steiger.config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/steiger.config.ts b/steiger.config.ts index 15c8e8f..cd84fef 100644 --- a/steiger.config.ts +++ b/steiger.config.ts @@ -10,8 +10,13 @@ export default defineConfig([ 'fsd/public-api': 'off', }, }, - // TODO: заглушка { + files: [ + './src/features/boards/**', + './src/entities/board/**', + './src/entities/task/**', + './src/features/task/**', + ], rules: { 'fsd/insignificant-slice': 'off', }, From 7514a8dd9409096475d16d80d00a0cd7a480f8ef Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Wed, 3 Jun 2026 10:38:33 +0300 Subject: [PATCH 21/26] feat(board/column): add CreateBoardColumn components, form, and dialog for board column creation --- src/features/boards/column/create/index.ts | 2 + .../column/create/model/default-values.ts | 9 ++ .../boards/column/create/model/schemas.ts | 12 +++ .../boards/column/create/model/type.ts | 6 ++ .../create/model/useCreateBoardColumn.ts | 32 +++++++ .../create/model/useCreateBoardColumnForm.ts | 55 +++++++++++ .../create/ui/CreateBoardColumnDialog.tsx | 77 ++++++++++++++++ .../create/ui/CreateBoardColumnForm.tsx | 91 +++++++++++++++++++ src/pages/project/model/useActiveBoard.ts | 4 +- src/pages/project/ui/boards/ProjectKanban.tsx | 25 ++++- 10 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 src/features/boards/column/create/index.ts create mode 100644 src/features/boards/column/create/model/default-values.ts create mode 100644 src/features/boards/column/create/model/schemas.ts create mode 100644 src/features/boards/column/create/model/type.ts create mode 100644 src/features/boards/column/create/model/useCreateBoardColumn.ts create mode 100644 src/features/boards/column/create/model/useCreateBoardColumnForm.ts create mode 100644 src/features/boards/column/create/ui/CreateBoardColumnDialog.tsx create mode 100644 src/features/boards/column/create/ui/CreateBoardColumnForm.tsx diff --git a/src/features/boards/column/create/index.ts b/src/features/boards/column/create/index.ts new file mode 100644 index 0000000..ffaa0bf --- /dev/null +++ b/src/features/boards/column/create/index.ts @@ -0,0 +1,2 @@ +export { CreateBoardColumnDialog } from './ui/CreateBoardColumnDialog'; +export { CreateBoardColumnForm } from './ui/CreateBoardColumnForm'; diff --git a/src/features/boards/column/create/model/default-values.ts b/src/features/boards/column/create/model/default-values.ts new file mode 100644 index 0000000..b0af386 --- /dev/null +++ b/src/features/boards/column/create/model/default-values.ts @@ -0,0 +1,9 @@ +import { CreateBoardColumnFormValues } from './type'; + +export function getDefaultCreateBoardColumnValues(position = 0): CreateBoardColumnFormValues { + return { + name: '', + position, + color: '', + }; +} diff --git a/src/features/boards/column/create/model/schemas.ts b/src/features/boards/column/create/model/schemas.ts new file mode 100644 index 0000000..d40b16b --- /dev/null +++ b/src/features/boards/column/create/model/schemas.ts @@ -0,0 +1,12 @@ +import { SBoard } from 'entities/board'; +import { z } from 'zod/v4'; + +export const CreateBoardColumnFormSchema = SBoard.CreateBoardColumnBody.extend({ + position: z.union([z.string(), z.number()]).transform(Number), + color: z + .string() + .regex(/^#[A-Fa-f0-9]{6}$/) + .optional() + .or(z.literal('')) + .transform((val) => (val === '' ? undefined : val)), +}); diff --git a/src/features/boards/column/create/model/type.ts b/src/features/boards/column/create/model/type.ts new file mode 100644 index 0000000..576da7c --- /dev/null +++ b/src/features/boards/column/create/model/type.ts @@ -0,0 +1,6 @@ +import { z } from 'zod/v4'; +import { CreateBoardColumnFormSchema } from './schemas'; + +export type CreateBoardColumnFormValues = z.input; + +export type CreateBoardColumnFormOutput = z.output; diff --git a/src/features/boards/column/create/model/useCreateBoardColumn.ts b/src/features/boards/column/create/model/useCreateBoardColumn.ts new file mode 100644 index 0000000..7dc82c9 --- /dev/null +++ b/src/features/boards/column/create/model/useCreateBoardColumn.ts @@ -0,0 +1,32 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { boardFabricKeys, BoardHttp, TBoard } from 'entities/board'; +import { toast } from 'sonner'; + +type CreateBoardColumnVariables = { + boardId: string; + projectId: string; + body: TBoard.CreateBoardColumnBody; +}; + +export type UseCreateBoardColumnOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useCreateBoardColumn({ onSuccess, ...rest }: UseCreateBoardColumnOptions = {}) { + return useMutation({ + ...rest, + mutationFn: ({ boardId, body }) => BoardHttp.createBoardColumn(boardId, body), + onSuccess: async (res, variables, _r, context) => { + onSuccess?.(res, variables, _r, context); + toast.success(res.message ?? 'Колонка создана'); + + await context.client.invalidateQueries({ + queryKey: boardFabricKeys.list(variables.projectId), + }); + await context.client.invalidateQueries({ + queryKey: boardFabricKeys.columns(variables.boardId), + }); + }, + }); +} diff --git a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts new file mode 100644 index 0000000..429ebc7 --- /dev/null +++ b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts @@ -0,0 +1,55 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { getDefaultCreateBoardColumnValues } from './default-values'; +import { useCreateBoardColumn, UseCreateBoardColumnOptions } from './useCreateBoardColumn'; +import { CreateBoardColumnFormSchema } from './schemas'; +import { CreateBoardColumnFormValues } from './type'; +import { setFormErrors } from 'shared/lib/utils'; +import { extractValidationIssues } from 'shared/api'; +import { TBoard } from 'entities/board'; +import { useProjectStore } from 'entities/project'; + +type UseCreateBoardColumnFormOptions = UseCreateBoardColumnOptions & { + defaultPosition?: number; +}; + +export function useCreateBoardColumnForm( + boardId: string, + options: UseCreateBoardColumnFormOptions = {} +) { + const { defaultPosition = 0, ...mutationOptions } = options; + const projectId = useProjectStore((s) => s.projectId!); + const form = useForm({ + resolver: zodResolver(CreateBoardColumnFormSchema), + defaultValues: getDefaultCreateBoardColumnValues(defaultPosition), + }); + + const createBoardColumn = useCreateBoardColumn({ + ...mutationOptions, + meta: { + skipGlobalValidationToast: true, + }, + onError: (err, ...args) => { + mutationOptions.onError?.(err, ...args); + setFormErrors(extractValidationIssues(err), form); + }, + }); + + const onSubmit = (data: CreateBoardColumnFormValues) => { + const body: TBoard.CreateBoardColumnBody = { + name: data.name, + position: Number(data.position), + ...(data.color ? { color: data.color } : {}), + }; + + createBoardColumn.mutate({ boardId, projectId, body }); + }; + + return { + form, + boardId, + projectId, + isPending: createBoardColumn.isPending, + handleSubmit: form.handleSubmit(onSubmit), + }; +} diff --git a/src/features/boards/column/create/ui/CreateBoardColumnDialog.tsx b/src/features/boards/column/create/ui/CreateBoardColumnDialog.tsx new file mode 100644 index 0000000..cef8757 --- /dev/null +++ b/src/features/boards/column/create/ui/CreateBoardColumnDialog.tsx @@ -0,0 +1,77 @@ +import { ComponentProps, useId, useState } from 'react'; +import { useControllableState } from 'shared/lib/hooks'; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + Spinner, +} from 'shared/ui'; +import { CreateBoardColumnForm } from './CreateBoardColumnForm'; + +interface CreateBoardColumnDialogProps extends ComponentProps { + boardId: string; + defaultPosition?: number; + dialog?: ComponentProps; +} + +export function CreateBoardColumnDialog({ + boardId, + defaultPosition, + dialog = {}, + ...props +}: CreateBoardColumnDialogProps) { + const [open, setOpen] = useControllableState({ + defaultValue: dialog.defaultOpen, + value: dialog.open, + onChange: dialog.onOpenChange, + }); + const formId = useId(); + const [pending, setPending] = useState(false); + + return ( + + + + + Новая колонка + + + + { + setPending(true); + }, + onSuccess: () => { + setOpen(false); + }, + onSettled: () => { + setPending(false); + }, + }} + /> + + + + + + + + + + ); +} diff --git a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx new file mode 100644 index 0000000..f9241b4 --- /dev/null +++ b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx @@ -0,0 +1,91 @@ +import { Controller, FormProvider } from 'react-hook-form'; +import { cn } from 'shared/lib/utils'; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input } from 'shared/ui'; +import { useCreateBoardColumnForm } from '../model/useCreateBoardColumnForm'; +import { UseCreateBoardColumnOptions } from '../model/useCreateBoardColumn'; +import { ComponentProps } from 'react'; + +interface CreateBoardColumnFormProps extends Omit, 'children' | 'onSubmit'> { + boardId: string; + defaultPosition?: number; + mutateOptions?: UseCreateBoardColumnOptions; +} + +export function CreateBoardColumnForm({ + boardId, + defaultPosition, + className, + mutateOptions, + ...props +}: CreateBoardColumnFormProps) { + const { form, isPending, handleSubmit } = useCreateBoardColumnForm(boardId, { + defaultPosition, + ...mutateOptions, + }); + + return ( + +
+ + ( + + Название + + {fieldState.invalid && } + + )} + /> + ( + + Цвет + HEX, например #6366f1 + + {fieldState.invalid && } + + )} + /> + ( + + Позиция колонки + + {fieldState.invalid && } + + )} + /> + +
+
+ ); +} diff --git a/src/pages/project/model/useActiveBoard.ts b/src/pages/project/model/useActiveBoard.ts index 2adbaed..5fbd588 100644 --- a/src/pages/project/model/useActiveBoard.ts +++ b/src/pages/project/model/useActiveBoard.ts @@ -6,8 +6,8 @@ export const useActiveBoards = (boards: BoardWithTasks[]) => { const activeBoardId = useBoardStore((s) => s.activeBoardId); const setActiveBoardId = useBoardStore((s) => s.setBoardId); - const activeBoard: BoardWithTasks | undefined = - boards?.find((v) => v.board.id === activeBoardId) ?? boards[0]; + const activeBoard: BoardWithTasks | null = + boards?.find((v) => v.board.id === activeBoardId) ?? (boards.length > 0 ? boards[0] : null); useEffect(() => { if (!activeBoardId && boards.length > 0) { diff --git a/src/pages/project/ui/boards/ProjectKanban.tsx b/src/pages/project/ui/boards/ProjectKanban.tsx index 9746977..355d556 100644 --- a/src/pages/project/ui/boards/ProjectKanban.tsx +++ b/src/pages/project/ui/boards/ProjectKanban.tsx @@ -1,17 +1,27 @@ 'use client'; -import { useState } from 'react'; -import { Kanban, KanbanBoard, KanbanOverlay } from 'shared/ui'; +import { useEffect, useState } from 'react'; +import { Kanban, KanbanBoard, KanbanOverlay, Button } from 'shared/ui'; import { TaskColumn } from './task-column/TaskColumn'; import { BoardWithTasks } from 'entities/board'; import { TTask } from 'entities/task'; +import { CreateBoardColumnDialog } from 'features/boards/column/create'; interface ProjectKanbanProps { board: BoardWithTasks; } export function ProjectKanban({ board }: ProjectKanbanProps) { + console.log(board); + const [columns, setColumns] = useState(board.tasksByColumn); + + useEffect(() => { + setColumns(board.tasksByColumn); + }, [board]); + + const nextColumnPosition = Object.keys(board.columns).length; + return ( ; })} +
+ + + +
From c9ff6404e7da75365a00499b6a1564229bc7e0b9 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Wed, 3 Jun 2026 10:55:40 +0300 Subject: [PATCH 22/26] feat(board/remove/column): enhance useRemoveColumn hook and RemoveColumnDialog with projectId handling --- .../boards/column/remove/model/useRemoveColumn.ts | 3 +++ .../column/remove/ui/RemoveColumnDialog.tsx | 13 +++++++++---- .../ui/boards/task-column/TaskColumnHeader.tsx | 15 +++++++++------ src/shared/ui/color-picker/ColorPicker.tsx | 5 +++-- src/widgets/app-sidebar/ui/Team.tsx | 2 +- 5 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/features/boards/column/remove/model/useRemoveColumn.ts b/src/features/boards/column/remove/model/useRemoveColumn.ts index b93399f..57da851 100644 --- a/src/features/boards/column/remove/model/useRemoveColumn.ts +++ b/src/features/boards/column/remove/model/useRemoveColumn.ts @@ -1,5 +1,6 @@ import { DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; import { boardFabricKeys, BoardHttp, TBoard } from 'entities/board'; +import { useProjectStore } from 'entities/project'; import { toast } from 'sonner'; export type RemoveColunmnVariables = { @@ -13,6 +14,7 @@ export type UseDeleteColumnOptions = Omit< >; export function useRemoveColumn({ onSuccess, onSettled, ...rest }: UseDeleteColumnOptions = {}) { + const projectId = useProjectStore((s) => s.projectId); return useMutation({ ...rest, mutationFn: (args) => BoardHttp.removeBoardColumn(args.boardId, args.columnId), @@ -23,6 +25,7 @@ export function useRemoveColumn({ onSuccess, onSettled, ...rest }: UseDeleteColu onSettled: async (_d, _e, _v, _m, context) => { onSettled?.(_d, _e, _v, _m, context); context.client.invalidateQueries({ queryKey: boardFabricKeys.columns(_v.boardId) }); + context.client.invalidateQueries({ queryKey: boardFabricKeys.list(projectId!) }); }, }); } diff --git a/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx b/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx index 762ac63..d22f264 100644 --- a/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx +++ b/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx @@ -10,12 +10,17 @@ import { AlertDialogTitle, AlertDialogTrigger, } from 'shared/ui'; -import { RemoveColunmnVariables, useRemoveColumn } from '../model/useRemoveColumn'; +import { + RemoveColunmnVariables, + UseDeleteColumnOptions, + useRemoveColumn, +} from '../model/useRemoveColumn'; -type Props = ComponentProps & RemoveColunmnVariables; +type Props = ComponentProps & + RemoveColunmnVariables & { options?: UseDeleteColumnOptions }; -export function RemoveColumnDialog({ columnId, boardId, ...props }: Props) { - const removeBoard = useRemoveColumn(); +export function RemoveColumnDialog({ columnId, boardId, options = {}, ...props }: Props) { + const removeBoard = useRemoveColumn(options); const onRemove = () => { removeBoard.mutate({ columnId, boardId }); diff --git a/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx b/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx index 1bf412c..445f288 100644 --- a/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx +++ b/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx @@ -2,7 +2,7 @@ import { BOARD_COLUMN_COLORS, TBoard } from 'entities/board'; import { RemoveColumnDialog } from 'features/boards/column/remove'; import { CreateTaskButton } from 'features/task/create'; import { Ellipsis } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { Button, ColorPicker, @@ -23,9 +23,15 @@ export interface TaskColumnHeaderProps extends TBoard.BoardColumnResponse { export function TaskColumnHeader({ data }: { data: TaskColumnHeaderProps }) { const { tasksLength, name, id, boardId, color } = data; const [activeColor, setActiveColor] = useState(color ?? BOARD_COLUMN_COLORS[0]); + console.log(color); - const isExistColor = BOARD_COLUMN_COLORS.find((v) => v.toLowerCase() === data.color); - const newColors = isExistColor ? [...BOARD_COLUMN_COLORS] : [color, ...BOARD_COLUMN_COLORS]; + const existColor = BOARD_COLUMN_COLORS.findIndex((v) => v.toLowerCase() === data.color); + const isExistColor = existColor !== -1; + const newColors = isExistColor + ? [...BOARD_COLUMN_COLORS] + : color + ? [color, ...BOARD_COLUMN_COLORS] + : [...BOARD_COLUMN_COLORS]; return ( @@ -33,13 +39,10 @@ export function TaskColumnHeader({ data }: { data: TaskColumnHeaderProps }) {
- {/* {icon && {icon}} */}

{`${name} (${tasksLength})`}

- {/* TODO: добавить dialog/popover */} - {/* TODO: добавить dialog/popover */} + + +
+ ); +} diff --git a/src/pages/project/ui/boards/TaskColumn.skeleton.tsx b/src/pages/project/ui/boards/TaskColumn.skeleton.tsx new file mode 100644 index 0000000..234f842 --- /dev/null +++ b/src/pages/project/ui/boards/TaskColumn.skeleton.tsx @@ -0,0 +1,43 @@ +import { Skeleton } from 'shared/ui'; +import { cn } from 'shared/lib/utils'; +import { ComponentProps } from 'react'; + +interface TaskColumnSkeletonProps extends ComponentProps<'div'> { + taskCount?: number; +} + +export function TaskColumnSkeleton({ + className, + taskCount = 3, + ...props +}: TaskColumnSkeletonProps) { + return ( +
+
+ +
+ +
+ + +
+
+
+ + + + {Array.from({ length: taskCount }).map((_, index) => ( +
+
+ + +
+
+ + +
+
+ ))} +
+ ); +} diff --git a/src/shared/ui/color-picker/ColorPicker.tsx b/src/shared/ui/color-picker/ColorPicker.tsx index 534768a..a1cb245 100644 --- a/src/shared/ui/color-picker/ColorPicker.tsx +++ b/src/shared/ui/color-picker/ColorPicker.tsx @@ -33,7 +33,6 @@ export function ColorPicker({ onClick?.(e); setActiveColor?.(color); }; - console.log(colors ?? COLORS); return (
{(colors ?? COLORS)?.map((item) => { From 17110d4aec935c9f56931935f63286ce9ce6c9ea Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Wed, 3 Jun 2026 12:53:30 +0300 Subject: [PATCH 26/26] fix(ProjectBoardsPage): update PageProps type definition for params --- src/pages/project/ui/boards/ProjectBoardsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/project/ui/boards/ProjectBoardsPage.tsx b/src/pages/project/ui/boards/ProjectBoardsPage.tsx index 065d33d..fb53319 100644 --- a/src/pages/project/ui/boards/ProjectBoardsPage.tsx +++ b/src/pages/project/ui/boards/ProjectBoardsPage.tsx @@ -1,6 +1,6 @@ import { ProjectBoards } from './ProjectBoards'; -export async function ProjectBoardsPage({ params }: PageProps<'/team/projects/[projectId]'>) { +export async function ProjectBoardsPage({ params }: { params: Promise<{ projectId: string }> }) { const { projectId } = await params; return ;