Files
shopdb-flask/frontend/src/views/settings/ModelsList.vue
cproudlock f8c4246483
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
Fix model photo upload, and give network devices the model link the page assumed
Three faults around vendor-model photos, found while looking at why an uploaded
image did not appear.

Saving a model was blocked after uploading a photo. The Image URL field was
type="url", and an upload sets it to an application path such as
/api/models/image/model-120.png. Native url validation demands an absolute URL
with a scheme, so the browser refused to submit the form with "Please enter a
URL" for a value the page had just written itself. The field is now type="text",
which is what it always needed to be: it holds either a full web address or a
path on this server. documentationurl stays type="url".

The upload button did not appear when adding a model, only when editing one.
That was deliberate - the photo is stored as model-<id>.<ext>, so it cannot be
sent before the record has an id - but it reads as a missing feature, and the
hint explaining it was easy to miss. A photo chosen while creating is now held
and uploaded as soon as the model is saved, and it is dropped if the dialog is
cancelled, so it cannot land on the next model created in the same session.

Network devices could never show a photo. NetworkDeviceDetail.vue binds its hero
image to networkdevice.imageurl, but networkdevices carried only vendorid, with
no link to a catalog model, so nothing could populate it - a feature that looked
present and could not work. Machines, PCs and printers have carried
modelnumberid since July. This adds the same column and relationship, the
to_dict branch that exposes modelname and imageurl, the field on the API, and a
Model selector on the form so the link can actually be set.

The migration is guarded the same way employees0002photo is: on a fresh database
the tables come from the SQLAlchemy models, which already declare the column, so
an unconditional add fails with "duplicate column name". The foreign key is
created only on databases that can add one by ALTER; routing it through
batch_alter_table made Alembic's column sort raise "Circular dependency
detected" on the fresh-database test.

Deploying this needs `flask db upgrade` and `flask plugin upgrade-all` on the
server, not just a file copy.
2026-08-05 09:08:40 -04:00

