From fe54496d799f30e34f4fe8715c0b5e220a68e1a2 Mon Sep 17 00:00:00 2001 From: grabowski Date: Tue, 7 Apr 2026 13:55:16 +0700 Subject: [PATCH] Add todo list with kanban board view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/lib/components/layout/Sidebar.svelte | 5 + src/lib/constants.ts | 14 + src/lib/server/db/schema.ts | 24 ++ src/routes/(app)/todos/+page.server.ts | 108 +++++++ src/routes/(app)/todos/+page.svelte | 350 +++++++++++++++++++++++ 5 files changed, 501 insertions(+) create mode 100644 src/routes/(app)/todos/+page.server.ts create mode 100644 src/routes/(app)/todos/+page.svelte diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 03dcbe1..37ead8d 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -37,6 +37,11 @@ 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' }, + { + href: '/todos', + label: 'Todos', + icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' + }, { href: '/checklists', label: 'Checklists', diff --git a/src/lib/constants.ts b/src/lib/constants.ts index c835316..3fd816b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -49,6 +49,20 @@ export const DEVICE_LOG_TYPES = [ export type DeviceLogType = (typeof DEVICE_LOG_TYPES)[number]; +export const TODO_STATUSES = ['todo', 'in_progress', 'done'] as const; +export const TODO_STATUS_LABELS: Record = { + 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 = [ '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', diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 47dc321..5caaf1f 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -274,3 +274,27 @@ export const deviceLog = pgTable( 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)`) + ] +); diff --git a/src/routes/(app)/todos/+page.server.ts b/src/routes/(app)/todos/+page.server.ts new file mode 100644 index 0000000..d9fad32 --- /dev/null +++ b/src/routes/(app)/todos/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/(app)/todos/+page.svelte b/src/routes/(app)/todos/+page.svelte new file mode 100644 index 0000000..85ce04f --- /dev/null +++ b/src/routes/(app)/todos/+page.svelte @@ -0,0 +1,350 @@ + + + + Todos - B4L Repair + + +
+
+

Todos

+
+ +
+ + +
+ +
+
+ + + {#if showNewForm} +
+
{ + return async ({ update, result }) => { + await update(); + if (result.type === 'success') showNewForm = false; + }; + }} class="space-y-3"> +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+ {/if} + + + {#if view === 'list'} + {#if data.todos.length === 0} +
+

No todos yet.

+
+ {:else} +
+ {#each data.todos as todo} +
+ {#if editingId === todo.id} + +
{ + return async ({ update, result }) => { + await update(); + if (result.type === 'success') editingId = null; + }; + }} class="space-y-2"> + +
+ +
+ + + +
+
+ + +
+ + +
+
+ {:else} +
+ +
+ + + +
+ +
+
+ + {todo.title} + + + {priorityLabel(todo.priority)} + + {#if todo.status !== 'todo'} + + {TODO_STATUS_LABELS[todo.status]} + + {/if} +
+ {#if todo.description} +

{todo.description}

+ {/if} +
+ {#if todo.deviceTitle} + {todo.deviceTitle} + {/if} + {#if todo.dueDate} + Due {formatDate(todo.dueDate)} + {/if} +
+
+ +
+ +
+ + +
+
+
+ {/if} +
+ {/each} +
+ {/if} + + + {:else} +
+ {#each TODO_STATUSES as status} +
+
+

+ {TODO_STATUS_LABELS[status]} +

+ + {todosByStatus[status]?.length ?? 0} + +
+ +
+ {#each todosByStatus[status] ?? [] as todo} +
+
+ {todo.title} + + {priorityLabel(todo.priority)} + +
+ {#if todo.description} +

{todo.description}

+ {/if} + {#if todo.deviceTitle} + {todo.deviceTitle} + {/if} + {#if todo.dueDate} +

Due {formatDate(todo.dueDate)}

+ {/if} + + +
+ {#if status !== 'todo'} +
+ + + +
+ {/if} + {#if status !== 'done'} +
+ + + +
+ {/if} +
+ +
+ + +
+
+
+
+ {/each} + + {#if (todosByStatus[status]?.length ?? 0) === 0} +

No items

+ {/if} +
+
+ {/each} +
+ {/if} +