Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 172 additions & 21 deletions frontend/src/components/ListView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,15 @@
v-model="activeTab"
/>

<div
class="flex flex-col bg-white rounded mt-5"
v-if="!documents.loading && documents.data?.length && !managed_employees.loading"
>
<div
class="p-3.5 items-center justify-between border-b cursor-pointer"
v-for="link in documents.data"
:key="link.name"
class="flex flex-col bg-white rounded mt-5"
v-if="!isDocumentLoading && visibleDocuments?.length"
>
<div
class="p-3.5 items-center justify-between border-b cursor-pointer"
v-for="link in visibleDocuments"
:key="link.name"
>
<component
v-if="props.doctype === 'Employee Checkin'"
:is="listItemComponent[doctype]"
Expand All @@ -89,16 +89,16 @@
</router-link>
</div>
</div>
<EmptyState
:message="__('No {0} found', [props.doctype?.toLowerCase()])"
v-else-if="!documents.loading"
/>
<EmptyState
:message="__('No {0} found', [props.doctype?.toLowerCase()])"
v-else-if="!isDocumentLoading"
/>

<!-- Loading Indicator -->
<div v-if="documents.loading || managed_employees.loading" class="flex mt-2 items-center justify-center">
<LoadingIndicator class="w-8 h-8 text-gray-800" />
<!-- Loading Indicator -->
<div v-if="isDocumentLoading" class="flex mt-2 items-center justify-center">
<LoadingIndicator class="w-8 h-8 text-gray-800" />
</div>
</div>
</div>
</div>

<CustomIonModal trigger="show-filter-modal">
Expand Down Expand Up @@ -130,7 +130,7 @@
</template>

<script setup>
import { useRouter } from "vue-router"
import { useRouter, useRoute } from "vue-router"
import { inject, ref, markRaw, watch, computed, reactive, onMounted } from "vue"
import {
modalController,
Expand Down Expand Up @@ -203,11 +203,25 @@ const listItemComponent = {
}

const router = useRouter()
const route = useRoute()
const dayjs = inject("$dayjs")
const socket = inject("$socket")
const employee = inject("$employee")
const filterMap = reactive({})
const activeTab = ref(props.tabButtons ? getButtonKey(props.tabButtons[0]) : undefined)

const normalizeTabValue = (value) => `${value ?? ""}`.replace(/\+/g, " ").trim().toLowerCase()

const resolveTabFromQuery = () => {
if (!props.tabButtons) return undefined
const requestedTab = normalizeTabValue(route.query?.tab)
if (!requestedTab) return undefined

return props.tabButtons
.map((tab) => getButtonKey(tab))
.find((tab) => normalizeTabValue(tab) === requestedTab)
}

const activeTab = ref(resolveTabFromQuery() || (props.tabButtons ? getButtonKey(props.tabButtons[0]) : undefined))
const areFiltersApplied = ref(false)
const appliedFilters = ref([])
const workflowStateField = ref(null)
Expand All @@ -230,6 +244,10 @@ const isTeamRequest = computed(() => {
return props.tabButtons && activeTab.value === getButtonKey(props.tabButtons[1])
})

const isTeamLeaveRequest = computed(() => {
return props.doctype === "Leave Application" && isTeamRequest.value
})

const formViewRoute = computed(() => {
return `${props.doctype.replace(/\s+/g, "")}FormView`
})
Expand All @@ -250,6 +268,82 @@ const defaultFilters = computed(() => {
return filters
})

const visibleDocuments = computed(() => {
if (isTeamLeaveRequest.value) {
return teamLeaveDocuments.data
}
return documents.data
})

const isDocumentLoading = computed(() => {
if (isTeamLeaveRequest.value) {
return teamLeaveDocuments.loading || managed_employees.loading
}
return documents.loading || managed_employees.loading
})

function applyLocalFilters(docs) {
if (!appliedFilters.value?.length) {
return docs
}

const toComparable = (value) => {
if (value === undefined || value === null || value === "") {
return null
}

const valueAsString = `${value}`.trim()
if (/^-?\d+(\.\d+)?$/.test(valueAsString)) {
return Number(valueAsString)
}

const dateValue = dayjs(value)
if (dateValue.isValid()) {
return dateValue.valueOf()
}

return valueAsString.toLowerCase()
}

return docs.filter((doc) => {
return appliedFilters.value.every((filter) => {
const [, fieldname, condition, value] = filter
const docValue = doc?.[fieldname]
if (!condition) return true
const normalizedCondition = `${condition}`.trim()

if (normalizedCondition === "=") {
return `${docValue ?? ""}` === `${value ?? ""}`
Comment on lines +315 to +316

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard local Team Leaves filters against missing fields

Team Leaves filtering is now done client-side, but this equality check treats absent fields as empty strings, so any filter on a field not present in the fetched payload (for example department) rejects every row and shows a false empty state. Since get_leave_applications_for_approval does not return all filterable fields, this path regresses valid filters into “no data” for approvers.

Useful? React with 👍 / 👎.

}
if (normalizedCondition === "!=") {
return `${docValue ?? ""}` !== `${value ?? ""}`
}
if (normalizedCondition === "Like" || normalizedCondition === "like") {
return `${docValue ?? ""}`.toLowerCase().includes(`${value ?? ""}`.toLowerCase())
}
if (
normalizedCondition === ">" ||
normalizedCondition === "<" ||
normalizedCondition === ">=" ||
normalizedCondition === "<="
) {
const left = toComparable(docValue)
const right = toComparable(value)
if (left === null || right === null) {
return false
}

if (normalizedCondition === ">") return left > right
if (normalizedCondition === "<") return left < right
if (normalizedCondition === ">=") return left >= right
if (normalizedCondition === "<=") return left <= right
}

return true
})
})
}

// resources
const documents = createResource({
url: "frappe.desk.reportview.get",
Expand All @@ -273,8 +367,11 @@ const documents = createResource({
})
return doc
})
// filter out the non managed_employees from docs
docs = docs.filter(item => managed_employees.data.includes(item.employee))
// Team requests must always be scoped to managed employees.
const managedEmployees = managed_employees.data || []
if (isTeamRequest.value) {
docs = docs.filter((item) => managedEmployees.includes(item.employee))
}


let pagedData
Expand All @@ -288,6 +385,27 @@ const documents = createResource({
},
})

