List rows click through to the item; drop the redundant VLANs tab
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Has been cancelled

Row-click: the whole table row now navigates to the item detail (machines, PCs,
printers, network devices, measuring tools, applications), matching the Networks
list. The actions cell is @click.stop so View/Edit/Delete still work
independently; a shared .clickable-row style gives the cursor + hover.

Network hub: drop the VLANs tab - a subnet belongs to a VLAN (each Networks row
already shows its VLAN) so a sibling tab was redundant; VLAN naming stays in
Settings. Hub is now Devices | Networks.

Verified: row-click navigates on machines/pcs/network; hub shows two tabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-13 14:21:34 -04:00
parent 8528617037
commit 58af5afda2
8 changed files with 785 additions and 779 deletions

View File

@@ -1742,3 +1742,9 @@ td.actions {
color: var(--text-light); color: var(--text-light);
cursor: pointer; cursor: pointer;
} }
/* Clickable list rows: the whole row navigates to the item detail; interactive
cells (the actions column, in-row links) stop propagation so they still work
independently. */
.clickable-row { cursor: pointer; }
.clickable-row:hover td { background: var(--bg); }

View File

@@ -1,213 +1,213 @@
<template> <template>
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>Applications</h2> <h2>Applications</h2>
<router-link to="/applications/new" class="btn btn-primary">Add Application</router-link> <router-link to="/applications/new" class="btn btn-primary">Add Application</router-link>
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="filters"> <div class="filters">
<input <input
v-model="search" v-model="search"
type="text" type="text"
class="form-control" class="form-control"
placeholder="Search applications..." placeholder="Search applications..."
@input="debouncedSearch" @input="debouncedSearch"
/> />
<select v-model="filter" class="form-control" @change="loadApplications"> <select v-model="filter" class="form-control" @change="loadApplications">
<option value="installable">Installable Applications</option> <option value="installable">Installable Applications</option>
<option value="all">All Applications</option> <option value="all">All Applications</option>
<option value="hidden">Hidden Applications</option> <option value="hidden">Hidden Applications</option>
</select> </select>
</div> </div>
<div class="card"> <div class="card">
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else> <template v-else>
<div class="table-container"> <div class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th style="width: 50px;">Files</th> <th style="width: 50px;">Files</th>
<th style="width: 50px;">Docs</th> <th style="width: 50px;">Docs</th>
<th>Application Name</th> <th>Application Name</th>
<th>Description</th> <th>Description</th>
<th>Support Team</th> <th>Support Team</th>
<th>Contacts</th> <th>Contacts</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="app in applications" :key="app.appid"> <tr v-for="app in applications" :key="app.appid" class="clickable-row" @click="$router.push(`/applications/${app.appid}`)">
<td class="icon-cell"> <td class="icon-cell">
<a v-if="app.installpath" :href="app.installpath" target="_blank" title="Download Installation Files" class="icon-download"> <a v-if="app.installpath" :href="app.installpath" target="_blank" title="Download Installation Files" class="icon-download">
&#x2B07; &#x2B07;
</a> </a>
<a v-else-if="app.applicationlink" :href="app.applicationlink" target="_blank" title="Application Link" class="icon-link"> <a v-else-if="app.applicationlink" :href="app.applicationlink" target="_blank" title="Application Link" class="icon-link">
&#x1F517; &#x1F517;
</a> </a>
</td> </td>
<td class="icon-cell"> <td class="icon-cell">
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" title="View Documentation" class="icon-docs"> <a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" title="View Documentation" class="icon-docs">
&#x1F4C4; &#x1F4C4;
</a> </a>
</td> </td>
<td> <td>
<router-link :to="`/applications/${app.appid}`"> <router-link :to="`/applications/${app.appid}`">
{{ app.appname }} {{ app.appname }}
</router-link> </router-link>
<span class="app-flags"> <span class="app-flags">
<span v-if="app.isinstallable" class="badge badge-info badge-sm">Installable</span> <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.islicenced" class="badge badge-warning badge-sm">Licensed</span>
<span v-if="app.isprinter" class="badge badge-secondary badge-sm">Printer</span> <span v-if="app.isprinter" class="badge badge-secondary badge-sm">Printer</span>
</span> </span>
</td> </td>
<td class="description">{{ app.appdescription || '-' }}</td> <td class="description">{{ app.appdescription || '-' }}</td>
<td>{{ app.supportteamname || '-' }}</td> <td>{{ app.supportteamname || '-' }}</td>
<td>{{ app.contacts && app.contacts.length ? app.contacts.map(c => c.name).join(', ') : '-' }}</td> <td>{{ app.contacts && app.contacts.length ? app.contacts.map(c => c.name).join(', ') : '-' }}</td>
<td class="actions"> <td class="actions" @click.stop>
<router-link <router-link
:to="`/applications/${app.appid}`" :to="`/applications/${app.appid}`"
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"
> >
View View
</router-link> </router-link>
</td> </td>
</tr> </tr>
<tr v-if="applications.length === 0"> <tr v-if="applications.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);"> <td colspan="7" style="text-align: center; color: var(--text-light);">
No applications found No applications found
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<PaginationBar <PaginationBar
:page="page" :page="page"
:totalPages="totalPages" :totalPages="totalPages"
:perPage="perPage" :perPage="perPage"
@update:page="goToPage" @update:page="goToPage"
@update:perPage="changePerPage" @update:perPage="changePerPage"
/> />
</template> </template>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { applicationsApi } from '../../api' import { applicationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue' import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery' import { useListQuery } from '@/composables/listQuery'
const applications = ref([]) const applications = ref([])
const loading = ref(true) const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadApplications }) const { page, search, setPage, setSearch } = useListQuery({ onChange: loadApplications })
const filter = ref('installable') const filter = ref('installable')
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20) const perPage = ref(20)
let searchTimeout = null let searchTimeout = null
onMounted(() => { onMounted(() => {
loadApplications() loadApplications()
}) })
async function loadApplications() { async function loadApplications() {
loading.value = true loading.value = true
try { try {
const params = { const params = {
page: page.value, page: page.value,
perpage: perPage.value, perpage: perPage.value,
search: search.value || undefined search: search.value || undefined
} }
// Apply filter // Apply filter
if (filter.value === 'installable') { if (filter.value === 'installable') {
params.installable = true params.installable = true
} else if (filter.value === 'hidden') { } else if (filter.value === 'hidden') {
params.hidden = true params.hidden = true
} }
const response = await applicationsApi.list(params) const response = await applicationsApi.list(params)
applications.value = response.data.data || [] applications.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading applications:', error) console.error('Error loading applications:', error)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function debouncedSearch() { function debouncedSearch() {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
setSearch(search.value) setSearch(search.value)
loadApplications() loadApplications()
}, 300) }, 300)
} }
function goToPage(p) { function goToPage(p) {
setPage(p) setPage(p)
loadApplications() loadApplications()
} }
function changePerPage(newPerPage) { function changePerPage(newPerPage) {
perPage.value = newPerPage perPage.value = newPerPage
setPage(1) setPage(1)
loadApplications() loadApplications()
} }
</script> </script>
<style scoped> <style scoped>
/* Application list specific styles */ /* Application list specific styles */
.icon-cell { .icon-cell {
text-align: center; text-align: center;
width: 50px; width: 50px;
} }
.icon-cell a { .icon-cell a {
font-size: 1.25rem; font-size: 1.25rem;
text-decoration: none; text-decoration: none;
} }
.icon-download { .icon-download {
color: var(--success); color: var(--success);
} }
.icon-link { .icon-link {
color: var(--link); color: var(--link);
} }
.icon-docs { .icon-docs {
color: var(--secondary); color: var(--secondary);
} }
.icon-cell a:hover { .icon-cell a:hover {
opacity: 0.7; opacity: 0.7;
} }
.app-flags { .app-flags {
display: inline-flex; display: inline-flex;
gap: 0.375rem; gap: 0.375rem;
flex-wrap: wrap; flex-wrap: wrap;
margin-left: 0.5rem; margin-left: 0.5rem;
vertical-align: middle; vertical-align: middle;
} }
.badge-sm { .badge-sm {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
} }
.description { .description {
max-width: 200px; max-width: 200px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: var(--text-light); color: var(--text-light);
} }
</style> </style>

View File

@@ -36,7 +36,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in machines" :key="item.assetid"> <tr v-for="item in machines" :key="item.assetid" class="clickable-row" @click="$router.push(`/machines/${item.machine?.machineid || item.assetid}`)">
<td> <td>
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template> {{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
</td> </td>
@@ -50,7 +50,7 @@
</span> </span>
</td> </td>
<td>{{ item.locationname || '-' }}</td> <td>{{ item.locationname || '-' }}</td>
<td class="actions"> <td class="actions" @click.stop>
<router-link <router-link
:to="`/machines/${item.machine?.machineid || item.assetid}`" :to="`/machines/${item.machine?.machineid || item.assetid}`"
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"

View File

@@ -49,7 +49,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in tools" :key="item.assetid"> <tr v-for="item in tools" :key="item.assetid" class="clickable-row" @click="$router.push(`/measuringtools/${item.measuringtool?.measuringtoolid || item.assetid}`)">
<td>{{ item.assetnumber }}</td> <td>{{ item.assetnumber }}</td>
<td>{{ item.name || '-' }}</td> <td>{{ item.name || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td> <td class="mono">{{ item.serialnumber || '-' }}</td>
@@ -61,7 +61,7 @@
</span> </span>
</td> </td>
<td>{{ item.locationname || '-' }}</td> <td>{{ item.locationname || '-' }}</td>
<td class="actions"> <td class="actions" @click.stop>
<router-link <router-link
:to="`/measuringtools/${item.measuringtool?.measuringtoolid || item.assetid}`" :to="`/measuringtools/${item.measuringtool?.measuringtoolid || item.assetid}`"
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"

View File

@@ -1,347 +1,347 @@
<template> <template>
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>Network Devices</h2> <h2>Network Devices</h2>
<router-link to="/print/asset-label-batch/network_device" class="btn btn-secondary" target="_blank">Print Labels</router-link> <router-link to="/print/asset-label-batch/network_device" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/network/new" class="btn btn-primary">Add Device</router-link> <router-link to="/network/new" class="btn btn-primary">Add Device</router-link>
</div> </div>
<!-- Type Tabs --> <!-- Type Tabs -->
<div class="type-tabs"> <div class="type-tabs">
<button <button
:class="{ active: selectedType === null }" :class="{ active: selectedType === null }"
@click="selectType(null)" @click="selectType(null)"
> >
All ({{ totalCount }}) All ({{ totalCount }})
</button> </button>
<button <button
v-for="t in deviceTypes" v-for="t in deviceTypes"
:key="t.networkdevicetypeid" :key="t.networkdevicetypeid"
:class="{ active: selectedType === t.networkdevicetypeid }" :class="{ active: selectedType === t.networkdevicetypeid }"
@click="selectType(t.networkdevicetypeid)" @click="selectType(t.networkdevicetypeid)"
> >
{{ t.networkdevicetype }} ({{ t.count || 0 }}) {{ t.networkdevicetype }} ({{ t.count || 0 }})
</button> </button>
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="filters"> <div class="filters">
<input <input
v-model="search" v-model="search"
type="text" type="text"
class="form-control" class="form-control"
placeholder="Search by hostname, asset #, serial..." placeholder="Search by hostname, asset #, serial..."
@input="debouncedSearch" @input="debouncedSearch"
/> />
<select v-model="vendorFilter" class="form-control" @change="loadDevices"> <select v-model="vendorFilter" class="form-control" @change="loadDevices">
<option value="">All Vendors</option> <option value="">All Vendors</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid"> <option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }} {{ v.vendor }}
</option> </option>
</select> </select>
<select v-model="locationFilter" class="form-control" @change="loadDevices"> <select v-model="locationFilter" class="form-control" @change="loadDevices">
<option value="">All Locations</option> <option value="">All Locations</option>
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid"> <option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
{{ loc.location }} {{ loc.location }}
</option> </option>
</select> </select>
</div> </div>
<div class="card"> <div class="card">
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else> <template v-else>
<div class="table-container"> <div class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Asset Tag</th> <th>Asset Tag</th>
<th>Hostname</th> <th>Hostname</th>
<th>Serial Number</th> <th>Serial Number</th>
<th>Type</th> <th>Type</th>
<th>Vendor</th> <th>Vendor</th>
<th>Features</th> <th>Features</th>
<th>Status</th> <th>Status</th>
<th>Location</th> <th>Location</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="device in devices" :key="device.networkdevice?.networkdeviceid || device.assetid"> <tr v-for="device in devices" :key="device.networkdevice?.networkdeviceid || device.assetid" class="clickable-row" @click="$router.push(`/network/${device.networkdevice?.networkdeviceid || device.assetid}`)">
<td>{{ device.assetnumber }}</td> <td>{{ device.assetnumber }}</td>
<td class="mono">{{ device.networkdevice?.hostname || '-' }}</td> <td class="mono">{{ device.networkdevice?.hostname || '-' }}</td>
<td class="mono">{{ device.serialnumber || '-' }}</td> <td class="mono">{{ device.serialnumber || '-' }}</td>
<td>{{ device.networkdevice?.networkdevicetypename || '-' }}</td> <td>{{ device.networkdevice?.networkdevicetypename || '-' }}</td>
<td>{{ device.networkdevice?.vendorname || '-' }}</td> <td>{{ device.networkdevice?.vendorname || '-' }}</td>
<td class="features"> <td class="features">
<span v-if="device.networkdevice?.ispoe" class="feature-tag poe">PoE</span> <span v-if="device.networkdevice?.ispoe" class="feature-tag poe">PoE</span>
<span v-if="device.networkdevice?.ismanaged" class="feature-tag managed">Managed</span> <span v-if="device.networkdevice?.ismanaged" class="feature-tag managed">Managed</span>
<span v-if="device.networkdevice?.portcount" class="feature-tag ports">{{ device.networkdevice.portcount }} ports</span> <span v-if="device.networkdevice?.portcount" class="feature-tag ports">{{ device.networkdevice.portcount }} ports</span>
<span v-if="!device.networkdevice?.ispoe && !device.networkdevice?.ismanaged && !device.networkdevice?.portcount">-</span> <span v-if="!device.networkdevice?.ispoe && !device.networkdevice?.ismanaged && !device.networkdevice?.portcount">-</span>
</td> </td>
<td> <td>
<span class="badge" :style="colorStyle(device.statuscolor)"> <span class="badge" :style="colorStyle(device.statuscolor)">
{{ device.statusname || 'Unknown' }} {{ device.statusname || 'Unknown' }}
</span> </span>
</td> </td>
<td>{{ device.locationname || '-' }}</td> <td>{{ device.locationname || '-' }}</td>
<td class="actions"> <td class="actions" @click.stop>
<router-link <router-link
:to="`/network/${device.networkdevice?.networkdeviceid}`" :to="`/network/${device.networkdevice?.networkdeviceid}`"
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"
> >
View View
</router-link> </router-link>
</td> </td>
</tr> </tr>
<tr v-if="devices.length === 0"> <tr v-if="devices.length === 0">
<td colspan="9" style="text-align: center; color: var(--text-light);"> <td colspan="9" style="text-align: center; color: var(--text-light);">
No network devices found No network devices found
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<PaginationBar <PaginationBar
:page="page" :page="page"
:totalPages="totalPages" :totalPages="totalPages"
:perPage="perPage" :perPage="perPage"
@update:page="goToPage" @update:page="goToPage"
@update:perPage="changePerPage" @update:perPage="changePerPage"
/> />
</template> </template>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle" import { colorStyle } from "@/utils/colorStyle"
import { networkApi, vendorsApi, locationsApi } from '../../api' import { networkApi, vendorsApi, locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue' import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery' import { useListQuery } from '@/composables/listQuery'
const devices = ref([]) const devices = ref([])
const deviceTypes = ref([]) const deviceTypes = ref([])
const vendors = ref([]) const vendors = ref([])
const locations = ref([]) const locations = ref([])
const loading = ref(true) const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices }) const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices })
const selectedType = ref(null) const selectedType = ref(null)
const vendorFilter = ref('') const vendorFilter = ref('')
const locationFilter = ref('') const locationFilter = ref('')
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(25) const perPage = ref(25)
const totalCount = ref(0) const totalCount = ref(0)
let searchTimeout = null let searchTimeout = null
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
loadDeviceTypes(), loadDeviceTypes(),
loadVendors(), loadVendors(),
loadLocations() loadLocations()
]) ])
await loadDevices() await loadDevices()
}) })
async function loadDeviceTypes() { async function loadDeviceTypes() {
try { try {
const response = await networkApi.types.list({ perpage: 100 }) const response = await networkApi.types.list({ perpage: 100 })
deviceTypes.value = response.data.data || [] deviceTypes.value = response.data.data || []
// Get counts for each type // Get counts for each type
await updateTypeCounts() await updateTypeCounts()
} catch (error) { } catch (error) {
console.error('Error loading device types:', error) console.error('Error loading device types:', error)
} }
} }
async function updateTypeCounts() { async function updateTypeCounts() {
// Get summary for type counts // Get summary for type counts
try { try {
const response = await networkApi.dashboardSummary() const response = await networkApi.dashboardSummary()
const byType = response.data.data?.bytype || response.data.data?.by_type || [] const byType = response.data.data?.bytype || response.data.data?.by_type || []
totalCount.value = response.data.data?.total || 0 totalCount.value = response.data.data?.total || 0
// Map counts to types // Map counts to types
deviceTypes.value = deviceTypes.value.map(t => { deviceTypes.value = deviceTypes.value.map(t => {
const found = byType.find(bt => bt.type === t.networkdevicetype) const found = byType.find(bt => bt.type === t.networkdevicetype)
return { ...t, count: found?.count || 0 } return { ...t, count: found?.count || 0 }
}) })
} catch (error) { } catch (error) {
console.error('Error loading type counts:', error) console.error('Error loading type counts:', error)
} }
} }
async function loadVendors() { async function loadVendors() {
try { try {
const response = await vendorsApi.list({ perpage: 100 }) const response = await vendorsApi.list({ perpage: 100 })
vendors.value = response.data.data || [] vendors.value = response.data.data || []
} catch (error) { } catch (error) {
console.error('Error loading vendors:', error) console.error('Error loading vendors:', error)
} }
} }
async function loadLocations() { async function loadLocations() {
try { try {
const response = await locationsApi.list({ perpage: 100 }) const response = await locationsApi.list({ perpage: 100 })
locations.value = response.data.data || [] locations.value = response.data.data || []
} catch (error) { } catch (error) {
console.error('Error loading locations:', error) console.error('Error loading locations:', error)
} }
} }
async function loadDevices() { async function loadDevices() {
loading.value = true loading.value = true
try { try {
const params = { const params = {
page: page.value, page: page.value,
perpage: perPage.value perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
if (selectedType.value) params.typeid = selectedType.value if (selectedType.value) params.typeid = selectedType.value
if (vendorFilter.value) params.vendorid = vendorFilter.value if (vendorFilter.value) params.vendorid = vendorFilter.value
if (locationFilter.value) params.locationid = locationFilter.value if (locationFilter.value) params.locationid = locationFilter.value
const response = await networkApi.list(params) const response = await networkApi.list(params)
devices.value = response.data.data || [] devices.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading network devices:', error) console.error('Error loading network devices:', error)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function selectType(typeId) { function selectType(typeId) {
selectedType.value = typeId selectedType.value = typeId
setPage(1) setPage(1)
loadDevices() loadDevices()
} }
function debouncedSearch() { function debouncedSearch() {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
setSearch(search.value) setSearch(search.value)
loadDevices() loadDevices()
}, 300) }, 300)
} }
function goToPage(p) { function goToPage(p) {
if (p >= 1 && p <= totalPages.value) { if (p >= 1 && p <= totalPages.value) {
setPage(p) setPage(p)
loadDevices() loadDevices()
} }
} }
function changePerPage(newPerPage) { function changePerPage(newPerPage) {
perPage.value = newPerPage perPage.value = newPerPage
setPage(1) setPage(1)
loadDevices() loadDevices()
} }
function getStatusClass(status) { function getStatusClass(status) {
if (!status) return 'badge-info' if (!status) return 'badge-info'
const s = status.toLowerCase() const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success' if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair' || s === 'maintenance') return 'badge-warning' if (s === 'in repair' || s === 'maintenance') return 'badge-warning'
if (s === 'retired' || s === 'decommissioned') return 'badge-danger' if (s === 'retired' || s === 'decommissioned') return 'badge-danger'
return 'badge-info' return 'badge-info'
} }
</script> </script>
<style scoped> <style scoped>
.type-tabs { .type-tabs {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.type-tabs button { .type-tabs button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--bg-card); background: var(--bg-card);
color: var(--text); color: var(--text);
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.type-tabs button:hover { .type-tabs button:hover {
background: var(--bg); background: var(--bg);
border-color: var(--primary); border-color: var(--primary);
} }
.type-tabs button.active { .type-tabs button.active {
background: var(--primary); background: var(--primary);
color: white; color: white;
border-color: var(--primary); border-color: var(--primary);
} }
.filters { .filters {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 1rem; margin-bottom: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.filters .form-control { .filters .form-control {
flex: 1; flex: 1;
min-width: 150px; min-width: 150px;
} }
.filters select.form-control { .filters select.form-control {
flex: 0 0 auto; flex: 0 0 auto;
width: auto; width: auto;
min-width: 150px; min-width: 150px;
} }
.mono { .mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
} }
.features { .features {
display: flex; display: flex;
gap: 0.375rem; gap: 0.375rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.feature-tag { .feature-tag {
display: inline-block; display: inline-block;
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
font-size: 0.75rem; font-size: 0.75rem;
border-radius: 4px; border-radius: 4px;
background: var(--bg); background: var(--bg);
color: var(--text-light); color: var(--text-light);
} }
.feature-tag.poe { .feature-tag.poe {
background: #d4edda; background: #d4edda;
color: #155724; color: #155724;
} }
.feature-tag.managed { .feature-tag.managed {
background: #e3f2fd; background: #e3f2fd;
color: #1565c0; color: #1565c0;
} }
.feature-tag.ports { .feature-tag.ports {
background: var(--bg); background: var(--bg);
color: var(--text-light); color: var(--text-light);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.feature-tag.poe { .feature-tag.poe {
background: #1e3a29; background: #1e3a29;
color: #4ade80; color: #4ade80;
} }
.feature-tag.managed { .feature-tag.managed {
background: #1e3a5f; background: #1e3a5f;
color: #60a5fa; color: #60a5fa;
} }
} }
</style> </style>

View File

@@ -21,15 +21,15 @@ import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import NetworkDevicesList from './NetworkDevicesList.vue' import NetworkDevicesList from './NetworkDevicesList.vue'
import SubnetsBrowse from './SubnetsBrowse.vue' import SubnetsBrowse from './SubnetsBrowse.vue'
import VLANsList from '../settings/VLANsList.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
// VLANs are a layer under a subnet (each Networks row shows its VLAN); VLAN
// naming lives in Settings, so the hub is just Devices + Networks.
const tabs = [ const tabs = [
{ key: 'devices', label: 'Devices', comp: NetworkDevicesList }, { key: 'devices', label: 'Devices', comp: NetworkDevicesList },
{ key: 'networks', label: 'Networks', comp: SubnetsBrowse }, { key: 'networks', label: 'Networks', comp: SubnetsBrowse },
{ key: 'vlans', label: 'VLANs', comp: VLANsList },
] ]
const active = ref(tabs.some(t => t.key === route.query.tab) ? route.query.tab : 'devices') const active = ref(tabs.some(t => t.key === route.query.tab) ? route.query.tab : 'devices')

View File

@@ -1,211 +1,211 @@
<template> <template>
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>Computers</h2> <h2>Computers</h2>
<router-link to="/print/asset-label-batch/computer" class="btn btn-secondary" target="_blank">Print Labels</router-link> <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> <router-link to="/pcs/new" class="btn btn-primary">Add Computer</router-link>
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="filters"> <div class="filters">
<input <input
v-model="search" v-model="search"
type="text" type="text"
class="form-control" class="form-control"
placeholder="Search computers..." placeholder="Search computers..."
@input="debouncedSearch" @input="debouncedSearch"
/> />
</div> </div>
<div class="card"> <div class="card">
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else> <template v-else>
<div class="table-container"> <div class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Asset Tag</th> <th>Asset Tag</th>
<th>Hostname</th> <th>Hostname</th>
<th>Serial Number</th> <th>Serial Number</th>
<th>Type</th> <th>Type</th>
<th>Remote Access</th> <th>Remote Access</th>
<th>Status</th> <th>Status</th>
<th>Location</th> <th>Location</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in computers" :key="item.assetid"> <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.assetnumber }}</td>
<td>{{ item.computer?.hostname || '-' }}</td> <td>{{ item.computer?.hostname || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td> <td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.computer?.computertypename || '-' }}</td> <td>{{ item.computer?.computertypename || '-' }}</td>
<td class="features"> <td class="features">
<template v-for="a in (item.accessmethods || [])" :key="a.id"> <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> <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> <span v-else class="access-link disabled" title="No hostname/IP set">{{ a.name }}</span>
</template> </template>
<span v-if="!(item.accessmethods || []).length">-</span> <span v-if="!(item.accessmethods || []).length">-</span>
</td> </td>
<td> <td>
<span class="badge" :style="colorStyle(item.statuscolor)"> <span class="badge" :style="colorStyle(item.statuscolor)">
{{ item.statusname || 'Unknown' }} {{ item.statusname || 'Unknown' }}
</span> </span>
</td> </td>
<td>{{ item.locationname || '-' }}</td> <td>{{ item.locationname || '-' }}</td>
<td class="actions"> <td class="actions" @click.stop>
<router-link <router-link
:to="`/pcs/${item.computer?.computerid || item.assetid}`" :to="`/pcs/${item.computer?.computerid || item.assetid}`"
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"
> >
View View
</router-link> </router-link>
</td> </td>
</tr> </tr>
<tr v-if="computers.length === 0"> <tr v-if="computers.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);"> <td colspan="8" style="text-align: center; color: var(--text-light);">
No computers found No computers found
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<PaginationBar <PaginationBar
:page="page" :page="page"
:totalPages="totalPages" :totalPages="totalPages"
:perPage="perPage" :perPage="perPage"
@update:page="goToPage" @update:page="goToPage"
@update:perPage="changePerPage" @update:perPage="changePerPage"
/> />
</template> </template>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { computersApi } from '../../api' import { computersApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue' import PaginationBar from '../../components/PaginationBar.vue'
import { colorStyle } from '@/utils/colorStyle' import { colorStyle } from '@/utils/colorStyle'
import { useListQuery } from '@/composables/listQuery' import { useListQuery } from '@/composables/listQuery'
const computers = ref([]) const computers = ref([])
const loading = ref(true) const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers }) const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers })
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20) const perPage = ref(20)
let searchTimeout = null let searchTimeout = null
onMounted(() => { onMounted(() => {
loadComputers() loadComputers()
}) })
async function loadComputers() { async function loadComputers() {
loading.value = true loading.value = true
try { try {
const params = { const params = {
page: page.value, page: page.value,
perpage: perPage.value perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
const response = await computersApi.list(params) const response = await computersApi.list(params)
computers.value = response.data.data || [] computers.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading computers:', error) console.error('Error loading computers:', error)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function debouncedSearch() { function debouncedSearch() {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
setSearch(search.value) setSearch(search.value)
loadComputers() loadComputers()
}, 300) }, 300)
} }
function goToPage(p) { function goToPage(p) {
setPage(p) setPage(p)
loadComputers() loadComputers()
} }
function changePerPage(newPerPage) { function changePerPage(newPerPage) {
perPage.value = newPerPage perPage.value = newPerPage
setPage(1) setPage(1)
loadComputers() loadComputers()
} }
</script> </script>
<style scoped> <style scoped>
.access-link { .access-link {
display: inline-block; display: inline-block;
padding: 2px 10px; padding: 2px 10px;
margin: 0 4px 3px 0; margin: 0 4px 3px 0;
border-radius: 12px; border-radius: 12px;
background: var(--primary); background: var(--primary);
color: #fff; color: #fff;
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
} }
.access-link:hover { .access-link:hover {
background: var(--primary-dark); background: var(--primary-dark);
text-decoration: none; text-decoration: none;
} }
.access-link.disabled { .access-link.disabled {
background: var(--secondary); background: var(--secondary);
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.mono { .mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
} }
/* keep the cell as a table-cell; flex on a <td> strips table-cell layout and /* keep the cell as a table-cell; flex on a <td> strips table-cell layout and
offsets the row. lay tags out inline instead. */ offsets the row. lay tags out inline instead. */
.features { .features {
white-space: nowrap; white-space: nowrap;
} }
.feature-tag { .feature-tag {
display: inline-block; display: inline-block;
margin-right: 0.375rem; margin-right: 0.375rem;
padding: 0.3rem 0.625rem; padding: 0.3rem 0.625rem;
font-size: 0.875rem; font-size: 0.875rem;
border-radius: 5px; border-radius: 5px;
background: var(--bg); background: var(--bg);
color: var(--text-light); color: var(--text-light);
} }
.feature-tag:last-child { .feature-tag:last-child {
margin-right: 0; margin-right: 0;
} }
/* the global .actions rule is inline-flex, which also breaks table-cell /* 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. */ alignment when applied straight on a <td>; pin it back to a cell here. */
td.actions { td.actions {
display: table-cell; display: table-cell;
vertical-align: middle; vertical-align: middle;
} }
.feature-tag.active { .feature-tag.active {
background: #e3f2fd; background: #e3f2fd;
color: #1976d2; color: #1976d2;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.feature-tag.active { .feature-tag.active {
background: #1e3a5f; background: #1e3a5f;
color: #60a5fa; color: #60a5fa;
} }
} }
</style> </style>

View File

@@ -44,7 +44,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid"> <tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid" class="clickable-row" @click="$router.push(`/printers/${printer.printer?.printerid || printer.assetid}`)">
<td>{{ printer.assetnumber }}</td> <td>{{ printer.assetnumber }}</td>
<td>{{ printer.name && printer.name.toUpperCase() !== 'NONE' ? printer.name : (printer.printer?.hostname || '-') }}</td> <td>{{ printer.name && printer.name.toUpperCase() !== 'NONE' ? printer.name : (printer.printer?.hostname || '-') }}</td>
<td>{{ printer.businessunitname || '-' }}</td> <td>{{ printer.businessunitname || '-' }}</td>
@@ -55,7 +55,7 @@
{{ printer.statusname || 'Active' }} {{ printer.statusname || 'Active' }}
</span> </span>
</td> </td>
<td class="actions"> <td class="actions" @click.stop>
<router-link <router-link
:to="`/printers/${printer.printer?.printerid || printer.assetid}`" :to="`/printers/${printer.printer?.printerid || printer.assetid}`"
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"