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:
@@ -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: '/checklists',
|
||||||
|
label: 'Checklists',
|
||||||
|
icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/gallery',
|
href: '/gallery',
|
||||||
label: 'Gallery',
|
label: 'Gallery',
|
||||||
|
|||||||
@@ -195,6 +195,29 @@ export const installationLog = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Checklist Templates ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const checklistTemplates = pgTable('checklist_templates', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const templateItems = pgTable(
|
||||||
|
'template_items',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
templateId: uuid('template_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => checklistTemplates.id, { onDelete: 'cascade' }),
|
||||||
|
text: text('text').notNull(),
|
||||||
|
sortOrder: integer('sort_order').default(0).notNull()
|
||||||
|
},
|
||||||
|
(table) => [index('template_items_template_idx').on(table.templateId)]
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Device Checklists ──────────────────────────────────────────────
|
// ─── Device Checklists ──────────────────────────────────────────────
|
||||||
|
|
||||||
export const deviceChecklists = pgTable(
|
export const deviceChecklists = pgTable(
|
||||||
|
|||||||
@@ -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,
|
deviceLog,
|
||||||
deviceChecklists,
|
deviceChecklists,
|
||||||
checklistItems,
|
checklistItems,
|
||||||
|
checklistTemplates,
|
||||||
|
templateItems,
|
||||||
locations
|
locations
|
||||||
} from '$lib/server/db/schema.js';
|
} from '$lib/server/db/schema.js';
|
||||||
import { eq, desc, sql } from 'drizzle-orm';
|
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 {
|
return {
|
||||||
device,
|
device,
|
||||||
computerDetails: compDetails,
|
computerDetails: compDetails,
|
||||||
@@ -139,7 +147,8 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
checklists: checklists.map((c) => ({
|
checklists: checklists.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
items: itemsByChecklist[c.id] ?? []
|
items: itemsByChecklist[c.id] ?? []
|
||||||
}))
|
})),
|
||||||
|
templates
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -314,5 +323,47 @@ export const actions: Actions = {
|
|||||||
const itemId = formData.get('itemId') as string;
|
const itemId = formData.get('itemId') as string;
|
||||||
await db.delete(checklistItems).where(eq(checklistItems.id, itemId));
|
await db.delete(checklistItems).where(eq(checklistItems.id, itemId));
|
||||||
return { itemDeleted: true };
|
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 showDeleteConfirm = $state(false);
|
||||||
let showLogForm = $state(false);
|
let showLogForm = $state(false);
|
||||||
let showNewChecklist = $state(false);
|
let showNewChecklist = $state(false);
|
||||||
|
let showImportChecklist = $state(false);
|
||||||
let newItemText: Record<string, string> = $state({});
|
let newItemText: Record<string, string> = $state({});
|
||||||
</script>
|
</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="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">
|
<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>
|
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Checklists</h2>
|
||||||
<button onclick={() => (showNewChecklist = !showNewChecklist)}
|
<div class="flex gap-2">
|
||||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
{#if data.templates.length > 0}
|
||||||
{showNewChecklist ? 'Cancel' : 'New Checklist'}
|
<button onclick={() => { showImportChecklist = !showImportChecklist; showNewChecklist = false; }}
|
||||||
</button>
|
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>
|
</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}
|
{#if showNewChecklist}
|
||||||
<form method="POST" action="?/createChecklist" use:enhance class="mb-4 flex gap-2">
|
<form method="POST" action="?/createChecklist" use:enhance class="mb-4 flex gap-2">
|
||||||
<input type="text" name="title" required placeholder="Checklist name..."
|
<input type="text" name="title" required placeholder="Checklist name..."
|
||||||
|
|||||||
Reference in New Issue
Block a user