Operators reported losing a part-filled form by clicking slightly outside it. Every data-entry modal closed on a backdrop click with no warning and no way back - the worst possible response to a misplaced click, and it happens most to someone adding their first records at a new site. Close-on-overlay is removed from 35 modals across 30 files: anything containing an input, textarea, select or v-model. They still close by Cancel or the X. Confirmation dialogs keep it, because a delete prompt holds nothing to lose and dismissing one by clicking away is the behaviour people expect. VendorsList shows the distinction - its edit form no longer closes that way, its delete confirmation still does. The shared Modal component now defaults closeOnOverlay to FALSE. Every current caller holds a form, a checkout, a stock adjustment or a map position being picked, and not one passed the prop, so all of them had the same fault. A modal that genuinely wants dismissing that way opts in explicitly. Also regroups the operator console menu, which had grown to numbers 1-9 plus three letters bolted on with no order to them. Actions are now grouped by what they touch, keyed by their first letter, and the old numbers still work so nobody who has used it for months is stopped by a rearrangement. The menu also warns when the server is not fully provisioned and names the key that fixes it, instead of reporting it as ordinary status lines that read as normal unless you already knew what to look for. That check is cached for the session because it shells out to flask twice and the answer does not change while somebody reads the screen.
304 lines
12 KiB
Vue
304 lines
12 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Employee Directory</h2>
|
|
<div class="header-actions">
|
|
<button class="btn btn-secondary" @click="showImport = true" :disabled="!selfhosted">Import CSV</button>
|
|
<button class="btn btn-primary" @click="openModal()" :disabled="!selfhosted">+ Add Person</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!selfhosted" class="card">
|
|
<p class="hint">
|
|
The directory is in <strong>external</strong> mode - people come from a
|
|
separate HR database, so there is nothing to manage here. To manage
|
|
people in-app, set <code>employee_directory_mode</code> to
|
|
<code>selfhosted</code> under Site & Facility settings, then reload.
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else class="card">
|
|
<div class="filters">
|
|
<input v-model="search" type="text" class="form-control" placeholder="Search name or SSO..." />
|
|
<span class="result-count">{{ filtered.length }} of {{ items.length }}</span>
|
|
</div>
|
|
<div v-if="loading" class="muted">Loading...</div>
|
|
<template v-else>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr><th>SSO</th><th>Name</th><th>Team</th><th>Role</th><th>Photo</th><th>Actions</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="e in paginated" :key="e.SSO">
|
|
<td class="mono">{{ e.SSO }}</td>
|
|
<td>{{ e.First_Name }} {{ e.Last_Name }}</td>
|
|
<td>{{ e.Team || '-' }}</td>
|
|
<td>{{ e.Role || '-' }}</td>
|
|
<td>
|
|
<img :src="e.photourl || fallbackAvatar" alt="Photo" class="photo-thumb"
|
|
:class="{ 'ge-thumb-fallback': !e.photourl }" />
|
|
</td>
|
|
<td class="actions">
|
|
<button class="btn btn-secondary btn-sm" @click="openModal(e)">Edit</button>
|
|
<button class="btn btn-danger btn-sm" @click="remove(e)">Delete</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="filtered.length === 0">
|
|
<td colspan="6" style="text-align:center;color:var(--text-light);">No people yet</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div v-if="totalPages > 1" class="pagination">
|
|
<button class="btn btn-secondary btn-sm" :disabled="page===1" @click="page--">Prev</button>
|
|
<span class="page-info">Page {{ page }} of {{ totalPages }}</span>
|
|
<button class="btn btn-secondary btn-sm" :disabled="page===totalPages" @click="page++">Next</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Add / edit modal -->
|
|
<div v-if="showModal" class="modal-overlay">
|
|
<div class="modal">
|
|
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Person</h3></div>
|
|
<form @submit.prevent="save">
|
|
<div class="modal-body">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>SSO *</label>
|
|
<input v-model="form.SSO" type="number" class="form-control" :disabled="!!editing" required />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Photo filename</label>
|
|
<input v-model="form.Picture" type="text" class="form-control" placeholder="123456.jpg" />
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label>First name *</label><input v-model="form.First_Name" type="text" class="form-control" required /></div>
|
|
<div class="form-group"><label>Last name *</label><input v-model="form.Last_Name" type="text" class="form-control" required /></div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label>Team</label><input v-model="form.Team" type="text" class="form-control" /></div>
|
|
<div class="form-group"><label>Role</label><input v-model="form.Role" type="text" class="form-control" /></div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Photo</label>
|
|
<div class="photo-manage">
|
|
<img v-if="form.photourl" :src="form.photourl" alt="Employee photo" class="photo-thumb-lg" />
|
|
<div class="photo-actions">
|
|
<template v-if="editing">
|
|
<input
|
|
ref="photoFileInput"
|
|
type="file"
|
|
accept=".png,.jpg,.jpeg,.gif,.webp"
|
|
style="display: none"
|
|
@change="onPhotoSelected"
|
|
/>
|
|
<button type="button" class="btn btn-secondary btn-sm" :disabled="uploadingPhoto" @click="triggerPhotoUpload">
|
|
{{ uploadingPhoto ? 'Uploading...' : (form.photourl ? 'Replace' : 'Upload') }}
|
|
</button>
|
|
<button v-if="form.photourl" type="button" class="btn btn-danger btn-sm" @click="removePhoto">Remove</button>
|
|
</template>
|
|
<small v-else class="hint">Save the person first, then upload a photo.</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="error" class="error-message">{{ error }}</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CSV import modal -->
|
|
<div v-if="showImport" class="modal-overlay">
|
|
<div class="modal">
|
|
<div class="modal-header"><h3>Import CSV</h3></div>
|
|
<div class="modal-body">
|
|
<p class="hint">Headers: <code>SSO,First_Name,Last_Name,Team,Role,Picture</code>. Existing SSOs are updated.</p>
|
|
<input type="file" accept=".csv,text/csv" @change="onFile" />
|
|
<textarea v-model="csvText" class="form-control" rows="8" placeholder="or paste CSV here" style="margin-top:0.6rem;"></textarea>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" @click="showImport = false">Cancel</button>
|
|
<button class="btn btn-primary" :disabled="importing || !csvText.trim()" @click="doImport">{{ importing ? 'Importing...' : 'Import' }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, onMounted } from 'vue'
|
|
import { employeesApi, settingsApi } from '@/api'
|
|
import { useToast } from '@/composables/toast'
|
|
import { apiError } from '@/utils/apiError'
|
|
|
|
const toast = useToast()
|
|
const selfhosted = ref(false)
|
|
const items = ref([])
|
|
const loading = ref(true)
|
|
const search = ref('')
|
|
const page = ref(1)
|
|
const perPage = 25
|
|
const showModal = ref(false)
|
|
const editing = ref(null)
|
|
const saving = ref(false)
|
|
const error = ref('')
|
|
const form = ref(blank())
|
|
const showImport = ref(false)
|
|
const csvText = ref('')
|
|
const importing = ref(false)
|
|
const photoFileInput = ref(null)
|
|
const uploadingPhoto = ref(false)
|
|
|
|
function blank() { return { SSO: '', First_Name: '', Last_Name: '', Team: '', Role: '', Picture: '', photourl: '' } }
|
|
|
|
const filtered = computed(() => {
|
|
const term = search.value.trim().toLowerCase()
|
|
if (!term) return items.value
|
|
return items.value.filter(e =>
|
|
`${e.First_Name} ${e.Last_Name}`.toLowerCase().includes(term) ||
|
|
String(e.SSO).includes(term))
|
|
})
|
|
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / perPage)))
|
|
const paginated = computed(() => filtered.value.slice((page.value - 1) * perPage, page.value * perPage))
|
|
watch(search, () => { page.value = 1 })
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const response = await settingsApi.get('employee_directory_mode')
|
|
selfhosted.value = (response.data?.data?.value || 'external') === 'selfhosted'
|
|
} catch (err) { /* default external */ }
|
|
if (selfhosted.value) await load()
|
|
else loading.value = false
|
|
})
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
try {
|
|
const response = await employeesApi.directory.list()
|
|
items.value = response.data.data || []
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Could not load directory'))
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function openModal(item = null) {
|
|
editing.value = item
|
|
form.value = item ? { ...item } : blank()
|
|
error.value = ''
|
|
showModal.value = true
|
|
}
|
|
function closeModal() { showModal.value = false; editing.value = null }
|
|
|
|
async function save() {
|
|
error.value = ''
|
|
saving.value = true
|
|
try {
|
|
if (editing.value) await employeesApi.directory.update(editing.value.SSO, form.value)
|
|
else await employeesApi.directory.create(form.value)
|
|
closeModal()
|
|
load()
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Failed to save')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
function triggerPhotoUpload() {
|
|
photoFileInput.value?.click()
|
|
}
|
|
|
|
async function onPhotoSelected(event) {
|
|
const file = event.target.files?.[0]
|
|
if (!file || !editing.value) return
|
|
uploadingPhoto.value = true
|
|
try {
|
|
const response = await employeesApi.directory.uploadPhoto(editing.value.SSO, file)
|
|
// Backend returns the updated employee with photourl set to the served URL.
|
|
form.value.photourl = response.data.data.photourl || ''
|
|
form.value.photofilename = response.data.data.photofilename || ''
|
|
toast.success('Photo uploaded')
|
|
load()
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Failed to upload photo'))
|
|
} finally {
|
|
uploadingPhoto.value = false
|
|
if (photoFileInput.value) photoFileInput.value.value = ''
|
|
}
|
|
}
|
|
|
|
async function removePhoto() {
|
|
if (!editing.value) return
|
|
if (!confirm('Remove this photo?')) return
|
|
try {
|
|
await employeesApi.directory.removePhoto(editing.value.SSO)
|
|
form.value.photourl = ''
|
|
form.value.photofilename = ''
|
|
toast.success('Photo removed')
|
|
load()
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Failed to remove photo'))
|
|
}
|
|
}
|
|
|
|
async function remove(e) {
|
|
if (!confirm(`Remove ${e.First_Name} ${e.Last_Name}?`)) return
|
|
try {
|
|
await employeesApi.directory.remove(e.SSO)
|
|
load()
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Failed to delete'))
|
|
}
|
|
}
|
|
|
|
function onFile(event) {
|
|
const file = event.target.files[0]
|
|
if (!file) return
|
|
const reader = new FileReader()
|
|
reader.onload = () => { csvText.value = reader.result }
|
|
reader.readAsText(file)
|
|
}
|
|
|
|
async function doImport() {
|
|
importing.value = true
|
|
try {
|
|
const response = await employeesApi.directory.importCsv(csvText.value)
|
|
toast.success(response.data?.message || 'Imported')
|
|
showImport.value = false
|
|
csvText.value = ''
|
|
load()
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Import failed'))
|
|
} finally {
|
|
importing.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.hint { color: var(--text-light); font-size: 0.9rem; }
|
|
.muted { color: var(--text-light); }
|
|
.mono { font-family: monospace; font-size: 0.85rem; }
|
|
.filters { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
|
|
.filters .form-control { max-width: 320px; }
|
|
.result-count { color: var(--text-light); font-size: 0.85rem; }
|
|
.form-row { display: flex; gap: 1rem; }
|
|
.form-row .form-group { flex: 1; }
|
|
.pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.9rem 0 0.2rem; }
|
|
.page-info { color: var(--text-light); font-size: 0.85rem; }
|
|
.ge-thumb-fallback { object-fit: contain; padding: 2px; background: #fff; }
|
|
.photo-thumb { width: 36px; height: 36px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); }
|
|
.photo-manage { display: flex; align-items: center; gap: 1rem; }
|
|
.photo-thumb-lg { width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); }
|
|
.photo-actions { display: flex; align-items: center; gap: 0.5rem; }
|
|
</style>
|