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:
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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..."
|
||||
|
||||
Reference in New Issue
Block a user