2026-05-20 10:59:17 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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<string, { key: string, type: string, label: string, sortOrder: number, children: Map<string, object> }>} */
|
|
|
|
|
|
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')
|
|
|
|
|
|
})
|
2026-05-20 11:18:34 +02:00
|
|
|
|
.map((cat) => ({
|
|
|
|
|
|
key: cat.key,
|
|
|
|
|
|
type: cat.type,
|
|
|
|
|
|
label: cat.label,
|
|
|
|
|
|
children: [...cat.skills].sort(bySortThenName).map((skill) => ({
|
2026-05-20 10:59:17 +02:00
|
|
|
|
key: `s-${skill.id}`,
|
|
|
|
|
|
type: 'skill',
|
|
|
|
|
|
label: (skill.name || '').trim() || `#${skill.id}`,
|
|
|
|
|
|
skillId: Number(skill.id),
|
|
|
|
|
|
skill,
|
2026-05-20 11:18:34 +02:00
|
|
|
|
})),
|
|
|
|
|
|
})),
|
2026-05-20 10:59:17 +02:00
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Alle wählbaren Blätter mit Pfad-Label (für Suche / Chips).
|
|
|
|
|
|
* @param {ReturnType<typeof buildSkillCatalogTree>} tree
|
|
|
|
|
|
* @param {Set<number>|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) {
|
2026-05-20 11:18:34 +02:00
|
|
|
|
walk(n.children, [...pathParts, n.label])
|
2026-05-20 10:59:17 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
walk(tree, [])
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @param {ReturnType<typeof buildSkillCatalogTree>} 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 11:18:34 +02:00
|
|
|
|
/** Alle aufklappbaren Knoten (Hauptgruppe und Kategorie). */
|
2026-05-20 10:59:17 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-05-20 11:13:34 +02:00
|
|
|
|
|
2026-05-20 11:21:18 +02:00
|
|
|
|
/** Standard: nur Hauptgruppe aufgeklappt; Kategorien und Fähigkeiten zugeklappt. */
|
2026-05-20 11:13:34 +02:00
|
|
|
|
export function defaultExpandedKeysForSkillTree(tree) {
|
2026-05-20 11:21:18 +02:00
|
|
|
|
if (!Array.isArray(tree)) return []
|
|
|
|
|
|
return tree.filter((n) => n.type === 'main').map((n) => n.key)
|
2026-05-20 11:13:34 +02:00
|
|
|
|
}
|
2026-05-20 11:16:49 +02:00
|
|
|
|
|
|
|
|
|
|
/** 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
|
|
|
|
|
|
}
|