Add todo list with kanban board view

- todos table with title, description, status (todo/in_progress/done),
  priority (urgent/high/medium/low), optional device link, due date
- List view: sorted by priority, inline edit, click-to-advance status
  circle (empty → blue dot → green check), edit/delete actions
- Kanban board view: three columns, move buttons between statuses,
  priority badges, device links, due dates
- Toggle between List and Board views via URL param
- Optional link to a device for repair-related todos
- Sidebar nav item added

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 13:55:16 +07:00
parent 59371d0cbb
commit fe54496d79
5 changed files with 501 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { todos, devices } from '$lib/server/db/schema.js';
import { eq, asc, desc } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ url }) => {
const view = url.searchParams.get('view') ?? 'list';
const allTodos = await db
.select({
id: todos.id,
title: todos.title,
description: todos.description,
status: todos.status,
priority: todos.priority,
deviceId: todos.deviceId,
deviceTitle: devices.title,
dueDate: todos.dueDate,
createdAt: todos.createdAt,
updatedAt: todos.updatedAt
})
.from(todos)
.leftJoin(devices, eq(todos.deviceId, devices.id))
.orderBy(asc(todos.priority), desc(todos.createdAt));
// Device list for the form
const deviceList = await db
.select({ id: devices.id, title: devices.title })
.from(devices)
.where(eq(devices.disabled, false))
.orderBy(devices.title);
return { todos: allTodos, devices: deviceList, view };
};
export const actions: Actions = {
create: async ({ request }) => {
const formData = await request.formData();
const title = (formData.get('title') as string)?.trim();
const description = (formData.get('description') as string)?.trim();
const priority = Number(formData.get('priority') ?? 2);
const status = (formData.get('status') as string) || 'todo';
const deviceId = (formData.get('deviceId') as string) || null;
const dueDateStr = formData.get('dueDate') as string;
if (!title) return fail(400, { error: 'Title is required' });
await db.insert(todos).values({
title,
description: description || null,
priority,
status,
deviceId: deviceId || null,
dueDate: dueDateStr ? new Date(dueDateStr) : null
});
return { created: true };
},
update: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id') as string;
const title = (formData.get('title') as string)?.trim();
const description = (formData.get('description') as string)?.trim();
const priority = Number(formData.get('priority') ?? 2);
const status = (formData.get('status') as string) || 'todo';
const deviceId = (formData.get('deviceId') as string) || null;
const dueDateStr = formData.get('dueDate') as string;
if (!title) return fail(400, { error: 'Title is required' });
await db
.update(todos)
.set({
title,
description: description || null,
priority,
status,
deviceId: deviceId || null,
dueDate: dueDateStr ? new Date(dueDateStr) : null,
updatedAt: new Date()
})
.where(eq(todos.id, id));
return { updated: true };
},
moveStatus: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id') as string;
const status = formData.get('status') as string;
await db
.update(todos)
.set({ status, updatedAt: new Date() })
.where(eq(todos.id, id));
return { moved: true };
},
delete: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id') as string;
await db.delete(todos).where(eq(todos.id, id));
return { deleted: true };
}
};
+350
View File
@@ -0,0 +1,350 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { TODO_STATUSES, TODO_STATUS_LABELS, TODO_PRIORITIES } from '$lib/constants.js';
import { formatDate } from '$lib/utils/date.js';
let { data } = $props();
let showNewForm = $state(false);
let editingId = $state<string | null>(null);
const view = $derived(data.view);
function setView(v: string) {
const url = new URL($page.url);
url.searchParams.set('view', v);
goto(url.toString());
}
function priorityStyle(p: number) {
const map: Record<number, string> = {
0: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
1: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
2: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
3: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
};
return map[p] ?? map[2];
}
function priorityLabel(p: number) {
return TODO_PRIORITIES.find((x) => x.value === p)?.label ?? 'Medium';
}
function statusStyle(s: string) {
const map: Record<string, string> = {
todo: 'border-gray-300 dark:border-gray-600',
in_progress: 'border-blue-400 dark:border-blue-500',
done: 'border-green-400 dark:border-green-500'
};
return map[s] ?? map['todo'];
}
const todosByStatus = $derived(
TODO_STATUSES.reduce(
(acc, s) => {
acc[s] = data.todos.filter((t) => t.status === s);
return acc;
},
{} as Record<string, typeof data.todos>
)
);
</script>
<svelte:head>
<title>Todos - B4L Repair</title>
</svelte:head>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Todos</h1>
<div class="flex items-center gap-3">
<!-- View toggle -->
<div class="flex rounded-md border border-gray-300 dark:border-gray-600">
<button onclick={() => setView('list')}
class="px-3 py-1.5 text-sm {view === 'list' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'} rounded-l-md">
List
</button>
<button onclick={() => setView('kanban')}
class="px-3 py-1.5 text-sm {view === 'kanban' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'} rounded-r-md">
Board
</button>
</div>
<button onclick={() => { showNewForm = !showNewForm; editingId = null; }}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
{showNewForm ? 'Cancel' : 'New Todo'}
</button>
</div>
</div>
<!-- New todo form -->
{#if showNewForm}
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<form method="POST" action="?/create" use:enhance={() => {
return async ({ update, result }) => {
await update();
if (result.type === 'success') showNewForm = false;
};
}} class="space-y-3">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label for="title" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Title *</label>
<input type="text" id="title" name="title" required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
placeholder="What needs to be done?" />
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label for="priority" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
<select id="priority" name="priority"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each TODO_PRIORITIES as p}
<option value={p.value} selected={p.value === 2}>{p.label}</option>
{/each}
</select>
</div>
<div>
<label for="status" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<select id="status" name="status"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each TODO_STATUSES as s}
<option value={s}>{TODO_STATUS_LABELS[s]}</option>
{/each}
</select>
</div>
<div>
<label for="dueDate" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Due</label>
<input type="date" id="dueDate" name="dueDate"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label for="description" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea id="description" name="description" rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
placeholder="Details..."></textarea>
</div>
<div>
<label for="deviceId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Linked Device</label>
<select id="deviceId" name="deviceId"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">None</option>
{#each data.devices as d}
<option value={d.id}>{d.title}</option>
{/each}
</select>
</div>
</div>
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Create</button>
</form>
</div>
{/if}
<!-- List View -->
{#if view === 'list'}
{#if data.todos.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="text-gray-500 dark:text-gray-400">No todos yet.</p>
</div>
{:else}
<div class="space-y-2">
{#each data.todos as todo}
<div class="rounded-lg border-l-4 border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 {statusStyle(todo.status)}">
{#if editingId === todo.id}
<!-- Inline edit form -->
<form method="POST" action="?/update" use:enhance={() => {
return async ({ update, result }) => {
await update();
if (result.type === 'success') editingId = null;
};
}} class="space-y-2">
<input type="hidden" name="id" value={todo.id} />
<div class="grid gap-2 sm:grid-cols-2">
<input type="text" name="title" value={todo.title} required
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<div class="grid grid-cols-3 gap-2">
<select name="priority"
class="rounded-md border border-gray-300 px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each TODO_PRIORITIES as p}
<option value={p.value} selected={p.value === todo.priority}>{p.label}</option>
{/each}
</select>
<select name="status"
class="rounded-md border border-gray-300 px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each TODO_STATUSES as s}
<option value={s} selected={s === todo.status}>{TODO_STATUS_LABELS[s]}</option>
{/each}
</select>
<input type="date" name="dueDate" value={todo.dueDate ? formatDate(todo.dueDate) : ''}
class="rounded-md border border-gray-300 px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
</div>
<textarea name="description" rows="2" placeholder="Description..."
class="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white">{todo.description ?? ''}</textarea>
<input type="hidden" name="deviceId" value={todo.deviceId ?? ''} />
<div class="flex gap-2">
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Save</button>
<button type="button" onclick={() => (editingId = null)} class="rounded-md px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">Cancel</button>
</div>
</form>
{:else}
<div class="flex items-start gap-3">
<!-- Quick status toggle -->
<form method="POST" action="?/moveStatus" use:enhance class="flex-shrink-0 pt-0.5">
<input type="hidden" name="id" value={todo.id} />
<input type="hidden" name="status" value={todo.status === 'done' ? 'todo' : todo.status === 'todo' ? 'in_progress' : 'done'} />
<button type="submit" class="flex h-5 w-5 items-center justify-center rounded-full border-2
{todo.status === 'done' ? 'border-green-500 bg-green-500 text-white' : ''}
{todo.status === 'in_progress' ? 'border-blue-500 bg-blue-100 dark:bg-blue-900/40' : ''}
{todo.status === 'todo' ? 'border-gray-300 dark:border-gray-600' : ''}
" title="Click to advance status">
{#if todo.status === 'done'}
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
{:else if todo.status === 'in_progress'}
<div class="h-2 w-2 rounded-full bg-blue-500"></div>
{/if}
</button>
</form>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium {todo.status === 'done' ? 'text-gray-400 line-through dark:text-gray-500' : 'text-gray-900 dark:text-white'}">
{todo.title}
</span>
<span class="rounded-full px-1.5 py-0.5 text-xs font-medium {priorityStyle(todo.priority)}">
{priorityLabel(todo.priority)}
</span>
{#if todo.status !== 'todo'}
<span class="rounded-full px-1.5 py-0.5 text-xs
{todo.status === 'in_progress' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'}
">
{TODO_STATUS_LABELS[todo.status]}
</span>
{/if}
</div>
{#if todo.description}
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{todo.description}</p>
{/if}
<div class="mt-1 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
{#if todo.deviceTitle}
<a href="/devices/{todo.deviceId}" class="hover:text-blue-600 dark:hover:text-blue-400">{todo.deviceTitle}</a>
{/if}
{#if todo.dueDate}
<span>Due {formatDate(todo.dueDate)}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<button onclick={() => (editingId = todo.id)}
class="rounded p-1 text-gray-400 hover:text-blue-600 dark:text-gray-500 dark:hover:text-blue-400" title="Edit">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</button>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={todo.id} />
<button type="submit" class="rounded p-1 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400" title="Delete">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</button>
</form>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Kanban View -->
{:else}
<div class="grid gap-4 lg:grid-cols-3">
{#each TODO_STATUSES as status}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider
{status === 'todo' ? 'text-gray-500 dark:text-gray-400' : ''}
{status === 'in_progress' ? 'text-blue-600 dark:text-blue-400' : ''}
{status === 'done' ? 'text-green-600 dark:text-green-400' : ''}
">
{TODO_STATUS_LABELS[status]}
</h2>
<span class="rounded-full bg-gray-200 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400">
{todosByStatus[status]?.length ?? 0}
</span>
</div>
<div class="space-y-2">
{#each todosByStatus[status] ?? [] as todo}
<div class="rounded-md border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-600 dark:bg-gray-800">
<div class="mb-1 flex items-start justify-between gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-white">{todo.title}</span>
<span class="flex-shrink-0 rounded-full px-1.5 py-0.5 text-xs font-medium {priorityStyle(todo.priority)}">
{priorityLabel(todo.priority)}
</span>
</div>
{#if todo.description}
<p class="mb-2 text-xs text-gray-500 dark:text-gray-400">{todo.description}</p>
{/if}
{#if todo.deviceTitle}
<a href="/devices/{todo.deviceId}" class="mb-2 block text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400">{todo.deviceTitle}</a>
{/if}
{#if todo.dueDate}
<p class="mb-2 text-xs text-gray-400 dark:text-gray-500">Due {formatDate(todo.dueDate)}</p>
{/if}
<!-- Move buttons -->
<div class="flex gap-1">
{#if status !== 'todo'}
<form method="POST" action="?/moveStatus" use:enhance>
<input type="hidden" name="id" value={todo.id} />
<input type="hidden" name="status" value={status === 'done' ? 'in_progress' : 'todo'} />
<button type="submit" class="rounded px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700" title="Move left">
&larr; {status === 'done' ? 'In Progress' : 'To Do'}
</button>
</form>
{/if}
{#if status !== 'done'}
<form method="POST" action="?/moveStatus" use:enhance>
<input type="hidden" name="id" value={todo.id} />
<input type="hidden" name="status" value={status === 'todo' ? 'in_progress' : 'done'} />
<button type="submit" class="rounded px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700" title="Move right">
{status === 'todo' ? 'In Progress' : 'Done'} &rarr;
</button>
</form>
{/if}
<div class="ml-auto flex gap-1">
<button onclick={() => { editingId = todo.id; showNewForm = false; }}
class="rounded p-0.5 text-gray-400 hover:text-blue-600 dark:text-gray-500" title="Edit">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</button>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={todo.id} />
<button type="submit" class="rounded p-0.5 text-gray-400 hover:text-red-500 dark:text-gray-500" title="Delete">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
</div>
</div>
</div>
{/each}
{#if (todosByStatus[status]?.length ?? 0) === 0}
<p class="py-4 text-center text-xs text-gray-400 dark:text-gray-500">No items</p>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>