diff --git a/programmerbar-web/src/lib/components/portal/Training.svelte b/programmerbar-web/src/lib/components/portal/Training.svelte index 6fc4e718..69a92152 100644 --- a/programmerbar-web/src/lib/components/portal/Training.svelte +++ b/programmerbar-web/src/lib/components/portal/Training.svelte @@ -13,18 +13,27 @@ interface Props { userId?: string | number | null; + userIds?: string[]; isOpen: boolean; userName?: string; onclose?: () => void; onsave?: (data: { completionStatus: { isComplete: boolean } }) => void; } - let { userId = null, isOpen = false, userName = 'bruker', onclose, onsave }: Props = $props(); + let { + userId = null, + userIds = [], + isOpen = false, + userName = 'bruker', + onclose, + onsave + }: Props = $props(); let trainingItems = $state([...DEFAULT_TRAINING_ITEMS]); let isSaving = $state(false); + let saveError = $state(''); - let isTrainingMode = $derived(userId !== null && userId !== undefined); + let isTrainingMode = $derived(userIds.length > 0 || (userId !== null && userId !== undefined)); let completedCount = $derived(trainingItems.filter((item) => item.completed).length); let totalCount = $derived(trainingItems.length); let isComplete = $derived(completedCount === totalCount); @@ -35,6 +44,7 @@ $effect(() => { if (isOpen) { trainingItems = [...DEFAULT_TRAINING_ITEMS]; + saveError = ''; } }); @@ -46,7 +56,8 @@ } function handleSave() { - if (!isComplete) return; + if (!isComplete || isSaving) return; + saveError = ''; isSaving = true; const form = document.getElementById('trainingForm') as HTMLFormElement; if (form) { @@ -55,7 +66,7 @@ } function handleClose() { - onclose?.(); + if (!isSaving) onclose?.(); } const groupedItems = $derived( @@ -84,6 +95,9 @@ + {#if saveError}{/if}
{#each Object.entries(groupedItems) as [category, items] (category)}
@@ -188,12 +202,16 @@ } } else if (result.type === 'failure') { const data = result.data as { error?: string } | undefined; - console.error('Failed to complete training:', data?.error); + saveError = data?.error || 'Kunne ikke lagre opplæringen. Prøv igjen.'; + } else { + saveError = 'Kunne ikke lagre opplæringen. Prøv igjen.'; } }; }} > - + {#each userIds.length ? userIds : [userId?.toString() || ''] as id (id)} + + {/each} {/if} diff --git a/programmerbar-web/src/lib/server/services/user.service.ts b/programmerbar-web/src/lib/server/services/user.service.ts index b179637e..0b319d79 100644 --- a/programmerbar-web/src/lib/server/services/user.service.ts +++ b/programmerbar-web/src/lib/server/services/user.service.ts @@ -91,6 +91,15 @@ export class UserService { .then((rows) => rows[0]); } + async completeTrainingForUsers(userIds: string[]) { + if (userIds.length === 0) return []; + return await this.#db + .update(users) + .set({ isTrained: true }) + .where(and(inArray(users.id, userIds), not(users.isDeleted))) + .returning({ id: users.id }); + } + async updateTrainingStatus(userId: string, isTrained: boolean) { const updatedUser = await this.#db .update(users) diff --git a/programmerbar-web/src/lib/utils/training.test.ts b/programmerbar-web/src/lib/utils/training.test.ts new file mode 100644 index 00000000..804e27d6 --- /dev/null +++ b/programmerbar-web/src/lib/utils/training.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_TRAINING_ITEMS, isTrainingComplete } from './training'; + +const completed = DEFAULT_TRAINING_ITEMS.map((item) => ({ ...item, completed: true })); +describe('isTrainingComplete', () => { + it('accepts the complete checklist', () => { + expect(isTrainingComplete(completed)).toBe(true); + }); + it('rejects missing, duplicated, unknown and incomplete items', () => { + for (const value of [ + null, + {}, + [], + completed.slice(1), + [...completed.slice(1), completed[1]], + [...completed.slice(1), { id: -1, completed: true }], + DEFAULT_TRAINING_ITEMS + ]) { + expect(isTrainingComplete(value)).toBe(false); + } + }); +}); diff --git a/programmerbar-web/src/lib/utils/training.ts b/programmerbar-web/src/lib/utils/training.ts index d9c26646..cda3e532 100644 --- a/programmerbar-web/src/lib/utils/training.ts +++ b/programmerbar-web/src/lib/utils/training.ts @@ -203,3 +203,11 @@ export const DEFAULT_TRAINING_ITEMS: TrainingItem[] = [ category: TRAINING_CATEGORIES.LAWS_SAFETY } ]; + +export function isTrainingComplete(value: unknown): boolean { + if (!Array.isArray(value) || value.length !== DEFAULT_TRAINING_ITEMS.length) return false; + return DEFAULT_TRAINING_ITEMS.every( + (required) => + value.filter((item) => item?.id === required.id && item.completed === true).length === 1 + ); +} diff --git a/programmerbar-web/src/routes/(portal)/portal/admin/+page.server.ts b/programmerbar-web/src/routes/(portal)/portal/admin/+page.server.ts index 878a6447..27ba19d6 100644 --- a/programmerbar-web/src/routes/(portal)/portal/admin/+page.server.ts +++ b/programmerbar-web/src/routes/(portal)/portal/admin/+page.server.ts @@ -1,3 +1,4 @@ +import { isTrainingComplete } from '$lib/utils/training'; import { redirect, fail, type Actions } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; @@ -15,6 +16,34 @@ export const load: PageServerLoad = async ({ locals }) => { }; export const actions: Actions = { + completeTraining: async ({ request, locals }) => { + if (!locals.user || locals.user.role !== 'board') { + return fail(401, { error: 'Du har ikke tilgang til å registrere opplæring.' }); + } + const formData = await request.formData(); + const ids = formData.getAll('userId'); + if (!ids.length || ids.some((id) => typeof id !== 'string' || !id.trim())) { + return fail(400, { error: 'Velg minst én bruker.' }); + } + const userIds = [...new Set(ids as string[])]; + let trainingData: unknown; + try { + trainingData = JSON.parse(String(formData.get('trainingData'))); + } catch { + return fail(400, { error: 'Ugyldig opplæringsdata.' }); + } + if (!isTrainingComplete(trainingData)) { + return fail(400, { error: 'Alle opplæringspunktene må være fullført.' }); + } + const users = await locals.userService.findAll(); + if (userIds.some((id) => !users.some((user) => user.id === id))) { + return fail(400, { + error: 'En valgt bruker finnes ikke lenger. Oppdater siden og prøv igjen.' + }); + } + await locals.userService.completeTrainingForUsers(userIds); + return { success: true, trainingCompleted: true }; + }, updateRole: async ({ request, locals }) => { const formData = await request.formData(); const userId = formData.get('userId') as string; diff --git a/programmerbar-web/src/routes/(portal)/portal/admin/+page.svelte b/programmerbar-web/src/routes/(portal)/portal/admin/+page.svelte index dc9bb07e..c6c18b31 100644 --- a/programmerbar-web/src/routes/(portal)/portal/admin/+page.svelte +++ b/programmerbar-web/src/routes/(portal)/portal/admin/+page.svelte @@ -1,4 +1,6 @@ + { + if ( + selectedDropdown?.open && + event.target instanceof Node && + !selectedDropdown.contains(event.target) + ) { + selectedDropdown.open = false; + } + }} + onkeydown={(event) => { + if (event.key === 'Escape' && selectedDropdown?.open) { + selectedDropdown.open = false; + selectedDropdown.querySelector('summary')?.focus(); + } + }} + onfocusin={(event) => { + if ( + selectedDropdown?.open && + event.target instanceof Node && + !selectedDropdown.contains(event.target) + ) { + selectedDropdown.open = false; + } + }} +/> + Admin @@ -86,12 +131,75 @@
+
+
+ toggleUser(user.id)} + class="h-5 w-5 cursor-pointer rounded border-gray-300" + /> +{/snippet} + +{#snippet trainingStatus(user: User)} + {user.isTrained ? 'Opplæring fullført' : 'Mangler opplæring'} +{/snippet} + + user.id)} + userName={selectedUsers.map((user: User) => user.name).join(', ')} + onclose={() => { + trainingOpen = false; + }} + onsave={() => { + successMessage = `Opplæring registrert for ${selectedUsers.length} brukere.`; + trainingOpen = false; + selectedIds = []; + }} +/> diff --git a/programmerbar-web/src/routes/(portal)/portal/admin/bruker/[id]/+page.server.ts b/programmerbar-web/src/routes/(portal)/portal/admin/bruker/[id]/+page.server.ts index 8b395b51..b3e7253e 100644 --- a/programmerbar-web/src/routes/(portal)/portal/admin/bruker/[id]/+page.server.ts +++ b/programmerbar-web/src/routes/(portal)/portal/admin/bruker/[id]/+page.server.ts @@ -1,3 +1,4 @@ +import { isTrainingComplete } from '$lib/utils/training'; import { error } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; import { fail } from '@sveltejs/kit'; @@ -126,10 +127,13 @@ export const actions: Actions = { return fail(400, { error: 'Training data is required' }); } - const trainingData = JSON.parse(trainingDataJson); - - const isComplete = - trainingData && trainingData.every((item: { completed: boolean }) => item.completed === true); + let trainingData: unknown; + try { + trainingData = JSON.parse(trainingDataJson); + } catch { + return fail(400, { error: 'Invalid training data' }); + } + const isComplete = isTrainingComplete(trainingData); if (!isComplete) { return fail(400, { error: 'All training items must be completed' });