Merge pull request #1791 from rehanrehman389/job-application

feat: show job applications on frontend
This commit is contained in:
Jannat Patel
2025-11-07 12:02:02 +05:30
committed by GitHub
3 changed files with 344 additions and 1 deletions

View File

@@ -0,0 +1,318 @@
<template>
<div class="">
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs
class="h-7"
:items="[
{ label: __('Jobs'), route: { name: 'Jobs' } },
{
label: applications.data?.[0]?.job_title,
route: { name: 'JobDetail', params: { job: props.job } },
},
{ label: __('Applications') },
]"
/>
</header>
<div class="max-w-4xl mx-auto pt-5 p-4">
<div class="mb-6">
<h1 class="text-lg font-semibold text-ink-gray-9 mb-2">
{{ applications.data?.length || 0 }}
{{
applications.data?.length === 1
? __('Application')
: __('Applications')
}}
</h1>
</div>
<ListView
v-if="applications.data?.length"
:columns="applicationColumns"
:rows="applicantRows"
row-key="name"
:options="{
showTooltip: false,
selectable: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem
:item="item"
v-for="item in applicationColumns"
:key="item.key"
>
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon?.toString()"
class="h-4 w-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-slot="{ column, item }"
v-for="row in applicantRows"
class="cursor-pointer"
>
<ListRowItem :item="item">
<div
v-if="column.key === 'full_name'"
class="flex items-center space-x-3"
>
<img
v-if="row.user_image"
:src="row.user_image"
:alt="row.full_name"
class="w-8 h-8 rounded-full object-cover"
/>
<div
v-else
class="w-8 h-8 rounded-full bg-surface-gray-3 flex items-center justify-center"
>
<FeatherIcon name="user" class="w-4 h-4 text-ink-gray-6" />
</div>
<span class="text-sm font-medium">{{ item }}</span>
</div>
<div
v-else-if="column.key === 'actions'"
class="flex justify-center"
>
<Dropdown :options="getActionOptions(row)">
<Button variant="ghost" size="sm">
<FeatherIcon name="more-horizontal" class="w-4 h-4" />
</Button>
</Dropdown>
</div>
<div v-else class="text-sm">
{{ item }}
</div>
</ListRowItem>
</ListRow>
</ListRows>
</ListView>
<EmptyState v-else type="Applications" />
</div>
<Dialog
v-model="showEmailModal"
:options="{
title: __('Send Email to {0}').format(selectedApplicant?.full_name),
size: 'lg',
actions: [
{
label: __('Send'),
variant: 'solid',
onClick: (close) => sendEmail(close),
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<FormControl
v-model="emailForm.subject"
:label="__('Subject')"
:placeholder="__('Enter email subject')"
required
/>
<FormControl
v-model="emailForm.replyTo"
:label="__('Reply To')"
:placeholder="__('Enter reply to email')"
/>
<div>
<div class="text-sm text-ink-gray-5 mb-1">
{{ __('Message') }}
</div>
<TextEditor
:content="emailForm.message"
@change="(val) => (emailForm.message = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div>
</template>
</Dialog>
</div>
</template>
<script setup>
import {
Button,
Breadcrumbs,
Dialog,
Dropdown,
FeatherIcon,
FormControl,
TextEditor,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
createResource,
createListResource,
usePageMeta,
toast,
} from 'frappe-ui'
import { inject, ref, computed, reactive } from 'vue'
import { sessionStore } from '../stores/session'
import EmptyState from '@/components/EmptyState.vue'
const dayjs = inject('$dayjs')
const { brand } = sessionStore()
const showEmailModal = ref(false)
const selectedApplicant = ref(null)
const emailForm = reactive({
subject: '',
message: '',
replyTo: '',
})
const props = defineProps({
job: {
type: String,
required: true,
},
})
const applications = createListResource({
doctype: 'LMS Job Application',
fields: [
'name',
'user.user_image as user_image',
'user.full_name as full_name',
'user.email as email',
'creation',
'resume',
'job.job_title as job_title',
],
filters: {
job: props.job,
},
auto: true,
})
const emailResource = createResource({
url: 'frappe.core.doctype.communication.email.make',
makeParams(values) {
return {
recipients: selectedApplicant.value.email,
cc: emailForm.replyTo,
subject: emailForm.subject,
content: emailForm.message,
doctype: 'LMS Job Application',
name: selectedApplicant.value.name,
send_email: 1,
now: true,
}
},
})
const openEmailModal = (applicant) => {
selectedApplicant.value = applicant
emailForm.subject = `Job Application for ${applications.data?.[0]?.job_title} - ${applicant.full_name}`
emailForm.replyTo = ''
emailForm.message = ''
showEmailModal.value = true
}
const sendEmail = (close) => {
emailResource.submit(
{},
{
validate() {
if (!emailForm.subject) {
return __('Subject is required')
}
if (!emailForm.message) {
return __('Message is required')
}
},
onSuccess: () => {
toast.success(__('Email sent successfully'))
close()
},
onError: (err) => {
toast.error(err.messages?.[0] || err)
},
}
)
}
const downloadResume = (resumeUrl) => {
window.open(resumeUrl, '_blank')
}
const getActionOptions = (row) => {
const options = []
if (row.resume) {
options.push({
label: __('View Resume'),
icon: 'download',
onClick: () => downloadResume(row.resume),
})
}
options.push({
label: __('Send Email'),
icon: 'mail',
onClick: () => openEmailModal(row),
})
return options
}
const applicationColumns = computed(() => {
return [
{
label: __('Full Name'),
key: 'full_name',
width: 2,
icon: 'user',
},
{
label: __('Email'),
key: 'email',
width: 2,
icon: 'at-sign',
},
{
label: __('Applied On'),
key: 'applied_date',
width: 1,
icon: 'calendar',
},
{
label: '',
key: 'actions',
width: 1,
},
]
})
const applicantRows = computed(() => {
if (!applications.data) return []
return applications.data.map((application) => ({
...application,
full_name: application.full_name,
applied_date: dayjs(application.creation).format('MMM DD, YYYY'),
}))
})
usePageMeta(() => {
return {
title: `Applications - ${applications.data?.[0]?.job_title}`,
icon: brand.favicon,
}
})
</script>

View File

@@ -20,6 +20,17 @@
v-if="user.data?.name && !readOnlyMode"
class="flex items-center space-x-2"
>
<router-link
v-if="canManageJob && applicationCount.data > 0"
:to="{
name: 'JobApplications',
params: { job: job.data?.name },
}"
>
<Button variant="subtle">
{{ __('View Applications') }}
</Button>
</router-link>
<router-link
v-if="user.data.name == job.data?.owner"
:to="{
@@ -146,7 +157,7 @@ import {
createResource,
usePageMeta,
} from 'frappe-ui'
import { inject, ref } from 'vue'
import { inject, ref, computed } from 'vue'
import { sessionStore } from '../stores/session'
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
import {
@@ -159,6 +170,7 @@ import {
FileText,
ClipboardType,
BriefcaseBusiness,
Users,
} from 'lucide-vue-next'
const user = inject('$user')
@@ -226,6 +238,13 @@ const redirectToWebsite = (url) => {
window.open(url, '_blank')
}
const canManageJob = computed(() => {
if (!user.data?.name || !job.data) return false
return (
user.data.name === job.data.owner || user.data.roles?.includes('Moderator')
)
})
usePageMeta(() => {
return {
title: job.data?.job_title,