Merge pull request #1953 from pateljannat/open-to-hiring
feat: open to hiring
This commit is contained in:
@@ -80,6 +80,7 @@ onMounted(() => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
destructureSidebarLinks()
|
||||||
filterLinksToShow(data)
|
filterLinksToShow(data)
|
||||||
addOtherLinks()
|
addOtherLinks()
|
||||||
},
|
},
|
||||||
@@ -103,6 +104,16 @@ watch(showMenu, (val) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const destructureSidebarLinks = () => {
|
||||||
|
let links = []
|
||||||
|
sidebarLinks.value.forEach((link) => {
|
||||||
|
link.items?.forEach((item) => {
|
||||||
|
links.push(item)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
sidebarLinks.value = links
|
||||||
|
}
|
||||||
|
|
||||||
const filterLinksToShow = (data) => {
|
const filterLinksToShow = (data) => {
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
if (!parseInt(data[key])) {
|
if (!parseInt(data[key])) {
|
||||||
|
|||||||
@@ -1,72 +1,70 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog
|
||||||
:options="{
|
:options="{
|
||||||
title: 'Edit your profile',
|
|
||||||
size: '3xl',
|
size: '3xl',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-header>
|
||||||
<div>
|
<div class="flex items-center mb-5">
|
||||||
<div class="grid grid-cols-2 gap-10">
|
<div class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||||
<div>
|
{{ __('Edit Profile') }}
|
||||||
<div class="text-xs text-ink-gray-5 mb-1">
|
|
||||||
{{ __('Profile Image') }}
|
|
||||||
</div>
|
|
||||||
<FileUploader
|
|
||||||
v-if="!profile.image"
|
|
||||||
:fileTypes="['image/*']"
|
|
||||||
:validateFile="validateFile"
|
|
||||||
@success="(file) => saveImage(file)"
|
|
||||||
>
|
|
||||||
<template
|
|
||||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
|
||||||
>
|
|
||||||
<div class="mb-4">
|
|
||||||
<Button @click="openFileSelector" :loading="uploading">
|
|
||||||
{{
|
|
||||||
uploading
|
|
||||||
? `Uploading ${progress}%`
|
|
||||||
: 'Upload a profile image'
|
|
||||||
}}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</FileUploader>
|
|
||||||
<div v-else class="mb-4">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<img
|
|
||||||
:src="profile.image?.file_url"
|
|
||||||
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="text-base flex flex-col ml-2">
|
|
||||||
<span>
|
|
||||||
{{ profile.image?.file_name }}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm text-ink-gray-4 mt-1">
|
|
||||||
{{ getFileSize(profile.image?.file_size) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<X
|
|
||||||
@click="removeImage()"
|
|
||||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
v-model="profile.looking_for_job"
|
|
||||||
:label="__('Open to Opportunities')"
|
|
||||||
:description="
|
|
||||||
__('Show recruiters and others that you are open to work.')
|
|
||||||
"
|
|
||||||
class="!px-0"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Badge v-if="isDirty" class="ml-4" theme="orange">
|
||||||
|
{{ __('Not Saved') }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="text-base">
|
||||||
<div class="grid grid-cols-2 gap-10">
|
<div class="grid grid-cols-2 gap-10">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-ink-gray-5 mb-1">
|
||||||
|
{{ __('Profile Image') }}
|
||||||
|
</div>
|
||||||
|
<FileUploader
|
||||||
|
v-if="!profile.image"
|
||||||
|
:fileTypes="['image/*']"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
@success="(file) => saveImage(file)"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||||
|
>
|
||||||
|
<div class="mb-4">
|
||||||
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
|
{{
|
||||||
|
uploading
|
||||||
|
? `Uploading ${progress}%`
|
||||||
|
: 'Upload a profile image'
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
<div v-else class="mb-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<img
|
||||||
|
:src="profile.image?.file_url"
|
||||||
|
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="text-base flex flex-col ml-2">
|
||||||
|
<span>
|
||||||
|
{{ profile.image?.file_name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-ink-gray-4 mt-1">
|
||||||
|
{{ getFileSize(profile.image?.file_size) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<X
|
||||||
|
@click="removeImage()"
|
||||||
|
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="profile.first_name"
|
v-model="profile.first_name"
|
||||||
:label="__('First Name')"
|
:label="__('First Name')"
|
||||||
@@ -89,6 +87,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="profile.open_to"
|
||||||
|
type="select"
|
||||||
|
:options="[' ', 'Opportunities', 'Hiring']"
|
||||||
|
:label="__('Open to')"
|
||||||
|
:placeholder="__('Looking for new work or hiring talent?')"
|
||||||
|
/>
|
||||||
<Link
|
<Link
|
||||||
:label="__('Language')"
|
:label="__('Language')"
|
||||||
v-model="profile.language"
|
v-model="profile.language"
|
||||||
@@ -103,7 +108,7 @@
|
|||||||
@change="(val) => (profile.bio = val)"
|
@change="(val) => (profile.bio = val)"
|
||||||
:content="profile.bio"
|
:content="profile.bio"
|
||||||
:rows="15"
|
:rows="15"
|
||||||
editorClass="prose-sm py-2 px-2 min-h-[200px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
|
editorClass="prose-sm py-2 px-2 min-h-[280px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,12 +126,12 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
Dialog,
|
Dialog,
|
||||||
FormControl,
|
FormControl,
|
||||||
FileUploader,
|
FileUploader,
|
||||||
Switch,
|
|
||||||
TextEditor,
|
TextEditor,
|
||||||
toast,
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
@@ -137,6 +142,7 @@ import Link from '@/components/Controls/Link.vue'
|
|||||||
|
|
||||||
const reloadProfile = defineModel('reloadProfile')
|
const reloadProfile = defineModel('reloadProfile')
|
||||||
const hasLanguageChanged = ref(false)
|
const hasLanguageChanged = ref(false)
|
||||||
|
const isDirty = ref(false)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
profile: {
|
profile: {
|
||||||
@@ -151,7 +157,7 @@ const profile = reactive({
|
|||||||
headline: '',
|
headline: '',
|
||||||
bio: '',
|
bio: '',
|
||||||
image: '',
|
image: '',
|
||||||
looking_for_job: false,
|
open_to: '',
|
||||||
linkedin: '',
|
linkedin: '',
|
||||||
github: '',
|
github: '',
|
||||||
twitter: '',
|
twitter: '',
|
||||||
@@ -222,6 +228,27 @@ const removeImage = () => {
|
|||||||
profile.image = null
|
profile.image = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => profile,
|
||||||
|
(newVal) => {
|
||||||
|
if (!props.profile.data) return
|
||||||
|
let keys = Object.keys(newVal)
|
||||||
|
keys.splice(keys.indexOf('image'), 1)
|
||||||
|
for (let key of keys) {
|
||||||
|
if (newVal[key] !== props.profile.data[key]) {
|
||||||
|
isDirty.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (profile.image?.file_url !== props.profile.data.user_image) {
|
||||||
|
isDirty.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isDirty.value = false
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.profile.data,
|
() => props.profile.data,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
@@ -231,11 +258,12 @@ watch(
|
|||||||
profile.headline = newVal.headline
|
profile.headline = newVal.headline
|
||||||
profile.language = newVal.language
|
profile.language = newVal.language
|
||||||
profile.bio = newVal.bio
|
profile.bio = newVal.bio
|
||||||
profile.looking_for_job = newVal.looking_for_job
|
profile.open_to = newVal.open_to
|
||||||
profile.linkedin = newVal.linkedin
|
profile.linkedin = newVal.linkedin
|
||||||
profile.github = newVal.github
|
profile.github = newVal.github
|
||||||
profile.twitter = newVal.twitter
|
profile.twitter = newVal.twitter
|
||||||
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
|
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
|
||||||
|
isDirty.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,42 +1,50 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="border rounded-md w-1/3 mx-auto my-32">
|
<div class="bg-surface-white w-full h-full">
|
||||||
<div class="border-b px-5 py-3 font-medium text-ink-gray-9">
|
<div class="w-fit mx-auto mt-56 text-center p-4">
|
||||||
<span
|
<div class="text-3xl font-semibold text-ink-gray-5 pb-4 mb-2 border-b">
|
||||||
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
{{ __('Not Permitted') }}
|
||||||
></span>
|
|
||||||
{{ __('Not Permitted') }}
|
|
||||||
</div>
|
|
||||||
<div v-if="user.data" class="px-5 py-3">
|
|
||||||
<div class="text-ink-gray-7">
|
|
||||||
{{ __('You do not have permission to access this page.') }}
|
|
||||||
</div>
|
</div>
|
||||||
<router-link
|
<div v-if="user.data" class="px-5 py-3">
|
||||||
:to="{
|
<div class="text-ink-gray-5">
|
||||||
name: 'Courses',
|
{{ __('You do not have permission to access this page.') }}
|
||||||
}"
|
</div>
|
||||||
>
|
<router-link
|
||||||
<Button variant="solid" class="mt-2">
|
:to="{
|
||||||
{{ __('Checkout Courses') }}
|
name: 'Courses',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="solid" class="mt-2 w-full">
|
||||||
|
{{ __('Checkout Courses') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 py-3">
|
||||||
|
<div class="text-ink-gray-5">
|
||||||
|
{{ __('You are not permitted to access this page.') }}
|
||||||
|
</div>
|
||||||
|
<Button @click="redirectToLogin()" class="mt-4 w-full" variant="solid">
|
||||||
|
{{ __('Login') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<div class="px-5 py-3">
|
|
||||||
<div class="text-ink-gray-7">
|
|
||||||
{{ __('Please login to access this page.') }}
|
|
||||||
</div>
|
</div>
|
||||||
<Button @click="redirectToLogin()" class="mt-4">
|
|
||||||
{{ __('Login') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { inject } from 'vue'
|
import { inject } from 'vue'
|
||||||
import { Button } from 'frappe-ui'
|
import { Button, usePageMeta } from 'frappe-ui'
|
||||||
|
import { sessionStore } from '../stores/session'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const { brand } = sessionStore()
|
||||||
|
|
||||||
const redirectToLogin = () => {
|
const redirectToLogin = () => {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usePageMeta(() => {
|
||||||
|
return {
|
||||||
|
title: __('Not Permitted'),
|
||||||
|
icon: brand.favicon,
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,13 +7,20 @@
|
|||||||
:size="size"
|
:size="size"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
>
|
>
|
||||||
<template v-if="user.looking_for_job" #indicator>
|
<template v-if="user.open_to === 'Opportunities'" #indicator>
|
||||||
<Tooltip :text="__('Open to Opportunities')" placement="right">
|
<Tooltip :text="__('Open to Opportunities')" placement="right">
|
||||||
<div class="rounded-full bg-surface-green-3 w-fit">
|
<div class="rounded-full bg-surface-green-3 w-fit">
|
||||||
<BadgeCheckIcon :class="'text-ink-white ' + checkSize" />
|
<BadgeCheckIcon :class="'text-ink-white ' + checkSize" />
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="user.open_to === 'Hiring'" #indicator>
|
||||||
|
<Tooltip :text="__('Hiring')" placement="right">
|
||||||
|
<div class="rounded-full bg-purple-500 w-fit">
|
||||||
|
<BadgeCheckIcon :class="'text-ink-white ' + checkSize" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</template>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|||||||
@@ -13,27 +13,45 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</header>
|
</header>
|
||||||
<div class="mx-auto w-full max-w-4xl pt-6 pb-10">
|
<div class="mx-auto w-full max-w-4xl pt-6 pb-10">
|
||||||
<div class="flex flex-col md:flex-row justify-between mb-4 px-3">
|
<div class="flex flex-col md:flex-row justify-between mb-8 px-3">
|
||||||
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
<div class="text-xl font-semibold text-ink-gray-9 mb-4 md:mb-0">
|
||||||
{{ memberCount }} {{ __('certified members') }}
|
{{ memberCount }} {{ __('certified members') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div
|
||||||
<FormControl
|
class="flex flex-col md:flex-row md:items-center space-y-4 md:space-y-0 md:space-x-4"
|
||||||
v-model="nameFilter"
|
>
|
||||||
:placeholder="__('Search by Name')"
|
<div class="flex items-center space-x-4">
|
||||||
type="text"
|
<FormControl
|
||||||
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
v-model="nameFilter"
|
||||||
@input="updateParticipants()"
|
:placeholder="__('Search by Name')"
|
||||||
/>
|
type="text"
|
||||||
<div
|
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||||
v-if="categories.data?.length"
|
@input="updateParticipants()"
|
||||||
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
/>
|
||||||
>
|
<div
|
||||||
<Select
|
v-if="categories.data?.length"
|
||||||
v-model="currentCategory"
|
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||||
:options="categories.data"
|
>
|
||||||
:placeholder="__('Category')"
|
<Select
|
||||||
@update:modelValue="updateParticipants()"
|
v-model="currentCategory"
|
||||||
|
:options="categories.data"
|
||||||
|
:placeholder="__('Category')"
|
||||||
|
@update:modelValue="updateParticipants()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="openToOpportunities"
|
||||||
|
:label="__('Open to Opportunities')"
|
||||||
|
type="checkbox"
|
||||||
|
@change="updateParticipants()"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="hiring"
|
||||||
|
:label="__('Hiring')"
|
||||||
|
type="checkbox"
|
||||||
|
@change="updateParticipants()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,15 +60,15 @@
|
|||||||
<template v-for="(participant, index) in participants.data">
|
<template v-for="(participant, index) in participants.data">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'ProfileCertificates',
|
name: 'ProfileAbout',
|
||||||
params: {
|
params: {
|
||||||
username: participant.username,
|
username: participant.username,
|
||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
class="flex h-15 rounded-md hover:bg-surface-gray-2 px-3"
|
class="flex rounded-md hover:bg-surface-gray-2 px-3"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center w-full space-x-3 py-2"
|
class="flex w-full space-x-3 py-2"
|
||||||
:class="{
|
:class="{
|
||||||
'border-b': index < participants.data.length - 1,
|
'border-b': index < participants.data.length - 1,
|
||||||
}"
|
}"
|
||||||
@@ -118,9 +136,11 @@ import { sessionStore } from '../stores/session'
|
|||||||
import EmptyState from '@/components/EmptyState.vue'
|
import EmptyState from '@/components/EmptyState.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
|
||||||
const currentCategory = ref('')
|
|
||||||
const filters = ref({})
|
const filters = ref({})
|
||||||
|
const currentCategory = ref('')
|
||||||
const nameFilter = ref('')
|
const nameFilter = ref('')
|
||||||
|
const openToOpportunities = ref(false)
|
||||||
|
const hiring = ref(false)
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
const memberCount = ref(0)
|
const memberCount = ref(0)
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
@@ -152,7 +172,7 @@ const categories = createListResource({
|
|||||||
cache: ['certification_categories'],
|
cache: ['certification_categories'],
|
||||||
auto: true,
|
auto: true,
|
||||||
transform(data) {
|
transform(data) {
|
||||||
data.unshift({ label: __(''), value: '' })
|
data.unshift({ label: __(' '), value: ' ' })
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -169,16 +189,19 @@ const updateParticipants = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateFilters = () => {
|
const updateFilters = () => {
|
||||||
if (currentCategory.value) {
|
filters.value = {
|
||||||
filters.value.category = currentCategory.value
|
...(currentCategory.value && {
|
||||||
} else {
|
category: currentCategory.value,
|
||||||
delete filters.value.category
|
}),
|
||||||
}
|
...(nameFilter.value && {
|
||||||
|
member_name: ['like', `%${nameFilter.value}%`],
|
||||||
if (nameFilter.value) {
|
}),
|
||||||
filters.value.member_name = ['like', `%${nameFilter.value}%`]
|
...(openToOpportunities.value && {
|
||||||
} else {
|
open_to_opportunities: true,
|
||||||
delete filters.value.member_name
|
}),
|
||||||
|
...(hiring.value && {
|
||||||
|
hiring: true,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,10 +210,12 @@ const setQueryParams = () => {
|
|||||||
let filterKeys = {
|
let filterKeys = {
|
||||||
category: currentCategory.value,
|
category: currentCategory.value,
|
||||||
name: nameFilter.value,
|
name: nameFilter.value,
|
||||||
|
'open-to-opportunities': openToOpportunities.value,
|
||||||
|
hiring: hiring.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.keys(filterKeys).forEach((key) => {
|
Object.keys(filterKeys).forEach((key) => {
|
||||||
if (filterKeys[key]) {
|
if (filterKeys[key] && hasValue(filterKeys[key])) {
|
||||||
queries.set(key, filterKeys[key])
|
queries.set(key, filterKeys[key])
|
||||||
} else {
|
} else {
|
||||||
queries.delete(key)
|
queries.delete(key)
|
||||||
@@ -203,10 +228,19 @@ const setQueryParams = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasValue = (value) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.trim() !== ''
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const setFiltersFromQuery = () => {
|
const setFiltersFromQuery = () => {
|
||||||
let queries = new URLSearchParams(location.search)
|
let queries = new URLSearchParams(location.search)
|
||||||
nameFilter.value = queries.get('name') || ''
|
nameFilter.value = queries.get('name') || ''
|
||||||
currentCategory.value = queries.get('category') || ''
|
currentCategory.value = queries.get('category') || ''
|
||||||
|
openToOpportunities.value = queries.get('open-to-opportunities') === 'true'
|
||||||
|
hiring.value = queries.get('hiring') === 'true'
|
||||||
}
|
}
|
||||||
|
|
||||||
const breadcrumbs = computed(() => [
|
const breadcrumbs = computed(() => [
|
||||||
|
|||||||
@@ -26,56 +26,72 @@
|
|||||||
</header>
|
</header>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
|
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto mb-2 p-5"
|
||||||
>
|
>
|
||||||
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
<div class="flex items-center justify-between">
|
||||||
{{ __('{0} Open Jobs').format(jobCount) }}
|
<div class="text-xl font-semibold text-ink-gray-9 md:mb-0">
|
||||||
</div>
|
{{ __('{0} {1} Jobs').format(jobCount, activeTab) }}
|
||||||
|
</div>
|
||||||
<div class="flex items-center justify-between space-x-4">
|
|
||||||
<TabButtons
|
<TabButtons
|
||||||
v-if="tabs.length > 1"
|
v-if="tabs.length > 1"
|
||||||
v-model="activeTab"
|
v-model="activeTab"
|
||||||
:buttons="tabs"
|
:buttons="tabs"
|
||||||
|
class="lg:hidden"
|
||||||
@change="updateJobs"
|
@change="updateJobs"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
</div>
|
||||||
type="text"
|
|
||||||
:placeholder="__('Search')"
|
<div
|
||||||
v-model="searchQuery"
|
class="flex flex-col md:flex-row md:items-center md:space-x-4 space-y-4 md:space-y-0"
|
||||||
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
>
|
||||||
@input="updateJobs"
|
<TabButtons
|
||||||
>
|
v-if="tabs.length > 1"
|
||||||
<template #prefix>
|
v-model="activeTab"
|
||||||
<Search
|
:buttons="tabs"
|
||||||
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
|
class="hidden lg:block"
|
||||||
name="search"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</FormControl>
|
|
||||||
<Link
|
|
||||||
v-if="user.data"
|
|
||||||
doctype="Country"
|
|
||||||
v-model="country"
|
|
||||||
:placeholder="__('Country')"
|
|
||||||
class="min-w-32 lg:min-w-0 lg:w-32 xl:w-32"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
v-model="jobType"
|
|
||||||
type="select"
|
|
||||||
:options="jobTypes"
|
|
||||||
class="min-w-32 lg:min-w-0 lg:w-32 xl:w-32"
|
|
||||||
:placeholder="__('Type')"
|
|
||||||
@change="updateJobs"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
v-model="workMode"
|
|
||||||
type="select"
|
|
||||||
:options="workModes"
|
|
||||||
class="min-w-32 lg:min-w-0 lg:w-32 xl:w-32"
|
|
||||||
:placeholder="__('Work Mode')"
|
|
||||||
@change="updateJobs"
|
@change="updateJobs"
|
||||||
/>
|
/>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<FormControl
|
||||||
|
type="text"
|
||||||
|
:placeholder="__('Search')"
|
||||||
|
v-model="searchQuery"
|
||||||
|
class="w-full max-w-40"
|
||||||
|
@input="updateJobs"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Search
|
||||||
|
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
|
||||||
|
name="search"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</FormControl>
|
||||||
|
<Link
|
||||||
|
v-if="user.data"
|
||||||
|
doctype="Country"
|
||||||
|
v-model="country"
|
||||||
|
:placeholder="__('Country')"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="jobType"
|
||||||
|
type="select"
|
||||||
|
:options="jobTypes"
|
||||||
|
class="w-full"
|
||||||
|
:placeholder="__('Type')"
|
||||||
|
@change="updateJobs"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="workMode"
|
||||||
|
type="select"
|
||||||
|
:options="workModes"
|
||||||
|
class="w-full"
|
||||||
|
:placeholder="__('Work Mode')"
|
||||||
|
@change="updateJobs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="jobs.data?.length" class="w-full md:w-4/5 mx-auto p-5 pt-0">
|
<div v-if="jobs.data?.length" class="w-full md:w-4/5 mx-auto p-5 pt-0">
|
||||||
|
|||||||
@@ -56,15 +56,32 @@
|
|||||||
:src="profile.data.user_image"
|
:src="profile.data.user_image"
|
||||||
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex items-center justify-center h-[100px] w-[100px] rounded-full border-4 border-white bg-surface-gray-2 text-3xl font-semibold text-ink-gray-7"
|
||||||
|
>
|
||||||
|
{{ profile.data.full_name.charAt(0).toUpperCase() }}
|
||||||
|
</div>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
v-if="profile.data.looking_for_job"
|
v-if="profile.data.open_to"
|
||||||
:text="__('Open to Opportunities')"
|
:text="
|
||||||
|
profile.data.open_to === 'Opportunities'
|
||||||
|
? __('Open to Opportunities')
|
||||||
|
: __('Hiring')
|
||||||
|
"
|
||||||
placement="right"
|
placement="right"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-3 right-1 p-0.5 bg-surface-white rounded-full"
|
class="absolute bottom-3 right-1 p-0.5 bg-surface-white rounded-full"
|
||||||
>
|
>
|
||||||
<div class="rounded-full bg-surface-green-3 w-fit">
|
<div
|
||||||
|
class="rounded-full w-fit"
|
||||||
|
:class="
|
||||||
|
profile.data.open_to === 'Opportunities'
|
||||||
|
? 'bg-surface-green-3'
|
||||||
|
: 'bg-purple-500'
|
||||||
|
"
|
||||||
|
>
|
||||||
<BadgeCheckIcon class="text-ink-white size-5" />
|
<BadgeCheckIcon class="text-ink-white size-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -238,8 +238,8 @@
|
|||||||
"dt": "User",
|
"dt": "User",
|
||||||
"fetch_from": null,
|
"fetch_from": null,
|
||||||
"fetch_if_empty": 0,
|
"fetch_if_empty": 0,
|
||||||
"fieldname": "looking_for_job",
|
"fieldname": "open_to",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Select",
|
||||||
"hidden": 0,
|
"hidden": 0,
|
||||||
"hide_border": 0,
|
"hide_border": 0,
|
||||||
"hide_days": 0,
|
"hide_days": 0,
|
||||||
@@ -253,16 +253,16 @@
|
|||||||
"insert_after": "verify_terms",
|
"insert_after": "verify_terms",
|
||||||
"is_system_generated": 1,
|
"is_system_generated": 1,
|
||||||
"is_virtual": 0,
|
"is_virtual": 0,
|
||||||
"label": "Open to Opportunities",
|
"label": "Open to",
|
||||||
"length": 0,
|
"length": 0,
|
||||||
"link_filters": null,
|
"link_filters": null,
|
||||||
"mandatory_depends_on": null,
|
"mandatory_depends_on": null,
|
||||||
"modified": "2021-12-31 12:56:32.110405",
|
"modified": "2025-12-24 12:56:32.110405",
|
||||||
"module": null,
|
"module": null,
|
||||||
"name": "User-looking_for_job",
|
"name": "User-open_to",
|
||||||
"no_copy": 0,
|
"no_copy": 0,
|
||||||
"non_negative": 0,
|
"non_negative": 0,
|
||||||
"options": null,
|
"options": "\nOpportunities\nHiring",
|
||||||
"permlevel": 0,
|
"permlevel": 0,
|
||||||
"precision": "",
|
"precision": "",
|
||||||
"print_hide": 0,
|
"print_hide": 0,
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ def delete_custom_fields():
|
|||||||
"medium",
|
"medium",
|
||||||
"linkedin",
|
"linkedin",
|
||||||
"profession",
|
"profession",
|
||||||
"looking_for_job",
|
"open_to",
|
||||||
"cover_image" "work_environment",
|
"cover_image" "work_environment",
|
||||||
"dream_companies",
|
"dream_companies",
|
||||||
"career_preference_column",
|
"career_preference_column",
|
||||||
|
|||||||
@@ -281,10 +281,34 @@ def get_evaluator_details(evaluator):
|
|||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_certified_participants(filters=None, start=0, page_length=100):
|
def get_certified_participants(filters=None, start=0, page_length=100):
|
||||||
|
filters, or_filters, open_to_opportunities, hiring = update_certification_filters(filters)
|
||||||
|
|
||||||
|
participants = frappe.db.get_all(
|
||||||
|
"LMS Certificate",
|
||||||
|
filters=filters,
|
||||||
|
or_filters=or_filters,
|
||||||
|
fields=["member", "issue_date", "batch_name", "course", "name"],
|
||||||
|
group_by="member",
|
||||||
|
order_by="issue_date desc",
|
||||||
|
start=start,
|
||||||
|
page_length=page_length,
|
||||||
|
)
|
||||||
|
|
||||||
|
for participant in participants:
|
||||||
|
details = get_certified_participant_details(participant.member)
|
||||||
|
participant.update(details)
|
||||||
|
|
||||||
|
participants = filter_by_open_to_criteria(participants, open_to_opportunities, hiring)
|
||||||
|
|
||||||
|
return participants
|
||||||
|
|
||||||
|
|
||||||
|
def update_certification_filters(filters):
|
||||||
|
open_to_opportunities = False
|
||||||
|
hiring = False
|
||||||
or_filters = {}
|
or_filters = {}
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = {}
|
filters = {}
|
||||||
|
|
||||||
filters.update({"published": 1})
|
filters.update({"published": 1})
|
||||||
|
|
||||||
category = filters.get("category")
|
category = filters.get("category")
|
||||||
@@ -293,27 +317,38 @@ def get_certified_participants(filters=None, start=0, page_length=100):
|
|||||||
or_filters["course_title"] = ["like", f"%{category}%"]
|
or_filters["course_title"] = ["like", f"%{category}%"]
|
||||||
or_filters["batch_title"] = ["like", f"%{category}%"]
|
or_filters["batch_title"] = ["like", f"%{category}%"]
|
||||||
|
|
||||||
participants = frappe.db.get_all(
|
if filters.get("open_to_opportunities"):
|
||||||
"LMS Certificate",
|
del filters["open_to_opportunities"]
|
||||||
filters=filters,
|
open_to_opportunities = True
|
||||||
or_filters=or_filters,
|
|
||||||
fields=["member", "issue_date"],
|
|
||||||
group_by="member",
|
|
||||||
order_by="issue_date desc",
|
|
||||||
start=start,
|
|
||||||
page_length=page_length,
|
|
||||||
)
|
|
||||||
|
|
||||||
for participant in participants:
|
if filters.get("hiring"):
|
||||||
count = frappe.db.count("LMS Certificate", {"member": participant.member})
|
del filters["hiring"]
|
||||||
details = frappe.db.get_value(
|
hiring = True
|
||||||
"User",
|
|
||||||
participant.member,
|
return filters, or_filters, open_to_opportunities, hiring
|
||||||
["full_name", "user_image", "username", "country", "headline", "looking_for_job"],
|
|
||||||
as_dict=1,
|
|
||||||
)
|
def get_certified_participant_details(member):
|
||||||
details["certificate_count"] = count
|
count = frappe.db.count("LMS Certificate", {"member": member})
|
||||||
participant.update(details)
|
details = frappe.db.get_value(
|
||||||
|
"User",
|
||||||
|
member,
|
||||||
|
["full_name", "user_image", "username", "country", "headline", "open_to"],
|
||||||
|
as_dict=1,
|
||||||
|
)
|
||||||
|
details["certificate_count"] = count
|
||||||
|
return details
|
||||||
|
|
||||||
|
|
||||||
|
def filter_by_open_to_criteria(participants, open_to_opportunities, hiring):
|
||||||
|
if not open_to_opportunities and not hiring:
|
||||||
|
return participants
|
||||||
|
|
||||||
|
if open_to_opportunities:
|
||||||
|
participants = [participant for participant in participants if participant.open_to == "Opportunities"]
|
||||||
|
|
||||||
|
if hiring:
|
||||||
|
participants = [participant for participant in participants if participant.open_to == "Hiring"]
|
||||||
|
|
||||||
return participants
|
return participants
|
||||||
|
|
||||||
@@ -1635,7 +1670,7 @@ def get_profile_details(username):
|
|||||||
"headline",
|
"headline",
|
||||||
"language",
|
"language",
|
||||||
"cover_image",
|
"cover_image",
|
||||||
"looking_for_job",
|
"open_to",
|
||||||
"linkedin",
|
"linkedin",
|
||||||
"github",
|
"github",
|
||||||
"twitter",
|
"twitter",
|
||||||
|
|||||||
@@ -52,8 +52,14 @@ class TestCourseEvaluator(UnitTestCase):
|
|||||||
return first_date
|
return first_date
|
||||||
|
|
||||||
def calculated_last_date_of_schedule(self, first_date):
|
def calculated_last_date_of_schedule(self, first_date):
|
||||||
last_date = add_days(first_date, 56) # 8 weeks course
|
last_day = add_days(first_date, 56)
|
||||||
return last_date
|
offset_monday = (0 - last_day.weekday() + 7) % 7 # 0 for Monday
|
||||||
|
offset_wednesday = (2 - last_day.weekday() + 7) % 7 # 2 for Wednesday
|
||||||
|
if offset_monday > offset_wednesday and offset_monday < 4:
|
||||||
|
last_day = add_days(last_day, offset_monday)
|
||||||
|
else:
|
||||||
|
last_day = add_days(last_day, offset_wednesday)
|
||||||
|
return last_day
|
||||||
|
|
||||||
def test_unavailability_dates(self):
|
def test_unavailability_dates(self):
|
||||||
unavailable_from = getdate(self.evaluator.unavailable_from)
|
unavailable_from = getdate(self.evaluator.unavailable_from)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import frappe
|
|||||||
from frappe.tests import UnitTestCase
|
from frappe.tests import UnitTestCase
|
||||||
from frappe.utils import add_days, nowdate
|
from frappe.utils import add_days, nowdate
|
||||||
|
|
||||||
|
from lms.lms.api import get_certified_participants
|
||||||
from lms.lms.doctype.lms_certificate.lms_certificate import get_default_certificate_template, is_certified
|
from lms.lms.doctype.lms_certificate.lms_certificate import get_default_certificate_template, is_certified
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
@@ -147,6 +148,7 @@ class TestUtils(UnitTestCase):
|
|||||||
certificate.member = member
|
certificate.member = member
|
||||||
certificate.issue_date = frappe.utils.nowdate()
|
certificate.issue_date = frappe.utils.nowdate()
|
||||||
certificate.template = get_default_certificate_template()
|
certificate.template = get_default_certificate_template()
|
||||||
|
certificate.published = 1
|
||||||
certificate.save()
|
certificate.save()
|
||||||
return certificate
|
return certificate
|
||||||
|
|
||||||
@@ -265,6 +267,36 @@ class TestUtils(UnitTestCase):
|
|||||||
self.assertIsNone(is_certified(self.course.name))
|
self.assertIsNone(is_certified(self.course.name))
|
||||||
frappe.session.user = "Administrator"
|
frappe.session.user = "Administrator"
|
||||||
|
|
||||||
|
def test_certified_participants_with_category(self):
|
||||||
|
filters = {"category": "Utility Course"}
|
||||||
|
certified_participants = get_certified_participants(filters=filters)
|
||||||
|
self.assertEqual(len(certified_participants), 1)
|
||||||
|
self.assertEqual(certified_participants[0].member, self.student1.email)
|
||||||
|
|
||||||
|
filters = {"category": "Nonexistent Category"}
|
||||||
|
certified_participants_no_match = get_certified_participants(filters=filters)
|
||||||
|
self.assertEqual(len(certified_participants_no_match), 0)
|
||||||
|
|
||||||
|
def test_certified_participants_with_open_to_opportunities(self):
|
||||||
|
filters = {"open_to_opportunities": 1}
|
||||||
|
certified_participants_open_to_oppo = get_certified_participants(filters=filters)
|
||||||
|
self.assertEqual(len(certified_participants_open_to_oppo), 0)
|
||||||
|
|
||||||
|
frappe.db.set_value("User", self.student1.email, "open_to", "Opportunities")
|
||||||
|
certified_participants_open_to_oppo = get_certified_participants(filters=filters)
|
||||||
|
self.assertEqual(len(certified_participants_open_to_oppo), 1)
|
||||||
|
frappe.db.set_value("User", self.student1.email, "open_to", "")
|
||||||
|
|
||||||
|
def test_certified_participants_with_open_to_hiring(self):
|
||||||
|
filters = {"hiring": 1}
|
||||||
|
certified_participants_hiring = get_certified_participants(filters=filters)
|
||||||
|
self.assertEqual(len(certified_participants_hiring), 0)
|
||||||
|
|
||||||
|
frappe.db.set_value("User", self.student1.email, "open_to", "Hiring")
|
||||||
|
certified_participants_hiring = get_certified_participants(filters=filters)
|
||||||
|
self.assertEqual(len(certified_participants_hiring), 1)
|
||||||
|
frappe.db.set_value("User", self.student1.email, "open_to", "")
|
||||||
|
|
||||||
def test_rating_validation(self):
|
def test_rating_validation(self):
|
||||||
student3 = self.create_user("student3@example.com", "Emily", "Cooper", ["LMS Student"])
|
student3 = self.create_user("student3@example.com", "Emily", "Cooper", ["LMS Student"])
|
||||||
with self.assertRaises(frappe.exceptions.ValidationError):
|
with self.assertRaises(frappe.exceptions.ValidationError):
|
||||||
|
|||||||
@@ -113,4 +113,5 @@ lms.patches.v2_0.enable_programming_exercises_in_sidebar
|
|||||||
lms.patches.v2_0.count_in_program
|
lms.patches.v2_0.count_in_program
|
||||||
lms.patches.v2_0.fix_scorm_lesson_reference_idx #02-09-2025
|
lms.patches.v2_0.fix_scorm_lesson_reference_idx #02-09-2025
|
||||||
lms.patches.v2_0.certified_members_to_certifications #05-10-2025
|
lms.patches.v2_0.certified_members_to_certifications #05-10-2025
|
||||||
lms.patches.v2_0.fix_job_application_resume_urls
|
lms.patches.v2_0.fix_job_application_resume_urls
|
||||||
|
lms.patches.v2_0.open_to_opportunities
|
||||||
10
lms/patches/v2_0/open_to_opportunities.py
Normal file
10
lms/patches/v2_0/open_to_opportunities.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import frappe
|
||||||
|
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
looking_for_job = frappe.get_all("User", {"looking_for_job": 1}, ["name"])
|
||||||
|
|
||||||
|
for user in looking_for_job:
|
||||||
|
frappe.db.set_value("User", user.name, "open_to", "Opportunities")
|
||||||
|
|
||||||
|
frappe.db.delete("Custom Field", {"dt": "User", "fieldname": "looking_for_job"})
|
||||||
Reference in New Issue
Block a user