mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
feat(todo): add Kanban board with drag & drop and filters
- Add kanban_columns table for custom column support - Add columnId and columnOrder fields to tasks - Create NestJS Kanban module with CRUD endpoints - Implement KanbanBoard, KanbanColumn, KanbanTaskCard components - Add drag & drop support between columns using svelte-dnd-action - Add Quick Add Task inline in each column - Add filter panel (priority, project, labels, search) - Add /kanban route and navigation link 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7b8335a3fb
commit
bb59227aff
27 changed files with 2057 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ import { ProjectModule } from './project/project.module';
|
|||
import { TaskModule } from './task/task.module';
|
||||
import { LabelModule } from './label/label.module';
|
||||
import { ReminderModule } from './reminder/reminder.module';
|
||||
import { KanbanModule } from './kanban/kanban.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -21,6 +22,7 @@ import { ReminderModule } from './reminder/reminder.module';
|
|||
TaskModule,
|
||||
LabelModule,
|
||||
ReminderModule,
|
||||
KanbanModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './projects.schema';
|
||||
export * from './kanban-columns.schema';
|
||||
export * from './tasks.schema';
|
||||
export * from './labels.schema';
|
||||
export * from './task-labels.schema';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import { pgTable, uuid, timestamp, varchar, boolean, integer, index } from 'drizzle-orm/pg-core';
|
||||
import { projects } from './projects.schema';
|
||||
|
||||
// Define locally to avoid circular dependency with tasks.schema
|
||||
export type KanbanTaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
||||
|
||||
export const kanbanColumns = pgTable(
|
||||
'kanban_columns',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }),
|
||||
|
||||
// Column properties
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
color: varchar('color', { length: 7 }).default('#6B7280'),
|
||||
order: integer('order').default(0).notNull(),
|
||||
|
||||
// Behavior
|
||||
isDefault: boolean('is_default').default(false),
|
||||
defaultStatus: varchar('default_status', { length: 20 }).$type<KanbanTaskStatus>(),
|
||||
autoComplete: boolean('auto_complete').default(false),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(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),
|
||||
})
|
||||
);
|
||||
|
||||
export type KanbanColumn = typeof kanbanColumns.$inferSelect;
|
||||
export type NewKanbanColumn = typeof kanbanColumns.$inferInsert;
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { projects } from './projects.schema';
|
||||
import { kanbanColumns } from './kanban-columns.schema';
|
||||
|
||||
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
||||
|
|
@ -56,6 +57,10 @@ export const tasks = pgTable(
|
|||
// Ordering
|
||||
order: integer('order').default(0),
|
||||
|
||||
// Kanban
|
||||
columnId: uuid('column_id').references(() => kanbanColumns.id, { onDelete: 'set null' }),
|
||||
columnOrder: integer('column_order').default(0),
|
||||
|
||||
// Recurrence (RFC 5545 RRULE format)
|
||||
recurrenceRule: varchar('recurrence_rule', { length: 500 }),
|
||||
recurrenceEndDate: timestamp('recurrence_end_date', { withTimezone: true }),
|
||||
|
|
@ -77,6 +82,7 @@ export const tasks = pgTable(
|
|||
statusIdx: index('tasks_status_idx').on(table.isCompleted, table.status),
|
||||
parentIdx: index('tasks_parent_idx').on(table.parentTaskId),
|
||||
orderIdx: index('tasks_order_idx').on(table.projectId, table.order),
|
||||
columnIdx: index('tasks_column_idx').on(table.columnId, table.columnOrder),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
29
apps/todo/apps/backend/src/kanban/dto/create-column.dto.ts
Normal file
29
apps/todo/apps/backend/src/kanban/dto/create-column.dto.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { IsString, IsOptional, IsBoolean, MaxLength, IsIn } from 'class-validator';
|
||||
import type { KanbanTaskStatus } from '../../db/schema/kanban-columns.schema';
|
||||
|
||||
export class CreateColumnDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(7)
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
projectId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['pending', 'in_progress', 'completed', 'cancelled'])
|
||||
defaultStatus?: KanbanTaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
autoComplete?: boolean;
|
||||
}
|
||||
4
apps/todo/apps/backend/src/kanban/dto/index.ts
Normal file
4
apps/todo/apps/backend/src/kanban/dto/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './create-column.dto';
|
||||
export * from './update-column.dto';
|
||||
export * from './reorder-columns.dto';
|
||||
export * from './move-task.dto';
|
||||
18
apps/todo/apps/backend/src/kanban/dto/move-task.dto.ts
Normal file
18
apps/todo/apps/backend/src/kanban/dto/move-task.dto.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { IsString, IsNumber, IsOptional } from 'class-validator';
|
||||
|
||||
export class MoveTaskToColumnDto {
|
||||
@IsString()
|
||||
columnId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export class ReorderTasksDto {
|
||||
@IsString()
|
||||
columnId: string;
|
||||
|
||||
@IsString({ each: true })
|
||||
taskIds: string[];
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { IsArray, IsString } from 'class-validator';
|
||||
|
||||
export class ReorderColumnsDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
columnIds: string[];
|
||||
}
|
||||
22
apps/todo/apps/backend/src/kanban/dto/update-column.dto.ts
Normal file
22
apps/todo/apps/backend/src/kanban/dto/update-column.dto.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { IsString, IsOptional, IsBoolean, MaxLength, IsIn } from 'class-validator';
|
||||
import type { KanbanTaskStatus } from '../../db/schema/kanban-columns.schema';
|
||||
|
||||
export class UpdateColumnDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(7)
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['pending', 'in_progress', 'completed', 'cancelled'])
|
||||
defaultStatus?: KanbanTaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
autoComplete?: boolean;
|
||||
}
|
||||
97
apps/todo/apps/backend/src/kanban/kanban.controller.ts
Normal file
97
apps/todo/apps/backend/src/kanban/kanban.controller.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { KanbanService } from './kanban.service';
|
||||
import {
|
||||
CreateColumnDto,
|
||||
UpdateColumnDto,
|
||||
ReorderColumnsDto,
|
||||
MoveTaskToColumnDto,
|
||||
ReorderTasksDto,
|
||||
} from './dto';
|
||||
|
||||
@Controller('kanban')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class KanbanController {
|
||||
constructor(private readonly kanbanService: KanbanService) {}
|
||||
|
||||
// Column endpoints
|
||||
|
||||
@Get('columns')
|
||||
async getColumns(@CurrentUser() user: CurrentUserData, @Query('projectId') projectId?: string) {
|
||||
const columns = await this.kanbanService.findAllColumns(user.userId, projectId);
|
||||
return { columns };
|
||||
}
|
||||
|
||||
@Post('columns')
|
||||
async createColumn(@CurrentUser() user: CurrentUserData, @Body() dto: CreateColumnDto) {
|
||||
const column = await this.kanbanService.createColumn(user.userId, dto);
|
||||
return { column };
|
||||
}
|
||||
|
||||
@Put('columns/:id')
|
||||
async updateColumn(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateColumnDto
|
||||
) {
|
||||
const column = await this.kanbanService.updateColumn(id, user.userId, dto);
|
||||
return { column };
|
||||
}
|
||||
|
||||
@Delete('columns/:id')
|
||||
async deleteColumn(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.kanbanService.deleteColumn(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Put('columns/reorder')
|
||||
async reorderColumns(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderColumnsDto) {
|
||||
const columns = await this.kanbanService.reorderColumns(user.userId, dto.columnIds);
|
||||
return { columns };
|
||||
}
|
||||
|
||||
@Post('columns/init')
|
||||
async initializeColumns(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('projectId') projectId?: string
|
||||
) {
|
||||
const columns = await this.kanbanService.initializeDefaultColumns(user.userId, projectId);
|
||||
return { columns };
|
||||
}
|
||||
|
||||
// Task endpoints
|
||||
|
||||
@Get('tasks')
|
||||
async getTasksGrouped(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('projectId') projectId?: string
|
||||
) {
|
||||
const result = await this.kanbanService.getTasksGroupedByColumn(user.userId, projectId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('tasks/:taskId/move')
|
||||
async moveTaskToColumn(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() dto: MoveTaskToColumnDto
|
||||
) {
|
||||
const task = await this.kanbanService.moveTaskToColumn(
|
||||
taskId,
|
||||
user.userId,
|
||||
dto.columnId,
|
||||
dto.order
|
||||
);
|
||||
return { task };
|
||||
}
|
||||
|
||||
@Put('tasks/reorder')
|
||||
async reorderTasks(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderTasksDto) {
|
||||
const tasks = await this.kanbanService.reorderTasksInColumn(
|
||||
user.userId,
|
||||
dto.columnId,
|
||||
dto.taskIds
|
||||
);
|
||||
return { tasks };
|
||||
}
|
||||
}
|
||||
10
apps/todo/apps/backend/src/kanban/kanban.module.ts
Normal file
10
apps/todo/apps/backend/src/kanban/kanban.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { KanbanController } from './kanban.controller';
|
||||
import { KanbanService } from './kanban.service';
|
||||
|
||||
@Module({
|
||||
controllers: [KanbanController],
|
||||
providers: [KanbanService],
|
||||
exports: [KanbanService],
|
||||
})
|
||||
export class KanbanModule {}
|
||||
302
apps/todo/apps/backend/src/kanban/kanban.service.ts
Normal file
302
apps/todo/apps/backend/src/kanban/kanban.service.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { eq, and, asc, isNull } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import {
|
||||
kanbanColumns,
|
||||
type KanbanColumn,
|
||||
type NewKanbanColumn,
|
||||
type KanbanTaskStatus,
|
||||
} from '../db/schema/kanban-columns.schema';
|
||||
import { tasks, type Task } from '../db/schema/tasks.schema';
|
||||
import { CreateColumnDto, UpdateColumnDto } from './dto';
|
||||
|
||||
// Default columns configuration
|
||||
const DEFAULT_COLUMNS: Omit<NewKanbanColumn, 'userId' | 'projectId'>[] = [
|
||||
{
|
||||
name: 'To Do',
|
||||
color: '#6B7280',
|
||||
order: 0,
|
||||
isDefault: true,
|
||||
defaultStatus: 'pending' as KanbanTaskStatus,
|
||||
autoComplete: false,
|
||||
},
|
||||
{
|
||||
name: 'In Arbeit',
|
||||
color: '#3B82F6',
|
||||
order: 1,
|
||||
isDefault: true,
|
||||
defaultStatus: 'in_progress' as KanbanTaskStatus,
|
||||
autoComplete: false,
|
||||
},
|
||||
{
|
||||
name: 'Erledigt',
|
||||
color: '#22C55E',
|
||||
order: 2,
|
||||
isDefault: true,
|
||||
defaultStatus: 'completed' as KanbanTaskStatus,
|
||||
autoComplete: true,
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class KanbanService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
// Column operations
|
||||
|
||||
async findAllColumns(userId: string, projectId?: string | null): Promise<KanbanColumn[]> {
|
||||
if (projectId) {
|
||||
return this.db.query.kanbanColumns.findMany({
|
||||
where: and(eq(kanbanColumns.userId, userId), eq(kanbanColumns.projectId, projectId)),
|
||||
orderBy: [asc(kanbanColumns.order)],
|
||||
});
|
||||
}
|
||||
|
||||
// Global columns (no project)
|
||||
return this.db.query.kanbanColumns.findMany({
|
||||
where: and(eq(kanbanColumns.userId, userId), isNull(kanbanColumns.projectId)),
|
||||
orderBy: [asc(kanbanColumns.order)],
|
||||
});
|
||||
}
|
||||
|
||||
async findColumnById(id: string, userId: string): Promise<KanbanColumn | null> {
|
||||
const result = await this.db.query.kanbanColumns.findFirst({
|
||||
where: and(eq(kanbanColumns.id, id), eq(kanbanColumns.userId, userId)),
|
||||
});
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
async findColumnByIdOrThrow(id: string, userId: string): Promise<KanbanColumn> {
|
||||
const column = await this.findColumnById(id, userId);
|
||||
if (!column) {
|
||||
throw new NotFoundException(`Column with id ${id} not found`);
|
||||
}
|
||||
return column;
|
||||
}
|
||||
|
||||
async createColumn(userId: string, dto: CreateColumnDto): Promise<KanbanColumn> {
|
||||
// Get the highest order value for this scope
|
||||
const existingColumns = await this.findAllColumns(userId, dto.projectId);
|
||||
const maxOrder = existingColumns.reduce((max, c) => Math.max(max, c.order ?? 0), -1);
|
||||
|
||||
const newColumn: NewKanbanColumn = {
|
||||
userId,
|
||||
projectId: dto.projectId ?? null,
|
||||
name: dto.name,
|
||||
color: dto.color ?? '#6B7280',
|
||||
order: maxOrder + 1,
|
||||
isDefault: dto.isDefault ?? false,
|
||||
defaultStatus: dto.defaultStatus,
|
||||
autoComplete: dto.autoComplete ?? false,
|
||||
};
|
||||
|
||||
const [created] = await this.db.insert(kanbanColumns).values(newColumn).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateColumn(id: string, userId: string, dto: UpdateColumnDto): Promise<KanbanColumn> {
|
||||
await this.findColumnByIdOrThrow(id, userId);
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(kanbanColumns)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(kanbanColumns.id, id), eq(kanbanColumns.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteColumn(id: string, userId: string): Promise<void> {
|
||||
const column = await this.findColumnByIdOrThrow(id, userId);
|
||||
|
||||
// Get first column to move tasks to
|
||||
const columns = await this.findAllColumns(userId, column.projectId);
|
||||
const firstColumn = columns.find((c) => c.id !== id);
|
||||
|
||||
if (!firstColumn) {
|
||||
throw new BadRequestException('Cannot delete the last column');
|
||||
}
|
||||
|
||||
// Move all tasks from this column to the first column
|
||||
await this.db
|
||||
.update(tasks)
|
||||
.set({
|
||||
columnId: firstColumn.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tasks.columnId, id));
|
||||
|
||||
// Delete the column
|
||||
await this.db
|
||||
.delete(kanbanColumns)
|
||||
.where(and(eq(kanbanColumns.id, id), eq(kanbanColumns.userId, userId)));
|
||||
}
|
||||
|
||||
async reorderColumns(userId: string, columnIds: string[]): Promise<KanbanColumn[]> {
|
||||
const updates = columnIds.map((id, index) =>
|
||||
this.db
|
||||
.update(kanbanColumns)
|
||||
.set({ order: index, updatedAt: new Date() })
|
||||
.where(and(eq(kanbanColumns.id, id), eq(kanbanColumns.userId, userId)))
|
||||
);
|
||||
|
||||
await Promise.all(updates);
|
||||
|
||||
// Determine projectId from first column
|
||||
const firstColumn = await this.findColumnById(columnIds[0], userId);
|
||||
return this.findAllColumns(userId, firstColumn?.projectId);
|
||||
}
|
||||
|
||||
async initializeDefaultColumns(
|
||||
userId: string,
|
||||
projectId?: string | null
|
||||
): Promise<KanbanColumn[]> {
|
||||
// Check if columns already exist
|
||||
const existing = await this.findAllColumns(userId, projectId);
|
||||
if (existing.length > 0) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create default columns
|
||||
const columnsToCreate: NewKanbanColumn[] = DEFAULT_COLUMNS.map((col) => ({
|
||||
...col,
|
||||
userId,
|
||||
projectId: projectId ?? null,
|
||||
}));
|
||||
|
||||
await this.db.insert(kanbanColumns).values(columnsToCreate);
|
||||
|
||||
return this.findAllColumns(userId, projectId);
|
||||
}
|
||||
|
||||
// Task operations
|
||||
|
||||
async getTasksGroupedByColumn(
|
||||
userId: string,
|
||||
projectId?: string | null
|
||||
): Promise<{ columns: KanbanColumn[]; tasksByColumn: Record<string, Task[]> }> {
|
||||
// Ensure columns exist
|
||||
const columns = await this.initializeDefaultColumns(userId, projectId);
|
||||
|
||||
// Get all tasks for this user
|
||||
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 {
|
||||
userTasks = await this.db.query.tasks.findMany({
|
||||
where: eq(tasks.userId, userId),
|
||||
orderBy: [asc(tasks.columnOrder), asc(tasks.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
// Group tasks by column
|
||||
const tasksByColumn: Record<string, Task[]> = {};
|
||||
|
||||
// Initialize empty arrays for each column
|
||||
for (const column of columns) {
|
||||
tasksByColumn[column.id] = [];
|
||||
}
|
||||
|
||||
// Distribute tasks
|
||||
for (const task of userTasks) {
|
||||
if (task.columnId && tasksByColumn[task.columnId]) {
|
||||
// Task has explicit column assignment
|
||||
tasksByColumn[task.columnId].push(task);
|
||||
} else {
|
||||
// Map based on status to default column
|
||||
const matchingColumn = columns.find((c) => c.defaultStatus === task.status);
|
||||
if (matchingColumn) {
|
||||
tasksByColumn[matchingColumn.id].push(task);
|
||||
} else {
|
||||
// Fallback to first column
|
||||
const firstColumn = columns[0];
|
||||
if (firstColumn) {
|
||||
tasksByColumn[firstColumn.id].push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { columns, tasksByColumn };
|
||||
}
|
||||
|
||||
async moveTaskToColumn(
|
||||
taskId: string,
|
||||
userId: string,
|
||||
columnId: string,
|
||||
order?: number
|
||||
): Promise<Task> {
|
||||
// Verify task exists and belongs to user
|
||||
const task = await this.db.query.tasks.findFirst({
|
||||
where: and(eq(tasks.id, taskId), eq(tasks.userId, userId)),
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException(`Task with id ${taskId} not found`);
|
||||
}
|
||||
|
||||
// Verify column exists and belongs to user
|
||||
const column = await this.findColumnByIdOrThrow(columnId, userId);
|
||||
|
||||
// Determine new status and completion state
|
||||
const updateData: Partial<Task> = {
|
||||
columnId,
|
||||
columnOrder: order ?? 0,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// If column has autoComplete, mark task as completed
|
||||
if (column.autoComplete) {
|
||||
updateData.isCompleted = true;
|
||||
updateData.completedAt = new Date();
|
||||
updateData.status = 'completed';
|
||||
} else if (column.defaultStatus) {
|
||||
// Update status based on column's default status
|
||||
updateData.status = column.defaultStatus;
|
||||
if (column.defaultStatus !== 'completed') {
|
||||
updateData.isCompleted = false;
|
||||
updateData.completedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(tasks)
|
||||
.set(updateData)
|
||||
.where(and(eq(tasks.id, taskId), eq(tasks.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async reorderTasksInColumn(userId: string, columnId: string, taskIds: string[]): Promise<Task[]> {
|
||||
// Verify column exists
|
||||
await this.findColumnByIdOrThrow(columnId, userId);
|
||||
|
||||
// Update order for each task
|
||||
const updates = taskIds.map((id, index) =>
|
||||
this.db
|
||||
.update(tasks)
|
||||
.set({
|
||||
columnId,
|
||||
columnOrder: index,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
|
||||
);
|
||||
|
||||
await Promise.all(updates);
|
||||
|
||||
// Return updated tasks
|
||||
return this.db.query.tasks.findMany({
|
||||
where: and(eq(tasks.userId, userId), eq(tasks.columnId, columnId)),
|
||||
orderBy: [asc(tasks.columnOrder)],
|
||||
});
|
||||
}
|
||||
}
|
||||
104
apps/todo/apps/web/src/lib/api/kanban.ts
Normal file
104
apps/todo/apps/web/src/lib/api/kanban.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { apiClient } from './client';
|
||||
import type { KanbanColumn, Task } from '@todo/shared';
|
||||
|
||||
interface ColumnsResponse {
|
||||
columns: KanbanColumn[];
|
||||
}
|
||||
|
||||
interface ColumnResponse {
|
||||
column: KanbanColumn;
|
||||
}
|
||||
|
||||
interface KanbanTasksResponse {
|
||||
columns: KanbanColumn[];
|
||||
tasksByColumn: Record<string, Task[]>;
|
||||
}
|
||||
|
||||
interface TaskResponse {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
interface TasksResponse {
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
interface CreateColumnDto {
|
||||
name: string;
|
||||
color?: string;
|
||||
projectId?: string;
|
||||
isDefault?: boolean;
|
||||
defaultStatus?: string;
|
||||
autoComplete?: boolean;
|
||||
}
|
||||
|
||||
interface UpdateColumnDto {
|
||||
name?: string;
|
||||
color?: string;
|
||||
defaultStatus?: string;
|
||||
autoComplete?: boolean;
|
||||
}
|
||||
|
||||
// Column operations
|
||||
|
||||
export async function getColumns(projectId?: string): Promise<KanbanColumn[]> {
|
||||
const query = projectId ? `?projectId=${projectId}` : '';
|
||||
const response = await apiClient.get<ColumnsResponse>(`/api/v1/kanban/columns${query}`);
|
||||
return response.columns;
|
||||
}
|
||||
|
||||
export async function createColumn(data: CreateColumnDto): Promise<KanbanColumn> {
|
||||
const response = await apiClient.post<ColumnResponse>('/api/v1/kanban/columns', data);
|
||||
return response.column;
|
||||
}
|
||||
|
||||
export async function updateColumn(id: string, data: UpdateColumnDto): Promise<KanbanColumn> {
|
||||
const response = await apiClient.put<ColumnResponse>(`/api/v1/kanban/columns/${id}`, data);
|
||||
return response.column;
|
||||
}
|
||||
|
||||
export async function deleteColumn(id: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/kanban/columns/${id}`);
|
||||
}
|
||||
|
||||
export async function reorderColumns(columnIds: string[]): Promise<KanbanColumn[]> {
|
||||
const response = await apiClient.put<ColumnsResponse>('/api/v1/kanban/columns/reorder', {
|
||||
columnIds,
|
||||
});
|
||||
return response.columns;
|
||||
}
|
||||
|
||||
export async function initializeColumns(projectId?: string): Promise<KanbanColumn[]> {
|
||||
const query = projectId ? `?projectId=${projectId}` : '';
|
||||
const response = await apiClient.post<ColumnsResponse>(`/api/v1/kanban/columns/init${query}`);
|
||||
return response.columns;
|
||||
}
|
||||
|
||||
// Task operations
|
||||
|
||||
export async function getKanbanTasks(
|
||||
projectId?: string
|
||||
): Promise<{ columns: KanbanColumn[]; tasksByColumn: Record<string, Task[]> }> {
|
||||
const query = projectId ? `?projectId=${projectId}` : '';
|
||||
const response = await apiClient.get<KanbanTasksResponse>(`/api/v1/kanban/tasks${query}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function moveTaskToColumn(
|
||||
taskId: string,
|
||||
columnId: string,
|
||||
order?: number
|
||||
): Promise<Task> {
|
||||
const response = await apiClient.post<TaskResponse>(`/api/v1/kanban/tasks/${taskId}/move`, {
|
||||
columnId,
|
||||
order,
|
||||
});
|
||||
return response.task;
|
||||
}
|
||||
|
||||
export async function reorderTasksInColumn(columnId: string, taskIds: string[]): Promise<Task[]> {
|
||||
const response = await apiClient.put<TasksResponse>('/api/v1/kanban/tasks/reorder', {
|
||||
columnId,
|
||||
taskIds,
|
||||
});
|
||||
return response.tasks;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
onAdd: (name: string) => void;
|
||||
}
|
||||
|
||||
let { onAdd }: Props = $props();
|
||||
|
||||
let isAdding = $state(false);
|
||||
let newName = $state('');
|
||||
|
||||
function handleSubmit() {
|
||||
if (newName.trim()) {
|
||||
onAdd(newName.trim());
|
||||
newName = '';
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
handleSubmit();
|
||||
} else if (event.key === 'Escape') {
|
||||
newName = '';
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="add-column min-w-[280px] max-w-[320px] h-fit">
|
||||
{#if isAdding}
|
||||
<div class="bg-muted/50 rounded-xl p-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Spaltenname..."
|
||||
class="w-full px-3 py-2 text-sm bg-card border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary text-foreground placeholder:text-muted-foreground"
|
||||
autofocus
|
||||
/>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button
|
||||
class="flex-1 px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
onclick={handleSubmit}
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
onclick={() => {
|
||||
newName = '';
|
||||
isAdding = false;
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="w-full p-3 text-sm text-muted-foreground hover:text-foreground bg-muted/30 hover:bg-muted/50 rounded-xl border-2 border-dashed border-border hover:border-muted-foreground transition-colors flex items-center justify-center gap-2"
|
||||
onclick={() => (isAdding = true)}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Spalte hinzufügen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
183
apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte
Normal file
183
apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<script lang="ts">
|
||||
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import type { KanbanColumn, Task, TaskPriority } from '@todo/shared';
|
||||
import KanbanColumnComponent from './KanbanColumn.svelte';
|
||||
import AddColumnButton from './AddColumnButton.svelte';
|
||||
import { kanbanStore } from '$lib/stores/kanban.svelte';
|
||||
|
||||
interface Props {
|
||||
projectId?: string;
|
||||
filterPriorities?: TaskPriority[];
|
||||
filterProjectId?: string | null;
|
||||
filterLabelIds?: string[];
|
||||
filterSearchQuery?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
projectId,
|
||||
filterPriorities = [],
|
||||
filterProjectId = null,
|
||||
filterLabelIds = [],
|
||||
filterSearchQuery = '',
|
||||
}: Props = $props();
|
||||
|
||||
// Local columns state for drag and drop
|
||||
let localColumns = $state<KanbanColumn[]>([]);
|
||||
|
||||
// Sync with store
|
||||
$effect(() => {
|
||||
localColumns = [...kanbanStore.columns];
|
||||
});
|
||||
|
||||
const flipDurationMs = 200;
|
||||
|
||||
function handleColumnDndConsider(e: CustomEvent<{ items: KanbanColumn[] }>) {
|
||||
localColumns = e.detail.items;
|
||||
}
|
||||
|
||||
function handleColumnDndFinalize(e: CustomEvent<{ items: KanbanColumn[] }>) {
|
||||
localColumns = e.detail.items.filter((c) => c.id !== SHADOW_PLACEHOLDER_ITEM_ID);
|
||||
const columnIds = localColumns.map((c) => c.id);
|
||||
kanbanStore.reorderColumns(columnIds);
|
||||
}
|
||||
|
||||
async function handleAddColumn(name: string) {
|
||||
await kanbanStore.createColumn({ name, projectId });
|
||||
}
|
||||
|
||||
async function handleUpdateColumn(columnId: string, data: { name?: string; color?: string }) {
|
||||
await kanbanStore.updateColumn(columnId, data);
|
||||
}
|
||||
|
||||
async function handleDeleteColumn(columnId: string) {
|
||||
if (confirm('Spalte wirklich löschen? Alle Aufgaben werden in die erste Spalte verschoben.')) {
|
||||
await kanbanStore.deleteColumn(columnId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTasksReorder(columnId: string, taskIds: string[]) {
|
||||
await kanbanStore.reorderTasksInColumn(columnId, taskIds);
|
||||
}
|
||||
|
||||
async function handleAddTask(columnId: string, title: string) {
|
||||
await kanbanStore.createTaskInColumn(columnId, title, projectId);
|
||||
}
|
||||
|
||||
async function handleTaskMove(taskId: string, toColumnId: string, order: number) {
|
||||
// Find which column the task is currently in
|
||||
let fromColumnId: string | null = null;
|
||||
for (const [colId, tasks] of Object.entries(kanbanStore.tasksByColumn)) {
|
||||
if (tasks.some((t) => t.id === taskId)) {
|
||||
fromColumnId = colId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (fromColumnId && fromColumnId !== toColumnId) {
|
||||
await kanbanStore.moveTaskToColumn(taskId, fromColumnId, toColumnId, order);
|
||||
}
|
||||
}
|
||||
|
||||
function getTasksForColumn(columnId: string): Task[] {
|
||||
let tasks = kanbanStore.tasksByColumn[columnId] || [];
|
||||
|
||||
// Apply filters
|
||||
if (filterPriorities.length > 0) {
|
||||
tasks = tasks.filter((t) => filterPriorities.includes(t.priority));
|
||||
}
|
||||
|
||||
if (filterProjectId !== null) {
|
||||
tasks = tasks.filter((t) => t.projectId === filterProjectId);
|
||||
}
|
||||
|
||||
if (filterLabelIds.length > 0) {
|
||||
tasks = tasks.filter((t) => t.labels?.some((l) => filterLabelIds.includes(l.id)));
|
||||
}
|
||||
|
||||
if (filterSearchQuery.trim()) {
|
||||
const query = filterSearchQuery.toLowerCase().trim();
|
||||
tasks = tasks.filter(
|
||||
(t) =>
|
||||
t.title.toLowerCase().includes(query) ||
|
||||
(t.description && t.description.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="kanban-board h-full">
|
||||
{#if kanbanStore.loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="animate-spin h-8 w-8 border-4 border-primary border-r-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
{:else if kanbanStore.error}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
|
||||
{kanbanStore.error}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="columns-container flex gap-4 overflow-x-auto pb-4 h-full"
|
||||
use:dndzone={{
|
||||
items: localColumns,
|
||||
flipDurationMs,
|
||||
type: 'columns',
|
||||
dropTargetStyle: {},
|
||||
}}
|
||||
onconsider={handleColumnDndConsider}
|
||||
onfinalize={handleColumnDndFinalize}
|
||||
>
|
||||
{#each localColumns.filter((c) => c.id !== SHADOW_PLACEHOLDER_ITEM_ID) as column (column.id)}
|
||||
<div animate:flip={{ duration: flipDurationMs }} class="flex-shrink-0">
|
||||
<KanbanColumnComponent
|
||||
{column}
|
||||
tasks={getTasksForColumn(column.id)}
|
||||
onUpdateColumn={(data) => handleUpdateColumn(column.id, data)}
|
||||
onDeleteColumn={() => handleDeleteColumn(column.id)}
|
||||
onTasksReorder={(taskIds) => handleTasksReorder(column.id, taskIds)}
|
||||
onTaskMove={(taskId, toColumnId, order) => handleTaskMove(taskId, toColumnId, order)}
|
||||
onAddTask={(title) => handleAddTask(column.id, title)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add column button -->
|
||||
<div class="flex-shrink-0">
|
||||
<AddColumnButton onAdd={handleAddColumn} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kanban-board {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.columns-container {
|
||||
min-height: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.columns-container::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.columns-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.columns-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.columns-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
</style>
|
||||
129
apps/todo/apps/web/src/lib/components/kanban/KanbanColumn.svelte
Normal file
129
apps/todo/apps/web/src/lib/components/kanban/KanbanColumn.svelte
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<script lang="ts">
|
||||
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';
|
||||
import QuickAddTaskInline from './QuickAddTaskInline.svelte';
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
|
||||
interface Props {
|
||||
column: KanbanColumn;
|
||||
tasks: Task[];
|
||||
onUpdateColumn?: (data: { name?: string; color?: string }) => void;
|
||||
onDeleteColumn?: () => void;
|
||||
onTasksReorder?: (taskIds: string[]) => void;
|
||||
onTaskMove?: (taskId: string, toColumnId: string, order: number) => void;
|
||||
onAddTask?: (title: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
column,
|
||||
tasks,
|
||||
onUpdateColumn,
|
||||
onDeleteColumn,
|
||||
onTasksReorder,
|
||||
onTaskMove,
|
||||
onAddTask,
|
||||
}: Props = $props();
|
||||
|
||||
// Local tasks state for drag and drop
|
||||
let localTasks = $state<Task[]>([]);
|
||||
|
||||
// Sync with parent
|
||||
$effect(() => {
|
||||
localTasks = [...tasks];
|
||||
});
|
||||
|
||||
const flipDurationMs = 200;
|
||||
|
||||
function handleDndConsider(e: CustomEvent<{ items: Task[] }>) {
|
||||
localTasks = e.detail.items;
|
||||
}
|
||||
|
||||
function handleDndFinalize(
|
||||
e: CustomEvent<{ items: Task[]; info: { id: string; source: { items: Task[] } } }>
|
||||
) {
|
||||
const newItems = e.detail.items.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID);
|
||||
const movedTaskId = e.detail.info.id;
|
||||
|
||||
// Check if this task came from another column
|
||||
const movedTask = newItems.find((t) => t.id === movedTaskId);
|
||||
const wasInThisColumn = tasks.some((t) => t.id === movedTaskId);
|
||||
|
||||
if (movedTask && !wasInThisColumn) {
|
||||
// Task moved FROM another column TO this column
|
||||
const newIndex = newItems.findIndex((t) => t.id === movedTaskId);
|
||||
onTaskMove?.(movedTaskId, column.id, newIndex);
|
||||
} else {
|
||||
// Task reordered within this column
|
||||
const taskIds = newItems.map((t) => t.id);
|
||||
onTasksReorder?.(taskIds);
|
||||
}
|
||||
|
||||
localTasks = newItems;
|
||||
}
|
||||
|
||||
async function handleToggleComplete(task: Task) {
|
||||
if (task.isCompleted) {
|
||||
await tasksStore.uncompleteTask(task.id);
|
||||
} else {
|
||||
await tasksStore.completeTask(task.id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="kanban-column flex flex-col bg-muted/50 rounded-xl min-w-[280px] max-w-[320px] h-full">
|
||||
<!-- Header -->
|
||||
<KanbanColumnHeader
|
||||
{column}
|
||||
taskCount={localTasks.length}
|
||||
onUpdate={onUpdateColumn}
|
||||
onDelete={onDeleteColumn}
|
||||
/>
|
||||
|
||||
<!-- Tasks list with drag and drop -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto px-2 pb-2"
|
||||
use:dndzone={{
|
||||
items: localTasks,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: {},
|
||||
dropTargetClasses: ['drop-target'],
|
||||
type: 'tasks',
|
||||
}}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
>
|
||||
{#each localTasks.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID) as task (task.id)}
|
||||
<div animate:flip={{ duration: flipDurationMs }} class="mb-2">
|
||||
<KanbanTaskCard {task} onToggleComplete={() => handleToggleComplete(task)} />
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if localTasks.length === 0}
|
||||
<div class="text-center py-4 text-muted-foreground text-sm">
|
||||
<p>Keine Aufgaben</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quick Add Task -->
|
||||
{#if onAddTask}
|
||||
<div class="px-2 pb-2">
|
||||
<QuickAddTaskInline onAdd={onAddTask} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kanban-column {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
:global(.drop-target) {
|
||||
outline: 2px dashed var(--primary);
|
||||
outline-offset: -2px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
<script lang="ts">
|
||||
import type { KanbanColumn } from '@todo/shared';
|
||||
|
||||
interface Props {
|
||||
column: KanbanColumn;
|
||||
taskCount: number;
|
||||
onUpdate?: (data: { name?: string; color?: string }) => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
let { column, taskCount, onUpdate, onDelete }: Props = $props();
|
||||
|
||||
let isEditing = $state(false);
|
||||
let editName = $state(column.name);
|
||||
let showMenu = $state(false);
|
||||
let showColorPicker = $state(false);
|
||||
|
||||
const colors = [
|
||||
'#6B7280', // gray
|
||||
'#EF4444', // red
|
||||
'#F97316', // orange
|
||||
'#EAB308', // yellow
|
||||
'#22C55E', // green
|
||||
'#14B8A6', // teal
|
||||
'#3B82F6', // blue
|
||||
'#8B5CF6', // purple
|
||||
'#EC4899', // pink
|
||||
];
|
||||
|
||||
function handleSubmit() {
|
||||
if (editName.trim() && editName !== column.name) {
|
||||
onUpdate?.({ name: editName.trim() });
|
||||
}
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
handleSubmit();
|
||||
} else if (event.key === 'Escape') {
|
||||
editName = column.name;
|
||||
isEditing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleColorSelect(color: string) {
|
||||
onUpdate?.({ color });
|
||||
showColorPicker = false;
|
||||
showMenu = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="column-header flex items-center justify-between p-3 pb-2">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<!-- Color indicator -->
|
||||
<div class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: {column.color}"></div>
|
||||
|
||||
<!-- Name (editable) -->
|
||||
{#if isEditing}
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="text-sm font-semibold text-foreground truncate text-left hover:text-primary transition-colors"
|
||||
ondblclick={() => {
|
||||
if (!column.isDefault || onUpdate) {
|
||||
isEditing = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{column.name}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Task count -->
|
||||
<span class="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded-full flex-shrink-0">
|
||||
{taskCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Menu button -->
|
||||
{#if onUpdate || onDelete}
|
||||
<div class="relative">
|
||||
<button
|
||||
class="p-1 text-muted-foreground hover:text-foreground rounded transition-colors"
|
||||
onclick={() => (showMenu = !showMenu)}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showMenu}
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 bg-card border border-border rounded-lg shadow-lg py-1 z-50 min-w-[150px]"
|
||||
>
|
||||
{#if onUpdate}
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-accent flex items-center gap-2"
|
||||
onclick={() => {
|
||||
isEditing = true;
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
Umbenennen
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-accent flex items-center gap-2"
|
||||
onclick={() => (showColorPicker = !showColorPicker)}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
|
||||
/>
|
||||
</svg>
|
||||
Farbe ändern
|
||||
</button>
|
||||
|
||||
{#if showColorPicker}
|
||||
<div class="px-3 py-2 flex flex-wrap gap-1 border-t border-border mt-1 pt-2">
|
||||
{#each colors as color}
|
||||
<button
|
||||
class="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110"
|
||||
class:border-primary={color === column.color}
|
||||
class:border-transparent={color !== column.color}
|
||||
style="background-color: {color}"
|
||||
onclick={() => handleColorSelect(color)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if onDelete && !column.isDefault}
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"
|
||||
onclick={() => {
|
||||
onDelete?.();
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Löschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Click outside to close menu -->
|
||||
{#if showMenu}
|
||||
<button
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => {
|
||||
showMenu = false;
|
||||
showColorPicker = false;
|
||||
}}
|
||||
></button>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
<script lang="ts">
|
||||
import type { TaskPriority } from '@todo/shared';
|
||||
import { projectsStore } from '$lib/stores/projects.svelte';
|
||||
import { labelsStore } from '$lib/stores/labels.svelte';
|
||||
|
||||
interface Props {
|
||||
selectedPriorities: TaskPriority[];
|
||||
selectedProjectId: string | null;
|
||||
selectedLabelIds: string[];
|
||||
searchQuery: string;
|
||||
onPrioritiesChange: (priorities: TaskPriority[]) => void;
|
||||
onProjectChange: (projectId: string | null) => void;
|
||||
onLabelsChange: (labelIds: string[]) => void;
|
||||
onSearchChange: (query: string) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
selectedPriorities,
|
||||
selectedProjectId,
|
||||
selectedLabelIds,
|
||||
searchQuery,
|
||||
onPrioritiesChange,
|
||||
onProjectChange,
|
||||
onLabelsChange,
|
||||
onSearchChange,
|
||||
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' },
|
||||
];
|
||||
|
||||
let showLabelsDropdown = $state(false);
|
||||
|
||||
function togglePriority(priority: TaskPriority) {
|
||||
if (selectedPriorities.includes(priority)) {
|
||||
onPrioritiesChange(selectedPriorities.filter((p) => p !== priority));
|
||||
} else {
|
||||
onPrioritiesChange([...selectedPriorities, priority]);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLabel(labelId: string) {
|
||||
if (selectedLabelIds.includes(labelId)) {
|
||||
onLabelsChange(selectedLabelIds.filter((id) => id !== labelId));
|
||||
} else {
|
||||
onLabelsChange([...selectedLabelIds, labelId]);
|
||||
}
|
||||
}
|
||||
|
||||
let hasActiveFilters = $derived(
|
||||
selectedPriorities.length > 0 ||
|
||||
selectedProjectId !== null ||
|
||||
selectedLabelIds.length > 0 ||
|
||||
searchQuery.trim() !== ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="kanban-filters flex flex-wrap items-center gap-3 p-3 bg-muted/30 rounded-lg">
|
||||
<!-- Search -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
oninput={(e) => 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}
|
||||
<button
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onclick={() => onSearchChange('')}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Priority filters -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-muted-foreground mr-1">Priorität:</span>
|
||||
{#each priorities as priority}
|
||||
<button
|
||||
class="px-2 py-1 text-xs rounded-full transition-all {selectedPriorities.includes(
|
||||
priority.value
|
||||
)
|
||||
? `${priority.color} text-white`
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
|
||||
onclick={() => togglePriority(priority.value)}
|
||||
>
|
||||
{priority.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Project filter -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-muted-foreground">Projekt:</span>
|
||||
<select
|
||||
class="px-2 py-1 text-sm bg-background border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary/50"
|
||||
value={selectedProjectId || ''}
|
||||
onchange={(e) => onProjectChange(e.currentTarget.value || null)}
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{#each projectsStore.activeProjects as project}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Labels filter -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-2 py-1 text-sm bg-background border border-border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
onclick={() => (showLabelsDropdown = !showLabelsDropdown)}
|
||||
>
|
||||
<span class="text-xs text-muted-foreground">Labels:</span>
|
||||
{#if selectedLabelIds.length > 0}
|
||||
<span class="px-1.5 py-0.5 text-xs bg-primary text-primary-foreground rounded">
|
||||
{selectedLabelIds.length}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">Alle</span>
|
||||
{/if}
|
||||
<svg
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showLabelsDropdown}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40" onclick={() => (showLabelsDropdown = false)}></div>
|
||||
<div
|
||||
class="absolute top-full left-0 mt-1 z-50 min-w-[200px] bg-popover border border-border rounded-lg shadow-lg p-2"
|
||||
>
|
||||
{#if labelsStore.labels.length === 0}
|
||||
<p class="text-sm text-muted-foreground p-2">Keine Labels vorhanden</p>
|
||||
{:else}
|
||||
{#each labelsStore.labels as label}
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-muted/50 transition-colors"
|
||||
onclick={() => toggleLabel(label.id)}
|
||||
>
|
||||
<div class="w-3 h-3 rounded-full" style="background-color: {label.color}"></div>
|
||||
<span class="flex-1 text-left">{label.name}</span>
|
||||
{#if selectedLabelIds.includes(label.id)}
|
||||
<svg
|
||||
class="w-4 h-4 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Clear filters -->
|
||||
{#if hasActiveFilters}
|
||||
<button
|
||||
class="ml-auto px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded transition-colors"
|
||||
onclick={onClearFilters}
|
||||
>
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
<script lang="ts">
|
||||
import type { Task } from '@todo/shared';
|
||||
import { format, isToday, isPast, isTomorrow } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
onToggleComplete?: () => void;
|
||||
}
|
||||
|
||||
let { task, onToggleComplete }: Props = $props();
|
||||
|
||||
// Priority colors
|
||||
const priorityColors: Record<string, string> = {
|
||||
low: 'bg-green-500',
|
||||
medium: 'bg-yellow-500',
|
||||
high: 'bg-orange-500',
|
||||
urgent: 'bg-red-500',
|
||||
};
|
||||
|
||||
// Format due date
|
||||
let dueDateText = $derived(() => {
|
||||
if (!task.dueDate) return null;
|
||||
const date = new Date(task.dueDate);
|
||||
if (isToday(date)) return 'Heute';
|
||||
if (isTomorrow(date)) return 'Morgen';
|
||||
return format(date, 'dd. MMM', { locale: de });
|
||||
});
|
||||
|
||||
// Check if overdue
|
||||
let isOverdue = $derived(() => {
|
||||
if (!task.dueDate || task.isCompleted) return false;
|
||||
const date = new Date(task.dueDate);
|
||||
return isPast(date) && !isToday(date);
|
||||
});
|
||||
|
||||
// Subtasks progress
|
||||
let subtaskProgress = $derived(() => {
|
||||
if (!task.subtasks || task.subtasks.length === 0) return null;
|
||||
const completed = task.subtasks.filter((s) => s.isCompleted).length;
|
||||
return `${completed}/${task.subtasks.length}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="kanban-card group bg-card border border-border rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow cursor-grab active:cursor-grabbing"
|
||||
class:opacity-60={task.isCompleted}
|
||||
>
|
||||
<!-- Priority indicator -->
|
||||
<div class="flex items-start gap-2">
|
||||
<div
|
||||
class="priority-dot {priorityColors[task.priority]} w-2 h-2 rounded-full mt-1.5 flex-shrink-0"
|
||||
></div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Title -->
|
||||
<h4
|
||||
class="text-sm font-medium text-foreground line-clamp-2"
|
||||
class:line-through={task.isCompleted}
|
||||
>
|
||||
{task.title}
|
||||
</h4>
|
||||
|
||||
<!-- Meta info -->
|
||||
{#if dueDateText() || subtaskProgress() || (task.labels && task.labels.length > 0)}
|
||||
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
||||
{#if dueDateText()}
|
||||
<span
|
||||
class="text-xs flex items-center gap-1 px-1.5 py-0.5 rounded {isOverdue()
|
||||
? 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'bg-muted text-muted-foreground'}"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{dueDateText()}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if subtaskProgress()}
|
||||
<span
|
||||
class="text-xs text-muted-foreground flex items-center gap-1 bg-muted px-1.5 py-0.5 rounded"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
{subtaskProgress()}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if task.labels && task.labels.length > 0}
|
||||
<div class="flex items-center gap-1">
|
||||
{#each task.labels.slice(0, 2) as label}
|
||||
<span
|
||||
class="w-4 h-1.5 rounded-full"
|
||||
style="background-color: {label.color}"
|
||||
title={label.name}
|
||||
></span>
|
||||
{/each}
|
||||
{#if task.labels.length > 2}
|
||||
<span class="text-xs text-muted-foreground">+{task.labels.length - 2}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Complete button -->
|
||||
{#if onToggleComplete}
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 flex-shrink-0 w-5 h-5 rounded-full border-2 border-muted-foreground hover:border-primary flex items-center justify-center transition-opacity"
|
||||
class:bg-primary={task.isCompleted}
|
||||
class:border-primary={task.isCompleted}
|
||||
onclick={onToggleComplete}
|
||||
>
|
||||
{#if task.isCompleted}
|
||||
<svg class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="3"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kanban-card {
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
onAdd: (title: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { onAdd, placeholder = 'Neue Aufgabe...' }: Props = $props();
|
||||
|
||||
let isAdding = $state(false);
|
||||
let title = $state('');
|
||||
let inputRef = $state<HTMLInputElement | null>(null);
|
||||
|
||||
function handleSubmit() {
|
||||
if (title.trim()) {
|
||||
onAdd(title.trim());
|
||||
title = '';
|
||||
// Keep input open for rapid adding
|
||||
inputRef?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (event.key === 'Escape') {
|
||||
title = '';
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (!title.trim()) {
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="quick-add-inline">
|
||||
{#if isAdding}
|
||||
<div class="bg-card border border-border rounded-lg p-2 shadow-sm">
|
||||
<input
|
||||
bind:this={inputRef}
|
||||
bind:value={title}
|
||||
onkeydown={handleKeydown}
|
||||
onblur={handleBlur}
|
||||
{placeholder}
|
||||
class="w-full px-2 py-1.5 text-sm bg-transparent outline-none text-foreground placeholder:text-muted-foreground"
|
||||
autofocus
|
||||
/>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<button
|
||||
class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors"
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={handleSubmit}
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => {
|
||||
title = '';
|
||||
isAdding = false;
|
||||
}}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="w-full p-2 text-sm text-muted-foreground hover:text-foreground hover:bg-card/50 rounded-lg transition-colors flex items-center gap-2"
|
||||
onclick={() => (isAdding = true)}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Aufgabe hinzufügen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
6
apps/todo/apps/web/src/lib/components/kanban/index.ts
Normal file
6
apps/todo/apps/web/src/lib/components/kanban/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { default as KanbanBoard } from './KanbanBoard.svelte';
|
||||
export { default as KanbanColumn } from './KanbanColumn.svelte';
|
||||
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';
|
||||
288
apps/todo/apps/web/src/lib/stores/kanban.svelte.ts
Normal file
288
apps/todo/apps/web/src/lib/stores/kanban.svelte.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
/**
|
||||
* Kanban Store - Manages kanban board state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { KanbanColumn, Task } from '@todo/shared';
|
||||
import * as kanbanApi from '$lib/api/kanban';
|
||||
import * as tasksApi from '$lib/api/tasks';
|
||||
|
||||
// State
|
||||
let columns = $state<KanbanColumn[]>([]);
|
||||
let tasksByColumn = $state<Record<string, Task[]>>({});
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const kanbanStore = {
|
||||
// Getters
|
||||
get columns() {
|
||||
return columns;
|
||||
},
|
||||
get tasksByColumn() {
|
||||
return tasksByColumn;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch columns and tasks grouped by column
|
||||
*/
|
||||
async fetchKanbanData(projectId?: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const data = await kanbanApi.getKanbanTasks(projectId);
|
||||
columns = data.columns;
|
||||
tasksByColumn = data.tasksByColumn;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch kanban data';
|
||||
console.error('Failed to fetch kanban data:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch only columns
|
||||
*/
|
||||
async fetchColumns(projectId?: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
columns = await kanbanApi.getColumns(projectId);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch columns';
|
||||
console.error('Failed to fetch columns:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new column
|
||||
*/
|
||||
async createColumn(data: {
|
||||
name: string;
|
||||
color?: string;
|
||||
projectId?: string;
|
||||
defaultStatus?: string;
|
||||
autoComplete?: boolean;
|
||||
}) {
|
||||
error = null;
|
||||
try {
|
||||
const newColumn = await kanbanApi.createColumn(data);
|
||||
columns = [...columns, newColumn];
|
||||
tasksByColumn[newColumn.id] = [];
|
||||
return newColumn;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create column';
|
||||
console.error('Failed to create column:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a column
|
||||
*/
|
||||
async updateColumn(
|
||||
id: string,
|
||||
data: {
|
||||
name?: string;
|
||||
color?: string;
|
||||
defaultStatus?: string;
|
||||
autoComplete?: boolean;
|
||||
}
|
||||
) {
|
||||
error = null;
|
||||
try {
|
||||
const updated = await kanbanApi.updateColumn(id, data);
|
||||
columns = columns.map((c) => (c.id === id ? updated : c));
|
||||
return updated;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update column';
|
||||
console.error('Failed to update column:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a column
|
||||
*/
|
||||
async deleteColumn(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await kanbanApi.deleteColumn(id);
|
||||
columns = columns.filter((c) => c.id !== id);
|
||||
delete tasksByColumn[id];
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete column';
|
||||
console.error('Failed to delete column:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder columns (optimistic update)
|
||||
*/
|
||||
async reorderColumns(columnIds: string[]) {
|
||||
error = null;
|
||||
const previousColumns = [...columns];
|
||||
try {
|
||||
// Optimistic update
|
||||
columns = columnIds
|
||||
.map((id) => columns.find((c) => c.id === id))
|
||||
.filter((c): c is KanbanColumn => c !== undefined);
|
||||
|
||||
// Persist to server
|
||||
const updated = await kanbanApi.reorderColumns(columnIds);
|
||||
columns = updated;
|
||||
} catch (e) {
|
||||
// Rollback on error
|
||||
columns = previousColumns;
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder columns';
|
||||
console.error('Failed to reorder columns:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Move task to a different column (optimistic update)
|
||||
*/
|
||||
async moveTaskToColumn(taskId: string, fromColumnId: string, toColumnId: string, order?: number) {
|
||||
error = null;
|
||||
const previousTasksByColumn = { ...tasksByColumn };
|
||||
|
||||
try {
|
||||
// Find the task
|
||||
const task = tasksByColumn[fromColumnId]?.find((t) => t.id === taskId);
|
||||
if (!task) {
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
tasksByColumn[fromColumnId] = tasksByColumn[fromColumnId].filter((t) => t.id !== taskId);
|
||||
|
||||
if (!tasksByColumn[toColumnId]) {
|
||||
tasksByColumn[toColumnId] = [];
|
||||
}
|
||||
|
||||
const insertIndex = order ?? tasksByColumn[toColumnId].length;
|
||||
const updatedTask = { ...task, columnId: toColumnId, columnOrder: insertIndex };
|
||||
tasksByColumn[toColumnId] = [
|
||||
...tasksByColumn[toColumnId].slice(0, insertIndex),
|
||||
updatedTask,
|
||||
...tasksByColumn[toColumnId].slice(insertIndex),
|
||||
];
|
||||
|
||||
// Persist to server
|
||||
await kanbanApi.moveTaskToColumn(taskId, toColumnId, order);
|
||||
} catch (e) {
|
||||
// Rollback on error
|
||||
tasksByColumn = previousTasksByColumn;
|
||||
error = e instanceof Error ? e.message : 'Failed to move task';
|
||||
console.error('Failed to move task:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder tasks within a column (optimistic update)
|
||||
*/
|
||||
async reorderTasksInColumn(columnId: string, taskIds: string[]) {
|
||||
error = null;
|
||||
const previousTasks = [...(tasksByColumn[columnId] || [])];
|
||||
|
||||
try {
|
||||
// Optimistic update
|
||||
const columnTasks = tasksByColumn[columnId] || [];
|
||||
tasksByColumn[columnId] = taskIds
|
||||
.map((id) => columnTasks.find((t) => t.id === id))
|
||||
.filter((t): t is Task => t !== undefined);
|
||||
|
||||
// Persist to server
|
||||
await kanbanApi.reorderTasksInColumn(columnId, taskIds);
|
||||
} catch (e) {
|
||||
// Rollback on error
|
||||
tasksByColumn[columnId] = previousTasks;
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder tasks';
|
||||
console.error('Failed to reorder tasks:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize default columns if none exist
|
||||
*/
|
||||
async initializeDefaultColumns(projectId?: string) {
|
||||
error = null;
|
||||
try {
|
||||
const newColumns = await kanbanApi.initializeColumns(projectId);
|
||||
columns = newColumns;
|
||||
// Initialize empty task arrays for each column
|
||||
for (const col of newColumns) {
|
||||
if (!tasksByColumn[col.id]) {
|
||||
tasksByColumn[col.id] = [];
|
||||
}
|
||||
}
|
||||
return newColumns;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to initialize columns';
|
||||
console.error('Failed to initialize columns:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks for a specific column
|
||||
*/
|
||||
getTasksForColumn(columnId: string): Task[] {
|
||||
return tasksByColumn[columnId] || [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new task in a specific column
|
||||
*/
|
||||
async createTaskInColumn(columnId: string, title: string, projectId?: string) {
|
||||
error = null;
|
||||
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({
|
||||
title,
|
||||
projectId,
|
||||
priority: 'medium',
|
||||
});
|
||||
|
||||
// Move task to the column (this will set columnId and status)
|
||||
const movedTask = await kanbanApi.moveTaskToColumn(newTask.id, columnId, 0);
|
||||
|
||||
// Add to local state at the beginning of the column
|
||||
if (!tasksByColumn[columnId]) {
|
||||
tasksByColumn[columnId] = [];
|
||||
}
|
||||
tasksByColumn[columnId] = [movedTask, ...tasksByColumn[columnId]];
|
||||
|
||||
return movedTask;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create task';
|
||||
console.error('Failed to create task in column:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all state (for logout)
|
||||
*/
|
||||
clear() {
|
||||
columns = [];
|
||||
tasksByColumn = {};
|
||||
loading = false;
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
|
|
@ -66,6 +66,7 @@
|
|||
// Navigation items for Todo
|
||||
const navItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Aufgaben', icon: 'list' },
|
||||
{ href: '/kanban', label: 'Kanban', icon: 'columns' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
|
|
|||
75
apps/todo/apps/web/src/routes/(app)/kanban/+page.svelte
Normal file
75
apps/todo/apps/web/src/routes/(app)/kanban/+page.svelte
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { TaskPriority } from '@todo/shared';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { kanbanStore } from '$lib/stores/kanban.svelte';
|
||||
import { KanbanBoard, KanbanFilters } from '$lib/components/kanban';
|
||||
|
||||
// Filter state
|
||||
let filterPriorities = $state<TaskPriority[]>([]);
|
||||
let filterProjectId = $state<string | null>(null);
|
||||
let filterLabelIds = $state<string[]>([]);
|
||||
let filterSearchQuery = $state('');
|
||||
|
||||
function clearFilters() {
|
||||
filterPriorities = [];
|
||||
filterProjectId = null;
|
||||
filterLabelIds = [];
|
||||
filterSearchQuery = '';
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch kanban data (columns + tasks grouped by column)
|
||||
await kanbanStore.fetchKanbanData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Kanban Board - Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="kanban-page h-full">
|
||||
<header class="mb-4">
|
||||
<h1 class="text-2xl font-bold text-foreground">Kanban Board</h1>
|
||||
<p class="text-muted-foreground text-sm mt-1">
|
||||
Ziehe Aufgaben zwischen Spalten, um ihren Status zu ändern
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mb-4">
|
||||
<KanbanFilters
|
||||
selectedPriorities={filterPriorities}
|
||||
selectedProjectId={filterProjectId}
|
||||
selectedLabelIds={filterLabelIds}
|
||||
searchQuery={filterSearchQuery}
|
||||
onPrioritiesChange={(priorities) => (filterPriorities = priorities)}
|
||||
onProjectChange={(projectId) => (filterProjectId = projectId)}
|
||||
onLabelsChange={(labelIds) => (filterLabelIds = labelIds)}
|
||||
onSearchChange={(query) => (filterSearchQuery = query)}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="board-container">
|
||||
<KanbanBoard {filterPriorities} {filterProjectId} {filterLabelIds} {filterSearchQuery} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kanban-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.board-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,3 +2,4 @@ export * from './project';
|
|||
export * from './task';
|
||||
export * from './label';
|
||||
export * from './reminder';
|
||||
export * from './kanban';
|
||||
|
|
|
|||
45
apps/todo/packages/shared/src/types/kanban.ts
Normal file
45
apps/todo/packages/shared/src/types/kanban.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { TaskStatus } from './task';
|
||||
|
||||
export interface KanbanColumn {
|
||||
id: string;
|
||||
userId: string;
|
||||
projectId?: string | null;
|
||||
name: string;
|
||||
color: string;
|
||||
order: number;
|
||||
isDefault: boolean;
|
||||
defaultStatus?: TaskStatus | null;
|
||||
autoComplete: boolean;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
export interface CreateColumnInput {
|
||||
name: string;
|
||||
color?: string;
|
||||
projectId?: string;
|
||||
isDefault?: boolean;
|
||||
defaultStatus?: TaskStatus;
|
||||
autoComplete?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateColumnInput {
|
||||
name?: string;
|
||||
color?: string;
|
||||
defaultStatus?: TaskStatus;
|
||||
autoComplete?: boolean;
|
||||
}
|
||||
|
||||
export interface ReorderColumnsInput {
|
||||
columnIds: string[];
|
||||
}
|
||||
|
||||
export interface MoveTaskToColumnInput {
|
||||
columnId: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface ReorderTasksInColumnInput {
|
||||
columnId: string;
|
||||
taskIds: string[];
|
||||
}
|
||||
|
|
@ -43,6 +43,10 @@ export interface Task {
|
|||
// Ordering
|
||||
order: number;
|
||||
|
||||
// Kanban
|
||||
columnId?: string | null;
|
||||
columnOrder?: number;
|
||||
|
||||
// Recurrence
|
||||
recurrenceRule?: string | null;
|
||||
recurrenceEndDate?: Date | string | null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue