Two unrelated things found while looking at blank printer types. Selecting a filter while past page one returned an empty list. The filter asked the server for page 5 of a result set that now had one page, and the screen said nothing matched. useListQuery already resets the page - setSearch and setExtra both do - but the filter dropdowns bypassed it and called the loader directly. Nine list pages now route through applyFilter, which calls setPage(1) when it needs to and loads directly when already on page one, so the composable's URL watcher does not also fire and fetch twice. scripts/retype_models.py addresses why printer types cannot be derived. The catalog types every printer model "Printer": true, and useless, since it does not say whether the product is a laser, a plotter or a label printer. That answer is a property of the model - every VersaLink C405 is a laser MFP - but nothing recorded it, so nothing could derive it. Recording it on the MODEL means the existing backfill fills every printer by exact name match, and a printer added later inherits the right type the moment its model is chosen. It exports the models needing a decision to CSV with a type suggested from the model number, a person corrects the column, and applying it is a dry run unless given --commit. A suggested type is refused unless it already exists in that asset class's own vocabulary, which is what keeps the later name match working. The suggestion order matters and got this wrong first time: a generic plotter pattern matched "Zebra ZT411" and filed a label printer as a plotter. Brands now come before generic patterns, and the review step exists precisely because a confident wrong guess would type every asset using that model. Verified on the development database: 24 printer models need a decision, 22 got a sensible suggestion, applying them let all 42 printers match a printertype by name, and the transaction rolled back cleanly.
228 lines
6.5 KiB
Vue
228 lines
6.5 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Applications</h2>
|
|
<router-link to="/applications/new" class="btn btn-primary">Add Application</router-link>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="filters">
|
|
<input
|
|
v-model="search"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search applications..."
|
|
@input="debouncedSearch"
|
|
/>
|
|
<select v-model="filter" class="form-control" @change="applyFilter">
|
|
<option value="installable">Installable Applications</option>
|
|
<option value="all">All Applications</option>
|
|
<option value="hidden">Hidden Applications</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 style="width: 50px;">Files</th>
|
|
<th style="width: 50px;">Docs</th>
|
|
<th>Application Name</th>
|
|
<th>Description</th>
|
|
<th>Support Team</th>
|
|
<th>Contacts</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="app in applications" :key="app.appid" class="clickable-row" @click="$router.push(`/applications/${app.appid}`)">
|
|
<td class="icon-cell">
|
|
<a v-if="app.installpath" :href="fileHref(app.installpath)" target="_blank" title="Download Installation Files" class="icon-download">
|
|
⬇
|
|
</a>
|
|
<a v-else-if="app.applicationlink" :href="fileHref(app.applicationlink)" target="_blank" title="Application Link" class="icon-link">
|
|
🔗
|
|
</a>
|
|
</td>
|
|
<td class="icon-cell">
|
|
<a v-if="app.documentationpath" :href="fileHref(app.documentationpath)" target="_blank" title="View Documentation" class="icon-docs">
|
|
📄
|
|
</a>
|
|
</td>
|
|
<td>
|
|
<router-link :to="`/applications/${app.appid}`">
|
|
{{ app.appname }}
|
|
</router-link>
|
|
<span class="app-flags">
|
|
<span v-if="app.isinstallable" class="badge badge-info badge-sm">Installable</span>
|
|
<span v-if="app.islicenced" class="badge badge-warning badge-sm">Licensed</span>
|
|
<span v-if="app.isprinter" class="badge badge-secondary badge-sm">Printer</span>
|
|
</span>
|
|
</td>
|
|
<td class="description">{{ app.appdescription || '-' }}</td>
|
|
<td>{{ app.supportteamname || '-' }}</td>
|
|
<td>{{ app.contacts && app.contacts.length ? app.contacts.map(c => c.name).join(', ') : '-' }}</td>
|
|
<td class="actions" @click.stop>
|
|
<router-link
|
|
:to="`/applications/${app.appid}`"
|
|
class="btn btn-secondary btn-sm"
|
|
>
|
|
View
|
|
</router-link>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="applications.length === 0">
|
|
<td colspan="7" style="text-align: center; color: var(--text-light);">
|
|
No applications found
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<PaginationBar
|
|
:page="page"
|
|
:totalPages="totalPages"
|
|
:perPage="perPage"
|
|
@update:page="goToPage"
|
|
@update:perPage="changePerPage"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { applicationsApi } from '@/api'
|
|
import PaginationBar from '@/components/PaginationBar.vue'
|
|
import { useListQuery } from '@/composables/listQuery'
|
|
import { fileHref } from '@/utils/basePath'
|
|
|
|
const applications = ref([])
|
|
const loading = ref(true)
|
|
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadApplications })
|
|
const filter = ref('installable')
|
|
const totalPages = ref(1)
|
|
const perPage = ref(20)
|
|
|
|
let searchTimeout = null
|
|
|
|
onMounted(() => {
|
|
loadApplications()
|
|
})
|
|
|
|
// A filter change must go back to page 1. Selecting a filter while on page 5
|
|
// asked the server for page 5 of a result set that now has one page, and the
|
|
// list came back empty as though the filter matched nothing.
|
|
//
|
|
// setPage(1) writes the URL, which the composable's watcher picks up and
|
|
// answers with onChange - so calling the loader as well would fetch twice.
|
|
// Load directly only when already on page 1, where nothing changes and the
|
|
// watcher stays silent.
|
|
function applyFilter() {
|
|
if (page.value > 1) setPage(1)
|
|
else loadApplications()
|
|
}
|
|
|
|
async function loadApplications() {
|
|
loading.value = true
|
|
try {
|
|
const params = {
|
|
page: page.value,
|
|
perpage: perPage.value,
|
|
search: search.value || undefined
|
|
}
|
|
|
|
// Apply filter
|
|
if (filter.value === 'installable') {
|
|
params.installable = true
|
|
} else if (filter.value === 'hidden') {
|
|
params.hidden = true
|
|
}
|
|
|
|
const response = await applicationsApi.list(params)
|
|
applications.value = response.data.data || []
|
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
|
} catch (error) {
|
|
console.error('Error loading applications:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function debouncedSearch() {
|
|
clearTimeout(searchTimeout)
|
|
searchTimeout = setTimeout(() => {
|
|
setSearch(search.value)
|
|
loadApplications()
|
|
}, 300)
|
|
}
|
|
|
|
function goToPage(p) {
|
|
setPage(p)
|
|
loadApplications()
|
|
}
|
|
|
|
function changePerPage(newPerPage) {
|
|
perPage.value = newPerPage
|
|
setPage(1)
|
|
loadApplications()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Application list specific styles */
|
|
.icon-cell {
|
|
text-align: center;
|
|
width: 50px;
|
|
}
|
|
|
|
.icon-cell a {
|
|
font-size: 1.25rem;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.icon-download {
|
|
color: var(--success);
|
|
}
|
|
|
|
.icon-link {
|
|
color: var(--link);
|
|
}
|
|
|
|
.icon-docs {
|
|
color: var(--secondary);
|
|
}
|
|
|
|
.icon-cell a:hover {
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.app-flags {
|
|
display: inline-flex;
|
|
gap: 0.375rem;
|
|
flex-wrap: wrap;
|
|
margin-left: 0.5rem;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.badge-sm {
|
|
font-size: 0.75rem;
|
|
padding: 0.2rem 0.5rem;
|
|
}
|
|
|
|
.description {
|
|
max-width: 200px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--text-light);
|
|
}
|
|
</style>
|