From 0906a448b38c3777eaac1ce3bbb5293989aa0b1b Mon Sep 17 00:00:00 2001 From: grabowski Date: Fri, 17 Apr 2026 14:58:10 +0700 Subject: [PATCH] Add procedure instance detail with step completion and auto-complete Co-Authored-By: Claude Opus 4.6 (1M context) --- .../instances/[instanceId]/+page.server.ts | 220 ++++++++++++++++++ .../instances/[instanceId]/+page.svelte | 160 +++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 src/routes/(app)/companies/[companyId]/procedures/[templateId]/instances/[instanceId]/+page.server.ts create mode 100644 src/routes/(app)/companies/[companyId]/procedures/[templateId]/instances/[instanceId]/+page.svelte diff --git a/src/routes/(app)/companies/[companyId]/procedures/[templateId]/instances/[instanceId]/+page.server.ts b/src/routes/(app)/companies/[companyId]/procedures/[templateId]/instances/[instanceId]/+page.server.ts new file mode 100644 index 0000000..a19373d --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/procedures/[templateId]/instances/[instanceId]/+page.server.ts @@ -0,0 +1,220 @@ +import { error, fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { + procedureInstances, + procedureInstanceSteps, + procedureTemplates, + 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, 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 [instance] = await db + .select({ + id: procedureInstances.id, + templateId: procedureInstances.templateId, + title: procedureInstances.title, + status: procedureInstances.status, + startedByName: users.displayName, + notes: procedureInstances.notes, + completedAt: procedureInstances.completedAt, + cancelledAt: procedureInstances.cancelledAt, + createdAt: procedureInstances.createdAt + }) + .from(procedureInstances) + .leftJoin(users, eq(procedureInstances.startedBy, users.id)) + .where( + and( + eq(procedureInstances.id, params.instanceId), + eq(procedureInstances.companyId, params.companyId) + ) + ) + .limit(1); + + if (!instance) error(404, 'Instance not found'); + + const steps = await db + .select({ + id: procedureInstanceSteps.id, + stepNumber: procedureInstanceSteps.stepNumber, + title: procedureInstanceSteps.title, + description: procedureInstanceSteps.description, + isCompleted: procedureInstanceSteps.isCompleted, + completedByName: users.displayName, + completedAt: procedureInstanceSteps.completedAt, + notes: procedureInstanceSteps.notes + }) + .from(procedureInstanceSteps) + .leftJoin(users, eq(procedureInstanceSteps.completedBy, users.id)) + .where(eq(procedureInstanceSteps.instanceId, params.instanceId)) + .orderBy(asc(procedureInstanceSteps.stepNumber)); + + return { instance, instanceSteps: steps, canManage }; +}; + +async function checkAutoComplete(instanceId: string, companyId: string, userId: string) { + const [counts] = await db + .select({ + total: sql`count(*)::int`, + done: sql`count(*) filter (where ${procedureInstanceSteps.isCompleted})::int` + }) + .from(procedureInstanceSteps) + .where(eq(procedureInstanceSteps.instanceId, instanceId)); + + if (counts && counts.total > 0 && counts.done === counts.total) { + await db + .update(procedureInstances) + .set({ status: 'completed', completedAt: new Date(), updatedAt: new Date() }) + .where(eq(procedureInstances.id, instanceId)); + + await logCompanyEvent(companyId, userId, 'procedure_instance_completed', + 'All steps completed — instance auto-completed', + { instanceId }); + } +} + +export const actions: Actions = { + completeStep: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', 'manager', 'user', 'hr', 'accountant' + ]); + const fd = await request.formData(); + const stepId = trimOrNull(fd.get('stepId')); + if (!stepId) return fail(400, { action: 'completeStep', error: 'Step id required' }); + + await db + .update(procedureInstanceSteps) + .set({ + isCompleted: true, + completedBy: user.id, + completedAt: new Date() + }) + .where( + and( + eq(procedureInstanceSteps.id, stepId), + eq(procedureInstanceSteps.instanceId, params.instanceId) + ) + ); + + await logCompanyEvent(params.companyId, user.id, 'procedure_step_completed', + 'Procedure step completed', { instanceId: params.instanceId, stepId }); + + await checkAutoComplete(params.instanceId, params.companyId, user.id); + + return { success: true, action: 'completeStep' }; + }, + + uncompleteStep: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', 'manager', 'user', 'hr', 'accountant' + ]); + const fd = await request.formData(); + const stepId = trimOrNull(fd.get('stepId')); + if (!stepId) return fail(400, { action: 'uncompleteStep', error: 'Step id required' }); + + await db + .update(procedureInstanceSteps) + .set({ isCompleted: false, completedBy: null, completedAt: null }) + .where( + and( + eq(procedureInstanceSteps.id, stepId), + eq(procedureInstanceSteps.instanceId, params.instanceId) + ) + ); + + // If instance was completed, revert to in_progress + const [inst] = await db + .select({ status: procedureInstances.status }) + .from(procedureInstances) + .where(eq(procedureInstances.id, params.instanceId)) + .limit(1); + + if (inst?.status === 'completed') { + await db + .update(procedureInstances) + .set({ status: 'in_progress', completedAt: null, updatedAt: new Date() }) + .where(eq(procedureInstances.id, params.instanceId)); + } + + return { success: true, action: 'uncompleteStep' }; + }, + + addStepNote: async ({ request, locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', 'manager', 'user', 'hr', 'accountant' + ]); + const fd = await request.formData(); + const stepId = trimOrNull(fd.get('stepId')); + const notes = trimOrNull(fd.get('notes')); + if (!stepId) return fail(400, { action: 'addStepNote', error: 'Step id required' }); + + await db + .update(procedureInstanceSteps) + .set({ notes }) + .where( + and( + eq(procedureInstanceSteps.id, stepId), + eq(procedureInstanceSteps.instanceId, params.instanceId) + ) + ); + + return { success: true, action: 'addStepNote' }; + }, + + completeInstance: async ({ locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', 'manager', 'user', 'hr', 'accountant' + ]); + + await db + .update(procedureInstances) + .set({ status: 'completed', completedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(procedureInstances.id, params.instanceId), + eq(procedureInstances.companyId, params.companyId) + ) + ); + + await logCompanyEvent(params.companyId, user.id, 'procedure_instance_completed', + 'Procedure instance manually completed', + { instanceId: params.instanceId }); + + return { success: true, action: 'completeInstance' }; + }, + + cancelInstance: async ({ locals, params }) => { + const { user } = await requireCompanyRole(locals, params.companyId, 'manager'); + + await db + .update(procedureInstances) + .set({ status: 'cancelled', cancelledAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(procedureInstances.id, params.instanceId), + eq(procedureInstances.companyId, params.companyId) + ) + ); + + await logCompanyEvent(params.companyId, user.id, 'procedure_instance_cancelled', + 'Procedure instance cancelled', + { instanceId: params.instanceId }); + + return { success: true, action: 'cancelInstance' }; + } +}; diff --git a/src/routes/(app)/companies/[companyId]/procedures/[templateId]/instances/[instanceId]/+page.svelte b/src/routes/(app)/companies/[companyId]/procedures/[templateId]/instances/[instanceId]/+page.svelte new file mode 100644 index 0000000..f69cb6e --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/procedures/[templateId]/instances/[instanceId]/+page.svelte @@ -0,0 +1,160 @@ + + + + {data.instance.title} - Procedure Instance + + +
+
+ ← Back to template +

