Remediate the 2026-09-03 feature-test findings
This commit is contained in:
@@ -1,251 +1,272 @@
|
||||
<template>
|
||||
<div class="employee-search">
|
||||
<div class="employee-search-container">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="onSearch"
|
||||
@keydown.enter.prevent="addCustom"
|
||||
/>
|
||||
<div v-if="results.length" class="employee-dropdown">
|
||||
<div
|
||||
v-for="emp in results"
|
||||
:key="emp.SSO"
|
||||
class="employee-option"
|
||||
@click="selectEmployee(emp)"
|
||||
>
|
||||
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
|
||||
<span class="emp-sso">{{ emp.SSO }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="allowCustom" class="form-hint">Search for employees or press Enter to add a custom name</small>
|
||||
|
||||
<!-- Selected employee(s) display -->
|
||||
<div v-if="multiple && selectedList.length" class="selected-employees">
|
||||
<div
|
||||
v-for="(emp, idx) in selectedList"
|
||||
:key="idx"
|
||||
class="selected-employee"
|
||||
>
|
||||
<span>{{ emp.name }}</span>
|
||||
<span v-if="emp.sso && !emp.sso.startsWith('NAME:')" class="emp-sso-tag">{{ emp.sso }}</span>
|
||||
<button v-if="!disabled" type="button" class="btn-remove" @click="removeEmployee(idx)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!multiple && selected" class="selected-employee-display">
|
||||
<span>{{ selected.name }}</span>
|
||||
<span v-if="selected.sso" class="emp-sso-tag">{{ selected.sso }}</span>
|
||||
<button v-if="!disabled" type="button" class="btn-remove" @click="clearSelection">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { employeesApi } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Object, Array], default: null },
|
||||
multiple: { type: Boolean, default: false },
|
||||
allowCustom: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: 'Search by name...' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
const results = ref([])
|
||||
const selected = ref(null)
|
||||
const selectedList = ref([])
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
// Initialize from modelValue
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (props.multiple) {
|
||||
selectedList.value = val || []
|
||||
} else {
|
||||
selected.value = val
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function onSearch() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
|
||||
const query = searchQuery.value.trim()
|
||||
if (query.length < 2) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await employeesApi.search(query)
|
||||
results.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Employee search error:', err)
|
||||
results.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectEmployee(emp) {
|
||||
const employee = {
|
||||
sso: String(emp.SSO),
|
||||
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
// Check if already selected
|
||||
if (selectedList.value.some(e => e.sso === employee.sso)) {
|
||||
return
|
||||
}
|
||||
selectedList.value.push(employee)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
} else {
|
||||
selected.value = employee
|
||||
emit('update:modelValue', employee)
|
||||
}
|
||||
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
function addCustom() {
|
||||
if (!props.allowCustom) return
|
||||
|
||||
const name = searchQuery.value.trim()
|
||||
if (!name) return
|
||||
|
||||
const employee = {
|
||||
sso: `NAME:${name}`,
|
||||
name: name
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
selectedList.value.push(employee)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
} else {
|
||||
selected.value = employee
|
||||
emit('update:modelValue', employee)
|
||||
}
|
||||
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
function removeEmployee(idx) {
|
||||
selectedList.value.splice(idx, 1)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selected.value = null
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.employee-search-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.employee-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card-solid, #1a1a1a);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.employee-option {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.employee-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.emp-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.emp-sso {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Multiple selection */
|
||||
.selected-employees {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.selected-employee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Single selection */
|
||||
.selected-employee-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.emp-sso-tag {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="employee-search">
|
||||
<div class="employee-search-container">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="onSearch"
|
||||
@keydown.enter.prevent="addCustom"
|
||||
/>
|
||||
<div v-if="results.length" class="employee-dropdown">
|
||||
<div
|
||||
v-for="emp in results"
|
||||
:key="emp.SSO"
|
||||
class="employee-option"
|
||||
@click="selectEmployee(emp)"
|
||||
>
|
||||
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
|
||||
<span class="emp-sso">{{ emp.SSO }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="allowCustom" class="form-hint">Search for employees or press Enter to add a custom name</small>
|
||||
|
||||
<!-- Selected employee(s) display -->
|
||||
<div v-if="multiple && selectedList.length" class="selected-employees">
|
||||
<div
|
||||
v-for="(emp, idx) in selectedList"
|
||||
:key="idx"
|
||||
class="selected-employee"
|
||||
>
|
||||
<span>{{ emp.name }}</span>
|
||||
<span v-if="emp.sso && !emp.sso.startsWith('NAME:')" class="emp-sso-tag">{{ emp.sso }}</span>
|
||||
<button v-if="!disabled" type="button" class="btn-remove" @click="removeEmployee(idx)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!multiple && selected" class="selected-employee-display">
|
||||
<span>{{ selected.name }}</span>
|
||||
<span v-if="selected.sso" class="emp-sso-tag">{{ selected.sso }}</span>
|
||||
<button v-if="!disabled" type="button" class="btn-remove" @click="clearSelection">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { employeesApi } from '../api'
|
||||
import { resolveEmployeeName } from '../composables/employeeResolve'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Object, Array], default: null },
|
||||
multiple: { type: Boolean, default: false },
|
||||
allowCustom: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: 'Search by name...' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
const results = ref([])
|
||||
const selected = ref(null)
|
||||
const selectedList = ref([])
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
// Initialize from modelValue
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (props.multiple) {
|
||||
selectedList.value = val || []
|
||||
} else {
|
||||
selected.value = val
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function onSearch() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
|
||||
const query = searchQuery.value.trim()
|
||||
if (query.length < 2) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await employeesApi.search(query)
|
||||
results.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Employee search error:', err)
|
||||
results.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectEmployee(emp) {
|
||||
const employee = {
|
||||
sso: String(emp.SSO),
|
||||
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
// Check if already selected
|
||||
if (selectedList.value.some(e => e.sso === employee.sso)) {
|
||||
return
|
||||
}
|
||||
selectedList.value.push(employee)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
} else {
|
||||
selected.value = employee
|
||||
emit('update:modelValue', employee)
|
||||
}
|
||||
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
async function addCustom() {
|
||||
const name = searchQuery.value.trim()
|
||||
if (!name) return
|
||||
|
||||
// Look it up before deciding it is free text. Picking a suggestion gave a
|
||||
// real SSO; typing the same name in full and pressing Enter gave
|
||||
// `NAME:<text>`, linked to nothing - and the chip looked the same either
|
||||
// way. Nobody types a name in full expecting a different record.
|
||||
const { match, matches, failed } = await resolveEmployeeName(name)
|
||||
if (match) {
|
||||
selectEmployee(match)
|
||||
return
|
||||
}
|
||||
if (matches && matches.length) {
|
||||
// Ambiguous: show the candidates rather than guessing, which is exactly
|
||||
// when guessing wrong matters.
|
||||
results.value = matches
|
||||
return
|
||||
}
|
||||
if (failed) {
|
||||
// The directory is unreachable. Falling through to a custom label here
|
||||
// would quietly turn a real employee into free text.
|
||||
return
|
||||
}
|
||||
if (!props.allowCustom) return
|
||||
|
||||
const employee = {
|
||||
sso: `NAME:${name}`,
|
||||
name: name
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
selectedList.value.push(employee)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
} else {
|
||||
selected.value = employee
|
||||
emit('update:modelValue', employee)
|
||||
}
|
||||
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
function removeEmployee(idx) {
|
||||
selectedList.value.splice(idx, 1)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selected.value = null
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.employee-search-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.employee-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card-solid, #1a1a1a);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.employee-option {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.employee-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.emp-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.emp-sso {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Multiple selection */
|
||||
.selected-employees {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.selected-employee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Single selection */
|
||||
.selected-employee-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.emp-sso-tag {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,83 +1,96 @@
|
||||
<template>
|
||||
<div class="pagination-bar" v-if="totalPages > 1 || showPerPage">
|
||||
<div class="pagination-info">
|
||||
<select
|
||||
v-if="showPerPage"
|
||||
:value="perPage"
|
||||
class="perpage-select"
|
||||
@change="$emit('update:perPage', Number($event.target.value))"
|
||||
>
|
||||
<option v-for="opt in perPageOptions" :key="opt" :value="opt">
|
||||
{{ opt }} per page
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button :disabled="page === 1" @click="$emit('update:page', page - 1)">
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="p in visiblePages"
|
||||
:key="p"
|
||||
:class="{ active: p === page, ellipsis: p === '...' }"
|
||||
:disabled="p === '...'"
|
||||
@click="p !== '...' && $emit('update:page', p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
|
||||
<button :disabled="page === totalPages" @click="$emit('update:page', page + 1)">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
page: { type: Number, required: true },
|
||||
totalPages: { type: Number, required: true },
|
||||
perPage: { type: Number, default: 20 },
|
||||
showPerPage: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
defineEmits(['update:page', 'update:perPage'])
|
||||
|
||||
const perPageOptions = [10, 20, 50, 100]
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const total = props.totalPages
|
||||
const current = props.page
|
||||
const pages = []
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) pages.push(i)
|
||||
return pages
|
||||
}
|
||||
|
||||
pages.push(1)
|
||||
|
||||
if (current > 3) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
const start = Math.max(2, current - 1)
|
||||
const end = Math.min(total - 1, current + 1)
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (current < total - 2) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
pages.push(total)
|
||||
|
||||
return pages
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="pagination-bar" v-if="totalPages > 1 || showPerPage">
|
||||
<div class="pagination-info">
|
||||
<select
|
||||
v-if="showPerPage"
|
||||
:value="perPage"
|
||||
class="perpage-select"
|
||||
@change="$emit('update:perPage', Number($event.target.value))"
|
||||
>
|
||||
<option v-for="opt in perPageOptions" :key="opt" :value="opt">
|
||||
{{ opt }} per page
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button :disabled="page === 1" @click="$emit('update:page', page - 1)">
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="p in visiblePages"
|
||||
:key="p"
|
||||
:class="{ active: p === page, ellipsis: p === '...' }"
|
||||
:disabled="p === '...'"
|
||||
@click="p !== '...' && $emit('update:page', p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
|
||||
<button :disabled="page === totalPages" @click="$emit('update:page', page + 1)">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
page: { type: Number, required: true },
|
||||
totalPages: { type: Number, required: true },
|
||||
perPage: { type: Number, default: 20 },
|
||||
showPerPage: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
defineEmits(['update:page', 'update:perPage'])
|
||||
|
||||
// A page size the caller actually uses must be selectable, or the control renders
|
||||
// blank: NetworkDevicesList defaults to 25, which was in no option, so
|
||||
// :value="25" matched nothing and the box showed empty - the operator could not
|
||||
// see the current page size at all. Any size the component is handed is folded
|
||||
// in rather than silently dropped.
|
||||
const BASE_PER_PAGE_OPTIONS = [10, 20, 50, 100]
|
||||
|
||||
const perPageOptions = computed(() => {
|
||||
const current = Number(props.perPage)
|
||||
if (!current || BASE_PER_PAGE_OPTIONS.includes(current)) {
|
||||
return BASE_PER_PAGE_OPTIONS
|
||||
}
|
||||
return [...BASE_PER_PAGE_OPTIONS, current].sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const total = props.totalPages
|
||||
const current = props.page
|
||||
const pages = []
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) pages.push(i)
|
||||
return pages
|
||||
}
|
||||
|
||||
pages.push(1)
|
||||
|
||||
if (current > 3) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
const start = Math.max(2, current - 1)
|
||||
const end = Math.min(total - 1, current + 1)
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (current < total - 2) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
pages.push(total)
|
||||
|
||||
return pages
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user