Redesign checklists: grid overview + dedicated edit page
Deploy to LXC / deploy (push) Successful in 20s
Deploy to LXC / deploy (push) Successful in 20s
- /checklists now shows templates in a responsive grid (cards with title, description, item count, last updated) - Clicking a card opens /checklists/[id] dedicated edit page - "New Template" creates and redirects to the edit page - Edit page has: rename title/description, add/edit/delete/reorder items, delete template (redirects back to grid) - Breadcrumb navigation back to grid Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { db } from '$lib/server/db/index.js';
|
import { db } from '$lib/server/db/index.js';
|
||||||
import { checklistTemplates, templateItems } from '$lib/server/db/schema.js';
|
import { checklistTemplates, templateItems } from '$lib/server/db/schema.js';
|
||||||
import { eq, desc, sql } from 'drizzle-orm';
|
import { eq, count, sql } from 'drizzle-orm';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const templates = await db
|
const templates = await db
|
||||||
@@ -10,144 +10,49 @@ export const load: PageServerLoad = async () => {
|
|||||||
.from(checklistTemplates)
|
.from(checklistTemplates)
|
||||||
.orderBy(checklistTemplates.title);
|
.orderBy(checklistTemplates.title);
|
||||||
|
|
||||||
|
// Get item counts per template
|
||||||
const templateIds = templates.map((t) => t.id);
|
const templateIds = templates.map((t) => t.id);
|
||||||
let itemsByTemplate: Record<string, Array<{ id: string; text: string; itemType: string; unit: string | null; sortOrder: number }>> = {};
|
let itemCounts: Record<string, number> = {};
|
||||||
|
|
||||||
if (templateIds.length > 0) {
|
if (templateIds.length > 0) {
|
||||||
const allItems = await db
|
const counts = await db
|
||||||
.select({
|
.select({ templateId: templateItems.templateId, count: count() })
|
||||||
id: templateItems.id,
|
|
||||||
templateId: templateItems.templateId,
|
|
||||||
text: templateItems.text,
|
|
||||||
itemType: templateItems.itemType,
|
|
||||||
unit: templateItems.unit,
|
|
||||||
sortOrder: templateItems.sortOrder
|
|
||||||
})
|
|
||||||
.from(templateItems)
|
.from(templateItems)
|
||||||
.where(sql`${templateItems.templateId} IN ${templateIds}`)
|
.where(sql`${templateItems.templateId} IN ${templateIds}`)
|
||||||
.orderBy(templateItems.sortOrder);
|
.groupBy(templateItems.templateId);
|
||||||
|
|
||||||
for (const item of allItems) {
|
for (const c of counts) {
|
||||||
if (!itemsByTemplate[item.templateId]) itemsByTemplate[item.templateId] = [];
|
itemCounts[c.templateId] = c.count;
|
||||||
itemsByTemplate[item.templateId].push(item);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
templates: templates.map((t) => ({
|
templates: templates.map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
items: itemsByTemplate[t.id] ?? []
|
itemCount: itemCounts[t.id] ?? 0
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
createTemplate: async ({ request }) => {
|
create: async ({ request }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const title = (formData.get('title') as string)?.trim();
|
const title = (formData.get('title') as string)?.trim();
|
||||||
const description = (formData.get('description') as string)?.trim();
|
const description = (formData.get('description') as string)?.trim();
|
||||||
if (!title) return fail(400, { error: 'Title is required' });
|
if (!title) return fail(400, { error: 'Title is required' });
|
||||||
|
|
||||||
await db.insert(checklistTemplates).values({
|
const [template] = await db
|
||||||
title,
|
.insert(checklistTemplates)
|
||||||
description: description || null
|
.values({ title, description: description || null })
|
||||||
});
|
.returning({ id: checklistTemplates.id });
|
||||||
|
|
||||||
return { created: true };
|
redirect(303, `/checklists/${template!.id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTemplate: async ({ request }) => {
|
delete: async ({ request }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const templateId = formData.get('templateId') as string;
|
const templateId = formData.get('templateId') as string;
|
||||||
await db.delete(checklistTemplates).where(eq(checklistTemplates.id, templateId));
|
await db.delete(checklistTemplates).where(eq(checklistTemplates.id, templateId));
|
||||||
return { deleted: true };
|
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();
|
|
||||||
const itemType = (formData.get('itemType') as string) || 'checkbox';
|
|
||||||
const unit = (formData.get('unit') 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,
|
|
||||||
itemType,
|
|
||||||
unit: unit || null,
|
|
||||||
sortOrder: (existing[0]?.sortOrder ?? -1) + 1
|
|
||||||
});
|
|
||||||
|
|
||||||
return { itemAdded: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
renameTemplate: async ({ request }) => {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const templateId = formData.get('templateId') as string;
|
|
||||||
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
|
|
||||||
.update(checklistTemplates)
|
|
||||||
.set({ title, description: description || null, updatedAt: new Date() })
|
|
||||||
.where(eq(checklistTemplates.id, templateId));
|
|
||||||
return { renamed: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
editItem: async ({ request }) => {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const itemId = formData.get('itemId') as string;
|
|
||||||
const text = (formData.get('text') as string)?.trim();
|
|
||||||
const unit = (formData.get('unit') as string)?.trim();
|
|
||||||
if (!text) return fail(400, { error: 'Item text is required' });
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(templateItems)
|
|
||||||
.set({ text, unit: unit || null })
|
|
||||||
.where(eq(templateItems.id, itemId));
|
|
||||||
return { itemEdited: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
moveItem: async ({ request }) => {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const itemId = formData.get('itemId') as string;
|
|
||||||
const direction = formData.get('direction') as string; // 'up' or 'down'
|
|
||||||
const templateId = formData.get('templateId') as string;
|
|
||||||
|
|
||||||
const items = await db
|
|
||||||
.select({ id: templateItems.id, sortOrder: templateItems.sortOrder })
|
|
||||||
.from(templateItems)
|
|
||||||
.where(eq(templateItems.templateId, templateId))
|
|
||||||
.orderBy(templateItems.sortOrder);
|
|
||||||
|
|
||||||
const idx = items.findIndex((i) => i.id === itemId);
|
|
||||||
if (idx < 0) return fail(400, { error: 'Item not found' });
|
|
||||||
|
|
||||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
|
||||||
if (swapIdx < 0 || swapIdx >= items.length) return { moved: true };
|
|
||||||
|
|
||||||
// Swap sort orders
|
|
||||||
const a = items[idx];
|
|
||||||
const b = items[swapIdx];
|
|
||||||
await db.update(templateItems).set({ sortOrder: b.sortOrder }).where(eq(templateItems.id, a.id));
|
|
||||||
await db.update(templateItems).set({ sortOrder: a.sortOrder }).where(eq(templateItems.id, b.id));
|
|
||||||
|
|
||||||
return { moved: 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 };
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { formatDate } from '$lib/utils/date.js';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
let showNewTemplate = $state(false);
|
let showNewForm = $state(false);
|
||||||
let editingTemplateId = $state<string | null>(null);
|
|
||||||
let editingItemId = $state<string | null>(null);
|
|
||||||
function resetForm(form: HTMLFormElement) {
|
|
||||||
const input = form.querySelector('input[name="text"]') as HTMLInputElement;
|
|
||||||
const unit = form.querySelector('input[name="unit"]') as HTMLInputElement;
|
|
||||||
if (input) input.value = '';
|
|
||||||
if (unit) unit.value = '';
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -23,9 +16,9 @@
|
|||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Checklist Templates</h1>
|
<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>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Create reusable checklists to import into devices.</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick={() => (showNewTemplate = !showNewTemplate)}
|
<button type="button" onclick={() => (showNewForm = !showNewForm)}
|
||||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||||
{showNewTemplate ? 'Cancel' : 'New Template'}
|
{showNewForm ? 'Cancel' : 'New Template'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -33,9 +26,9 @@
|
|||||||
<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>
|
<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}
|
||||||
|
|
||||||
{#if showNewTemplate}
|
{#if showNewForm}
|
||||||
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
<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">
|
<form method="POST" action="?/create" use:enhance class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Template Name *</label>
|
<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
|
<input type="text" id="title" name="title" required
|
||||||
@@ -48,177 +41,31 @@
|
|||||||
placeholder="What is this checklist for?"
|
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" />
|
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>
|
||||||
<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>
|
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Create & Edit</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.templates.length === 0}
|
{#if data.templates.length === 0 && !showNewForm}
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
|
<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>
|
<p class="text-gray-500 dark:text-gray-400">No templates yet. Create your first checklist template to reuse across devices.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if data.templates.length > 0}
|
||||||
<div class="space-y-4">
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each data.templates as template}
|
{#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">
|
<a href="/checklists/{template.id}"
|
||||||
<!-- Template header -->
|
class="group rounded-lg border border-gray-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800">
|
||||||
{#if editingTemplateId === template.id}
|
<div class="mb-2 flex items-start justify-between">
|
||||||
<form method="POST" action="?/renameTemplate" use:enhance={() => {
|
<h2 class="font-medium text-gray-900 group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400">{template.title}</h2>
|
||||||
return async ({ update, result }) => {
|
<span class="flex-shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||||
await update();
|
{template.itemCount} item{template.itemCount !== 1 ? 's' : ''}
|
||||||
if (result.type === 'success') editingTemplateId = null;
|
</span>
|
||||||
};
|
|
||||||
}} class="mb-3 flex flex-wrap items-end gap-2">
|
|
||||||
<input type="hidden" name="templateId" value={template.id} />
|
|
||||||
<div class="flex-1">
|
|
||||||
<label for="title-{template.id}" class="mb-1 block text-xs text-gray-500 dark:text-gray-400">Name</label>
|
|
||||||
<input type="text" id="title-{template.id}" name="title" value={template.title} required
|
|
||||||
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" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<label for="desc-{template.id}" class="mb-1 block text-xs text-gray-500 dark:text-gray-400">Description</label>
|
|
||||||
<input type="text" id="desc-{template.id}" name="description" value={template.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" />
|
|
||||||
</div>
|
|
||||||
<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={() => (editingTemplateId = 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>
|
|
||||||
</form>
|
|
||||||
{:else}
|
|
||||||
<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>
|
|
||||||
<button type="button" onclick={() => (editingTemplateId = template.id)} class="text-gray-400 hover:text-blue-600 dark:text-gray-500 dark:hover:text-blue-400" title="Edit 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="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="?/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>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Items -->
|
|
||||||
<div class="space-y-1">
|
|
||||||
{#each template.items as item, idx}
|
|
||||||
{#if editingItemId === item.id}
|
|
||||||
<!-- Inline edit item -->
|
|
||||||
<form method="POST" action="?/editItem" use:enhance={() => {
|
|
||||||
return async ({ update, result }) => {
|
|
||||||
await update();
|
|
||||||
if (result.type === 'success') editingItemId = null;
|
|
||||||
};
|
|
||||||
}} class="flex items-center gap-2 rounded-md bg-gray-50 p-2 dark:bg-gray-700/30">
|
|
||||||
<input type="hidden" name="itemId" value={item.id} />
|
|
||||||
<input type="text" name="text" value={item.text} required
|
|
||||||
class="flex-1 rounded-md border border-gray-300 px-2 py-1 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" />
|
|
||||||
{#if item.itemType === 'input'}
|
|
||||||
<input type="text" name="unit" value={item.unit ?? ''} placeholder="Unit"
|
|
||||||
class="w-16 rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
|
||||||
{/if}
|
|
||||||
<button type="submit" class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">Save</button>
|
|
||||||
<button type="button" onclick={() => (editingItemId = null)} class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button>
|
|
||||||
</form>
|
|
||||||
{:else}
|
|
||||||
<div class="group flex items-center gap-2 rounded-md py-1">
|
|
||||||
<!-- Move buttons -->
|
|
||||||
<div class="flex flex-col">
|
|
||||||
{#if idx > 0}
|
|
||||||
<form method="POST" action="?/moveItem" use:enhance>
|
|
||||||
<input type="hidden" name="itemId" value={item.id} />
|
|
||||||
<input type="hidden" name="templateId" value={template.id} />
|
|
||||||
<input type="hidden" name="direction" value="up" />
|
|
||||||
<button type="submit" class="text-gray-300 hover:text-gray-600 dark:text-gray-600 dark:hover:text-gray-400" title="Move up">
|
|
||||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{:else}
|
|
||||||
<div class="h-3 w-3"></div>
|
|
||||||
{/if}
|
|
||||||
{#if idx < template.items.length - 1}
|
|
||||||
<form method="POST" action="?/moveItem" use:enhance>
|
|
||||||
<input type="hidden" name="itemId" value={item.id} />
|
|
||||||
<input type="hidden" name="templateId" value={template.id} />
|
|
||||||
<input type="hidden" name="direction" value="down" />
|
|
||||||
<button type="submit" class="text-gray-300 hover:text-gray-600 dark:text-gray-600 dark:hover:text-gray-400" title="Move down">
|
|
||||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{:else}
|
|
||||||
<div class="h-3 w-3"></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<span class="flex-1 text-sm text-gray-700 dark:text-gray-300">{item.text}</span>
|
|
||||||
{#if item.itemType === 'input'}
|
|
||||||
<span class="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
|
|
||||||
input{#if item.unit}: {item.unit}{/if}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
<button type="button" onclick={() => (editingItemId = item.id)} class="hidden text-gray-400 hover:text-blue-600 group-hover:block dark:text-gray-500 dark:hover:text-blue-400" 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="?/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>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
{#if template.description}
|
||||||
<!-- Add item -->
|
<p class="mb-3 text-sm text-gray-500 dark:text-gray-400">{template.description}</p>
|
||||||
<form method="POST" action="?/addItem" use:enhance={() => {
|
{/if}
|
||||||
return async ({ update, result }) => {
|
<p class="text-xs text-gray-400 dark:text-gray-500">Updated {formatDate(template.updatedAt)}</p>
|
||||||
await update();
|
</a>
|
||||||
if (result.type === 'success') {
|
|
||||||
const form = document.querySelector(`form[data-template="${template.id}"]`) as HTMLFormElement;
|
|
||||||
if (form) resetForm(form);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
data-template={template.id}
|
|
||||||
class="mt-2 space-y-2">
|
|
||||||
<input type="hidden" name="templateId" value={template.id} />
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input type="text" name="text" required
|
|
||||||
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" />
|
|
||||||
<select name="itemType"
|
|
||||||
class="rounded-md border border-gray-200 px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
|
||||||
<option value="checkbox">Checkbox</option>
|
|
||||||
<option value="input">Text input</option>
|
|
||||||
</select>
|
|
||||||
<input type="text" name="unit"
|
|
||||||
placeholder="Unit"
|
|
||||||
class="w-20 rounded-md border border-gray-200 px-2 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>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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 } from 'drizzle-orm';
|
||||||
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
const [template] = await db
|
||||||
|
.select()
|
||||||
|
.from(checklistTemplates)
|
||||||
|
.where(eq(checklistTemplates.id, params.id));
|
||||||
|
|
||||||
|
if (!template) error(404, 'Template not found');
|
||||||
|
|
||||||
|
const items = await db
|
||||||
|
.select()
|
||||||
|
.from(templateItems)
|
||||||
|
.where(eq(templateItems.templateId, params.id))
|
||||||
|
.orderBy(templateItems.sortOrder);
|
||||||
|
|
||||||
|
return { template, items };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
rename: async ({ request, params }) => {
|
||||||
|
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
|
||||||
|
.update(checklistTemplates)
|
||||||
|
.set({ title, description: description || null, updatedAt: new Date() })
|
||||||
|
.where(eq(checklistTemplates.id, params.id));
|
||||||
|
return { renamed: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ params }) => {
|
||||||
|
await db.delete(checklistTemplates).where(eq(checklistTemplates.id, params.id));
|
||||||
|
redirect(303, '/checklists');
|
||||||
|
},
|
||||||
|
|
||||||
|
addItem: async ({ request, params }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const text = (formData.get('text') as string)?.trim();
|
||||||
|
const itemType = (formData.get('itemType') as string) || 'checkbox';
|
||||||
|
const unit = (formData.get('unit') 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, params.id))
|
||||||
|
.orderBy(desc(templateItems.sortOrder))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
await db.insert(templateItems).values({
|
||||||
|
templateId: params.id,
|
||||||
|
text,
|
||||||
|
itemType,
|
||||||
|
unit: unit || null,
|
||||||
|
sortOrder: (existing[0]?.sortOrder ?? -1) + 1
|
||||||
|
});
|
||||||
|
|
||||||
|
return { itemAdded: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
editItem: async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const itemId = formData.get('itemId') as string;
|
||||||
|
const text = (formData.get('text') as string)?.trim();
|
||||||
|
const unit = (formData.get('unit') as string)?.trim();
|
||||||
|
if (!text) return fail(400, { error: 'Item text is required' });
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(templateItems)
|
||||||
|
.set({ text, unit: unit || null })
|
||||||
|
.where(eq(templateItems.id, itemId));
|
||||||
|
return { itemEdited: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
moveItem: async ({ request, params }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const itemId = formData.get('itemId') as string;
|
||||||
|
const direction = formData.get('direction') as string;
|
||||||
|
|
||||||
|
const items = await db
|
||||||
|
.select({ id: templateItems.id, sortOrder: templateItems.sortOrder })
|
||||||
|
.from(templateItems)
|
||||||
|
.where(eq(templateItems.templateId, params.id))
|
||||||
|
.orderBy(templateItems.sortOrder);
|
||||||
|
|
||||||
|
const idx = items.findIndex((i) => i.id === itemId);
|
||||||
|
if (idx < 0) return fail(400, { error: 'Item not found' });
|
||||||
|
|
||||||
|
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||||
|
if (swapIdx < 0 || swapIdx >= items.length) return { moved: true };
|
||||||
|
|
||||||
|
const a = items[idx];
|
||||||
|
const b = items[swapIdx];
|
||||||
|
await db.update(templateItems).set({ sortOrder: b.sortOrder }).where(eq(templateItems.id, a.id));
|
||||||
|
await db.update(templateItems).set({ sortOrder: a.sortOrder }).where(eq(templateItems.id, b.id));
|
||||||
|
|
||||||
|
return { moved: 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,188 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
let editingTitle = $state(false);
|
||||||
|
let editingItemId = $state<string | null>(null);
|
||||||
|
|
||||||
|
function resetAddForm() {
|
||||||
|
const f = document.querySelector('form[data-add-item]') as HTMLFormElement;
|
||||||
|
if (f) {
|
||||||
|
const text = f.querySelector('input[name="text"]') as HTMLInputElement;
|
||||||
|
const unit = f.querySelector('input[name="unit"]') as HTMLInputElement;
|
||||||
|
if (text) text.value = '';
|
||||||
|
if (unit) unit.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.template.title} - Checklists - B4L Repair</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-3xl">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="mb-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<a href="/checklists" class="hover:text-blue-600 dark:hover:text-blue-400">Checklists</a>
|
||||||
|
<span>›</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
{#if editingTitle}
|
||||||
|
<form method="POST" action="?/rename" use:enhance={() => {
|
||||||
|
return async ({ update, result }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') editingTitle = false;
|
||||||
|
};
|
||||||
|
}} class="mb-6 rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<div class="mb-3 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">Name *</label>
|
||||||
|
<input type="text" id="title" name="title" value={data.template.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" />
|
||||||
|
</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" value={data.template.description ?? ''}
|
||||||
|
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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save</button>
|
||||||
|
<button type="button" onclick={() => (editingTitle = false)} class="rounded-md px-3 py-2 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="mb-6 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.template.title}</h1>
|
||||||
|
{#if data.template.description}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{data.template.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="button" onclick={() => (editingTitle = true)}
|
||||||
|
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">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<form method="POST" action="?/delete" use:enhance>
|
||||||
|
<button type="submit"
|
||||||
|
class="rounded-md border border-red-300 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#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}
|
||||||
|
|
||||||
|
<!-- Items -->
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||||
|
Items <span class="text-gray-300 dark:text-gray-600">({data.items.length})</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if data.items.length === 0}
|
||||||
|
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">No items yet. Add your first item below.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="mb-4 space-y-1">
|
||||||
|
{#each data.items as item, idx}
|
||||||
|
{#if editingItemId === item.id}
|
||||||
|
<form method="POST" action="?/editItem" use:enhance={() => {
|
||||||
|
return async ({ update, result }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') editingItemId = null;
|
||||||
|
};
|
||||||
|
}} class="flex items-center gap-2 rounded-md bg-gray-50 p-2 dark:bg-gray-700/30">
|
||||||
|
<input type="hidden" name="itemId" value={item.id} />
|
||||||
|
<input type="text" name="text" value={item.text} required
|
||||||
|
class="flex-1 rounded-md border border-gray-300 px-2 py-1 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" />
|
||||||
|
{#if item.itemType === 'input'}
|
||||||
|
<input type="text" name="unit" value={item.unit ?? ''} placeholder="Unit"
|
||||||
|
class="w-20 rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||||
|
{/if}
|
||||||
|
<button type="submit" class="text-sm text-blue-600 dark:text-blue-400">Save</button>
|
||||||
|
<button type="button" onclick={() => (editingItemId = null)} class="text-sm text-gray-500 dark:text-gray-400">Cancel</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<div class="group flex items-center gap-2 rounded-md py-1.5 px-1 hover:bg-gray-50 dark:hover:bg-gray-700/20">
|
||||||
|
<!-- Move arrows -->
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
{#if idx > 0}
|
||||||
|
<form method="POST" action="?/moveItem" use:enhance>
|
||||||
|
<input type="hidden" name="itemId" value={item.id} />
|
||||||
|
<input type="hidden" name="direction" value="up" />
|
||||||
|
<button type="submit" class="text-gray-300 hover:text-gray-600 dark:text-gray-600 dark:hover:text-gray-400" title="Move up">
|
||||||
|
<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="M5 15l7-7 7 7" /></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{:else}<div class="h-3.5 w-3.5"></div>{/if}
|
||||||
|
{#if idx < data.items.length - 1}
|
||||||
|
<form method="POST" action="?/moveItem" use:enhance>
|
||||||
|
<input type="hidden" name="itemId" value={item.id} />
|
||||||
|
<input type="hidden" name="direction" value="down" />
|
||||||
|
<button type="submit" class="text-gray-300 hover:text-gray-600 dark:text-gray-600 dark:hover:text-gray-400" title="Move down">
|
||||||
|
<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="M19 9l-7 7-7-7" /></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{:else}<div class="h-3.5 w-3.5"></div>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Number -->
|
||||||
|
<span class="w-5 text-right text-xs text-gray-400 dark:text-gray-500">{idx + 1}.</span>
|
||||||
|
|
||||||
|
<!-- Text -->
|
||||||
|
<span class="flex-1 text-sm text-gray-700 dark:text-gray-300">{item.text}</span>
|
||||||
|
|
||||||
|
<!-- Type badge -->
|
||||||
|
{#if item.itemType === 'input'}
|
||||||
|
<span class="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
|
||||||
|
input{#if item.unit}: {item.unit}{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit -->
|
||||||
|
<button type="button" onclick={() => (editingItemId = item.id)}
|
||||||
|
class="hidden text-gray-400 hover:text-blue-600 group-hover:block dark:text-gray-500 dark:hover:text-blue-400" 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>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Add item -->
|
||||||
|
<form method="POST" action="?/addItem" use:enhance={() => {
|
||||||
|
return async ({ update, result }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') resetAddForm();
|
||||||
|
};
|
||||||
|
}} data-add-item class="border-t border-gray-100 pt-3 dark:border-gray-700">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" name="text" required
|
||||||
|
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" />
|
||||||
|
<select name="itemType"
|
||||||
|
class="rounded-md border border-gray-200 px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||||
|
<option value="checkbox">Checkbox</option>
|
||||||
|
<option value="input">Text input</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" name="unit" placeholder="Unit"
|
||||||
|
class="w-20 rounded-md border border-gray-200 px-2 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 bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Add</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user