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
+5
View File
@@ -37,6 +37,11 @@
label: 'Locations', label: 'Locations',
icon: 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z' icon: 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z'
}, },
{
href: '/todos',
label: 'Todos',
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'
},
{ {
href: '/checklists', href: '/checklists',
label: 'Checklists', label: 'Checklists',
+14
View File
@@ -49,6 +49,20 @@ export const DEVICE_LOG_TYPES = [
export type DeviceLogType = (typeof DEVICE_LOG_TYPES)[number]; export type DeviceLogType = (typeof DEVICE_LOG_TYPES)[number];
export const TODO_STATUSES = ['todo', 'in_progress', 'done'] as const;
export const TODO_STATUS_LABELS: Record<string, string> = {
todo: 'To Do',
in_progress: 'In Progress',
done: 'Done'
};
export const TODO_PRIORITIES = [
{ value: 0, label: 'Urgent', color: 'red' },
{ value: 1, label: 'High', color: 'orange' },
{ value: 2, label: 'Medium', color: 'blue' },
{ value: 3, label: 'Low', color: 'gray' }
] as const;
export const VOLTAGE_OPTIONS = [ export const VOLTAGE_OPTIONS = [
'110V AC', '115V AC', '120V AC', '127V AC', '220V AC', '230V AC', '240V AC', '110V AC', '115V AC', '120V AC', '127V AC', '220V AC', '230V AC', '240V AC',
'5V DC', '6V DC', '9V DC', '12V DC', '15V DC', '19V DC', '24V DC', '5V DC', '6V DC', '9V DC', '12V DC', '15V DC', '19V DC', '24V DC',
+24
View File
@@ -274,3 +274,27 @@ export const deviceLog = pgTable(
check('device_log_type_check', sql`${table.type} IN ('repair', 'inspection', 'cleaning', 'modification', 'diagnostic', 'recap', 'other')`) check('device_log_type_check', sql`${table.type} IN ('repair', 'inspection', 'cleaning', 'modification', 'diagnostic', 'recap', 'other')`)
] ]
); );
// ─── Todos ──────────────────────────────────────────────────────────
export const todos = pgTable(
'todos',
{
id: uuid('id').defaultRandom().primaryKey(),
title: text('title').notNull(),
description: text('description'),
status: text('status').notNull().default('todo'),
priority: integer('priority').notNull().default(2), // 0=urgent, 1=high, 2=medium, 3=low
deviceId: uuid('device_id').references(() => devices.id, { onDelete: 'set null' }),
dueDate: timestamp('due_date', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [
index('todos_status_idx').on(table.status),
index('todos_priority_idx').on(table.priority),
index('todos_device_idx').on(table.deviceId),
check('todos_status_check', sql`${table.status} IN ('todo', 'in_progress', 'done')`),
check('todos_priority_check', sql`${table.priority} IN (0, 1, 2, 3)`)
]
);
+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>