mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(todo): add task edit modal and fix task loading
- Add TaskEditModal with full task editing (title, description, dates, priority, status, project, labels, subtasks, recurrence, notes, storypoints, duration, fun rating) - Add SubtaskList component with drag-and-drop reordering - Add onEdit prop to TaskItem and TaskList for modal integration - Fix task loading on homepage by simplifying fetchAllTasks to load all tasks without isCompleted filter - Fix isCompleted query parameter parsing in backend DTO 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
863dd621f5
commit
3e35e6a2f4
7 changed files with 1718 additions and 25 deletions
|
|
@ -28,7 +28,11 @@ export class QueryTasksDto {
|
|||
status?: TaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@Transform(({ value }) => {
|
||||
if (value === 'true' || value === true) return true;
|
||||
if (value === 'false' || value === false) return false;
|
||||
return undefined;
|
||||
})
|
||||
@IsBoolean()
|
||||
isCompleted?: boolean;
|
||||
|
||||
|
|
|
|||
404
apps/todo/apps/web/src/lib/components/SubtaskList.svelte
Normal file
404
apps/todo/apps/web/src/lib/components/SubtaskList.svelte
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
<script lang="ts">
|
||||
import type { Subtask } from '@todo/shared';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
interface Props {
|
||||
subtasks: Subtask[];
|
||||
onChange: (subtasks: Subtask[]) => void;
|
||||
}
|
||||
|
||||
let { subtasks, onChange }: Props = $props();
|
||||
|
||||
let newSubtaskTitle = $state('');
|
||||
let editingId = $state<string | null>(null);
|
||||
let editingTitle = $state('');
|
||||
|
||||
// Convert subtasks to items with id for dnd
|
||||
let items = $derived(
|
||||
subtasks.map((s, index) => ({
|
||||
...s,
|
||||
id: s.id,
|
||||
order: index,
|
||||
}))
|
||||
);
|
||||
|
||||
function handleDndConsider(e: CustomEvent<{ items: Subtask[] }>) {
|
||||
onChange(e.detail.items.map((item, index) => ({ ...item, order: index })));
|
||||
}
|
||||
|
||||
function handleDndFinalize(e: CustomEvent<{ items: Subtask[] }>) {
|
||||
onChange(e.detail.items.map((item, index) => ({ ...item, order: index })));
|
||||
}
|
||||
|
||||
function toggleComplete(id: string) {
|
||||
const updated = subtasks.map((s) =>
|
||||
s.id === id
|
||||
? {
|
||||
...s,
|
||||
isCompleted: !s.isCompleted,
|
||||
completedAt: !s.isCompleted ? new Date().toISOString() : null,
|
||||
}
|
||||
: s
|
||||
);
|
||||
onChange(updated);
|
||||
}
|
||||
|
||||
function deleteSubtask(id: string) {
|
||||
onChange(subtasks.filter((s) => s.id !== id));
|
||||
}
|
||||
|
||||
function startEditing(subtask: Subtask) {
|
||||
editingId = subtask.id;
|
||||
editingTitle = subtask.title;
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (!editingId || !editingTitle.trim()) {
|
||||
cancelEdit();
|
||||
return;
|
||||
}
|
||||
const updated = subtasks.map((s) =>
|
||||
s.id === editingId ? { ...s, title: editingTitle.trim() } : s
|
||||
);
|
||||
onChange(updated);
|
||||
cancelEdit();
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
editingTitle = '';
|
||||
}
|
||||
|
||||
function handleEditKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
saveEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
cancelEdit();
|
||||
}
|
||||
}
|
||||
|
||||
function addSubtask() {
|
||||
if (!newSubtaskTitle.trim()) return;
|
||||
|
||||
const newSubtask: Subtask = {
|
||||
id: crypto.randomUUID(),
|
||||
title: newSubtaskTitle.trim(),
|
||||
isCompleted: false,
|
||||
order: subtasks.length,
|
||||
};
|
||||
|
||||
onChange([...subtasks, newSubtask]);
|
||||
newSubtaskTitle = '';
|
||||
}
|
||||
|
||||
function handleAddKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addSubtask();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="subtask-list">
|
||||
{#if items.length > 0}
|
||||
<div
|
||||
class="subtask-items"
|
||||
use:dndzone={{ items, flipDurationMs: 200, dropTargetStyle: {} }}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
>
|
||||
{#each items as subtask (subtask.id)}
|
||||
<div class="subtask-item" animate:flip={{ duration: 200 }}>
|
||||
<!-- Drag handle -->
|
||||
<div class="drag-handle" aria-label="Ziehen zum Sortieren">
|
||||
<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="M4 8h16M4 16h16"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Checkbox -->
|
||||
<button
|
||||
type="button"
|
||||
class="subtask-checkbox"
|
||||
class:checked={subtask.isCompleted}
|
||||
onclick={() => toggleComplete(subtask.id)}
|
||||
>
|
||||
{#if subtask.isCompleted}
|
||||
<svg class="check-icon" 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>
|
||||
|
||||
<!-- Title (editable) -->
|
||||
{#if editingId === subtask.id}
|
||||
<input
|
||||
type="text"
|
||||
class="subtask-edit-input"
|
||||
bind:value={editingTitle}
|
||||
onkeydown={handleEditKeydown}
|
||||
onblur={saveEdit}
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="subtask-title"
|
||||
class:completed={subtask.isCompleted}
|
||||
ondblclick={() => startEditing(subtask)}
|
||||
>
|
||||
{subtask.title}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
type="button"
|
||||
class="subtask-delete"
|
||||
onclick={() => deleteSubtask(subtask.id)}
|
||||
title="Löschen"
|
||||
>
|
||||
<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>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add new subtask -->
|
||||
<div class="add-subtask">
|
||||
<div class="add-icon">
|
||||
<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>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="add-input"
|
||||
placeholder="Subtask hinzufügen..."
|
||||
bind:value={newSubtaskTitle}
|
||||
onkeydown={handleAddKeydown}
|
||||
/>
|
||||
{#if newSubtaskTitle.trim()}
|
||||
<button type="button" class="add-btn" onclick={addSubtask}> Hinzufügen </button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.subtask-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.subtask-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.subtask-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .subtask-item {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.subtask-item:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
:global(.dark) .subtask-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: #9ca3af;
|
||||
padding: 0.25rem;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.subtask-item:hover .drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.subtask-checkbox {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.dark) .subtask-checkbox {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.subtask-checkbox:hover {
|
||||
border-color: #8b5cf6;
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.subtask-checkbox.checked {
|
||||
background: #8b5cf6;
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.subtask-title {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
:global(.dark) .subtask-title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.subtask-title.completed {
|
||||
text-decoration: line-through;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.subtask-edit-input {
|
||||
flex: 1;
|
||||
background: white;
|
||||
border: 1px solid #8b5cf6;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(.dark) .subtask-edit-input {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.subtask-delete {
|
||||
opacity: 0;
|
||||
padding: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.subtask-item:hover .subtask-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.subtask-delete:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* Add subtask */
|
||||
.add-subtask {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px dashed rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .add-subtask {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.add-subtask:focus-within {
|
||||
border-color: #8b5cf6;
|
||||
background: rgba(139, 92, 246, 0.05);
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.add-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.add-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
:global(.dark) .add-input {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
background: #8b5cf6;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: #7c3aed;
|
||||
}
|
||||
</style>
|
||||
1220
apps/todo/apps/web/src/lib/components/TaskEditModal.svelte
Normal file
1220
apps/todo/apps/web/src/lib/components/TaskEditModal.svelte
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -9,9 +9,16 @@
|
|||
showCompleted?: boolean;
|
||||
onToggleComplete: () => void;
|
||||
onDelete: () => void;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
let { task, showCompleted = false, onToggleComplete, onDelete }: Props = $props();
|
||||
let { task, showCompleted = false, onToggleComplete, onDelete, onEdit }: Props = $props();
|
||||
|
||||
function handleContentClick() {
|
||||
if (onEdit) {
|
||||
onEdit();
|
||||
}
|
||||
}
|
||||
|
||||
// Priority colors
|
||||
const priorityColors: Record<string, string> = {
|
||||
|
|
@ -67,8 +74,8 @@
|
|||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="task-content">
|
||||
<!-- Content (clickable to edit) -->
|
||||
<button type="button" class="task-content" onclick={handleContentClick}>
|
||||
<span class="task-title" class:line-through={task.isCompleted}>
|
||||
{task.title}
|
||||
</span>
|
||||
|
|
@ -120,7 +127,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Project indicator -->
|
||||
{#if projectColor()}
|
||||
|
|
@ -232,6 +239,11 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@
|
|||
interface Props {
|
||||
tasks: Task[];
|
||||
showCompleted?: boolean;
|
||||
onEditTask?: (task: Task) => void;
|
||||
}
|
||||
|
||||
let { tasks, showCompleted = false }: Props = $props();
|
||||
let { tasks, showCompleted = false, onEditTask }: Props = $props();
|
||||
|
||||
async function handleToggleComplete(task: Task) {
|
||||
if (task.isCompleted) {
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
{showCompleted}
|
||||
onToggleComplete={() => handleToggleComplete(task)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
onEdit={onEditTask ? () => onEditTask(task) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -119,18 +119,10 @@ export const tasksStore = {
|
|||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
// Fetch both incomplete and completed tasks
|
||||
const [incompleteTasks, completedTasks] = await Promise.all([
|
||||
tasksApi.getTasks({ isCompleted: false }),
|
||||
tasksApi.getTasks({ isCompleted: true }),
|
||||
]);
|
||||
// Deduplicate tasks by ID (in case API returns duplicates)
|
||||
const allTasks = [...incompleteTasks, ...completedTasks];
|
||||
const uniqueTasksMap = new Map<string, Task>();
|
||||
for (const task of allTasks) {
|
||||
uniqueTasksMap.set(task.id, task);
|
||||
}
|
||||
tasks = Array.from(uniqueTasksMap.values());
|
||||
// Fetch all tasks without filter - let frontend handle filtering
|
||||
const allTasks = await tasksApi.getTasks({});
|
||||
console.log('API response - all tasks:', allTasks.length);
|
||||
tasks = allTasks;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch all tasks';
|
||||
console.error('Failed to fetch all tasks:', e);
|
||||
|
|
@ -171,9 +163,13 @@ export const tasksStore = {
|
|||
* Get tasks due today
|
||||
*/
|
||||
get todayTasks(): Task[] {
|
||||
const today = startOfDay(new Date());
|
||||
return tasks.filter((t) => {
|
||||
if (!t.dueDate || t.isCompleted) return false;
|
||||
return isToday(new Date(t.dueDate));
|
||||
if (t.isCompleted) return false;
|
||||
// Include tasks without dueDate as "today" tasks (inbox behavior)
|
||||
if (!t.dueDate) return true;
|
||||
const taskDate = startOfDay(new Date(t.dueDate));
|
||||
return taskDate.getTime() === today.getTime();
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@
|
|||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
import QuickAddTask from '$lib/components/QuickAddTask.svelte';
|
||||
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
|
||||
import TaskEditModal from '$lib/components/TaskEditModal.svelte';
|
||||
import type { Task } from '@todo/shared';
|
||||
|
||||
let isLoading = $state(true);
|
||||
let editingTask = $state<Task | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
|
|
@ -21,7 +23,13 @@
|
|||
}
|
||||
|
||||
viewStore.setToday();
|
||||
await tasksStore.fetchAllTasks();
|
||||
|
||||
try {
|
||||
await tasksStore.fetchAllTasks();
|
||||
} catch (error) {
|
||||
console.error('Failed to load tasks:', error);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
|
|
@ -71,6 +79,42 @@
|
|||
upcomingCount === 0 &&
|
||||
completedTasks.length === 0
|
||||
);
|
||||
|
||||
// Modal handlers
|
||||
function openEditModal(task: Task) {
|
||||
editingTask = task;
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
editingTask = null;
|
||||
}
|
||||
|
||||
async function handleSaveTask(data: Partial<Task>) {
|
||||
if (!editingTask) return;
|
||||
|
||||
try {
|
||||
// Update task
|
||||
await tasksStore.updateTask(editingTask.id, data);
|
||||
|
||||
// Update labels if provided
|
||||
if ('labelIds' in data) {
|
||||
await tasksStore.updateLabels(editingTask.id, (data as any).labelIds);
|
||||
}
|
||||
|
||||
closeEditModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to save task:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteTask(taskId: string) {
|
||||
try {
|
||||
await tasksStore.deleteTask(taskId);
|
||||
closeEditModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete task:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -114,7 +158,7 @@
|
|||
variant="warning"
|
||||
defaultOpen={true}
|
||||
>
|
||||
<TaskList tasks={overdueTasks} />
|
||||
<TaskList tasks={overdueTasks} onEditTask={openEditModal} />
|
||||
</CollapsibleSection>
|
||||
{/if}
|
||||
|
||||
|
|
@ -131,7 +175,7 @@
|
|||
<p>Keine Aufgaben für heute</p>
|
||||
</div>
|
||||
{:else}
|
||||
<TaskList tasks={todayTasks} />
|
||||
<TaskList tasks={todayTasks} onEditTask={openEditModal} />
|
||||
{/if}
|
||||
</CollapsibleSection>
|
||||
|
||||
|
|
@ -154,7 +198,7 @@
|
|||
<h3 class="text-sm font-medium text-muted-foreground mb-2 pl-2">
|
||||
{group.label} ({group.tasks.length})
|
||||
</h3>
|
||||
<TaskList tasks={group.tasks} />
|
||||
<TaskList tasks={group.tasks} onEditTask={openEditModal} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -174,13 +218,24 @@
|
|||
<p>Noch keine erledigten Aufgaben</p>
|
||||
</div>
|
||||
{:else}
|
||||
<TaskList tasks={completedTasks} showCompleted />
|
||||
<TaskList tasks={completedTasks} showCompleted onEditTask={openEditModal} />
|
||||
{/if}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Task Edit Modal -->
|
||||
{#if editingTask}
|
||||
<TaskEditModal
|
||||
task={editingTask}
|
||||
open={true}
|
||||
onClose={closeEditModal}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={handleDeleteTask}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.unified-view {
|
||||
padding-bottom: 100px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue