ADR-013 Phase 4: relocate the remaining 9 plugin frontends (all 13 done)
Relocate warranty, measuringtools, network, printers, usb, notifications, computers, and slides into plugins/<name>/frontend/. Each plugin's views are pulled from wherever they lived (own dir, plus the shared views/settings/, views/reports/, views/print/ dirs, and top-level views) into the plugin's frontend/views/, and its route file becomes the self-contained routes.js. Handled the messy cases: - computers: name mismatch (its views live in views/pcs/) - moved by following the route file's own imports, so the dir name did not matter. Its OS/access- protocol/PC-type settings views move with it (only computers.js routed them). - network: NetworkHub's sibling sub-views (NetworkDevicesList, SubnetsBrowse, not directly routed) moved too so its `./` imports resolve. - printers: the qrLogo helper is SHARED with core AssetLabel, so it stays in views/print/ and PrinterQR imports it via @/views/print/qrLogo. - slides: route file is toplevel-only (TVDashboard); SlideManager stays core (core.js routes /settings/slides). frontend/src/views/ now holds only core views; frontend/src/router/routes/ holds only core.js. All 13 plugins are self-contained under plugins/<name>/frontend/. Verified live: Network (hub + moved sub-views), Computers (name mismatch), GE-Enforce (helper), printedparts all render from their staged frontends. Build + 58 vitest + naming green.
This commit is contained in:
48
plugins/computers/frontend/routes.js
Normal file
48
plugins/computers/frontend/routes.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Computers plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'pcs',
|
||||
name: 'pcs',
|
||||
component: () => import('./views/PCsList.vue'),
|
||||
meta: { plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'pcs/new',
|
||||
name: 'pc-new',
|
||||
component: () => import('./views/PCForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'pcs/:id',
|
||||
name: 'pc-detail',
|
||||
component: () => import('./views/PCDetail.vue'),
|
||||
meta: { plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'pcs/:id/edit',
|
||||
name: 'pc-edit',
|
||||
component: () => import('./views/PCForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'computers' }
|
||||
},
|
||||
// Computer-specific settings
|
||||
{
|
||||
path: 'settings/pctypes',
|
||||
name: 'pctypes',
|
||||
component: () => import('./views/PCTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/operatingsystems',
|
||||
name: 'operatingsystems',
|
||||
component: () => import('./views/OperatingSystemsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/accessprotocols',
|
||||
name: 'access-protocols',
|
||||
component: () => import('./views/AccessProtocolsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
}
|
||||
]
|
||||
220
plugins/computers/frontend/views/AccessProtocolsList.vue
Normal file
220
plugins/computers/frontend/views/AccessProtocolsList.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>PC Access Protocols</h1>
|
||||
<div class="actions">
|
||||
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
|
||||
<button class="btn btn-primary" @click="openNew">New Protocol</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="hint">
|
||||
Remote-access protocols offered on PCs. Links are built as
|
||||
<code>{{ '{scheme}://{hostname}.<pc_access_domain>:{port}' }}</code> from
|
||||
each protocol's template. Set the domain in
|
||||
<router-link to="/settings/site">Site & Facility</router-link>.
|
||||
</p>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Scheme</th>
|
||||
<th>Default port</th>
|
||||
<th>Link template</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in protocols" :key="p.protocolid">
|
||||
<td><strong>{{ p.name }}</strong></td>
|
||||
<td class="mono">{{ p.scheme }}</td>
|
||||
<td>{{ p.defaultport ?? '-' }}</td>
|
||||
<td class="mono">{{ p.linktemplate }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="p.isactive ? 'badge-success' : 'badge-secondary'">
|
||||
{{ p.isactive ? 'yes' : 'no' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-sm btn-secondary" @click="openEdit(p)">Edit</button>
|
||||
<button class="btn btn-sm btn-danger" @click="remove(p)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !protocols.length">
|
||||
<td colspan="6" class="muted" style="text-align:center;">No protocols.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="editing" class="modal-overlay" @click.self="close">
|
||||
<div class="modal-panel">
|
||||
<h2>{{ form.protocolid ? 'Edit' : 'New' }} Protocol</h2>
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input v-model="form.name" type="text" maxlength="50" placeholder="e.g. VNC" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Scheme</span>
|
||||
<input v-model="form.scheme" type="text" maxlength="20" placeholder="vnc / rdp / https / ssh" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Default port</span>
|
||||
<input v-model.number="form.defaultport" type="number" min="1" max="65535" placeholder="5900" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Link template</span>
|
||||
<input v-model="form.linktemplate" type="text" maxlength="255" placeholder="vnc://{host}:{port}" />
|
||||
<small class="muted">Placeholders: <code>{host}</code>, <code>{port}</code>, <code>{scheme}</code></small>
|
||||
</label>
|
||||
<label class="field checkbox">
|
||||
<input v-model="form.isactive" type="checkbox" />
|
||||
<span>Active</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="close">Cancel</button>
|
||||
<button class="btn btn-primary" :disabled="saving || !formValid" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computersApi } from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const protocols = ref([])
|
||||
const loading = ref(true)
|
||||
const editing = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({})
|
||||
|
||||
const formValid = computed(() =>
|
||||
form.value.name && form.value.scheme && form.value.linktemplate
|
||||
)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await computersApi.protocols.list({ active: false })
|
||||
protocols.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading protocols:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
error.value = ''
|
||||
form.value = { name: '', scheme: '', defaultport: null, linktemplate: '', isactive: true }
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function openEdit(p) {
|
||||
error.value = ''
|
||||
form.value = {
|
||||
protocolid: p.protocolid,
|
||||
name: p.name,
|
||||
scheme: p.scheme,
|
||||
defaultport: p.defaultport ?? null,
|
||||
linktemplate: p.linktemplate,
|
||||
isactive: p.isactive !== false
|
||||
}
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
if (form.value.protocolid) {
|
||||
await computersApi.protocols.update(form.value.protocolid, form.value)
|
||||
} else {
|
||||
await computersApi.protocols.create(form.value)
|
||||
}
|
||||
editing.value = false
|
||||
await load()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Save failed.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(p) {
|
||||
if (!confirm(`Delete protocol "${p.name}"? (kept but deactivated if any PC uses it)`)) return
|
||||
try {
|
||||
await computersApi.protocols.remove(p.protocolid)
|
||||
await load()
|
||||
} catch (err) {
|
||||
console.error('Error deleting protocol:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.mono { font-family: monospace; font-size: 0.85rem; }
|
||||
.muted { color: var(--text-light); }
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 40px 16px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-panel {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.modal-panel h2 { margin: 0 0 18px; }
|
||||
.form-grid { display: flex; flex-direction: column; gap: 14px; }
|
||||
.field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.field > span { font-size: 0.85rem; color: var(--text-light); }
|
||||
.field input[type="text"],
|
||||
.field input[type="number"] {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.field.checkbox { flex-direction: row; align-items: center; gap: 8px; }
|
||||
.field.checkbox > span { color: var(--text); font-size: 1rem; }
|
||||
.error { color: var(--danger); margin: 12px 0 0; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
|
||||
</style>
|
||||
228
plugins/computers/frontend/views/OperatingSystemsList.vue
Normal file
228
plugins/computers/frontend/views/OperatingSystemsList.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Operating Systems</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add OS</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>OS Name</th>
|
||||
<th>Version</th>
|
||||
<th>Architecture</th>
|
||||
<th>End of Life</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="os in items" :key="os.osid">
|
||||
<td>{{ os.osname }}</td>
|
||||
<td>{{ os.osversion || '-' }}</td>
|
||||
<td>{{ os.architecture || '-' }}</td>
|
||||
<td>
|
||||
<span v-if="os.endoflife" :class="{ 'text-danger': isPastEol(os.endoflife) }">
|
||||
{{ os.endoflife }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(os)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="confirmDelete(os)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-light);">
|
||||
No operating systems 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">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit Operating System' : 'Add Operating System' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="osname">OS Name *</label>
|
||||
<input id="osname" v-model="form.osname" type="text" class="form-control" required placeholder="Windows 11" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="osversion">Version</label>
|
||||
<input id="osversion" v-model="form.osversion" type="text" class="form-control" placeholder="23H2" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="architecture">Architecture</label>
|
||||
<select id="architecture" v-model="form.architecture" class="form-control">
|
||||
<option value="">Select...</option>
|
||||
<option value="x64">x64</option>
|
||||
<option value="x86">x86</option>
|
||||
<option value="ARM64">ARM64</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="endoflife">End of Life Date</label>
|
||||
<input id="endoflife" v-model="form.endoflife" type="date" class="form-control" />
|
||||
</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 Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Delete Operating System</h3></div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ toDelete?.osname }}</strong>?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteItem">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { operatingsystemsApi } from '@/api'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, setPage } = useListQuery({ onChange: loadData })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const toDelete = ref(null)
|
||||
|
||||
const form = ref({ osname: '', osversion: '', architecture: '', endoflife: '' })
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await operatingsystemsApi.list({ page: page.value, perpage: perPage.value })
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading operating systems:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p) { setPage(p); loadData() }
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadData()
|
||||
}
|
||||
|
||||
function isPastEol(date) {
|
||||
return new Date(date) < new Date()
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item ? {
|
||||
osname: item.osname || '',
|
||||
osversion: item.osversion || '',
|
||||
architecture: item.architecture || '',
|
||||
endoflife: item.endoflife || ''
|
||||
} : { osname: '', osversion: '', architecture: '', endoflife: '' }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const data = { ...form.value }
|
||||
if (!data.endoflife) data.endoflife = null
|
||||
if (editing.value) {
|
||||
await operatingsystemsApi.update(editing.value.osid, data)
|
||||
} else {
|
||||
await operatingsystemsApi.create(data)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
|
||||
|
||||
async function deleteItem() {
|
||||
try {
|
||||
await operatingsystemsApi.delete(toDelete.value.osid)
|
||||
showDeleteModal.value = false
|
||||
toDelete.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.text-danger {
|
||||
color: var(--danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
426
plugins/computers/frontend/views/PCDetail.vue
Normal file
426
plugins/computers/frontend/views/PCDetail.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Computer Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
|
||||
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="computer">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="computer.computer?.imageurl">
|
||||
<img :src="computer.computer.imageurl" :alt="computer.computer.modelname || 'Model photo'" />
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ computer.assetnumber }}</h1>
|
||||
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-info">Computer</span>
|
||||
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
|
||||
{{ computer.statusname || 'Unknown' }}
|
||||
</span>
|
||||
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
|
||||
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
||||
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ formatWarrantyDate(heroWarranty.enddate) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="computer.computer?.computertypename">
|
||||
<span class="hero-detail-label">Type</span>
|
||||
<span class="hero-detail-value">{{ computer.computer.computertypename }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="computer.computer?.osname">
|
||||
<span class="hero-detail-label">OS</span>
|
||||
<span class="hero-detail-value">{{ computer.computer.osname }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="computer.locationname">
|
||||
<span class="hero-detail-label">Location</span>
|
||||
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Identity Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Identity</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number</span>
|
||||
<span class="info-value">{{ computer.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="computer.name">
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ computer.name }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname">
|
||||
<span class="info-label">Hostname</span>
|
||||
<span class="info-value mono">{{ computer.computer.hostname }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="computer.serialnumber">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ computer.serialnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('gaugelabreference', 'computer') && computer.gaugelabreference">
|
||||
<span class="info-label">Gauge Lab Reference</span>
|
||||
<span class="info-value mono">{{ computer.gaugelabreference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('maintenancereference', 'computer') && computer.maintenancereference">
|
||||
<span class="info-label">Maintenance Reference</span>
|
||||
<span class="info-value mono">{{ computer.maintenancereference }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardware Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Hardware</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Computer Type</span>
|
||||
<span class="info-value">{{ computer.computer?.computertypename || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Vendor</span>
|
||||
<span class="info-value">{{ computer.computer?.vendorname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Model</span>
|
||||
<span class="info-value">{{ computer.computer?.modelname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Operating System</span>
|
||||
<span class="info-value">{{ computer.computer?.osname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remote Access -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Remote Access</h3>
|
||||
<div class="access-methods">
|
||||
<template v-for="a in (computer.accessmethods || [])" :key="a.id">
|
||||
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link">{{ a.name }}</a>
|
||||
<span v-else class="access-link disabled" title="No hostname/IP set for this PC">{{ a.name }}</span>
|
||||
</template>
|
||||
<span v-if="!(computer.accessmethods || []).length" class="muted">None configured</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status / Check-in -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Status</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Logged In User</span>
|
||||
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Reported</span>
|
||||
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Boot</span>
|
||||
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Network -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Network</h3>
|
||||
<div v-if="computer.communications?.length" class="network-list">
|
||||
<div v-for="comm in computer.communications" :key="comm.communicationid" class="network-item">
|
||||
<div class="network-primary">
|
||||
<span class="ip-address mono">{{ comm.ipaddress || comm.address || '-' }}</span>
|
||||
<span v-if="comm.communicationtypename" class="comm-type">{{ comm.communicationtypename }}</span>
|
||||
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
||||
</div>
|
||||
<div class="network-secondary" v-if="comm.macaddress">
|
||||
<span class="mac-address mono">{{ comm.macaddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="muted">No network addresses on record</p>
|
||||
</div>
|
||||
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">
|
||||
<LocationMapTooltip
|
||||
v-if="computer.mapx != null && computer.mapy != null"
|
||||
:left="computer.mapx"
|
||||
:top="computer.mapy"
|
||||
:machineName="computer.assetnumber"
|
||||
>
|
||||
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>
|
||||
</LocationMapTooltip>
|
||||
<span v-else>{{ computer.locationname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ computer.businessunitname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installed Applications -->
|
||||
<div class="section-card" v-if="installedApps.length > 0">
|
||||
<h3 class="section-title">Installed Applications</h3>
|
||||
<div class="app-list">
|
||||
<router-link
|
||||
v-for="app in installedApps"
|
||||
:key="app.id"
|
||||
:to="`/applications/${app.appid}`"
|
||||
class="app-item"
|
||||
>
|
||||
<div class="app-info">
|
||||
<span class="app-name">{{ app.appname }}</span>
|
||||
<span class="app-version" v-if="app.installedversion">v{{ app.installedversion }}</span>
|
||||
</div>
|
||||
<div class="app-desc" v-if="app.appdescription">
|
||||
{{ app.appdescription }}
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="computer.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<PluginAssetPanels :assetid="computer.assetid" />
|
||||
|
||||
<!-- All relationships (controls, defaultprinter, ...) -->
|
||||
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="computer.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ computer.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
|
||||
<span>Modified {{ formatDate(computer.modifieddate) }}<template v-if="computer.modifiedby"> by {{ computer.modifiedby }}</template></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Computer not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computersApi, applicationsApi } from '@/api'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
||||
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
|
||||
const route = useRoute()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const computer = ref(null)
|
||||
const installedApps = ref([])
|
||||
|
||||
// Warranty fetch + hero badge (shared across asset detail pages).
|
||||
const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid)
|
||||
|
||||
// relationships render via the shared AssetRelationships card
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await computersApi.get(route.params.id)
|
||||
computer.value = response.data.data
|
||||
|
||||
// Load installed applications
|
||||
try {
|
||||
const appsResponse = await applicationsApi.getMachineApps(route.params.id)
|
||||
installedApps.value = appsResponse.data.data || []
|
||||
} catch (appError) {
|
||||
}
|
||||
// Warranties load via useWarrantyBadge (watches computer.assetid).
|
||||
} catch (error) {
|
||||
console.error('Error loading computer:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (!status) return 'badge-info'
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'in use' || s === 'active') return 'badge-success'
|
||||
if (s === 'in repair') return 'badge-warning'
|
||||
if (s === 'retired') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* PC-specific styles - shared styles are in global style.css */
|
||||
|
||||
/* Remote-access protocol links */
|
||||
.access-methods {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.access-methods .muted {
|
||||
color: var(--text-light);
|
||||
}
|
||||
.access-link {
|
||||
display: inline-block;
|
||||
padding: 3px 12px;
|
||||
margin: 0 6px 4px 0;
|
||||
border-radius: 14px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 0.82rem;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Installed Applications */
|
||||
.app-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.app-item:hover {
|
||||
background: var(--border);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app-version {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
margin-top: 0.375rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Network details */
|
||||
.network-details {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Network card (IP / MAC list) */
|
||||
.network-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.network-item {
|
||||
padding: 0.5rem 0.7rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.network-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ip-address {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.comm-type {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.primary-badge {
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.mac-address {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-light);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
609
plugins/computers/frontend/views/PCForm.vue
Normal file
609
plugins/computers/frontend/views/PCForm.vue
Normal file
@@ -0,0 +1,609 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit PC' : 'New PC' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="savePC">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="machinenumber">PC Number *</label>
|
||||
<input
|
||||
id="machinenumber"
|
||||
v-model="form.machinenumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
@input="onPcNumberInput"
|
||||
/>
|
||||
<small class="form-hint">Defaults to the serial number; editable</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="alias">Alias</label>
|
||||
<input
|
||||
id="alias"
|
||||
v-model="form.alias"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group" v-if="isEnabled('fqdn', 'computer')">
|
||||
<label for="hostname">Hostname</label>
|
||||
<input
|
||||
id="hostname"
|
||||
v-model="form.hostname"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number</label>
|
||||
<input
|
||||
id="serialnumber"
|
||||
v-model="form.serialnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" v-if="isEnabled('gaugelabreference', 'computer') || isEnabled('maintenancereference', 'computer')">
|
||||
<div class="form-group" v-if="isEnabled('gaugelabreference', 'computer')">
|
||||
<label for="gaugelabreference">Gauge Lab Reference</label>
|
||||
<input
|
||||
id="gaugelabreference"
|
||||
v-model="form.gaugelabreference"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="isEnabled('maintenancereference', 'computer')">
|
||||
<label for="maintenancereference">Maintenance Reference</label>
|
||||
<input
|
||||
id="maintenancereference"
|
||||
v-model="form.maintenancereference"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="machinetypeid">PC Type *</label>
|
||||
<select
|
||||
id="machinetypeid"
|
||||
v-model="form.machinetypeid"
|
||||
class="form-control"
|
||||
required
|
||||
@change="form.modelnumberid = ''"
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
<option
|
||||
v-for="pt in pcTypes"
|
||||
:key="pt.computertypeid"
|
||||
:value="pt.computertypeid"
|
||||
>
|
||||
{{ pt.computertype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="osid">Operating System</label>
|
||||
<select
|
||||
id="osid"
|
||||
v-model="form.osid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select OS...</option>
|
||||
<option
|
||||
v-for="os in operatingsystems"
|
||||
:key="os.osid"
|
||||
:value="os.osid"
|
||||
>
|
||||
{{ os.osname }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="statusid">Status</label>
|
||||
<select
|
||||
id="statusid"
|
||||
v-model="form.statusid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select status...</option>
|
||||
<option
|
||||
v-for="s in statuses"
|
||||
:key="s.statusid"
|
||||
:value="s.statusid"
|
||||
>
|
||||
{{ s.status }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="locationid">Location</label>
|
||||
<select
|
||||
id="locationid"
|
||||
v-model="form.locationid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select location...</option>
|
||||
<option
|
||||
v-for="l in locations"
|
||||
:key="l.locationid"
|
||||
:value="l.locationid"
|
||||
>
|
||||
{{ l.location }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="vendorid">Vendor</label>
|
||||
<select
|
||||
id="vendorid"
|
||||
v-model="form.vendorid"
|
||||
class="form-control"
|
||||
@change="form.modelnumberid = ''"
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modelnumberid">Model</label>
|
||||
<select
|
||||
id="modelnumberid"
|
||||
v-model="form.modelnumberid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select model...</option>
|
||||
<option
|
||||
v-for="m in filteredModels"
|
||||
:key="m.modelnumberid"
|
||||
:value="m.modelnumberid"
|
||||
>
|
||||
{{ m.modelnumber }}
|
||||
</option>
|
||||
</select>
|
||||
<small v-if="!form.vendorid && !form.machinetypeid" class="form-hint">
|
||||
Select vendor or PC type to filter models
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PC-specific fields -->
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Network Settings</h4>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="ipaddress">IP Address</label>
|
||||
<input
|
||||
id="ipaddress"
|
||||
v-model="form.ipaddress"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., 192.168.1.100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="loggedinuser">Logged In User</label>
|
||||
<input
|
||||
id="loggedinuser"
|
||||
v-model="form.loggedinuser"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Remote Access Protocols</label>
|
||||
<div class="protocol-list">
|
||||
<label v-for="p in protocols" :key="p.protocolid" class="protocol-item">
|
||||
<input type="checkbox" :checked="isProtocolOn(p.protocolid)" @change="toggleProtocol(p.protocolid, $event.target.checked)" />
|
||||
<span>{{ p.name }}</span>
|
||||
<input
|
||||
v-if="isProtocolOn(p.protocolid)"
|
||||
type="number"
|
||||
class="port-override"
|
||||
:value="protocolPort(p.protocolid)"
|
||||
:placeholder="p.defaultport || 'port'"
|
||||
min="1"
|
||||
max="65535"
|
||||
title="Port override (blank = default)"
|
||||
@input="setProtocolPort(p.protocolid, $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
<span v-if="!protocols.length" class="muted">No protocols defined. Add them under Settings > PC Access Protocols.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
v-model="form.notes"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Map Location Picker -->
|
||||
<div class="form-group">
|
||||
<label>Map Location</label>
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
Set Location on Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Picker Modal -->
|
||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||
<div class="map-modal-content">
|
||||
<ShopFloorMap
|
||||
:pickerMode="true"
|
||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||
:theme="currentTheme"
|
||||
@positionPicked="handlePositionPicked"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="showMapPicker = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="confirmMapPosition">Confirm Location</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Site-defined custom fields for computers -->
|
||||
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="COMPUTER_ASSETTYPEID" :assetid="currentAssetId" />
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save PC' }}
|
||||
</button>
|
||||
<router-link to="/pcs" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '@/api'
|
||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
// Seeded asset-type id for computers (see /api/assets/types). Custom-field
|
||||
// values are keyed by the underlying asset id, captured on load / create.
|
||||
const COMPUTER_ASSETTYPEID = 2
|
||||
const customFieldsRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
|
||||
// PC Number (assetnumber) defaults to the serial number while the user hasn't
|
||||
// typed their own. Editable; only auto-fills on a new PC.
|
||||
const manualPcNumber = ref(false)
|
||||
function onPcNumberInput() {
|
||||
manualPcNumber.value = true
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const showMapPicker = ref(false)
|
||||
const tempMapPosition = ref(null)
|
||||
|
||||
const form = ref({
|
||||
machinenumber: '',
|
||||
alias: '',
|
||||
hostname: '',
|
||||
serialnumber: '',
|
||||
gaugelabreference: '',
|
||||
maintenancereference: '',
|
||||
machinetypeid: '',
|
||||
statusid: '',
|
||||
vendorid: '',
|
||||
modelnumberid: '',
|
||||
locationid: '',
|
||||
osid: '',
|
||||
loggedinuser: '',
|
||||
accessmethods: [],
|
||||
notes: '',
|
||||
mapx: null,
|
||||
mapy: null,
|
||||
ipaddress: ''
|
||||
})
|
||||
|
||||
const pcTypes = ref([])
|
||||
const protocols = ref([])
|
||||
const statuses = ref([])
|
||||
|
||||
// Access-method editor helpers (form.accessmethods = [{protocolid, portoverride}])
|
||||
function isProtocolOn(protocolid) {
|
||||
return form.value.accessmethods.some(a => a.protocolid === protocolid)
|
||||
}
|
||||
function protocolPort(protocolid) {
|
||||
const found = form.value.accessmethods.find(a => a.protocolid === protocolid)
|
||||
return found && found.portoverride != null ? found.portoverride : ''
|
||||
}
|
||||
function toggleProtocol(protocolid, on) {
|
||||
if (on) {
|
||||
if (!isProtocolOn(protocolid)) {
|
||||
form.value.accessmethods.push({ protocolid, portoverride: null })
|
||||
}
|
||||
} else {
|
||||
form.value.accessmethods = form.value.accessmethods.filter(a => a.protocolid !== protocolid)
|
||||
}
|
||||
}
|
||||
function setProtocolPort(protocolid, value) {
|
||||
const found = form.value.accessmethods.find(a => a.protocolid === protocolid)
|
||||
if (found) {
|
||||
const n = parseInt(value, 10)
|
||||
found.portoverride = Number.isFinite(n) ? n : null
|
||||
}
|
||||
}
|
||||
const vendors = ref([])
|
||||
const models = ref([])
|
||||
const locations = ref([])
|
||||
const operatingsystems = ref([])
|
||||
|
||||
// Default PC Number to serial while the user hasn't typed their own (new PC only)
|
||||
watch(() => form.value.serialnumber, (serial) => {
|
||||
if (!isEdit.value && !manualPcNumber.value && serial) {
|
||||
form.value.machinenumber = serial
|
||||
}
|
||||
})
|
||||
|
||||
// Filter models by selected vendor and PC type
|
||||
const filteredModels = computed(() => {
|
||||
// filter by vendor only (PC type now maps to computertypeid, a different id
|
||||
// space than a model's machinetypeid)
|
||||
if (!form.value.vendorid) return models.value
|
||||
return models.value.filter(m => m.vendorid === form.value.vendorid)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load reference data
|
||||
// perpage 100 so dropdowns aren't truncated to the default 20-row page
|
||||
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes, protoRes] = await Promise.all([
|
||||
computersApi.types.list({ perpage: 100 }),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 100 }),
|
||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
locationsApi.list({ perpage: 100 }),
|
||||
operatingsystemsApi.list({ perpage: 100 }),
|
||||
computersApi.protocols.list()
|
||||
])
|
||||
|
||||
pcTypes.value = ptRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
models.value = allModels
|
||||
locations.value = locRes.data.data || []
|
||||
operatingsystems.value = osRes.data.data || []
|
||||
protocols.value = protoRes.data.data || []
|
||||
|
||||
// Load PC if editing (asset-based shape: extension under pc.computer)
|
||||
if (isEdit.value) {
|
||||
const response = await computersApi.get(route.params.id)
|
||||
const pc = response.data.data
|
||||
const ext = pc.computer || {}
|
||||
currentAssetId.value = pc.assetid || null
|
||||
|
||||
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
|
||||
|
||||
form.value = {
|
||||
machinenumber: pc.assetnumber || '',
|
||||
alias: pc.name && pc.name.toUpperCase() !== 'NONE' ? pc.name : '',
|
||||
hostname: ext.hostname || '',
|
||||
serialnumber: pc.serialnumber || '',
|
||||
gaugelabreference: pc.gaugelabreference || '',
|
||||
maintenancereference: pc.maintenancereference || '',
|
||||
machinetypeid: ext.computertypeid || '',
|
||||
statusid: pc.statusid || '',
|
||||
vendorid: ext.vendorid || '',
|
||||
modelnumberid: ext.modelnumberid || '',
|
||||
locationid: pc.locationid || '',
|
||||
osid: ext.osid || '',
|
||||
loggedinuser: ext.loggedinuser || '',
|
||||
accessmethods: (pc.accessmethods || []).map(a => ({
|
||||
protocolid: a.protocolid,
|
||||
portoverride: a.portoverride ?? null
|
||||
})),
|
||||
notes: pc.notes || '',
|
||||
mapx: pc.mapx ?? null,
|
||||
mapy: pc.mapy ?? null,
|
||||
ipaddress: primaryComm?.ipaddress || ''
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function handlePositionPicked(position) {
|
||||
tempMapPosition.value = position
|
||||
}
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapx = tempMapPosition.value.left
|
||||
form.value.mapy = tempMapPosition.value.top
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
|
||||
function clearMapPosition() {
|
||||
form.value.mapx = null
|
||||
form.value.mapy = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
async function savePC() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
// One payload for the computers plugin (asset core + computer extension +
|
||||
// primary IP). "PC Number" is the business identifier (assetnumber).
|
||||
const payload = {
|
||||
assetnumber: form.value.machinenumber,
|
||||
hostname: form.value.hostname || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
gaugelabreference: form.value.gaugelabreference || null,
|
||||
maintenancereference: form.value.maintenancereference || null,
|
||||
computertypeid: form.value.machinetypeid || null,
|
||||
statusid: form.value.statusid || null,
|
||||
vendorid: form.value.vendorid || null,
|
||||
modelnumberid: form.value.modelnumberid || null,
|
||||
locationid: form.value.locationid || null,
|
||||
osid: form.value.osid || null,
|
||||
loggedinuser: form.value.loggedinuser || null,
|
||||
accessmethods: form.value.accessmethods,
|
||||
notes: form.value.notes || null,
|
||||
ipaddress: form.value.ipaddress || null,
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy
|
||||
}
|
||||
// only set display name when an alias is given, so we don't clobber it
|
||||
if (form.value.alias) {
|
||||
payload.name = form.value.alias
|
||||
}
|
||||
|
||||
let assetId = currentAssetId.value
|
||||
if (isEdit.value) {
|
||||
const response = await computersApi.update(route.params.id, payload)
|
||||
assetId = assetId || response.data?.data?.assetid || response.data?.data?.asset?.assetid
|
||||
} else {
|
||||
const response = await computersApi.create(payload)
|
||||
assetId = response.data?.data?.assetid || response.data?.data?.asset?.assetid
|
||||
}
|
||||
|
||||
// Persist any custom-field values now that we have an asset id.
|
||||
if (assetId && customFieldsRef.value) {
|
||||
try {
|
||||
await customFieldsRef.value.save(assetId)
|
||||
} catch (cfErr) {
|
||||
console.error('Error saving custom fields:', cfErr)
|
||||
}
|
||||
}
|
||||
|
||||
router.push('/pcs')
|
||||
} catch (err) {
|
||||
console.error('Error saving PC:', err)
|
||||
error.value = apiError(err, 'Failed to save PC')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.protocol-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
.protocol-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.protocol-item .port-override {
|
||||
width: 78px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.map-location-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.current-position {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.map-modal-content {
|
||||
height: calc(90vh - 140px);
|
||||
}
|
||||
|
||||
.map-modal-content :deep(.shopfloor-map) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-modal-content :deep(.map-container) {
|
||||
height: calc(100% - 50px);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light, #666);
|
||||
}
|
||||
</style>
|
||||
157
plugins/computers/frontend/views/PCTypesList.vue
Normal file
157
plugins/computers/frontend/views/PCTypesList.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>PC Types</h2>
|
||||
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PC Type</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pt in visiblePcTypes" :key="pt.computertypeid">
|
||||
<td>{{ pt.computertype }}</td>
|
||||
<td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(pt.color)">{{ pt.color || 'auto' }}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<span v-if="pt.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteType(pt)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visiblePcTypes.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No PC types found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit PC Type' : 'Add PC Type' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="computertype">PC Type *</label>
|
||||
<input id="computertype" v-model="form.computertype" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
|
||||
<ColorSwatchPicker v-model="form.color" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computersApi } from '@/api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const pcTypes = ref([])
|
||||
const showInactive = ref(false)
|
||||
const visiblePcTypes = computed(() => showInactive.value ? pcTypes.value : pcTypes.value.filter(x => x.isactive !== false))
|
||||
const loading = ref(true)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({ computertype: '', description: '', color: '', isactive: true })
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await computersApi.types.list({ perpage: 200, active: false })
|
||||
pcTypes.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading PC types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? { computertype: item.computertype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
|
||||
: { computertype: '', description: '', color: '', isactive: true }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function deleteType(pt) {
|
||||
if (!confirm(`Delete PC type "${pt.computertype}"?`)) return
|
||||
try {
|
||||
await computersApi.types.remove(pt.computertypeid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await computersApi.types.update(editing.value.computertypeid, form.value)
|
||||
} else {
|
||||
await computersApi.types.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
211
plugins/computers/frontend/views/PCsList.vue
Normal file
211
plugins/computers/frontend/views/PCsList.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<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"
|
||||
/>
|
||||
</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>Hostname</th>
|
||||
<th>Serial Number</th>
|
||||
<th>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.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);">
|
||||
No computers 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 { computersApi } from '@/api'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const computers = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers })
|
||||
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
|
||||
|
||||
const response = await computersApi.list(params)
|
||||
computers.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading computers:', error)
|
||||
} 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>
|
||||
Reference in New Issue
Block a user