From 65cee9855cf87ba2358915a3251b592711be8a2f Mon Sep 17 00:00:00 2001 From: grabowski Date: Fri, 17 Apr 2026 14:51:29 +0700 Subject: [PATCH] Add procedures templates, step management, and nav tab Co-Authored-By: Claude Opus 4.6 (1M context) --- .../companies/[companyId]/+layout.svelte | 3 +- .../[companyId]/procedures/+page.server.ts | 198 +++++++++++++++ .../[companyId]/procedures/+page.svelte | 131 ++++++++++ .../procedures/[templateId]/+page.server.ts | 230 ++++++++++++++++++ .../procedures/[templateId]/+page.svelte | 190 +++++++++++++++ 5 files changed, 751 insertions(+), 1 deletion(-) create mode 100644 src/routes/(app)/companies/[companyId]/procedures/+page.server.ts create mode 100644 src/routes/(app)/companies/[companyId]/procedures/+page.svelte create mode 100644 src/routes/(app)/companies/[companyId]/procedures/[templateId]/+page.server.ts create mode 100644 src/routes/(app)/companies/[companyId]/procedures/[templateId]/+page.svelte diff --git a/src/routes/(app)/companies/[companyId]/+layout.svelte b/src/routes/(app)/companies/[companyId]/+layout.svelte index f4c6a2f..4b5a006 100644 --- a/src/routes/(app)/companies/[companyId]/+layout.svelte +++ b/src/routes/(app)/companies/[companyId]/+layout.svelte @@ -56,7 +56,8 @@ { href: `${baseUrl}/packages`, label: 'Packages', show: has(['admin', 'manager', 'user', 'hr']) }, { href: `${baseUrl}/links`, label: 'Links', show: true }, { href: `${baseUrl}/documents`, label: 'Documents', show: has(['admin', 'manager', 'accountant']) }, - { href: `${baseUrl}/service-accounts`, label: 'Service Accounts', show: has(['admin', 'manager', 'accountant']) } + { href: `${baseUrl}/service-accounts`, label: 'Service Accounts', show: has(['admin', 'manager', 'accountant']) }, + { href: `${baseUrl}/procedures`, label: 'Procedures', show: true } ].filter((t) => t.show) ); diff --git a/src/routes/(app)/companies/[companyId]/procedures/+page.server.ts b/src/routes/(app)/companies/[companyId]/procedures/+page.server.ts new file mode 100644 index 0000000..110e1c4 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/procedures/+page.server.ts @@ -0,0 +1,198 @@ +import { error, fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { + procedureTemplates, + procedureSteps, + procedureInstances +} from '$lib/server/db/schema.js'; +import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js'; +import { logCompanyEvent } from '$lib/server/audit.js'; +import { and, asc, eq, isNull, sql, ne } from 'drizzle-orm'; + +function trimOrNull(v: FormDataEntryValue | null): string | null { + const s = v?.toString().trim(); + return s ? s : null; +} + +export const load: PageServerLoad = async ({ locals, params, parent }) => { + const { roles } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', 'manager', 'user', 'viewer', 'hr', 'accountant' + ]); + await parent(); + + const canManage = roles.some((r) => r === 'admin' || r === 'manager'); + + const whereClause = canManage + ? and(eq(procedureTemplates.companyId, params.companyId), isNull(procedureTemplates.deletedAt)) + : and( + eq(procedureTemplates.companyId, params.companyId), + isNull(procedureTemplates.deletedAt), + eq(procedureTemplates.isPublished, true) + ); + + const templates = await db + .select({ + id: procedureTemplates.id, + title: procedureTemplates.title, + description: procedureTemplates.description, + category: procedureTemplates.category, + isPublished: procedureTemplates.isPublished, + createdAt: procedureTemplates.createdAt, + stepCount: sql`(select count(*)::int from procedure_steps where template_id = ${procedureTemplates.id})`, + instanceCount: sql`(select count(*)::int from procedure_instances where template_id = ${procedureTemplates.id} and status != 'cancelled')` + }) + .from(procedureTemplates) + .where(whereClause) + .orderBy(asc(procedureTemplates.title)); + + return { templates, canManage }; +}; + +export const actions: Actions = { + createTemplate: async ({ request, locals, params }) => { + const { user } = await requireCompanyRole(locals, params.companyId, 'manager'); + const fd = await request.formData(); + const title = trimOrNull(fd.get('title')); + const description = trimOrNull(fd.get('description')); + const category = trimOrNull(fd.get('category')); + + if (!title) return fail(400, { action: 'createTemplate', error: 'Title is required' }); + + const [inserted] = await db + .insert(procedureTemplates) + .values({ + companyId: params.companyId, + title, + description, + category, + createdBy: user.id + }) + .returning({ id: procedureTemplates.id }); + + await logCompanyEvent( + params.companyId, + user.id, + 'procedure_template_created', + `Procedure "${title}" created`, + { templateId: inserted.id } + ); + + return { success: true, action: 'createTemplate' }; + }, + + updateTemplate: async ({ request, locals, params }) => { + const { user } = await requireCompanyRole(locals, params.companyId, 'manager'); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + const title = trimOrNull(fd.get('title')); + const description = trimOrNull(fd.get('description')); + const category = trimOrNull(fd.get('category')); + + if (!id) return fail(400, { action: 'updateTemplate', error: 'Template id required' }); + if (!title) return fail(400, { action: 'updateTemplate', error: 'Title is required' }); + + const result = await db + .update(procedureTemplates) + .set({ title, description, category, updatedAt: new Date() }) + .where( + and( + eq(procedureTemplates.id, id), + eq(procedureTemplates.companyId, params.companyId), + isNull(procedureTemplates.deletedAt) + ) + ) + .returning({ id: procedureTemplates.id }); + + if (result.length === 0) error(404, 'Template not found'); + + await logCompanyEvent(params.companyId, user.id, 'procedure_template_updated', + `Procedure "${title}" updated`, { templateId: id }); + + return { success: true, action: 'updateTemplate' }; + }, + + deleteTemplate: async ({ request, locals, params }) => { + const { user } = await requireCompanyRole(locals, params.companyId, 'manager'); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'deleteTemplate', error: 'Template id required' }); + + const [existing] = await db + .select({ id: procedureTemplates.id, title: procedureTemplates.title }) + .from(procedureTemplates) + .where( + and( + eq(procedureTemplates.id, id), + eq(procedureTemplates.companyId, params.companyId), + isNull(procedureTemplates.deletedAt) + ) + ) + .limit(1); + if (!existing) error(404, 'Template not found'); + + // Check for active instances (RESTRICT FK would block, but give a nice error) + const [activeCount] = await db + .select({ count: sql`count(*)::int` }) + .from(procedureInstances) + .where( + and(eq(procedureInstances.templateId, id), ne(procedureInstances.status, 'cancelled')) + ); + if (activeCount && activeCount.count > 0) { + return fail(400, { + action: 'deleteTemplate', + error: `Cannot delete — ${activeCount.count} active instance(s) exist` + }); + } + + await db + .update(procedureTemplates) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(procedureTemplates.id, id)); + + await logCompanyEvent(params.companyId, user.id, 'procedure_template_deleted', + `Procedure "${existing.title}" deleted`, { templateId: id }); + + return { success: true, action: 'deleteTemplate' }; + }, + + publishTemplate: async ({ request, locals, params }) => { + await requireCompanyRole(locals, params.companyId, 'manager'); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'publishTemplate', error: 'Template id required' }); + + await db + .update(procedureTemplates) + .set({ isPublished: true, updatedAt: new Date() }) + .where( + and( + eq(procedureTemplates.id, id), + eq(procedureTemplates.companyId, params.companyId), + isNull(procedureTemplates.deletedAt) + ) + ); + + return { success: true, action: 'publishTemplate' }; + }, + + unpublishTemplate: async ({ request, locals, params }) => { + await requireCompanyRole(locals, params.companyId, 'manager'); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'unpublishTemplate', error: 'Template id required' }); + + await db + .update(procedureTemplates) + .set({ isPublished: false, updatedAt: new Date() }) + .where( + and( + eq(procedureTemplates.id, id), + eq(procedureTemplates.companyId, params.companyId), + isNull(procedureTemplates.deletedAt) + ) + ); + + return { success: true, action: 'unpublishTemplate' }; + } +}; diff --git a/src/routes/(app)/companies/[companyId]/procedures/+page.svelte b/src/routes/(app)/companies/[companyId]/procedures/+page.svelte new file mode 100644 index 0000000..85302ae --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/procedures/+page.svelte @@ -0,0 +1,131 @@ + + + + Procedures - {data.company.name} + + +
+
+

