From c88626d26b786c2749ea08f22b62414205c13562 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:41:50 +0100 Subject: [PATCH] feat(todo): add multiple kanban boards with task editing features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add multiple boards support with board navigation (pill-style) - Add global board for all tasks - Board CRUD operations with color/icon customization - Task detail modal: click on card opens full edit modal - Inline title editing: double-click on title for quick edit - Right-click context menu: edit, complete, delete actions - Responsive board navigation (top on desktop, bottom on mobile) - Remove flip animations for cleaner board switching 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/todo/apps/backend/src/db/schema/index.ts | 1 + .../src/db/schema/kanban-boards.schema.ts | 32 + .../src/db/schema/kanban-columns.schema.ts | 10 +- .../src/kanban/dto/create-board.dto.ts | 21 + .../src/kanban/dto/create-column.dto.ts | 7 +- .../todo/apps/backend/src/kanban/dto/index.ts | 8 + .../src/kanban/dto/reorder-boards.dto.ts | 7 + .../src/kanban/dto/update-board.dto.ts | 18 + .../backend/src/kanban/kanban.controller.ts | 75 ++- .../apps/backend/src/kanban/kanban.service.ts | 229 ++++++- apps/todo/apps/web/src/lib/api/kanban.ts | 96 ++- .../components/kanban/AddColumnButton.svelte | 139 +++- .../components/kanban/BoardNavigation.svelte | 243 +++++++ .../lib/components/kanban/KanbanBoard.svelte | 77 ++- .../lib/components/kanban/KanbanColumn.svelte | 93 ++- .../kanban/KanbanColumnHeader.svelte | 132 +++- .../components/kanban/KanbanFilters.svelte | 379 +++++++---- .../components/kanban/KanbanTaskCard.svelte | 584 +++++++++++++--- .../kanban/QuickAddTaskInline.svelte | 69 +- .../web/src/lib/components/kanban/index.ts | 1 + .../apps/web/src/lib/stores/kanban.svelte.ts | 204 +++++- .../web/src/routes/(app)/kanban/+page.svelte | 627 +++++++++++++++++- apps/todo/packages/shared/src/types/kanban.ts | 36 +- 23 files changed, 2678 insertions(+), 410 deletions(-) create mode 100644 apps/todo/apps/backend/src/db/schema/kanban-boards.schema.ts create mode 100644 apps/todo/apps/backend/src/kanban/dto/create-board.dto.ts create mode 100644 apps/todo/apps/backend/src/kanban/dto/reorder-boards.dto.ts create mode 100644 apps/todo/apps/backend/src/kanban/dto/update-board.dto.ts create mode 100644 apps/todo/apps/web/src/lib/components/kanban/BoardNavigation.svelte diff --git a/apps/todo/apps/backend/src/db/schema/index.ts b/apps/todo/apps/backend/src/db/schema/index.ts index d365c634d..93670d553 100644 --- a/apps/todo/apps/backend/src/db/schema/index.ts +++ b/apps/todo/apps/backend/src/db/schema/index.ts @@ -1,4 +1,5 @@ export * from './projects.schema'; +export * from './kanban-boards.schema'; export * from './kanban-columns.schema'; export * from './tasks.schema'; export * from './labels.schema'; diff --git a/apps/todo/apps/backend/src/db/schema/kanban-boards.schema.ts b/apps/todo/apps/backend/src/db/schema/kanban-boards.schema.ts new file mode 100644 index 000000000..1245668e8 --- /dev/null +++ b/apps/todo/apps/backend/src/db/schema/kanban-boards.schema.ts @@ -0,0 +1,32 @@ +import { pgTable, uuid, timestamp, varchar, boolean, integer, index } from 'drizzle-orm/pg-core'; +import { projects } from './projects.schema'; + +export const kanbanBoards = pgTable( + 'kanban_boards', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }), + + // Board properties + name: varchar('name', { length: 100 }).notNull(), + color: varchar('color', { length: 7 }).default('#8b5cf6'), + icon: varchar('icon', { length: 50 }), + order: integer('order').default(0).notNull(), + + // Special flags + isGlobal: boolean('is_global').default(false), + + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + userIdx: index('kanban_boards_user_idx').on(table.userId), + projectIdx: index('kanban_boards_project_idx').on(table.projectId), + orderIdx: index('kanban_boards_order_idx').on(table.userId, table.order), + globalIdx: index('kanban_boards_global_idx').on(table.userId, table.isGlobal), + }) +); + +export type KanbanBoard = typeof kanbanBoards.$inferSelect; +export type NewKanbanBoard = typeof kanbanBoards.$inferInsert; diff --git a/apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts b/apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts index f10ab3321..3764e4872 100644 --- a/apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts +++ b/apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts @@ -1,5 +1,5 @@ import { pgTable, uuid, timestamp, varchar, boolean, integer, index } from 'drizzle-orm/pg-core'; -import { projects } from './projects.schema'; +import { kanbanBoards } from './kanban-boards.schema'; // Define locally to avoid circular dependency with tasks.schema export type KanbanTaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; @@ -9,7 +9,9 @@ export const kanbanColumns = pgTable( { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').notNull(), - projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }), + boardId: uuid('board_id') + .references(() => kanbanBoards.id, { onDelete: 'cascade' }) + .notNull(), // Column properties name: varchar('name', { length: 100 }).notNull(), @@ -26,8 +28,8 @@ export const kanbanColumns = pgTable( }, (table) => ({ userIdx: index('kanban_columns_user_idx').on(table.userId), - projectIdx: index('kanban_columns_project_idx').on(table.projectId), - orderIdx: index('kanban_columns_order_idx').on(table.userId, table.projectId, table.order), + boardIdx: index('kanban_columns_board_idx').on(table.boardId), + orderIdx: index('kanban_columns_order_idx').on(table.boardId, table.order), }) ); diff --git a/apps/todo/apps/backend/src/kanban/dto/create-board.dto.ts b/apps/todo/apps/backend/src/kanban/dto/create-board.dto.ts new file mode 100644 index 000000000..761fe4349 --- /dev/null +++ b/apps/todo/apps/backend/src/kanban/dto/create-board.dto.ts @@ -0,0 +1,21 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; + +export class CreateBoardDto { + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + projectId?: string; + + @IsOptional() + @IsString() + @MaxLength(7) + color?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; +} diff --git a/apps/todo/apps/backend/src/kanban/dto/create-column.dto.ts b/apps/todo/apps/backend/src/kanban/dto/create-column.dto.ts index f0a6358a5..0f15982cf 100644 --- a/apps/todo/apps/backend/src/kanban/dto/create-column.dto.ts +++ b/apps/todo/apps/backend/src/kanban/dto/create-column.dto.ts @@ -6,15 +6,14 @@ export class CreateColumnDto { @MaxLength(100) name: string; + @IsString() + boardId: string; + @IsOptional() @IsString() @MaxLength(7) color?: string; - @IsOptional() - @IsString() - projectId?: string; - @IsOptional() @IsBoolean() isDefault?: boolean; diff --git a/apps/todo/apps/backend/src/kanban/dto/index.ts b/apps/todo/apps/backend/src/kanban/dto/index.ts index 44d6e0b58..6c7711c9e 100644 --- a/apps/todo/apps/backend/src/kanban/dto/index.ts +++ b/apps/todo/apps/backend/src/kanban/dto/index.ts @@ -1,4 +1,12 @@ +// Board DTOs +export * from './create-board.dto'; +export * from './update-board.dto'; +export * from './reorder-boards.dto'; + +// Column DTOs export * from './create-column.dto'; export * from './update-column.dto'; export * from './reorder-columns.dto'; + +// Task DTOs export * from './move-task.dto'; diff --git a/apps/todo/apps/backend/src/kanban/dto/reorder-boards.dto.ts b/apps/todo/apps/backend/src/kanban/dto/reorder-boards.dto.ts new file mode 100644 index 000000000..0c9616410 --- /dev/null +++ b/apps/todo/apps/backend/src/kanban/dto/reorder-boards.dto.ts @@ -0,0 +1,7 @@ +import { IsString, IsArray } from 'class-validator'; + +export class ReorderBoardsDto { + @IsArray() + @IsString({ each: true }) + boardIds: string[]; +} diff --git a/apps/todo/apps/backend/src/kanban/dto/update-board.dto.ts b/apps/todo/apps/backend/src/kanban/dto/update-board.dto.ts new file mode 100644 index 000000000..5a5622296 --- /dev/null +++ b/apps/todo/apps/backend/src/kanban/dto/update-board.dto.ts @@ -0,0 +1,18 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; + +export class UpdateBoardDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(7) + color?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; +} diff --git a/apps/todo/apps/backend/src/kanban/kanban.controller.ts b/apps/todo/apps/backend/src/kanban/kanban.controller.ts index 1b5e43484..465aac8a3 100644 --- a/apps/todo/apps/backend/src/kanban/kanban.controller.ts +++ b/apps/todo/apps/backend/src/kanban/kanban.controller.ts @@ -2,6 +2,9 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } fro import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { KanbanService } from './kanban.service'; import { + CreateBoardDto, + UpdateBoardDto, + ReorderBoardsDto, CreateColumnDto, UpdateColumnDto, ReorderColumnsDto, @@ -14,11 +17,63 @@ import { export class KanbanController { constructor(private readonly kanbanService: KanbanService) {} + // ===================== + // Board endpoints + // ===================== + + @Get('boards') + async getBoards(@CurrentUser() user: CurrentUserData) { + const boards = await this.kanbanService.findAllBoards(user.userId); + return { boards }; + } + + @Get('boards/global') + async getGlobalBoard(@CurrentUser() user: CurrentUserData) { + const board = await this.kanbanService.getOrCreateGlobalBoard(user.userId); + return { board }; + } + + @Get('boards/:id') + async getBoard(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + const board = await this.kanbanService.findBoardByIdOrThrow(id, user.userId); + return { board }; + } + + @Post('boards') + async createBoard(@CurrentUser() user: CurrentUserData, @Body() dto: CreateBoardDto) { + const board = await this.kanbanService.createBoard(user.userId, dto); + return { board }; + } + + @Put('boards/reorder') + async reorderBoards(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderBoardsDto) { + const boards = await this.kanbanService.reorderBoards(user.userId, dto.boardIds); + return { boards }; + } + + @Put('boards/:id') + async updateBoard( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: UpdateBoardDto + ) { + const board = await this.kanbanService.updateBoard(id, user.userId, dto); + return { board }; + } + + @Delete('boards/:id') + async deleteBoard(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + await this.kanbanService.deleteBoard(id, user.userId); + return { success: true }; + } + + // ===================== // Column endpoints + // ===================== @Get('columns') - async getColumns(@CurrentUser() user: CurrentUserData, @Query('projectId') projectId?: string) { - const columns = await this.kanbanService.findAllColumns(user.userId, projectId); + async getColumns(@CurrentUser() user: CurrentUserData, @Query('boardId') boardId: string) { + const columns = await this.kanbanService.findAllColumns(boardId, user.userId); return { columns }; } @@ -51,22 +106,18 @@ export class KanbanController { } @Post('columns/init') - async initializeColumns( - @CurrentUser() user: CurrentUserData, - @Query('projectId') projectId?: string - ) { - const columns = await this.kanbanService.initializeDefaultColumns(user.userId, projectId); + async initializeColumns(@CurrentUser() user: CurrentUserData, @Query('boardId') boardId: string) { + const columns = await this.kanbanService.initializeDefaultColumns(boardId, user.userId); return { columns }; } + // ===================== // Task endpoints + // ===================== @Get('tasks') - async getTasksGrouped( - @CurrentUser() user: CurrentUserData, - @Query('projectId') projectId?: string - ) { - const result = await this.kanbanService.getTasksGroupedByColumn(user.userId, projectId); + async getTasksGrouped(@CurrentUser() user: CurrentUserData, @Query('boardId') boardId: string) { + const result = await this.kanbanService.getTasksGroupedByColumn(boardId, user.userId); return result; } diff --git a/apps/todo/apps/backend/src/kanban/kanban.service.ts b/apps/todo/apps/backend/src/kanban/kanban.service.ts index 4998f86dd..322072d01 100644 --- a/apps/todo/apps/backend/src/kanban/kanban.service.ts +++ b/apps/todo/apps/backend/src/kanban/kanban.service.ts @@ -1,7 +1,12 @@ import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; -import { eq, and, asc, isNull } from 'drizzle-orm'; +import { eq, and, asc } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../db/database.module'; import { type Database } from '../db/connection'; +import { + kanbanBoards, + type KanbanBoard, + type NewKanbanBoard, +} from '../db/schema/kanban-boards.schema'; import { kanbanColumns, type KanbanColumn, @@ -9,10 +14,10 @@ import { type KanbanTaskStatus, } from '../db/schema/kanban-columns.schema'; import { tasks, type Task } from '../db/schema/tasks.schema'; -import { CreateColumnDto, UpdateColumnDto } from './dto'; +import { CreateBoardDto, UpdateBoardDto, CreateColumnDto, UpdateColumnDto } from './dto'; // Default columns configuration -const DEFAULT_COLUMNS: Omit[] = [ +const DEFAULT_COLUMNS: Omit[] = [ { name: 'To Do', color: '#6B7280', @@ -43,19 +48,152 @@ const DEFAULT_COLUMNS: Omit[] = [ export class KanbanService { constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - // Column operations + // ===================== + // Board operations + // ===================== - async findAllColumns(userId: string, projectId?: string | null): Promise { - if (projectId) { - return this.db.query.kanbanColumns.findMany({ - where: and(eq(kanbanColumns.userId, userId), eq(kanbanColumns.projectId, projectId)), - orderBy: [asc(kanbanColumns.order)], - }); + async findAllBoards(userId: string): Promise { + return this.db.query.kanbanBoards.findMany({ + where: eq(kanbanBoards.userId, userId), + orderBy: [asc(kanbanBoards.order)], + }); + } + + async findBoardById(id: string, userId: string): Promise { + const result = await this.db.query.kanbanBoards.findFirst({ + where: and(eq(kanbanBoards.id, id), eq(kanbanBoards.userId, userId)), + }); + return result ?? null; + } + + async findBoardByIdOrThrow(id: string, userId: string): Promise { + const board = await this.findBoardById(id, userId); + if (!board) { + throw new NotFoundException(`Board with id ${id} not found`); + } + return board; + } + + async getOrCreateGlobalBoard(userId: string): Promise { + // Check if global board exists + const existingGlobal = await this.db.query.kanbanBoards.findFirst({ + where: and(eq(kanbanBoards.userId, userId), eq(kanbanBoards.isGlobal, true)), + }); + + if (existingGlobal) { + return existingGlobal; } - // Global columns (no project) + // Create global board + const [globalBoard] = await this.db + .insert(kanbanBoards) + .values({ + userId, + name: 'Alle Aufgaben', + color: '#8b5cf6', + order: 0, + isGlobal: true, + }) + .returning(); + + // Initialize default columns for the global board + await this.initializeDefaultColumns(globalBoard.id, userId); + + return globalBoard; + } + + async createBoard(userId: string, dto: CreateBoardDto): Promise { + // Get the highest order value + const existingBoards = await this.findAllBoards(userId); + const maxOrder = existingBoards.reduce((max, b) => Math.max(max, b.order ?? 0), -1); + + const newBoard: NewKanbanBoard = { + userId, + projectId: dto.projectId ?? null, + name: dto.name, + color: dto.color ?? '#8b5cf6', + icon: dto.icon ?? null, + order: maxOrder + 1, + isGlobal: false, + }; + + const [created] = await this.db.insert(kanbanBoards).values(newBoard).returning(); + + // Initialize default columns for the new board + await this.initializeDefaultColumns(created.id, userId); + + return created; + } + + async updateBoard(id: string, userId: string, dto: UpdateBoardDto): Promise { + await this.findBoardByIdOrThrow(id, userId); + + const [updated] = await this.db + .update(kanbanBoards) + .set({ + ...dto, + updatedAt: new Date(), + }) + .where(and(eq(kanbanBoards.id, id), eq(kanbanBoards.userId, userId))) + .returning(); + + return updated; + } + + async deleteBoard(id: string, userId: string): Promise { + const board = await this.findBoardByIdOrThrow(id, userId); + + if (board.isGlobal) { + throw new BadRequestException('Cannot delete the global board'); + } + + // Get global board to move tasks to + const globalBoard = await this.getOrCreateGlobalBoard(userId); + const globalColumns = await this.findAllColumns(globalBoard.id, userId); + const firstGlobalColumn = globalColumns[0]; + + if (firstGlobalColumn) { + // Get all columns for this board + const boardColumns = await this.findAllColumns(id, userId); + + // Move tasks from board columns to first global column + for (const column of boardColumns) { + await this.db + .update(tasks) + .set({ + columnId: firstGlobalColumn.id, + updatedAt: new Date(), + }) + .where(eq(tasks.columnId, column.id)); + } + } + + // Delete the board (columns will cascade delete) + await this.db + .delete(kanbanBoards) + .where(and(eq(kanbanBoards.id, id), eq(kanbanBoards.userId, userId))); + } + + async reorderBoards(userId: string, boardIds: string[]): Promise { + const updates = boardIds.map((id, index) => + this.db + .update(kanbanBoards) + .set({ order: index, updatedAt: new Date() }) + .where(and(eq(kanbanBoards.id, id), eq(kanbanBoards.userId, userId))) + ); + + await Promise.all(updates); + + return this.findAllBoards(userId); + } + + // ===================== + // Column operations + // ===================== + + async findAllColumns(boardId: string, userId: string): Promise { return this.db.query.kanbanColumns.findMany({ - where: and(eq(kanbanColumns.userId, userId), isNull(kanbanColumns.projectId)), + where: and(eq(kanbanColumns.boardId, boardId), eq(kanbanColumns.userId, userId)), orderBy: [asc(kanbanColumns.order)], }); } @@ -76,13 +214,16 @@ export class KanbanService { } async createColumn(userId: string, dto: CreateColumnDto): Promise { - // Get the highest order value for this scope - const existingColumns = await this.findAllColumns(userId, dto.projectId); + // Verify board exists + await this.findBoardByIdOrThrow(dto.boardId, userId); + + // Get the highest order value for this board + const existingColumns = await this.findAllColumns(dto.boardId, userId); const maxOrder = existingColumns.reduce((max, c) => Math.max(max, c.order ?? 0), -1); const newColumn: NewKanbanColumn = { userId, - projectId: dto.projectId ?? null, + boardId: dto.boardId, name: dto.name, color: dto.color ?? '#6B7280', order: maxOrder + 1, @@ -114,7 +255,7 @@ export class KanbanService { const column = await this.findColumnByIdOrThrow(id, userId); // Get first column to move tasks to - const columns = await this.findAllColumns(userId, column.projectId); + const columns = await this.findAllColumns(column.boardId, userId); const firstColumn = columns.find((c) => c.id !== id); if (!firstColumn) { @@ -146,17 +287,17 @@ export class KanbanService { await Promise.all(updates); - // Determine projectId from first column + // Determine boardId from first column const firstColumn = await this.findColumnById(columnIds[0], userId); - return this.findAllColumns(userId, firstColumn?.projectId); + if (!firstColumn) { + return []; + } + return this.findAllColumns(firstColumn.boardId, userId); } - async initializeDefaultColumns( - userId: string, - projectId?: string | null - ): Promise { + async initializeDefaultColumns(boardId: string, userId: string): Promise { // Check if columns already exist - const existing = await this.findAllColumns(userId, projectId); + const existing = await this.findAllColumns(boardId, userId); if (existing.length > 0) { return existing; } @@ -165,35 +306,51 @@ export class KanbanService { const columnsToCreate: NewKanbanColumn[] = DEFAULT_COLUMNS.map((col) => ({ ...col, userId, - projectId: projectId ?? null, + boardId, })); await this.db.insert(kanbanColumns).values(columnsToCreate); - return this.findAllColumns(userId, projectId); + return this.findAllColumns(boardId, userId); } + // ===================== // Task operations + // ===================== async getTasksGroupedByColumn( - userId: string, - projectId?: string | null + boardId: string, + userId: string ): Promise<{ columns: KanbanColumn[]; tasksByColumn: Record }> { - // Ensure columns exist - const columns = await this.initializeDefaultColumns(userId, projectId); + // Get board to check if it's global + const board = await this.findBoardByIdOrThrow(boardId, userId); - // Get all tasks for this user + // Ensure columns exist + const columns = await this.initializeDefaultColumns(boardId, userId); + + // Get tasks based on board type let userTasks: Task[]; - if (projectId) { - userTasks = await this.db.query.tasks.findMany({ - where: and(eq(tasks.userId, userId), eq(tasks.projectId, projectId)), - orderBy: [asc(tasks.columnOrder), asc(tasks.createdAt)], - }); - } else { + if (board.isGlobal) { + // Global board: all tasks userTasks = await this.db.query.tasks.findMany({ where: eq(tasks.userId, userId), orderBy: [asc(tasks.columnOrder), asc(tasks.createdAt)], }); + } else if (board.projectId) { + // Project-specific board: only project tasks + userTasks = await this.db.query.tasks.findMany({ + where: and(eq(tasks.userId, userId), eq(tasks.projectId, board.projectId)), + orderBy: [asc(tasks.columnOrder), asc(tasks.createdAt)], + }); + } else { + // Custom board without project: tasks assigned to this board's columns + const columnIds = columns.map((c) => c.id); + userTasks = await this.db.query.tasks.findMany({ + where: eq(tasks.userId, userId), + orderBy: [asc(tasks.columnOrder), asc(tasks.createdAt)], + }); + // Filter to only tasks in this board's columns + userTasks = userTasks.filter((t) => t.columnId && columnIds.includes(t.columnId)); } // Group tasks by column diff --git a/apps/todo/apps/web/src/lib/api/kanban.ts b/apps/todo/apps/web/src/lib/api/kanban.ts index 6bc041726..f2e04e1c2 100644 --- a/apps/todo/apps/web/src/lib/api/kanban.ts +++ b/apps/todo/apps/web/src/lib/api/kanban.ts @@ -1,5 +1,15 @@ import { apiClient } from './client'; -import type { KanbanColumn, Task } from '@todo/shared'; +import type { KanbanBoard, KanbanColumn, Task } from '@todo/shared'; + +// Response types + +interface BoardsResponse { + boards: KanbanBoard[]; +} + +interface BoardResponse { + board: KanbanBoard; +} interface ColumnsResponse { columns: KanbanColumn[]; @@ -22,10 +32,25 @@ interface TasksResponse { tasks: Task[]; } +// DTO types + +interface CreateBoardDto { + name: string; + projectId?: string; + color?: string; + icon?: string; +} + +interface UpdateBoardDto { + name?: string; + color?: string; + icon?: string; +} + interface CreateColumnDto { name: string; + boardId: string; color?: string; - projectId?: string; isDefault?: boolean; defaultStatus?: string; autoComplete?: boolean; @@ -38,11 +63,54 @@ interface UpdateColumnDto { autoComplete?: boolean; } -// Column operations +// ===================== +// Board operations +// ===================== -export async function getColumns(projectId?: string): Promise { - const query = projectId ? `?projectId=${projectId}` : ''; - const response = await apiClient.get(`/api/v1/kanban/columns${query}`); +export async function getBoards(): Promise { + const response = await apiClient.get('/api/v1/kanban/boards'); + return response.boards; +} + +export async function getGlobalBoard(): Promise { + const response = await apiClient.get('/api/v1/kanban/boards/global'); + return response.board; +} + +export async function getBoard(id: string): Promise { + const response = await apiClient.get(`/api/v1/kanban/boards/${id}`); + return response.board; +} + +export async function createBoard(data: CreateBoardDto): Promise { + const response = await apiClient.post('/api/v1/kanban/boards', data); + return response.board; +} + +export async function updateBoard(id: string, data: UpdateBoardDto): Promise { + const response = await apiClient.put(`/api/v1/kanban/boards/${id}`, data); + return response.board; +} + +export async function deleteBoard(id: string): Promise { + await apiClient.delete(`/api/v1/kanban/boards/${id}`); +} + +export async function reorderBoards(boardIds: string[]): Promise { + const response = await apiClient.put('/api/v1/kanban/boards/reorder', { + boardIds, + }); + return response.boards; +} + +// ===================== +// Column operations +// ===================== + +export async function getColumns(boardId: string): Promise { + const response = await apiClient.get( + `/api/v1/kanban/columns?boardId=${boardId}` + ); return response.columns; } @@ -67,19 +135,23 @@ export async function reorderColumns(columnIds: string[]): Promise { - const query = projectId ? `?projectId=${projectId}` : ''; - const response = await apiClient.post(`/api/v1/kanban/columns/init${query}`); +export async function initializeColumns(boardId: string): Promise { + const response = await apiClient.post( + `/api/v1/kanban/columns/init?boardId=${boardId}` + ); return response.columns; } +// ===================== // Task operations +// ===================== export async function getKanbanTasks( - projectId?: string + boardId: string ): Promise<{ columns: KanbanColumn[]; tasksByColumn: Record }> { - const query = projectId ? `?projectId=${projectId}` : ''; - const response = await apiClient.get(`/api/v1/kanban/tasks${query}`); + const response = await apiClient.get( + `/api/v1/kanban/tasks?boardId=${boardId}` + ); return response; } diff --git a/apps/todo/apps/web/src/lib/components/kanban/AddColumnButton.svelte b/apps/todo/apps/web/src/lib/components/kanban/AddColumnButton.svelte index c039032ff..375a3af25 100644 --- a/apps/todo/apps/web/src/lib/components/kanban/AddColumnButton.svelte +++ b/apps/todo/apps/web/src/lib/components/kanban/AddColumnButton.svelte @@ -26,26 +26,38 @@ } -
+
{#if isAdding} -
+
+
+
+ Neue Spalte +
-
+
{:else} {/if}
+ + diff --git a/apps/todo/apps/web/src/lib/components/kanban/BoardNavigation.svelte b/apps/todo/apps/web/src/lib/components/kanban/BoardNavigation.svelte new file mode 100644 index 000000000..cec67969e --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/kanban/BoardNavigation.svelte @@ -0,0 +1,243 @@ + + +
+
+ + + + +
+ {#each boards as board (board.id)} + + {/each} +
+
+
+ + diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte index 74a661af6..b13afe2bd 100644 --- a/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte +++ b/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte @@ -1,6 +1,5 @@
- {#if kanbanStore.loading} -
-
-
- {:else if kanbanStore.error} -
- {kanbanStore.error} + {#if kanbanStore.error} +
+ + + + {kanbanStore.error}
{:else}
{#each localColumns.filter((c) => c.id !== SHADOW_PLACEHOLDER_ITEM_ID) as column (column.id)} -
+
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action'; - import { flip } from 'svelte/animate'; import type { KanbanColumn, Task } from '@todo/shared'; import KanbanTaskCard from './KanbanTaskCard.svelte'; import KanbanColumnHeader from './KanbanColumnHeader.svelte'; @@ -71,9 +70,31 @@ await tasksStore.completeTask(task.id); } } + + async function handleSaveTask(task: Task, data: Partial) { + // Transform data to match updateTask API (convert null to undefined) + const updateData: Parameters[1] = {}; + if (data.title !== undefined) updateData.title = data.title; + if (data.description !== undefined) updateData.description = data.description ?? undefined; + if (data.projectId !== undefined) updateData.projectId = data.projectId; + if (data.dueDate !== undefined) updateData.dueDate = data.dueDate ?? undefined; + if (data.priority !== undefined) updateData.priority = data.priority; + if (data.status !== undefined) updateData.status = data.status; + if (data.subtasks !== undefined) updateData.subtasks = data.subtasks ?? undefined; + if (data.recurrenceRule !== undefined) + updateData.recurrenceRule = data.recurrenceRule ?? undefined; + if (data.metadata !== undefined) updateData.metadata = data.metadata; + if ((data as any).labelIds !== undefined) (updateData as any).labelIds = (data as any).labelIds; + + await tasksStore.updateTask(task.id, updateData); + } + + async function handleDeleteTask(task: Task) { + await tasksStore.deleteTask(task.id); + } -
+
{#each localTasks.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID) as task (task.id)} -
- handleToggleComplete(task)} /> +
+ handleToggleComplete(task)} + onSave={(data) => handleSaveTask(task, data)} + onDelete={() => handleDeleteTask(task)} + />
{/each} - - {#if localTasks.length === 0} -
-

Keine Aufgaben

-
- {/if}
{#if onAddTask} -
+
{/if} @@ -118,12 +138,55 @@ diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanColumnHeader.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanColumnHeader.svelte index 3e9a00249..f9fcf16ce 100644 --- a/apps/todo/apps/web/src/lib/components/kanban/KanbanColumnHeader.svelte +++ b/apps/todo/apps/web/src/lib/components/kanban/KanbanColumnHeader.svelte @@ -50,10 +50,13 @@ } -
-
- -
+
+
+ +
{#if isEditing} @@ -62,7 +65,7 @@ bind:value={editName} onblur={handleSubmit} onkeydown={handleKeydown} - class="text-sm font-semibold bg-transparent border-b border-primary outline-none text-foreground flex-1 min-w-0" + class="text-sm font-semibold bg-transparent border-b-2 border-primary outline-none text-foreground flex-1 min-w-0 py-0.5" autofocus /> {:else} @@ -78,8 +81,11 @@ {/if} - - + + {taskCount}
@@ -88,7 +94,7 @@ {#if onUpdate || onDelete}
@@ -155,23 +174,26 @@ {/if} {#if onDelete && !column.isDefault} - +
+ +
{/if}
{/if} @@ -189,3 +211,45 @@ }} > {/if} + + diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanFilters.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanFilters.svelte index 1ce759ddc..33319e33d 100644 --- a/apps/todo/apps/web/src/lib/components/kanban/KanbanFilters.svelte +++ b/apps/todo/apps/web/src/lib/components/kanban/KanbanFilters.svelte @@ -27,11 +27,31 @@ onClearFilters, }: Props = $props(); - const priorities: { value: TaskPriority; label: string; color: string }[] = [ - { value: 'urgent', label: 'Dringend', color: 'bg-red-500' }, - { value: 'high', label: 'Hoch', color: 'bg-orange-500' }, - { value: 'medium', label: 'Mittel', color: 'bg-yellow-500' }, - { value: 'low', label: 'Niedrig', color: 'bg-blue-500' }, + const priorities: { value: TaskPriority; label: string; color: string; bgColor: string }[] = [ + { + value: 'urgent', + label: 'Dringend', + color: 'text-red-600 dark:text-red-400', + bgColor: 'bg-red-500', + }, + { + value: 'high', + label: 'Hoch', + color: 'text-orange-600 dark:text-orange-400', + bgColor: 'bg-orange-500', + }, + { + value: 'medium', + label: 'Mittel', + color: 'text-yellow-600 dark:text-yellow-400', + bgColor: 'bg-yellow-500', + }, + { + value: 'low', + label: 'Niedrig', + color: 'text-blue-600 dark:text-blue-400', + bgColor: 'bg-blue-500', + }, ]; let showLabelsDropdown = $state(false); @@ -60,134 +80,241 @@ ); -
- -
- onSearchChange(e.currentTarget.value)} - placeholder="Suchen..." - class="w-40 px-3 py-1.5 text-sm bg-background border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 placeholder:text-muted-foreground" - /> - {#if searchQuery} - - {/if} -
- - -
- Priorität: - {#each priorities as priority} - - {/each} -
- - -
- Projekt: - -
- - -
- - - {#if showLabelsDropdown} - -
(showLabelsDropdown = false)}>
-
- {#if labelsStore.labels.length === 0} -

Keine Labels vorhanden

- {:else} - {#each labelsStore.labels as label} - - {/each} + onSearchChange(e.currentTarget.value)} + placeholder="Aufgaben suchen..." + class="w-full pl-10 pr-8 py-2 text-sm bg-background border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary placeholder:text-muted-foreground transition-all" + /> + {#if searchQuery} + {/if}
- {/if} -
- - {#if hasActiveFilters} - - {/if} + {#if hasActiveFilters} + + {/if} +
+ + +
+ +
+ Priorität +
+ {#each priorities as priority} + + {/each} +
+
+ + + + +
+ Projekt + +
+ + + + +
+ Labels + + + {#if showLabelsDropdown} + +
(showLabelsDropdown = false)}>
+
+ {#if labelsStore.labels.length === 0} +

Keine Labels vorhanden

+ {:else} +
+ {#each labelsStore.labels as label} + + {/each} +
+ {/if} +
+ {/if} +
+
+
+ + diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanTaskCard.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanTaskCard.svelte index aaebbcadc..13ad48392 100644 --- a/apps/todo/apps/web/src/lib/components/kanban/KanbanTaskCard.svelte +++ b/apps/todo/apps/web/src/lib/components/kanban/KanbanTaskCard.svelte @@ -2,17 +2,33 @@ import type { Task } from '@todo/shared'; import { format, isToday, isPast, isTomorrow } from 'date-fns'; import { de } from 'date-fns/locale'; + import TaskEditModal from '../TaskEditModal.svelte'; interface Props { task: Task; onToggleComplete?: () => void; + onSave?: (data: Partial) => void; + onDelete?: () => void; } - let { task, onToggleComplete }: Props = $props(); + let { task, onToggleComplete, onSave, onDelete }: Props = $props(); - // Priority colors + // Modal state + let showModal = $state(false); + + // Inline edit state + let isEditingTitle = $state(false); + let editTitle = $state(''); + let titleInputRef = $state(null); + + // Context menu state + let showContextMenu = $state(false); + let contextMenuX = $state(0); + let contextMenuY = $state(0); + + // Priority colors (consistent with KanbanFilters) const priorityColors: Record = { - low: 'bg-green-500', + low: 'bg-blue-500', medium: 'bg-yellow-500', high: 'bg-orange-500', urgent: 'bg-red-500', @@ -40,107 +56,501 @@ const completed = task.subtasks.filter((s) => s.isCompleted).length; return `${completed}/${task.subtasks.length}`; }); + + // Click to open modal + function handleCardClick(e: MouseEvent) { + // Don't open modal if clicking on checkbox or during inline edit + if (isEditingTitle) return; + const target = e.target as HTMLElement; + if (target.closest('.task-checkbox')) return; + showModal = true; + } + + // Double-click to edit title inline + function handleTitleDoubleClick(e: MouseEvent) { + e.stopPropagation(); + editTitle = task.title; + isEditingTitle = true; + // Focus input after render + setTimeout(() => { + titleInputRef?.focus(); + titleInputRef?.select(); + }, 0); + } + + // Save inline title edit + function saveInlineTitle() { + if (editTitle.trim() && editTitle.trim() !== task.title) { + onSave?.({ title: editTitle.trim() }); + } + isEditingTitle = false; + } + + // Cancel inline title edit + function cancelInlineTitle() { + isEditingTitle = false; + editTitle = ''; + } + + // Handle title input keydown + function handleTitleKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + saveInlineTitle(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelInlineTitle(); + } + } + + // Right-click context menu + function handleContextMenu(e: MouseEvent) { + e.preventDefault(); + contextMenuX = e.clientX; + contextMenuY = e.clientY; + showContextMenu = true; + } + + // Close context menu when clicking outside + function handleClickOutside() { + showContextMenu = false; + } + + // Context menu actions + function handleContextEdit() { + showContextMenu = false; + showModal = true; + } + + function handleContextToggleComplete() { + showContextMenu = false; + onToggleComplete?.(); + } + + function handleContextDelete() { + showContextMenu = false; + if (confirm('Aufgabe wirklich löschen?')) { + onDelete?.(); + } + } + + // Modal handlers + function handleModalClose() { + showModal = false; + } + + function handleModalSave(data: Partial) { + onSave?.(data); + showModal = false; + } + + function handleModalDelete(taskId: string) { + onDelete?.(); + showModal = false; + } + +
-
-
+
-
- -

+ {#if onToggleComplete} + + {/if} + + +
+ {#if isEditingTitle} + + {:else} + {task.title} -

+ + {/if} - - {#if dueDateText() || subtaskProgress() || (task.labels && task.labels.length > 0)} -
- {#if dueDateText()} - - - - - {dueDateText()} - - {/if} - - {#if subtaskProgress()} - - - - - {subtaskProgress()} - - {/if} - - {#if task.labels && task.labels.length > 0} -
- {#each task.labels.slice(0, 2) as label} - - {/each} - {#if task.labels.length > 2} - +{task.labels.length - 2} - {/if} -
- {/if} -
- {/if} -
- - - {#if onToggleComplete} - + + {#if subtaskProgress()} + + + + + {subtaskProgress()} + + {/if} + + {#if task.labels && task.labels.length > 0} + {#each task.labels.slice(0, 2) as label} + + {label.name} + + {/each} + {#if task.labels.length > 2} + +{task.labels.length - 2} + {/if} + {/if} +
{/if}
+ +{#if showContextMenu} +
e.stopPropagation()} + > + + +
+ +
+{/if} + + + + diff --git a/apps/todo/apps/web/src/lib/components/kanban/QuickAddTaskInline.svelte b/apps/todo/apps/web/src/lib/components/kanban/QuickAddTaskInline.svelte index bcb8b2f65..413380df3 100644 --- a/apps/todo/apps/web/src/lib/components/kanban/QuickAddTaskInline.svelte +++ b/apps/todo/apps/web/src/lib/components/kanban/QuickAddTaskInline.svelte @@ -38,26 +38,34 @@
{#if isAdding} -
+
-
+
{:else} {/if}
+ + diff --git a/apps/todo/apps/web/src/lib/components/kanban/index.ts b/apps/todo/apps/web/src/lib/components/kanban/index.ts index 4d5cbc8db..56e0f3247 100644 --- a/apps/todo/apps/web/src/lib/components/kanban/index.ts +++ b/apps/todo/apps/web/src/lib/components/kanban/index.ts @@ -4,3 +4,4 @@ export { default as KanbanColumnHeader } from './KanbanColumnHeader.svelte'; export { default as KanbanTaskCard } from './KanbanTaskCard.svelte'; export { default as AddColumnButton } from './AddColumnButton.svelte'; export { default as KanbanFilters } from './KanbanFilters.svelte'; +export { default as BoardNavigation } from './BoardNavigation.svelte'; diff --git a/apps/todo/apps/web/src/lib/stores/kanban.svelte.ts b/apps/todo/apps/web/src/lib/stores/kanban.svelte.ts index dae60a761..2cde6a356 100644 --- a/apps/todo/apps/web/src/lib/stores/kanban.svelte.ts +++ b/apps/todo/apps/web/src/lib/stores/kanban.svelte.ts @@ -1,19 +1,46 @@ /** - * Kanban Store - Manages kanban board state using Svelte 5 runes + * Kanban Store - Manages kanban boards, columns, and tasks using Svelte 5 runes */ -import type { KanbanColumn, Task } from '@todo/shared'; +import type { KanbanBoard, KanbanColumn, Task } from '@todo/shared'; import * as kanbanApi from '$lib/api/kanban'; import * as tasksApi from '$lib/api/tasks'; -// State +// Board state +let boards = $state([]); +let currentBoardId = $state(null); + +// Column & Task state let columns = $state([]); let tasksByColumn = $state>({}); + +// Loading & Error state let loading = $state(false); +let boardsLoading = $state(false); let error = $state(null); export const kanbanStore = { - // Getters + // ===================== + // Board Getters + // ===================== + + get boards() { + return boards; + }, + get currentBoardId() { + return currentBoardId; + }, + get currentBoard() { + return boards.find((b) => b.id === currentBoardId) ?? null; + }, + get globalBoard() { + return boards.find((b) => b.isGlobal) ?? null; + }, + + // ===================== + // Column & Task Getters + // ===================== + get columns() { return columns; }, @@ -23,18 +50,165 @@ export const kanbanStore = { get loading() { return loading; }, + get boardsLoading() { + return boardsLoading; + }, get error() { return error; }, + // ===================== + // Board Operations + // ===================== + /** - * Fetch columns and tasks grouped by column + * Fetch all boards for the current user */ - async fetchKanbanData(projectId?: string) { + async fetchBoards() { + boardsLoading = true; + error = null; + try { + boards = await kanbanApi.getBoards(); + + // If no current board selected, select global board or first board + if (!currentBoardId && boards.length > 0) { + const globalBoard = boards.find((b) => b.isGlobal); + currentBoardId = globalBoard?.id ?? boards[0].id; + } + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to fetch boards'; + console.error('Failed to fetch boards:', e); + } finally { + boardsLoading = false; + } + }, + + /** + * Get or create the global board + */ + async getOrCreateGlobalBoard() { + error = null; + try { + const globalBoard = await kanbanApi.getGlobalBoard(); + + // Update or add to boards list + const existingIndex = boards.findIndex((b) => b.id === globalBoard.id); + if (existingIndex >= 0) { + boards[existingIndex] = globalBoard; + } else { + boards = [globalBoard, ...boards]; + } + + return globalBoard; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to get global board'; + console.error('Failed to get global board:', e); + throw e; + } + }, + + /** + * Select a board and load its data + */ + async selectBoard(boardId: string) { + if (currentBoardId === boardId) return; + + currentBoardId = boardId; + await this.fetchKanbanData(boardId); + }, + + /** + * Create a new board + */ + async createBoard(data: { name: string; projectId?: string; color?: string; icon?: string }) { + error = null; + try { + const newBoard = await kanbanApi.createBoard(data); + boards = [...boards, newBoard]; + return newBoard; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create board'; + console.error('Failed to create board:', e); + throw e; + } + }, + + /** + * Update a board + */ + async updateBoard(id: string, data: { name?: string; color?: string; icon?: string }) { + error = null; + try { + const updated = await kanbanApi.updateBoard(id, data); + boards = boards.map((b) => (b.id === id ? updated : b)); + return updated; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update board'; + console.error('Failed to update board:', e); + throw e; + } + }, + + /** + * Delete a board + */ + async deleteBoard(id: string) { + error = null; + try { + await kanbanApi.deleteBoard(id); + boards = boards.filter((b) => b.id !== id); + + // If deleted board was current, switch to global board + if (currentBoardId === id) { + const globalBoard = boards.find((b) => b.isGlobal); + currentBoardId = globalBoard?.id ?? boards[0]?.id ?? null; + if (currentBoardId) { + await this.fetchKanbanData(currentBoardId); + } + } + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete board'; + console.error('Failed to delete board:', e); + throw e; + } + }, + + /** + * Reorder boards (optimistic update) + */ + async reorderBoards(boardIds: string[]) { + error = null; + const previousBoards = [...boards]; + try { + // Optimistic update + boards = boardIds + .map((id) => boards.find((b) => b.id === id)) + .filter((b): b is KanbanBoard => b !== undefined); + + // Persist to server + const updated = await kanbanApi.reorderBoards(boardIds); + boards = updated; + } catch (e) { + // Rollback on error + boards = previousBoards; + error = e instanceof Error ? e.message : 'Failed to reorder boards'; + console.error('Failed to reorder boards:', e); + throw e; + } + }, + + // ===================== + // Column & Task Operations + // ===================== + + /** + * Fetch columns and tasks grouped by column for a board + */ + async fetchKanbanData(boardId: string) { loading = true; error = null; try { - const data = await kanbanApi.getKanbanTasks(projectId); + const data = await kanbanApi.getKanbanTasks(boardId); columns = data.columns; tasksByColumn = data.tasksByColumn; } catch (e) { @@ -46,13 +220,13 @@ export const kanbanStore = { }, /** - * Fetch only columns + * Fetch only columns for a board */ - async fetchColumns(projectId?: string) { + async fetchColumns(boardId: string) { loading = true; error = null; try { - columns = await kanbanApi.getColumns(projectId); + columns = await kanbanApi.getColumns(boardId); } catch (e) { error = e instanceof Error ? e.message : 'Failed to fetch columns'; console.error('Failed to fetch columns:', e); @@ -66,8 +240,8 @@ export const kanbanStore = { */ async createColumn(data: { name: string; + boardId: string; color?: string; - projectId?: string; defaultStatus?: string; autoComplete?: boolean; }) { @@ -216,10 +390,10 @@ export const kanbanStore = { /** * Initialize default columns if none exist */ - async initializeDefaultColumns(projectId?: string) { + async initializeDefaultColumns(boardId: string) { error = null; try { - const newColumns = await kanbanApi.initializeColumns(projectId); + const newColumns = await kanbanApi.initializeColumns(boardId); columns = newColumns; // Initialize empty task arrays for each column for (const col of newColumns) { @@ -250,7 +424,6 @@ export const kanbanStore = { try { // Find the column to get its default status const column = columns.find((c) => c.id === columnId); - const status = column?.defaultStatus || 'pending'; // Create the task const newTask = await tasksApi.createTask({ @@ -280,9 +453,12 @@ export const kanbanStore = { * Clear all state (for logout) */ clear() { + boards = []; + currentBoardId = null; columns = []; tasksByColumn = {}; loading = false; + boardsLoading = false; error = null; }, }; diff --git a/apps/todo/apps/web/src/routes/(app)/kanban/+page.svelte b/apps/todo/apps/web/src/routes/(app)/kanban/+page.svelte index 4739f2316..c448392fd 100644 --- a/apps/todo/apps/web/src/routes/(app)/kanban/+page.svelte +++ b/apps/todo/apps/web/src/routes/(app)/kanban/+page.svelte @@ -1,16 +1,64 @@ - Kanban Board - Todo + {boardTitle} - Todo -
-
-

Kanban Board

-

- Ziehe Aufgaben zwischen Spalten, um ihren Status zu ändern -

-
- -
- (filterPriorities = priorities)} - onProjectChange={(projectId) => (filterProjectId = projectId)} - onLabelsChange={(labelIds) => (filterLabelIds = labelIds)} - onSearchChange={(query) => (filterSearchQuery = query)} - onClearFilters={clearFilters} +
+ + {#if !isMobile} + + {/if} + + +
+
+ {#if isEditingTitle} + + {:else} + + {/if} +
+
-
+ + {#if showFilters} +
+ (filterPriorities = priorities)} + onProjectChange={(projectId) => (filterProjectId = projectId)} + onLabelsChange={(labelIds) => (filterLabelIds = labelIds)} + onSearchChange={(query) => (filterSearchQuery = query)} + onClearFilters={clearFilters} + /> +
+ {/if} + + +
+ + + {#if isMobile} + + {/if}
+ +{#if showCreateBoard} + +{/if} + diff --git a/apps/todo/packages/shared/src/types/kanban.ts b/apps/todo/packages/shared/src/types/kanban.ts index 050f30645..3c83ea508 100644 --- a/apps/todo/packages/shared/src/types/kanban.ts +++ b/apps/todo/packages/shared/src/types/kanban.ts @@ -1,11 +1,43 @@ import type { TaskStatus } from './task'; -export interface KanbanColumn { +// Kanban Board +export interface KanbanBoard { id: string; userId: string; projectId?: string | null; name: string; color: string; + icon?: string | null; + order: number; + isGlobal: boolean; + createdAt: Date | string; + updatedAt: Date | string; +} + +export interface CreateBoardInput { + name: string; + projectId?: string; + color?: string; + icon?: string; +} + +export interface UpdateBoardInput { + name?: string; + color?: string; + icon?: string; +} + +export interface ReorderBoardsInput { + boardIds: string[]; +} + +// Kanban Column +export interface KanbanColumn { + id: string; + userId: string; + boardId: string; + name: string; + color: string; order: number; isDefault: boolean; defaultStatus?: TaskStatus | null; @@ -16,8 +48,8 @@ export interface KanbanColumn { export interface CreateColumnInput { name: string; + boardId: string; color?: string; - projectId?: string; isDefault?: boolean; defaultStatus?: TaskStatus; autoComplete?: boolean;