Add device checklists and retro Mac favicon

Checklists feature:
- device_checklists and checklist_items tables
- Create multiple named checklists per device
- Add/toggle/delete items with progress bar
- Checkbox UI with green check, strikethrough for completed items
- Delete checklist button with item count display

Also adds the classic Mac happy face as favicon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 10:31:38 +07:00
parent 04ca0a8299
commit dbe82c1019
4 changed files with 232 additions and 2 deletions
+30
View File
@@ -195,6 +195,36 @@ export const installationLog = pgTable(
]
);
// ─── Device Checklists ──────────────────────────────────────────────
export const deviceChecklists = pgTable(
'device_checklists',
{
id: uuid('id').defaultRandom().primaryKey(),
deviceId: uuid('device_id')
.notNull()
.references(() => devices.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [index('device_checklists_device_idx').on(table.deviceId)]
);
export const checklistItems = pgTable(
'checklist_items',
{
id: uuid('id').defaultRandom().primaryKey(),
checklistId: uuid('checklist_id')
.notNull()
.references(() => deviceChecklists.id, { onDelete: 'cascade' }),
text: text('text').notNull(),
checked: boolean('checked').default(false).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [index('checklist_items_checklist_idx').on(table.checklistId)]
);
// ─── Device Log (append-only repair/operation history) ───────────────
export const deviceLog = pgTable(
+97 -2
View File
@@ -8,9 +8,11 @@ import {
components,
installationLog,
deviceLog,
deviceChecklists,
checklistItems,
locations
} from '$lib/server/db/schema.js';
import { eq, desc } from 'drizzle-orm';
import { eq, desc, sql } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import { saveImage, saveDocument, deleteFile } from '$lib/server/uploads.js';
@@ -102,6 +104,30 @@ export const load: PageServerLoad = async ({ params }) => {
.where(eq(deviceLog.deviceId, params.id))
.orderBy(desc(deviceLog.performedAt));
// Checklists
const checklists = await db
.select()
.from(deviceChecklists)
.where(eq(deviceChecklists.deviceId, params.id))
.orderBy(deviceChecklists.createdAt);
const checklistIds = checklists.map((c) => c.id);
let itemsByChecklist: Record<string, typeof allItems> = {};
let allItems: Array<{ id: string; checklistId: string; text: string; checked: boolean; sortOrder: number; createdAt: Date }> = [];
if (checklistIds.length > 0) {
allItems = await db
.select()
.from(checklistItems)
.where(sql`${checklistItems.checklistId} IN ${checklistIds}`)
.orderBy(checklistItems.sortOrder, checklistItems.createdAt);
for (const item of allItems) {
if (!itemsByChecklist[item.checklistId]) itemsByChecklist[item.checklistId] = [];
itemsByChecklist[item.checklistId].push(item);
}
}
return {
device,
computerDetails: compDetails,
@@ -109,7 +135,11 @@ export const load: PageServerLoad = async ({ params }) => {
documents,
installedComponents,
history,
operationLog
operationLog,
checklists: checklists.map((c) => ({
...c,
items: itemsByChecklist[c.id] ?? []
}))
};
};
@@ -219,5 +249,70 @@ export const actions: Actions = {
.where(eq(devices.id, params.id));
redirect(303, '/devices');
},
createChecklist: async ({ request, params }) => {
const formData = await request.formData();
const title = (formData.get('title') as string)?.trim();
if (!title) return fail(400, { error: 'Checklist title is required' });
await db.insert(deviceChecklists).values({
deviceId: params.id,
title
});
return { checklistCreated: true };
},
deleteChecklist: async ({ request }) => {
const formData = await request.formData();
const checklistId = formData.get('checklistId') as string;
await db.delete(deviceChecklists).where(eq(deviceChecklists.id, checklistId));
return { checklistDeleted: true };
},
addChecklistItem: async ({ request }) => {
const formData = await request.formData();
const checklistId = formData.get('checklistId') as string;
const text = (formData.get('text') as string)?.trim();
if (!text) return fail(400, { error: 'Item text is required' });
// Get next sort order
const existing = await db
.select({ sortOrder: checklistItems.sortOrder })
.from(checklistItems)
.where(eq(checklistItems.checklistId, checklistId))
.orderBy(desc(checklistItems.sortOrder))
.limit(1);
const nextOrder = (existing[0]?.sortOrder ?? -1) + 1;
await db.insert(checklistItems).values({
checklistId,
text,
sortOrder: nextOrder
});
return { itemAdded: true };
},
toggleChecklistItem: async ({ request }) => {
const formData = await request.formData();
const itemId = formData.get('itemId') as string;
const checked = formData.get('checked') === 'true';
await db
.update(checklistItems)
.set({ checked: !checked })
.where(eq(checklistItems.id, itemId));
return { itemToggled: true };
},
deleteChecklistItem: async ({ request }) => {
const formData = await request.formData();
const itemId = formData.get('itemId') as string;
await db.delete(checklistItems).where(eq(checklistItems.id, itemId));
return { itemDeleted: true };
}
};
+105
View File
@@ -9,6 +9,8 @@
let showDocForm = $state(false);
let showDeleteConfirm = $state(false);
let showLogForm = $state(false);
let showNewChecklist = $state(false);
let newItemText: Record<string, string> = $state({});
</script>
<svelte:head>
@@ -327,6 +329,109 @@
</div>
{/if}
</div>
<!-- Checklists -->
<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>
{#if showNewChecklist}
<form method="POST" action="?/createChecklist" use:enhance class="mb-4 flex gap-2">
<input type="text" name="title" required placeholder="Checklist name..."
class="flex-1 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 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">Create</button>
</form>
{/if}
{#if data.checklists.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No checklists yet.</p>
{:else}
<div class="space-y-4">
{#each data.checklists as checklist}
<div class="rounded-md border border-gray-100 p-3 dark:border-gray-700">
<div class="mb-2 flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{checklist.title}</h3>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400 dark:text-gray-500">
{checklist.items.filter((i: any) => i.checked).length}/{checklist.items.length}
</span>
<form method="POST" action="?/deleteChecklist" use:enhance>
<input type="hidden" name="checklistId" value={checklist.id} />
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400" title="Delete checklist">
<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>
<!-- Progress bar -->
{#if checklist.items.length > 0}
{@const pct = Math.round((checklist.items.filter((i: any) => i.checked).length / checklist.items.length) * 100)}
<div class="mb-2 h-1.5 w-full rounded-full bg-gray-100 dark:bg-gray-700">
<div class="h-1.5 rounded-full transition-all {pct === 100 ? 'bg-green-500' : 'bg-blue-500'}" style="width: {pct}%"></div>
</div>
{/if}
<!-- Items -->
<div class="space-y-1">
{#each checklist.items as item}
<div class="group flex items-center gap-2">
<form method="POST" action="?/toggleChecklistItem" use:enhance class="flex items-center">
<input type="hidden" name="itemId" value={item.id} />
<input type="hidden" name="checked" value={String(item.checked)} />
<button type="submit" class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border
{item.checked
? 'border-green-500 bg-green-500 text-white dark:border-green-400 dark:bg-green-500'
: 'border-gray-300 hover:border-blue-400 dark:border-gray-600 dark:hover:border-blue-500'}
">
{#if item.checked}
<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>
{/if}
</button>
</form>
<span class="flex-1 text-sm {item.checked ? 'text-gray-400 line-through dark:text-gray-500' : 'text-gray-700 dark:text-gray-300'}">
{item.text}
</span>
<form method="POST" action="?/deleteChecklistItem" 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="?/addChecklistItem" use:enhance
onsubmit={() => { newItemText[checklist.id] = ''; }}
class="mt-2 flex gap-2">
<input type="hidden" name="checklistId" value={checklist.id} />
<input type="text" name="text" required bind:value={newItemText[checklist.id]}
placeholder="Add item..."
class="flex-1 rounded-md border border-gray-200 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 dark:placeholder-gray-400" />
<button type="submit" class="rounded-md px-2 py-1 text-sm text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20">
<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="M12 4v16m8-8H4" />
</svg>
</button>
</form>
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Sidebar (1 col) -->
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB