The asset name field presented as "Name" in PC Details but as "Alias" in Edit PC, and was absent from Fleet entirely. Three surfaces, two words, one field. Adds an Alias column to the Fleet table (positioned after Asset Tag to mirror PC Details' ordering) and renames the PC Details label to "Alias". Edit PC already used "Alias", so it is unchanged. Label and presentation only: the field stays `name` on the asset and the API payload is untouched, so no backend contract moves.
247 lines
7.4 KiB
Vue
247 lines
7.4 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Computers</h2>
|
|
<router-link to="/print/asset-label-batch/computer" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
|
<router-link to="/pcs/new" class="btn btn-primary">Add Computer</router-link>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="filters">
|
|
<input
|
|
v-model="search"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search computers..."
|
|
@input="debouncedSearch"
|
|
/>
|
|
<!-- Retire is a soft delete and the confirm dialog promises the record
|
|
"can be restored". The API kept that promise (?active=false lists
|
|
them, PUT isactive restores); nothing in the UI could reach it. -->
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" v-model="showRetired" @change="loadComputers" />
|
|
Show retired
|
|
</label>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Asset Tag</th>
|
|
<th>Alias</th>
|
|
<th>Hostname</th>
|
|
<th>Serial Number</th>
|
|
<th>PC Type</th>
|
|
<th>Remote Access</th>
|
|
<th>Status</th>
|
|
<th>Location</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="item in computers" :key="item.assetid" class="clickable-row" @click="$router.push(`/pcs/${item.computer?.computerid || item.assetid}`)">
|
|
<td>{{ item.assetnumber }}</td>
|
|
<td>{{ item.name || '-' }}</td>
|
|
<td>{{ item.computer?.hostname || '-' }}</td>
|
|
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
|
<td>{{ item.computer?.computertypename || '-' }}</td>
|
|
<td class="features">
|
|
<template v-for="a in (item.accessmethods || [])" :key="a.id">
|
|
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link" @click.stop>{{ a.name }}</a>
|
|
<span v-else class="access-link disabled" title="No hostname/IP set">{{ a.name }}</span>
|
|
</template>
|
|
<span v-if="!(item.accessmethods || []).length">-</span>
|
|
</td>
|
|
<td>
|
|
<span class="badge" :style="colorStyle(item.statuscolor)">
|
|
{{ item.statusname || 'Unknown' }}
|
|
</span>
|
|
</td>
|
|
<td>{{ item.locationname || '-' }}</td>
|
|
<td class="actions" @click.stop>
|
|
<router-link
|
|
:to="`/pcs/${item.computer?.computerid || item.assetid}`"
|
|
class="btn btn-secondary btn-sm"
|
|
>
|
|
View
|
|
</router-link>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="computers.length === 0">
|
|
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
|
<!-- A 403 is not an empty result. Rendering "No computers
|
|
found" to somebody who simply may not see them tells
|
|
them the fleet is empty, and they go looking for the
|
|
data rather than for the permission. -->
|
|
<template v-if="denied">
|
|
You do not have permission to view computers. Ask an
|
|
administrator for the computers.view permission.
|
|
</template>
|
|
<template v-else>No computers found</template>
|
|
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
|
</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 { computersApi } from '@/api'
|
|
import PaginationBar from '@/components/PaginationBar.vue'
|
|
import { colorStyle } from '@/utils/colorStyle'
|
|
import { useListQuery } from '@/composables/listQuery'
|
|
import SearchSuggestion from '@/components/SearchSuggestion.vue'
|
|
|
|
const computers = ref([])
|
|
const loading = ref(true)
|
|
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadComputers })
|
|
|
|
// Take the offered spelling. setSearch only syncs the URL and the watcher
|
|
// that reloads is suppressed because search.value already holds the new
|
|
// term, so load directly.
|
|
function applySuggestion(word) {
|
|
search.value = word
|
|
setSearch(word)
|
|
loadComputers()
|
|
}
|
|
const showRetired = ref(false)
|
|
// Told apart from an empty list: see the table's empty row.
|
|
const denied = ref(false)
|
|
const totalPages = ref(1)
|
|
const perPage = ref(20)
|
|
|
|
let searchTimeout = null
|
|
|
|
onMounted(() => {
|
|
loadComputers()
|
|
})
|
|
|
|
async function loadComputers() {
|
|
loading.value = true
|
|
try {
|
|
const params = {
|
|
page: page.value,
|
|
perpage: perPage.value
|
|
}
|
|
if (search.value) params.search = search.value
|
|
// active=false means "include retired", matching the API.
|
|
if (showRetired.value) params.active = 'false'
|
|
|
|
const response = await computersApi.list(params)
|
|
computers.value = response.data.data || []
|
|
reportCount(computers.value.length)
|
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
|
} catch (error) {
|
|
console.error('Error loading computers:', error)
|
|
denied.value = error?.response?.status === 403
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function debouncedSearch() {
|
|
clearTimeout(searchTimeout)
|
|
searchTimeout = setTimeout(() => {
|
|
setSearch(search.value)
|
|
loadComputers()
|
|
}, 300)
|
|
}
|
|
|
|
function goToPage(p) {
|
|
setPage(p)
|
|
loadComputers()
|
|
}
|
|
|
|
function changePerPage(newPerPage) {
|
|
perPage.value = newPerPage
|
|
setPage(1)
|
|
loadComputers()
|
|
}
|
|
|
|
</script>
|
|
|
|
<style scoped>
|
|
.access-link {
|
|
display: inline-block;
|
|
padding: 2px 10px;
|
|
margin: 0 4px 3px 0;
|
|
border-radius: 12px;
|
|
background: var(--primary);
|
|
color: #fff;
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
text-decoration: none;
|
|
}
|
|
.access-link:hover {
|
|
background: var(--primary-dark);
|
|
text-decoration: none;
|
|
}
|
|
.access-link.disabled {
|
|
background: var(--secondary);
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.mono {
|
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
|
}
|
|
|
|
/* keep the cell as a table-cell; flex on a <td> strips table-cell layout and
|
|
offsets the row. lay tags out inline instead. */
|
|
.features {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.feature-tag {
|
|
display: inline-block;
|
|
margin-right: 0.375rem;
|
|
padding: 0.3rem 0.625rem;
|
|
font-size: 0.875rem;
|
|
border-radius: 5px;
|
|
background: var(--bg);
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.feature-tag:last-child {
|
|
margin-right: 0;
|
|
}
|
|
|
|
/* the global .actions rule is inline-flex, which also breaks table-cell
|
|
alignment when applied straight on a <td>; pin it back to a cell here. */
|
|
td.actions {
|
|
display: table-cell;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.feature-tag.active {
|
|
background: #e3f2fd;
|
|
color: #1976d2;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
.feature-tag.active {
|
|
background: #1e3a5f;
|
|
color: #60a5fa;
|
|
}
|
|
}
|
|
</style>
|