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:
Till-JS 2025-12-07 16:36:17 +01:00
parent 7b8335a3fb
commit bb59227aff
27 changed files with 2057 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import { ProjectModule } from './project/project.module';
import { TaskModule } from './task/task.module'; import { TaskModule } from './task/task.module';
import { LabelModule } from './label/label.module'; import { LabelModule } from './label/label.module';
import { ReminderModule } from './reminder/reminder.module'; import { ReminderModule } from './reminder/reminder.module';
import { KanbanModule } from './kanban/kanban.module';
@Module({ @Module({
imports: [ imports: [
@ -21,6 +22,7 @@ import { ReminderModule } from './reminder/reminder.module';
TaskModule, TaskModule,
LabelModule, LabelModule,
ReminderModule, ReminderModule,
KanbanModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View file

@ -1,4 +1,5 @@
export * from './projects.schema'; export * from './projects.schema';
export * from './kanban-columns.schema';
export * from './tasks.schema'; export * from './tasks.schema';
export * from './labels.schema'; export * from './labels.schema';
export * from './task-labels.schema'; export * from './task-labels.schema';

View file

@ -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;

View file

@ -10,6 +10,7 @@ import {
index, index,
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
import { projects } from './projects.schema'; import { projects } from './projects.schema';
import { kanbanColumns } from './kanban-columns.schema';
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent'; export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
@ -56,6 +57,10 @@ export const tasks = pgTable(
// Ordering // Ordering
order: integer('order').default(0), 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) // Recurrence (RFC 5545 RRULE format)
recurrenceRule: varchar('recurrence_rule', { length: 500 }), recurrenceRule: varchar('recurrence_rule', { length: 500 }),
recurrenceEndDate: timestamp('recurrence_end_date', { withTimezone: true }), 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), statusIdx: index('tasks_status_idx').on(table.isCompleted, table.status),
parentIdx: index('tasks_parent_idx').on(table.parentTaskId), parentIdx: index('tasks_parent_idx').on(table.parentTaskId),
orderIdx: index('tasks_order_idx').on(table.projectId, table.order), orderIdx: index('tasks_order_idx').on(table.projectId, table.order),
columnIdx: index('tasks_column_idx').on(table.columnId, table.columnOrder),
}) })
); );

View 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;
}

View 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';

View 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[];
}

View file

@ -0,0 +1,7 @@
import { IsArray, IsString } from 'class-validator';
export class ReorderColumnsDto {
@IsArray()
@IsString({ each: true })
columnIds: string[];
}

View 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;
}

View 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 };
}
}

View 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 {}

View 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)],
});
}
}

View 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;
}

View file

@ -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>

View 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>

View 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>

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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';

View 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;
},
};

View file

@ -66,6 +66,7 @@
// Navigation items for Todo // Navigation items for Todo
const navItems: PillNavItem[] = [ const navItems: PillNavItem[] = [
{ href: '/', label: 'Aufgaben', icon: 'list' }, { href: '/', label: 'Aufgaben', icon: 'list' },
{ href: '/kanban', label: 'Kanban', icon: 'columns' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' }, { href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' }, { href: '/feedback', label: 'Feedback', icon: 'chat' },
]; ];

View 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>

View file

@ -2,3 +2,4 @@ export * from './project';
export * from './task'; export * from './task';
export * from './label'; export * from './label';
export * from './reminder'; export * from './reminder';
export * from './kanban';

View 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[];
}

View file

@ -43,6 +43,10 @@ export interface Task {
// Ordering // Ordering
order: number; order: number;
// Kanban
columnId?: string | null;
columnOrder?: number;
// Recurrence // Recurrence
recurrenceRule?: string | null; recurrenceRule?: string | null;
recurrenceEndDate?: Date | string | null; recurrenceEndDate?: Date | string | null;