Add procedure instance detail with step completion and auto-complete
Deploy to LXC / deploy (push) Successful in 1m55s
Validate / validate (push) Successful in 33s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 14:58:10 +07:00
parent 65cee9855c
commit 0906a448b3
2 changed files with 380 additions and 0 deletions
@@ -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<number>`count(*)::int`,
done: sql<number>`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' };
}
};
@@ -0,0 +1,160 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatDate } from '$lib/utils/date.js';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
const STATUS_BADGE: Record<string, string> = {
in_progress: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
completed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
cancelled: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'
};
const completedCount = $derived(data.instanceSteps.filter((s) => s.isCompleted).length);
const totalSteps = $derived(data.instanceSteps.length);
const progressPct = $derived(totalSteps > 0 ? (completedCount / totalSteps) * 100 : 0);
const isLive = $derived(data.instance.status === 'in_progress');
let editingNoteId = $state<string | null>(null);
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';
</script>
<svelte:head>
<title>{data.instance.title} - Procedure Instance</title>
</svelte:head>
<div class="space-y-6">
<header>
<a href={`/companies/${data.company.id}/procedures/${data.instance.templateId}`}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">&larr; Back to template</a>
<h1 class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">{data.instance.title}</h1>
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[data.instance.status]}">
{data.instance.status.replace('_', ' ')}
</span>
<span>Started by {data.instance.startedByName ?? 'Unknown'}</span>
<span>{formatDate(data.instance.createdAt)}</span>
{#if data.instance.completedAt}
<span>Completed {formatDate(data.instance.completedAt)}</span>
{/if}
</div>
</header>
<!-- Progress bar -->
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">Progress</span>
<span class="text-gray-500 dark:text-gray-400">{completedCount} of {totalSteps} steps</span>
</div>
<div class="mt-2 h-2.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div
class="h-full rounded-full transition-all {progressPct === 100 ? 'bg-green-500' : 'bg-blue-500'}"
style="width: {progressPct}%"
></div>
</div>
</div>
<!-- Steps checklist -->
<section class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<ol class="divide-y divide-gray-100 dark:divide-gray-700">
{#each data.instanceSteps as step (step.id)}
<li class="flex items-start gap-3 p-4 {step.isCompleted ? 'bg-green-50/50 dark:bg-green-900/10' : ''}">
<div class="pt-0.5">
{#if isLive}
<form method="POST" action={step.isCompleted ? '?/uncompleteStep' : '?/completeStep'}
use:enhance={() => async ({ update }) => await update({ reset: false })}>
<input type="hidden" name="stepId" value={step.id} />
<button type="submit"
class="flex h-5 w-5 items-center justify-center rounded border-2 transition-colors {step.isCompleted
? 'border-green-500 bg-green-500 text-white'
: 'border-gray-300 hover:border-blue-400 dark:border-gray-600'}">
{#if step.isCompleted}
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6L5 8.5L9.5 3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
</button>
</form>
{:else}
<div class="flex h-5 w-5 items-center justify-center rounded border-2 {step.isCompleted
? 'border-green-500 bg-green-500 text-white'
: 'border-gray-300 dark:border-gray-600'}">
{#if step.isCompleted}
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6L5 8.5L9.5 3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
</div>
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<div>
<span class="text-xs font-bold text-gray-400 dark:text-gray-500">Step {step.stepNumber}</span>
<p class="text-sm font-medium {step.isCompleted ? 'text-gray-500 line-through dark:text-gray-400' : 'text-gray-900 dark:text-white'}">
{step.title}
</p>
</div>
</div>
{#if step.description}
<p class="mt-0.5 text-xs text-gray-600 dark:text-gray-300">{step.description}</p>
{/if}
{#if step.isCompleted && step.completedByName}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">
Completed by {step.completedByName}
{#if step.completedAt} on {formatDate(step.completedAt)}{/if}
</p>
{/if}
{#if step.notes}
<p class="mt-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{step.notes}
</p>
{/if}
{#if isLive}
{#if editingNoteId === step.id}
<form method="POST" action="?/addStepNote"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editingNoteId = null;
}}
class="mt-2">
<input type="hidden" name="stepId" value={step.id} />
<textarea name="notes" rows="2" class={inputCls} placeholder="Add a note...">{step.notes ?? ''}</textarea>
<div class="mt-1 flex justify-end gap-2">
<button type="button" onclick={() => (editingNoteId = null)} class="text-xs text-gray-500">Cancel</button>
<button type="submit" class="text-xs font-medium text-blue-600">Save note</button>
</div>
</form>
{:else}
<button type="button" onclick={() => (editingNoteId = step.id)}
class="mt-1 text-xs text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400">
{step.notes ? 'Edit note' : 'Add note'}
</button>
{/if}
{/if}
</div>
</li>
{/each}
</ol>
</section>
<!-- Actions -->
{#if isLive}
<div class="flex gap-3">
<form method="POST" action="?/completeInstance" use:enhance={() => async ({ update }) => await update({ reset: false })}>
<button type="submit" class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
Complete Instance
</button>
</form>
{#if data.canManage}
<form method="POST" action="?/cancelInstance" use:enhance={() => async ({ update }) => await update({ reset: false })}>
<button type="submit" class="rounded-md border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
Cancel Instance
</button>
</form>
{/if}
</div>
{/if}
</div>