Procedures

+

+ Reusable checklists for standard processes. Start an instance to track progress on a specific case. +

+
+ + {#if form?.error} +
{form.error}
+ {/if} + + {#if data.canManage} +
+
+

New Procedure

+ +
+ {#if showAddForm} +
async ({ result, update, formElement }) => { + await update({ reset: false }); + if (result.type === 'success') { showAddForm = false; formElement.reset(); } + }} + class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2"> +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ {/if} +
+ {/if} + + {#if data.templates.length === 0} +
+

No procedures yet.

+
+ {:else} +
+ {#each data.templates as tmpl (tmpl.id)} +
+ + Open {tmpl.title} + +
+

{tmpl.title}

+ + {tmpl.isPublished ? 'Published' : 'Draft'} + +
+ {#if tmpl.category} + {tmpl.category} + {/if} + {#if tmpl.description} +

{tmpl.description}

+ {/if} +
+ {tmpl.stepCount} {tmpl.stepCount === 1 ? 'step' : 'steps'} + {tmpl.instanceCount} {tmpl.instanceCount === 1 ? 'instance' : 'instances'} +
+ {#if data.canManage} +
+ {#if tmpl.isPublished} +
async ({ update }) => await update({ reset: false })}> + + +
+ {:else} +
async ({ update }) => await update({ reset: false })}> + + +
+ {/if} + +
+ {#if deletingId === tmpl.id} +
async ({ update }) => { await update({ reset: false }); deletingId = null; }} + class="relative z-10 mt-1 rounded-md bg-red-50 p-2 text-xs dark:bg-red-900/30"> + +

Delete "{tmpl.title}"?

+
+ + +
+
+ {/if} + {/if} +
+ {/each} +
+ {/if} +
diff --git a/src/routes/(app)/companies/[companyId]/procedures/[templateId]/+page.server.ts b/src/routes/(app)/companies/[companyId]/procedures/[templateId]/+page.server.ts new file mode 100644 index 0000000..bec6cfe --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/procedures/[templateId]/+page.server.ts @@ -0,0 +1,230 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { + procedureTemplates, + procedureSteps, + procedureInstances, + procedureInstanceSteps, + users +} from '$lib/server/db/schema.js'; +import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js'; +import { logCompanyEvent } from '$lib/server/audit.js'; +import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm'; + +function trimOrNull(v: FormDataEntryValue | null): string | null { + const s = v?.toString().trim(); + return s ? s : null; +} + +export const load: PageServerLoad = async ({ locals, params, parent }) => { + const { roles } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', 'manager', 'user', 'viewer', 'hr', 'accountant' + ]); + await parent(); + + const canManage = roles.some((r) => r === 'admin' || r === 'manager'); + + const [template] = await db + .select() + .from(procedureTemplates) + .where( + and( + eq(procedureTemplates.id, params.templateId), + eq(procedureTemplates.companyId, params.companyId), + isNull(procedureTemplates.deletedAt) + ) + ) + .limit(1); + + if (!template) error(404, 'Procedure not found'); + if (!canManage && !template.isPublished) error(404, 'Procedure not found'); + + const steps = await db + .select() + .from(procedureSteps) + .where(eq(procedureSteps.templateId, params.templateId)) + .orderBy(asc(procedureSteps.stepNumber)); + + const instances = await db + .select({ + id: procedureInstances.id, + title: procedureInstances.title, + status: procedureInstances.status, + startedByName: users.displayName, + createdAt: procedureInstances.createdAt, + completedAt: procedureInstances.completedAt + }) + .from(procedureInstances) + .leftJoin(users, eq(procedureInstances.startedBy, users.id)) + .where( + and( + eq(procedureInstances.templateId, params.templateId), + eq(procedureInstances.companyId, params.companyId) + ) + ) + .orderBy(desc(procedureInstances.createdAt)); + + return { template, steps, instances, canManage }; +}; + +export const actions: Actions = { + addStep: async ({ request, locals, params }) => { + await requireCompanyRole(locals, params.companyId, 'manager'); + const fd = await request.formData(); + const title = trimOrNull(fd.get('title')); + const description = trimOrNull(fd.get('description')); + const assigneeRole = trimOrNull(fd.get('assigneeRole')); + const estimatedMinutes = fd.get('estimatedMinutes') + ? parseInt(fd.get('estimatedMinutes')!.toString()) + : null; + + if (!title) return fail(400, { action: 'addStep', error: 'Step title is required' }); + + const [maxRow] = await db + .select({ max: sql`coalesce(max(${procedureSteps.stepNumber}), 0)::int` }) + .from(procedureSteps) + .where(eq(procedureSteps.templateId, params.templateId)); + + await db.insert(procedureSteps).values({ + templateId: params.templateId, + stepNumber: (maxRow?.max ?? 0) + 1, + title, + description, + assigneeRole, + estimatedMinutes: estimatedMinutes && !isNaN(estimatedMinutes) ? estimatedMinutes : null + }); + + return { success: true, action: 'addStep' }; + }, + + updateStep: async ({ request, locals, params }) => { + await requireCompanyRole(locals, params.companyId, 'manager'); + const fd = await request.formData(); + const stepId = trimOrNull(fd.get('stepId')); + const title = trimOrNull(fd.get('title')); + const description = trimOrNull(fd.get('description')); + const assigneeRole = trimOrNull(fd.get('assigneeRole')); + const estimatedMinutes = fd.get('estimatedMinutes') + ? parseInt(fd.get('estimatedMinutes')!.toString()) + : null; + + if (!stepId) return fail(400, { action: 'updateStep', error: 'Step id required' }); + if (!title) return fail(400, { action: 'updateStep', error: 'Step title is required' }); + + await db + .update(procedureSteps) + .set({ + title, + description, + assigneeRole, + estimatedMinutes: estimatedMinutes && !isNaN(estimatedMinutes) ? estimatedMinutes : null + }) + .where( + and(eq(procedureSteps.id, stepId), eq(procedureSteps.templateId, params.templateId)) + ); + + return { success: true, action: 'updateStep' }; + }, + + removeStep: async ({ request, locals, params }) => { + await requireCompanyRole(locals, params.companyId, 'manager'); + const fd = await request.formData(); + const stepId = trimOrNull(fd.get('stepId')); + if (!stepId) return fail(400, { action: 'removeStep', error: 'Step id required' }); + + try { + await db + .delete(procedureSteps) + .where( + and(eq(procedureSteps.id, stepId), eq(procedureSteps.templateId, params.templateId)) + ); + } catch { + return fail(400, { + action: 'removeStep', + error: 'Cannot remove — step is referenced by active instances' + }); + } + + return { success: true, action: 'removeStep' }; + }, + + reorderSteps: async ({ request, locals, params }) => { + await requireCompanyRole(locals, params.companyId, 'manager'); + const fd = await request.formData(); + const raw = fd.get('steps')?.toString(); + if (!raw) return fail(400, { action: 'reorderSteps', error: 'Steps data required' }); + + let parsed: Array<{ id: string; stepNumber: number }>; + try { + parsed = JSON.parse(raw); + } catch { + return fail(400, { action: 'reorderSteps', error: 'Invalid JSON' }); + } + + await db.transaction(async (tx) => { + for (const { id, stepNumber } of parsed) { + await tx + .update(procedureSteps) + .set({ stepNumber }) + .where( + and(eq(procedureSteps.id, id), eq(procedureSteps.templateId, params.templateId)) + ); + } + }); + + return { success: true, action: 'reorderSteps' }; + }, + + startInstance: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', 'manager', 'user', 'hr', 'accountant' + ]); + const fd = await request.formData(); + const title = trimOrNull(fd.get('title')); + if (!title) return fail(400, { action: 'startInstance', error: 'Instance title is required' }); + + const steps = await db + .select() + .from(procedureSteps) + .where(eq(procedureSteps.templateId, params.templateId)) + .orderBy(asc(procedureSteps.stepNumber)); + + if (steps.length === 0) { + return fail(400, { action: 'startInstance', error: 'Template has no steps' }); + } + + const [instance] = await db + .insert(procedureInstances) + .values({ + templateId: params.templateId, + companyId: params.companyId, + title, + startedBy: user.id + }) + .returning({ id: procedureInstances.id }); + + await db.insert(procedureInstanceSteps).values( + steps.map((s) => ({ + instanceId: instance.id, + stepId: s.id, + stepNumber: s.stepNumber, + title: s.title, + description: s.description + })) + ); + + await logCompanyEvent( + params.companyId, + user.id, + 'procedure_instance_started', + `Started "${title}" (from procedure template)`, + { instanceId: instance.id, templateId: params.templateId } + ); + + redirect( + 303, + `/companies/${params.companyId}/procedures/${params.templateId}/instances/${instance.id}` + ); + } +}; diff --git a/src/routes/(app)/companies/[companyId]/procedures/[templateId]/+page.svelte b/src/routes/(app)/companies/[companyId]/procedures/[templateId]/+page.svelte new file mode 100644 index 0000000..795ea8b --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/procedures/[templateId]/+page.svelte @@ -0,0 +1,190 @@ + + + + {data.template.title} - Procedures + + +
+
+
+ +

{data.template.title}

+ {#if data.template.description} +

{data.template.description}

+ {/if} +
+ + {data.template.isPublished ? 'Published' : 'Draft'} + + {#if data.template.category} + {data.template.category} + {/if} +
+
+ +
+ + {#if form?.error} +
{form.error}
+ {/if} + + {#if showStartInstance} +
+ + +

Customize for this specific case, e.g. "Onboard ABC Corp"

+
+ + +
+
+ {/if} + + +
+

+ Steps ({data.steps.length}) +

+ + {#if data.steps.length === 0} +

No steps yet. Add the first step below.

+ {:else} +
    + {#each data.steps as step, i (step.id)} +
  1. + + {step.stepNumber} + +
    + {#if editingStepId === step.id && data.canManage} +
    async ({ result, update }) => { + await update({ reset: false }); + if (result.type === 'success') editingStepId = null; + }} + class="space-y-2"> + + + +
    + + +
    +
    + + +
    +
    + {:else} +
    {step.title}
    + {#if step.description} +

    {step.description}

    + {/if} +
    + {#if step.assigneeRole} + {step.assigneeRole} + {/if} + {#if step.estimatedMinutes} + ~{step.estimatedMinutes} min + {/if} +
    + {#if data.canManage} +
    + +
    async ({ update }) => await update({ reset: false })}> + + +
    +
    + {/if} + {/if} +
    +
  2. + {/each} +
+ {/if} + + {#if data.canManage} +
+ {#if showAddStep} +
async ({ result, update, formElement }) => { + await update({ reset: false }); + if (result.type === 'success') formElement.reset(); + }} + class="space-y-2"> + + +
+ + +
+
+ + +
+
+ {:else} + + {/if} +
+ {/if} +
+ + +
+

+ Instances ({data.instances.length}) +

+ {#if data.instances.length === 0} +

No instances started yet.

+ {:else} + + {/if} +
+