/** * Hierarchie Fähigkeiten-Katalog: Hauptgruppe → Kategorie → Fähigkeit. * Datenbasis: GET /api/skills/catalog (catalog_* Felder). */ export function bySortThenName(a, b) { const sa = a.sort_order != null ? Number(a.sort_order) : 99999 const sb = b.sort_order != null ? Number(b.sort_order) : 99999 if (sa !== sb) return sa - sb return String(a.name || a.label || '').localeCompare(String(b.name || b.label || ''), 'de') } /** @param {object} skill */ export function skillCatalogPathLabel(skill) { if (!skill || typeof skill !== 'object') return '' const parts = [] const main = (skill.catalog_main_category_name || '').trim() const cat = (skill.catalog_category_name || '').trim() if (main) parts.push(main) if (cat) parts.push(cat) parts.push((skill.name || '').trim() || `#${skill.id}`) return parts.join(' › ') } /** * @param {object[]} skills * @returns {Array<{ key: string, type: string, label: string, skillId?: number, skill?: object, children?: object[] }>} */ export function buildSkillCatalogTree(skills) { if (!Array.isArray(skills) || skills.length === 0) return [] /** @type {Map }>} */ const mainMap = new Map() for (const skill of skills) { const mainId = skill.main_category_id ?? null const mainKey = mainId != null ? `m-${mainId}` : 'm-none' const mainLabel = (skill.catalog_main_category_name || '').trim() || 'Ohne Hauptgruppe' const mainSort = Number(skill.catalog_main_sort ?? 99999) if (!mainMap.has(mainKey)) { mainMap.set(mainKey, { key: mainKey, type: 'main', label: mainLabel, sortOrder: mainSort, children: new Map(), }) } const mainNode = mainMap.get(mainKey) const catId = skill.category_id ?? null const catKey = catId != null ? `c-${catId}` : 'c-none' const catLabel = (skill.catalog_category_name || '').trim() || 'Ohne Kategorie' const catSort = Number(skill.catalog_category_sort ?? 99999) if (!mainNode.children.has(catKey)) { mainNode.children.set(catKey, { key: `${mainKey}-${catKey}`, type: 'category', label: catLabel, sortOrder: catSort, skills: [], }) } mainNode.children.get(catKey).skills.push(skill) } return [...mainMap.values()] .sort((a, b) => { if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder return a.label.localeCompare(b.label, 'de') }) .map((main) => ({ key: main.key, type: main.type, label: main.label, children: [...main.children.values()] .sort((a, b) => { if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder return a.label.localeCompare(b.label, 'de') }) .map((cat) => ({ key: cat.key, type: cat.type, label: cat.label, children: [...cat.skills].sort(bySortThenName).map((skill) => ({ key: `s-${skill.id}`, type: 'skill', label: (skill.name || '').trim() || `#${skill.id}`, skillId: Number(skill.id), skill, })), })), })) } /** * Alle wählbaren Blätter mit Pfad-Label (für Suche / Chips). * @param {ReturnType} tree * @param {Set|number[]} [excludeIds] */ export function collectSkillLeavesFromTree(tree, excludeIds) { const exclude = new Set( (excludeIds instanceof Set ? [...excludeIds] : excludeIds || []) .map((x) => Number(x)) .filter((n) => Number.isFinite(n)) ) /** @type {Array<{ id: number, label: string, pathLabel: string, skill: object }>} */ const out = [] const walk = (nodes, pathParts) => { for (const n of nodes) { if (n.type === 'skill' && n.skillId != null) { if (!exclude.has(n.skillId)) { const pathLabel = [...pathParts, n.label].join(' › ') out.push({ id: n.skillId, label: n.label, pathLabel, skill: n.skill }) } } else if (n.children?.length) { walk(n.children, [...pathParts, n.label]) } } } walk(tree, []) return out } /** @param {ReturnType} tree */ export function filterSkillTreeByQuery(tree, query) { const q = String(query || '').trim().toLowerCase() if (!q) return tree const filterNodes = (nodes) => { /** @type {typeof nodes} */ const result = [] for (const n of nodes) { if (n.type === 'skill') { const hay = `${n.label} ${skillCatalogPathLabel(n.skill)}`.toLowerCase() if (hay.includes(q)) result.push(n) } else { const kids = filterNodes(n.children || []) if (kids.length) result.push({ ...n, children: kids }) } } return result } return filterNodes(tree) } /** Alle aufklappbaren Knoten (Hauptgruppe und Kategorie). */ export function allExpandableKeys(tree) { const keys = [] const walk = (nodes) => { for (const n of nodes) { if (n.type !== 'skill' && n.children?.length) { keys.push(n.key) walk(n.children) } } } walk(tree) return keys } /** Standard: nur Hauptgruppe aufgeklappt; Kategorien und Fähigkeiten zugeklappt. */ export function defaultExpandedKeysForSkillTree(tree) { if (!Array.isArray(tree)) return [] return tree.filter((n) => n.type === 'main').map((n) => n.key) } /** Sentinel für Katalog-Admin: keine Hauptgruppe gewählt. */ export const SKILL_MAIN_UNASSIGNED_KEY = '__unassigned_main__' /** Sentinel für Katalog-Admin: keine Kategorie gewählt. */ export const SKILL_CATEGORY_UNASSIGNED_KEY = '__unassigned_category__' export const SKILL_UNASSIGNED_MAIN_LABEL = 'Ohne Hauptgruppe' export const SKILL_UNASSIGNED_CATEGORY_LABEL = 'Ohne Kategorie' /** * Fähigkeiten für die Admin-Katalog-Auswahl (Hauptgruppe + Kategorie-Slot). * @param {object[]} skills * @param {number|string|null} mainId * @param {number|string|null} categoryId */ export function catalogSkillsForSelection(skills, mainId, categoryId) { if (!Array.isArray(skills) || categoryId == null) return [] if (categoryId === SKILL_CATEGORY_UNASSIGNED_KEY) { if (mainId === SKILL_MAIN_UNASSIGNED_KEY) { return skills.filter((s) => s.main_category_id == null && s.category_id == null) } if (typeof mainId === 'number') { return skills.filter((s) => s.main_category_id === mainId && s.category_id == null) } return [] } if (typeof categoryId === 'number') { return skills.filter((s) => s.category_id === categoryId) } return [] } /** @param {object[]} skills */ export function countCatalogSkillsWithoutMain(skills) { if (!Array.isArray(skills)) return 0 return skills.filter((s) => s.main_category_id == null).length } /** @param {object[]} skills @param {number|string|null} mainId */ export function countCatalogSkillsWithoutCategory(skills, mainId) { if (!Array.isArray(skills)) return 0 if (mainId === SKILL_MAIN_UNASSIGNED_KEY) { return skills.filter((s) => s.main_category_id == null && s.category_id == null).length } if (typeof mainId === 'number') { return skills.filter((s) => s.main_category_id === mainId && s.category_id == null).length } return 0 }