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