Add document uploads to components (PDFs, manuals, datasheets)

- component_documents table matching device_documents structure
- Upload/delete actions on component detail page
- Documents section in sidebar with file icon, filename, delete on hover
- Devices already had document support; components now match

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 15:03:43 +07:00
parent d6b53d8ec8
commit 117646d2cb
3 changed files with 110 additions and 4 deletions
+18
View File
@@ -170,6 +170,24 @@ export const componentImages = pgTable(
(table) => [index('component_images_component_idx').on(table.componentId)]
);
// ─── Component Documents ────────────────────────────────────────────
export const componentDocuments = pgTable(
'component_documents',
{
id: uuid('id').defaultRandom().primaryKey(),
componentId: uuid('component_id')
.notNull()
.references(() => components.id, { onDelete: 'cascade' }),
filePath: text('file_path').notNull(),
originalFilename: text('original_filename').notNull(),
fileType: text('file_type'),
description: text('description'),
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [index('component_documents_component_idx').on(table.componentId)]
);
// ─── Installation Log (append-only) ─────────────────────────────────
export const installationLog = pgTable(
@@ -1,8 +1,9 @@
import type { PageServerLoad } from './$types';
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { components, devices, locations, installationLog, componentImages } from '$lib/server/db/schema.js';
import { components, devices, locations, installationLog, componentImages, componentDocuments } from '$lib/server/db/schema.js';
import { eq, desc } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
import { error, fail } from '@sveltejs/kit';
import { saveDocument, deleteFile } from '$lib/server/uploads.js';
export const load: PageServerLoad = async ({ params }) => {
const [component] = await db
@@ -36,6 +37,11 @@ export const load: PageServerLoad = async ({ params }) => {
.from(componentImages)
.where(eq(componentImages.componentId, params.id));
const documents = await db
.select()
.from(componentDocuments)
.where(eq(componentDocuments.componentId, params.id));
const history = await db
.select({
id: installationLog.id,
@@ -51,5 +57,39 @@ export const load: PageServerLoad = async ({ params }) => {
.where(eq(installationLog.componentId, params.id))
.orderBy(desc(installationLog.performedAt));
return { component, images, history };
return { component, images, documents, history };
};
export const actions: Actions = {
uploadDocument: async ({ request, params }) => {
const formData = await request.formData();
const file = formData.get('document') as File;
if (!file || file.size === 0) return fail(400, { error: 'No file selected' });
const { filePath, originalFilename } = await saveDocument(file);
const description = formData.get('description') as string;
await db.insert(componentDocuments).values({
componentId: params.id,
filePath,
originalFilename,
fileType: file.type,
description: description || null
});
return { documentUploaded: true };
},
deleteDocument: async ({ request }) => {
const formData = await request.formData();
const docId = formData.get('docId') as string;
const [doc] = await db.select().from(componentDocuments).where(eq(componentDocuments.id, docId));
if (doc) {
await deleteFile(doc.filePath);
await db.delete(componentDocuments).where(eq(componentDocuments.id, docId));
}
return { documentDeleted: true };
}
};
@@ -1,8 +1,10 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatDate, timeAgo } from '$lib/utils/date.js';
let { data } = $props();
const c = $derived(data.component);
let showDocForm = $state(false);
</script>
<svelte:head>
@@ -142,6 +144,52 @@
</dl>
</div>
<!-- Documents -->
<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">Documents</h2>
<button type="button" onclick={() => (showDocForm = !showDocForm)}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
{showDocForm ? 'Cancel' : 'Upload'}
</button>
</div>
{#if showDocForm}
<form method="POST" action="?/uploadDocument" enctype="multipart/form-data" use:enhance class="mb-3 space-y-2">
<input type="file" name="document" required class="text-sm text-gray-600 dark:text-gray-400" />
<input type="text" name="description" placeholder="Description"
class="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Upload</button>
</form>
{/if}
{#if data.documents.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No documents.</p>
{:else}
<div class="space-y-1">
{#each data.documents as doc}
<div class="group flex items-center gap-2">
<a href={doc.filePath} target="_blank"
class="flex flex-1 items-center gap-2 rounded-md p-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700/50">
<svg class="h-4 w-4 flex-shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span class="truncate text-gray-700 dark:text-gray-300">{doc.originalFilename}</span>
</a>
<form method="POST" action="?/deleteDocument" use:enhance>
<input type="hidden" name="docId" value={doc.id} />
<button type="submit" class="hidden rounded p-1 text-gray-400 hover:text-red-500 group-hover:block dark:text-gray-500 dark:hover:text-red-400" title="Delete">
<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>
{/if}
</div>
{#if c.notes}
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Notes</h2>