Add document detail page with versions and download endpoint
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,256 @@
|
|||||||
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
import {
|
||||||
|
companyDocuments,
|
||||||
|
companyDocumentVersions,
|
||||||
|
users
|
||||||
|
} from '$lib/server/db/schema.js';
|
||||||
|
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||||
|
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||||
|
import {
|
||||||
|
saveCompanyFile,
|
||||||
|
deleteCompanyFile,
|
||||||
|
isAllowedMime,
|
||||||
|
MAX_BYTES,
|
||||||
|
ALLOWED_MIME
|
||||||
|
} from '$lib/server/uploads/index.js';
|
||||||
|
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
const DOCUMENT_CATEGORIES = [
|
||||||
|
'dbd_registration',
|
||||||
|
'affidavit',
|
||||||
|
'memorandum',
|
||||||
|
'articles_of_association',
|
||||||
|
'vat_registration',
|
||||||
|
'tax_id_document',
|
||||||
|
'bank_document',
|
||||||
|
'director_id',
|
||||||
|
'director_signature_card',
|
||||||
|
'shareholder_list',
|
||||||
|
'annual_filing',
|
||||||
|
'contract',
|
||||||
|
'license',
|
||||||
|
'insurance',
|
||||||
|
'other'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type DocumentCategory = (typeof DOCUMENT_CATEGORIES)[number];
|
||||||
|
|
||||||
|
function trimOrNull(v: FormDataEntryValue | null): string | null {
|
||||||
|
const s = v?.toString().trim();
|
||||||
|
return s ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||||
|
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||||
|
await parent();
|
||||||
|
|
||||||
|
const [doc] = await db
|
||||||
|
.select()
|
||||||
|
.from(companyDocuments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(companyDocuments.id, params.docId),
|
||||||
|
eq(companyDocuments.companyId, params.companyId),
|
||||||
|
isNull(companyDocuments.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!doc) error(404, 'Document not found');
|
||||||
|
|
||||||
|
const versions = await db
|
||||||
|
.select({
|
||||||
|
id: companyDocumentVersions.id,
|
||||||
|
versionNumber: companyDocumentVersions.versionNumber,
|
||||||
|
fileName: companyDocumentVersions.fileName,
|
||||||
|
mimeType: companyDocumentVersions.mimeType,
|
||||||
|
sizeBytes: companyDocumentVersions.sizeBytes,
|
||||||
|
uploadedAt: companyDocumentVersions.uploadedAt,
|
||||||
|
comment: companyDocumentVersions.comment,
|
||||||
|
uploadedByName: users.displayName,
|
||||||
|
uploadedByEmail: users.email
|
||||||
|
})
|
||||||
|
.from(companyDocumentVersions)
|
||||||
|
.leftJoin(users, eq(companyDocumentVersions.uploadedBy, users.id))
|
||||||
|
.where(eq(companyDocumentVersions.documentId, doc.id))
|
||||||
|
.orderBy(desc(companyDocumentVersions.versionNumber));
|
||||||
|
|
||||||
|
return { document: doc, versions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
addVersion: async ({ request, locals, params }) => {
|
||||||
|
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||||
|
'admin',
|
||||||
|
'accountant'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [doc] = await db
|
||||||
|
.select({ id: companyDocuments.id, title: companyDocuments.title })
|
||||||
|
.from(companyDocuments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(companyDocuments.id, params.docId),
|
||||||
|
eq(companyDocuments.companyId, params.companyId),
|
||||||
|
isNull(companyDocuments.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!doc) error(404, 'Document not found');
|
||||||
|
|
||||||
|
const fd = await request.formData();
|
||||||
|
const file = fd.get('file') as File | null;
|
||||||
|
const comment = trimOrNull(fd.get('comment'));
|
||||||
|
|
||||||
|
if (!file || !(file instanceof File) || file.size === 0) {
|
||||||
|
return fail(400, { action: 'addVersion', error: 'File is required' });
|
||||||
|
}
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
return fail(400, {
|
||||||
|
action: 'addVersion',
|
||||||
|
error: `File too large (max ${Math.round(MAX_BYTES / 1024 / 1024)} MB)`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const mime = file.type || 'application/octet-stream';
|
||||||
|
if (!isAllowedMime(mime)) {
|
||||||
|
return fail(400, {
|
||||||
|
action: 'addVersion',
|
||||||
|
error: `File type not allowed. Allowed: ${ALLOWED_MIME.join(', ')}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [maxRow] = await db
|
||||||
|
.select({ max: sql<number>`coalesce(max(${companyDocumentVersions.versionNumber}), 0)::int` })
|
||||||
|
.from(companyDocumentVersions)
|
||||||
|
.where(eq(companyDocumentVersions.documentId, doc.id));
|
||||||
|
const nextVersion = (maxRow?.max ?? 0) + 1;
|
||||||
|
|
||||||
|
let saved;
|
||||||
|
try {
|
||||||
|
saved = await saveCompanyFile(params.companyId, file);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('saveCompanyFile failed', err);
|
||||||
|
return fail(500, { action: 'addVersion', error: 'Failed to save file to disk' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(companyDocumentVersions).values({
|
||||||
|
documentId: doc.id,
|
||||||
|
versionNumber: nextVersion,
|
||||||
|
fileName: file.name,
|
||||||
|
storedPath: saved.storedPath,
|
||||||
|
mimeType: saved.mimeType,
|
||||||
|
sizeBytes: saved.sizeBytes,
|
||||||
|
uploadedBy: user.id,
|
||||||
|
comment
|
||||||
|
});
|
||||||
|
await db
|
||||||
|
.update(companyDocuments)
|
||||||
|
.set({ updatedAt: new Date() })
|
||||||
|
.where(eq(companyDocuments.id, doc.id));
|
||||||
|
} catch (err) {
|
||||||
|
await deleteCompanyFile(saved.storedPath);
|
||||||
|
console.error('insert version failed', err);
|
||||||
|
return fail(500, { action: 'addVersion', error: 'Failed to register file version' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await logCompanyEvent(
|
||||||
|
params.companyId,
|
||||||
|
user.id,
|
||||||
|
'document_version_added',
|
||||||
|
`New version v${nextVersion} uploaded for "${doc.title}"`,
|
||||||
|
{ documentId: doc.id, versionNumber: nextVersion, fileName: file.name }
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, action: 'addVersion' };
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMetadata: async ({ request, locals, params }) => {
|
||||||
|
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||||
|
'admin',
|
||||||
|
'accountant'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [doc] = await db
|
||||||
|
.select({ id: companyDocuments.id })
|
||||||
|
.from(companyDocuments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(companyDocuments.id, params.docId),
|
||||||
|
eq(companyDocuments.companyId, params.companyId),
|
||||||
|
isNull(companyDocuments.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!doc) error(404, 'Document not found');
|
||||||
|
|
||||||
|
const fd = await request.formData();
|
||||||
|
const title = trimOrNull(fd.get('title'));
|
||||||
|
const category = fd.get('category')?.toString() as DocumentCategory | undefined;
|
||||||
|
|
||||||
|
if (!title) return fail(400, { action: 'updateMetadata', error: 'Title is required' });
|
||||||
|
if (!category || !DOCUMENT_CATEGORIES.includes(category)) {
|
||||||
|
return fail(400, { action: 'updateMetadata', error: 'Invalid category' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(companyDocuments)
|
||||||
|
.set({
|
||||||
|
title,
|
||||||
|
category,
|
||||||
|
customLabel: trimOrNull(fd.get('customLabel')),
|
||||||
|
description: trimOrNull(fd.get('description')),
|
||||||
|
expiresAt: trimOrNull(fd.get('expiresAt')) ?? null,
|
||||||
|
notes: trimOrNull(fd.get('notes')),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(companyDocuments.id, doc.id));
|
||||||
|
|
||||||
|
await logCompanyEvent(
|
||||||
|
params.companyId,
|
||||||
|
user.id,
|
||||||
|
'document_metadata_updated',
|
||||||
|
`Document "${title}" metadata updated`,
|
||||||
|
{ documentId: doc.id }
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, action: 'updateMetadata' };
|
||||||
|
},
|
||||||
|
|
||||||
|
softDelete: async ({ locals, params }) => {
|
||||||
|
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||||
|
'admin',
|
||||||
|
'accountant'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [doc] = await db
|
||||||
|
.select({ id: companyDocuments.id, title: companyDocuments.title })
|
||||||
|
.from(companyDocuments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(companyDocuments.id, params.docId),
|
||||||
|
eq(companyDocuments.companyId, params.companyId),
|
||||||
|
isNull(companyDocuments.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!doc) error(404, 'Document not found');
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(companyDocuments)
|
||||||
|
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(eq(companyDocuments.id, doc.id));
|
||||||
|
|
||||||
|
await logCompanyEvent(
|
||||||
|
params.companyId,
|
||||||
|
user.id,
|
||||||
|
'document_deleted',
|
||||||
|
`Document "${doc.title}" deleted`,
|
||||||
|
{ documentId: doc.id }
|
||||||
|
);
|
||||||
|
|
||||||
|
redirect(303, `/companies/${params.companyId}/documents`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import type { PageData, ActionData } from './$types';
|
||||||
|
import { formatDate, formatDateTime } from '$lib/utils/date.js';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
const canEdit = $derived(
|
||||||
|
data.companyRoles.includes('admin') || data.companyRoles.includes('accountant')
|
||||||
|
);
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
dbd_registration: 'DBD Registration',
|
||||||
|
affidavit: 'Affidavit (หนังสือรับรอง)',
|
||||||
|
memorandum: 'Memorandum (MOA)',
|
||||||
|
articles_of_association: 'Articles of Association',
|
||||||
|
vat_registration: 'VAT Registration (PP.20)',
|
||||||
|
tax_id_document: 'Tax ID Document',
|
||||||
|
bank_document: 'Bank Document',
|
||||||
|
director_id: 'Director ID',
|
||||||
|
director_signature_card: 'Director Signature Card',
|
||||||
|
shareholder_list: 'Shareholder List (BOJ.5)',
|
||||||
|
annual_filing: 'Annual Filing',
|
||||||
|
contract: 'Contract',
|
||||||
|
license: 'License',
|
||||||
|
insurance: 'Insurance',
|
||||||
|
other: 'Other'
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_BADGE: Record<string, string> = {
|
||||||
|
dbd_registration: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||||
|
affidavit: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||||
|
memorandum: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
articles_of_association: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
|
||||||
|
vat_registration: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||||
|
tax_id_document: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
||||||
|
bank_document: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||||
|
director_id: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
|
||||||
|
director_signature_card: 'bg-pink-100 text-pink-700 dark:bg-pink-900/40 dark:text-pink-300',
|
||||||
|
shareholder_list: 'bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-900/40 dark:text-fuchsia-300',
|
||||||
|
annual_filing: 'bg-lime-100 text-lime-700 dark:bg-lime-900/40 dark:text-lime-300',
|
||||||
|
contract: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||||
|
license: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300',
|
||||||
|
insurance: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||||
|
other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_CATEGORIES = Object.keys(CATEGORY_LABELS);
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
let showAddVersion = $state(false);
|
||||||
|
let confirmDelete = $state(false);
|
||||||
|
|
||||||
|
function humanSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeIcon(mime: string): string {
|
||||||
|
if (mime === 'application/pdf') return '📄';
|
||||||
|
if (mime.startsWith('image/')) return '🖼';
|
||||||
|
if (mime.includes('word')) return '📝';
|
||||||
|
if (mime.includes('excel') || mime.includes('spreadsheet')) return '📊';
|
||||||
|
return '📎';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpired(expiresAt: string | null): boolean {
|
||||||
|
if (!expiresAt) return false;
|
||||||
|
return new Date(expiresAt) < new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionFileUrl(versionId: string): string {
|
||||||
|
return `/companies/${$page.params.companyId}/documents/${data.document.id}/versions/${versionId}/file`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
|
||||||
|
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.document.title} - {data.company.name}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<nav class="text-sm">
|
||||||
|
<a
|
||||||
|
href="/companies/{$page.params.companyId}/documents"
|
||||||
|
class="text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
>
|
||||||
|
← Back to documents
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="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}
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<section
|
||||||
|
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
{#if editing}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateMetadata"
|
||||||
|
use:enhance={() => async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
editing = false;
|
||||||
|
}}
|
||||||
|
class="grid grid-cols-1 gap-3 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label for="title" class={labelCls}>Title *</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
value={data.document.title}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="category" class={labelCls}>Category *</label>
|
||||||
|
<select id="category" name="category" required class={inputCls}>
|
||||||
|
{#each ALL_CATEGORIES as cat}
|
||||||
|
<option value={cat} selected={cat === data.document.category}>
|
||||||
|
{CATEGORY_LABELS[cat]}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="customLabel" class={labelCls}>Custom Label</label>
|
||||||
|
<input
|
||||||
|
id="customLabel"
|
||||||
|
name="customLabel"
|
||||||
|
value={data.document.customLabel ?? ''}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="expiresAt" class={labelCls}>Expiry Date</label>
|
||||||
|
<input
|
||||||
|
id="expiresAt"
|
||||||
|
name="expiresAt"
|
||||||
|
type="date"
|
||||||
|
value={data.document.expiresAt ?? ''}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="description" class={labelCls}>Description</label>
|
||||||
|
<textarea id="description" name="description" rows="2" class={inputCls}
|
||||||
|
>{data.document.description ?? ''}</textarea
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="notes" class={labelCls}>Notes</label>
|
||||||
|
<textarea id="notes" name="notes" rows="2" class={inputCls}>{data.document.notes ?? ''}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (editing = false)}
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.document.title}</h1>
|
||||||
|
<span
|
||||||
|
class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {CATEGORY_BADGE[
|
||||||
|
data.document.category
|
||||||
|
]}"
|
||||||
|
>
|
||||||
|
{CATEGORY_LABELS[data.document.category]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if data.document.customLabel}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{data.document.customLabel}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if canEdit}
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => (editing = true)}
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (confirmDelete = !confirmDelete)}
|
||||||
|
class="rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/30"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
{#if data.document.description}
|
||||||
|
<div class="sm:col-span-3">
|
||||||
|
<dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||||
|
Description
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{data.document.description}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||||
|
Expiry Date
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
class={data.document.expiresAt && isExpired(data.document.expiresAt)
|
||||||
|
? 'mt-1 text-sm font-medium text-red-600 dark:text-red-400'
|
||||||
|
: 'mt-1 text-sm text-gray-900 dark:text-gray-100'}
|
||||||
|
>
|
||||||
|
{data.document.expiresAt ? formatDate(data.document.expiresAt) : '—'}
|
||||||
|
{#if data.document.expiresAt && isExpired(data.document.expiresAt)}
|
||||||
|
<span class="ml-1 text-xs">(expired)</span>
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Created</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{formatDateTime(data.document.createdAt)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Updated</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{formatDateTime(data.document.updatedAt)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{#if data.document.notes}
|
||||||
|
<div class="sm:col-span-3">
|
||||||
|
<dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Notes</dt>
|
||||||
|
<dd class="mt-1 whitespace-pre-wrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{data.document.notes}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{#if confirmDelete && canEdit}
|
||||||
|
<div
|
||||||
|
class="mt-4 rounded-md border border-red-300 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-red-700 dark:text-red-300">
|
||||||
|
Delete this document? Files will remain on disk for recovery, but the document will be
|
||||||
|
hidden from the list.
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (confirmDelete = false)}
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<form method="POST" action="?/softDelete">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Versions -->
|
||||||
|
<section
|
||||||
|
class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<h2 class="font-semibold text-gray-900 dark:text-white">
|
||||||
|
Versions ({data.versions.length})
|
||||||
|
</h2>
|
||||||
|
{#if canEdit}
|
||||||
|
<button
|
||||||
|
onclick={() => (showAddVersion = !showAddVersion)}
|
||||||
|
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{showAddVersion ? 'Cancel' : '+ Upload New Version'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showAddVersion && canEdit}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addVersion"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
use:enhance={() => async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
showAddVersion = false;
|
||||||
|
}}
|
||||||
|
class="grid grid-cols-1 gap-3 border-b border-gray-200 bg-blue-50 p-4 dark:border-gray-700 dark:bg-blue-900/20"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label for="version-file" class={labelCls}>File *</label>
|
||||||
|
<input
|
||||||
|
id="version-file"
|
||||||
|
type="file"
|
||||||
|
name="file"
|
||||||
|
required
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.webp,.heic,.doc,.docx,.xls,.xlsx"
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
PDF, image, or Office document up to 25 MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="version-comment" class={labelCls}>Note</label>
|
||||||
|
<input
|
||||||
|
id="version-comment"
|
||||||
|
name="comment"
|
||||||
|
placeholder="What changed? (optional)"
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Upload Version
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if data.versions.length === 0}
|
||||||
|
<p class="p-6 text-sm text-gray-500 dark:text-gray-400">No versions yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{#each data.versions as v}
|
||||||
|
<li class="p-4">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-lg">{mimeIcon(v.mimeType)}</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">v{v.versionNumber}</span>
|
||||||
|
<span class="truncate text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{v.fileName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{humanSize(v.sizeBytes)} · {v.mimeType} · uploaded
|
||||||
|
{formatDateTime(v.uploadedAt)} by
|
||||||
|
{v.uploadedByName ?? v.uploadedByEmail ?? 'unknown'}
|
||||||
|
</div>
|
||||||
|
{#if v.comment}
|
||||||
|
<div
|
||||||
|
class="mt-2 rounded-md bg-gray-50 p-2 text-sm text-gray-700 dark:bg-gray-700/50 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{v.comment}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={versionFileUrl(v.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="shrink-0 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
import { companyDocuments, companyDocumentVersions } from '$lib/server/db/schema.js';
|
||||||
|
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||||
|
import { readCompanyFile } from '$lib/server/uploads/index.js';
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals, params }) => {
|
||||||
|
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
storedPath: companyDocumentVersions.storedPath,
|
||||||
|
fileName: companyDocumentVersions.fileName,
|
||||||
|
mimeType: companyDocumentVersions.mimeType
|
||||||
|
})
|
||||||
|
.from(companyDocumentVersions)
|
||||||
|
.innerJoin(companyDocuments, eq(companyDocumentVersions.documentId, companyDocuments.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(companyDocumentVersions.id, params.versionId),
|
||||||
|
eq(companyDocuments.id, params.docId),
|
||||||
|
eq(companyDocuments.companyId, params.companyId),
|
||||||
|
isNull(companyDocuments.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) error(404, 'File not found');
|
||||||
|
|
||||||
|
let buf: Buffer;
|
||||||
|
try {
|
||||||
|
buf = await readCompanyFile(row.storedPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('readCompanyFile failed', err);
|
||||||
|
error(404, 'File missing on disk');
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeName = row.fileName.replace(/[\r\n"\\]/g, '_');
|
||||||
|
|
||||||
|
return new Response(new Blob([buf as BlobPart], { type: row.mimeType }), {
|
||||||
|
headers: {
|
||||||
|
'Content-Disposition': `inline; filename="${safeName}"`,
|
||||||
|
'Cache-Control': 'private, no-store'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user