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:
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)`)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
← {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'} →
|
||||||
|
</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>
|
||||||
Reference in New Issue
Block a user