Add checklist templates with import into devices

- checklist_templates and template_items tables for reusable checklists
- /checklists page: create/edit/delete templates with ordered items
- "Import" button on device detail imports a template as a new checklist
  with all items copied (unchecked)
- Sidebar nav item for Checklists between Locations and Gallery

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 10:36:02 +07:00
parent dbe82c1019
commit 1351b77034
6 changed files with 309 additions and 5 deletions
@@ -0,0 +1,92 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { checklistTemplates, templateItems } from '$lib/server/db/schema.js';
import { eq, desc, sql } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
export const load: PageServerLoad = async () => {
const templates = await db
.select()
.from(checklistTemplates)
.orderBy(checklistTemplates.title);
const templateIds = templates.map((t) => t.id);
let itemsByTemplate: Record<string, Array<{ id: string; text: string; sortOrder: number }>> = {};
if (templateIds.length > 0) {
const allItems = await db
.select({
id: templateItems.id,
templateId: templateItems.templateId,
text: templateItems.text,
sortOrder: templateItems.sortOrder
})
.from(templateItems)
.where(sql`${templateItems.templateId} IN ${templateIds}`)
.orderBy(templateItems.sortOrder);
for (const item of allItems) {
if (!itemsByTemplate[item.templateId]) itemsByTemplate[item.templateId] = [];
itemsByTemplate[item.templateId].push(item);
}
}
return {
templates: templates.map((t) => ({
...t,
items: itemsByTemplate[t.id] ?? []
}))
};
};
export const actions: Actions = {
createTemplate: async ({ request }) => {
const formData = await request.formData();
const title = (formData.get('title') as string)?.trim();
const description = (formData.get('description') as string)?.trim();
if (!title) return fail(400, { error: 'Title is required' });
await db.insert(checklistTemplates).values({
title,
description: description || null
});
return { created: true };
},
deleteTemplate: async ({ request }) => {
const formData = await request.formData();
const templateId = formData.get('templateId') as string;
await db.delete(checklistTemplates).where(eq(checklistTemplates.id, templateId));
return { deleted: true };
},
addItem: async ({ request }) => {
const formData = await request.formData();
const templateId = formData.get('templateId') as string;
const text = (formData.get('text') as string)?.trim();
if (!text) return fail(400, { error: 'Item text is required' });
const existing = await db
.select({ sortOrder: templateItems.sortOrder })
.from(templateItems)
.where(eq(templateItems.templateId, templateId))
.orderBy(desc(templateItems.sortOrder))
.limit(1);
await db.insert(templateItems).values({
templateId,
text,
sortOrder: (existing[0]?.sortOrder ?? -1) + 1
});
return { itemAdded: true };
},
deleteItem: async ({ request }) => {
const formData = await request.formData();
const itemId = formData.get('itemId') as string;
await db.delete(templateItems).where(eq(templateItems.id, itemId));
return { itemDeleted: true };
}
};
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
let showNewTemplate = $state(false);
let newItemText: Record<string, string> = $state({});
</script>
<svelte:head>
<title>Checklist Templates - B4L Repair</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Checklist Templates</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Create reusable checklists to import into devices.</p>
</div>
<button onclick={() => (showNewTemplate = !showNewTemplate)}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
{showNewTemplate ? 'Cancel' : 'New Template'}
</button>
</div>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if}
{#if showNewTemplate}
<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="?/createTemplate" use:enhance class="space-y-3">
<div>
<label for="title" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Template Name *</label>
<input type="text" id="title" name="title" required
placeholder="e.g. Mac Classic Repair Checklist"
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" />
</div>
<div>
<label for="description" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<input type="text" id="description" name="description"
placeholder="What is this checklist for?"
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" />
</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 Template</button>
</form>
</div>
{/if}
{#if data.templates.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 templates yet. Create your first checklist template to reuse across devices.</p>
</div>
{:else}
<div class="space-y-4">
{#each data.templates as template}
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-3 flex items-start justify-between">
<div>
<h2 class="font-medium text-gray-900 dark:text-white">{template.title}</h2>
{#if template.description}
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">{template.description}</p>
{/if}
</div>
<div class="flex items-center gap-2">
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400">
{template.items.length} item{template.items.length !== 1 ? 's' : ''}
</span>
<form method="POST" action="?/deleteTemplate" use:enhance>
<input type="hidden" name="templateId" value={template.id} />
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400" title="Delete template">
<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>
<!-- Items -->
<div class="space-y-1">
{#each template.items as item}
<div class="group flex items-center gap-2 rounded-md py-1">
<span class="text-sm text-gray-400 dark:text-gray-500">{item.sortOrder + 1}.</span>
<span class="flex-1 text-sm text-gray-700 dark:text-gray-300">{item.text}</span>
<form method="POST" action="?/deleteItem" use:enhance>
<input type="hidden" name="itemId" value={item.id} />
<button type="submit" class="hidden text-gray-400 hover:text-red-500 group-hover:block dark:text-gray-500 dark:hover:text-red-400" title="Remove">
<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>
{/each}
</div>
<!-- Add item -->
<form method="POST" action="?/addItem" use:enhance
onsubmit={() => { newItemText[template.id] = ''; }}
class="mt-2 flex gap-2">
<input type="hidden" name="templateId" value={template.id} />
<input type="text" name="text" required bind:value={newItemText[template.id]}
placeholder="Add item..."
class="flex-1 rounded-md border border-gray-200 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 dark:placeholder-gray-400" />
<button type="submit" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">Add</button>
</form>
</div>
{/each}
</div>
{/if}
</div>
+52 -1
View File
@@ -10,6 +10,8 @@ import {
deviceLog,
deviceChecklists,
checklistItems,
checklistTemplates,
templateItems,
locations
} from '$lib/server/db/schema.js';
import { eq, desc, sql } from 'drizzle-orm';
@@ -128,6 +130,12 @@ export const load: PageServerLoad = async ({ params }) => {
}
}
// Available templates for import
const templates = await db
.select({ id: checklistTemplates.id, title: checklistTemplates.title })
.from(checklistTemplates)
.orderBy(checklistTemplates.title);
return {
device,
computerDetails: compDetails,
@@ -139,7 +147,8 @@ export const load: PageServerLoad = async ({ params }) => {
checklists: checklists.map((c) => ({
...c,
items: itemsByChecklist[c.id] ?? []
}))
})),
templates
};
};
@@ -314,5 +323,47 @@ export const actions: Actions = {
const itemId = formData.get('itemId') as string;
await db.delete(checklistItems).where(eq(checklistItems.id, itemId));
return { itemDeleted: true };
},
importChecklist: async ({ request, params }) => {
const formData = await request.formData();
const templateId = formData.get('templateId') as string;
if (!templateId) return fail(400, { error: 'Select a template' });
// Get template
const [template] = await db
.select()
.from(checklistTemplates)
.where(eq(checklistTemplates.id, templateId));
if (!template) return fail(400, { error: 'Template not found' });
// Get template items
const items = await db
.select()
.from(templateItems)
.where(eq(templateItems.templateId, templateId))
.orderBy(templateItems.sortOrder);
// Create device checklist
const [checklist] = await db
.insert(deviceChecklists)
.values({
deviceId: params.id,
title: template.title
})
.returning({ id: deviceChecklists.id });
// Copy items
if (items.length > 0 && checklist) {
await db.insert(checklistItems).values(
items.map((item) => ({
checklistId: checklist.id,
text: item.text,
sortOrder: item.sortOrder
}))
);
}
return { checklistImported: true };
}
};
+26 -4
View File
@@ -10,6 +10,7 @@
let showDeleteConfirm = $state(false);
let showLogForm = $state(false);
let showNewChecklist = $state(false);
let showImportChecklist = $state(false);
let newItemText: Record<string, string> = $state({});
</script>
@@ -334,12 +335,33 @@
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Checklists</h2>
<button onclick={() => (showNewChecklist = !showNewChecklist)}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
{showNewChecklist ? 'Cancel' : 'New Checklist'}
</button>
<div class="flex gap-2">
{#if data.templates.length > 0}
<button onclick={() => { showImportChecklist = !showImportChecklist; showNewChecklist = false; }}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
{showImportChecklist ? 'Cancel' : 'Import'}
</button>
{/if}
<button onclick={() => { showNewChecklist = !showNewChecklist; showImportChecklist = false; }}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
{showNewChecklist ? 'Cancel' : 'New'}
</button>
</div>
</div>
{#if showImportChecklist}
<form method="POST" action="?/importChecklist" use:enhance class="mb-4 flex gap-2">
<select name="templateId" required
class="flex-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">Select template...</option>
{#each data.templates as t}
<option value={t.id}>{t.title}</option>
{/each}
</select>
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Import</button>
</form>
{/if}
{#if showNewChecklist}
<form method="POST" action="?/createChecklist" use:enhance class="mb-4 flex gap-2">
<input type="text" name="title" required placeholder="Checklist name..."