Add procedure instance detail with step completion and auto-complete
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+220
@@ -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' };
|
||||
}
|
||||
};
|
||||
+160
@@ -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">← 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>
|
||||
Reference in New Issue
Block a user