Establishes the framework's foundation as a multi-site adoptable platform. ADRs (migrations/adr/): - ADR-001 (ACCEPTED): Asset is the platform contract; Machine retires. Three relationship types (partof, controls, connectedto) with free-text label, position-resolution chain (asset > related > location), hierarchical locations, sibling-bay propagation. - ADR-002 (ACCEPTED): Plugin contract semver via __contract_version__. - ADR-003 (ACCEPTED): Hybrid plugin distribution (in-tree bundled + filesystem-based external). - ADR-004 (ACCEPTED): Per-site instances, not multi-tenant. - ADR-005 (ACCEPTED): Equipment plugin (manufacturing) split from measuringtools plugin (metrology). Subtype-table pattern for protocol data (FOCAS, CLM, MTConnect). - ADR-006 (ACCEPTED): Plugin collector contract via get_collector_schema hook with API-key auth and identity-based upsert. Naming convention v1 (CONTRIBUTING.md): - DB tables/columns: lowercase concatenated, no underscores or dashes - DB-mirrored Python/JS variables match column names exactly; pure code follows host-language convention (PEP 8 / camelCase) - Closed acronym allowlist (universal + shop-floor domain), banned shorthand list with suffix exception (printers_bp etc allowed) - Plain ASCII everywhere: chat, docs, comments, string literals Style enforcement (scripts/check-naming-and-style.sh): - Pre-commit-runnable check script: non-ASCII, banned shorthand, snake_case DB names, snake_case API params in frontend - Fixes 14 violations across 11 files (Unicode arrows, snake_case params, ctx -> canvasContext, res -> response, req -> request_obj) Project state (CLAUDE.md, README.md, frontend/CLAUDE.md): - De-staled CLAUDE.md to reflect actual current state - README unifies DB story (MySQL canonical, SQLite test-only) - frontend/CLAUDE.md points at root convention Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
252 lines
5.7 KiB
Vue
252 lines
5.7 KiB
Vue
<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>
|