parent
9b1ad1e632
commit
c4b1f64463
@ -0,0 +1,84 @@ |
||||
<script setup> |
||||
import { computed, reactive, unref } from "vue" |
||||
import { useI18n } from "vue-i18n" |
||||
|
||||
import BaseTextArea from "../basecomponents/BaseTextArea.vue" |
||||
import BaseButton from "../basecomponents/BaseButton.vue" |
||||
import BaseDialog from "../basecomponents/BaseDialog.vue" |
||||
import BaseInputText from "../basecomponents/BaseInputText.vue" |
||||
|
||||
import { createProfile, updateProfile } from "../../services/skillProfileService" |
||||
|
||||
const { t } = useI18n() |
||||
|
||||
const profile = reactive({ |
||||
title: "", |
||||
description: "", |
||||
}) |
||||
|
||||
const isVisible = defineModel("visible", { |
||||
required: true, |
||||
type: Boolean, |
||||
}) |
||||
|
||||
const skills = defineModel("skills", { |
||||
type: Array, |
||||
required: false, |
||||
}) |
||||
|
||||
const emit = defineEmits(["saved"]) |
||||
|
||||
async function saveProfile() { |
||||
if (profile["@id"]) { |
||||
await updateProfile({ |
||||
iri: profile["@id"], |
||||
title: profile.title, |
||||
description: profile.description, |
||||
}) |
||||
} else { |
||||
await createProfile({ |
||||
title: profile.title, |
||||
description: profile.description, |
||||
skills: skills.value.map((skill) => ({ skill: skill["@id"] })), |
||||
}) |
||||
} |
||||
|
||||
isVisible.value = false |
||||
|
||||
emit("saved", unref(profile)) |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<BaseDialog |
||||
:title="t('Skill profile')" |
||||
v-model:is-visible="isVisible" |
||||
> |
||||
<BaseInputText |
||||
id="name_profile" |
||||
v-model="profile.title" |
||||
:label="t('Title')" |
||||
/> |
||||
<BaseTextArea |
||||
v-model="profile.description" |
||||
:label="t('Description')" |
||||
/> |
||||
|
||||
<template #footer> |
||||
<BaseButton |
||||
:label="t('Cancel')" |
||||
icon="close" |
||||
type="black" |
||||
@click="isVisible = false" |
||||
/> |
||||
<BaseButton |
||||
:label="t('Save')" |
||||
icon="save" |
||||
type="primary" |
||||
@click="saveProfile" |
||||
/> |
||||
|
||||
<pre>{{ skillIdList }}</pre> |
||||
</template> |
||||
</BaseDialog> |
||||
</template> |
||||
@ -0,0 +1,47 @@ |
||||
<script setup> |
||||
import { ref } from "vue" |
||||
|
||||
import Skeleton from "primevue/skeleton" |
||||
|
||||
import { useNotification } from "../../composables/notification" |
||||
|
||||
import * as skillProfileService from "../../services/skillProfileService" |
||||
|
||||
const { showErrorNotification } = useNotification() |
||||
|
||||
const containerEl = ref() |
||||
const isLoading = ref(false) |
||||
|
||||
/** |
||||
* @param {Array<Object>} skills |
||||
* @returns {Promise<void>} |
||||
*/ |
||||
async function searchProfileMatches(skills) { |
||||
isLoading.value = true |
||||
|
||||
const skillIdList = skills.map((skill) => skill.id) |
||||
|
||||
try { |
||||
containerEl.value.innerHTML = await skillProfileService.matchProfiles(skillIdList) |
||||
} catch (e) { |
||||
showErrorNotification(e) |
||||
} finally { |
||||
isLoading.value = false |
||||
} |
||||
} |
||||
|
||||
defineExpose({ |
||||
searchProfileMatches, |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<Skeleton |
||||
v-if="isLoading" |
||||
height="10rem" |
||||
/> |
||||
<div |
||||
v-show="!isLoading" |
||||
ref="containerEl" |
||||
/> |
||||
</template> |
||||
@ -0,0 +1,30 @@ |
||||
<script setup> |
||||
import { onMounted } from "vue" |
||||
|
||||
import Skeleton from "primevue/skeleton" |
||||
|
||||
import { useSkillWheel } from "../../composables/skill/skillWheel" |
||||
|
||||
const { wheelContainer, isLoading, loadSkills, showRoot, showSkill } = useSkillWheel() |
||||
|
||||
defineExpose({ |
||||
showRoot, |
||||
showSkill, |
||||
}) |
||||
|
||||
onMounted(() => { |
||||
loadSkills() |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="aspect-square"> |
||||
<div ref="wheelContainer" /> |
||||
|
||||
<Skeleton |
||||
shape="circle" |
||||
v-if="isLoading" |
||||
size="100%" |
||||
/> |
||||
</div> |
||||
</template> |
||||
@ -0,0 +1,132 @@ |
||||
<script setup> |
||||
import { onMounted, ref } from "vue" |
||||
import { useI18n } from "vue-i18n" |
||||
|
||||
import BaseCard from "../basecomponents/BaseCard.vue" |
||||
import BaseButton from "../basecomponents/BaseButton.vue" |
||||
import Skeleton from "primevue/skeleton" |
||||
|
||||
import * as skillProfileService from "../../services/skillProfileService" |
||||
|
||||
import { useNotification } from "../../composables/notification" |
||||
import { useConfirm } from "primevue/useconfirm" |
||||
|
||||
const { t } = useI18n() |
||||
const { showErrorNotification, showSuccessNotification } = useNotification() |
||||
const confirm = useConfirm() |
||||
|
||||
const isLoading = ref(true) |
||||
|
||||
const profileList = ref([]) |
||||
|
||||
async function loadProfiles() { |
||||
try { |
||||
isLoading.value = true |
||||
|
||||
const { items } = await skillProfileService.findAll() |
||||
|
||||
profileList.value = items |
||||
} catch (e) { |
||||
showErrorNotification(e) |
||||
} finally { |
||||
isLoading.value = false |
||||
} |
||||
} |
||||
|
||||
defineExpose({ |
||||
loadProfiles, |
||||
}) |
||||
|
||||
onMounted(() => { |
||||
loadProfiles() |
||||
}) |
||||
|
||||
async function onClickDeleteProfile(profile) { |
||||
confirm.require({ |
||||
message: t('Are you sure you want to delete "%s"?', [profile.title]), |
||||
header: t("Delete skill profile"), |
||||
icon: "mdi mdi-alert", |
||||
async accept() { |
||||
if (!profile) { |
||||
return |
||||
} |
||||
|
||||
try { |
||||
isLoading.value = true |
||||
|
||||
await skillProfileService.deleteProfile(profile["@id"]) |
||||
|
||||
showSuccessNotification(t("Skill profile deleted")) |
||||
} catch (e) { |
||||
showErrorNotification(e) |
||||
} finally { |
||||
isLoading.value = false |
||||
} |
||||
|
||||
await loadProfiles() |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
const emit = defineEmits(["searchProfile"]) |
||||
</script> |
||||
|
||||
<template> |
||||
<BaseCard> |
||||
<template #title>{{ t("Skill profiles") }}</template> |
||||
|
||||
<div |
||||
v-if="isLoading" |
||||
class="space-y-2" |
||||
> |
||||
<div |
||||
v-for="v in 3" |
||||
:key="v" |
||||
class="flex flex-row gap-2 items-center" |
||||
> |
||||
<Skeleton |
||||
class="mr-auto" |
||||
width="10rem" |
||||
/> |
||||
<Skeleton size="2.5rem" /> |
||||
<Skeleton size="2.5rem" /> |
||||
</div> |
||||
</div> |
||||
|
||||
<ul |
||||
v-else-if="profileList.length > 0" |
||||
class="space-y-2" |
||||
> |
||||
<li |
||||
v-for="(profile, i) in profileList" |
||||
:key="i" |
||||
class="flex flex-row gap-2 items-center" |
||||
> |
||||
<span |
||||
class="mr-auto" |
||||
v-text="profile.title" |
||||
/> |
||||
|
||||
<BaseButton |
||||
:label="t('Search')" |
||||
icon="search" |
||||
only-icon |
||||
type="black" |
||||
@click="emit('searchProfile', profile)" |
||||
/> |
||||
<BaseButton |
||||
:label="t('Delete')" |
||||
icon="delete" |
||||
only-icon |
||||
type="danger" |
||||
@click="onClickDeleteProfile(profile)" |
||||
/> |
||||
</li> |
||||
</ul> |
||||
|
||||
<p |
||||
v-else |
||||
v-t="'No skill profiles'" |
||||
/> |
||||
</BaseCard> |
||||
</template> |
||||
@ -0,0 +1,285 @@ |
||||
import { ref, unref, watch } from "vue" |
||||
import * as d3 from "d3" |
||||
import { getSkillTree } from "../../services/skillService" |
||||
import { useNotification } from "../notification" |
||||
|
||||
export function useSkillWheel() { |
||||
const isLoading = ref(true) |
||||
|
||||
const skillList = ref([]) |
||||
const wheelContainer = ref(null) |
||||
|
||||
const { showErrorNotification } = useNotification() |
||||
|
||||
const colorList = ["#deebf7", "#9ecae1", "#3182bd"] |
||||
|
||||
let root |
||||
let centralCircle |
||||
let path |
||||
|
||||
const width = 928 |
||||
const height = width |
||||
const radius = width / 6 |
||||
|
||||
function transformSkillToWheelItem({ |
||||
id, |
||||
title, |
||||
shortCode, |
||||
status, |
||||
children, |
||||
hasGradebook, |
||||
isSearched, |
||||
isAchievedByUser, |
||||
}) { |
||||
const item = { |
||||
id, |
||||
name: title, |
||||
shortCode, |
||||
status, |
||||
children: [], |
||||
hasGradebook, |
||||
isSearched, |
||||
isAchievedByUser, |
||||
} |
||||
|
||||
if (children.length) { |
||||
for (const child of children) { |
||||
item.children.push(transformSkillToWheelItem(child)) |
||||
} |
||||
} else { |
||||
item.value = 1 |
||||
} |
||||
|
||||
return item |
||||
} |
||||
|
||||
function render() { |
||||
const data = { |
||||
name: "root", |
||||
children: unref(skillList).map(transformSkillToWheelItem), |
||||
} |
||||
|
||||
// Compute the layout.
|
||||
const hierarchy = d3 |
||||
.hierarchy(data) |
||||
.sum((d) => d.value) |
||||
.sort((a, b) => b.id - a.id) |
||||
|
||||
root = d3.partition().size([2 * Math.PI, hierarchy.height + 1])(hierarchy) |
||||
|
||||
root.each((d) => (d.current = d)) |
||||
|
||||
// Create the arc generator.
|
||||
const arc = d3 |
||||
.arc() |
||||
.startAngle((d) => d.x0) |
||||
.endAngle((d) => d.x1) |
||||
.padAngle((d) => Math.min((d.x1 - d.x0) / 2, 0.005)) |
||||
.padRadius(radius * 1.5) |
||||
.innerRadius((d) => d.y0 * radius) |
||||
.outerRadius((d) => Math.max(d.y0 * radius, d.y1 * radius - 1)) |
||||
|
||||
// Create the SVG container.
|
||||
const svg = d3 |
||||
.create("svg") |
||||
.attr("viewBox", [-width / 2, -height / 2, width, width]) |
||||
.style("font", "10px sans-serif") |
||||
|
||||
// Append the arcs.
|
||||
path = svg |
||||
.append("g") |
||||
.selectAll("path") |
||||
.data(root.descendants().slice(1)) |
||||
.join("path") |
||||
.attr("fill", setFillColor) |
||||
.attr("fill-opacity", (d) => (arcVisible(d.current) ? 1 : 0)) |
||||
.attr("pointer-events", (d) => (arcVisible(d.current) ? "auto" : "none")) |
||||
.attr("d", (d) => arc(d.current)) |
||||
.attr("id", (d) => "skill-" + d.data.id) |
||||
|
||||
// Make them clickable if they have children.
|
||||
path |
||||
.filter((d) => d.children) |
||||
.style("cursor", "pointer") |
||||
.on("click", clicked) |
||||
|
||||
path.append("title").text( |
||||
(d) => |
||||
`${d |
||||
.ancestors() |
||||
.filter((d) => d.depth > 0) |
||||
.map(setNodeText) |
||||
.reverse() |
||||
.join("/")}`,
|
||||
) |
||||
|
||||
const label = svg |
||||
.append("g") |
||||
.attr("pointer-events", "none") |
||||
.attr("text-anchor", "middle") |
||||
.style("user-select", "none") |
||||
.selectAll("text") |
||||
.data(root.descendants().slice(1)) |
||||
.join("text") |
||||
.attr("dy", "0.35em") |
||||
.attr("fill-opacity", (d) => +labelVisible(d.current)) |
||||
.attr("transform", (d) => labelTransform(d.current)) |
||||
.text(setNodeText) |
||||
|
||||
centralCircle = svg |
||||
.append("circle") |
||||
.datum(root) |
||||
.attr("r", radius) |
||||
.attr("fill", "none") |
||||
.attr("pointer-events", "all") |
||||
.on("click", clicked) |
||||
|
||||
// Handle zoom on click.
|
||||
function clicked(event, p) { |
||||
centralCircle.datum(p.parent || root) |
||||
|
||||
root.each( |
||||
(d) => |
||||
(d.target = { |
||||
x0: Math.max(0, Math.min(1, (d.x0 - p.x0) / (p.x1 - p.x0))) * 2 * Math.PI, |
||||
x1: Math.max(0, Math.min(1, (d.x1 - p.x0) / (p.x1 - p.x0))) * 2 * Math.PI, |
||||
y0: Math.max(0, d.y0 - p.depth), |
||||
y1: Math.max(0, d.y1 - p.depth), |
||||
}), |
||||
) |
||||
|
||||
const t = svg.transition().duration(750) |
||||
|
||||
// Transition the data on all arcs, even the ones that aren’t visible,
|
||||
// so that if this transition is interrupted, entering arcs will start
|
||||
// the next transition from the desired position.
|
||||
path |
||||
.transition(t) |
||||
.tween("data", (d) => { |
||||
const i = d3.interpolate(d.current, d.target) |
||||
|
||||
return (t) => (d.current = i(t)) |
||||
}) |
||||
.filter(function (d) { |
||||
return +this.getAttribute("fill-opacity") || arcVisible(d.target) |
||||
}) |
||||
.attr("fill-opacity", (d) => (arcVisible(d.target) ? 1 : 0)) |
||||
.attr("pointer-events", (d) => (arcVisible(d.target) ? "auto" : "none")) |
||||
.attrTween("d", (d) => () => arc(d.current)) |
||||
|
||||
label |
||||
.filter(function (d) { |
||||
return +this.getAttribute("fill-opacity") || labelVisible(d.target) |
||||
}) |
||||
.transition(t) |
||||
.attr("fill-opacity", (d) => +labelVisible(d.target)) |
||||
.attrTween("transform", (d) => () => labelTransform(d.current)) |
||||
} |
||||
|
||||
function arcVisible(d) { |
||||
return d.y1 <= 3 && d.y0 >= 1 && d.x1 > d.x0 |
||||
} |
||||
|
||||
function labelVisible(d) { |
||||
return d.y1 <= 3 && d.y0 >= 1 && (d.y1 - d.y0) * (d.x1 - d.x0) > 0.03 |
||||
} |
||||
|
||||
function labelTransform(d) { |
||||
const x = (((d.x0 + d.x1) / 2) * 180) / Math.PI |
||||
const y = ((d.y0 + d.y1) / 2) * radius |
||||
|
||||
return `rotate(${x - 90}) translate(${y},0) rotate(${x < 180 ? 0 : 180})` |
||||
} |
||||
|
||||
function setFillColor(d, i) { |
||||
if (d.data.hasGradebook) { |
||||
return "#F89406" |
||||
} |
||||
|
||||
if (d.data.isSearched) { |
||||
return "#B94A48" |
||||
} |
||||
|
||||
if (d.data.isAchievedByUser) { |
||||
return "#A1D99B" |
||||
} |
||||
|
||||
if (!d.data.status) { |
||||
return "#48616C" |
||||
} |
||||
|
||||
return colorList[i % colorList.length] |
||||
} |
||||
|
||||
function setNodeText(d) { |
||||
if (d.data.shortCode) { |
||||
return d.data.shortCode |
||||
} |
||||
|
||||
return d.data.name |
||||
} |
||||
|
||||
return svg.node() |
||||
} |
||||
|
||||
function showRoot() { |
||||
if (isLoading.value) { |
||||
return |
||||
} |
||||
|
||||
centralCircle.datum(root).dispatch("click") |
||||
} |
||||
|
||||
function showSkill(skillId) { |
||||
if (isLoading.value) { |
||||
return |
||||
} |
||||
|
||||
const skillNode = root.descendants().find((d) => d.data.id === skillId) |
||||
|
||||
if (!skillNode) { |
||||
return |
||||
} |
||||
|
||||
if (skillNode.children && skillNode.children.length > 0) { |
||||
centralCircle.datum(skillNode).dispatch("click") |
||||
|
||||
return |
||||
} |
||||
|
||||
if (skillNode.parent) { |
||||
centralCircle.datum(skillNode.parent).dispatch("click") |
||||
|
||||
return |
||||
} |
||||
|
||||
showRoot() |
||||
} |
||||
|
||||
async function loadSkills() { |
||||
isLoading.value = true |
||||
|
||||
try { |
||||
skillList.value = await getSkillTree() |
||||
} catch (e) { |
||||
showErrorNotification(e) |
||||
} finally { |
||||
isLoading.value = false |
||||
} |
||||
} |
||||
|
||||
watch(skillList, () => { |
||||
if (wheelContainer.value) { |
||||
wheelContainer.value.innerHTML = "" |
||||
wheelContainer.value.appendChild(render()) |
||||
} |
||||
}) |
||||
|
||||
return { |
||||
wheelContainer, |
||||
isLoading, |
||||
loadSkills, |
||||
showRoot, |
||||
showSkill, |
||||
} |
||||
} |
||||
@ -0,0 +1,18 @@ |
||||
export default { |
||||
path: "/skill", |
||||
component: () => import("../components/layout/SimpleRouterViewLayout.vue"), |
||||
children: [ |
||||
{ |
||||
name: "SkillWheel", |
||||
path: "wheel", |
||||
meta: { |
||||
requiresAuth: true, |
||||
requiresAdmin: true, |
||||
requiresSessionAdmin: false, |
||||
requiresHR: true, |
||||
showBreadcrumb: false, |
||||
}, |
||||
component: () => import("../views/skill/SkillWheel.vue"), |
||||
}, |
||||
], |
||||
} |
||||
@ -0,0 +1,47 @@ |
||||
import baseService from "./baseService" |
||||
|
||||
/** |
||||
* @returns {Promise<{totalItems, items}>} |
||||
*/ |
||||
export async function findAll() { |
||||
return baseService.getCollection("/api/skill_profiles") |
||||
} |
||||
|
||||
/** |
||||
* @param {string} title |
||||
* @param {string} description |
||||
* @param {Array<{skill}>} skills |
||||
* @returns {Promise<Object>} |
||||
*/ |
||||
export async function createProfile({ title, description, skills }) { |
||||
return baseService.post("/api/skill_profiles", { title, description, skills }) |
||||
} |
||||
|
||||
/** |
||||
* @param {string} iri |
||||
* @param {string} title |
||||
* @param {string} description |
||||
* @returns {Promise<Object>} |
||||
*/ |
||||
export async function updateProfile({ iri, title, description }) { |
||||
return baseService.put(iri, { title, description }) |
||||
} |
||||
|
||||
/** |
||||
* @param {string} iri |
||||
* @returns {Promise<void>} |
||||
*/ |
||||
export async function deleteProfile(iri) { |
||||
await baseService.delete(iri) |
||||
} |
||||
|
||||
/** |
||||
* @param {Array<number>} idList |
||||
* @returns {Promise<string>} |
||||
*/ |
||||
export async function matchProfiles(idList) { |
||||
return await baseService.get("/main/inc/ajax/skill.ajax.php", { |
||||
a: "profile_matches", |
||||
skill_id: idList, |
||||
}) |
||||
} |
||||
@ -0,0 +1,18 @@ |
||||
import baseService from "./baseService" |
||||
|
||||
/** |
||||
* @returns {Promise<Array>} |
||||
*/ |
||||
export async function getSkillTree() { |
||||
const { items } = await baseService.getCollection("/api/skills/tree") |
||||
|
||||
return items |
||||
} |
||||
|
||||
/** |
||||
* @param {Object} searchParams |
||||
* @returns {Promise<{totalItems, items}>} |
||||
*/ |
||||
export async function findAll(searchParams) { |
||||
return await baseService.getCollection("api/skills", searchParams) |
||||
} |
||||
@ -0,0 +1,180 @@ |
||||
<script setup> |
||||
import { ref } from "vue" |
||||
import { useI18n } from "vue-i18n" |
||||
|
||||
import SectionHeader from "../../components/layout/SectionHeader.vue" |
||||
import BaseAutocomplete from "../../components/basecomponents/BaseAutocomplete.vue" |
||||
import BaseIcon from "../../components/basecomponents/BaseIcon.vue" |
||||
import BaseButton from "../../components/basecomponents/BaseButton.vue" |
||||
import BaseCard from "../../components/basecomponents/BaseCard.vue" |
||||
import SkillWheelProfileList from "../../components/skill/SkillWheelProfileList.vue" |
||||
import SkillProfileDialog from "../../components/skill/SkillProfileDialog.vue" |
||||
import SkillWheelGraph from "../../components/skill/SkillWheelGraph.vue" |
||||
|
||||
import { useNotification } from "../../composables/notification" |
||||
|
||||
import * as skillService from "../../services/skillService" |
||||
import SkillProfileMatches from "../../components/skill/SkillProfileMatches.vue" |
||||
|
||||
const { t } = useI18n() |
||||
|
||||
const { showErrorNotification } = useNotification() |
||||
|
||||
const profileListEL = ref() |
||||
const wheelEl = ref() |
||||
|
||||
const foundSkills = ref([]) |
||||
|
||||
/** |
||||
* @param {string} query |
||||
* @returns {Promise<Object[]>} |
||||
*/ |
||||
async function findSkills(query) { |
||||
try { |
||||
const { items } = await skillService.findAll({ title: query }) |
||||
|
||||
return items.map((item) => ({ name: item.title, value: item["@id"], ...item })) |
||||
} catch (e) { |
||||
showErrorNotification(e) |
||||
|
||||
return [] |
||||
} |
||||
} |
||||
|
||||
const showSkilProfileForm = ref(false) |
||||
|
||||
const profileMatchesEl = ref() |
||||
const showProfileMatches = ref(false) |
||||
|
||||
async function onClickSearchProfileMatches() { |
||||
showProfileMatches.value = true |
||||
|
||||
await profileMatchesEl.value.searchProfileMatches(foundSkills.value) |
||||
} |
||||
|
||||
async function onClickViewSkillWheel() { |
||||
showProfileMatches.value = false |
||||
|
||||
wheelEl.value.showRoot() |
||||
} |
||||
|
||||
async function onSearchProfile(profile) { |
||||
const profileSkills = profile.skills.map((skillRelProfile) => skillRelProfile.skill) |
||||
|
||||
showProfileMatches.value = true |
||||
|
||||
await profileMatchesEl.value.searchProfileMatches(profileSkills) |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<SectionHeader :title="t('Skill wheel')" /> |
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4"> |
||||
<div class="xl:col-span-1 skill-options flex flex-col gap-4"> |
||||
<SkillWheelProfileList |
||||
ref="profileListEL" |
||||
@search-profile="onSearchProfile" |
||||
/> |
||||
|
||||
<BaseCard> |
||||
<template #title>{{ t("What skills are you looking for?") }}</template> |
||||
|
||||
<template #footer> |
||||
<BaseButton |
||||
:label="t('View skills wheel')" |
||||
icon="wheel" |
||||
type="secondary" |
||||
@click="onClickViewSkillWheel()" |
||||
/> |
||||
</template> |
||||
|
||||
<BaseAutocomplete |
||||
id="skill_id" |
||||
v-model="foundSkills" |
||||
:label="t('Enter the skill name to search')" |
||||
:search="findSkills" |
||||
is-multiple |
||||
> |
||||
<template #chip="{ value }"> |
||||
{{ value.name }} |
||||
|
||||
<span |
||||
class="p-autocomplete-token-icon" |
||||
@click="wheelEl.showSkill(value.id)" |
||||
> |
||||
<BaseIcon |
||||
icon="crosshairs" |
||||
size="small" |
||||
/> |
||||
</span> |
||||
</template> |
||||
</BaseAutocomplete> |
||||
|
||||
<BaseButton |
||||
:disabled="!foundSkills.length" |
||||
:label="t('Search profile matches')" |
||||
icon="search" |
||||
type="black" |
||||
@click="onClickSearchProfileMatches" |
||||
/> |
||||
|
||||
<p v-t="'Is this what you were looking for?'" /> |
||||
|
||||
<BaseButton |
||||
:disabled="!foundSkills.length" |
||||
:label="t('Save this search')" |
||||
icon="search" |
||||
type="black" |
||||
@click="showSkilProfileForm = true" |
||||
/> |
||||
</BaseCard> |
||||
|
||||
<BaseCard> |
||||
<template #title>{{ t("Legend") }}</template> |
||||
|
||||
<ul class="fa-ul"> |
||||
<li> |
||||
<BaseIcon |
||||
class="skill-legend-basic" |
||||
icon="square" |
||||
/> |
||||
|
||||
{{ t("Basic skills") }} |
||||
</li> |
||||
<li> |
||||
<BaseIcon |
||||
class="skill-legend-add" |
||||
icon="square" |
||||
/> |
||||
{{ t("Skills you can learn") }} |
||||
</li> |
||||
<li> |
||||
<BaseIcon |
||||
class="skill-legend-search" |
||||
icon="square" |
||||
/> |
||||
{{ t("Skills searched for") }} |
||||
</li> |
||||
</ul> |
||||
</BaseCard> |
||||
</div> |
||||
|
||||
<div class="xl:col-span-2"> |
||||
<SkillWheelGraph |
||||
v-show="!showProfileMatches" |
||||
ref="wheelEl" |
||||
/> |
||||
<SkillProfileMatches |
||||
v-show="showProfileMatches" |
||||
ref="profileMatchesEl" |
||||
/> |
||||
</div> |
||||
</div> |
||||
|
||||
<SkillProfileDialog |
||||
v-model:skills="foundSkills" |
||||
v-model:visible="showSkilProfileForm" |
||||
@saved="profileListEL.loadProfiles()" |
||||
/> |
||||
</template> |
||||
Loading…
Reference in new issue