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) ───────────────
|
// ─── Device Log (append-only repair/operation history) ───────────────
|
||||||
|
|
||||||
export const deviceLog = pgTable(
|
export const deviceLog = pgTable(
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
components,
|
components,
|
||||||
installationLog,
|
installationLog,
|
||||||
deviceLog,
|
deviceLog,
|
||||||
|
deviceChecklists,
|
||||||
|
checklistItems,
|
||||||
locations
|
locations
|
||||||
} from '$lib/server/db/schema.js';
|
} 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 { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { saveImage, saveDocument, deleteFile } from '$lib/server/uploads.js';
|
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))
|
.where(eq(deviceLog.deviceId, params.id))
|
||||||
.orderBy(desc(deviceLog.performedAt));
|
.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 {
|
return {
|
||||||
device,
|
device,
|
||||||
computerDetails: compDetails,
|
computerDetails: compDetails,
|
||||||
@@ -109,7 +135,11 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
documents,
|
documents,
|
||||||
installedComponents,
|
installedComponents,
|
||||||
history,
|
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));
|
.where(eq(devices.id, params.id));
|
||||||
|
|
||||||
redirect(303, '/devices');
|
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 showDocForm = $state(false);
|
||||||
let showDeleteConfirm = $state(false);
|
let showDeleteConfirm = $state(false);
|
||||||
let showLogForm = $state(false);
|
let showLogForm = $state(false);
|
||||||
|
let showNewChecklist = $state(false);
|
||||||
|
let newItemText: Record<string, string> = $state({});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -327,6 +329,109 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar (1 col) -->
|
<!-- Sidebar (1 col) -->
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Reference in New Issue
Block a user