Add photo management for models and employees; fix stale detail navigation
Model photos: upload/replace/delete on /api/models/<id>/image (admin), stored under instance/modelimages/ with a public serve route; thumbnail plus Upload/Replace/Remove controls in the Models settings modal; the URL field remains as a manual alternative. Employee photos, mode-aware: self-hosted directory employees get upload/replace/delete (photo-<sso> under instance/employeephotos/, employees plugin migration 0002); external directory mode passes the HR-supplied picture URL through read-only (writes 409). One resolver feeds both consumers - the shopfloor recognition/recert kiosk cards and the employee detail hero - in either mode. Navigation fix: router-view is keyed on route path, so following a relationship link between two assets of the same type (machine -> dualpath machine) reloads the page instead of showing stale content; query-only URL changes still avoid a remount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -389,6 +389,15 @@ export const modelsApi = {
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/models/${id}`)
|
||||
},
|
||||
uploadImage(id, file) {
|
||||
// multipart photo upload; backend sets imageurl to the served URL
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post(`/models/${id}/image`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
},
|
||||
removeImage(id) {
|
||||
return api.delete(`/models/${id}/image`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,6 +739,15 @@ export const employeesApi = {
|
||||
},
|
||||
importCsv(csv) {
|
||||
return api.post('/employees/directory/import', { csv })
|
||||
},
|
||||
// multipart photo upload; backend sets photofilename + returns photourl
|
||||
uploadPhoto(sso, file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post(`/employees/${sso}/photo`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
},
|
||||
removePhoto(sso) {
|
||||
return api.delete(`/employees/${sso}/photo`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,10 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<router-view />
|
||||
<!-- Keyed on path so same-component navigation (machine -> machine via
|
||||
a relationship link) remounts and reloads; query-only changes
|
||||
(e.g. /reports?report=x) do not remount. -->
|
||||
<router-view :key="route.path" />
|
||||
</main>
|
||||
<ToastHost />
|
||||
</div>
|
||||
@@ -83,7 +86,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||
@@ -95,6 +98,7 @@ import { dashboardApi, notificationsApi } from '../api'
|
||||
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const searchQuery = ref('')
|
||||
const navItems = ref([])
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<div class="recognition-photo-container">
|
||||
<img
|
||||
v-if="rec.employeepicture"
|
||||
:src="`/static/employees/${rec.employeepicture}`"
|
||||
:src="rec.employeepicture"
|
||||
:alt="rec.employeename"
|
||||
class="recognition-photo"
|
||||
@error="handlePhotoError"
|
||||
@@ -91,7 +91,7 @@
|
||||
>
|
||||
<img
|
||||
v-if="rec.employeepicture"
|
||||
:src="`/static/employees/${rec.employeepicture}`"
|
||||
:src="rec.employeepicture"
|
||||
:alt="rec.employeename"
|
||||
class="recert-photo"
|
||||
@error="handlePhotoError"
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
<template v-else-if="employee">
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="employee.Picture">
|
||||
<img :src="employee.Picture" :alt="fullName" />
|
||||
<div class="hero-image" v-if="employee.photourl">
|
||||
<img :src="employee.photourl" :alt="fullName" />
|
||||
</div>
|
||||
<div class="hero-image placeholder" v-else>
|
||||
<span class="initials">{{ initials }}</span>
|
||||
|
||||
@@ -35,7 +35,10 @@
|
||||
<td>{{ e.First_Name }} {{ e.Last_Name }}</td>
|
||||
<td>{{ e.Team || '-' }}</td>
|
||||
<td>{{ e.Role || '-' }}</td>
|
||||
<td class="mono">{{ e.Picture || '-' }}</td>
|
||||
<td>
|
||||
<img v-if="e.photourl" :src="e.photourl" alt="Photo" class="photo-thumb" />
|
||||
<span v-else class="mono">-</span>
|
||||
</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>
|
||||
@@ -79,6 +82,28 @@
|
||||
<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">
|
||||
@@ -128,8 +153,10 @@ 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: '' } }
|
||||
function blank() { return { SSO: '', First_Name: '', Last_Name: '', Team: '', Role: '', Picture: '', photourl: '' } }
|
||||
|
||||
const filtered = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
@@ -186,6 +213,43 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -231,4 +295,8 @@ async function doImport() {
|
||||
.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; }
|
||||
.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>
|
||||
|
||||
@@ -133,6 +133,29 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Image</label>
|
||||
<div class="image-manage">
|
||||
<img v-if="form.imageurl" :src="form.imageurl" alt="Model image" class="image-thumb" />
|
||||
<div class="image-actions">
|
||||
<template v-if="editingModel">
|
||||
<input
|
||||
ref="imageFileInput"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.webp,.svg"
|
||||
style="display: none"
|
||||
@change="onImageSelected"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="uploadingImage" @click="triggerImageUpload">
|
||||
{{ uploadingImage ? 'Uploading...' : (form.imageurl ? 'Replace' : 'Upload') }}
|
||||
</button>
|
||||
<button v-if="form.imageurl" type="button" class="btn btn-danger btn-sm" @click="removeImage">Remove</button>
|
||||
</template>
|
||||
<small v-else class="text-muted">Save the model first, then upload a photo.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="imageurl">Image URL</label>
|
||||
<input
|
||||
@@ -142,6 +165,7 @@
|
||||
class="form-control"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
<small class="text-muted">Manual alternative. Uploading a photo overwrites this URL.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -205,6 +229,9 @@ const error = ref('')
|
||||
const showDeleteModal = ref(false)
|
||||
const modelToDelete = ref(null)
|
||||
|
||||
const imageFileInput = ref(null)
|
||||
const uploadingImage = ref(false)
|
||||
|
||||
const form = ref({
|
||||
modelnumber: '',
|
||||
vendorid: '',
|
||||
@@ -335,6 +362,43 @@ async function saveModel() {
|
||||
}
|
||||
}
|
||||
|
||||
function triggerImageUpload() {
|
||||
imageFileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onImageSelected(event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file || !editingModel.value) return
|
||||
uploadingImage.value = true
|
||||
try {
|
||||
const response = await modelsApi.uploadImage(editingModel.value.modelnumberid, file)
|
||||
// Backend returns the updated model with imageurl set to the served URL.
|
||||
form.value.imageurl = response.data.data.imageurl || ''
|
||||
toast.success('Image uploaded')
|
||||
loadModels()
|
||||
} catch (err) {
|
||||
console.error('Error uploading image:', err)
|
||||
toast.error(apiError(err, 'Failed to upload image'))
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
if (imageFileInput.value) imageFileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function removeImage() {
|
||||
if (!editingModel.value) return
|
||||
if (!confirm('Remove this model image?')) return
|
||||
try {
|
||||
await modelsApi.removeImage(editingModel.value.modelnumberid)
|
||||
form.value.imageurl = ''
|
||||
toast.success('Image removed')
|
||||
loadModels()
|
||||
} catch (err) {
|
||||
console.error('Error removing image:', err)
|
||||
toast.error(apiError(err, 'Failed to remove image'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(m) {
|
||||
modelToDelete.value = m
|
||||
showDeleteModal.value = true
|
||||
@@ -368,4 +432,25 @@ async function deleteModel() {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.image-manage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.image-thumb {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user