From 6a87f8132d128f7cc732ff196a572debe0be6ba5 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 28 May 2026 22:37:21 +0300 Subject: [PATCH 01/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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 ; From 9e2ca81de9b0d19cf3b83723c315f076a8d64ee0 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 18 Jun 2026 23:53:23 +0300 Subject: [PATCH 27/37] refactor(board): remove unused queries, update board and column schemas, and enhance sorting functionality - Removed `getBoardViewList` and `getBoardView` methods from `BoardQueries`. - Deleted `mockBoard` and related mock data from `mock-data.ts`. - Updated `BoardMapper` to accept a column list for sorting. - Enhanced `Board` and `BoardColumn` schemas with new properties and validation. - Simplified `CreateBoardColumnFormSchema` and `CreateBoardFormSchema`. - Refactored default values in board and column creation forms. - Updated UI components to reflect changes in data structure and naming conventions. - Introduced `createSortingSchema` for consistent sorting parameter handling. --- src/entities/board/api/http.ts | 56 --- src/entities/board/api/queries.ts | 16 - src/entities/board/index.ts | 1 - src/entities/board/model/mapper.ts | 4 +- src/entities/board/model/mock-data.ts | 395 ------------------ src/entities/board/model/schemas.ts | 246 +++++++---- src/entities/board/model/types.ts | 8 - .../column/create/model/default-values.ts | 4 +- .../boards/column/create/model/schemas.ts | 6 +- .../create/model/useCreateBoardColumnForm.ts | 4 +- .../create/ui/CreateBoardColumnForm.tsx | 46 +- .../boards/create/model/default-values.ts | 6 +- src/features/boards/create/model/schemas.ts | 5 +- .../boards/create/ui/CreateBoardForm.tsx | 15 +- src/pages/project/model/useActiveBoard.ts | 10 +- src/pages/project/model/useBoardsPage.ts | 12 +- src/pages/project/ui/boards/ProjectBoards.tsx | 20 +- .../boards/task-column/TaskColumnHeader.tsx | 6 +- src/shared/api/index.ts | 1 + src/shared/api/schemas/index.ts | 1 + src/shared/api/schemas/sorting.ts | 28 ++ 21 files changed, 260 insertions(+), 630 deletions(-) delete mode 100644 src/entities/board/model/mock-data.ts create mode 100644 src/shared/api/schemas/sorting.ts diff --git a/src/entities/board/api/http.ts b/src/entities/board/api/http.ts index ca6097d..e85529c 100644 --- a/src/entities/board/api/http.ts +++ b/src/entities/board/api/http.ts @@ -114,60 +114,4 @@ export class BoardHttp { }, }); } - - 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 9154c47..af0fec0 100644 --- a/src/entities/board/api/queries.ts +++ b/src/entities/board/api/queries.ts @@ -34,20 +34,4 @@ export class BoardQueries { 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 b063b65..b761ce6 100644 --- a/src/entities/board/index.ts +++ b/src/entities/board/index.ts @@ -3,6 +3,5 @@ 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'; export { BOARD_COLUMN_COLORS } from './lib/colors'; diff --git a/src/entities/board/model/mapper.ts b/src/entities/board/model/mapper.ts index d6407bc..d01c34d 100644 --- a/src/entities/board/model/mapper.ts +++ b/src/entities/board/model/mapper.ts @@ -9,8 +9,8 @@ export type BoardWithTasks = { }; export class BoardMapper { - static toBoardWithTasks(board: BoardResponse): BoardWithTasks { - const sortedColumns = [...board.boardColumns].sort((a, b) => a.position - b.position); + static toBoardWithTasks(board: BoardResponse, columnList: BoardColumnResponse[]): BoardWithTasks { + const sortedColumns = [...columnList].sort((a, b) => a.orderIndex - b.orderIndex); const tasksByColumn: Record = {}; const columns: Record = {}; diff --git a/src/entities/board/model/mock-data.ts b/src/entities/board/model/mock-data.ts deleted file mode 100644 index 3e6e7b1..0000000 --- a/src/entities/board/model/mock-data.ts +++ /dev/null @@ -1,395 +0,0 @@ -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 index 2af7ea0..b350912 100644 --- a/src/entities/board/model/schemas.ts +++ b/src/entities/board/model/schemas.ts @@ -1,59 +1,136 @@ -import { DateTimeString, GlobalSuccess, PaginatedResponseSchema } from 'shared/api'; +import { createSortingSchema, DateTimeString, GlobalSuccess } from 'shared/api'; import { z } from 'zod/v4'; 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 BoardColumnCategoryEnum = z.enum([ + 'backlog', + 'active', + 'review', + 'completed', + 'archived', +]); +export const ViewTypeEnum = z.enum(['kanban', 'list', 'calendar', 'gantt']); +export const ColumnStatusEnum = z.enum([ + 'backlog', + 'todo', + 'in_progress', + 'review', + 'done', + 'archived', + 'custom', +]); -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: Settings, - position: z.number(), +export const Board = z.object({ + id: z.string().min(1, 'ID не может быть пустым'), + projectId: z.string().min(1, 'ID проекта обязателен'), + title: z + .string() + .min(1, 'Название области обязательно') + .max(255, 'Название не должно превышать 255 символов'), + slug: z + .string() + .min(1, 'Slug обязателен') + .max(100, 'Slug не должен превышать 100 символов') + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug должен быть в формате kebab-case'), + description: z.string().nullable().optional(), + descriptionHtml: z.string().nullable().optional(), + color: z + .string() + .regex( + /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)' + ) + .nullable() + .optional(), + icon: z.string().max(20, 'Иконка должна быть не длиннее 20 символов').nullable().optional(), + tasksCount: z + .number() + .int('Количество задач должно быть целым числом') + .min(0, 'Количество задач не может быть отрицательным'), + defaultView: ViewTypeEnum, + position: z + .number() + .int('Позиция должна быть целым числом') + .min(0, 'Позиция не может быть отрицательной'), + maxTasksLimit: z + .number() + .int('Лимит задач должен быть целым числом') + .positive('Лимит задач должен быть положительным числом') + .nullable() + .optional(), + isLocked: z.boolean(), createdAt: DateTimeString, updatedAt: DateTimeString, + createdBy: z.string().nullable().optional(), + deletedAt: z.string().nullable().optional(), }); -export const Board = z.object({ - id: z.string(), - name: z.string(), - projectId: z.string(), - settings: Settings, - position: z.number(), - ownerId: z.string().nullable(), +export const BoardColumn = z.object({ + id: z.string().min(1, 'ID не может быть пустым'), + title: z + .string() + .min(1, 'Название состояния обязательно') + .max(255, 'Название не должно превышать 255 символов'), + description: z.string().nullable().optional(), + stateType: BoardColumnCategoryEnum, + category: BoardColumnCategoryEnum, + color: z + .string() + .regex( + /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)' + ) + .nullable() + .optional(), + icon: z.string().max(20, 'Иконка должна быть не длиннее 20 символов').nullable().optional(), + orderIndex: z + .number() + .int('Порядковый номер должен быть целым числом') + .min(0, 'Порядковый номер не может быть отрицательным'), + isVisible: z.boolean(), + maxTasksLimit: z + .number() + .int('Лимит задач должен быть целым числом') + .positive('Лимит задач должен быть положительным числом') + .nullable() + .optional(), + autoTransitionTo: z.string().nullable().optional(), + notifyOnEnter: z.boolean(), + notifyOnExit: z.boolean(), + isLocked: z.boolean(), createdAt: DateTimeString, updatedAt: DateTimeString, - boardColumns: z.array(BoardColumn), - boardViews: z.array(BoardView), + createdBy: z.string().nullable().optional(), + deletedAt: z.string().nullable().optional(), }); -export const BoardListResponse = PaginatedResponseSchema(Board); -export const BoardColumnListResponse = PaginatedResponseSchema(BoardColumn); -export const BoardViewListResponse = PaginatedResponseSchema(BoardView); - -export const CreateBoardBody = z.object({ - name: z - .string() - .min(1, 'Название доски не может быть пустым') - .max(100, 'Название доски не должно превышать 100 символов'), - position: z.number(), - settings: Settings.optional(), -}); +export const CreateBoardBody = Board.omit({ + id: true, + projectId: true, + tasksCount: true, + createdAt: true, + updatedAt: true, + createdBy: true, + deletedAt: true, +}) + .partial({ + description: true, + descriptionHtml: true, + color: true, + icon: true, + maxTasksLimit: true, + slug: true, + defaultView: true, + position: true, + isLocked: true, + }) + .extend({ + slug: z + .string() + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug должен быть в формате kebab-case') + .optional(), + }); export const UpdateBoardBody = CreateBoardBody.partial().refine( (data) => Object.keys(data).length > 0, @@ -63,40 +140,25 @@ export const UpdateBoardBody = CreateBoardBody.partial().refine( } ); -export const CreateBoardResponse = GlobalSuccess.extend({ boardId: z.string() }); - -export const CreateBoardViewBody = z.object({ - type: ViewTypeEnum, - name: z - .string() - .min(1, 'Название представления не может быть пустым') - .max(100, 'Название представления не должно превышать 100 символов'), - settings: Settings.optional(), - position: z.number(), -}); - -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(), - color: z - .string() - .regex(/^#[A-Fa-f0-9]{6}$/) - .optional(), +export const CreateBoardColumnBody = BoardColumn.omit({ + id: true, + createdAt: true, + updatedAt: true, + createdBy: true, + deletedAt: true, +}).partial({ + description: true, + color: true, + icon: true, + maxTasksLimit: true, + autoTransitionTo: true, + stateType: true, + category: true, + orderIndex: true, + isVisible: true, + notifyOnEnter: true, + notifyOnExit: true, + isLocked: true, }); export const UpdateBoardColumnBody = CreateBoardColumnBody.partial().refine( @@ -107,6 +169,34 @@ export const UpdateBoardColumnBody = CreateBoardColumnBody.partial().refine( } ); +export const BoardColumnQueryParams = z + .object({ + hidden: z.boolean().optional(), + counts: z.boolean().optional(), + my: z.boolean().optional(), + category: z.string().optional(), + overdue: z.boolean().optional(), + page: z.coerce.number().int().positive().optional(), + offset: z.coerce.number().int().min(0).optional(), + limit: z.coerce.number().int().min(0).max(100).optional(), + }) + .extend(createSortingSchema(['order', 'title', 'tasksCount', 'createdAt']).shape) + .transform((data) => { + if (data.page && data.page > 1 && data.offset === 0) { + return { + ...data, + offset: (data.page - 1) * (data.limit || 20), + }; + } + return data; + }); + +export const UpdateBoardColumnResponse = GlobalSuccess; export const CreateBoardColumnResponse = GlobalSuccess.extend({ - columnId: z.string(), + stateId: z.string(), }); +export const BoardListResponse = Board.array(); +export const BoardColumnListResponse = BoardColumn.array(); + +export const CreateBoardResponse = GlobalSuccess; +export const UpdateBoardResponse = GlobalSuccess; diff --git a/src/entities/board/model/types.ts b/src/entities/board/model/types.ts index ef88b13..4ded86e 100644 --- a/src/entities/board/model/types.ts +++ b/src/entities/board/model/types.ts @@ -1,16 +1,12 @@ 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; @@ -18,10 +14,6 @@ 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; diff --git a/src/features/boards/column/create/model/default-values.ts b/src/features/boards/column/create/model/default-values.ts index 710f1fc..1fee8c9 100644 --- a/src/features/boards/column/create/model/default-values.ts +++ b/src/features/boards/column/create/model/default-values.ts @@ -3,8 +3,8 @@ import { CreateBoardColumnFormValues } from './type'; export function getDefaultCreateBoardColumnValues(position = 0): CreateBoardColumnFormValues { return { - name: '', - position, + title: '', color: DEFAULT_COLUMN_COLOR, + orderIndex: position, }; } diff --git a/src/features/boards/column/create/model/schemas.ts b/src/features/boards/column/create/model/schemas.ts index dca9c6e..520d8a1 100644 --- a/src/features/boards/column/create/model/schemas.ts +++ b/src/features/boards/column/create/model/schemas.ts @@ -1,7 +1,3 @@ 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}$/), -}); +export const CreateBoardColumnFormSchema = SBoard.CreateBoardColumnBody; diff --git a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts index 429ebc7..24b34f9 100644 --- a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts +++ b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts @@ -37,8 +37,8 @@ export function useCreateBoardColumnForm( const onSubmit = (data: CreateBoardColumnFormValues) => { const body: TBoard.CreateBoardColumnBody = { - name: data.name, - position: Number(data.position), + title: data.title, + orderIndex: data.orderIndex, ...(data.color ? { color: data.color } : {}), }; diff --git a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx index 3f68167..4bc6daf 100644 --- a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx +++ b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx @@ -4,7 +4,8 @@ import { ColorPicker, Field, FieldError, FieldGroup, FieldLabel, Input } from 's import { useCreateBoardColumnForm } from '../model/useCreateBoardColumnForm'; import { UseCreateBoardColumnOptions } from '../model/useCreateBoardColumn'; import { ComponentProps } from 'react'; -import { COLORS } from '../model/consts'; +import { COLORS, DEFAULT_COLUMN_COLOR } from '../model/consts'; +import { fi } from 'zod/v4/locales'; interface CreateBoardColumnFormProps extends Omit, 'children' | 'onSubmit'> { boardId: string; @@ -29,7 +30,7 @@ export function CreateBoardColumnForm({
( @@ -50,28 +51,31 @@ export function CreateBoardColumnForm({ ( - - Цвет - { - form.setValue('color', c); - }} - {...field} - /> + render={({ field, fieldState }) => { + const { value, ...rest } = field; + return ( + + Цвет + { + form.setValue('color', c); + }} + {...rest} + /> - {fieldState.invalid && } - - )} + {fieldState.invalid && } + + ); + }} /> ( diff --git a/src/features/boards/create/model/default-values.ts b/src/features/boards/create/model/default-values.ts index 66c28fb..cdd5a57 100644 --- a/src/features/boards/create/model/default-values.ts +++ b/src/features/boards/create/model/default-values.ts @@ -1,9 +1,5 @@ import { CreateBoardFormValues } from './type'; export function getDefaultCreateBoardValues(): CreateBoardFormValues { - return { - name: '', - position: 0, - settings: {}, - }; + return { title: '' }; } diff --git a/src/features/boards/create/model/schemas.ts b/src/features/boards/create/model/schemas.ts index fae521c..d4e5afe 100644 --- a/src/features/boards/create/model/schemas.ts +++ b/src/features/boards/create/model/schemas.ts @@ -1,6 +1,3 @@ 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), -}); +export const CreateBoardFormSchema = SBoard.CreateBoardBody; diff --git a/src/features/boards/create/ui/CreateBoardForm.tsx b/src/features/boards/create/ui/CreateBoardForm.tsx index 7471e0d..a5dfad5 100644 --- a/src/features/boards/create/ui/CreateBoardForm.tsx +++ b/src/features/boards/create/ui/CreateBoardForm.tsx @@ -1,6 +1,6 @@ import { Controller, FormProvider } from 'react-hook-form'; import { cn } from 'shared/lib/utils'; -import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input } from 'shared/ui'; +import { Field, FieldError, FieldGroup, FieldLabel, Input } from 'shared/ui'; import { useCreateBoardForm } from '../model/useCreateBoardForm'; import { UseCreateBoardOptions } from '../model/useCreateBoard'; import { ComponentProps } from 'react'; @@ -17,7 +17,7 @@ export function CreateBoardForm({ className, mutateOptions, ...props }: CreateBo ( @@ -35,17 +35,6 @@ export function CreateBoardForm({ className, mutateOptions, ...props }: CreateBo )} /> - ( - - Настройки - Не реализовано - {fieldState.invalid && } - - )} - /> { +export const useActiveBoards = (boards: TBoard.BoardResponse[]) => { const activeBoardId = useBoardStore((s) => s.activeBoardId); const setActiveBoardId = useBoardStore((s) => s.setBoardId); - const activeBoard: BoardWithTasks | null = - boards?.find((v) => v.board.id === activeBoardId) ?? (boards.length > 0 ? boards[0] : null); + const activeBoard: TBoard.BoardResponse | null = + boards?.find((v) => v.id === activeBoardId) ?? (boards.length > 0 ? boards[0] : null); useEffect(() => { if (!activeBoardId && boards.length > 0) { - setActiveBoardId(boards[0].board.id); + setActiveBoardId(boards[0].id); } }, [activeBoardId, boards, setActiveBoardId]); diff --git a/src/pages/project/model/useBoardsPage.ts b/src/pages/project/model/useBoardsPage.ts index 05c653d..7d43bd7 100644 --- a/src/pages/project/model/useBoardsPage.ts +++ b/src/pages/project/model/useBoardsPage.ts @@ -1,16 +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, - error, - refetch, - } = useQuery(BoardQueries.getBoardList(projectId)); - const data = dto?.items.map(BoardMapper.toBoardWithTasks) ?? []; + const { data, isLoading, isError, error, refetch } = useQuery( + BoardQueries.getBoardList(projectId) + ); return { data, isLoading, isError, error, refetch }; }; diff --git a/src/pages/project/ui/boards/ProjectBoards.tsx b/src/pages/project/ui/boards/ProjectBoards.tsx index 11d3683..5696b11 100644 --- a/src/pages/project/ui/boards/ProjectBoards.tsx +++ b/src/pages/project/ui/boards/ProjectBoards.tsx @@ -11,7 +11,7 @@ import { import { CreateBoardDialog } from 'features/boards/create'; import { useInitProjectId } from 'entities/project'; import { ComponentProps, PropsWithChildren } from 'react'; -import { TBoard } from 'entities/board'; +import { BoardMapper, BoardQueries, TBoard } from 'entities/board'; import { useBoardStore } from 'pages/project/model/store'; import { EllipsisVertical } from 'lucide-react'; import { ProjectKanban } from './ProjectKanban'; @@ -22,12 +22,22 @@ import { VariantProps } from 'class-variance-authority'; import { RemoveBoardDialog } from 'features/boards/remove'; import { ProjectBoardsSkeleton } from './ProjectBoards.skeleton'; import { ProjectBoardsError } from './ProjectBoardsError'; +import { useQuery } from '@tanstack/react-query'; export function ProjectBoards({ projectId }: PropsWithChildren<{ projectId: string }>) { useInitProjectId(projectId); const { data, isLoading, isError, error, refetch } = useBoardsPage(projectId); - const { activeBoard, activeBoardId } = useActiveBoards(data); + const { activeBoard, activeBoardId } = useActiveBoards(data ?? []); + + const columns = useQuery({ + ...BoardQueries.getBoardColumnList(activeBoardId!), + enabled: !!activeBoardId, + }); + + const board = + activeBoard && columns.data ? BoardMapper.toBoardWithTasks(activeBoard, columns.data) : null; + if (isLoading) return ; if (isError) { return refetch()} />; @@ -36,14 +46,14 @@ export function ProjectBoards({ projectId }: PropsWithChildren<{ projectId: stri
{data?.map((item) => ( - + ))}
- {activeBoard ? : null} + {board ? : null}
); @@ -80,7 +90,7 @@ function BoardButton({ onClick={() => setActiveBoardId(board.id)} className="h-full px-3 text-left" > - {board.name} + {board.title} diff --git a/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx b/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx index 3a1ee21..57d757b 100644 --- a/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx +++ b/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx @@ -21,7 +21,7 @@ export interface TaskColumnHeaderProps extends TBoard.BoardColumnResponse { } export function TaskColumnHeader({ data }: { data: TaskColumnHeaderProps }) { - const { tasksLength, name, id, boardId, color } = data; + const { tasksLength, title, id, color } = data; const [activeColor, setActiveColor] = useState(color ?? BOARD_COLUMN_COLORS[0]); const existColor = BOARD_COLUMN_COLORS.findIndex((v) => v.toLowerCase() === data.color); @@ -38,7 +38,7 @@ export function TaskColumnHeader({ data }: { data: TaskColumnHeaderProps }) {
-

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

+

{`${title} (${tasksLength})`}

@@ -50,7 +50,7 @@ export function TaskColumnHeader({ data }: { data: TaskColumnHeaderProps }) { - + e.preventDefault()} variant="destructive"> Удалить diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 716a251..ac233f3 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -10,6 +10,7 @@ export { GlobalError, DateTimeString, PaginatedResponseSchema, + createSortingSchema, MetaSchema, } from './schemas'; export { AccessToken } from './token'; diff --git a/src/shared/api/schemas/index.ts b/src/shared/api/schemas/index.ts index e4df2be..b34cc97 100644 --- a/src/shared/api/schemas/index.ts +++ b/src/shared/api/schemas/index.ts @@ -2,3 +2,4 @@ export { GlobalSuccess } from './global-success'; export { GlobalError } from './global-error'; export { DateTimeString } from './date-time-string'; export { PaginatedResponseSchema, MetaSchema } from './pagination'; +export { createSortingSchema } from './sorting'; diff --git a/src/shared/api/schemas/sorting.ts b/src/shared/api/schemas/sorting.ts new file mode 100644 index 0000000..48aebed --- /dev/null +++ b/src/shared/api/schemas/sorting.ts @@ -0,0 +1,28 @@ +import { z } from 'zod/v4'; + +export const createSortingSchema = ( + fields: T, + defaultField?: T[number], + defaultOrder: 'asc' | 'desc' = 'asc' +) => + z.object({ + sortBy: z + .enum(fields) + .optional() + /** + * Приведение as any обусловлено ограничением системы типов TypeScript: + * тип fields[0 выводится как string, а Zod ожидает конкретный литеральный тип + * из объединения T[number]. В рантайме значение гарантированно валидно, + * так как массив fields используется для создания enum. + * as any безопасно подавляет ошибку, не расширяя тип за пределы этой строки. + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + .default(defaultField ?? (fields[0] as any)) + .describe(`Поле для сортировки. Доступно: ${fields.join(', ')}`), + + sortOrder: z + .enum(['asc', 'desc']) + .optional() + .default(() => defaultOrder) + .describe('Направление сортировки: asc - по возрастанию, desc - по убыванию'), + }); From 0205f264cc2fdd6a20733263fa8a343d5b822e48 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Fri, 19 Jun 2026 00:54:02 +0300 Subject: [PATCH 28/37] refactor(board, project): replace projectId with projectSlug and update related methods and queries --- src/entities/board/api/http.ts | 55 ++++++++++++------- src/entities/board/api/queries.ts | 24 ++++---- src/entities/board/model/conts.ts | 10 ++-- src/entities/project/index.ts | 2 +- src/entities/project/lib/useInitProjectId.ts | 16 ------ .../project/lib/useInitProjectSlug.ts | 16 ++++++ src/entities/project/model/store.ts | 16 +++--- .../create/model/useCreateBoardColumn.ts | 13 ++--- .../create/model/useCreateBoardColumnForm.ts | 12 ++-- .../create/ui/CreateBoardColumnDialog.tsx | 6 +- .../create/ui/CreateBoardColumnForm.tsx | 6 +- .../column/remove/model/useRemoveColumn.ts | 13 ++--- .../column/remove/ui/RemoveColumnDialog.tsx | 4 +- .../boards/create/model/useCreateBoard.ts | 8 +-- .../boards/create/model/useCreateBoardForm.ts | 8 +-- .../boards/remove/model/useRemoveBoard.ts | 8 +-- .../boards/remove/ui/RemoveBoardDialog.tsx | 4 +- src/pages/project/model/store.ts | 18 ++---- src/pages/project/model/useActiveBoard.ts | 2 +- src/pages/project/ui/boards/ProjectBoards.tsx | 12 ++-- .../project/ui/boards/ProjectBoardsPage.tsx | 6 +- src/pages/project/ui/boards/ProjectKanban.tsx | 2 +- .../ui/boards/task-column/TaskColumn.tsx | 4 +- .../boards/task-column/TaskColumnHeader.tsx | 5 +- 24 files changed, 135 insertions(+), 135 deletions(-) delete mode 100644 src/entities/project/lib/useInitProjectId.ts create mode 100644 src/entities/project/lib/useInitProjectSlug.ts diff --git a/src/entities/board/api/http.ts b/src/entities/board/api/http.ts index e85529c..4b0bc5f 100644 --- a/src/entities/board/api/http.ts +++ b/src/entities/board/api/http.ts @@ -3,9 +3,9 @@ import * as SBoard from '../model/schemas'; import * as TBoard from '../model/types'; export class BoardHttp { - static getBoardList(projectId: string, signal?: AbortSignal) { + static getBoardList(projectSlug: string, signal?: AbortSignal) { return api({ - url: `/projects/${projectId}/boards`, + url: `/projects/${projectSlug}/area`, method: 'GET', contracts: { response: SBoard.BoardListResponse, @@ -14,9 +14,9 @@ export class BoardHttp { }); } - static getBoard(projectId: string, id: string, signal?: AbortSignal) { + static getBoard(projectSlug: string, id: string, signal?: AbortSignal) { return api({ - url: `/projects/${projectId}/boards/${id}`, + url: `/projects/${projectSlug}/area/${id}`, method: 'GET', contracts: { response: SBoard.Board, @@ -25,9 +25,9 @@ export class BoardHttp { }); } - static createBoard(projectId: string, data: TBoard.CreateBoardBody) { + static createBoard(projectSlug: string, data: TBoard.CreateBoardBody) { return api({ - url: `/projects/${projectId}/boards`, + url: `/projects/${projectSlug}/area`, method: 'POST', data, contracts: { @@ -37,10 +37,10 @@ export class BoardHttp { }); } - static updateBoard(projectId: string, id: string, data: TBoard.UpdateBoardBody) { + static updateBoard(projectSlug: string, boardSlug: string, data: TBoard.UpdateBoardBody) { return api({ - url: `/projects/${projectId}/boards/${id}`, - method: 'PATCH', + url: `/projects/${projectSlug}/area/${boardSlug}`, + method: 'PUT', data, contracts: { body: SBoard.UpdateBoardBody, @@ -49,9 +49,9 @@ export class BoardHttp { }); } - static removeBoard(projectId: string, id: string) { + static removeBoard(projectSlug: string, boardSlug: string) { return api({ - url: `/projects/${projectId}/boards/${id}`, + url: `/projects/${projectSlug}/boards/${boardSlug}`, method: 'DELETE', contracts: { response: SBoard.ActionResponse, @@ -59,9 +59,9 @@ export class BoardHttp { }); } - static getBoardColumnList(boardId: string, signal?: AbortSignal) { + static getBoardColumnList(boardSlug: string, signal?: AbortSignal) { return api({ - url: `/boards/${boardId}/columns`, + url: `/area/${boardSlug}/columns`, method: 'GET', contracts: { response: SBoard.BoardColumnListResponse, @@ -70,9 +70,9 @@ export class BoardHttp { }); } - static getBoardColumn(boardId: string, id: string, signal?: AbortSignal) { + static getBoardColumn(boardSlug: string, columnId: string, signal?: AbortSignal) { return api({ - url: `/boards/${boardId}/columns/${id}`, + url: `/area/${boardSlug}/columns/${columnId}`, method: 'GET', contracts: { response: SBoard.BoardColumn, @@ -81,9 +81,9 @@ export class BoardHttp { }); } - static createBoardColumn(boardId: string, data: TBoard.CreateBoardColumnBody) { + static createBoardColumn(boardSlug: string, data: TBoard.CreateBoardColumnBody) { return api({ - url: `/boards/${boardId}/columns`, + url: `/area/${boardSlug}/columns`, method: 'POST', data, contracts: { @@ -93,9 +93,13 @@ export class BoardHttp { }); } - static updateBoardColumn(boardId: string, id: string, data: TBoard.UpdateBoardColumnBody) { + static updateBoardColumn( + boardSlug: string, + columnId: string, + data: TBoard.UpdateBoardColumnBody + ) { return api({ - url: `/boards/${boardId}/columns/${id}`, + url: `/area/${boardSlug}/columns/${columnId}`, method: 'PATCH', data, contracts: { @@ -105,13 +109,22 @@ export class BoardHttp { }); } - static removeBoardColumn(boardId: string, id: string) { + static removeBoardColumn(boardSlug: string, columnId: string) { return api({ - url: `/boards/${boardId}/columns/${id}`, + url: `/area/${boardSlug}/columns/${columnId}`, method: 'DELETE', contracts: { response: SBoard.ActionResponse, }, }); } + static restoreBoardColumn(boardSlug: string, columnId: string) { + return api({ + url: `/area/${boardSlug}/columns/${columnId}/restore`, + method: 'POST', + contracts: { + response: SBoard.ActionResponse, + }, + }); + } } diff --git a/src/entities/board/api/queries.ts b/src/entities/board/api/queries.ts index af0fec0..ba76f97 100644 --- a/src/entities/board/api/queries.ts +++ b/src/entities/board/api/queries.ts @@ -3,34 +3,34 @@ import { boardFabricKeys } from '../model/conts'; import { BoardHttp } from './http'; export class BoardQueries { - static getBoardList(projectId: string) { + static getBoardList(slug: string) { return queryOptions({ - queryKey: boardFabricKeys.list(projectId), - queryFn: async ({ signal }) => BoardHttp.getBoardList(projectId, signal), + queryKey: boardFabricKeys.list(slug), + queryFn: async ({ signal }) => BoardHttp.getBoardList(slug, signal), staleTime: 60_000, }); } - static getBoard(projectId: string, id: string) { + static getBoard(slug: string, id: string) { return queryOptions({ - queryKey: boardFabricKeys.detail(projectId, id), - queryFn: async ({ signal }) => BoardHttp.getBoard(projectId, id, signal), + queryKey: boardFabricKeys.detail(slug, id), + queryFn: async ({ signal }) => BoardHttp.getBoard(slug, id, signal), staleTime: 60_000, }); } - static getBoardColumnList(boardId: string) { + static getBoardColumnList(slug: string) { return queryOptions({ - queryKey: boardFabricKeys.columns(boardId), - queryFn: async ({ signal }) => BoardHttp.getBoardColumnList(boardId, signal), + queryKey: boardFabricKeys.columns(slug), + queryFn: async ({ signal }) => BoardHttp.getBoardColumnList(slug, signal), staleTime: 60_000, }); } - static getBoardColumn(boardId: string, id: string) { + static getBoardColumn(slug: string, id: string) { return queryOptions({ - queryKey: boardFabricKeys.column(boardId, id), - queryFn: async ({ signal }) => BoardHttp.getBoardColumn(boardId, id, signal), + queryKey: boardFabricKeys.column(slug, id), + queryFn: async ({ signal }) => BoardHttp.getBoardColumn(slug, id, signal), staleTime: 60_000, }); } diff --git a/src/entities/board/model/conts.ts b/src/entities/board/model/conts.ts index cbfe1d7..0b80b05 100644 --- a/src/entities/board/model/conts.ts +++ b/src/entities/board/model/conts.ts @@ -1,9 +1,9 @@ import { createEntityKeys } from 'shared/lib/utils'; export const boardFabricKeys = createEntityKeys('board', { - 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], + detail: (slug: string, id: string) => ['projects', slug, 'boards', id], + columns: (slug: string) => ['boards', slug, 'columns'], + column: (slug: string, id: string) => ['boards', slug, 'columns', id], + views: (slug: string) => ['boards', slug, 'views'], + view: (slug: string, id: string) => ['boards', slug, 'views', id], }); diff --git a/src/entities/project/index.ts b/src/entities/project/index.ts index b2cf538..7e0113b 100644 --- a/src/entities/project/index.ts +++ b/src/entities/project/index.ts @@ -8,7 +8,7 @@ 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'; +export { useInitProjectSlug } from './lib/useInitProjectSlug'; export { validateProjectSlugAsync } from './lib/validate-project-slug'; export { SlugField } from './ui/SlugField'; export { useCheckSlug } from './lib/useCheckSlug'; diff --git a/src/entities/project/lib/useInitProjectId.ts b/src/entities/project/lib/useInitProjectId.ts deleted file mode 100644 index c115e5b..0000000 --- a/src/entities/project/lib/useInitProjectId.ts +++ /dev/null @@ -1,16 +0,0 @@ -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/lib/useInitProjectSlug.ts b/src/entities/project/lib/useInitProjectSlug.ts new file mode 100644 index 0000000..a765880 --- /dev/null +++ b/src/entities/project/lib/useInitProjectSlug.ts @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; +import { useProjectStore } from '../model/store'; + +export function useInitProjectSlug(slug: string) { + const setProjectSlug = useProjectStore((s) => s.setProjectSlug); + const clearProjectSlug = useProjectStore((s) => s.clearProjectSlug); + + useEffect(() => { + setProjectSlug(slug); + + return () => { + clearProjectSlug(); + }; + }, [clearProjectSlug, slug, setProjectSlug]); + return null; +} diff --git a/src/entities/project/model/store.ts b/src/entities/project/model/store.ts index d90c368..4da56d1 100644 --- a/src/entities/project/model/store.ts +++ b/src/entities/project/model/store.ts @@ -1,17 +1,17 @@ import { create } from 'zustand'; interface ProjectStore { - projectId: string | null; - setProjectId: (id: string) => void; - clearProjectId: () => void; + projectSlug: string | null; + setProjectSlug: (id: string) => void; + clearProjectSlug: () => void; } export const useProjectStore = create((set) => ({ - projectId: null, - setProjectId(id) { - set({ projectId: id }); + projectSlug: null, + setProjectSlug(id) { + set({ projectSlug: id }); }, - clearProjectId() { - set({ projectId: null }); + clearProjectSlug() { + set({ projectSlug: null }); }, })); diff --git a/src/features/boards/column/create/model/useCreateBoardColumn.ts b/src/features/boards/column/create/model/useCreateBoardColumn.ts index 7dc82c9..e16ac22 100644 --- a/src/features/boards/column/create/model/useCreateBoardColumn.ts +++ b/src/features/boards/column/create/model/useCreateBoardColumn.ts @@ -1,10 +1,9 @@ import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; -import { boardFabricKeys, BoardHttp, TBoard } from 'entities/board'; +import { boardFabricKeys, BoardHttp, type TBoard } from 'entities/board'; import { toast } from 'sonner'; type CreateBoardColumnVariables = { - boardId: string; - projectId: string; + boardSlug: string; body: TBoard.CreateBoardColumnBody; }; @@ -16,16 +15,12 @@ export type UseCreateBoardColumnOptions = Omit< export function useCreateBoardColumn({ onSuccess, ...rest }: UseCreateBoardColumnOptions = {}) { return useMutation({ ...rest, - mutationFn: ({ boardId, body }) => BoardHttp.createBoardColumn(boardId, body), + mutationFn: ({ boardSlug, body }) => BoardHttp.createBoardColumn(boardSlug, 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), + queryKey: boardFabricKeys.columns(variables.boardSlug), }); }, }); diff --git a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts index 24b34f9..5bdeb6e 100644 --- a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts +++ b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts @@ -6,7 +6,7 @@ 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 { type TBoard } from 'entities/board'; import { useProjectStore } from 'entities/project'; type UseCreateBoardColumnFormOptions = UseCreateBoardColumnOptions & { @@ -14,11 +14,11 @@ type UseCreateBoardColumnFormOptions = UseCreateBoardColumnOptions & { }; export function useCreateBoardColumnForm( - boardId: string, + boardSlug: string, options: UseCreateBoardColumnFormOptions = {} ) { const { defaultPosition = 0, ...mutationOptions } = options; - const projectId = useProjectStore((s) => s.projectId!); + const projectSlug = useProjectStore((s) => s.projectSlug!); const form = useForm({ resolver: zodResolver(CreateBoardColumnFormSchema), defaultValues: getDefaultCreateBoardColumnValues(defaultPosition), @@ -42,13 +42,13 @@ export function useCreateBoardColumnForm( ...(data.color ? { color: data.color } : {}), }; - createBoardColumn.mutate({ boardId, projectId, body }); + createBoardColumn.mutate({ boardSlug, body }); }; return { form, - boardId, - projectId, + boardSlug, + projectSlug, 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 index cef8757..e295dce 100644 --- a/src/features/boards/column/create/ui/CreateBoardColumnDialog.tsx +++ b/src/features/boards/column/create/ui/CreateBoardColumnDialog.tsx @@ -15,13 +15,13 @@ import { import { CreateBoardColumnForm } from './CreateBoardColumnForm'; interface CreateBoardColumnDialogProps extends ComponentProps { - boardId: string; + boardSlug: string; defaultPosition?: number; dialog?: ComponentProps; } export function CreateBoardColumnDialog({ - boardId, + boardSlug, defaultPosition, dialog = {}, ...props @@ -45,7 +45,7 @@ export function CreateBoardColumnDialog({ { diff --git a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx index 4bc6daf..384631e 100644 --- a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx +++ b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx @@ -8,19 +8,19 @@ import { COLORS, DEFAULT_COLUMN_COLOR } from '../model/consts'; import { fi } from 'zod/v4/locales'; interface CreateBoardColumnFormProps extends Omit, 'children' | 'onSubmit'> { - boardId: string; + boardSlug: string; defaultPosition?: number; mutateOptions?: UseCreateBoardColumnOptions; } export function CreateBoardColumnForm({ - boardId, + boardSlug, defaultPosition, className, mutateOptions, ...props }: CreateBoardColumnFormProps) { - const { form, isPending, handleSubmit } = useCreateBoardColumnForm(boardId, { + const { form, isPending, handleSubmit } = useCreateBoardColumnForm(boardSlug, { defaultPosition, ...mutateOptions, }); diff --git a/src/features/boards/column/remove/model/useRemoveColumn.ts b/src/features/boards/column/remove/model/useRemoveColumn.ts index 57da851..e03d429 100644 --- a/src/features/boards/column/remove/model/useRemoveColumn.ts +++ b/src/features/boards/column/remove/model/useRemoveColumn.ts @@ -1,11 +1,10 @@ -import { DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; -import { boardFabricKeys, BoardHttp, TBoard } from 'entities/board'; -import { useProjectStore } from 'entities/project'; +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { boardFabricKeys, BoardHttp, type TBoard } from 'entities/board'; import { toast } from 'sonner'; export type RemoveColunmnVariables = { columnId: string; - boardId: string; + boardSlug: string; }; export type UseDeleteColumnOptions = Omit< @@ -14,18 +13,16 @@ 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), + mutationFn: (args) => BoardHttp.removeBoardColumn(args.boardSlug, 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) }); - context.client.invalidateQueries({ queryKey: boardFabricKeys.list(projectId!) }); + context.client.invalidateQueries({ queryKey: boardFabricKeys.columns(_v.boardSlug) }); }, }); } diff --git a/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx b/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx index d22f264..30ce9dd 100644 --- a/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx +++ b/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx @@ -19,11 +19,11 @@ import { type Props = ComponentProps & RemoveColunmnVariables & { options?: UseDeleteColumnOptions }; -export function RemoveColumnDialog({ columnId, boardId, options = {}, ...props }: Props) { +export function RemoveColumnDialog({ columnId, boardSlug, options = {}, ...props }: Props) { const removeBoard = useRemoveColumn(options); const onRemove = () => { - removeBoard.mutate({ columnId, boardId }); + removeBoard.mutate({ columnId, boardSlug }); }; return ( diff --git a/src/features/boards/create/model/useCreateBoard.ts b/src/features/boards/create/model/useCreateBoard.ts index 8f182b7..7d5be21 100644 --- a/src/features/boards/create/model/useCreateBoard.ts +++ b/src/features/boards/create/model/useCreateBoard.ts @@ -1,9 +1,9 @@ import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; -import { boardFabricKeys, BoardHttp, TBoard } from 'entities/board'; +import { boardFabricKeys, BoardHttp, type TBoard } from 'entities/board'; import { toast } from 'sonner'; type CreateBoardVariables = { - projectId: string; + projectSlug: string; body: TBoard.CreateBoardBody; }; @@ -15,13 +15,13 @@ export type UseCreateBoardOptions = Omit< export function useCreateBoard({ onSuccess, ...rest }: UseCreateBoardOptions = {}) { return useMutation({ ...rest, - mutationFn: ({ projectId, body }) => BoardHttp.createBoard(projectId, body), + mutationFn: ({ projectSlug, body }) => BoardHttp.createBoard(projectSlug, 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), + queryKey: boardFabricKeys.list(variables.projectSlug), }); }, }); diff --git a/src/features/boards/create/model/useCreateBoardForm.ts b/src/features/boards/create/model/useCreateBoardForm.ts index ce62017..656afcc 100644 --- a/src/features/boards/create/model/useCreateBoardForm.ts +++ b/src/features/boards/create/model/useCreateBoardForm.ts @@ -6,11 +6,11 @@ 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 { type TBoard } from 'entities/board'; import { useProjectStore } from 'entities/project'; export function useCreateBoardForm(options: UseCreateBoardOptions = {}) { - const projectId = useProjectStore((s) => s.projectId!); + const slug = useProjectStore((s) => s.projectSlug!); const form = useForm({ resolver: zodResolver(CreateBoardFormSchema), defaultValues: getDefaultCreateBoardValues(), @@ -33,12 +33,12 @@ export function useCreateBoardForm(options: UseCreateBoardOptions = {}) { position: Number(data.position), }; - createBoard.mutate({ projectId, body }); + createBoard.mutate({ projectSlug: slug, body }); }; return { form, - projectId, + projectSlug: slug, isPending: createBoard.isPending, handleSubmit: form.handleSubmit(onSubmit), }; diff --git a/src/features/boards/remove/model/useRemoveBoard.ts b/src/features/boards/remove/model/useRemoveBoard.ts index 3d10635..27a314b 100644 --- a/src/features/boards/remove/model/useRemoveBoard.ts +++ b/src/features/boards/remove/model/useRemoveBoard.ts @@ -3,8 +3,8 @@ import { boardFabricKeys, BoardHttp, TBoard } from 'entities/board'; import { toast } from 'sonner'; export type RemoveBoardVariables = { - projectId: string; - boardId: string; + projectSlug: string; + boardSlug: string; }; export type UseDeleteBoardOptions = Omit< @@ -15,14 +15,14 @@ export type UseDeleteBoardOptions = Omit< export function useRemoveBoard({ onSuccess, onSettled, ...rest }: UseDeleteBoardOptions = {}) { return useMutation({ ...rest, - mutationFn: (args) => BoardHttp.removeBoard(args.projectId, args.boardId), + mutationFn: (args) => BoardHttp.removeBoard(args.projectSlug, args.boardSlug), 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) }); + context.client.invalidateQueries({ queryKey: boardFabricKeys.list(_v.projectSlug) }); }, }); } diff --git a/src/features/boards/remove/ui/RemoveBoardDialog.tsx b/src/features/boards/remove/ui/RemoveBoardDialog.tsx index 2ca7a08..82261ba 100644 --- a/src/features/boards/remove/ui/RemoveBoardDialog.tsx +++ b/src/features/boards/remove/ui/RemoveBoardDialog.tsx @@ -13,11 +13,11 @@ import { RemoveBoardVariables, useRemoveBoard } from '../model/useRemoveBoard'; type Props = ComponentProps & RemoveBoardVariables; -export function RemoveBoardDialog({ projectId, boardId, ...props }: Props) { +export function RemoveBoardDialog({ projectSlug, boardSlug, ...props }: Props) { const removeBoard = useRemoveBoard(); const onRemove = () => { - removeBoard.mutate({ projectId, boardId }); + removeBoard.mutate({ projectSlug, boardSlug }); }; return ( diff --git a/src/pages/project/model/store.ts b/src/pages/project/model/store.ts index ad4e6ec..938e6db 100644 --- a/src/pages/project/model/store.ts +++ b/src/pages/project/model/store.ts @@ -2,26 +2,18 @@ import { create } from 'zustand'; interface BordStore { activeBoardId: string | null; + activeBoardSlug: string | null; activeColumnId: string | null; - projectId: string | null; - setProjectId: (id: string) => void; - clearProjectId: () => void; - setBoardId: (id: string | null) => void; + setBoardId: (id: string, slug: string) => 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 }); + activeBoardSlug: null, + setBoardId(id, slug) { + set({ activeBoardId: id, activeBoardSlug: slug }); }, setColumnId(id) { set({ activeColumnId: id }); diff --git a/src/pages/project/model/useActiveBoard.ts b/src/pages/project/model/useActiveBoard.ts index 3d3dd5b..6c5c1f7 100644 --- a/src/pages/project/model/useActiveBoard.ts +++ b/src/pages/project/model/useActiveBoard.ts @@ -11,7 +11,7 @@ export const useActiveBoards = (boards: TBoard.BoardResponse[]) => { useEffect(() => { if (!activeBoardId && boards.length > 0) { - setActiveBoardId(boards[0].id); + setActiveBoardId(boards[0].id, boards[0].slug); } }, [activeBoardId, boards, setActiveBoardId]); diff --git a/src/pages/project/ui/boards/ProjectBoards.tsx b/src/pages/project/ui/boards/ProjectBoards.tsx index 5696b11..9b12d6b 100644 --- a/src/pages/project/ui/boards/ProjectBoards.tsx +++ b/src/pages/project/ui/boards/ProjectBoards.tsx @@ -9,7 +9,7 @@ import { buttonVariants, } from 'shared/ui'; import { CreateBoardDialog } from 'features/boards/create'; -import { useInitProjectId } from 'entities/project'; +import { useInitProjectSlug } from 'entities/project'; import { ComponentProps, PropsWithChildren } from 'react'; import { BoardMapper, BoardQueries, TBoard } from 'entities/board'; import { useBoardStore } from 'pages/project/model/store'; @@ -24,10 +24,10 @@ import { ProjectBoardsSkeleton } from './ProjectBoards.skeleton'; import { ProjectBoardsError } from './ProjectBoardsError'; import { useQuery } from '@tanstack/react-query'; -export function ProjectBoards({ projectId }: PropsWithChildren<{ projectId: string }>) { - useInitProjectId(projectId); +export function ProjectBoards({ slug }: PropsWithChildren<{ slug: string }>) { + useInitProjectSlug(slug); - const { data, isLoading, isError, error, refetch } = useBoardsPage(projectId); + const { data, isLoading, isError, error, refetch } = useBoardsPage(slug); const { activeBoard, activeBoardId } = useActiveBoards(data ?? []); const columns = useQuery({ @@ -87,7 +87,7 @@ function BoardButton({ >
- {board ? : null} + {board ? : null}
); @@ -62,9 +62,11 @@ export function ProjectBoards({ slug }: PropsWithChildren<{ slug: string }>) { type BoardButtonProps = ComponentProps<'div'> & VariantProps & { board: TBoard.BoardResponse; + projectSlug: string; }; function BoardButton({ + projectSlug, board, className, variant = 'outline', @@ -98,7 +100,7 @@ function BoardButton({ - + e.preventDefault()}> Удалить From 7539e2199d7f356747d7856e1f3f2c26fe331288 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Fri, 19 Jun 2026 15:09:03 +0300 Subject: [PATCH 31/37] refactor(auth, board): update imports to use type-only imports for types --- src/entities/auth/api/http.ts | 2 +- src/entities/auth/config/oauth-providers.ts | 2 +- src/entities/board/api/http.ts | 2 +- src/entities/board/model/mapper.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/entities/auth/api/http.ts b/src/entities/auth/api/http.ts index 959f5a1..71e4799 100644 --- a/src/entities/auth/api/http.ts +++ b/src/entities/auth/api/http.ts @@ -1,6 +1,6 @@ import { api } from 'shared/api'; import * as SAuth from '../model/schemas'; -import * as TAuth from '../model/types'; +import type * as TAuth from '../model/types'; export class AuthHttp { static signin(data: TAuth.SigninBody) { diff --git a/src/entities/auth/config/oauth-providers.ts b/src/entities/auth/config/oauth-providers.ts index ea62141..50937eb 100644 --- a/src/entities/auth/config/oauth-providers.ts +++ b/src/entities/auth/config/oauth-providers.ts @@ -1,4 +1,4 @@ -import { type TAuth } from 'entities/auth'; +import type * as TAuth from '../model/types'; import YandexIcon from 'public/yandex-logo.svg'; import VkontakteIcon from 'public/vkontakte-logo.svg'; import GoogleIcon from 'public/google-logo.svg'; diff --git a/src/entities/board/api/http.ts b/src/entities/board/api/http.ts index a3695db..e320997 100644 --- a/src/entities/board/api/http.ts +++ b/src/entities/board/api/http.ts @@ -1,6 +1,6 @@ import { api } from 'shared/api'; import * as SBoard from '../model/schemas'; -import * as TBoard from '../model/types'; +import type * as TBoard from '../model/types'; export class BoardHttp { static getBoardList(projectSlug: string, signal?: AbortSignal) { diff --git a/src/entities/board/model/mapper.ts b/src/entities/board/model/mapper.ts index d01c34d..5017d65 100644 --- a/src/entities/board/model/mapper.ts +++ b/src/entities/board/model/mapper.ts @@ -1,4 +1,4 @@ -import { BoardColumnResponse, BoardResponse } from './types'; +import type { BoardColumnResponse, BoardResponse } from './types'; // TODO: добавить таски в типы, когда они появятся в API From 2728a601119f5e17b5fa968e5515d32e9acf5098 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Sat, 20 Jun 2026 20:04:19 +0300 Subject: [PATCH 32/37] fix: resolve PR review issues - refactor: reorganize color constants - fix(TaskColumnHeader): improve color handling logic, normalize color case to prevent duplicate keys - refactor(project): fix store creation - refactor(layout): font was remove from layout - refactor: simplify auth type import --- app/layout.tsx | 8 +------ src/entities/auth/config/oauth-providers.ts | 4 ++-- src/entities/board/{lib => config}/colors.ts | 0 src/entities/board/index.ts | 2 +- src/entities/project/model/store.ts | 24 ++++++++++--------- .../boards/task-column/TaskColumnHeader.tsx | 13 +++++----- src/shared/ui/color-picker/ColorPicker.tsx | 5 ++-- src/shared/ui/color-picker/const.ts | 14 ----------- 8 files changed, 25 insertions(+), 45 deletions(-) rename src/entities/board/{lib => config}/colors.ts (100%) delete mode 100644 src/shared/ui/color-picker/const.ts diff --git a/app/layout.tsx b/app/layout.tsx index 6643dfc..ffc6dec 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,15 +1,9 @@ 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/entities/auth/config/oauth-providers.ts b/src/entities/auth/config/oauth-providers.ts index 50937eb..69bcf00 100644 --- a/src/entities/auth/config/oauth-providers.ts +++ b/src/entities/auth/config/oauth-providers.ts @@ -1,15 +1,15 @@ -import type * as TAuth from '../model/types'; import YandexIcon from 'public/yandex-logo.svg'; import VkontakteIcon from 'public/vkontakte-logo.svg'; import GoogleIcon from 'public/google-logo.svg'; import GithubIcon from 'public/github-logo.svg'; +import { type OAuthProvider } from '../model/types'; export type OAuthProviderMeta = { iconSrc: string; buttonClassName?: string; }; -export const OAUTH_PROVIDERS: Record = { +export const OAUTH_PROVIDERS: Record = { yandex: { iconSrc: YandexIcon, buttonClassName: 'text-[#fc3f1d] hover:text-[#fc3f1d]', diff --git a/src/entities/board/lib/colors.ts b/src/entities/board/config/colors.ts similarity index 100% rename from src/entities/board/lib/colors.ts rename to src/entities/board/config/colors.ts diff --git a/src/entities/board/index.ts b/src/entities/board/index.ts index b761ce6..a7f3a28 100644 --- a/src/entities/board/index.ts +++ b/src/entities/board/index.ts @@ -4,4 +4,4 @@ export { boardFabricKeys } from './model/conts'; export { BoardHttp } from './api/http'; export { BoardQueries } from './api/queries'; export { BoardMapper, type BoardWithTasks } from './model/mapper'; -export { BOARD_COLUMN_COLORS } from './lib/colors'; +export { BOARD_COLUMN_COLORS } from './config/colors'; diff --git a/src/entities/project/model/store.ts b/src/entities/project/model/store.ts index 4da56d1..bdc5f54 100644 --- a/src/entities/project/model/store.ts +++ b/src/entities/project/model/store.ts @@ -1,17 +1,19 @@ -import { create } from 'zustand'; +import { createStore } from 'shared/lib/store'; -interface ProjectStore { +type ProjectState = { projectSlug: string | null; - setProjectSlug: (id: string) => void; + setProjectSlug: (projectSlug: string | null) => void; clearProjectSlug: () => void; -} +}; -export const useProjectStore = create((set) => ({ +export const useProjectStore = createStore((set) => ({ projectSlug: null, - setProjectSlug(id) { - set({ projectSlug: id }); - }, - clearProjectSlug() { - set({ projectSlug: null }); - }, + setProjectSlug: (projectSlug) => + set((state) => { + state.projectSlug = projectSlug; + }), + clearProjectSlug: () => + set((state) => { + state.projectSlug = null; + }), })); diff --git a/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx b/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx index 92adc4e..462e0e2 100644 --- a/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx +++ b/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx @@ -25,13 +25,12 @@ export function TaskColumnHeader({ data }: { data: TaskColumnHeaderProps }) { const { tasksLength, title, id, color, boardSlug } = data; const [activeColor, setActiveColor] = useState(color ?? BOARD_COLUMN_COLORS[0]); - const existColor = BOARD_COLUMN_COLORS.findIndex((v) => v.toLowerCase() === data.color); + const existColor = BOARD_COLUMN_COLORS.findIndex( + (v) => v.toLowerCase() === data.color?.toLowerCase() + ); const isExistColor = existColor !== -1; - const newColors = isExistColor - ? [...BOARD_COLUMN_COLORS] - : color - ? [color, ...BOARD_COLUMN_COLORS] - : [...BOARD_COLUMN_COLORS]; + const newColors = + color && !isExistColor ? [color, ...BOARD_COLUMN_COLORS] : [...BOARD_COLUMN_COLORS]; return ( @@ -61,7 +60,7 @@ export function TaskColumnHeader({ data }: { data: TaskColumnHeaderProps }) { Цвет колонки & { activeColor: string; setActiveColor: (color: string) => void; size?: keyof typeof variant.size; - colors?: string[]; + colors: string[]; }; const variant = { @@ -35,7 +34,7 @@ export function ColorPicker({ }; return (
- {(colors ?? COLORS)?.map((item) => { + {colors.map((item) => { const isSelected = activeColor === item; const isVeryLight = item?.toLowerCase() === '#ffffff'; diff --git a/src/shared/ui/color-picker/const.ts b/src/shared/ui/color-picker/const.ts deleted file mode 100644 index 3ee1d78..0000000 --- a/src/shared/ui/color-picker/const.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const COLORS = [ - '#9FA8DA', - '#7E57C2', - '#9575CD', - '#AB47BC', - '#F06292', - '#FF8A65', - '#4FC3F7', - '#4DB6AC', - '#81C784', - '#DCE775', - '#FFF176', - '#FFB74D', -] as const; From 037e071b5363b285a3dc356d02ec2e84826fcb02 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Sun, 21 Jun 2026 01:25:00 +0300 Subject: [PATCH 33/37] refactor: remove projectStore --- src/entities/project/index.ts | 2 -- .../project/lib/useInitProjectSlug.ts | 16 ---------------- src/entities/project/model/store.ts | 19 ------------------- .../boards/create/model/useCreateBoardForm.ts | 10 ++++++---- src/pages/project/ui/boards/ProjectBoards.tsx | 3 --- 5 files changed, 6 insertions(+), 44 deletions(-) delete mode 100644 src/entities/project/lib/useInitProjectSlug.ts delete mode 100644 src/entities/project/model/store.ts diff --git a/src/entities/project/index.ts b/src/entities/project/index.ts index 7e0113b..1f360b8 100644 --- a/src/entities/project/index.ts +++ b/src/entities/project/index.ts @@ -7,8 +7,6 @@ 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 { useInitProjectSlug } from './lib/useInitProjectSlug'; export { validateProjectSlugAsync } from './lib/validate-project-slug'; export { SlugField } from './ui/SlugField'; export { useCheckSlug } from './lib/useCheckSlug'; diff --git a/src/entities/project/lib/useInitProjectSlug.ts b/src/entities/project/lib/useInitProjectSlug.ts deleted file mode 100644 index a765880..0000000 --- a/src/entities/project/lib/useInitProjectSlug.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect } from 'react'; -import { useProjectStore } from '../model/store'; - -export function useInitProjectSlug(slug: string) { - const setProjectSlug = useProjectStore((s) => s.setProjectSlug); - const clearProjectSlug = useProjectStore((s) => s.clearProjectSlug); - - useEffect(() => { - setProjectSlug(slug); - - return () => { - clearProjectSlug(); - }; - }, [clearProjectSlug, slug, setProjectSlug]); - return null; -} diff --git a/src/entities/project/model/store.ts b/src/entities/project/model/store.ts deleted file mode 100644 index bdc5f54..0000000 --- a/src/entities/project/model/store.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createStore } from 'shared/lib/store'; - -type ProjectState = { - projectSlug: string | null; - setProjectSlug: (projectSlug: string | null) => void; - clearProjectSlug: () => void; -}; - -export const useProjectStore = createStore((set) => ({ - projectSlug: null, - setProjectSlug: (projectSlug) => - set((state) => { - state.projectSlug = projectSlug; - }), - clearProjectSlug: () => - set((state) => { - state.projectSlug = null; - }), -})); diff --git a/src/features/boards/create/model/useCreateBoardForm.ts b/src/features/boards/create/model/useCreateBoardForm.ts index b8fbcc0..cd1f42e 100644 --- a/src/features/boards/create/model/useCreateBoardForm.ts +++ b/src/features/boards/create/model/useCreateBoardForm.ts @@ -7,10 +7,12 @@ import { CreateBoardFormValues } from './type'; import { setFormErrors } from 'shared/lib/utils'; import { extractValidationIssues } from 'shared/api'; import { type TBoard } from 'entities/board'; -import { useProjectStore } from 'entities/project'; +import { useParams } from 'next/navigation'; export function useCreateBoardForm(options: UseCreateBoardOptions = {}) { - const slug = useProjectStore((s) => s.projectSlug!); + const params = useParams<{ slug: string }>(); + const slug = params?.slug; + const form = useForm({ resolver: zodResolver(CreateBoardFormSchema), defaultValues: getDefaultCreateBoardValues(), @@ -32,12 +34,12 @@ export function useCreateBoardForm(options: UseCreateBoardOptions = {}) { title: data.title, }; - createBoard.mutate({ projectSlug: slug, body }); + createBoard.mutate({ projectSlug: slug!, body }); }; return { form, - projectSlug: slug, + projectSlug: slug!, isPending: createBoard.isPending, handleSubmit: form.handleSubmit(onSubmit), }; diff --git a/src/pages/project/ui/boards/ProjectBoards.tsx b/src/pages/project/ui/boards/ProjectBoards.tsx index cbdb171..01dd65e 100644 --- a/src/pages/project/ui/boards/ProjectBoards.tsx +++ b/src/pages/project/ui/boards/ProjectBoards.tsx @@ -9,7 +9,6 @@ import { buttonVariants, } from 'shared/ui'; import { CreateBoardDialog } from 'features/boards/create'; -import { useInitProjectSlug } from 'entities/project'; import { ComponentProps, PropsWithChildren } from 'react'; import { BoardMapper, BoardQueries, TBoard } from 'entities/board'; import { useBoardStore } from 'pages/project/model/store'; @@ -25,8 +24,6 @@ import { ProjectBoardsError } from './ProjectBoardsError'; import { useQuery } from '@tanstack/react-query'; export function ProjectBoards({ slug }: PropsWithChildren<{ slug: string }>) { - useInitProjectSlug(slug); - const { data, isLoading, isError, error, refetch } = useBoardsPage(slug); const { activeBoard, activeBoardSlug } = useActiveBoards(data ?? []); From e817ba9c26caf7cd61884ffba8e79be23a233ffe Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Sun, 21 Jun 2026 01:30:15 +0300 Subject: [PATCH 34/37] refactor: remove orderIndex field --- .../create/ui/CreateBoardColumnForm.tsx | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx index 384631e..1effe41 100644 --- a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx +++ b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx @@ -5,7 +5,6 @@ import { useCreateBoardColumnForm } from '../model/useCreateBoardColumnForm'; import { UseCreateBoardColumnOptions } from '../model/useCreateBoardColumn'; import { ComponentProps } from 'react'; import { COLORS, DEFAULT_COLUMN_COLOR } from '../model/consts'; -import { fi } from 'zod/v4/locales'; interface CreateBoardColumnFormProps extends Omit, 'children' | 'onSubmit'> { boardSlug: string; @@ -74,25 +73,6 @@ export function CreateBoardColumnForm({ ); }} /> - ( - - Позиция колонки - - {fieldState.invalid && } - - )} - /> From 1bdd5fa387f220488c5df8273ddc876cf3fbc135 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Sun, 21 Jun 2026 03:21:36 +0300 Subject: [PATCH 35/37] refactor: restructure project boards view handling - split board navigation, header, content and view switcher into separate components - replace board-with-tasks mapper with kanban-specific data mapping and task grouping - move ProjectKanban to view data props with optimistic kanban state and query cache updates - add project board view data type and update board/column mutation hooks - adjust board creation/removal dialogs and column form return values --- src/entities/board/index.ts | 2 +- src/entities/board/model/mapper.ts | 29 ++++-- .../create/model/useCreateBoardColumnForm.ts | 5 +- .../boards/remove/ui/RemoveBoardDialog.tsx | 4 + src/pages/project/model/types.ts | 7 ++ src/pages/project/model/useUpdateBoard.ts | 20 ++++ .../project/model/useUpdateBoardColumn.ts | 21 +++++ .../project/ui/boards/ProjectBoardButton.tsx | 66 +++++++++++++ src/pages/project/ui/boards/ProjectBoards.tsx | 93 ++++--------------- .../ui/boards/ProjectBoardsContent.tsx | 29 ++++++ .../project/ui/boards/ProjectBoardsHeader.tsx | 15 +++ .../ui/boards/ProjectKanban.skeleton.tsx | 11 +++ src/pages/project/ui/boards/ProjectKanban.tsx | 75 +++++++++++---- .../project/ui/boards/SwitchBoardView.tsx | 40 ++++++++ 14 files changed, 311 insertions(+), 106 deletions(-) create mode 100644 src/pages/project/model/types.ts create mode 100644 src/pages/project/model/useUpdateBoard.ts create mode 100644 src/pages/project/model/useUpdateBoardColumn.ts create mode 100644 src/pages/project/ui/boards/ProjectBoardButton.tsx create mode 100644 src/pages/project/ui/boards/ProjectBoardsContent.tsx create mode 100644 src/pages/project/ui/boards/ProjectBoardsHeader.tsx create mode 100644 src/pages/project/ui/boards/ProjectKanban.skeleton.tsx create mode 100644 src/pages/project/ui/boards/SwitchBoardView.tsx diff --git a/src/entities/board/index.ts b/src/entities/board/index.ts index a7f3a28..2f40d78 100644 --- a/src/entities/board/index.ts +++ b/src/entities/board/index.ts @@ -3,5 +3,5 @@ export * as SBoard from './model/schemas'; export { boardFabricKeys } from './model/conts'; export { BoardHttp } from './api/http'; export { BoardQueries } from './api/queries'; -export { BoardMapper, type BoardWithTasks } from './model/mapper'; +export { BoardMapper, type KanbanBoardData } from './model/mapper'; export { BOARD_COLUMN_COLORS } from './config/colors'; diff --git a/src/entities/board/model/mapper.ts b/src/entities/board/model/mapper.ts index 5017d65..887786f 100644 --- a/src/entities/board/model/mapper.ts +++ b/src/entities/board/model/mapper.ts @@ -2,14 +2,23 @@ import type { BoardColumnResponse, BoardResponse } from './types'; // TODO: добавить таски в типы, когда они появятся в API -export type BoardWithTasks = { +type KanbanTaskStub = { + id: string; + columnId: string; +}; + +export type KanbanBoardData = { board: BoardResponse; columns: Record; tasksByColumn: Record; }; export class BoardMapper { - static toBoardWithTasks(board: BoardResponse, columnList: BoardColumnResponse[]): BoardWithTasks { + static toKanban( + board: BoardResponse, + columnList: BoardColumnResponse[], + taskList: unknown[] + ): KanbanBoardData { const sortedColumns = [...columnList].sort((a, b) => a.orderIndex - b.orderIndex); const tasksByColumn: Record = {}; const columns: Record = {}; @@ -19,13 +28,15 @@ export class BoardMapper { columns[column.id] = column; }); - // tasks?.forEach((task) => { - // if (tasksByColumn[task.columnId]) { - // tasksByColumn[task.columnId].push(task); - // } else { - // console.warn(`Task ${task.id} references unknown column ${task.columnId}`); - // } - // }); + taskList?.forEach((task) => { + const kanbanTask = task as KanbanTaskStub; + + if (tasksByColumn[kanbanTask.columnId]) { + tasksByColumn[kanbanTask.columnId].push(task); + } else { + console.warn(`Task ${kanbanTask.id} references unknown column ${kanbanTask.columnId}`); + } + }); // Object.keys(tasksByColumn).forEach((columnId) => { // tasksByColumn[columnId].sort((a, b) => a.position - b.position); diff --git a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts index 5bdeb6e..ea2f676 100644 --- a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts +++ b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts @@ -7,7 +7,6 @@ import { CreateBoardColumnFormValues } from './type'; import { setFormErrors } from 'shared/lib/utils'; import { extractValidationIssues } from 'shared/api'; import { type TBoard } from 'entities/board'; -import { useProjectStore } from 'entities/project'; type UseCreateBoardColumnFormOptions = UseCreateBoardColumnOptions & { defaultPosition?: number; @@ -18,7 +17,7 @@ export function useCreateBoardColumnForm( options: UseCreateBoardColumnFormOptions = {} ) { const { defaultPosition = 0, ...mutationOptions } = options; - const projectSlug = useProjectStore((s) => s.projectSlug!); + const form = useForm({ resolver: zodResolver(CreateBoardColumnFormSchema), defaultValues: getDefaultCreateBoardColumnValues(defaultPosition), @@ -47,8 +46,6 @@ export function useCreateBoardColumnForm( return { form, - boardSlug, - projectSlug, isPending: createBoardColumn.isPending, handleSubmit: form.handleSubmit(onSubmit), }; diff --git a/src/features/boards/remove/ui/RemoveBoardDialog.tsx b/src/features/boards/remove/ui/RemoveBoardDialog.tsx index 82261ba..1d27437 100644 --- a/src/features/boards/remove/ui/RemoveBoardDialog.tsx +++ b/src/features/boards/remove/ui/RemoveBoardDialog.tsx @@ -4,6 +4,7 @@ import { AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, @@ -26,6 +27,9 @@ export function RemoveBoardDialog({ projectSlug, boardSlug, ...props }: Props) { Удалить доску? + Отмена diff --git a/src/pages/project/model/types.ts b/src/pages/project/model/types.ts new file mode 100644 index 0000000..f85f4c3 --- /dev/null +++ b/src/pages/project/model/types.ts @@ -0,0 +1,7 @@ +import { type TBoard } from 'entities/board'; + +export type ProjectBoardViewData = { + board: TBoard.BoardResponse; + columns: TBoard.BoardColumnResponse[]; + tasks: unknown[]; // TODO: заглушка, пока не тасок; +}; diff --git a/src/pages/project/model/useUpdateBoard.ts b/src/pages/project/model/useUpdateBoard.ts new file mode 100644 index 0000000..be5ca4f --- /dev/null +++ b/src/pages/project/model/useUpdateBoard.ts @@ -0,0 +1,20 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { BoardHttp, type TBoard } from 'entities/board'; + +type UpdateBoardVariables = { + boardSlug: string; + columnId: string; + body: TBoard.UpdateBoardBody; +}; + +export type UseCreateBoardOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useUpdateBoard({ ...options }: UseCreateBoardOptions = {}) { + return useMutation({ + ...options, + mutationFn: ({ boardSlug, columnId, body }) => BoardHttp.updateBoard(boardSlug, columnId, body), + }); +} diff --git a/src/pages/project/model/useUpdateBoardColumn.ts b/src/pages/project/model/useUpdateBoardColumn.ts new file mode 100644 index 0000000..8c35483 --- /dev/null +++ b/src/pages/project/model/useUpdateBoardColumn.ts @@ -0,0 +1,21 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { BoardHttp, type TBoard } from 'entities/board'; + +type UpdateBoardColumnVariables = { + boardSlug: string; + columnId: string; + body: TBoard.UpdateBoardColumnBody; +}; + +export type UseUpdateBoardOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useUpdateBoardColumn({ ...options }: UseUpdateBoardOptions = {}) { + return useMutation({ + ...options, + mutationFn: ({ boardSlug, columnId, body }) => + BoardHttp.updateBoardColumn(boardSlug, columnId, body), + }); +} diff --git a/src/pages/project/ui/boards/ProjectBoardButton.tsx b/src/pages/project/ui/boards/ProjectBoardButton.tsx new file mode 100644 index 0000000..50e0e1a --- /dev/null +++ b/src/pages/project/ui/boards/ProjectBoardButton.tsx @@ -0,0 +1,66 @@ +import { VariantProps } from 'class-variance-authority'; +import { TBoard } from 'entities/board'; +import { RemoveBoardDialog } from 'features/boards/remove'; +import { EllipsisVertical } from 'lucide-react'; +import { useBoardStore } from 'pages/project/model/store'; +import { ComponentProps } from 'react'; +import { cn } from 'shared/lib/utils'; +import { + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenu, + buttonVariants, +} from 'shared/ui'; + +type BoardButtonProps = ComponentProps<'div'> & + VariantProps & { + board: TBoard.BoardResponse; + projectSlug: string; + }; + +export function ProjectBoardButton({ + projectSlug, + 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/ProjectBoards.tsx b/src/pages/project/ui/boards/ProjectBoards.tsx index 01dd65e..cf0c76d 100644 --- a/src/pages/project/ui/boards/ProjectBoards.tsx +++ b/src/pages/project/ui/boards/ProjectBoards.tsx @@ -1,109 +1,50 @@ 'use client'; -import { - Button, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenu, - buttonVariants, -} from 'shared/ui'; +import { Button } from 'shared/ui'; import { CreateBoardDialog } from 'features/boards/create'; -import { ComponentProps, PropsWithChildren } from 'react'; -import { BoardMapper, BoardQueries, TBoard } from 'entities/board'; -import { useBoardStore } from 'pages/project/model/store'; -import { EllipsisVertical } from 'lucide-react'; -import { ProjectKanban } from './ProjectKanban'; +import { PropsWithChildren, useState } from 'react'; +import { BoardQueries, type TBoard } from 'entities/board'; 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'; import { ProjectBoardsSkeleton } from './ProjectBoards.skeleton'; import { ProjectBoardsError } from './ProjectBoardsError'; import { useQuery } from '@tanstack/react-query'; +import { ProjectBoardsHeader } from './ProjectBoardsHeader'; +import { ProjectBoardsContent } from './ProjectBoardsContent'; +import { ProjectBoardButton } from './ProjectBoardButton'; export function ProjectBoards({ slug }: PropsWithChildren<{ slug: string }>) { const { data, isLoading, isError, error, refetch } = useBoardsPage(slug); const { activeBoard, activeBoardSlug } = useActiveBoards(data ?? []); - + const [view, setView] = useState(activeBoard?.defaultView ?? 'kanban'); const columns = useQuery({ ...BoardQueries.getBoardColumnList(activeBoardSlug!), enabled: !!activeBoardSlug, }); - const board = - activeBoard && columns.data ? BoardMapper.toBoardWithTasks(activeBoard, columns.data) : null; - if (isLoading) return ; if (isError) { return refetch()} />; } return ( -
+
{data?.map((item) => ( - + ))}
-
- {board ? : null} +
+ setView(v)} /> + {activeBoard && columns.data ? ( + + ) : null}
); } - -type BoardButtonProps = ComponentProps<'div'> & - VariantProps & { - board: TBoard.BoardResponse; - projectSlug: string; - }; - -function BoardButton({ - projectSlug, - 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/ProjectBoardsContent.tsx b/src/pages/project/ui/boards/ProjectBoardsContent.tsx new file mode 100644 index 0000000..f35e0c9 --- /dev/null +++ b/src/pages/project/ui/boards/ProjectBoardsContent.tsx @@ -0,0 +1,29 @@ +import { type TBoard } from 'entities/board'; +import React from 'react'; +import { type ProjectBoardViewData } from '../../model/types'; +import dynamic from 'next/dynamic'; +import { ProjectKanbanSkeleton } from './ProjectKanban.skeleton'; + +type ProjectBoardsContentProps = { + view: TBoard.BoardViewType; + data: ProjectBoardViewData; +}; + +const VIEW_COMPONENTS = { + kanban: dynamic(() => import('./ProjectKanban').then((mod) => mod.ProjectKanban), { + loading: () => , + }), + list: null, + calendar: null, + gantt: null, +} satisfies Record< + TBoard.BoardViewType, + React.ComponentType<{ + data: ProjectBoardViewData; + }> | null +>; + +export function ProjectBoardsContent({ view, data }: ProjectBoardsContentProps) { + const ViewComponent = VIEW_COMPONENTS[view]; + return ViewComponent ? : null; +} diff --git a/src/pages/project/ui/boards/ProjectBoardsHeader.tsx b/src/pages/project/ui/boards/ProjectBoardsHeader.tsx new file mode 100644 index 0000000..612dc4e --- /dev/null +++ b/src/pages/project/ui/boards/ProjectBoardsHeader.tsx @@ -0,0 +1,15 @@ +import { type TBoard } from 'entities/board'; +import { SwitchBoardView } from './SwitchBoardView'; + +type ProjectBoardsHeaderProps = { + currentView: TBoard.BoardViewType; + onViewChange: (view: TBoard.BoardViewType) => void; +}; + +export function ProjectBoardsHeader({ currentView, onViewChange }: ProjectBoardsHeaderProps) { + return ( +
+ +
+ ); +} diff --git a/src/pages/project/ui/boards/ProjectKanban.skeleton.tsx b/src/pages/project/ui/boards/ProjectKanban.skeleton.tsx new file mode 100644 index 0000000..01b4bdb --- /dev/null +++ b/src/pages/project/ui/boards/ProjectKanban.skeleton.tsx @@ -0,0 +1,11 @@ +import { TaskColumnSkeleton } from './TaskColumn.skeleton'; + +export function ProjectKanbanSkeleton() { + return ( +
+ + + +
+ ); +} diff --git a/src/pages/project/ui/boards/ProjectKanban.tsx b/src/pages/project/ui/boards/ProjectKanban.tsx index 53ba898..1406d32 100644 --- a/src/pages/project/ui/boards/ProjectKanban.tsx +++ b/src/pages/project/ui/boards/ProjectKanban.tsx @@ -1,42 +1,85 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Kanban, KanbanBoard, KanbanOverlay, Button } from 'shared/ui'; import { TaskColumn } from './task-column/TaskColumn'; -import { BoardWithTasks } from 'entities/board'; +import { boardFabricKeys, BoardMapper, KanbanBoardData, type TBoard } from 'entities/board'; import { TTask } from 'entities/task'; import { CreateBoardColumnDialog } from 'features/boards/column/create'; +import { useQueryClient } from '@tanstack/react-query'; +import { type ProjectBoardViewData } from '../../model/types'; interface ProjectKanbanProps { - board: BoardWithTasks; + data: ProjectBoardViewData; } -export function ProjectKanban({ board }: ProjectKanbanProps) { - const [columns, setColumns] = useState(board.tasksByColumn); +export const ProjectKanban = ({ data }: ProjectKanbanProps) => { + const { columns, tasksByColumn } = useMemo( + () => BoardMapper.toKanban(data.board, data.columns, data.tasks), + [data.board, data.columns, data.tasks] + ); + const [kanbanValue, setKanbanValue] = useState(tasksByColumn); useEffect(() => { - setColumns(board.tasksByColumn); - }, [board]); + setKanbanValue(tasksByColumn); + }, [tasksByColumn]); + + const nextColumnPosition = data.columns.length; + + const queryClient = useQueryClient(); + + const handleValueChange = async (value: KanbanBoardData['tasksByColumn']) => { + setKanbanValue(value); + queryClient.cancelQueries({ queryKey: boardFabricKeys.columns(data.board.slug) }); + + const ids = Object.keys(value); + const idsMap = new Map(ids.map((id, i) => [id, i])); + const columnIdsToUpdate: { columnId: string; orderIndex: number }[] = []; - const nextColumnPosition = Object.keys(board.columns).length; + const prevColumns = queryClient.getQueryData( + boardFabricKeys.columns(data.board.slug) + ); + + queryClient.setQueryData( + boardFabricKeys.columns(data.board.slug), + (oldColumns) => { + if (!oldColumns) return oldColumns; + + return oldColumns.map((column) => { + const orderIndex = idsMap.get(column.id); + return orderIndex !== undefined ? { ...column, orderIndex } : column; + }); + } + ); + + prevColumns?.forEach((column) => { + const orderIndex = idsMap.get(column.id); + if (orderIndex !== undefined && column.orderIndex !== orderIndex) { + columnIdsToUpdate.push({ columnId: column.id, orderIndex }); + } + }); + + // TODO: тут должны быть логика и запрос на обновление позиций колоночек. + // Если запрос выполнился с ошибкой, вывести тост и вернуть предыдущее состояние из prevColumns + }; return ( setColumns(v)} - // TODO: as TTask.Task - заглушка, пока нет тасок - getItemValue={(item) => (item as TTask.Task).id} + value={kanbanValue} + onValueChange={handleValueChange} + onMove={(event) => console.log(event)} + getItemValue={(item) => (item as TTask.Task).id} // TODO: as TTask.Task - заглушка, пока нет тасок > - {Object.entries(columns).map(([id, items]) => { - const column = board.columns[id]; + {Object.entries(kanbanValue).map(([id, items]) => { + const column = columns[id]; // TODO: as TTask.Task - заглушка, пока нет тасок return ; })}
@@ -49,4 +92,4 @@ export function ProjectKanban({ board }: ProjectKanbanProps) { ); -} +}; diff --git a/src/pages/project/ui/boards/SwitchBoardView.tsx b/src/pages/project/ui/boards/SwitchBoardView.tsx new file mode 100644 index 0000000..3de8a60 --- /dev/null +++ b/src/pages/project/ui/boards/SwitchBoardView.tsx @@ -0,0 +1,40 @@ +import { type TBoard } from 'entities/board'; +import { SquareKanban, Calendar, GanttChart, List } from 'lucide-react'; +import { type ComponentType } from 'react'; +import { Button } from 'shared/ui'; + +const VIEWS = [ + { view: 'kanban', label: 'Доска', icon: SquareKanban }, + { view: 'calendar', label: 'Календарь', icon: Calendar }, + { view: 'gantt', label: 'Гант', icon: GanttChart }, + { view: 'list', label: 'Список', icon: List }, +] satisfies { + view: TBoard.BoardViewType; + label: string; + icon: ComponentType; +}[]; + +type ProjectBoardViewsProps = { + currentView: TBoard.BoardViewType; + onViewChange: (view: TBoard.BoardViewType) => void; +}; + +export function SwitchBoardView({ onViewChange, currentView }: ProjectBoardViewsProps) { + return ( +
+ {VIEWS.map((item) => { + const isActive = currentView === item.view; + return ( + + ); + })} +
+ ); +} From ee4edde95045ba44b5bd9f216e9eef1aa6606056 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Mon, 22 Jun 2026 15:34:26 +0300 Subject: [PATCH 36/37] chore: move constants to config & fix typos --- src/entities/board/model/{conts.ts => consts.ts} | 2 ++ src/features/auth/sign-out/model/useSignOut.ts | 4 ++-- .../boards/column/create/{model => config}/consts.ts | 0 .../column/create/{model => config}/default-values.ts | 4 ++-- .../boards/column/create/model/{type.ts => types.ts} | 0 .../boards/column/create/model/useCreateBoardColumn.ts | 4 ++-- .../column/create/model/useCreateBoardColumnForm.ts | 4 ++-- .../boards/column/create/ui/CreateBoardColumnForm.tsx | 2 +- .../boards/column/remove/model/useRemoveColumn.ts | 6 +++--- .../boards/create/{model => config}/default-values.ts | 2 +- src/features/boards/create/model/{type.ts => types.ts} | 0 src/features/boards/create/model/useCreateBoard.ts | 4 ++-- src/features/boards/create/model/useCreateBoardForm.ts | 8 ++++---- src/features/boards/remove/model/useRemoveBoard.ts | 6 +++--- src/features/otp-form/index.ts | 2 +- src/features/otp-form/model/{const.ts => consts.ts} | 0 src/features/otp-form/ui/ResendCodeControl.tsx | 2 +- .../projects/archive/model/useArchiveProject.ts | 4 ++-- .../projects/archive/model/useRestoreProject.ts | 4 ++-- .../create/{model => config}/default-values.ts | 2 +- src/features/projects/create/model/useCreateProject.ts | 4 ++-- .../projects/create/model/useCreateProjectForm.ts | 8 ++++---- src/features/projects/remove/model/useRemoveProject.ts | 4 ++-- src/features/projects/share/model/useShareProject.ts | 4 ++-- src/features/teams/create/model/useCreateTeam.ts | 4 ++-- src/features/teams/invite/model/useInviteTeamMember.ts | 10 ++++++---- src/features/teams/remove/model/useRemoveTeam.ts | 4 ++-- src/pages/invitations/api/useAcceptTeamInvitation.ts | 4 ++-- src/pages/profile/api/useUpdateNotifications.ts | 4 ++-- src/pages/profile/api/useUpdateProfile.ts | 4 ++-- src/pages/project/api/useUpdateProject.ts | 4 ++-- src/pages/project/model/store.ts | 4 ++-- src/pages/project/ui/boards/ProjectBoardsContent.tsx | 6 +++--- src/pages/team/api/useRemoveMember.ts | 4 ++-- src/pages/team/api/useRemoveMemberInvitation.ts | 4 ++-- src/pages/team/api/useUpdateInvitation.ts | 4 ++-- src/pages/team/api/useUpdateMember.ts | 4 ++-- src/pages/team/api/useUpdateTeam.ts | 4 ++-- 38 files changed, 74 insertions(+), 70 deletions(-) rename src/entities/board/model/{conts.ts => consts.ts} (74%) rename src/features/boards/column/create/{model => config}/consts.ts (100%) rename src/features/boards/column/create/{model => config}/default-values.ts (61%) rename src/features/boards/column/create/model/{type.ts => types.ts} (100%) rename src/features/boards/create/{model => config}/default-values.ts (63%) rename src/features/boards/create/model/{type.ts => types.ts} (100%) rename src/features/otp-form/model/{const.ts => consts.ts} (100%) rename src/features/projects/create/{model => config}/default-values.ts (88%) diff --git a/src/entities/board/model/conts.ts b/src/entities/board/model/consts.ts similarity index 74% rename from src/entities/board/model/conts.ts rename to src/entities/board/model/consts.ts index 0b80b05..901922c 100644 --- a/src/entities/board/model/conts.ts +++ b/src/entities/board/model/consts.ts @@ -6,4 +6,6 @@ export const boardFabricKeys = createEntityKeys('board', { column: (slug: string, id: string) => ['boards', slug, 'columns', id], views: (slug: string) => ['boards', slug, 'views'], view: (slug: string, id: string) => ['boards', slug, 'views', id], + tasks: (boardSlug: string) => ['board', boardSlug, 'tasks'], + task: (columnId: string, taskId: string) => ['columns', columnId, 'tasks', taskId], }); diff --git a/src/features/auth/sign-out/model/useSignOut.ts b/src/features/auth/sign-out/model/useSignOut.ts index 0b13baa..ab1dc9c 100644 --- a/src/features/auth/sign-out/model/useSignOut.ts +++ b/src/features/auth/sign-out/model/useSignOut.ts @@ -16,8 +16,8 @@ export function useSignOut({ onSuccess, ...rest }: UseSignOutOptions = {}) { return useMutation, DefaultError, void>({ ...rest, mutationFn: AuthHttp.signout, - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); await context.client.cancelQueries(); AccessToken.clear(); context.client.clear(); diff --git a/src/features/boards/column/create/model/consts.ts b/src/features/boards/column/create/config/consts.ts similarity index 100% rename from src/features/boards/column/create/model/consts.ts rename to src/features/boards/column/create/config/consts.ts diff --git a/src/features/boards/column/create/model/default-values.ts b/src/features/boards/column/create/config/default-values.ts similarity index 61% rename from src/features/boards/column/create/model/default-values.ts rename to src/features/boards/column/create/config/default-values.ts index 1fee8c9..e645aee 100644 --- a/src/features/boards/column/create/model/default-values.ts +++ b/src/features/boards/column/create/config/default-values.ts @@ -1,5 +1,5 @@ -import { DEFAULT_COLUMN_COLOR } from './consts'; -import { CreateBoardColumnFormValues } from './type'; +import { DEFAULT_COLUMN_COLOR } from '../config/consts'; +import { CreateBoardColumnFormValues } from '../model/types'; export function getDefaultCreateBoardColumnValues(position = 0): CreateBoardColumnFormValues { return { diff --git a/src/features/boards/column/create/model/type.ts b/src/features/boards/column/create/model/types.ts similarity index 100% rename from src/features/boards/column/create/model/type.ts rename to src/features/boards/column/create/model/types.ts diff --git a/src/features/boards/column/create/model/useCreateBoardColumn.ts b/src/features/boards/column/create/model/useCreateBoardColumn.ts index e16ac22..5b83f68 100644 --- a/src/features/boards/column/create/model/useCreateBoardColumn.ts +++ b/src/features/boards/column/create/model/useCreateBoardColumn.ts @@ -16,8 +16,8 @@ export function useCreateBoardColumn({ onSuccess, ...rest }: UseCreateBoardColum return useMutation({ ...rest, mutationFn: ({ boardSlug, body }) => BoardHttp.createBoardColumn(boardSlug, body), - onSuccess: async (res, variables, _r, context) => { - onSuccess?.(res, variables, _r, context); + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); toast.success(res.message ?? 'Колонка создана'); await context.client.invalidateQueries({ queryKey: boardFabricKeys.columns(variables.boardSlug), diff --git a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts index ea2f676..1a48587 100644 --- a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts +++ b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts @@ -1,11 +1,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { getDefaultCreateBoardColumnValues } from './default-values'; +import { getDefaultCreateBoardColumnValues } from '../config/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 { type CreateBoardColumnFormValues } from './types'; import { type TBoard } from 'entities/board'; type UseCreateBoardColumnFormOptions = UseCreateBoardColumnOptions & { diff --git a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx index 1effe41..cde3108 100644 --- a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx +++ b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx @@ -4,7 +4,7 @@ import { ColorPicker, Field, FieldError, FieldGroup, FieldLabel, Input } from 's import { useCreateBoardColumnForm } from '../model/useCreateBoardColumnForm'; import { UseCreateBoardColumnOptions } from '../model/useCreateBoardColumn'; import { ComponentProps } from 'react'; -import { COLORS, DEFAULT_COLUMN_COLOR } from '../model/consts'; +import { COLORS, DEFAULT_COLUMN_COLOR } from '../config/consts'; interface CreateBoardColumnFormProps extends Omit, 'children' | 'onSubmit'> { boardSlug: string; diff --git a/src/features/boards/column/remove/model/useRemoveColumn.ts b/src/features/boards/column/remove/model/useRemoveColumn.ts index e03d429..d0fa7fc 100644 --- a/src/features/boards/column/remove/model/useRemoveColumn.ts +++ b/src/features/boards/column/remove/model/useRemoveColumn.ts @@ -20,9 +20,9 @@ export function useRemoveColumn({ onSuccess, onSettled, ...rest }: UseDeleteColu 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.boardSlug) }); + onSettled: async (d, e, v, m, context) => { + onSettled?.(d, e, v, m, context); + context.client.invalidateQueries({ queryKey: boardFabricKeys.columns(v.boardSlug) }); }, }); } diff --git a/src/features/boards/create/model/default-values.ts b/src/features/boards/create/config/default-values.ts similarity index 63% rename from src/features/boards/create/model/default-values.ts rename to src/features/boards/create/config/default-values.ts index cdd5a57..6744c25 100644 --- a/src/features/boards/create/model/default-values.ts +++ b/src/features/boards/create/config/default-values.ts @@ -1,4 +1,4 @@ -import { CreateBoardFormValues } from './type'; +import { CreateBoardFormValues } from '../model/types'; export function getDefaultCreateBoardValues(): CreateBoardFormValues { return { title: '' }; diff --git a/src/features/boards/create/model/type.ts b/src/features/boards/create/model/types.ts similarity index 100% rename from src/features/boards/create/model/type.ts rename to src/features/boards/create/model/types.ts diff --git a/src/features/boards/create/model/useCreateBoard.ts b/src/features/boards/create/model/useCreateBoard.ts index 7d5be21..b1489ed 100644 --- a/src/features/boards/create/model/useCreateBoard.ts +++ b/src/features/boards/create/model/useCreateBoard.ts @@ -16,8 +16,8 @@ export function useCreateBoard({ onSuccess, ...rest }: UseCreateBoardOptions = { return useMutation({ ...rest, mutationFn: ({ projectSlug, body }) => BoardHttp.createBoard(projectSlug, body), - onSuccess: async (res, variables, _r, context) => { - onSuccess?.(res, variables, _r, context); + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); toast.success(res.message ?? 'Доска создана'); await context.client.invalidateQueries({ diff --git a/src/features/boards/create/model/useCreateBoardForm.ts b/src/features/boards/create/model/useCreateBoardForm.ts index cd1f42e..7d00602 100644 --- a/src/features/boards/create/model/useCreateBoardForm.ts +++ b/src/features/boards/create/model/useCreateBoardForm.ts @@ -1,13 +1,13 @@ -import { zodResolver } from '@hookform/resolvers/zod'; +import { useParams } from 'next/navigation'; import { useForm } from 'react-hook-form'; -import { getDefaultCreateBoardValues } from './default-values'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { getDefaultCreateBoardValues } from '../config/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 { type TBoard } from 'entities/board'; -import { useParams } from 'next/navigation'; +import { type CreateBoardFormValues } from './types'; export function useCreateBoardForm(options: UseCreateBoardOptions = {}) { const params = useParams<{ slug: string }>(); diff --git a/src/features/boards/remove/model/useRemoveBoard.ts b/src/features/boards/remove/model/useRemoveBoard.ts index 27a314b..b5e7220 100644 --- a/src/features/boards/remove/model/useRemoveBoard.ts +++ b/src/features/boards/remove/model/useRemoveBoard.ts @@ -20,9 +20,9 @@ export function useRemoveBoard({ onSuccess, onSettled, ...rest }: UseDeleteBoard 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.projectSlug) }); + onSettled: async (d, e, v, m, context) => { + onSettled?.(d, e, v, m, context); + context.client.invalidateQueries({ queryKey: boardFabricKeys.list(v.projectSlug) }); }, }); } diff --git a/src/features/otp-form/index.ts b/src/features/otp-form/index.ts index a2daa32..0a11845 100644 --- a/src/features/otp-form/index.ts +++ b/src/features/otp-form/index.ts @@ -1,4 +1,4 @@ export { OTPForm } from './ui/OTPForm'; export { OTPFormLoader } from './ui/OTPFormLoader'; export { ResendCodeControl } from './ui/ResendCodeControl'; -export { DRAFT_TTL_MS, RESEND_CODE_DELAY_MS } from './model/const'; +export { DRAFT_TTL_MS, RESEND_CODE_DELAY_MS } from './model/consts'; diff --git a/src/features/otp-form/model/const.ts b/src/features/otp-form/model/consts.ts similarity index 100% rename from src/features/otp-form/model/const.ts rename to src/features/otp-form/model/consts.ts diff --git a/src/features/otp-form/ui/ResendCodeControl.tsx b/src/features/otp-form/ui/ResendCodeControl.tsx index a5b04fb..7e400c6 100644 --- a/src/features/otp-form/ui/ResendCodeControl.tsx +++ b/src/features/otp-form/ui/ResendCodeControl.tsx @@ -4,7 +4,7 @@ import { ComponentProps } from 'react'; import { useTimer } from 'shared/lib/hooks'; import { classNames, formatTime } from 'shared/lib/utils'; import { Button } from 'shared/ui'; -import { RESEND_CODE_DELAY_MS } from '../model/const'; +import { RESEND_CODE_DELAY_MS } from '../model/consts'; import { useResendCode, UseResendOptions } from '../model/useResend'; import { TAuth } from 'entities/auth'; import { toast } from 'sonner'; diff --git a/src/features/projects/archive/model/useArchiveProject.ts b/src/features/projects/archive/model/useArchiveProject.ts index aefa6fc..08e9e1c 100644 --- a/src/features/projects/archive/model/useArchiveProject.ts +++ b/src/features/projects/archive/model/useArchiveProject.ts @@ -16,8 +16,8 @@ export function useArchiveProject({ onSuccess, ...rest }: UseArchiveProjectOptio return useMutation({ ...rest, mutationFn: ({ teamId, slug }) => ProjectHttp.archiveProject(teamId, slug), - onSuccess: async (res, variables, _r, context) => { - onSuccess?.(res, variables, _r, context); + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); toast.success(res.message ?? 'Проект архивирован'); await Promise.all([ diff --git a/src/features/projects/archive/model/useRestoreProject.ts b/src/features/projects/archive/model/useRestoreProject.ts index 3c921e2..3e25a2d 100644 --- a/src/features/projects/archive/model/useRestoreProject.ts +++ b/src/features/projects/archive/model/useRestoreProject.ts @@ -16,8 +16,8 @@ export function useRestoreProject({ onSuccess, ...rest }: UseRestoreProjectOptio return useMutation({ ...rest, mutationFn: ({ teamId, slug }) => ProjectHttp.updateProject(teamId, slug, { status: 'active' }), - onSuccess: async (res, variables, _r, context) => { - onSuccess?.(res, variables, _r, context); + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); toast.success(res.message ?? 'Проект восстановлен'); await Promise.all([ diff --git a/src/features/projects/create/model/default-values.ts b/src/features/projects/create/config/default-values.ts similarity index 88% rename from src/features/projects/create/model/default-values.ts rename to src/features/projects/create/config/default-values.ts index bd7e585..a0c553b 100644 --- a/src/features/projects/create/model/default-values.ts +++ b/src/features/projects/create/config/default-values.ts @@ -1,5 +1,5 @@ import { PROJECT_COLORS, PROJECT_ICONS } from 'entities/project'; -import type { CreateProjectFormValues } from './types'; +import type { CreateProjectFormValues } from '../model/types'; function pickRandom(items: readonly T[]): T { return items[Math.floor(Math.random() * items.length)]!; diff --git a/src/features/projects/create/model/useCreateProject.ts b/src/features/projects/create/model/useCreateProject.ts index 94cda91..c054eee 100644 --- a/src/features/projects/create/model/useCreateProject.ts +++ b/src/features/projects/create/model/useCreateProject.ts @@ -16,8 +16,8 @@ export function useCreateProject({ onSuccess, ...rest }: UseCreateProjectOptions return useMutation({ ...rest, mutationFn: ({ teamId, body }) => ProjectHttp.createProject(teamId, body), - onSuccess: async (res, variables, _r, context) => { - onSuccess?.(res, variables, _r, context); + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); toast.success(res.message ?? 'Проект создан'); await context.client.invalidateQueries({ diff --git a/src/features/projects/create/model/useCreateProjectForm.ts b/src/features/projects/create/model/useCreateProjectForm.ts index b1048b1..8ae08c6 100644 --- a/src/features/projects/create/model/useCreateProjectForm.ts +++ b/src/features/projects/create/model/useCreateProjectForm.ts @@ -1,13 +1,13 @@ +import { useForm } from 'react-hook-form'; import { useCheckSlug, validateProjectSlugAsync, type TProject } from 'entities/project'; import { useTeamStore } from 'entities/team'; -import { useForm } from 'react-hook-form'; import { extractValidationIssues } from 'shared/api'; import { setFormErrors } from 'shared/lib/utils'; -import { getDefaultCreateProjectValues } from './default-values'; +import { useZodValidationWithAsyncCheck } from 'shared/lib/hooks'; +import { getDefaultCreateProjectValues } from '../config/default-values'; import { CreateProjectFormSchema } from './schemas'; +import { type UseCreateProjectOptions, useCreateProject } from './useCreateProject'; import type { CreateProjectFormValues } from './types'; -import { useCreateProject, type UseCreateProjectOptions } from './useCreateProject'; -import { useZodValidationWithAsyncCheck } from 'shared/lib/hooks'; export function useCreateProjectForm(options: UseCreateProjectOptions = {}) { const teamId = useTeamStore.use.teamId(); diff --git a/src/features/projects/remove/model/useRemoveProject.ts b/src/features/projects/remove/model/useRemoveProject.ts index 0181d27..7f6178a 100644 --- a/src/features/projects/remove/model/useRemoveProject.ts +++ b/src/features/projects/remove/model/useRemoveProject.ts @@ -21,8 +21,8 @@ export function useRemoveProject({ onSuccess, ...rest }: UseRemoveProjectOptions return useMutation({ ...rest, mutationFn: ({ teamId, slug }) => ProjectHttp.removeProject(teamId, slug), - onSuccess: async (res, variables, _r, context) => { - onSuccess?.(res, variables, _r, context); + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); if (pathname !== routes.team.projects()) { router.replace(routes.team.projects()); diff --git a/src/features/projects/share/model/useShareProject.ts b/src/features/projects/share/model/useShareProject.ts index 123b61c..7257ba8 100644 --- a/src/features/projects/share/model/useShareProject.ts +++ b/src/features/projects/share/model/useShareProject.ts @@ -17,8 +17,8 @@ export function useShareProject({ onSuccess, ...rest }: UseShareProjectOptions = return useMutation({ ...rest, mutationFn: ({ teamId, slug, body = {} }) => ProjectHttp.createShareToken(teamId, slug, body), - onSuccess: async (res, variables, _r, context) => { - onSuccess?.(res, variables, _r, context); + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); toast.success(res.message ?? 'Ссылка для доступа создана'); await context.client.invalidateQueries({ diff --git a/src/features/teams/create/model/useCreateTeam.ts b/src/features/teams/create/model/useCreateTeam.ts index ca80016..2c5f628 100644 --- a/src/features/teams/create/model/useCreateTeam.ts +++ b/src/features/teams/create/model/useCreateTeam.ts @@ -12,8 +12,8 @@ export function useCreateTeam({ onSuccess, ...rest }: UseCreateTeamOptions = {}) return useMutation({ ...rest, mutationFn: TeamHttp.createTeam, - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); toast.success(res.message ?? 'Команда создана'); await context.client.invalidateQueries({ queryKey: userFabricKeys.myTeams() }); }, diff --git a/src/features/teams/invite/model/useInviteTeamMember.ts b/src/features/teams/invite/model/useInviteTeamMember.ts index fae58e7..7c2ee6a 100644 --- a/src/features/teams/invite/model/useInviteTeamMember.ts +++ b/src/features/teams/invite/model/useInviteTeamMember.ts @@ -13,12 +13,14 @@ export function useInviteTeamMember({ onSuccess, ...rest }: UseInviteTeamMemberO return useMutation({ ...rest, mutationFn: ({ teamId, body }) => TeamHttp.inviteMember(teamId, body), - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); toast.success(res.message ?? 'Приглашение отправлено'); - if (_v.teamId) { - await context.client.invalidateQueries({ queryKey: teamFabricKeys.invitations(_v.teamId) }); + if (variables.teamId) { + await context.client.invalidateQueries({ + queryKey: teamFabricKeys.invitations(variables.teamId), + }); } }, }); diff --git a/src/features/teams/remove/model/useRemoveTeam.ts b/src/features/teams/remove/model/useRemoveTeam.ts index 453b01d..90a1c58 100644 --- a/src/features/teams/remove/model/useRemoveTeam.ts +++ b/src/features/teams/remove/model/useRemoveTeam.ts @@ -16,8 +16,8 @@ export function useRemoveTeam({ onSuccess, onSettled, ...rest }: UseDeleteTeamOp onSuccess?.(res, ...args); toast.success(res.message ?? 'Команда удалена'); }, - onSettled: async (_d, _e, _v, _m, context) => { - onSettled?.(_d, _e, _v, _m, context); + onSettled: async (d, e, v, m, context) => { + onSettled?.(d, e, v, m, context); context.client.invalidateQueries({ queryKey: userFabricKeys.myTeams() }); }, }); diff --git a/src/pages/invitations/api/useAcceptTeamInvitation.ts b/src/pages/invitations/api/useAcceptTeamInvitation.ts index 5e07160..7ae7cb6 100644 --- a/src/pages/invitations/api/useAcceptTeamInvitation.ts +++ b/src/pages/invitations/api/useAcceptTeamInvitation.ts @@ -18,8 +18,8 @@ export function useAcceptTeamInvitation(options: UseAcceptTeamInvitationOptions onSuccess?.(res, ...args); toast.success(res.message ?? 'Приглашение принято'); }, - onSettled: async (_d, _e, _v, _m, context) => { - onSettled?.(_d, _e, _v, _m, context); + onSettled: async (d, e, v, m, context) => { + onSettled?.(d, e, v, m, context); await Promise.all([ context.client.invalidateQueries({ queryKey: userFabricKeys.myTeams() }), context.client.invalidateQueries({ queryKey: userFabricKeys.myInvitations() }), diff --git a/src/pages/profile/api/useUpdateNotifications.ts b/src/pages/profile/api/useUpdateNotifications.ts index 63a7554..9510c0e 100644 --- a/src/pages/profile/api/useUpdateNotifications.ts +++ b/src/pages/profile/api/useUpdateNotifications.ts @@ -27,8 +27,8 @@ export function useUpdateNotifications({ onSuccess?.(...args); toast.success('Настройки уведомлений обновлены'); }, - onSettled: async (_d, _e, _v, _m, context) => { - onSettled?.(_d, _e, _v, _m, context); + onSettled: async (d, e, v, m, context) => { + onSettled?.(d, e, v, m, context); context.client.invalidateQueries({ queryKey: userFabricKeys.me() }); }, }); diff --git a/src/pages/profile/api/useUpdateProfile.ts b/src/pages/profile/api/useUpdateProfile.ts index 7ae5a07..d55623f 100644 --- a/src/pages/profile/api/useUpdateProfile.ts +++ b/src/pages/profile/api/useUpdateProfile.ts @@ -15,8 +15,8 @@ export function useUpdateProfile({ onSuccess, onSettled, ...rest }: UseUpdatePro onSuccess?.(res, ...args); toast.success(res.message ?? 'Профиль успешно обновлен'); }, - onSettled: async (_d, _e, _v, _m, context) => { - onSettled?.(_d, _e, _v, _m, context); + onSettled: async (d, e, v, m, context) => { + onSettled?.(d, e, v, m, context); context.client.invalidateQueries({ queryKey: userFabricKeys.me() }); }, }); diff --git a/src/pages/project/api/useUpdateProject.ts b/src/pages/project/api/useUpdateProject.ts index 6e63b13..e706973 100644 --- a/src/pages/project/api/useUpdateProject.ts +++ b/src/pages/project/api/useUpdateProject.ts @@ -22,8 +22,8 @@ export function useUpdateProject({ onSuccess, ...rest }: UseUpdateProjectProps = } return ProjectHttp.updateProject(teamId, slug, data); }, - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); toast.success(res.message ?? 'Проект обновлён'); if (teamId && slug) { diff --git a/src/pages/project/model/store.ts b/src/pages/project/model/store.ts index 938e6db..e63042f 100644 --- a/src/pages/project/model/store.ts +++ b/src/pages/project/model/store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -interface BordStore { +interface BoardStore { activeBoardId: string | null; activeBoardSlug: string | null; activeColumnId: string | null; @@ -8,7 +8,7 @@ interface BordStore { setColumnId: (id: string | null) => void; } -export const useBoardStore = create((set) => ({ +export const useBoardStore = create((set) => ({ activeBoardId: null, activeColumnId: null, activeBoardSlug: null, diff --git a/src/pages/project/ui/boards/ProjectBoardsContent.tsx b/src/pages/project/ui/boards/ProjectBoardsContent.tsx index f35e0c9..efc6b5d 100644 --- a/src/pages/project/ui/boards/ProjectBoardsContent.tsx +++ b/src/pages/project/ui/boards/ProjectBoardsContent.tsx @@ -1,8 +1,8 @@ -import { type TBoard } from 'entities/board'; -import React from 'react'; -import { type ProjectBoardViewData } from '../../model/types'; import dynamic from 'next/dynamic'; +import React from 'react'; import { ProjectKanbanSkeleton } from './ProjectKanban.skeleton'; +import { type TBoard } from 'entities/board'; +import { type ProjectBoardViewData } from '../../model/types'; type ProjectBoardsContentProps = { view: TBoard.BoardViewType; diff --git a/src/pages/team/api/useRemoveMember.ts b/src/pages/team/api/useRemoveMember.ts index de324a2..1d155f9 100644 --- a/src/pages/team/api/useRemoveMember.ts +++ b/src/pages/team/api/useRemoveMember.ts @@ -18,8 +18,8 @@ export function useRemoveMember({ onSuccess, ...rest }: UseRemoveMemberOptions = } return TeamHttp.removeMember(teamId, userId); }, - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); toast.success(res.message ?? 'Участник удалён из команды'); if (teamId) { diff --git a/src/pages/team/api/useRemoveMemberInvitation.ts b/src/pages/team/api/useRemoveMemberInvitation.ts index ed9ee8b..64ce1ec 100644 --- a/src/pages/team/api/useRemoveMemberInvitation.ts +++ b/src/pages/team/api/useRemoveMemberInvitation.ts @@ -21,8 +21,8 @@ export function useRemoveMemberInvitation({ } return TeamHttp.removeInvitation(teamId, code); }, - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); toast.success(res.message ?? 'Приглашение отозвано'); if (teamId) { diff --git a/src/pages/team/api/useUpdateInvitation.ts b/src/pages/team/api/useUpdateInvitation.ts index 6139666..703d9b2 100644 --- a/src/pages/team/api/useUpdateInvitation.ts +++ b/src/pages/team/api/useUpdateInvitation.ts @@ -20,8 +20,8 @@ export function useUpdateInvitation({ onSuccess, ...rest }: UseUpdateInvitationO } return TeamHttp.updateInvitation(teamId, code, data); }, - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); toast.success('Роль в приглашении обновлена'); if (teamId) { diff --git a/src/pages/team/api/useUpdateMember.ts b/src/pages/team/api/useUpdateMember.ts index feda5bc..d2eb59a 100644 --- a/src/pages/team/api/useUpdateMember.ts +++ b/src/pages/team/api/useUpdateMember.ts @@ -20,8 +20,8 @@ export function useUpdateMember({ onSuccess, ...rest }: UseUpdateMemberOptions = } return TeamHttp.updateMember(teamId, userId, data); }, - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); toast.success(res.message ?? 'Данные участника обновлены'); if (teamId) { diff --git a/src/pages/team/api/useUpdateTeam.ts b/src/pages/team/api/useUpdateTeam.ts index e6d8cd6..bab1fac 100644 --- a/src/pages/team/api/useUpdateTeam.ts +++ b/src/pages/team/api/useUpdateTeam.ts @@ -19,8 +19,8 @@ export function useUpdateTeam({ onSuccess, ...rest }: UseUpdateTeamProps = {}) { } return TeamHttp.updateTeam(teamId, data); }, - onSuccess: async (res, v, _r, context) => { - onSuccess?.(res, v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); toast.success(res.message ?? 'Данные команды обновлены'); await Promise.all([ From 2a249274278cb8aba720b4576ed1f36b29ba309d Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Mon, 22 Jun 2026 16:14:29 +0300 Subject: [PATCH 37/37] refactor: fix PR issues --- src/entities/board/api/queries.ts | 2 +- src/entities/board/index.ts | 3 ++- src/{pages/project => entities/board}/model/store.ts | 0 src/features/task/create/{lib => model}/useClickOutside.ts | 0 src/features/task/create/ui/CreateTaskField.tsx | 2 +- src/pages/project/model/useActiveBoard.ts | 3 +-- src/pages/project/ui/boards/ProjectBoardButton.tsx | 3 +-- src/pages/project/ui/boards/task-column/TaskColumn.tsx | 6 ++---- 8 files changed, 8 insertions(+), 11 deletions(-) rename src/{pages/project => entities/board}/model/store.ts (100%) rename src/features/task/create/{lib => model}/useClickOutside.ts (100%) diff --git a/src/entities/board/api/queries.ts b/src/entities/board/api/queries.ts index ba76f97..e8e4547 100644 --- a/src/entities/board/api/queries.ts +++ b/src/entities/board/api/queries.ts @@ -1,5 +1,5 @@ import { queryOptions } from '@tanstack/react-query'; -import { boardFabricKeys } from '../model/conts'; +import { boardFabricKeys } from '../model/consts'; import { BoardHttp } from './http'; export class BoardQueries { diff --git a/src/entities/board/index.ts b/src/entities/board/index.ts index 2f40d78..a04fb0b 100644 --- a/src/entities/board/index.ts +++ b/src/entities/board/index.ts @@ -1,7 +1,8 @@ export type * as TBoard from './model/types'; export * as SBoard from './model/schemas'; -export { boardFabricKeys } from './model/conts'; +export { boardFabricKeys } from './model/consts'; export { BoardHttp } from './api/http'; export { BoardQueries } from './api/queries'; export { BoardMapper, type KanbanBoardData } from './model/mapper'; export { BOARD_COLUMN_COLORS } from './config/colors'; +export { useBoardStore } from './model/store'; diff --git a/src/pages/project/model/store.ts b/src/entities/board/model/store.ts similarity index 100% rename from src/pages/project/model/store.ts rename to src/entities/board/model/store.ts diff --git a/src/features/task/create/lib/useClickOutside.ts b/src/features/task/create/model/useClickOutside.ts similarity index 100% rename from src/features/task/create/lib/useClickOutside.ts rename to src/features/task/create/model/useClickOutside.ts diff --git a/src/features/task/create/ui/CreateTaskField.tsx b/src/features/task/create/ui/CreateTaskField.tsx index 4388674..c81793f 100644 --- a/src/features/task/create/ui/CreateTaskField.tsx +++ b/src/features/task/create/ui/CreateTaskField.tsx @@ -2,7 +2,7 @@ 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 { useClickOutside } from '../model/useClickOutside'; import { useCreateTask } from '../model/useCreateTask'; import { TTask } from 'entities/task'; diff --git a/src/pages/project/model/useActiveBoard.ts b/src/pages/project/model/useActiveBoard.ts index 4161db1..379f9af 100644 --- a/src/pages/project/model/useActiveBoard.ts +++ b/src/pages/project/model/useActiveBoard.ts @@ -1,6 +1,5 @@ import { useEffect } from 'react'; -import { useBoardStore } from './store'; -import { TBoard } from 'entities/board'; +import { type TBoard, useBoardStore } from 'entities/board'; export const useActiveBoards = (boards: TBoard.BoardResponse[]) => { const activeBoardId = useBoardStore((s) => s.activeBoardId); diff --git a/src/pages/project/ui/boards/ProjectBoardButton.tsx b/src/pages/project/ui/boards/ProjectBoardButton.tsx index 50e0e1a..fb351b9 100644 --- a/src/pages/project/ui/boards/ProjectBoardButton.tsx +++ b/src/pages/project/ui/boards/ProjectBoardButton.tsx @@ -1,8 +1,7 @@ import { VariantProps } from 'class-variance-authority'; -import { TBoard } from 'entities/board'; +import { type TBoard, useBoardStore } from 'entities/board'; import { RemoveBoardDialog } from 'features/boards/remove'; import { EllipsisVertical } from 'lucide-react'; -import { useBoardStore } from 'pages/project/model/store'; import { ComponentProps } from 'react'; import { cn } from 'shared/lib/utils'; import { diff --git a/src/pages/project/ui/boards/task-column/TaskColumn.tsx b/src/pages/project/ui/boards/task-column/TaskColumn.tsx index 92fa27a..ef9dc83 100644 --- a/src/pages/project/ui/boards/task-column/TaskColumn.tsx +++ b/src/pages/project/ui/boards/task-column/TaskColumn.tsx @@ -2,8 +2,7 @@ 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 { type TBoard, useBoardStore } from 'entities/board'; import { ComponentProps } from 'react'; import { TTask } from 'entities/task'; import { TaskColumnHeader, TaskColumnHeaderProps } from './TaskColumnHeader'; @@ -24,7 +23,6 @@ export function TaskColumn({ }: TaskColumnProps) { const columnId = value; const boardSlug = useBoardStore((s) => s.activeBoardSlug!); - const boardId = useBoardStore((s) => s.activeBoardId!); const headerColumnData: TaskColumnHeaderProps = { ...column, @@ -41,7 +39,7 @@ export function TaskColumn({ - + {tasks.map((task) => ( ))}