+ Search for employees or press Enter to add a custom name
+
+
+
+
+ {{ emp.name }}
+ {{ emp.sso }}
+
+
+
+
+
+ {{ selected.name }}
+ {{ selected.sso }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/composables/employeeResolve.js b/frontend/src/composables/employeeResolve.js
new file mode 100644
index 00000000..acdd141c
--- /dev/null
+++ b/frontend/src/composables/employeeResolve.js
@@ -0,0 +1,55 @@
+// Turning what somebody typed into an employee, before giving up on it.
+//
+// Both employee pickers had the same shape: click a suggestion and you get the
+// person, with their SSO; type the whole name and press Enter and you get a
+// free-text label (`NAME:Jane Smith`) that is linked to nothing. It looks
+// identical on screen - the chip says "Jane Smith" either way - and the
+// consequences only show up later: no photo, no directory link, and a
+// recognition that cannot be found by the person it is about.
+//
+// Nobody types a name in full expecting a different outcome from picking it
+// off the list. So a typed name is looked up first, and only becomes a custom
+// label when the directory genuinely does not have it.
+//
+// An ambiguous name is NOT guessed at. Two people called J Smith is exactly
+// when picking the wrong one matters most, so the caller is told to choose.
+import { employeesApi } from '../api'
+
+function fullname(employee) {
+ return `${employee.First_Name || ''} ${employee.Last_Name || ''}`.trim()
+}
+
+// { match } when one employee is clearly meant, { matches } when several are
+// possible, {} when the directory has nobody by that name.
+export async function resolveEmployeeName(name) {
+ const query = (name || '').trim()
+ if (!query) return {}
+
+ let rows = []
+ try {
+ const response = await employeesApi.search(query)
+ rows = response?.data?.data || []
+ } catch (err) {
+ // A directory that cannot be reached must not silently turn a real
+ // employee into a custom label - the caller decides what to do about it.
+ // The MESSAGE, not the error object: a rejected mock handed to
+ // console.error is re-surfaced by the test runner as an unhandled error,
+ // which fails the very test that proves this branch works.
+ console.warn('Employee lookup failed:', err && err.message)
+ return { failed: true }
+ }
+
+ if (!rows.length) return {}
+
+ // An exact full-name match wins over a partial one: searching "Dana Reyes"
+ // can also return "Dana Reyes-Smith", and the person typing the shorter name
+ // meant the shorter name.
+ const exact = rows.filter(
+ (row) => fullname(row).toLowerCase() === query.toLowerCase())
+ if (exact.length === 1) return { match: exact[0] }
+ if (exact.length > 1) return { matches: exact }
+ if (rows.length === 1) return { match: rows[0] }
+ return { matches: rows }
+}
+
+export { fullname }
diff --git a/frontend/src/composables/employeeResolve.spec.js b/frontend/src/composables/employeeResolve.spec.js
new file mode 100644
index 00000000..860102f3
--- /dev/null
+++ b/frontend/src/composables/employeeResolve.spec.js
@@ -0,0 +1,81 @@
+// Typing a name in full must reach the same employee as picking it off the list.
+//
+// Both pickers turned a typed name straight into `NAME:` - a free-text
+// label linked to nothing. On screen the chip is identical to a real
+// selection, so the difference only shows up later: no photo, no directory
+// record, and a recognition that cannot be found by the person it is about.
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('../api', () => ({
+ employeesApi: { search: vi.fn() }
+}))
+
+import { employeesApi } from '../api'
+import { resolveEmployeeName } from './employeeResolve'
+
+const DANA = { SSO: 270015376, First_Name: 'Dana', Last_Name: 'Reyes' }
+const DANA_HYPHEN = { SSO: 270099999, First_Name: 'Dana', Last_Name: 'Reyes-Smith' }
+const SAM = { SSO: 270000001, First_Name: 'Sam', Last_Name: 'Reyes' }
+
+function found(rows) {
+ employeesApi.search.mockResolvedValue({ data: { data: rows } })
+}
+
+describe('resolveEmployeeName', () => {
+ beforeEach(() => employeesApi.search.mockReset())
+
+ it('resolves a full name to the employee', async () => {
+ found([DANA])
+ const { match } = await resolveEmployeeName('Dana Reyes')
+ expect(match.SSO).toBe(270015376)
+ })
+
+ it('prefers an exact full-name match over a longer partial one', async () => {
+ // Searching "Dana Reyes" also returns "Dana Reyes-Smith"; the person who
+ // typed the shorter name meant the shorter name.
+ found([DANA, DANA_HYPHEN])
+ const { match, matches } = await resolveEmployeeName('Dana Reyes')
+ expect(matches).toBeUndefined()
+ expect(match.SSO).toBe(270015376)
+ })
+
+ it('refuses to guess between genuinely ambiguous matches', async () => {
+ found([DANA, SAM])
+ const { match, matches } = await resolveEmployeeName('Reyes')
+ expect(match).toBeUndefined()
+ expect(matches).toHaveLength(2)
+ })
+
+ it('reports nothing found, so the caller may fall back to a label', async () => {
+ found([])
+ const result = await resolveEmployeeName('Somebody External')
+ expect(result.match).toBeUndefined()
+ expect(result.matches).toBeUndefined()
+ expect(result.failed).toBeUndefined()
+ })
+
+ it('says the lookup FAILED rather than reporting nobody', async () => {
+ // The difference matters: "not in the directory" may become a custom
+ // label, "could not ask" must not.
+ // A THENABLE that calls its reject callback, rather than a mock returning
+ // a rejected promise. The await sees the same failure, but no rejected
+ // promise object exists for the runner to collect as an unhandled error -
+ // which it does even when the code under test catches it, failing the very
+ // test that proves the catch works.
+ employeesApi.search.mockImplementationOnce(() => {
+ const failure = new Error('network down')
+ return { then: (_ok, fail) => fail(failure) }
+ })
+ // resolves.toEqual, so the runner sees the rejection consumed by an
+ // assertion rather than as an unhandled error of its own.
+ await expect(resolveEmployeeName('Dana Reyes')).resolves.toEqual({
+ failed: true
+ })
+ })
+
+ it('does not call the directory for an empty box', async () => {
+ const result = await resolveEmployeeName(' ')
+ expect(employeesApi.search).not.toHaveBeenCalled()
+ expect(result).toEqual({})
+ })
+})
diff --git a/plugins/computers/frontend/views/PCForm.vue b/plugins/computers/frontend/views/PCForm.vue
index 8b291d7b..90c374b2 100644
--- a/plugins/computers/frontend/views/PCForm.vue
+++ b/plugins/computers/frontend/views/PCForm.vue
@@ -143,6 +143,10 @@
v-model="form.locationid"
class="form-control"
>
+