chore: fixed merge conflicts

This commit is contained in:
Jannat Patel
2025-12-08 14:54:14 +05:30
110 changed files with 26439 additions and 11302 deletions

View File

@@ -3,7 +3,49 @@
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<router-link
<Dropdown
v-if="canCreateBatch()"
:options="[
{
label: __('New Batch'),
icon: 'users',
onClick() {
router.push({
name: 'BatchForm',
params: { batchName: 'new' },
})
},
},
{
label: __('Import Batch'),
icon: 'upload',
onClick() {
router.push({
name: 'NewDataImport',
params: { doctype: 'LMS Batch' },
})
},
},
]"
>
<template v-slot="{ open }">
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('Create') }}
<template #suffix>
<ChevronDown
:class="[
'w-4 h-4 stroke-1.5 ml-1 transform transition-transform',
open ? 'rotate-180' : '',
]"
/>
</template>
</Button>
</template>
</Dropdown>
<!-- <router-link
v-if="canCreateBatch()"
:to="{
name: 'BatchForm',
@@ -16,7 +58,7 @@
</template>
{{ __('Create') }}
</Button>
</router-link>
</router-link> -->
</header>
<div class="p-5 pb-10">
<div
@@ -90,13 +132,15 @@ import {
Button,
call,
createListResource,
Dropdown,
FormControl,
Select,
TabButtons,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { Plus } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { ChevronDown, Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import BatchCard from '@/components/BatchCard.vue'
import EmptyState from '@/components/EmptyState.vue'
@@ -115,6 +159,7 @@ const is_student = computed(() => user.data?.is_student)
const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
const orderBy = ref('start_date')
const readOnlyMode = window.read_only_mode
const router = useRouter()
onMounted(() => {
setFiltersFromQuery()

View File

@@ -3,20 +3,51 @@
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<router-link
<Dropdown
placement="start"
side="bottom"
v-if="canCreateCourse()"
:to="{
name: 'CourseForm',
params: { courseName: 'new' },
}"
:options="[
{
label: __('New Course'),
icon: 'book-open',
onClick() {
router.push({
name: 'CourseForm',
params: { courseName: 'new' },
})
},
},
{
label: __('Import Course'),
icon: 'upload',
onClick() {
router.push({
name: 'NewDataImport',
params: { doctype: 'LMS Course' },
})
},
},
]"
>
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('Create') }}
</Button>
</router-link>
<template v-slot="{ open }">
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('Create') }}
<template #suffix>
<ChevronDown
:class="[
'w-4 h-4 stroke-1.5 ml-1 transform transition-transform',
open ? 'rotate-180' : '',
]"
/>
</template>
</Button>
</template>
</Dropdown>
</header>
<div class="p-5 pb-10">
<div
@@ -85,13 +116,14 @@ import {
Button,
call,
createListResource,
Dropdown,
FormControl,
Select,
TabButtons,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { Plus } from 'lucide-vue-next'
import { ChevronDown, Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { canCreateCourse } from '@/utils'
import CourseCard from '@/components/CourseCard.vue'

View File

@@ -0,0 +1,50 @@
<template>
<DataImport
:doctype="route.params.doctype"
:importName="route.params.importName"
:doctypeMap="doctypeMap"
/>
</template>
<script setup lang="ts">
import { usePageMeta } from 'frappe-ui'
import { DataImport } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session'
import { useRoute, useRouter } from 'vue-router'
import { inject, onMounted } from 'vue'
const { brand } = sessionStore()
const route = useRoute()
const router = useRouter()
const user = inject<any>('$user')
onMounted(() => {
if (!user.data?.is_moderator) {
router.push({
name: 'Courses',
})
}
})
const doctypeMap = {
'LMS Course': {
title: 'Courses',
listRoute: '/courses',
pageRoute: `/courses/docname`,
},
'LMS Batch': {
title: 'Batches',
listRoute: '/batches',
},
'LMS Category': {
title: 'Categories',
listRoute: '/lms',
},
}
usePageMeta(() => {
return {
title: __('Data Import'),
icon: brand.favicon,
}
})
</script>

View File

@@ -30,7 +30,7 @@
<div v-if="createdBatches.data?.length" class="mt-10">
<div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg">
<span class="font-semibold text-lg text-ink-gray-9">
{{ __('Upcoming Batches') }}
</span>
<router-link
@@ -88,7 +88,7 @@
<div class="grid grid-cols-2 gap-5 mt-10">
<div v-if="evals?.data?.length">
<div class="font-semibold text-lg mb-3">
<div class="font-semibold text-lg text-ink-gray-9 mb-3">
{{ __('Upcoming Evaluations') }}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
@@ -124,7 +124,7 @@
</div>
</div>
<div v-if="liveClasses?.data?.length">
<div class="font-semibold text-lg mb-3">
<div class="font-semibold text-lg text-ink-gray-9 mb-3">
{{ __('Upcoming Live Classes') }}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">

View File

@@ -17,86 +17,91 @@
</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 class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
{{ applicationCount }}
{{ applicationCount === 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"
<div v-if="applications.data?.length">
<ListView
:columns="applicationColumns"
:rows="applicantRows"
row-key="name"
:options="{
showTooltip: false,
selectable: false,
}"
>
<ListHeaderItem
:item="item"
v-for="item in applicationColumns"
:key="item.key"
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<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"
>
<Avatar
size="sm"
:image="row['user_image']"
:label="row['full_name']"
<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"
>
<Avatar
size="sm"
:image="row['user_image']"
:label="row['full_name']"
/>
<span>{{ item }}</span>
</div>
<div
v-else-if="column.key === 'actions'"
class="flex justify-center"
>
<Dropdown :options="getActionOptions(row)">
<Button variant="ghost">
<FeatherIcon name="more-horizontal" class="w-4 h-4" />
</Button>
</Dropdown>
</div>
<div
v-else-if="column.key === 'applied_on'"
class="text-sm text-ink-gray-6"
>
{{ item }}
</div>
<div v-else>
{{ item }}
</div>
</ListRowItem>
</ListRow>
</ListRows>
</ListView>
<span>{{ item }}</span>
</div>
<div
v-else-if="column.key === 'actions'"
class="flex justify-center"
>
<Dropdown :options="getActionOptions(row)">
<Button variant="ghost">
<FeatherIcon name="more-horizontal" class="w-4 h-4" />
</Button>
</Dropdown>
</div>
<div
v-else-if="column.key === 'applied_on'"
class="text-sm text-ink-gray-6"
>
{{ item }}
</div>
<div v-else>
{{ item }}
</div>
</ListRowItem>
</ListRow>
</ListRows>
</ListView>
<div class="flex justify-center mt-5">
<Button v-if="applications.hasNextPage" @click="applications.next()">
<template #prefix>
<RefreshCw class="size-4 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
</div>
</div>
<EmptyState v-else-if="!applications.loading" type="Job Applications" />
</div>
@@ -150,6 +155,7 @@ import {
Avatar,
Button,
Breadcrumbs,
call,
Dialog,
Dropdown,
FeatherIcon,
@@ -166,8 +172,8 @@ import {
usePageMeta,
toast,
} from 'frappe-ui'
import { inject, ref, computed, reactive } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import { computed, inject, onMounted, ref, reactive } from 'vue'
import { sessionStore } from '../stores/session'
import EmptyState from '@/components/EmptyState.vue'
@@ -175,6 +181,7 @@ const dayjs = inject('$dayjs')
const { brand } = sessionStore()
const showEmailModal = ref(false)
const selectedApplicant = ref(null)
const applicationCount = ref(0)
const emailForm = reactive({
subject: '',
message: '',
@@ -188,6 +195,19 @@ const props = defineProps({
},
})
onMounted(() => {
getApplicationCount()
})
const getApplicationCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Job Application',
filters: { job: props.job },
}).then((count) => {
applicationCount.value = count
})
}
const applications = createListResource({
doctype: 'LMS Job Application',
fields: [
@@ -253,7 +273,6 @@ const sendEmail = (close) => {
}
const downloadResume = (resumeUrl) => {
console.log(resumeUrl)
window.open(resumeUrl, '_blank')
}

View File

@@ -32,7 +32,7 @@
</Button>
</router-link>
<router-link
v-if="user.data.name == job.data?.owner"
v-if="canManageJob"
:to="{
name: 'JobForm',
params: { jobName: job.data?.name },
@@ -240,9 +240,7 @@ const redirectToWebsite = (url) => {
const canManageJob = computed(() => {
if (!user.data?.name || !job.data) return false
return (
user.data.name === job.data.owner || user.data.roles?.includes('Moderator')
)
return user.data.name === job.data.owner || user.data?.is_moderator
})
usePageMeta(() => {

View File

@@ -207,6 +207,11 @@ const jobDetail = createResource({
}
},
onSuccess(data) {
if (data.owner != user.data?.name && !user.data?.is_moderator) {
router.push({
name: 'Jobs',
})
}
Object.keys(data).forEach((key) => {
if (Object.hasOwn(job, key)) job[key] = data[key]
})
@@ -242,7 +247,11 @@ const job = reactive({
})
onMounted(() => {
if (!user.data) window.location.href = '/login'
if (!user.data) {
router.push({
name: 'Jobs',
})
}
if (props.jobName != 'new') jobDetail.reload()
})

View File

@@ -32,10 +32,13 @@
{{ __('{0} Open Jobs').format(jobCount) }}
</div>
<div
class="grid grid-cols-1 gap-2 md:grid-cols-4"
:class="user.data ? 'md:grid-cols-3' : 'md:grid-cols-2'"
>
<div class="flex items-center justify-between space-x-4">
<TabButtons
v-if="tabs.length > 1"
v-model="activeTab"
:buttons="tabs"
@change="updateJobs"
/>
<FormControl
type="text"
:placeholder="__('Search')"
@@ -55,13 +58,13 @@
doctype="Country"
v-model="country"
:placeholder="__('Country')"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
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-40 lg:min-w-0 lg:w-32 xl:w-40"
class="min-w-32 lg:min-w-0 lg:w-32 xl:w-32"
:placeholder="__('Type')"
@change="updateJobs"
/>
@@ -69,7 +72,7 @@
v-model="workMode"
type="select"
:options="workModes"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
class="min-w-32 lg:min-w-0 lg:w-32 xl:w-32"
:placeholder="__('Work Mode')"
@change="updateJobs"
/>
@@ -100,6 +103,7 @@ import {
call,
createResource,
FormControl,
TabButtons,
usePageMeta,
} from 'frappe-ui'
import { Plus, Search } from 'lucide-vue-next'
@@ -118,9 +122,38 @@ const country = ref(null)
const filters = ref({})
const orFilters = ref({})
const jobCount = ref(0)
const closedJobs = ref(0)
const activeTab = ref('Open')
const readOnlyMode = window.read_only_mode
onMounted(() => {
getClosedJobCount()
setFiltersFromURL()
updateJobs()
})
const isModerator = computed(() => {
return user.data?.is_moderator
})
const getClosedJobCount = () => {
const filters = {
status: 'Closed',
}
if (!isModerator.value) {
filters.owner = user.data?.name
}
call('frappe.client.get_count', {
doctype: 'Job Opportunity',
filters: filters,
}).then((count) => {
closedJobs.value = count
})
}
const setFiltersFromURL = () => {
let queries = new URLSearchParams(location.search)
if (queries.has('type')) {
jobType.value = queries.get('type')
@@ -128,7 +161,22 @@ onMounted(() => {
if (queries.has('work_mode')) {
workMode.value = queries.get('work_mode')
}
updateJobs()
}
const tabs = computed(() => {
const tabsArray = [
{
label: __('Open'),
},
]
if (closedJobs.value) {
tabsArray.push({
label: __('Closed'),
})
}
return tabsArray
})
const jobs = createResource({
@@ -149,7 +197,6 @@ const updateJobs = () => {
const updateFilters = () => {
filters.value.status = 'Open'
filters.value.disabled = 0
if (jobType.value) {
filters.value.type = jobType.value
@@ -178,8 +225,22 @@ const updateFilters = () => {
} else {
delete filters.value.country
}
if (activeTab.value === 'Closed') {
filters.value.status = 'Closed'
if (!isModerator.value) {
filters.value.owner = user.data?.name
}
} else {
filters.value.status = 'Open'
delete filters.value.owner
}
}
watch(activeTab, (val) => {
updateJobs()
})
watch(country, (val) => {
updateJobs()
})
@@ -190,7 +251,7 @@ watch(jobs, () => {
const jobTypes = computed(() => {
return [
'',
{ label: '', value: '' },
{ label: __('Full Time'), value: 'Full Time' },
{ label: __('Part Time'), value: 'Part Time' },
{ label: __('Contract'), value: 'Contract' },
@@ -200,7 +261,7 @@ const jobTypes = computed(() => {
const workModes = computed(() => {
return [
'',
{ label: '', value: '' },
{ label: 'On site', value: 'On-site' },
{ label: 'Hybrid', value: 'Hybrid' },
{ label: 'Remote', value: 'Remote' },

View File

@@ -58,15 +58,15 @@
</Button>
</div>
<ListView
v-if="programCourses.data.length > 0"
v-if="program.program_courses?.length > 0"
:columns="courseColumns"
:rows="programCourses.data"
:rows="program.program_courses"
:options="{
selectable: true,
resizeColumn: true,
showTooltip: false,
}"
rowKey="name"
:rowKey="programName === 'new' ? 'course' : 'name'"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
@@ -75,8 +75,8 @@
</ListHeader>
<ListRows>
<Draggable
:list="programCourses.data"
item-key="name"
:list="program.program_courses"
:item-key="programName === 'new' ? 'course' : 'name'"
group="items"
@end="updateOrder"
class="cursor-move"
@@ -133,14 +133,14 @@
</div>
</div>
<ListView
v-if="programMembers.data.length > 0"
v-if="program.program_members?.length > 0"
:columns="memberColumns"
:rows="programMembers.data"
:rows="program.program_members"
:options="{
selectable: true,
resizeColumn: true,
}"
rowKey="name"
:rowKey="programName === 'new' ? 'member' : 'name'"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
@@ -148,7 +148,7 @@
<ListHeaderItem :item="item" v-for="item in memberColumns" />
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in programMembers.data" />
<ListRow :row="row" v-for="row in program.program_members" />
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
@@ -217,13 +217,12 @@
/>
</template>
<template #actions="{ close }">
<div class="flex justify-end space-x-2 group">
<div class="flex justify-end space-x-2">
<Button
v-if="programName != 'new'"
@click="deleteProgram(close)"
variant="outline"
theme="red"
class="invisible group-hover:visible"
>
<template #prefix>
<Trash2 class="size-4 stroke-1.5" />
@@ -252,7 +251,7 @@ import {
ListRow,
toast,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { computed, ref, watch, getCurrentInstance } from 'vue'
import { Plus, Trash2, TrendingUp } from 'lucide-vue-next'
import { Programs, Program } from '@/types/programs'
import { escapeHTML, openSettings } from '@/utils'
@@ -269,6 +268,9 @@ const member = ref<string>('')
const showProgressDialog = ref(false)
const dirty = ref(false)
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
const props = withDefaults(
defineProps<{
programName: string | null
@@ -427,25 +429,22 @@ const addCourse = (close: () => void) => {
return
}
programCourses.insert.submit(
{
parent: props.programName,
parenttype: 'LMS Program',
parentfield: 'program_courses',
course: course.value,
idx: programCourses.data.length + 1,
},
{
onSuccess() {
updateCounts('course', 'add')
close()
toast.success(__('Course added to program successfully'))
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
}
const existingCourse = program.value.program_courses.find(
(c: any) => c.course === course.value
)
if (!existingCourse) {
program.value.program_courses.push({
course: course.value,
idx: program.value.program_courses.length + 1,
})
if (props.programName !== 'new') {
dirty.value = true
}
close()
toast.success(__('Course added to program successfully'))
} else {
toast.warning(__('Course already added to program'))
}
}
const addMember = (close: () => void) => {
@@ -454,24 +453,21 @@ const addMember = (close: () => void) => {
return
}
programMembers.insert.submit(
{
parent: props.programName,
parenttype: 'LMS Program',
parentfield: 'program_members',
member: member.value,
},
{
onSuccess() {
updateCounts('member', 'add')
close()
toast.success(__('Member added to program successfully'))
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
}
const existingMember = program.value.program_members.find(
(m) => m.member === member.value
)
if (!existingMember) {
program.value.program_members.push({
member: member.value,
})
if (props.programName !== 'new') {
dirty.value = true
}
close()
toast.success(__('Member added to program successfully'))
} else {
toast.warning(__('Member already added to program'))
}
}
const updateCounts = async (
@@ -509,57 +505,83 @@ const updateCounts = async (
const updateOrder = async (e: DragEvent) => {
let sourceIdx = e.from.dataset.idx
let targetIdx = e.to.dataset.idx
let courses = programCourses.data
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
for (const [index, course] of courses.entries()) {
programCourses.setValue.submit(
{
name: course.name,
idx: index + 1,
},
{
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
if (props.programName === 'new') {
let courses = program.value.program_courses
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
courses.forEach((course, index) => {
course.idx = index + 1
})
dirty.value = true
} else {
let courses = programCourses.data
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
for (const [index, course] of courses.entries()) {
programCourses.setValue.submit(
{
name: course.name,
idx: index + 1,
},
}
)
await wait(100)
{
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
}
)
await wait(100)
}
}
}
const wait = (ms: number) => new Promise((res) => setTimeout(res, ms))
const remove = async (
const remove = (
selections: string[],
unselectAll: () => void,
type: string
) => {
selections = Array.from(selections)
for (const selection of selections) {
if (type == 'courses') {
await programCourses.delete.submit(selection)
await updateCounts('course', 'remove')
} else {
await programMembers.delete.submit(selection)
await updateCounts('member', 'remove')
}
await programs.value.reload()
await wait(100)
const selectionsArray = Array.from(selections)
if (type === 'courses') {
program.value.program_courses = program.value.program_courses.filter(
(c: any) => !selectionsArray.includes(c.name || c.course)
)
} else {
program.value.program_members = program.value.program_members.filter(
(m: any) => !selectionsArray.includes(m.name || m.member)
)
}
dirty.value = true
unselectAll()
}
const deleteProgram = (close: () => void) => {
if (props.programName == 'new') return
programs.value?.delete.submit(props.programName, {
onSuccess() {
toast.success(__('Program deleted successfully'))
close()
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
$dialog({
title: __('Delete Program'),
message: __(
'Are you sure you want to delete this program? This action cannot be undone.'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick(closeDialog) {
programs.value?.delete.submit(props.programName, {
onSuccess() {
toast.success(__('Program deleted successfully'))
close()
closeDialog()
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
closeDialog()
},
})
},
},
],
})
}
@@ -567,7 +589,7 @@ const courseColumns = computed(() => {
return [
{
label: 'Title',
key: 'course_title',
key: props.programName === 'new' ? 'course' : 'course_title',
width: 1,
},
]

View File

@@ -31,11 +31,11 @@
categoryColumn: 'category',
valueColumn: 'count',
colors: [
theme.colors.red['400'],
theme.colors.amber['400'],
theme.colors.pink['400'],
theme.colors.blue['400'],
theme.colors.green['400'],
getColor('red', 400),
getColor('amber', 400),
getColor('pink', 400),
getColor('blue', 400),
getColor('green', 400),
],
}"
/>
@@ -74,7 +74,7 @@ import {
} from 'frappe-ui'
import type { ProgramMember } from '@/types'
import { computed, ref, watch } from 'vue'
import { theme } from '@/utils/theme'
import { getColor } from '@/utils'
const show = defineModel<boolean>({ default: false })
const searchFilter = ref<string | null>(null)