From 4d69ebca74e91a440f16c980437a8277b0e7f045 Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Sat, 25 Oct 2025 01:26:44 +0200 Subject: [PATCH 01/17] feat: add competence management system - Introduced utility functions for competence matching in `competence-helpers.ts`. - Added score color and status helpers in `score-helpers.ts`. - Refactored imports in `process-list.tsx` and `index.tsx` to use new paths for potential owner store. - Created competence schema in `competence-schema.ts` to define types for space and user competences. - Implemented competence data handling in `competences.ts` with functions for CRUD operations. - Developed database interactions for competences in `competence.ts`, including user competence management. - Updated Next.js configuration to include user competence routes. - Added Prisma migrations for creating competence tables and constraints. - Enhanced Prisma schema to define competence models and relationships. --- .../(competence)/user-competence/page.tsx | 26 + .../[environmentId]/iam/competences/page.tsx | 40 + .../(dashboard)/[environmentId]/layout.tsx | 19 + .../processes/[processId]/page.tsx | 5 +- .../[processId]/properties-panel.tsx | 7 +- .../processes/[processId]/wrapper.tsx | 2 +- .../api/spaces/[spaceId]/competences/route.ts | 26 + .../competence/actions/fetch-matches.ts | 978 ++++++++++++++++++ .../competence/actions/match-constants.ts | 10 + .../competence/actions/match-types.ts | 0 .../organization-competence-actions.ts | 143 +++ .../actions}/potentialOwner-server-action.ts | 0 .../actions/user-competence-actions.ts | 208 ++++ .../organization/competences-dashboard.tsx | 57 + .../space-competence-form-modal.tsx | 133 +++ .../space-competences-management.tsx | 316 ++++++ .../organization/user-competences-display.tsx | 102 ++ .../user-competences-overview.tsx | 125 +++ .../user-list-selector.module.scss | 13 + .../organization/user-list-selector.tsx | 91 ++ .../potential-owner}/potential-owner.tsx | 2 +- .../potential-owner/ranked-user-list.tsx | 313 ++++++ .../suggest-potential-owner.tsx | 737 +++++++++++++ .../use-potentialOwner-store.ts | 2 +- .../space-competences-claim.tsx | 314 ++++++ .../user-competence-manager.tsx | 78 ++ .../user-competences-list.tsx | 377 +++++++ .../competence/utils/competence-helpers.ts | 10 + .../competence/utils/score-helpers.ts | 31 + .../components/process-list.tsx | 2 +- .../components/processes/index.tsx | 2 +- .../lib/data/competence-schema.ts | 59 ++ .../lib/data/competences.ts | 178 ++++ .../lib/data/db/competence.ts | 576 +++++++++++ src/management-system-v2/next.config.js | 1 + .../migration.sql | 51 + .../migration.sql | 14 + src/management-system-v2/prisma/schema.prisma | 52 + 38 files changed, 5093 insertions(+), 7 deletions(-) create mode 100644 src/management-system-v2/app/(dashboard)/[environmentId]/(competence)/user-competence/page.tsx create mode 100644 src/management-system-v2/app/(dashboard)/[environmentId]/iam/competences/page.tsx create mode 100644 src/management-system-v2/app/api/spaces/[spaceId]/competences/route.ts create mode 100644 src/management-system-v2/components/competence/actions/fetch-matches.ts create mode 100644 src/management-system-v2/components/competence/actions/match-constants.ts create mode 100644 src/management-system-v2/components/competence/actions/match-types.ts create mode 100644 src/management-system-v2/components/competence/actions/organization-competence-actions.ts rename src/management-system-v2/{app/(dashboard)/[environmentId]/processes/[processId] => components/competence/actions}/potentialOwner-server-action.ts (100%) create mode 100644 src/management-system-v2/components/competence/actions/user-competence-actions.ts create mode 100644 src/management-system-v2/components/competence/organization/competences-dashboard.tsx create mode 100644 src/management-system-v2/components/competence/organization/space-competence-form-modal.tsx create mode 100644 src/management-system-v2/components/competence/organization/space-competences-management.tsx create mode 100644 src/management-system-v2/components/competence/organization/user-competences-display.tsx create mode 100644 src/management-system-v2/components/competence/organization/user-competences-overview.tsx create mode 100644 src/management-system-v2/components/competence/organization/user-list-selector.module.scss create mode 100644 src/management-system-v2/components/competence/organization/user-list-selector.tsx rename src/management-system-v2/{app/(dashboard)/[environmentId]/processes/[processId] => components/competence/potential-owner}/potential-owner.tsx (98%) create mode 100644 src/management-system-v2/components/competence/potential-owner/ranked-user-list.tsx create mode 100644 src/management-system-v2/components/competence/potential-owner/suggest-potential-owner.tsx rename src/management-system-v2/{app/(dashboard)/[environmentId]/processes/[processId] => components/competence/potential-owner}/use-potentialOwner-store.ts (93%) create mode 100644 src/management-system-v2/components/competence/user-competences/space-competences-claim.tsx create mode 100644 src/management-system-v2/components/competence/user-competences/user-competence-manager.tsx create mode 100644 src/management-system-v2/components/competence/user-competences/user-competences-list.tsx create mode 100644 src/management-system-v2/components/competence/utils/competence-helpers.ts create mode 100644 src/management-system-v2/components/competence/utils/score-helpers.ts create mode 100644 src/management-system-v2/lib/data/competence-schema.ts create mode 100644 src/management-system-v2/lib/data/competences.ts create mode 100644 src/management-system-v2/lib/data/db/competence.ts create mode 100644 src/management-system-v2/prisma/migrations/20251022150644_added_user_and_role_competences/migration.sql create mode 100644 src/management-system-v2/prisma/migrations/20251022150723_add_competence_constraints/migration.sql diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/(competence)/user-competence/page.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/(competence)/user-competence/page.tsx new file mode 100644 index 000000000..1d91e2a98 --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/(competence)/user-competence/page.tsx @@ -0,0 +1,26 @@ +import { getCurrentUser } from '@/components/auth'; +import Content from '@/components/content'; +import { notFound } from 'next/navigation'; +import { env } from '@/lib/ms-config/env-vars'; +import UserCompetenceManager from '@/components/competence/user-competences/user-competence-manager'; +import { getAllCompetencesOfUser } from '@/lib/data/db/competence'; +import UnauthorizedFallback from '@/components/unauthorized-fallback'; + +const UserCompetencePage = async () => { + const { user, userId } = await getCurrentUser(); + + if (user?.isGuest) return ; + + if (!env.PROCEED_PUBLIC_IAM_ACTIVE) return notFound(); + + // Fetch user's competences + const userCompetences = await getAllCompetencesOfUser(userId); + + return ( + + + + ); +}; + +export default UserCompetencePage; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/competences/page.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/competences/page.tsx new file mode 100644 index 000000000..c5a40b619 --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/competences/page.tsx @@ -0,0 +1,40 @@ +import { getCurrentUser, getCurrentEnvironment } from '@/components/auth'; +import Content from '@/components/content'; +import { notFound } from 'next/navigation'; +import { env } from '@/lib/ms-config/env-vars'; +import UnauthorizedFallback from '@/components/unauthorized-fallback'; +import CompetencesDashboard from '@/components/competence/organization/competences-dashboard'; +import { getAllSpaceCompetences } from '@/lib/data/db/competence'; +import { getUsersInSpace } from '@/lib/data/db/iam/memberships'; +import { User } from '@/lib/data/user-schema'; + +const OrganizationCompetencesPage = async ({ params }: { params: { environmentId: string } }) => { + const { user, userId } = await getCurrentUser(); + const { activeEnvironment } = await getCurrentEnvironment(params.environmentId); + + if (user?.isGuest) return ; + + if (!env.PROCEED_PUBLIC_IAM_ACTIVE) return notFound(); + + // Only show for organization spaces + if (!activeEnvironment.isOrganization) return notFound(); + + // TODO: Add authorization check - can('view', 'Competence') or can('manage', 'User') + + // Fetch organization data + const spaceCompetences = await getAllSpaceCompetences(activeEnvironment.spaceId); + const organizationMembers = (await getUsersInSpace(activeEnvironment.spaceId)) as User[]; + + return ( + + + + ); +}; + +export default OrganizationCompetencesPage; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/layout.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/layout.tsx index 6f22f9b97..8bc954c18 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/layout.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/layout.tsx @@ -20,6 +20,7 @@ import { SolutionOutlined, HomeOutlined, AppstoreOutlined, + TrophyOutlined, } from '@ant-design/icons'; import { TbUser, TbUserEdit } from 'react-icons/tb'; @@ -252,6 +253,15 @@ const DashboardLayout = async ({ }); } + // TODO: Add proper authorization check for competences + if (can('manage', 'User')) { + children.push({ + key: 'competences', + label: Competences, + icon: , + }); + } + layoutMenuItems.push({ key: 'iam-group', label: 'Organization', @@ -275,6 +285,15 @@ const DashboardLayout = async ({ ), icon: , }, + { + key: 'personal-competence', + label: user?.isGuest ? ( + My Competences + ) : ( + My Competences + ), + icon: , + }, { key: 'personal-spaces', label: user?.isGuest ? ( diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/page.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/page.tsx index 7bddc6c49..9f1a35644 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/page.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/page.tsx @@ -9,7 +9,10 @@ import { getRolesWithMembers } from '@/lib/data/db/iam/roles'; import { getProcessBPMN } from '@/lib/data/processes'; import BPMNTimeline from '@/components/bpmn-timeline'; import { UnauthorizedError } from '@/lib/ability/abilityHelper'; -import { RoleType, UserType } from './use-potentialOwner-store'; +import { + RoleType, + UserType, +} from '../../../../../components/competence/potential-owner/use-potentialOwner-store'; import type { Process } from '@/lib/data/process-schema'; type ProcessProps = { diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/properties-panel.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/properties-panel.tsx index e7be19e39..57612896a 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/properties-panel.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/properties-panel.tsx @@ -26,7 +26,10 @@ import DescriptionSection from './description-section'; import PlannedCostInput from './planned-cost-input'; import { checkIfProcessExistsByName, updateProcessMetaData } from '@/lib/data/processes'; import { useEnvironment } from '@/components/auth-can'; -import { PotentialOwner, ResponsibleParty } from './potential-owner'; +import { + PotentialOwner, + ResponsibleParty, +} from '../../../../../components/competence/potential-owner/potential-owner'; import { EnvVarsContext } from '@/components/env-vars-context'; import { getBackgroundColor, getBorderColor, getTextColor } from '@/lib/helpers/bpmn-js-helpers'; import { Element, Shape } from 'bpmn-js/lib/model/Types'; @@ -34,6 +37,7 @@ import { useSession } from 'next-auth/react'; import { usePathname } from 'next/navigation'; import { BPMNCanvasRef } from '@/components/bpmn-canvas'; import VariableDefinition from './variable-definition'; +import SuggestPotentialOwner from '@/components/competence/potential-owner/suggest-potential-owner'; // Elements that should not display the planned duration field // These are non-executable elements that don't have execution time @@ -389,6 +393,7 @@ const PropertiesPanelContent: React.FC = ({ {selectedElement.type === 'bpmn:UserTask' && ( <> + )} diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/wrapper.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/wrapper.tsx index aece93980..e30840964 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/wrapper.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/wrapper.tsx @@ -35,7 +35,7 @@ import usePotentialOwnerStore, { UserType, RoleType, useInitialisePotentialOwnerStore, -} from './use-potentialOwner-store'; +} from '../../../../../components/competence/potential-owner/use-potentialOwner-store'; type SubprocessInfo = { id?: string; diff --git a/src/management-system-v2/app/api/spaces/[spaceId]/competences/route.ts b/src/management-system-v2/app/api/spaces/[spaceId]/competences/route.ts new file mode 100644 index 000000000..f030164bc --- /dev/null +++ b/src/management-system-v2/app/api/spaces/[spaceId]/competences/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAllSpaceCompetences, getAllCompetencesOfUser } from '@/lib/data/db/competence'; +import { getCurrentUser } from '@/components/auth'; + +export async function GET(request: NextRequest, { params }: { params: { spaceId: string } }) { + try { + const { userId } = await getCurrentUser(); + + // Get all space competences + const spaceCompetences = await getAllSpaceCompetences(params.spaceId); + + // Get user's claimed competences + const userCompetences = await getAllCompetencesOfUser(userId); + + // Mark which competences are already claimed + const competencesWithClaimStatus = spaceCompetences.map((comp) => ({ + ...comp, + isClaimed: userCompetences.some((uc) => uc.competenceId === comp.id), + })); + + return NextResponse.json(competencesWithClaimStatus); + } catch (error) { + console.error('Failed to fetch space competences:', error); + return NextResponse.json({ error: 'Failed to fetch competences' }, { status: 500 }); + } +} diff --git a/src/management-system-v2/components/competence/actions/fetch-matches.ts b/src/management-system-v2/components/competence/actions/fetch-matches.ts new file mode 100644 index 000000000..76571c162 --- /dev/null +++ b/src/management-system-v2/components/competence/actions/fetch-matches.ts @@ -0,0 +1,978 @@ +'use server'; + +import { getCurrentEnvironment, getCurrentUser } from '@/components/auth'; +import { getAllSpaceCompetences } from '@/lib/data/competences'; +import { getUsersInSpace } from '@/lib/data/db/iam/memberships'; +import { getAllCompetencesOfUser as getUserCompetences } from '@/lib/data/db/competence'; +import { SCORE_THRESHOLDS } from './match-constants'; + +/* API Configuration */ +const API_URL = 'https://ai.raschke.cc/competence-matcher'; +const COMPETENCE_LIST_PATH = '/resource-competence-list/jobs'; +const MATCH_PATH = '/matching-task-to-resource/jobs'; +const POLL_INTERVAL_MS = 2000; // Poll every 2 seconds +const MAX_POLL_ATTEMPTS = 120; // 4 minutes timeout + +/* Feature Flags */ +const ENABLE_OVERALL_COMPETENCE = false; // Set to true to include combined competence assessment + +/* Debug Logging */ +const DEBUG = true; // Set to false to disable logs +function debugLog(context: string, ...args: any[]) { + if (DEBUG) { + console.log(`[CompetenceMatching:${context}]`, ...args); + } +} + +/* Types */ +export type TaskContextData = { + processName: string; + processDescription: string; + taskName: string; + taskDescription: string; +}; + +/** + * Result type that can be either a success or an informational message + */ +export type MatchingResult = + | { + success: true; + data: { + matchResult: MatchJobResponse; + competenceListId: string; + }; + } + | { + success: false; + type: 'info' | 'warning' | 'error'; + title: string; + message: string; + }; + +type JobStatus = 'pending' | 'preprocessing' | 'running' | 'completed' | 'failed'; + +type CompetenceListJobResponse = { + jobId?: string; + status?: JobStatus; + competenceListId?: string; // Present when job is completed +}; + +type MatchJobResponse = { + jobId?: string; + status?: JobStatus; + tasks?: Array<{ + taskId: string; + taskText: string; + }>; + resourceRanking?: Array<{ + resourceId: string; + taskMatchings: Array<{ + taskId: string; + competenceMatchings: Array<{ + competenceId: string; + matchings: Array<{ + text: string; + type: string; + matchProbability: number; + alignment: string; + reason: string; + }>; + avgMatchProbability: number; + avgBestFitMatchProbability: number; + }>; + maxMatchProbability: number; + maxBestFitMatchProbability: number; + }>; + avgTaskMatchProbability: number; + avgBestFitTaskMatchProbability: number; + contradicting: boolean; + }>; +}; + +type APICompetence = { + competenceId: string; + name: string; + description: string; + externalQualificationNeeded: boolean; + renewTime: number; + proficiencyLevel: string; + qualificationDates: string[]; + lastUsages: string[]; +}; + +type APIResource = { + resourceId: string; + competencies: APICompetence[]; +}; + +type APITask = { + taskId: string; + name: string; + description: string; + executionInstructions: string; + requiredCompetencies: string[]; +}; + +// The competence list endpoint expects just an array of resources +type CompetenceListBody = APIResource[]; + +type MatchBody = { + competenceListId: string; + tasks: APITask[]; +}; + +/* Transformed Types for UI */ +export type CompetenceMatch = { + competenceId: string; + competenceName: string; + competenceDescription: string; + score: number; // avgMatchProbability as percentage + bestFitScore: number; // avgBestFitMatchProbability as percentage + reasons: string[]; + alignment?: 'aligning' | 'neutral' | 'contradicting'; // Worst alignment from matchings +}; + +export type RankedUser = { + userId: string; + userName: string; + userEmail: string | null; + score: number; // avgTaskMatchProbability as percentage + bestFitScore: number; // avgBestFitTaskMatchProbability as percentage + competenceMatches: CompetenceMatch[]; + contradicting: boolean; // Flag if any competence contradicts the task +}; + +/* Helper Functions */ + +/** + * Formats task context data into the API task format + * Note: Not exported because 'use server' files require all exports to be async + */ +function formatUserTaskForAPI(taskContext: TaskContextData, taskId: string = '1'): APITask { + const { processName, processDescription, taskName, taskDescription } = taskContext; + + // Combine all context into a comprehensive description + const fullDescription = ` +Process: ${processName} +${processDescription ? `Process Description: ${processDescription}\n` : ''} +Task: ${taskName} +${taskDescription ? `Task Description: ${taskDescription}` : ''} + `.trim(); + + return { + taskId, + name: taskName, + description: fullDescription, + executionInstructions: taskDescription || '', + requiredCompetencies: [], + }; +} + +/** + * Fetches all users with competences in the current space and formats them for the API + */ +export async function fetchUsersWithCompetences(environmentId: string): Promise { + const { activeEnvironment } = await getCurrentEnvironment(environmentId); + const { spaceId } = activeEnvironment; + + // Get all users in the space + const users = await getUsersInSpace(spaceId); + + // Get all space competences with user claims + const spaceCompetences = await getAllSpaceCompetences(environmentId); + + // Build a map of userId -> competences + const userCompetenceMap = new Map(); + + // First, add space competences (claimed by users) + for (const competence of spaceCompetences) { + // Process each user who claimed this competence + for (const claim of competence.claimedBy) { + if (!userCompetenceMap.has(claim.userId)) { + userCompetenceMap.set(claim.userId, []); + } + + const renewTimeDays = competence.renewalTimeInterval ?? 365; // Default to 365 days if not specified + + // Combine name and description for better matching + const combinedDescription = competence.description + ? `${competence.name}\n${competence.description}` + : competence.name; + + const apiCompetence: APICompetence = { + competenceId: competence.id, + name: competence.name, + description: combinedDescription, + externalQualificationNeeded: competence.externalQualificationNeeded, + renewTime: renewTimeDays, + proficiencyLevel: claim.proficiency || 'Not specified', + qualificationDates: claim.qualificationDate ? [claim.qualificationDate.toISOString()] : [], + lastUsages: claim.lastUsage ? [claim.lastUsage.toISOString()] : [], + }; + + userCompetenceMap.get(claim.userId)!.push(apiCompetence); + } + } + + // Second, add user-specific competences for all users + for (const user of users) { + const userCompetences = await getUserCompetences(user.id); + + if (userCompetences.length > 0) { + if (!userCompetenceMap.has(user.id)) { + userCompetenceMap.set(user.id, []); + } + + // Get existing competence IDs for this user to avoid duplicates + const existingCompetenceIds = new Set( + userCompetenceMap.get(user.id)!.map((c) => c.competenceId), + ); + + for (const userComp of userCompetences) { + // Skip if this competence is already in the list (e.g., from space competences) + if (existingCompetenceIds.has(userComp.competence.id)) { + continue; + } + + const renewTimeDays = userComp.competence.renewalTimeInterval ?? 365; + + // Combine name and description for better matching + const combinedDescription = userComp.competence.description + ? `${userComp.competence.name}\n${userComp.competence.description}` + : userComp.competence.name; + + const apiCompetence: APICompetence = { + competenceId: userComp.competence.id, + name: userComp.competence.name, + description: combinedDescription, + externalQualificationNeeded: userComp.competence.externalQualificationNeeded, + renewTime: renewTimeDays, + proficiencyLevel: userComp.proficiency || 'Not specified', + qualificationDates: userComp.qualificationDate + ? [userComp.qualificationDate.toISOString()] + : [], + lastUsages: userComp.lastUsage ? [userComp.lastUsage.toISOString()] : [], + }; + + userCompetenceMap.get(user.id)!.push(apiCompetence); + } + } + } + + // Convert map to array format and optionally add overall competence for each user + const resources: APIResource[] = Array.from(userCompetenceMap.entries()).map( + ([userId, competencies]) => { + // Create an "overall" competence by concatenating all competence descriptions + // Only if feature flag is enabled + if (ENABLE_OVERALL_COMPETENCE && competencies.length > 0) { + const overallDescription = competencies + .map( + (c) => `${c.name}:\n${c.description.split('\n').slice(1).join('\n') || c.description}`, + ) + .join('\n\n---\n\n'); + + const overallCompetence: APICompetence = { + competenceId: `__OVERALL__${userId}`, // Special ID to identify overall competence + name: 'Overall Competence Profile', + description: `Combined assessment of all competences:\n\n${overallDescription}`, + externalQualificationNeeded: false, + renewTime: 365, + proficiencyLevel: 'Combined', + qualificationDates: [], + lastUsages: [], + }; + + return { + resourceId: userId, + competencies: [...competencies, overallCompetence], + }; + } + + return { + resourceId: userId, + competencies, + }; + }, + ); + + return resources; +} + +/** + * Gets all competences (both space and user) for building a lookup map + * Returns a Map of competenceId -> { name, description } + */ +export async function getAllCompetencesMap( + environmentId: string, +): Promise> { + const { activeEnvironment } = await getCurrentEnvironment(environmentId); + const { spaceId } = activeEnvironment; + + const competenceMap = new Map(); + + // Add space competences + const spaceCompetences = await getAllSpaceCompetences(environmentId); + for (const comp of spaceCompetences) { + competenceMap.set(comp.id, { + name: comp.name, + description: comp.description || '', + }); + } + + // Add user competences from all users in the space + const users = await getUsersInSpace(spaceId); + for (const user of users) { + const userCompetences = await getUserCompetences(user.id); + for (const userComp of userCompetences) { + // Only add if not already present (space competences take precedence) + if (!competenceMap.has(userComp.competence.id)) { + competenceMap.set(userComp.competence.id, { + name: userComp.competence.name, + description: userComp.competence.description || '', + }); + } + } + } + + return competenceMap; +} + +/** + * Gets the appropriate x-proceed-db-id header value + */ +async function getDbIdHeader(environmentId: string): Promise { + const { activeEnvironment } = await getCurrentEnvironment(environmentId); + + // Use spaceId for organisations, userId for personal spaces + if (activeEnvironment.isOrganization) { + return activeEnvironment.spaceId; + } else { + const { userId } = await getCurrentUser(); + return userId; + } +} + +/** + * Creates headers for API requests + */ +async function createHeaders(environmentId: string): Promise { + const dbId = await getDbIdHeader(environmentId); + + return { + 'Content-Type': 'application/json', + 'x-proceed-db-id': dbId, + }; +} + +/** + * Polls a job until it completes or fails + * Supports optional progress callback for status updates + */ +async function pollJobStatus( + url: string, + headers: HeadersInit, + jobType: 'competence list' | 'matching', + maxAttempts: number = MAX_POLL_ATTEMPTS, + onProgress?: (status: JobStatus | undefined, attempt: number) => void, +): Promise<{ success: true; data: T } | { success: false; reason: string }> { + debugLog('pollJobStatus', `Starting poll for ${jobType} at ${url}`); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + debugLog('pollJobStatus', `Attempt ${attempt + 1}/${maxAttempts} for ${jobType}`); + + const response = await fetch(url, { headers }); + + debugLog('pollJobStatus', `Response status: ${response.status} ${response.statusText}`); + + if (!response.ok) { + debugLog('pollJobStatus', `Network error: ${response.status}`); + const errorText = await response.text().catch(() => 'No error text'); + debugLog('pollJobStatus', `Error response body:`, errorText); + return { + success: false, + reason: 'network', + }; + } + + const data: T = await response.json(); + debugLog('pollJobStatus', `Job status: ${data.status}`, data); + + // Notify progress callback if provided + if (onProgress) { + onProgress(data.status, attempt + 1); + } + + // Check for completion based on job type + if (jobType === 'competence list') { + const competenceData = data as CompetenceListJobResponse; + // Job is complete if competenceListId is present + if (competenceData.competenceListId) { + debugLog('pollJobStatus', `${jobType} completed successfully!`, data); + return { success: true, data }; + } + // Check for explicit failure + if (competenceData.status === 'failed') { + debugLog('pollJobStatus', `${jobType} failed`, data); + return { + success: false, + reason: 'server-failure', + }; + } + } else if (jobType === 'matching') { + const matchData = data as MatchJobResponse; + // Job is complete if resourceRanking is present + if (matchData.resourceRanking) { + debugLog('pollJobStatus', `${jobType} completed successfully!`, data); + return { success: true, data }; + } + // Check for explicit failure + if (matchData.status === 'failed') { + debugLog('pollJobStatus', `${jobType} failed`, data); + return { + success: false, + reason: 'server-failure', + }; + } + } + + // Wait before next poll (still processing) + debugLog('pollJobStatus', `Waiting ${POLL_INTERVAL_MS}ms before next poll...`); + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } catch (error) { + debugLog('pollJobStatus', `Exception during poll:`, error); + return { + success: false, + reason: 'network', + }; + } + } + + debugLog('pollJobStatus', `Timeout after ${maxAttempts} attempts`); + return { + success: false, + reason: 'timeout', + }; +} + +async function createCompetenceListJob( + environmentId: string, + resources: APIResource[], +): Promise<{ success: true; competenceListId: string } | { success: false; reason: string }> { + try { + debugLog('createCompetenceListJob', 'Starting competence list job creation'); + + const headers = await createHeaders(environmentId); + debugLog('createCompetenceListJob', 'Headers created:', headers); + + // The API expects just an array of resources + const body: CompetenceListBody = resources; + debugLog('createCompetenceListJob', `Sending ${resources.length} resources`); + debugLog('createCompetenceListJob', 'Body:', body); + + // POST to create job + const createResponse = await fetch(`${API_URL}${COMPETENCE_LIST_PATH}`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + + debugLog( + 'createCompetenceListJob', + `POST response: ${createResponse.status} ${createResponse.statusText}`, + ); + + if (!createResponse.ok) { + const errorText = await createResponse.text().catch(() => 'No error text'); + debugLog('createCompetenceListJob', 'POST failed with body:', errorText); + return { success: false, reason: 'network' }; + } + + const createData: CompetenceListJobResponse = await createResponse.json(); + debugLog('createCompetenceListJob', 'Job created:', createData); + + // Poll until complete + const result = await pollJobStatus( + `${API_URL}${COMPETENCE_LIST_PATH}/${createData.jobId}`, + headers, + 'competence list', + MAX_POLL_ATTEMPTS, + (status, attempt) => { + debugLog('createCompetenceListJob', `Status update: ${status} (attempt ${attempt})`); + }, + ); + + if (!result.success) { + debugLog('createCompetenceListJob', 'Polling failed:', result.reason); + return { success: false, reason: result.reason }; + } + + if (!result.data.competenceListId) { + debugLog('createCompetenceListJob', 'No competenceListId in response:', result.data); + return { success: false, reason: 'server-failure' }; + } + + debugLog('createCompetenceListJob', 'Success! competenceListId:', result.data.competenceListId); + return { success: true, competenceListId: result.data.competenceListId }; + } catch (error) { + debugLog('createCompetenceListJob', 'Exception caught:', error); + return { success: false, reason: 'network' }; + } +} + +async function createMatchingJob( + environmentId: string, + competenceListId: string, + task: APITask, +): Promise<{ success: true; result: MatchJobResponse } | { success: false; reason: string }> { + try { + debugLog('createMatchingJob', 'Starting matching job with competenceListId:', competenceListId); + + const headers = await createHeaders(environmentId); + debugLog('createMatchingJob', 'Headers created:', headers); + + const body: MatchBody = { + competenceListId, + tasks: [task], + }; + debugLog('createMatchingJob', 'Request body:', body); + + // POST to create matching job + const createResponse = await fetch(`${API_URL}${MATCH_PATH}`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + + debugLog( + 'createMatchingJob', + `POST response: ${createResponse.status} ${createResponse.statusText}`, + ); + + if (!createResponse.ok) { + const errorText = await createResponse.text().catch(() => 'No error text'); + debugLog('createMatchingJob', 'POST failed with body:', errorText); + return { success: false, reason: 'network' }; + } + + const createData: MatchJobResponse = await createResponse.json(); + debugLog('createMatchingJob', 'Job created:', createData); + + // Poll until complete + const result = await pollJobStatus( + `${API_URL}${MATCH_PATH}/${createData.jobId}`, + headers, + 'matching', + MAX_POLL_ATTEMPTS, + (status, attempt) => { + debugLog('createMatchingJob', `Status update: ${status} (attempt ${attempt})`); + }, + ); + + if (!result.success) { + debugLog('createMatchingJob', 'Polling failed:', result.reason); + return { success: false, reason: result.reason }; + } + + debugLog('createMatchingJob', 'Success! Match result:', result.data); + return { success: true, result: result.data }; + } catch (error) { + debugLog('createMatchingJob', 'Exception caught:', error); + return { success: false, reason: 'network' }; + } +} + +/** + * Step 1: Create or retrieve competence list + * Returns the competence list ID for use in step 2 + */ +export async function createOrGetCompetenceList( + environmentId: string, + taskContext: TaskContextData, + cachedCompetenceListId?: string, +): Promise< + | { success: true; competenceListId: string } + | { success: false; type: 'info' | 'warning' | 'error'; title: string; message: string } +> { + try { + debugLog('createOrGetCompetenceList', '=== Step 1: Creating/Getting Competence List ==='); + + // Format the task + const task = formatUserTaskForAPI(taskContext); + + // Get users with competences + const resources = await fetchUsersWithCompetences(environmentId); + debugLog('createOrGetCompetenceList', `Fetched ${resources.length} resources`); + + // Check if there are any users with competences + if (resources.length === 0) { + return { + success: false, + type: 'info', + title: 'No Competences Available', + message: + 'There are currently no users in this space who have claimed any competences. To use the matching feature, users need to add competences to their profiles first.', + }; + } + + // Create or use cached competence list + if (cachedCompetenceListId) { + debugLog('createOrGetCompetenceList', 'Using cached competence list ID'); + return { success: true, competenceListId: cachedCompetenceListId }; + } + + debugLog('createOrGetCompetenceList', 'Creating new competence list job'); + const listResult = await createCompetenceListJob(environmentId, resources); + + if (!listResult.success) { + debugLog('createOrGetCompetenceList', 'Failed:', listResult.reason); + const error = getUserFriendlyError(listResult.reason, 'competence list'); + if (!error.success) { + return { + success: false, + type: error.type, + title: error.title, + message: error.message, + }; + } + // Should never reach here due to type guard, but satisfy TypeScript + throw new Error('Unexpected error state'); + } + + debugLog('createOrGetCompetenceList', 'Success! ID:', listResult.competenceListId); + return { success: true, competenceListId: listResult.competenceListId }; + } catch (error) { + debugLog('createOrGetCompetenceList', 'Exception:', error); + return { + success: false, + type: 'error', + title: 'Unexpected Error', + message: 'An unexpected error occurred while creating the competence list.', + }; + } +} + +/** + * Step 2: Run matching with the competence list + */ +export async function runMatching( + environmentId: string, + taskContext: TaskContextData, + competenceListId: string, +): Promise { + try { + debugLog('runMatching', '=== Step 2: Running Matching ==='); + debugLog('runMatching', 'Competence list ID:', competenceListId); + + const task = formatUserTaskForAPI(taskContext); + + const matchJobResult = await createMatchingJob(environmentId, competenceListId, task); + + if (!matchJobResult.success) { + debugLog('runMatching', 'Failed:', matchJobResult.reason); + return getUserFriendlyError(matchJobResult.reason, 'matching'); + } + + debugLog('runMatching', 'Success!'); + return { + success: true, + data: { + matchResult: matchJobResult.result, + competenceListId, + }, + }; + } catch (error) { + debugLog('runMatching', 'Exception:', error); + return { + success: false, + type: 'error', + title: 'Unexpected Error', + message: 'An unexpected error occurred during matching.', + }; + } +} + +/** + * Step 1: Create competence list job + * Exported separately to allow UI progress updates between steps + */ +export async function createCompetenceList( + environmentId: string, +): Promise<{ success: true; competenceListId: string } | { success: false; reason: string }> { + try { + const resources = await fetchUsersWithCompetences(environmentId); + + if (resources.length === 0) { + return { success: false, reason: 'no-resources' }; + } + + return await createCompetenceListJob(environmentId, resources); + } catch (error) { + console.error('[createCompetenceList] Error:', error); + return { success: false, reason: 'network' }; + } +} + +/** + * Step 2: Create matching job using a competence list + * Exported separately to allow UI progress updates between steps + */ +export async function createMatching( + environmentId: string, + taskContext: TaskContextData, + competenceListId: string, +): Promise<{ success: true; result: MatchJobResponse } | { success: false; reason: string }> { + try { + const task = formatUserTaskForAPI(taskContext); + return await createMatchingJob(environmentId, competenceListId, task); + } catch (error) { + console.error('[createMatching] Error:', error); + return { success: false, reason: 'network' }; + } +} + +/** + * Main function to get competence matches for a user task + * Returns a result that can be either successful with data or a user-friendly message + * + * @param environmentId - The environment/space ID + * @param taskContext - Plain object with task context (processName, taskName, etc.) + * @param cachedCompetenceListId - Optional cached competence list ID to skip recreation + */ +export async function getCompetenceMatches( + environmentId: string, + taskContext: TaskContextData, + cachedCompetenceListId?: string, +): Promise { + try { + debugLog('getCompetenceMatches', '=== Starting competence matching ==='); + debugLog('getCompetenceMatches', 'Environment ID:', environmentId); + debugLog('getCompetenceMatches', 'Task context:', taskContext); + debugLog('getCompetenceMatches', 'Cached competence list ID:', cachedCompetenceListId); + + // Format the task + const task = formatUserTaskForAPI(taskContext); + debugLog('getCompetenceMatches', 'Formatted task for API:', task); + + // Get users with competences + const resources = await fetchUsersWithCompetences(environmentId); + debugLog( + 'getCompetenceMatches', + `Fetched ${resources.length} resources (users with competences)`, + ); + debugLog('getCompetenceMatches', 'Resources:', resources); + + // Check if there are any users with competences + if (resources.length === 0) { + debugLog('getCompetenceMatches', 'No resources found - returning info message'); + return { + success: false, + type: 'info', + title: 'No Competences Available', + message: + 'There are currently no users in this space who have claimed any competences. To use the matching feature, users need to add competences to their profiles first.', + }; + } + + // Create or use cached competence list + let competenceListId: string; + + if (cachedCompetenceListId) { + debugLog('getCompetenceMatches', 'Using cached competence list ID'); + competenceListId = cachedCompetenceListId; + } else { + debugLog('getCompetenceMatches', 'No cache - creating new competence list job'); + const listResult = await createCompetenceListJob(environmentId, resources); + + if (!listResult.success) { + debugLog('getCompetenceMatches', 'Competence list job failed:', listResult.reason); + return getUserFriendlyError(listResult.reason, 'competence list'); + } + + competenceListId = listResult.competenceListId; + debugLog('getCompetenceMatches', 'Competence list created with ID:', competenceListId); + } + + // Create matching job + debugLog('getCompetenceMatches', 'Creating matching job...'); + const matchJobResult = await createMatchingJob(environmentId, competenceListId, task); + + if (!matchJobResult.success) { + debugLog('getCompetenceMatches', 'Matching job failed:', matchJobResult.reason); + return getUserFriendlyError(matchJobResult.reason, 'matching'); + } + + debugLog('getCompetenceMatches', '=== Matching completed successfully ==='); + debugLog('getCompetenceMatches', 'Final result:', matchJobResult.result); + + return { + success: true, + data: { + matchResult: matchJobResult.result, + competenceListId, + }, + }; + } catch (error) { + debugLog('getCompetenceMatches', 'Exception in getCompetenceMatches:', error); + return { + success: false, + type: 'error', + title: 'Unexpected Error', + message: 'An unexpected error occurred while trying to find matches. Please try again later.', + }; + } +} + +function getUserFriendlyError( + reason: string, + stage: 'competence list' | 'matching', +): MatchingResult { + switch (reason) { + case 'network': + return { + success: false, + type: 'warning', + title: 'Connection Issue', + message: + 'Unable to connect to the matching service. Please check your internet connection and try again.', + }; + + case 'timeout': + return { + success: false, + type: 'warning', + title: 'Request Timed Out', + message: + 'The matching service is taking longer than expected. This might be due to high server load. Please try again in a few moments.', + }; + + case 'server-failure': + return { + success: false, + type: 'error', + title: 'Service Error', + message: + 'The matching service encountered an error while processing your request. Please try again later.', + }; + + default: + return { + success: false, + type: 'error', + title: 'Unknown Error', + message: 'An unknown error occurred. Please try again later.', + }; + } +} + +/** + * Transforms match results and enriches with user information + */ +export async function transformMatchResults( + matchResult: MatchJobResponse, + environmentId: string, + competences: Map, +): Promise { + if (!matchResult.resourceRanking || matchResult.resourceRanking.length === 0) { + return []; + } + + // Get user information + const { activeEnvironment } = await getCurrentEnvironment(environmentId); + const users = await getUsersInSpace(activeEnvironment.spaceId); + + // Create user lookup map + const userMap = new Map( + users.map((user) => [ + user.id, + { + userName: user.isGuest + ? 'Guest User' + : `${user.firstName || ''} ${user.lastName || ''}`.trim() || user.username || user.id, + userEmail: user.isGuest ? null : user.email || null, + }, + ]), + ); + + // Transform and sort by score + const rankedUsers: RankedUser[] = matchResult.resourceRanking + .map((resource) => { + // Debug: Log the resource object to see actual structure + // console.log('[transformMatchResults] Processing resource:', { + // resourceId: resource.resourceId, + // avgTaskMatchProbability: resource.avgTaskMatchProbability, + // avgBestFitTaskMatchProbability: resource.avgBestFitTaskMatchProbability, + // contradicting: resource.contradicting, + // taskMatchings: resource.taskMatchings, + // }); + + const userInfo = userMap.get(resource.resourceId) || { + userName: resource.resourceId, + userEmail: null, + }; + + // Get competence matches from first task (we only match one task) + const taskMatching = resource.taskMatchings[0]; + const competenceMatches: CompetenceMatch[] = taskMatching + ? taskMatching.competenceMatchings.map((cm) => { + // Check if this is the overall competence + const isOverall = cm.competenceId.startsWith('__OVERALL__'); + + const competenceInfo = competences.get(cm.competenceId); + const competenceName = isOverall + ? 'Overall Competence Profile' + : competenceInfo?.name || cm.competenceId; + const competenceDescription = isOverall + ? 'Combined assessment of all competences together' + : competenceInfo?.description || ''; + const reasons = cm.matchings.map((m) => m.reason).filter(Boolean); + + // Find worst alignment (contradicting > neutral > aligning) + const alignments = cm.matchings.map((m) => m.alignment); + let worstAlignment: 'aligning' | 'neutral' | 'contradicting' | undefined; + if (alignments.includes('contradicting')) { + worstAlignment = 'contradicting'; + } else if (alignments.includes('neutral')) { + worstAlignment = 'neutral'; + } else if (alignments.includes('aligning')) { + worstAlignment = 'aligning'; + } + + return { + competenceId: cm.competenceId, + competenceName, + competenceDescription, + score: Math.round(cm.avgMatchProbability * 100), + bestFitScore: Math.round(cm.avgBestFitMatchProbability * 100), + reasons, + alignment: worstAlignment, + }; + }) + : []; + + const userScore = Math.round(resource.avgTaskMatchProbability * 100); + const userBestFitScore = Math.round(resource.avgBestFitTaskMatchProbability * 100); + + // console.log('[transformMatchResults] Calculated scores:', { + // resourceId: resource.resourceId, + // rawAvgTaskMatchProbability: resource.avgTaskMatchProbability, + // rawAvgBestFitTaskMatchProbability: resource.avgBestFitTaskMatchProbability, + // score: userScore, + // bestFitScore: userBestFitScore, + // }); + + return { + userId: resource.resourceId, + userName: userInfo.userName, + userEmail: userInfo.userEmail, + score: userScore, + bestFitScore: userBestFitScore, + competenceMatches: competenceMatches.sort((a, b) => b.score - a.score), + contradicting: resource.contradicting, + }; + }) + .sort((a, b) => b.score - a.score); // Sort by score descending + + return rankedUsers; +} diff --git a/src/management-system-v2/components/competence/actions/match-constants.ts b/src/management-system-v2/components/competence/actions/match-constants.ts new file mode 100644 index 000000000..60b5f1b9f --- /dev/null +++ b/src/management-system-v2/components/competence/actions/match-constants.ts @@ -0,0 +1,10 @@ +/** + * Constants for competence matching + */ + +/* Score Color Thresholds */ +export const SCORE_THRESHOLDS = { + HIGH: 45, // >= -> green + MEDIUM: 20, // >= -> orange + // < -> red +} as const; diff --git a/src/management-system-v2/components/competence/actions/match-types.ts b/src/management-system-v2/components/competence/actions/match-types.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/management-system-v2/components/competence/actions/organization-competence-actions.ts b/src/management-system-v2/components/competence/actions/organization-competence-actions.ts new file mode 100644 index 000000000..9f429b5b6 --- /dev/null +++ b/src/management-system-v2/components/competence/actions/organization-competence-actions.ts @@ -0,0 +1,143 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { + getAllSpaceCompetences, + addSpaceCompetence, + updateSpaceCompetence, + deleteSpaceCompetence, + unclaimSpaceCompetenceForAllUsers, + getAllCompetencesOfUser, +} from '@/lib/data/db/competence'; +import { getUsersInSpace } from '@/lib/data/db/iam/memberships'; + +type ActionResult = { success: true; data?: T } | { success: false; message: string }; + +/** + * Gets all space competences for an organization with claimed user information + */ +export async function getOrganizationSpaceCompetences( + spaceId: string, +): Promise>>> { + try { + // TODO: Add authorization check - can('view', 'Competence') or can('manage', 'User') + const competences = await getAllSpaceCompetences(spaceId); + return { success: true, data: competences }; + } catch (error) { + console.error('Error fetching space competences:', error); + return { success: false, message: 'Failed to fetch space competences' }; + } +} + +/** + * Gets all members of an organization + */ +export async function getOrganizationMembers( + spaceId: string, +): Promise>>> { + try { + // TODO: Add authorization check - can('view', 'User') or can('manage', 'User') + const users = await getUsersInSpace(spaceId); + return { success: true, data: users }; + } catch (error) { + console.error('Error fetching organization members:', error); + return { success: false, message: 'Failed to fetch organization members' }; + } +} + +/** + * Gets all competences for a specific user + */ +export async function getUserCompetences( + userId: string, +): Promise>>> { + try { + // TODO: Add authorization check - can('view', 'User') or can('manage', 'User') + const competences = await getAllCompetencesOfUser(userId); + return { success: true, data: competences }; + } catch (error) { + console.error('Error fetching user competences:', error); + return { success: false, message: 'Failed to fetch user competences' }; + } +} + +/** + * Creates a new space competence + */ +export async function createOrganizationSpaceCompetence(data: { + spaceId: string; + creatorUserId: string; + name: string; + description: string; + externalQualificationNeeded: boolean; + renewalTimeInterval: number | null; +}): Promise>>> { + try { + // TODO: Add authorization check - can('manage', 'Competence') or admin role + const competence = await addSpaceCompetence(data.spaceId, data.creatorUserId, { + name: data.name, + description: data.description, + externalQualificationNeeded: data.externalQualificationNeeded, + renewalTimeInterval: data.renewalTimeInterval, + }); + revalidatePath(`/${data.spaceId}/iam/competences`); + return { success: true, data: competence }; + } catch (error) { + console.error('Error creating space competence:', error); + return { success: false, message: 'Failed to create space competence' }; + } +} + +/** + * Updates a space competence, optionally unclaiming it for all users + */ +export async function updateOrganizationSpaceCompetence(data: { + competenceId: string; + spaceId: string; + name?: string; + description?: string; + externalQualificationNeeded?: boolean; + renewalTimeInterval?: number | null; + unclaimForAllUsers: boolean; +}): Promise { + try { + // TODO: Add authorization check - can('manage', 'Competence') or admin role + + // If user chose to unclaim for all users, do that first + if (data.unclaimForAllUsers) { + await unclaimSpaceCompetenceForAllUsers(data.competenceId); + } + + // Update the competence + await updateSpaceCompetence(data.competenceId, { + name: data.name, + description: data.description, + externalQualificationNeeded: data.externalQualificationNeeded, + renewalTimeInterval: data.renewalTimeInterval, + }); + + revalidatePath(`/${data.spaceId}/iam/competences`); + return { success: true }; + } catch (error) { + console.error('Error updating space competence:', error); + return { success: false, message: 'Failed to update space competence' }; + } +} + +/** + * Deletes a space competence (automatically unclaims it for all users via cascade) + */ +export async function deleteOrganizationSpaceCompetence(data: { + competenceId: string; + spaceId: string; +}): Promise { + try { + // TODO: Add authorization check - can('manage', 'Competence') or admin role + await deleteSpaceCompetence(data.competenceId); + revalidatePath(`/${data.spaceId}/iam/competences`); + return { success: true }; + } catch (error) { + console.error('Error deleting space competence:', error); + return { success: false, message: 'Failed to delete space competence' }; + } +} diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/potentialOwner-server-action.ts b/src/management-system-v2/components/competence/actions/potentialOwner-server-action.ts similarity index 100% rename from src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/potentialOwner-server-action.ts rename to src/management-system-v2/components/competence/actions/potentialOwner-server-action.ts diff --git a/src/management-system-v2/components/competence/actions/user-competence-actions.ts b/src/management-system-v2/components/competence/actions/user-competence-actions.ts new file mode 100644 index 000000000..e949326d8 --- /dev/null +++ b/src/management-system-v2/components/competence/actions/user-competence-actions.ts @@ -0,0 +1,208 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { + addUserCompetence, + updateUserCompetence as dbUpdateUserCompetence, + deleteUserCompetence as dbDeleteUserCompetence, + claimSpaceCompetence as dbClaimSpaceCompetence, + unclaimSpaceCompetence as dbUnclaimSpaceCompetence, + updateSpaceCompetence, + getAllCompetencesOfUser, +} from '@/lib/data/db/competence'; + +type ActionResult = + | { success: true; data: T } + | { success: false; message: string; error?: any }; + +/** + * Creates a new user competence (type: USER) + */ +export async function createUserCompetence(data: { + userId: string; + name: string; + description?: string | null; + proficiency: string; + qualificationDate: Date | null; + lastUsage: Date | null; +}): Promise { + try { + // TODO: Add ability checks here when authorization is implemented + // if (!can('create', 'UserCompetence')) throw new Error('Unauthorized'); + + const userCompetence = await addUserCompetence( + data.userId, + { + name: data.name, + description: data.description || '', + externalQualificationNeeded: false, + renewalTimeInterval: null, + }, + { + proficiency: data.proficiency, + qualificationDate: data.qualificationDate, + lastUsage: data.lastUsage, + }, + ); + + revalidatePath('/user-competence'); + + return { success: true, data: userCompetence }; + } catch (error) { + console.error('Failed to create user competence:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to create competence', + error, + }; + } +} + +/** + * Updates an existing user competence + */ +export async function updateUserCompetence(data: { + userId: string; + competenceId: string; + name?: string; + description?: string | null; + proficiency: string; + qualificationDate: Date | null; + lastUsage: Date | null; +}): Promise { + try { + // TODO: Add ability checks here + // if (!can('update', { UserCompetence: { userId: data.userId } })) throw new Error('Unauthorized'); + + // Update the competence record (name, description) if provided (for USER type only) + if (data.name !== undefined || data.description !== undefined) { + await updateSpaceCompetence(data.competenceId, { + ...(data.name !== undefined && { name: data.name }), + ...(data.description !== undefined && { description: data.description || '' }), + }); + } + + // Update the user competence link (proficiency, dates) + const userCompetence = await dbUpdateUserCompetence(data.userId, data.competenceId, { + proficiency: data.proficiency, + qualificationDate: data.qualificationDate, + lastUsage: data.lastUsage, + }); + + revalidatePath('/user-competence'); + + return { success: true, data: userCompetence }; + } catch (error) { + console.error('Failed to update user competence:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to update competence', + error, + }; + } +} + +/** + * Deletes a user competence (only USER type competences can be deleted) + */ +export async function deleteUserCompetence(data: { + userId: string; + competenceId: string; +}): Promise { + try { + // TODO: Add ability checks here + // if (!can('delete', { UserCompetence: { userId: data.userId } })) throw new Error('Unauthorized'); + + const deletedCompetence = await dbDeleteUserCompetence(data.userId, data.competenceId); + + revalidatePath('/user-competence'); + + return { success: true, data: deletedCompetence }; + } catch (error) { + console.error('Failed to delete user competence:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to delete competence', + error, + }; + } +} + +/** + * Claims a space competence for the user + */ +export async function claimSpaceCompetence(data: { + userId: string; + competenceId: string; + proficiency: string; + qualificationDate: Date | null; + lastUsage: Date | null; +}): Promise { + try { + // TODO: Add ability checks here + // if (!can('create', 'UserCompetence')) throw new Error('Unauthorized'); + + const userCompetence = await dbClaimSpaceCompetence(data.userId, data.competenceId, { + proficiency: data.proficiency, + qualificationDate: data.qualificationDate, + lastUsage: data.lastUsage, + }); + + revalidatePath('/user-competence'); + + return { success: true, data: userCompetence }; + } catch (error) { + console.error('Failed to claim space competence:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to claim competence', + error, + }; + } +} + +/** + * Unclaims a space competence for the user + */ +export async function unclaimSpaceCompetence(data: { + userId: string; + competenceId: string; +}): Promise { + try { + // TODO: Add ability checks here + // if (!can('delete', { UserCompetence: { userId: data.userId } })) throw new Error('Unauthorized'); + + const unclaimedCompetence = await dbUnclaimSpaceCompetence(data.userId, data.competenceId); + + revalidatePath('/user-competence'); + + return { success: true, data: unclaimedCompetence }; + } catch (error) { + console.error('Failed to unclaim space competence:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to unclaim competence', + error, + }; + } +} + +/** + * Gets all competences for a user + */ +export async function getUserCompetences( + userId: string, +): Promise>>> { + try { + // TODO: Add ability checks here + const competences = await getAllCompetencesOfUser(userId); + return { success: true, data: competences }; + } catch (error) { + console.error('Failed to fetch user competences:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to fetch competences', + error, + }; + } +} diff --git a/src/management-system-v2/components/competence/organization/competences-dashboard.tsx b/src/management-system-v2/components/competence/organization/competences-dashboard.tsx new file mode 100644 index 000000000..21bd9a8b6 --- /dev/null +++ b/src/management-system-v2/components/competence/organization/competences-dashboard.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { Tabs } from 'antd'; +import { TrophyOutlined, TeamOutlined } from '@ant-design/icons'; +import SpaceCompetencesManagement from './space-competences-management'; +import UserCompetencesOverview from './user-competences-overview'; +import { SpaceCompetence } from '@/lib/data/competence-schema'; +import { User } from '@/lib/data/user-schema'; + +type CompetencesDashboardProps = { + spaceId: string; + initialSpaceCompetences: SpaceCompetence[]; + organizationMembers: User[]; + currentUserId: string; +}; + +const CompetencesDashboard: React.FC = ({ + spaceId, + initialSpaceCompetences, + organizationMembers, + currentUserId, +}) => { + const items = [ + { + key: 'space-competences', + label: ( + + + Space Competences + + ), + children: ( + + ), + }, + { + key: 'user-competences', + label: ( + + + User Competences + + ), + children: ( + + ), + }, + ]; + + return ; +}; + +export default CompetencesDashboard; diff --git a/src/management-system-v2/components/competence/organization/space-competence-form-modal.tsx b/src/management-system-v2/components/competence/organization/space-competence-form-modal.tsx new file mode 100644 index 000000000..8091f71d4 --- /dev/null +++ b/src/management-system-v2/components/competence/organization/space-competence-form-modal.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Modal, Form, Input, Checkbox, InputNumber, Radio, Alert } from 'antd'; +import { SpaceCompetence } from '@/lib/data/competence-schema'; + +const { TextArea } = Input; + +type SpaceCompetenceFormModalProps = { + open: boolean; + onCancel: () => void; + onSubmit: (values: any) => void; + editingCompetence: SpaceCompetence | null; + loading: boolean; +}; + +const SpaceCompetenceFormModal: React.FC = ({ + open, + onCancel, + onSubmit, + editingCompetence, + loading, +}) => { + const [form] = Form.useForm(); + const [unclaimDecisionMade, setUnclaimDecisionMade] = useState(false); + + const hasClaimedUsers = editingCompetence && editingCompetence.claimedBy.length > 0; + + useEffect(() => { + if (open) { + if (editingCompetence) { + form.setFieldsValue({ + name: editingCompetence.name, + description: editingCompetence.description, + externalQualificationNeeded: editingCompetence.externalQualificationNeeded, + renewalTimeInterval: editingCompetence.renewalTimeInterval, + unclaimForAllUsers: undefined, // No default selection + }); + setUnclaimDecisionMade(false); + } else { + form.resetFields(); + setUnclaimDecisionMade(true); // Not needed for create + } + } + }, [open, editingCompetence, form]); + + const handleOk = async () => { + try { + const values = await form.validateFields(); + onSubmit(values); + } catch (error) { + // Validation failed + } + }; + + return ( + +
+ {hasClaimedUsers && ( + 1 ? 's' : ''}`} + type="warning" + style={{ marginBottom: 16 }} + /> + )} + + + + + + +