Compare commits
3 Commits
8a23a849da
...
0906a448b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0906a448b3 | |||
| 65cee9855c | |||
| f1dd6877f6 |
+101
-1
@@ -1044,6 +1044,99 @@ export const companyServiceAccounts = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
// ── Procedures & Checklists ────────────────────────────
|
||||
|
||||
export const procedureInstanceStatusEnum = pgEnum('procedure_instance_status', [
|
||||
'in_progress',
|
||||
'completed',
|
||||
'cancelled'
|
||||
]);
|
||||
|
||||
export const procedureTemplates = pgTable(
|
||||
'procedure_templates',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
category: text('category'),
|
||||
isPublished: boolean('is_published').notNull().default(false),
|
||||
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [index('procedure_templates_company_idx').on(table.companyId)]
|
||||
);
|
||||
|
||||
export const procedureSteps = pgTable(
|
||||
'procedure_steps',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
templateId: uuid('template_id')
|
||||
.notNull()
|
||||
.references(() => procedureTemplates.id, { onDelete: 'cascade' }),
|
||||
stepNumber: integer('step_number').notNull(),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
assigneeRole: text('assignee_role'),
|
||||
estimatedMinutes: integer('estimated_minutes'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('procedure_steps_template_step_idx').on(table.templateId, table.stepNumber)
|
||||
]
|
||||
);
|
||||
|
||||
export const procedureInstances = pgTable(
|
||||
'procedure_instances',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
templateId: uuid('template_id')
|
||||
.notNull()
|
||||
.references(() => procedureTemplates.id, { onDelete: 'restrict' }),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
status: procedureInstanceStatusEnum('status').notNull().default('in_progress'),
|
||||
startedBy: text('started_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
index('procedure_instances_company_status_idx').on(table.companyId, table.status)
|
||||
]
|
||||
);
|
||||
|
||||
export const procedureInstanceSteps = pgTable(
|
||||
'procedure_instance_steps',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
instanceId: uuid('instance_id')
|
||||
.notNull()
|
||||
.references(() => procedureInstances.id, { onDelete: 'cascade' }),
|
||||
stepId: uuid('step_id')
|
||||
.notNull()
|
||||
.references(() => procedureSteps.id, { onDelete: 'restrict' }),
|
||||
stepNumber: integer('step_number').notNull(),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
isCompleted: boolean('is_completed').notNull().default(false),
|
||||
completedBy: text('completed_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
notes: text('notes')
|
||||
},
|
||||
(table) => [
|
||||
index('procedure_instance_steps_instance_idx').on(table.instanceId, table.stepNumber)
|
||||
]
|
||||
);
|
||||
|
||||
export const companyAddresses = pgTable(
|
||||
'company_addresses',
|
||||
{
|
||||
@@ -1147,7 +1240,14 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
|
||||
'recurring_bill_posted',
|
||||
'service_account_created',
|
||||
'service_account_updated',
|
||||
'service_account_deleted'
|
||||
'service_account_deleted',
|
||||
'procedure_template_created',
|
||||
'procedure_template_updated',
|
||||
'procedure_template_deleted',
|
||||
'procedure_instance_started',
|
||||
'procedure_step_completed',
|
||||
'procedure_instance_completed',
|
||||
'procedure_instance_cancelled'
|
||||
]);
|
||||
|
||||
export const companyLog = pgTable(
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
@@ -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<number>`(select count(*)::int from procedure_steps where template_id = ${procedureTemplates.id})`,
|
||||
instanceCount: sql<number>`(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<number>`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' };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let showAddForm = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let deletingId = $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';
|
||||
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Procedures - {data.company.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Procedures</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Reusable checklists for standard processes. Start an instance to track progress on a specific case.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if data.canManage}
|
||||
<section 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">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">New Procedure</h2>
|
||||
<button type="button" onclick={() => { showAddForm = !showAddForm; editingId = null; }}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
{showAddForm ? 'Cancel' : '+ New'}
|
||||
</button>
|
||||
</div>
|
||||
{#if showAddForm}
|
||||
<form method="POST" action="?/createTemplate"
|
||||
use:enhance={() => 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">
|
||||
<div class="md:col-span-2">
|
||||
<label for="new-title" class={labelCls}>Title <span class="text-red-500">*</span></label>
|
||||
<input id="new-title" name="title" type="text" required class={inputCls} placeholder="e.g. Purchase Order Process" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-category" class={labelCls}>Category</label>
|
||||
<input id="new-category" name="category" type="text" class={inputCls} placeholder="e.g. Finance, HR, Operations" />
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="new-desc" class={labelCls}>Description</label>
|
||||
<textarea id="new-desc" name="description" rows="2" class={inputCls}></textarea>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex justify-end">
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if data.templates.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No procedures yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each data.templates as tmpl (tmpl.id)}
|
||||
<div class="group relative flex flex-col gap-2 rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-400 hover:shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-500">
|
||||
<a href={`/companies/${data.company.id}/procedures/${tmpl.id}`}
|
||||
class="absolute inset-0 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500">
|
||||
<span class="sr-only">Open {tmpl.title}</span>
|
||||
</a>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="text-sm font-semibold text-gray-900 group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400">{tmpl.title}</h3>
|
||||
<span class="flex-shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {tmpl.isPublished
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||
: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'}">
|
||||
{tmpl.isPublished ? 'Published' : 'Draft'}
|
||||
</span>
|
||||
</div>
|
||||
{#if tmpl.category}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{tmpl.category}</span>
|
||||
{/if}
|
||||
{#if tmpl.description}
|
||||
<p class="line-clamp-2 text-xs text-gray-600 dark:text-gray-300">{tmpl.description}</p>
|
||||
{/if}
|
||||
<div class="mt-auto flex gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{tmpl.stepCount} {tmpl.stepCount === 1 ? 'step' : 'steps'}</span>
|
||||
<span>{tmpl.instanceCount} {tmpl.instanceCount === 1 ? 'instance' : 'instances'}</span>
|
||||
</div>
|
||||
{#if data.canManage}
|
||||
<div class="relative z-10 flex justify-end gap-2 border-t border-gray-100 pt-2 dark:border-gray-700">
|
||||
{#if tmpl.isPublished}
|
||||
<form method="POST" action="?/unpublishTemplate" use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<input type="hidden" name="id" value={tmpl.id} />
|
||||
<button type="submit" class="text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400">Unpublish</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form method="POST" action="?/publishTemplate" use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<input type="hidden" name="id" value={tmpl.id} />
|
||||
<button type="submit" class="text-xs font-medium text-green-600 hover:text-green-700 dark:text-green-400">Publish</button>
|
||||
</form>
|
||||
{/if}
|
||||
<button type="button" onclick={() => (deletingId = deletingId === tmpl.id ? null : tmpl.id)}
|
||||
class="text-xs font-medium text-red-600 hover:text-red-700 dark:text-red-400">Delete</button>
|
||||
</div>
|
||||
{#if deletingId === tmpl.id}
|
||||
<form method="POST" action="?/deleteTemplate"
|
||||
use:enhance={() => 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">
|
||||
<input type="hidden" name="id" value={tmpl.id} />
|
||||
<p class="mb-2 text-red-700 dark:text-red-300">Delete "{tmpl.title}"?</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (deletingId = null)}
|
||||
class="rounded border border-gray-300 bg-white px-2 py-1 text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200">Cancel</button>
|
||||
<button type="submit" class="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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<number>`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}`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
<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();
|
||||
|
||||
let showAddStep = $state(false);
|
||||
let editingStepId = $state<string | null>(null);
|
||||
let showStartInstance = $state(false);
|
||||
|
||||
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 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';
|
||||
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.template.title} - Procedures</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href={`/companies/${data.company.id}/procedures`} class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">← Procedures</a>
|
||||
</div>
|
||||
<h1 class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">{data.template.title}</h1>
|
||||
{#if data.template.description}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{data.template.description}</p>
|
||||
{/if}
|
||||
<div class="mt-2 flex gap-2">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {data.template.isPublished
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||
: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'}">
|
||||
{data.template.isPublished ? 'Published' : 'Draft'}
|
||||
</span>
|
||||
{#if data.template.category}
|
||||
<span class="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">{data.template.category}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick={() => { showStartInstance = !showStartInstance; }}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Start Instance
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if showStartInstance}
|
||||
<form method="POST" action="?/startInstance"
|
||||
use:enhance
|
||||
class="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-700 dark:bg-blue-900/20">
|
||||
<label for="instance-title" class={labelCls}>Instance Title <span class="text-red-500">*</span></label>
|
||||
<input id="instance-title" name="title" type="text" required value={data.template.title} class={inputCls} />
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Customize for this specific case, e.g. "Onboard ABC Corp"</p>
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (showStartInstance = false)}
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-200">Cancel</button>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Start</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Steps -->
|
||||
<section class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Steps ({data.steps.length})
|
||||
</h2>
|
||||
|
||||
{#if data.steps.length === 0}
|
||||
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No steps yet. Add the first step below.</p>
|
||||
{:else}
|
||||
<ol class="space-y-3">
|
||||
{#each data.steps as step, i (step.id)}
|
||||
<li class="flex items-start gap-3 rounded-md border border-gray-100 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-700/30">
|
||||
<span class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-bold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
{step.stepNumber}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if editingStepId === step.id && data.canManage}
|
||||
<form method="POST" action="?/updateStep"
|
||||
use:enhance={() => async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') editingStepId = null;
|
||||
}}
|
||||
class="space-y-2">
|
||||
<input type="hidden" name="stepId" value={step.id} />
|
||||
<input name="title" type="text" required value={step.title} class={inputCls} />
|
||||
<textarea name="description" rows="2" class={inputCls}>{step.description ?? ''}</textarea>
|
||||
<div class="flex gap-2">
|
||||
<input name="assigneeRole" type="text" value={step.assigneeRole ?? ''} placeholder="Role (e.g. manager)" class={inputCls} />
|
||||
<input name="estimatedMinutes" type="number" min="0" value={step.estimatedMinutes ?? ''} placeholder="Est. min" class={inputCls} />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (editingStepId = null)} class="text-xs text-gray-500">Cancel</button>
|
||||
<button type="submit" class="text-xs font-medium text-blue-600">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">{step.title}</div>
|
||||
{#if step.description}
|
||||
<p class="mt-0.5 text-xs text-gray-600 dark:text-gray-300">{step.description}</p>
|
||||
{/if}
|
||||
<div class="mt-1 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{#if step.assigneeRole}
|
||||
<span class="rounded bg-gray-200 px-1.5 py-0.5 dark:bg-gray-600 dark:text-gray-300">{step.assigneeRole}</span>
|
||||
{/if}
|
||||
{#if step.estimatedMinutes}
|
||||
<span>~{step.estimatedMinutes} min</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.canManage}
|
||||
<div class="mt-2 flex gap-2 text-xs">
|
||||
<button type="button" onclick={() => { editingStepId = step.id; }} class="font-medium text-blue-600 dark:text-blue-400">Edit</button>
|
||||
<form method="POST" action="?/removeStep" use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<input type="hidden" name="stepId" value={step.id} />
|
||||
<button type="submit" class="font-medium text-red-600 dark:text-red-400">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
{#if data.canManage}
|
||||
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700">
|
||||
{#if showAddStep}
|
||||
<form method="POST" action="?/addStep"
|
||||
use:enhance={() => async ({ result, update, formElement }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') formElement.reset();
|
||||
}}
|
||||
class="space-y-2">
|
||||
<input name="title" type="text" required placeholder="Step title" class={inputCls} />
|
||||
<textarea name="description" rows="2" placeholder="Description (optional)" class={inputCls}></textarea>
|
||||
<div class="flex gap-2">
|
||||
<input name="assigneeRole" type="text" placeholder="Role (optional)" class={inputCls} />
|
||||
<input name="estimatedMinutes" type="number" min="0" placeholder="Est. minutes" class={inputCls} />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (showAddStep = false)} class="text-xs text-gray-500">Cancel</button>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Add Step</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<button type="button" onclick={() => (showAddStep = true)}
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">+ Add Step</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Instances -->
|
||||
<section class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Instances ({data.instances.length})
|
||||
</h2>
|
||||
{#if data.instances.length === 0}
|
||||
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No instances started yet.</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each data.instances as inst (inst.id)}
|
||||
<a href={`/companies/${data.company.id}/procedures/${data.template.id}/instances/${inst.id}`}
|
||||
class="flex items-center justify-between rounded-md border border-gray-100 bg-gray-50 p-3 transition-colors hover:border-blue-300 dark:border-gray-700 dark:bg-gray-700/30 dark:hover:border-blue-500">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">{inst.title}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Started by {inst.startedByName ?? 'Unknown'} · {formatDate(inst.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[inst.status] ?? STATUS_BADGE.in_progress}">
|
||||
{inst.status.replace('_', ' ')}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
+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