const teamLeaveDocuments = createResource({
url: "hrms.api.get_leave_applications_for_approval",
onSuccess: (data) => {
const start = teamLeaveDocuments.params?.start || 0
if ((data?.length || 0) <= start + listOptions.value.page_length) {
hasNextPage.value = false
}
},
transform(data) {
const start = teamLeaveDocuments.params?.start || 0
const filtered = applyLocalFilters(Array.isArray(data) ? [...data] : [])

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep Team Leaves filters server-compatible

In Team Leaves mode this code applies all filters client-side, but hrms.api.get_leave_applications_for_approval only returns a limited field set (it does not include fields like department in hrms/api/__init__.py), so selecting Department in Leave History makes doc.department undefined for every row and the equality check filters out all records. This causes false empty results specifically when users apply filters that are present in the UI but missing from the Team Leaves payload.

Useful? React with 👍 / 👎.

const end = start + listOptions.value.page_length
const pageSlice = filtered.slice(start, end)

if (!start) {
return pageSlice
}
return (teamLeaveDocuments.data || []).concat(pageSlice)
},
})

const createPermission = createResource({
url: "frappe.client.has_permission",
params: { doctype: props.doctype, docname: null, perm_type: "create" },
Expand Down Expand Up @@ -358,13 +476,26 @@ function fetchDocumentList(start = 0) {
hasNextPage.value = true
}

if (isTeamLeaveRequest.value) {
teamLeaveDocuments.submit({
employee: employee.data.name,
approver_id: employee.data.user_id,
for_approval: 1,
limit: 500,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove hard 500-row cap from Team Leaves pagination

Team Leaves fetches are hard-limited to 500 records, and subsequent scroll loads only page through that same capped result, so approvers with more than 500 eligible leave applications can never reach older entries. This is a data-visibility regression that appears in larger datasets where Team Leaves exceeds the fixed cap.

Useful? React with 👍 / 👎.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove fixed 500-row cap from Team Leaves pagination

The Team Leaves request hard-codes limit: 500 and then paginates by slicing client-side, so records beyond the first 500 can never be reached even when infinite scroll continues. In environments with large teams or long histories, approvers will silently miss older applications.

Useful? React with 👍 / 👎.

start: start || 0,
})
return
}

const filters = [[props.doctype, "docstatus", "!=", "2"]]
filters.push(...defaultFilters.value)

if (appliedFilters.value) filters.push(...appliedFilters.value)

if (workflowStateField.value) {
listOptions.value.fields.push(workflowStateField.value)
if (!listOptions.value.fields.includes(workflowStateField.value)) {
listOptions.value.fields.push(workflowStateField.value)
}
}

documents.submit({
Expand All @@ -381,7 +512,8 @@ const handleScroll = debounce(() => {
const scrollPercentage = (scrollTop / (scrollHeight - clientHeight)) * 100

if (scrollPercentage >= 90) {
const start = documents.params.start + listOptions.value.page_length
const params = isTeamLeaveRequest.value ? teamLeaveDocuments.params : documents.params
const start = (params?.start || 0) + listOptions.value.page_length
fetchDocumentList(start)
}
}, 500)
Expand All @@ -400,6 +532,25 @@ watch(
}
)

watch(
() => managed_employees.loading,
(isLoading, wasLoading) => {
if (wasLoading && !isLoading && isTeamRequest.value) {
fetchDocumentList()
}
}
)

watch(
() => route.query?.tab,
() => {
const queryTab = resolveTabFromQuery()
if (queryTab && queryTab !== activeTab.value) {
activeTab.value = queryTab
}
}
)

onMounted(async () => {
const workflow = useWorkflow(props.doctype)
await workflow.workflowDoc.promise
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/data/employees.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import router from "@/router"
import { createResource } from "frappe-ui"
import { reactive } from "vue"
import { employeeResource } from "./employee"
Expand All @@ -9,10 +10,16 @@ export const employees = createResource({
url: "hrms.api.get_all_employees",
auto: true,
transform(data) {
return data.map((employee) => {
Object.keys(employeesByID).forEach((key) => delete employeesByID[key])
Object.keys(employeesByUserID).forEach((key) => delete employeesByUserID[key])

const rows = Array.isArray(data) ? data : []
return rows.map((employee) => {
employee.isActive = employee.status === "Active"
employeesByID[employee.name] = employee
employeesByUserID[employee.user_id] = employee
if (employee.user_id) {
employeesByUserID[employee.user_id] = employee
}

return employee
})
Expand Down
Loading