{data.instance.title}

+
+ + {data.instance.status.replace('_', ' ')} + + Started by {data.instance.startedByName ?? 'Unknown'} + {formatDate(data.instance.createdAt)} + {#if data.instance.completedAt} + Completed {formatDate(data.instance.completedAt)} + {/if} +
+
+ + +
+
+ Progress + {completedCount} of {totalSteps} steps +
+
+
+
+
+ + +
+
    + {#each data.instanceSteps as step (step.id)} +
  1. +
    + {#if isLive} +
    async ({ update }) => await update({ reset: false })}> + + +
    + {:else} +
    + {#if step.isCompleted} + + + + {/if} +
    + {/if} +
    +
    +
    +
    + Step {step.stepNumber} +

    + {step.title} +

    +
    +
    + {#if step.description} +

    {step.description}

    + {/if} + {#if step.isCompleted && step.completedByName} +

    + Completed by {step.completedByName} + {#if step.completedAt} on {formatDate(step.completedAt)}{/if} +

    + {/if} + {#if step.notes} +

    + {step.notes} +

    + {/if} + {#if isLive} + {#if editingNoteId === step.id} +
    async ({ result, update }) => { + await update({ reset: false }); + if (result.type === 'success') editingNoteId = null; + }} + class="mt-2"> + + +
    + + +
    +
    + {:else} + + {/if} + {/if} +
    +
  2. + {/each} +
+
+ + + {#if isLive} +
+
async ({ update }) => await update({ reset: false })}> + +
+ {#if data.canManage} +
async ({ update }) => await update({ reset: false })}> + +
+ {/if} +
+ {/if} +