diff --git a/.cursor/settings.json b/.cursor/settings.json
new file mode 100644
index 00000000..6595b051
--- /dev/null
+++ b/.cursor/settings.json
@@ -0,0 +1,7 @@
+{
+ "plugins": {
+ "svelte": {
+ "enabled": true
+ }
+ }
+}
diff --git a/apps/overseer/src/lib/env.ts b/apps/overseer/src/lib/env.ts
index 07e44c33..a8fcf05a 100644
--- a/apps/overseer/src/lib/env.ts
+++ b/apps/overseer/src/lib/env.ts
@@ -10,7 +10,8 @@ export const Env = type({
R2_BUCKET_NAME: 'string',
TURSO_URL: 'string',
TURSO_TOKEN: 'string',
- PUBLIC_WEB_URL: 'string.url'
+ PUBLIC_WEB_URL: 'string.url',
+ PUBLIC_FILES_BASE_URL: 'string.url'
});
let parsedEnv: typeof Env.infer | undefined;
@@ -19,7 +20,8 @@ function getEnv(): typeof Env.infer {
if (!parsedEnv) {
const result = Env({
...dynamicPrivateEnv,
- ...dynamicPublicEnv
+ ...dynamicPublicEnv,
+ PUBLIC_FILES_BASE_URL: dynamicPublicEnv.PUBLIC_FILES_BASE_URL ?? 'https://files.czqm.ca'
});
if (result instanceof type.errors) {
diff --git a/apps/overseer/src/lib/publicEnv.ts b/apps/overseer/src/lib/publicEnv.ts
index cdfa3459..2681fb00 100644
--- a/apps/overseer/src/lib/publicEnv.ts
+++ b/apps/overseer/src/lib/publicEnv.ts
@@ -2,11 +2,13 @@ import { type } from 'arktype';
import { env as dynamicPublicEnv } from '$env/dynamic/public';
export const Env = type({
- PUBLIC_WEB_URL: 'string.url'
+ PUBLIC_WEB_URL: 'string.url',
+ PUBLIC_FILES_BASE_URL: 'string.url'
});
const parsedEnv = Env({
- ...dynamicPublicEnv
+ ...dynamicPublicEnv,
+ PUBLIC_FILES_BASE_URL: dynamicPublicEnv.PUBLIC_FILES_BASE_URL ?? 'https://files.czqm.ca'
});
if (parsedEnv instanceof type.errors) {
diff --git a/apps/overseer/src/lib/remote/dms.remote.ts b/apps/overseer/src/lib/remote/dms.remote.ts
new file mode 100644
index 00000000..1bd0fb6a
--- /dev/null
+++ b/apps/overseer/src/lib/remote/dms.remote.ts
@@ -0,0 +1,562 @@
+import { command, form, getRequestEvent, query } from '$app/server';
+import { db } from '$lib/db';
+import env from '$lib/env';
+import { DmsAsset, DmsDocument, DmsGroup, User } from '@czqm/common';
+import { error, invalid, redirect } from '@sveltejs/kit';
+import { type } from 'arktype';
+import { asc } from 'drizzle-orm';
+import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
+
+export const getDocuments = query(async () => {
+ const event = getRequestEvent();
+ const user = await User.resolveAuthenticatedUser(db, {
+ cid: event.locals.user?.cid,
+ sessionToken: event.cookies.get('session')
+ });
+
+ if (!user) {
+ throw error(401, 'Unauthorized');
+ }
+
+ const documents = await DmsDocument.fetchAll(db);
+
+ return documents;
+});
+
+export const getDocument = query(type('string'), async (id: string) => {
+ const event = getRequestEvent();
+ const user = await User.resolveAuthenticatedUser(db, {
+ cid: event.locals.user?.cid,
+ sessionToken: event.cookies.get('session')
+ });
+
+ if (!user) {
+ throw error(401, 'Unauthorized');
+ }
+
+ const document = await DmsDocument.fromId(db, id);
+
+ if (!document) {
+ throw error(404, 'Document not found');
+ }
+
+ return document;
+});
+
+export const editDocument = form(
+ type({
+ id: 'string',
+ name: 'string',
+ short: 'string',
+ sort: type('string').pipe((value) => {
+ if (value === '') {
+ return 99;
+ }
+
+ return Number(value);
+ }),
+ required: type('string').pipe((value) => value === 'true'),
+ description: 'string?'
+ }),
+ async ({ sort, short, id, name, required, description }, issue) => {
+ const event = getRequestEvent();
+ const user = await User.resolveAuthenticatedUser(db, {
+ cid: event.locals.user?.cid,
+ sessionToken: event.cookies.get('session')
+ });
+
+ if (!user || !user.hasFlag(['admin', 'staff'])) {
+ throw error(401, 'Unauthorized');
+ }
+
+ if (!(0 <= sort && sort <= 99)) {
+ invalid(issue.sort('Sort must be a number between 0 and 99'));
+ }
+
+ if (!/^[a-z0-9-]+$/i.test(short)) {
+ invalid(issue.short('Short URL may only contain letters, numbers, and dashes'));
+ }
+
+ const normalizedName = name.trim();
+ const normalizedShort = short.trim().toLowerCase();
+ const normalizedDescription = description?.trim() ? description.trim() : null;
+
+ await DmsDocument.update(db, id, {
+ name: normalizedName,
+ required,
+ short: normalizedShort,
+ description: normalizedDescription,
+ sort
+ });
+
+ getDocuments().refresh();
+ getDocument(id).refresh();
+
+ return {
+ ok: true,
+ message: 'Document details updated successfully.'
+ };
+ }
+);
+
+const getSingleValue = (value: unknown) => {
+ if (Array.isArray(value)) {
+ return value[0];
+ }
+
+ return value;
+};
+
+const sanitizeFileName = (name: string) => name.replace(/[^a-zA-Z0-9._-]/g, '_');
+
+const parseDateInput = (value: unknown) => {
+ if (typeof value !== 'string' || value.trim().length === 0) {
+ return null;
+ }
+
+ // datetime-local inputs do not include timezone information.
+ // Treat them as UTC so UI and stored values stay consistent.
+ const date = new Date(`${value}Z`);
+ return Number.isNaN(date.getTime()) ? null : date;
+};
+
+const getR2Client = () =>
+ new S3Client({
+ region: 'auto',
+ endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
+ credentials: {
+ accessKeyId: env.R2_ACCESS_KEY_ID,
+ secretAccessKey: env.R2_ACCESS_KEY
+ }
+ });
+
+const uploadDmsAsset = async (documentId: string, file: File) => {
+ const fileName = `dms/${documentId}/${Date.now()}-${sanitizeFileName(file.name)}`;
+ const s3 = getR2Client();
+
+ try {
+ await s3.send(
+ new PutObjectCommand({
+ Bucket: env.R2_BUCKET_NAME,
+ Key: fileName,
+ Body: new Uint8Array(await file.arrayBuffer()),
+ ContentType: file.type
+ })
+ );
+ } catch {
+ throw error(500, 'Failed to upload document asset');
+ }
+
+ return fileName;
+};
+
+export const createDocumentAsset = form('unchecked', async (rawData) => {
+ const event = getRequestEvent();
+ const user = await User.resolveAuthenticatedUser(db, {
+ cid: event.locals.user?.cid,
+ sessionToken: event.cookies.get('session')
+ });
+
+ if (!user || !user.hasFlag(['admin', 'staff'])) {
+ throw error(401, 'Unauthorized');
+ }
+
+ const documentId = getSingleValue(rawData.documentId);
+ const versionInput = getSingleValue(rawData.version);
+ const effectiveDateInput = getSingleValue(rawData.effectiveDate);
+ const expiryDateInput = getSingleValue(rawData.expiryDate);
+ const fileInput = getSingleValue(rawData.file);
+ const isPublic = getSingleValue(rawData.public) === 'on';
+
+ if (typeof documentId !== 'string' || documentId.length === 0) {
+ throw error(400, 'Document id is required');
+ }
+
+ if (typeof versionInput !== 'string' || versionInput.trim().length === 0) {
+ throw error(400, 'Version is required');
+ }
+
+ const file = fileInput instanceof File ? fileInput : undefined;
+ if (!file || file.name.length === 0 || file.size === 0) {
+ throw error(400, 'Document asset file is required');
+ }
+
+ const effectiveDate = parseDateInput(effectiveDateInput);
+ if (!effectiveDate) {
+ throw error(400, 'Effective date is required');
+ }
+
+ const expiryDate = parseDateInput(expiryDateInput);
+ if (expiryDate && expiryDate.getTime() <= effectiveDate.getTime()) {
+ throw error(400, 'Expiry date must be after the effective date');
+ }
+
+ let document: DmsDocument;
+ try {
+ document = await DmsDocument.fromId(db, documentId);
+ } catch {
+ throw error(404, 'Document not found');
+ }
+
+ const url = await uploadDmsAsset(document.id, file);
+
+ await DmsAsset.create(db, {
+ documentId: document.id,
+ version: versionInput.trim(),
+ effectiveDate,
+ expiryDate,
+ public: isPublic,
+ url
+ });
+
+ getDocument(document.id).refresh();
+ getDocuments().refresh();
+
+ return {
+ ok: true,
+ message: 'Document asset uploaded successfully.'
+ };
+});
+
+export const updateDocumentAsset = form('unchecked', async (rawData) => {
+ const event = getRequestEvent();
+ const user = await User.resolveAuthenticatedUser(db, {
+ cid: event.locals.user?.cid,
+ sessionToken: event.cookies.get('session')
+ });
+
+ if (!user || !user.hasFlag(['admin', 'staff'])) {
+ throw error(401, 'Unauthorized');
+ }
+
+ const assetId = getSingleValue(rawData.assetId);
+ const effectiveDateInput = getSingleValue(rawData.effectiveDate);
+ const expiryDateInput = getSingleValue(rawData.expiryDate);
+ const isPublic = getSingleValue(rawData.public) === 'on';
+
+ if (typeof assetId !== 'string' || assetId.length === 0) {
+ return { ok: false, message: 'Asset id is required.' };
+ }
+
+ const effectiveDate = parseDateInput(effectiveDateInput);
+ if (!effectiveDate) {
+ return { ok: false, message: 'Effective date is required.' };
+ }
+
+ const expiryDate = parseDateInput(expiryDateInput);
+ if (expiryDate && expiryDate.getTime() <= effectiveDate.getTime()) {
+ return { ok: false, message: 'Expiry date must be after the effective date.' };
+ }
+
+ let asset: DmsAsset;
+ try {
+ asset = await DmsAsset.fromId(db, assetId);
+ } catch {
+ return { ok: false, message: 'Asset not found.' };
+ }
+
+ await DmsAsset.update(db, asset.id, {
+ documentId: asset.documentId,
+ version: asset.version,
+ effectiveDate,
+ expiryDate,
+ public: isPublic,
+ url: asset.url
+ });
+
+ getDocument(asset.documentId).refresh();
+ getDocuments().refresh();
+
+ return {
+ ok: true,
+ message: 'Document asset updated successfully.'
+ };
+});
+
+export const deleteDocumentAsset = form('unchecked', async (rawData) => {
+ const event = getRequestEvent();
+ const user = await User.resolveAuthenticatedUser(db, {
+ cid: event.locals.user?.cid,
+ sessionToken: event.cookies.get('session')
+ });
+
+ if (!user || !user.hasFlag(['admin', 'staff'])) {
+ throw error(401, 'Unauthorized');
+ }
+
+ const assetId = getSingleValue(rawData.assetId);
+ if (typeof assetId !== 'string' || assetId.length === 0) {
+ return { ok: false, message: 'Asset id is required.' };
+ }
+
+ let asset: DmsAsset;
+ try {
+ asset = await DmsAsset.fromId(db, assetId);
+ } catch {
+ return { ok: false, message: 'Asset not found.' };
+ }
+
+ const s3 = getR2Client();
+ try {
+ await s3.send(
+ new DeleteObjectCommand({
+ Bucket: env.R2_BUCKET_NAME,
+ Key: asset.url
+ })
+ );
+ } catch {
+ return { ok: false, message: 'Failed to delete asset file from R2.' };
+ }
+
+ await DmsAsset.remove(db, asset.id);
+
+ getDocument(asset.documentId).refresh();
+ getDocuments().refresh();
+
+ return {
+ ok: true,
+ message: 'Document asset deleted successfully.'
+ };
+});
+
+export const getDocumentsByGroup = query(type('string'), async (groupId) => {
+ const event = getRequestEvent();
+ const user = await User.resolveAuthenticatedUser(db, {
+ cid: event.locals.user?.cid,
+ sessionToken: event.cookies.get('session')
+ });
+
+ if (!user) {
+ throw error(401, 'Unauthorized');
+ }
+
+ const group = await DmsGroup.fromId(db, groupId);
+
+ if (!group) {
+ throw error(404, 'Group not found');
+ }
+
+ return await db.query.dmsDocuments.findMany({
+ where: { groupId },
+ with: { group: true },
+ orderBy: (document) => [asc(document.sort), asc(document.name)]
+ });
+});
+
+export const getGroups = query(async () => {
+ const event = getRequestEvent();
+ const user = await User.resolveAuthenticatedUser(db, {
+ cid: event.locals.user?.cid,
+ sessionToken: event.cookies.get('session')
+ });
+
+ if (!user) {
+ throw error(401, 'Unauthorized');
+ }
+
+ const groups = await DmsGroup.fetchAll(db);
+
+ return groups;
+});
+
+export const getGroup = query(type('string'), async (id) => {
+ const event = getRequestEvent();
+ const user = await User.resolveAuthenticatedUser(db, {
+ cid: event.locals.user?.cid,
+ sessionToken: event.cookies.get('session')
+ });
+
+ if (!user) {
+ throw error(401, 'Unauthorized');
+ }
+
+ const group = await DmsGroup.fromId(db, id);
+
+ if (!group) {
+ throw error(404, 'Group not found');
+ }
+
+ return group;
+});
+
+export const createGroup = form(
+ type({
+ name: 'string',
+ slug: type.string.pipe((str) => str.toLowerCase().trim()),
+ sort: 'number?'
+ }),
+ async ({ name, slug, sort = 99 }, issue) => {
+ const event = getRequestEvent();
+ const user = await User.resolveAuthenticatedUser(db, {
+ cid: event.locals.user?.cid,
+ sessionToken: event.cookies.get('session')
+ });
+
+ if (!user || !user.hasFlag(['admin', 'staff'])) {
+ throw error(401, 'Unauthorized');
+ }
+
+ if (!/^[a-z0-9-]+$/.test(slug)) {
+ invalid(issue.slug('Slug can only contain lowercase letters, numbers, and dashes'));
+ }
+
+ if (!(0 <= sort && sort <= 99)) {
+ invalid(issue.sort('Sort must be a number between 0 and 99'));
+ }
+
+ const existingGroup = await DmsGroup.fromSlug(db, slug);
+
+ if (existingGroup) {
+ invalid(issue.slug(`Slug is already in use by another group`));
+ }
+
+ const newGroup = await DmsGroup.create(db, {
+ name,
+ slug,
+ sort
+ });
+
+ return redirect(303, `/a/dms/groups/${newGroup.id}`);
+ }
+);
+
+export const createDocument = form(
+ type({
+ groupId: 'string',
+ name: type.string.pipe((str) => str.trim()),
+ required: type.string.pipe((value) => value === 'true'),
+ short: 'string',
+ description: 'string?',
+ sort: type('string').pipe((value) => {
+ if (value === '') {
+ return 99;
+ }
+
+ return Number(value);
+ })
+ }),
+ async ({ groupId, name, required, short, description, sort }, issue) => {
+ const event = getRequestEvent();
+ const user = await User.resolveAuthenticatedUser(db, {
+ cid: event.locals.user?.cid,
+ sessionToken: event.cookies.get('session')
+ });
+
+ if (!user || !user.hasFlag(['admin', 'staff'])) {
+ throw error(401, 'Unauthorized');
+ }
+
+ if (name.length === 0) {
+ invalid(issue.name('Name is required'));
+ }
+
+ if (!Number.isFinite(sort)) {
+ invalid(issue.sort('Sort must be a number'));
+ }
+
+ if (!(0 <= sort && sort <= 99)) {
+ invalid(issue.sort('Sort must be a number between 0 and 99'));
+ }
+
+ const group = await DmsGroup.fromId(db, groupId);
+
+ if (!group) {
+ invalid(issue.groupId('Group not found'));
+ }
+
+ const normalizedShort = short?.trim().toLowerCase();
+
+ if (normalizedShort) {
+ const documents = await DmsDocument.fetchAll(db);
+ const hasDuplicateShort = documents.some(
+ (document) => document.short?.trim().toLowerCase() === normalizedShort
+ );
+
+ if (hasDuplicateShort) {
+ invalid(issue.short('Short URL is already in use'));
+ }
+ }
+
+ await DmsDocument.create(db, {
+ groupId,
+ name,
+ required,
+ short: normalizedShort,
+ description: description?.trim() ? description.trim() : null,
+ sort
+ });
+
+ getGroup(groupId).refresh();
+ getDocumentsByGroup(groupId).refresh();
+ getDocuments().refresh();
+
+ return redirect(303, `/a/dms/groups/${groupId}`);
+ }
+);
+
+export const deleteGroup = command(type('string'), async (id) => {
+ const event = getRequestEvent();
+ const user = await User.resolveAuthenticatedUser(db, {
+ cid: event.locals.user?.cid,
+ sessionToken: event.cookies.get('session')
+ });
+
+ if (!user || !user.hasFlag(['admin', 'staff'])) {
+ throw error(401, 'Unauthorized');
+ }
+
+ const group = await DmsGroup.fromId(db, id);
+
+ if (!group) {
+ throw error(404, 'Group not found');
+ }
+
+ await group.delete();
+
+ getGroups().refresh();
+ getDocuments().refresh();
+
+ return;
+});
+
+export const editGroup = form(
+ type({
+ id: 'string',
+ name: 'string',
+ slug: type.string.pipe((str) => str.toLowerCase().trim()),
+ sort: 'number'
+ }),
+ async ({ id, name, slug, sort }, issue) => {
+ const event = getRequestEvent();
+ const user = await User.resolveAuthenticatedUser(db, {
+ cid: event.locals.user?.cid,
+ sessionToken: event.cookies.get('session')
+ });
+
+ if (!user || !user.hasFlag(['admin', 'staff'])) {
+ throw error(401, 'Unauthorized');
+ }
+
+ if (!/^[a-z0-9-]+$/.test(slug)) {
+ invalid(issue.slug('Slug can only contain lowercase letters, numbers, and dashes'));
+ }
+
+ if (!(0 <= sort && sort <= 99)) {
+ invalid(issue.sort('Sort must be a number between 0 and 99'));
+ }
+
+ await DmsGroup.update(db, id, {
+ name,
+ sort,
+ slug
+ });
+
+ return {
+ ok: true,
+ id,
+ name,
+ slug,
+ sort
+ };
+ }
+);
diff --git a/apps/overseer/src/routes/+layout.svelte b/apps/overseer/src/routes/+layout.svelte
index 4c71d1e3..b2773ac8 100644
--- a/apps/overseer/src/routes/+layout.svelte
+++ b/apps/overseer/src/routes/+layout.svelte
@@ -40,6 +40,9 @@
News
Resources
File Upload
+ {#if data.user?.flags.some( (f) => ['admin', 'web', 'cheif', 'deputy', 'chief-instructor'].includes(f.name) )}
+ Documents
+ {/if}
{/if}
@@ -58,6 +61,9 @@
News
Resources
File Upload
+ {#if data.user?.flags.some( (f) => ['admin', 'web', 'cheif', 'deputy', 'chief-instructor'].includes(f.name) )}
+ Documents
+ {/if}
{/if}
diff --git a/apps/overseer/src/routes/a/dms/+page.svelte b/apps/overseer/src/routes/a/dms/+page.svelte
new file mode 100644
index 00000000..6d231a43
--- /dev/null
+++ b/apps/overseer/src/routes/a/dms/+page.svelte
@@ -0,0 +1,199 @@
+
+
+
+
+
CZQM Document Management System
+
+
+
+ Welcome to the CZQM Document management system! Please select a group of documents to
+ continue or create a new group.
+
+
Create New Group
+
+
+
+ {#await getGroups()}
+
Loading groups...
+ {:then groups}
+
+
+
+
+ | # |
+ Group Name |
+ Slug |
+ # of Documents |
+ Actions |
+
+
+
+ {#each groups as { name, documents, slug, sort, id }, index (id)}
+
+ | {index + 1} |
+ {name} |
+ /{slug} |
+ {documents.length} |
+
+
+
+ Manage Documents
+ |
+
+ {/each}
+
+
+ {/await}
+
+
+
+
+
+
+
diff --git a/apps/overseer/src/routes/a/dms/document/+page.ts b/apps/overseer/src/routes/a/dms/document/+page.ts
new file mode 100644
index 00000000..d5d783e6
--- /dev/null
+++ b/apps/overseer/src/routes/a/dms/document/+page.ts
@@ -0,0 +1,6 @@
+import { redirect } from '@sveltejs/kit';
+import type { PageLoad } from './$types';
+
+export const load = (async () => {
+ redirect(303, '/a/dms');
+}) satisfies PageLoad;
diff --git a/apps/overseer/src/routes/a/dms/document/[id]/+page.svelte b/apps/overseer/src/routes/a/dms/document/[id]/+page.svelte
new file mode 100644
index 00000000..ac559ba3
--- /dev/null
+++ b/apps/overseer/src/routes/a/dms/document/[id]/+page.svelte
@@ -0,0 +1,654 @@
+
+
+
+
+ {#if isLoading}
+
Loading document...
+ {:else if loadError}
+
Error loading document: {loadError}
+ {:else if currentDocument}
+
DMS Document: {currentDocument.name}
+ {#if currentDocument.groupId}
+
+ Back to {currentDocument.group?.name ?? 'Group'}
+
+ {:else}
+
+ Back to Group
+
+ {/if}
+
+
+
Document Details
+
+ {#if editDocumentMessage}
+
+ {editDocumentMessage.text}
+
+ {/if}
+
+
+
+
Document Assets
+ {#if currentAsset}
+
+
+
Current Asset: {currentAsset.version}
+
+ Effective {formatDate(currentAsset.effectiveDate)}{#if currentAsset.expiryDate}
+ , Expires {formatDate(currentAsset.expiryDate)}
+ {/if}
+
+
+ Open current asset
+
+
+
+ {:else}
+
+
No current public asset is active for this document.
+
+ {/if}
+
+
Upload New Asset
+
+ {#if createAssetMessage}
+
+ {createAssetMessage.text}
+
+ {/if}
+
+
+
+
+
+ | Version |
+ Status |
+ Public |
+ Effective |
+ Expiry |
+ Asset |
+ Actions |
+
+
+
+ {#if currentDocument.assets.length === 0}
+
+ | No assets uploaded yet. |
+
+ {:else}
+ {#each currentDocument.assets as asset (asset.id)}
+
+ | {asset.version} |
+ {getAssetStatus(asset)} |
+ {asset.public ? 'Yes' : 'No'} |
+ {formatDate(asset.effectiveDate)} |
+ {formatDate(asset.expiryDate)} |
+
+
+ View
+
+ |
+
+
+
+
+
+ |
+
+ {/each}
+ {/if}
+
+
+
+ {#each currentDocument.assets as asset (asset.id)}
+
+ {/each}
+
+ {#if updateAssetMessage}
+
+ {updateAssetMessage.text}
+
+ {/if}
+ {#if deleteAssetMessage}
+
+ {deleteAssetMessage.text}
+
+ {/if}
+ {/if}
+
+
+
+
diff --git a/apps/overseer/src/routes/a/dms/groups/+page.ts b/apps/overseer/src/routes/a/dms/groups/+page.ts
new file mode 100644
index 00000000..d5d783e6
--- /dev/null
+++ b/apps/overseer/src/routes/a/dms/groups/+page.ts
@@ -0,0 +1,6 @@
+import { redirect } from '@sveltejs/kit';
+import type { PageLoad } from './$types';
+
+export const load = (async () => {
+ redirect(303, '/a/dms');
+}) satisfies PageLoad;
diff --git a/apps/overseer/src/routes/a/dms/groups/[id]/+page.svelte b/apps/overseer/src/routes/a/dms/groups/[id]/+page.svelte
new file mode 100644
index 00000000..a096df1b
--- /dev/null
+++ b/apps/overseer/src/routes/a/dms/groups/[id]/+page.svelte
@@ -0,0 +1,87 @@
+
+
+
+
+ {#await getGroup(id)}
+
DMS Group: Loading Group
+ {:then group}
+
DMS Group: {group.name} ( /{group.slug} )
+ {:catch error}
+
Error loading group: {error.message}
+ {/await}
+
+
+ Back to DMS
+
+
+
+
+
+ {#await getDocumentsByGroup(id)}
+
Loading documents...
+ {:then documents}
+ {@const sortedDocuments = [...documents].sort(
+ (a, b) => (a.sort ?? 99) - (b.sort ?? 99) || a.name.localeCompare(b.name)
+ )}
+
+
+
+ | # |
+ Document Name |
+ Short |
+ Required |
+ Sort |
+ |
+
+
+
+ {#if sortedDocuments.length === 0}
+
+ |
+ No documents found for this group.
+ |
+
+ {:else}
+ {#each sortedDocuments as { id: documentId, name, short, required, sort, group }, index (documentId)}
+
+ | {index + 1} |
+ {name} |
+ {#if short}
+ {group?.slug} / {short} |
+ {:else}
+ - |
+ {/if}
+ {required ? 'Yes' : 'No'} |
+ {sort ?? 99} |
+
+ Manage
+ |
+
+ {/each}
+ {/if}
+
+
+ {:catch error}
+
Error loading documents: {error.message}
+ {/await}
+
+
+
diff --git a/apps/overseer/src/routes/a/dms/groups/[id]/new-document/+page.svelte b/apps/overseer/src/routes/a/dms/groups/[id]/new-document/+page.svelte
new file mode 100644
index 00000000..ef8b8882
--- /dev/null
+++ b/apps/overseer/src/routes/a/dms/groups/[id]/new-document/+page.svelte
@@ -0,0 +1,144 @@
+
+
+
+
+ {#await getGroup(id)}
+
Create Document: Loading Group
+ {:then group}
+
Create Document: {group.name}
+ {:catch error}
+
Error loading group: {error.message}
+ {/await}
+
+
+ Back to Group
+
+
+
+
+
+
diff --git a/apps/overseer/src/routes/a/dms/new-group/+page.svelte b/apps/overseer/src/routes/a/dms/new-group/+page.svelte
new file mode 100644
index 00000000..c0c3f2a4
--- /dev/null
+++ b/apps/overseer/src/routes/a/dms/new-group/+page.svelte
@@ -0,0 +1,46 @@
+
+
+
+
+
Create New Group
+
+ Back to DMS
+
+
+
+
+
+
diff --git a/apps/overseer/src/routes/a/files/+page.svelte b/apps/overseer/src/routes/a/files/+page.svelte
index b729eae1..1e3d6319 100644
--- a/apps/overseer/src/routes/a/files/+page.svelte
+++ b/apps/overseer/src/routes/a/files/+page.svelte
@@ -12,7 +12,11 @@
File Upload
-
+
+ This file upload method should only be used when the Document Management System cannot be used.
+