505 lines
15 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>Models</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Model</button>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search models..."
@input="debouncedSearch"
/>
<select v-model="vendorFilter" class="form-control" @change="loadModels">
<option value="">All Vendors</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Model</th>
<th>Vendor</th>
<th>Type</th>
<th>Documentation</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="m in models" :key="m.modelnumberid">
<td>
<div>{{ m.modelnumber }}</div>
<small v-if="m.description" class="text-muted">{{ m.description }}</small>
</td>
<td>{{ m.vendor || '-' }}</td>
<td>{{ m.modeltype || '-' }}</td>
<td>
<a v-if="m.documentationurl" :href="m.documentationurl" target="_blank" class="btn btn-sm btn-link">
View Docs
</a>
<span v-else>-</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(m)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(m)">Delete</button>
</td>
</tr>
<tr v-if="models.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No models found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editingModel ? 'Edit Model' : 'Add Model' }}</h3>
</div>
<form @submit.prevent="saveModel">
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label for="modelnumber">Model Number *</label>
<input
id="modelnumber"
v-model="form.modelnumber"
type="text"
class="form-control"
required
/>
</div>
<div class="form-group">
<label for="vendorid">Vendor *</label>
<select id="vendorid" v-model="form.vendorid" class="form-control" required>
<option value="">Select vendor...</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="modeltypeid">Model Type</label>
<select id="modeltypeid" v-model="form.modeltypeid" class="form-control">
<option value="">Select type...</option>
<option v-for="mt in modelTypes" :key="mt.modeltypeid" :value="mt.modeltypeid">
{{ mt.modeltype }}
</option>
</select>
</div>
<div class="form-group">
<label for="description">Description</label>
<input id="description" v-model="form.description" type="text" class="form-control" />
</div>
</div>
<div class="form-group">
<label for="documentationurl">Documentation URL</label>
<input
id="documentationurl"
v-model="form.documentationurl"
type="url"
class="form-control"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label>Image</label>
<div class="image-manage">
<img v-if="form.imageurl" :src="withBase(form.imageurl)" alt="Model image" class="image-thumb" />
<div class="image-actions">
<!--
Offered when CREATING too. The photo is stored as
model-<id>.<ext>, so it cannot be sent before the model has
an id - but making the operator save, reopen and come back
just to add a picture reads as a missing feature. On a new
model the file is held here and uploaded immediately after
the model is created.
-->
<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 || pendingImageFile) ? 'Replace' : 'Upload') }}
</button>
<button v-if="form.imageurl || pendingImageFile" type="button" class="btn btn-danger btn-sm" @click="removeImage">Remove</button>
<small v-if="pendingImageFile" class="text-muted pending-note">
{{ pendingImageFile.name }} will be uploaded when you save.
</small>
</div>
</div>
</div>
<div class="form-group">
<label for="imageurl">Image URL</label>
<!--
type="text", NOT type="url". An uploaded photo sets this field to
an application path such as /api/models/image/model-120.png, and
native url validation demands an absolute URL with a scheme - so
the browser refused to save the form with "Please enter a URL"
for a value this page had just written itself.
-->
<input
id="imageurl"
v-model="form.imageurl"
type="text"
class="form-control"
placeholder="https://... or /api/models/image/..."
/>
<small class="text-muted">
Set automatically when you upload a photo. Accepts a full web address or a path on this server.
</small>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" v-model="form.notes" class="form-control" rows="2"></textarea>
</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>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header">
<h3>Delete Model</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ modelToDelete?.modelnumber }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteModel">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { modelsApi, vendorsApi, modeltypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { withBase } from '@/utils/basePath'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const models = ref([])
const vendors = ref([])
const modelTypes = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadModels })
const vendorFilter = ref('')
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingModel = ref(null)
// A photo chosen while CREATING a model, uploaded once the model has an id.
const pendingImageFile = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const modelToDelete = ref(null)
const imageFileInput = ref(null)
const uploadingImage = ref(false)
const form = ref({
modelnumber: '',
vendorid: '',
modeltypeid: '',
description: '',
documentationurl: '',
imageurl: '',
notes: ''
})
let searchTimeout = null
onMounted(async () => {
await Promise.all([
loadModels(),
loadVendors(),
loadModelTypes()
])
})
async function loadModels() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
if (vendorFilter.value) params.vendor = vendorFilter.value
const response = await modelsApi.list(params)
models.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading models:', err)
} finally {
loading.value = false
}
}
async function loadVendors() {
try {
const response = await vendorsApi.list({ perpage: 100 })
vendors.value = response.data.data || []
} catch (err) {
console.error('Error loading vendors:', err)
}
}
async function loadModelTypes() {
try {
const response = await modeltypesApi.list({ perpage: 100 })
modelTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading model types:', err)
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadModels()
}, 300)
}
function goToPage(p) {
setPage(p)
loadModels()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadModels()
}
function openModal(m = null) {
editingModel.value = m
if (m) {
form.value = {
modelnumber: m.modelnumber || '',
vendorid: m.vendorid || '',
modeltypeid: m.modeltypeid || '',
description: m.description || '',
documentationurl: m.documentationurl || '',
imageurl: m.imageurl || '',
notes: m.notes || ''
}
} else {
form.value = {
modelnumber: '',
vendorid: '',
modeltypeid: '',
description: '',
documentationurl: '',
imageurl: '',
notes: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingModel.value = null
// Or a photo picked for a model that was never saved would be uploaded onto
// the NEXT model created in this session.
pendingImageFile.value = null
}
async function saveModel() {
error.value = ''
saving.value = true
try {
const data = { ...form.value }
if (!data.vendorid) data.vendorid = null
if (!data.modeltypeid) data.modeltypeid = null
if (editingModel.value) {
await modelsApi.update(editingModel.value.modelnumberid, data)
} else {
const created = await modelsApi.create(data)
// Send a photo chosen before the model existed. A failure here must not
// read as "the model was not saved" - it was.
if (pendingImageFile.value) {
const newId = created?.data?.data?.modelnumberid
if (newId) {
try {
await modelsApi.uploadImage(newId, pendingImageFile.value)
} catch (imgErr) {
console.error('Error uploading image:', imgErr)
toast.error(apiError(imgErr, 'Model saved, but the photo could not be uploaded'))
}
}
}
}
closeModal()
loadModels()
} catch (err) {
console.error('Error saving model:', err)
error.value = apiError(err, 'Failed to save model')
} finally {
saving.value = false
}
}
function triggerImageUpload() {
imageFileInput.value?.click()
}
async function onImageSelected(event) {
const file = event.target.files?.[0]
if (!file) return
// No model id yet, so hold the file and send it once saveModel has created
// the record. Nothing is written to the server until the operator saves.
if (!editingModel.value) {
pendingImageFile.value = file
if (imageFileInput.value) imageFileInput.value.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() {
// On a new model there is nothing on the server yet; just drop the choice.
if (!editingModel.value) {
pendingImageFile.value = null
form.value.imageurl = ''
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
}
async function deleteModel() {
try {
await modelsApi.delete(modelToDelete.value.modelnumberid)
showDeleteModal.value = false
modelToDelete.value = null
loadModels()
} catch (err) {
console.error('Error deleting model:', err)
toast.error('Failed to delete model')
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.modal-lg {
max-width: 600px;
}
.text-muted {
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>