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:
cproudlock
2026-07-18 23:56:07 -04:00
parent 23dc9fa379
commit ebca0b00b0
45 changed files with 149 additions and 149 deletions

View File

@@ -0,0 +1,57 @@
/**
* Printers plugin routes
*/
export default [
{
path: 'printers',
name: 'printers',
component: () => import('./views/PrintersList.vue'),
meta: { plugin: 'printers' }
},
{
path: 'printers/new',
name: 'printer-new',
component: () => import('./views/PrinterForm.vue'),
meta: { requiresAuth: true, plugin: 'printers' }
},
{
path: 'printers/:id',
name: 'printer-detail',
component: () => import('./views/PrinterDetail.vue'),
meta: { plugin: 'printers' }
},
{
path: 'printers/:id/edit',
name: 'printer-edit',
component: () => import('./views/PrinterForm.vue'),
meta: { requiresAuth: true, plugin: 'printers' }
},
// printer-specific settings
{
path: 'settings/modelsupplies',
name: 'model-supplies',
component: () => import('./views/ModelSuppliesList.vue'),
meta: { requiresAuth: true, plugin: 'printers' }
},
{
path: 'settings/printerdrivers',
name: 'printer-drivers',
component: () => import('./views/PrinterDriversList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
}
]
export const toplevel = [
{
path: '/print/printer-qr',
name: 'print-printer-qr-batch',
component: () => import('./views/PrinterQRBatch.vue'),
meta: { plugin: 'printers' }
},
{
path: '/print/printer-qr/:id',
name: 'print-printer-qr-single',
component: () => import('./views/PrinterQRSingle.vue'),
meta: { plugin: 'printers' }
}
]

View File

@@ -0,0 +1,438 @@
<template>
<div>
<div class="page-header">
<h2>Model Toners and Supplies</h2>
<span class="subtitle">Map toner, drum, and waste part numbers to each printer model.</span>
</div>
<div class="supplies-layout">
<!-- model picker -->
<div class="card model-panel">
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search models..."
@input="debouncedSearch"
/>
<label class="withsupplies">
<input v-model="onlyWithSupplies" type="checkbox" @change="loadModels" />
With supplies only
</label>
</div>
<div v-if="loadingModels" class="loading">Loading...</div>
<div v-else class="model-list">
<button
v-for="m in models"
:key="m.modelnumberid"
class="model-row"
:class="{ active: selectedModel && selectedModel.modelnumberid === m.modelnumberid }"
@click="selectModel(m)"
>
<span class="model-name">{{ m.modelnumber }}</span>
<span class="model-meta">
<span class="vendor">{{ m.vendor || 'No vendor' }}</span>
<span class="badge" :class="m.supplycount ? 'badge-success' : ''">
{{ m.supplycount }}
</span>
</span>
</button>
<p v-if="!models.length" class="empty-state">No models match.</p>
</div>
</div>
<!-- supplies for the selected model -->
<div class="card supply-panel">
<div v-if="!selectedModel" class="empty-state">
Select a model to view and edit its supplies.
</div>
<template v-else>
<div class="panel-header">
<h3>{{ selectedModel.modelnumber }}</h3>
<button class="btn btn-primary" @click="openModal()">+ Add Supply</button>
</div>
<div v-if="loadingSupplies" class="loading">Loading...</div>
<div v-else class="table-container">
<table v-if="supplies.length">
<thead>
<tr>
<th>Type</th>
<th>Color</th>
<th>Tier</th>
<th>Part Number</th>
<th>Name</th>
<th>Yield</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="s in supplies" :key="s.modelsupplyid">
<td>{{ s.supplytype }}</td>
<td>
<span v-if="s.color !== 'none'" class="color-dot" :class="'dot-' + s.color"></span>
{{ s.color === 'none' ? '-' : s.color }}
</td>
<td>{{ s.capacitytier }}</td>
<td class="partnumber">{{ s.partnumber }}</td>
<td>{{ s.marketingname || '-' }}</td>
<td>{{ s.pageyield ? s.pageyield.toLocaleString() : '-' }}</td>
<td class="actions">
<button class="btn btn-sm btn-secondary" @click="openModal(s)">Edit</button>
<button class="btn btn-sm btn-danger" @click="remove(s)">Delete</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty-state">No supplies mapped yet. Add one above.</p>
</div>
</template>
</div>
</div>
<!-- add / edit modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<h3>{{ editing ? 'Edit Supply' : 'Add Supply' }}</h3>
<div class="form-grid">
<label>
Supply Type
<select v-model="form.supplytype" class="form-control" @change="onSupplyTypeChange">
<option v-for="t in meta.supplytypes" :key="t" :value="t">{{ t }}</option>
</select>
</label>
<label>
Color
<select v-model="form.color" class="form-control">
<option v-for="c in meta.colors" :key="c" :value="c">{{ c }}</option>
</select>
<small v-if="form.supplytype !== 'toner'" class="field-hint">Drums/waste are often colorless - leave as "none" unless per-color</small>
</label>
<label>
Capacity Tier
<select v-model="form.capacitytier" class="form-control">
<option v-for="t in meta.capacitytiers" :key="t" :value="t">{{ t }}</option>
</select>
</label>
<label>
Part Number
<input v-model="form.partnumber" type="text" class="form-control" placeholder="e.g. W2020A" />
</label>
<label class="full">
Marketing Name
<input v-model="form.marketingname" type="text" class="form-control" placeholder="e.g. 414A Black" />
</label>
<label>
Page Yield
<input v-model.number="form.pageyield" type="number" class="form-control" placeholder="e.g. 2400" />
</label>
</div>
<label class="full">
Notes
<input v-model="form.notes" type="text" class="form-control" />
</label>
<p v-if="formError" class="text-danger">{{ formError }}</p>
<div class="modal-actions">
<button class="btn btn-secondary" @click="closeModal">Cancel</button>
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { printersApi } from '@/api'
import { apiError } from '@/utils/apiError'
const models = ref([])
const loadingModels = ref(true)
const search = ref('')
const onlyWithSupplies = ref(false)
const selectedModel = ref(null)
const supplies = ref([])
const loadingSupplies = ref(false)
const meta = ref({ supplytypes: [], colors: [], capacitytiers: [] })
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const formError = ref('')
const form = ref({})
let searchTimer = null
function debouncedSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(loadModels, 300)
}
async function loadModels() {
loadingModels.value = true
try {
const params = { per_page: 100 }
if (search.value) params.search = search.value
if (onlyWithSupplies.value) params.withsupplies = 'true'
const response = await printersApi.modelSupplies.listModels(params)
models.value = response.data.data || []
} finally {
loadingModels.value = false
}
}
async function selectModel(model) {
selectedModel.value = model
loadingSupplies.value = true
try {
const response = await printersApi.modelSupplies.list(model.modelnumberid)
supplies.value = response.data.data.supplies || []
} finally {
loadingSupplies.value = false
}
}
function openModal(supply = null) {
editing.value = supply
formError.value = ''
if (supply) {
form.value = { ...supply }
} else {
form.value = {
supplytype: 'toner',
color: 'black',
capacitytier: 'standard',
partnumber: '',
marketingname: '',
pageyield: null,
notes: ''
}
}
showModal.value = true
}
function onSupplyTypeChange() {
// toner is color-specific; drum/waste/maintenance usually are not. Default
// the color sensibly on type change but keep it editable (per-color drums).
if (form.value.supplytype !== 'toner') {
if (!form.value.color || form.value.color === 'black') form.value.color = 'none'
} else if (form.value.color === 'none') {
form.value.color = 'black'
}
}
function closeModal() {
showModal.value = false
editing.value = null
}
async function save() {
if (!form.value.partnumber) {
formError.value = 'Part number is required.'
return
}
saving.value = true
formError.value = ''
try {
if (editing.value) {
await printersApi.modelSupplies.update(editing.value.modelsupplyid, form.value)
} else {
await printersApi.modelSupplies.create(selectedModel.value.modelnumberid, form.value)
}
closeModal()
await selectModel(selectedModel.value)
await loadModels()
} catch (err) {
formError.value = apiError(err, 'Save failed.')
} finally {
saving.value = false
}
}
async function remove(supply) {
if (!confirm(`Delete ${supply.partnumber}?`)) return
await printersApi.modelSupplies.delete(supply.modelsupplyid)
await selectModel(selectedModel.value)
await loadModels()
}
onMounted(async () => {
const metaResponse = await printersApi.modelSupplies.meta()
meta.value = metaResponse.data.data
await loadModels()
})
</script>
<style scoped>
.subtitle {
color: var(--text-light);
font-size: 0.9rem;
}
.supplies-layout {
display: grid;
grid-template-columns: 340px 1fr;
gap: 1.5rem;
align-items: start;
}
.model-panel {
padding: 1rem;
}
.withsupplies {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--text-light);
white-space: nowrap;
}
.model-list {
display: flex;
flex-direction: column;
max-height: 70vh;
overflow-y: auto;
}
.model-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.5rem;
background: none;
border: none;
border-bottom: 1px solid var(--border);
cursor: pointer;
text-align: left;
color: var(--text);
}
.model-row:hover {
background: var(--bg);
}
.model-row.active {
background: var(--primary);
color: white;
}
.model-row.active .vendor {
color: white;
}
.model-name {
font-weight: 600;
font-size: 0.9rem;
}
.model-meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.vendor {
font-size: 0.8rem;
color: var(--text-light);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.partnumber {
font-family: monospace;
}
.color-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 0.35rem;
vertical-align: middle;
}
.dot-black { background: #222; }
.dot-cyan { background: #00b7eb; }
.dot-magenta { background: #d633a0; }
.dot-yellow { background: #f0c000; }
.actions {
display: flex;
gap: 0.4rem;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
/* bg-card is intentionally translucent in dark mode; modals must be opaque */
background: var(--bg-card-solid);
padding: 1.5rem;
border-radius: 8px;
width: 520px;
max-width: 92vw;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin: 1rem 0;
}
.form-grid label,
.modal > label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--text-light);
}
.form-grid .full,
.modal > label.full {
grid-column: 1 / -1;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.field-hint {
font-size: 0.75rem;
color: var(--text-light);
margin-top: 0.2rem;
}
.text-danger {
color: var(--danger);
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-light);
}
</style>

View File

@@ -0,0 +1,457 @@
<template>
<div class="detail-page">
<div class="page-header">
<h2>Printer Details</h2>
<div class="header-actions">
<router-link :to="`/print/printer-qr/${$route.params.id}`" class="btn btn-secondary" target="_blank">
Print QR
</router-link>
<router-link :to="`/print/asset-label/printer/${$route.params.id}`" class="btn btn-secondary" target="_blank">
Print Label
</router-link>
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="printer">
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-image" v-if="printer.printer?.imageurl">
<img :src="printer.printer.imageurl" :alt="printer.printer.modelname || 'Model photo'" />
</div>
<div class="hero-content">
<div class="hero-title">
<h1>{{ displayTitle }}</h1>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-printer">Printer</span>
<span v-if="printer.printer?.iscsf" class="badge badge-lg badge-info">CSF</span>
<span v-if="printer.printer?.iscolor" class="badge badge-lg badge-success">Color</span>
<span v-if="printer.printer?.isnetwork" class="badge badge-lg badge-secondary">Network</span>
<span v-if="heroWarranty" class="badge badge-lg" :style="{ background: heroWarranty.statuscolor, color: '#fff' }"
:title="heroWarranty.enddate ? `Warranty ends ${warrantyDate(heroWarranty.enddate)}` : 'Warranty'">
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="printer.printer?.vendorname">
<span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ printer.printer.vendorname }}</span>
</div>
<div class="hero-detail" v-if="printer.printer?.modelname">
<span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ printer.printer.modelname }}</span>
</div>
<div class="hero-detail" v-if="printer.serialnumber">
<span class="hero-detail-label">Serial Number</span>
<span class="hero-detail-value mono">{{ printer.serialnumber }}</span>
</div>
<div class="hero-detail" v-if="ipAddress">
<span class="hero-detail-label">IP Address</span>
<span class="hero-detail-value mono">{{ ipAddress }}</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 mono">{{ printer.assetnumber }}</span>
</div>
<div class="info-row" v-if="printer.printer?.windowsname">
<span class="info-label">Windows Name</span>
<span class="info-value mono">{{ printer.printer.windowsname }}</span>
</div>
<div class="info-row" v-if="isEnabled('fqdn', 'printer') && printer.printer?.hostname">
<span class="info-label">Hostname / FQDN</span>
<span class="info-value mono">{{ printer.printer.hostname }}</span>
</div>
<div class="info-row" v-if="printer.printer?.sharename">
<span class="info-label">CSF Share Name</span>
<span class="info-value mono">{{ printer.printer.sharename }}</span>
</div>
<div class="info-row" v-if="printer.serialnumber">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ printer.serialnumber }}</span>
</div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'printer') && printer.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ printer.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'printer') && printer.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ printer.maintenancereference }}</span>
</div>
</div>
</div>
<!-- Printer Settings -->
<div class="section-card">
<h3 class="section-title">Printer Settings</h3>
<div class="info-list">
<div class="info-row" v-if="printer.printer?.installpath">
<span class="info-label">Install Path</span>
<span class="info-value mono">{{ printer.printer.installpath }}</span>
</div>
<div class="info-row">
<span class="info-label">Color</span>
<span class="info-value">{{ printer.printer?.iscolor ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">Duplex</span>
<span class="info-value">{{ printer.printer?.isduplex ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">Network Printer</span>
<span class="info-value">{{ printer.printer?.isnetwork ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">CSF Printer</span>
<span class="info-value">{{ printer.printer?.iscsf ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row" v-if="printer.printer?.pin">
<span class="info-label">PIN</span>
<span class="info-value mono">{{ printer.printer.pin }}</span>
</div>
</div>
</div>
</div>
<!-- Right Column -->
<div class="content-column">
<!-- Network -->
<div class="section-card" v-if="printer.communications?.length">
<h3 class="section-title">Network</h3>
<div class="network-list">
<div v-for="comm in printer.communications" :key="comm.communicationid" class="network-item">
<div class="network-primary">
<span class="ip-address">{{ comm.ipaddress || comm.address || '-' }}</span>
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
</div>
<div class="network-secondary" v-if="comm.macaddress">
<span class="mac-address">{{ comm.macaddress }}</span>
</div>
</div>
</div>
</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">Map Location</span>
<span class="info-value">
<LocationMapTooltip
v-if="printer.mapx != null && printer.mapy != null"
:left="printer.mapx"
:top="printer.mapy"
:machineName="printer.name || printer.assetnumber"
>
<span class="location-link">View on Map</span>
</LocationMapTooltip>
<span v-else>Not mapped</span>
</span>
</div>
<div class="info-row" v-if="printer.businessunitname">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ printer.businessunitname }}</span>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="printer.assetid" />
<!-- Warranty -->
<PluginAssetPanels :assetid="printer.assetid" />
<!-- All relationships (defaultprinter, connectedto, ...) -->
<AssetRelationships v-if="printer.assetid" :assetid="printer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="printer.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ printer.notes }}</p>
</div>
</div>
</div>
<!-- Supplies Card -->
<div class="card">
<div class="card-header">
<h3>Supplies</h3>
</div>
<div v-if="supplies.length === 0" class="empty-state">
No supply information available
</div>
<div v-else class="supplies-grid">
<div v-for="supply in supplies" :key="supply.itemid || supply.name" class="supply-item">
<div class="supply-header">
<span class="supply-name">{{ supply.name }}</span>
<span class="supply-level" :class="supply.status">
{{ supply.level !== null ? `${supply.level}%` : 'N/A' }}
</span>
</div>
<div class="supply-bar">
<div
class="supply-bar-fill"
:class="supply.status"
:style="{ width: `${supply.level || 0}%` }"
></div>
</div>
<div class="supply-meta">
<span>{{ formatSupplyType(supply.supplytype) }}<template v-if="supply.iswaste"> ({{ supply.remaining }}% remaining)</template></span>
<span v-if="supply.partnumbers && supply.partnumbers.length" class="supply-parts">
<span
v-for="part in supply.partnumbers"
:key="part.partnumber"
class="supply-part"
:data-tip="`Part ${part.partnumber}` + (part.pageyield ? ` - ${part.pageyield} pages` : '')"
>{{ part.marketingname || part.partnumber }}</span>
</span>
</div>
</div>
</div>
</div>
<!-- Drivers Card: pulled from the driver catalog by this printer's model -->
<div class="card">
<div class="card-header">
<h3>Drivers</h3>
</div>
<div v-if="drivers.length === 0" class="empty-state">
No drivers for this model. Add one under Settings &gt; Printer Drivers and
link it to this printer's model.
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Location</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr v-for="driver in drivers" :key="driver.driverid">
<td><strong>{{ driver.name }}</strong></td>
<td>
<a v-if="isHttp(driver.location)" :href="driver.location" target="_blank" class="mono">{{ driver.location }}</a>
<span v-else class="mono">{{ driver.location }}</span>
</td>
<td>{{ driver.description || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDate(printer.createddate) }}<template v-if="printer.createdby"> by {{ printer.createdby }}</template></span>
<span>Modified {{ formatDate(printer.modifieddate) }}<template v-if="printer.modifiedby"> by {{ printer.modifiedby }}</template></span>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Printer not found</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { printersApi } from '@/api'
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 { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings'
const route = useRoute()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
const printer = ref(null)
const { heroWarranty, warrantyDate } = useWarrantyBadge(() => printer.value?.assetid)
const supplies = ref([])
const drivers = ref([])
// Best display identifier for a printer. name is often the literal "NONE",
// so fall back to the Windows name, then hostname, then asset number.
const displayTitle = computed(() => {
const p = printer.value
if (!p) return ''
const name = (p.name || '').trim()
if (name && name.toUpperCase() !== 'NONE') return name
return p.printer?.windowsname || p.printer?.hostname || p.assetnumber
})
function isHttp(loc) {
return typeof loc === 'string' && /^https?:\/\//i.test(loc)
}
// Get IP address from communications
const ipAddress = computed(() => {
if (!printer.value?.communications) return null
const primaryComm = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0]
return primaryComm?.ipaddress || null
})
onMounted(async () => {
try {
const [printerRes, suppliesRes] = await Promise.all([
printersApi.get(route.params.id),
printersApi.getSupplies(route.params.id).catch(() => ({ data: { data: [] } }))
])
printer.value = printerRes.data.data
// supplies endpoint returns {ipaddress, pingstatus, supplies:[...]}
supplies.value = suppliesRes.data.data?.supplies || []
// drivers are attached to the printer detail, matched by model
drivers.value = printerRes.data.data?.drivers || []
} catch (error) {
console.error('Error loading printer:', error)
} finally {
loading.value = false
}
})
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active' || s === 'online') return 'badge-success'
if (s === 'in repair' || s === 'offline') return 'badge-warning'
if (s === 'retired' || s === 'error') return 'badge-danger'
return 'badge-info'
}
function formatSupplyType(supplytype) {
if (!supplytype) return ''
return supplytype.charAt(0).toUpperCase() + supplytype.slice(1)
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
</script>
<style scoped>
/* Printer-specific styles - shared styles are in global style.css */
.empty-state {
text-align: center;
color: var(--text-light);
padding: 2.5rem;
font-size: 1.125rem;
}
/* Supplies */
.mono { font-family: monospace; font-size: 0.85rem; word-break: break-all; }
.supplies-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
}
.supply-item {
background: var(--bg);
padding: 1.25rem;
border-radius: 8px;
}
.supply-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.supply-name {
font-weight: 500;
font-size: 1.125rem;
}
.supply-level {
font-weight: 600;
font-size: 1.125rem;
}
.supply-level.ok { color: var(--success); }
.supply-level.low { color: var(--warning); }
.supply-level.critical { color: var(--danger); }
.supply-bar {
height: 10px;
background: var(--border);
border-radius: 5px;
overflow: hidden;
}
.supply-bar-fill {
height: 100%;
transition: width 0.3s ease;
background: var(--success);
}
.supply-bar-fill.low { background: var(--warning); }
.supply-bar-fill.critical { background: var(--danger); }
.supply-meta {
display: flex;
justify-content: space-between;
font-size: 1rem;
color: var(--text-light);
margin-top: 0.625rem;
}
.supply-parts {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
}
.supply-part {
cursor: help;
border-bottom: 1px dotted var(--text-light);
position: relative;
}
/* instant CSS tooltip, no native title delay */
.supply-part:hover::after {
content: attr(data-tip);
position: absolute;
bottom: 125%;
right: 0;
white-space: nowrap;
background: var(--text);
color: var(--bg-card);
padding: 0.35rem 0.6rem;
border-radius: 4px;
font-size: 0.85rem;
z-index: 10;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<div>
<div class="page-header">
<h2>Printer Drivers</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Driver</button>
</div>
<div class="card">
<p class="hint">
Each driver is a name + a link to the driver package - an SMB path
(<code>\\server\share\driver</code>) or an HTTP URL. HTTP links open;
SMB paths are shown for copy-paste (browsers block file:// SMB).
</p>
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Printer Model</th>
<th>Location</th>
<th>Description</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="d in visibleItems" :key="d.driverid">
<td><strong>{{ d.name }}</strong></td>
<td>{{ d.modelname || '-' }}</td>
<td>
<a v-if="isHttp(d.location)" :href="d.location" target="_blank" class="mono">{{ d.location }}</a>
<span v-else class="mono">{{ d.location }}</span>
</td>
<td class="cell-truncate" :title="d.description">{{ d.description || '-' }}</td>
<td>
<span class="badge" :class="d.isactive ? 'badge-success' : 'badge-secondary'">{{ d.isactive ? 'yes' : 'no' }}</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(d)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteDriver(d)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">No drivers</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Driver</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input v-model="form.name" type="text" class="form-control" maxlength="150" required />
</div>
<div class="form-group">
<label>Printer Model <span class="hint">(links driver to a model so it shows on matching printers)</span></label>
<select v-model="form.modelnumberid" class="form-control">
<option :value="null">-- none --</option>
<option v-for="m in models" :key="m.modelnumberid" :value="m.modelnumberid">
{{ m.modelnumber }}<template v-if="m.vendorname"> ({{ m.vendorname }})</template>
</option>
</select>
</div>
<div class="form-group">
<label>Location * <span class="hint">(SMB path or HTTP URL)</span></label>
<input v-model="form.location" type="text" class="form-control" maxlength="500"
placeholder="\\server\share\driver or https://..." required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</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 { printersApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const items = ref([])
const models = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.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({ name: '', location: '', description: '', modelnumberid: null, isactive: true })
function isHttp(loc) {
return typeof loc === 'string' && /^https?:\/\//i.test(loc)
}
onMounted(() => { loadData(); loadModels() })
async function loadData() {
loading.value = true
try {
const response = await printersApi.drivers.list({ active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading drivers:', err)
} finally {
loading.value = false
}
}
async function loadModels() {
try {
const response = await printersApi.modelSupplies.listModels({ perpage: 100 })
models.value = response.data.data || []
} catch (err) {
console.error('Error loading models:', err)
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { name: item.name || '', location: item.location || '', description: item.description || '', modelnumberid: item.modelnumberid || null, isactive: item.isactive !== false }
: { name: '', location: '', description: '', modelnumberid: null, isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await printersApi.drivers.update(editing.value.driverid, form.value)
} else {
await printersApi.drivers.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save')
} finally {
saving.value = false
}
}
async function deleteDriver(d) {
if (!confirm(`Delete driver "${d.name}"?`)) return
try {
await printersApi.drivers.delete(d.driverid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>
<style scoped>
.hint { color: var(--text-light); font-size: 0.9rem; margin: 0 0 14px; }
.muted { color: var(--text-light); }
.mono { font-family: monospace; font-size: 0.85rem; word-break: break-all; }
</style>

View File

@@ -0,0 +1,655 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Printer' : 'New Printer' }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="savePrinter">
<div class="form-row">
<div class="form-group">
<label for="machinenumber">Windows Name *</label>
<input
id="machinenumber"
v-model="form.machinenumber"
type="text"
class="form-control"
required
@input="onWindowsNameInput"
:class="{ 'auto-generated': !manualWindowsName && form.machinenumber }"
/>
<small class="form-hint">Auto-generated from CSF Name, Alias, Vendor & Model</small>
</div>
<div class="form-group">
<label for="alias">Alias / Location</label>
<input
id="alias"
v-model="form.alias"
type="text"
class="form-control"
placeholder="e.g., SpoolsInspection"
/>
<small class="form-hint">Used in Windows name generation</small>
</div>
</div>
<div class="form-row">
<div class="form-group" v-if="isEnabled('fqdn', 'printer')">
<label for="hostname">Hostname (FQDN)</label>
<input
id="hostname"
v-model="form.hostname"
type="text"
class="form-control"
@input="onHostnameInput"
:class="{ 'auto-generated': !manualHostname && form.hostname }"
/>
<small class="form-hint">Auto-generated from IP address</small>
</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', 'printer') || isEnabled('maintenancereference', 'printer')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'printer')">
<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', 'printer')">
<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">Printer 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 printerTypes"
:key="pt.printertypeid"
:value="pt.printertypeid"
>
{{ pt.printertype }}
</option>
</select>
</div>
<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>
<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"
:disabled="!form.vendorid"
>
<option value="">{{ form.vendorid ? 'Select model...' : 'Select a vendor first' }}</option>
<option
v-for="m in filteredModels"
:key="m.modelnumberid"
:value="m.modelnumberid"
>
{{ m.modelnumber }}
</option>
</select>
<small v-if="!form.vendorid" class="form-hint">
Select a vendor first to choose a model
</small>
</div>
</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>
<!-- Printer-specific fields -->
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Printer 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="csfname">CSF Name</label>
<input
id="csfname"
v-model="form.csfname"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="pin">PIN</label>
<input
id="pin"
v-model="form.pin"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-group">
<label for="installpath">Driver Install Path</label>
<input
id="installpath"
v-model="form.installpath"
type="text"
class="form-control"
placeholder="Leave empty for universal driver"
/>
</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 printers -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="PRINTER_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 Printer' }}
</button>
<router-link to="/printers" 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 { assetsApi, vendorsApi, locationsApi, printersApi, modelsApi } 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'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
const { isEnabled } = useIdentifierFlags()
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for printers (see /api/assets/types).
const PRINTER_ASSETTYPEID = 4
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
const manualHostname = ref(false)
const manualWindowsName = ref(false)
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: '',
notes: '',
mapx: null,
mapy: null,
// Printer-specific
ipaddress: '',
csfname: '',
installpath: '',
pin: ''
})
const printerTypes = ref([])
const statuses = ref([])
const vendors = ref([])
const models = ref([])
const locations = ref([])
// Filter models by selected vendor and printer type
const filteredModels = computed(() => {
return models.value.filter(m => {
// Filter by vendor if selected
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
return false
}
// Filter by printer type if selected, but only exclude models that have a
// type set and it differs. Most models have no machinetypeid, so excluding
// null-typed models would hide the printer's own model from the dropdown.
if (form.value.machinetypeid && m.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
return false
}
return true
})
})
// Get short description from model number for naming
function getModelShortDesc(modelNumber) {
if (!modelNumber) return ''
const mn = modelNumber.toLowerCase()
if (mn.includes('colorlaserjet')) return 'ColorLaserJet'
if (mn.includes('laserjetpro') || mn.includes('laserjet pro')) return 'LaserJetPro'
if (mn.includes('laserjet')) return 'LaserJet'
if (mn.includes('altalink')) return 'Altalink'
if (mn.includes('versalink')) return 'Versalink'
if (mn.includes('designjet')) return 'DesignJet'
if (mn.includes('dtc')) return 'DTC'
if (mn.includes('officejet')) return 'OfficeJet'
if (mn.includes('pagewide')) return 'PageWide'
// Fallback: get letters before first digit
const match = modelNumber.match(/^([A-Za-z]+)/)
return match ? match[1] : modelNumber.substring(0, 5)
}
// Auto-generate hostname from IP address using the site template ({ip} =
// dash-separated IP).
async function generateHostname(ip) {
if (!ip) return ''
const ipDashed = ip.replace(/\./g, '-')
const template = await getPrinterHostnameTemplate()
return template.replace('{ip}', ipDashed)
}
// Auto-generate Windows name (machinenumber)
function generateWindowsName() {
const parts = []
// 1. CSF Name (if set and not "NONE")
const csfName = form.value.csfname?.trim()
if (csfName && csfName.toUpperCase() !== 'NONE') {
parts.push(csfName.replace(/\s+/g, ''))
}
// 2. Location (from alias, removing spaces and "Machine")
const alias = form.value.alias?.trim()
if (alias) {
const location = alias.replace(/\s+/g, '').replace(/Machine/gi, '')
// Skip if same as CSF name
if (location.toLowerCase() !== csfName?.toLowerCase()) {
parts.push(location)
}
}
// 3. Vendor + Model short description
const selectedModel = models.value.find(m => m.modelnumberid === form.value.modelnumberid)
const selectedVendor = vendors.value.find(v => v.vendorid === form.value.vendorid)
let vendorModel = ''
if (selectedVendor) {
vendorModel = selectedVendor.vendor.replace(/\s+/g, '')
}
if (selectedModel) {
vendorModel += getModelShortDesc(selectedModel.modelnumber)
}
if (vendorModel) {
parts.push(vendorModel)
}
return parts.join('-')
}
// Watch IP address and auto-generate hostname
watch(() => form.value.ipaddress, async (newIp) => {
if (!manualHostname.value && newIp) {
form.value.hostname = await generateHostname(newIp)
}
})
// Watch fields that affect Windows name generation
watch(
() => [form.value.csfname, form.value.alias, form.value.vendorid, form.value.modelnumberid],
() => {
if (!manualWindowsName.value && !isEdit.value) {
const generated = generateWindowsName()
if (generated) {
form.value.machinenumber = generated
}
}
}
)
// Track manual edits to hostname
function onHostnameInput() {
manualHostname.value = true
}
// Track manual edits to Windows name
function onWindowsNameInput() {
manualWindowsName.value = true
}
onMounted(async () => {
try {
// Load reference data (models paged in full via listAll, see api/index.js)
// perpage 100 so dropdowns aren't truncated to the default 20-row page
// (e.g. 44 vendors; the editing record's vendor can be past row 20)
const [mtRes, statusRes, vendorRes, allModels, locRes] = await Promise.all([
printersApi.types.list({ perpage: 100 }),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 100 }),
modelsApi.listAll(),
locationsApi.list({ perpage: 100 })
])
printerTypes.value = mtRes.data.data || []
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
models.value = allModels
locations.value = locRes.data.data || []
// Load printer if editing
if (isEdit.value) {
const response = await printersApi.get(route.params.id)
const printer = response.data.data
currentAssetId.value = printer.assetid || null
// asset-based shape: printer extension fields live under printer.printer
const ext = printer.printer || {}
// Get IP from communications
const primaryComm = printer.communications?.find(c => c.isprimary) || printer.communications?.[0]
form.value = {
// "Windows Name" is the printer's business identifier (assetnumber).
// Prefer the explicit windowsname, then fall back to assetnumber.
machinenumber: ext.windowsname || printer.assetnumber || '',
alias: printer.name && printer.name.toUpperCase() !== 'NONE' ? printer.name : '',
hostname: ext.hostname || '',
serialnumber: printer.serialnumber || '',
gaugelabreference: printer.gaugelabreference || '',
maintenancereference: printer.maintenancereference || '',
machinetypeid: ext.printertypeid || '',
statusid: printer.statusid || '',
vendorid: ext.vendorid || '',
modelnumberid: ext.modelnumberid || '',
locationid: printer.locationid || '',
notes: printer.notes || '',
mapx: printer.mapx ?? null,
mapy: printer.mapy ?? null,
// Printer-specific
ipaddress: primaryComm?.ipaddress || '',
csfname: ext.sharename || '',
installpath: ext.installpath || '',
pin: ext.pin || ''
}
// Don't auto-generate for existing printers
manualWindowsName.value = true
manualHostname.value = true
}
} 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 savePrinter() {
error.value = ''
saving.value = true
try {
// One payload for the printers plugin, which owns asset core + extension +
// primary communication. The "Windows Name" field is the business
// identifier, written to both assetnumber and the extension windowsname.
const payload = {
assetnumber: form.value.machinenumber,
windowsname: form.value.machinenumber || null,
hostname: form.value.hostname || null,
serialnumber: form.value.serialnumber || null,
gaugelabreference: form.value.gaugelabreference || null,
maintenancereference: form.value.maintenancereference || null,
printertypeid: form.value.machinetypeid || null,
statusid: form.value.statusid || null,
vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null,
locationid: form.value.locationid || null,
sharename: form.value.csfname || null,
iscsf: !!form.value.csfname,
installpath: form.value.installpath || null,
pin: form.value.pin || null,
ipaddress: form.value.ipaddress || null,
mapx: form.value.mapx,
mapy: form.value.mapy
}
// only set the 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 printersApi.update(route.params.id, payload)
assetId = assetId || response.data?.data?.assetid || response.data?.data?.asset?.assetid
} else {
const response = await printersApi.create(payload)
assetId = response.data?.data?.assetid || response.data?.data?.asset?.assetid
}
if (assetId && customFieldsRef.value) {
try {
await customFieldsRef.value.save(assetId)
} catch (cfErr) {
console.error('Error saving custom fields:', cfErr)
}
}
router.push('/printers')
} catch (err) {
console.error('Error saving printer:', err)
error.value = apiError(err, 'Failed to save printer')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.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);
}
/* auto-fill cue: a subtle translucent blue tint + accent border that reads
correctly over both light and dark backgrounds. Text color stays themed
(no solid light fill that turns into an unreadable white box in dark mode). */
.auto-generated {
background-color: rgba(33, 150, 243, 0.12);
border-color: #90caf9;
}
</style>

View File

@@ -0,0 +1,307 @@
<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Batch Print Printer QR Codes</h3>
<p>Select printers to print (6 per page):</p>
<div v-if="loadingPrinters" class="loading-msg">Loading printers...</div>
<div v-else class="printer-grid">
<div
v-for="printer in printers"
:key="printer.assetid"
class="printer-item"
:class="{ selected: isSelected(printer) }"
@click="togglePrinter(printer)"
>
<input type="checkbox" :checked="isSelected(printer)" @click.stop />
<label>
<strong>{{ displayName(printer) }}</strong>
<div class="model">{{ printer.printer?.modelname || '' }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedPrinters.length }}</span> printers
(<span class="pages">{{ pageCount }}</span> pages)
</div>
<button class="print-btn" :disabled="selectedPrinters.length === 0" @click="print">Print QR Codes</button>
<button class="clear-btn" @click="clearSelection">Clear All</button>
<button class="select-all-btn" @click="selectAll">Select All</button>
</div>
</div>
<div class="sheets-container">
<div v-for="(page, pageIdx) in pages" :key="pageIdx" class="print-sheet">
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
<div
v-for="pos in 6"
:key="pos"
class="label"
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
>
<template v-if="page[pos - 1]">
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
<div class="qr-container">
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
</div>
<div class="info-section">
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
<div class="info-inner">
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</div>
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
</div>
</div>
</template>
<span v-else class="empty-label">Empty</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/views/print/qrLogo'
import { buildQrUrl } from '@/utils/qrTarget'
const printers = ref([])
const selectedPrinters = ref([])
const loadingPrinters = ref(true)
// QR codes rendered to data-URL images (not live <canvas>): canvases are
// unreliable in print output, images print every time.
const qrImages = ref({})
const pageCount = computed(() => Math.ceil(selectedPrinters.value.length / 6) || 0)
const pages = computed(() => {
const result = []
for (let i = 0; i < selectedPrinters.value.length; i += 6) {
const page = []
for (let j = 0; j < 6; j++) {
page.push(selectedPrinters.value[i + j] || null)
}
result.push(page)
}
return result
})
onMounted(async () => {
try {
const response = await printersApi.list({ perpage: 500 })
printers.value = response.data.data || []
} catch (error) {
console.error('Error loading printers:', error)
} finally {
loadingPrinters.value = false
}
})
watch(selectedPrinters, async () => {
await nextTick()
generateQRCodes()
}, { deep: true })
async function generateQRCodes() {
const next = {}
for (let pageIdx = 0; pageIdx < pages.value.length; pageIdx++) {
const page = pages.value[pageIdx]
for (let idx = 0; idx < page.length; idx++) {
const printer = page[idx]
if (!printer) continue
const pos = idx + 1
const detailId = printer.printer?.printerid || printer.assetid
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
printerid: printer.printer?.printerid || '',
assetid: printer.assetid || '',
assetnumber: printer.assetnumber || '',
serialnumber: printer.serialnumber || '',
ip: getIp(printer) || '',
hostname: printer.printer?.windowsname || printer.name || '',
})
next[`${pageIdx}-${pos}`] = await renderQrDataUrl(qrUrl)
}
}
qrImages.value = next
}
function displayName(printer) {
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
}
function getIp(printer) {
// Check direct ipaddress field first (from list API)
if (printer.ipaddress && printer.ipaddress !== 'USB') return printer.ipaddress
// Fall back to communications array (from detail API)
if (!printer.communications?.length) return null
const primary = printer.communications.find(c => c.isprimary) || printer.communications[0]
return primary?.ipaddress || primary?.address || null
}
function isSelected(printer) {
return selectedPrinters.value.some(p => p.assetid === printer.assetid)
}
function togglePrinter(printer) {
const idx = selectedPrinters.value.findIndex(p => p.assetid === printer.assetid)
if (idx > -1) {
selectedPrinters.value.splice(idx, 1)
} else {
selectedPrinters.value.push(printer)
}
}
function clearSelection() {
selectedPrinters.value = []
}
function selectAll() {
selectedPrinters.value = [...printers.value]
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; padding: 20px; }
.controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
.controls h3 { margin-top: 0; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.clear-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--danger);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.select-all-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--success);
color: white;
border: none;
border-radius: 5px;
}
.printer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border);
padding: 10px;
background: var(--bg);
}
.printer-item {
display: flex;
align-items: center;
padding: 8px;
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.printer-item:hover { border-color: var(--primary); }
.printer-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.printer-item input { margin-right: 10px; }
.printer-item label { cursor: pointer; flex: 1; }
.printer-item .model { font-size: 11px; color: var(--text-light); }
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
.selected-count .count { color: var(--primary); }
.selected-count .pages { color: var(--success); }
.loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
page-break-after: always;
}
.print-sheet:last-child { page-break-after: auto; }
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
.label {
width: 3in;
height: 3in;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.1in;
box-sizing: border-box;
border: 1px dashed #ccc;
}
.label.filled { border: 2px solid var(--primary); }
.label.empty { background: #fafafa; }
.qr-img { width: 144px; height: 144px; display: block; }
.pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; }
.pos-3 { top: 4in; left: 1.1875in; }
.pos-4 { top: 4in; left: 4.3125in; }
.pos-5 { top: 7.125in; left: 1.1875in; }
.pos-6 { top: 7.125in; left: 4.3125in; }
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
.qr-container { text-align: center; }
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
.empty-label { color: #999; font-size: 14px; }
@media print {
/* Force the browser to print rendered images/colors even when the user's
"Background graphics" option is off. Without this the QR images and
borders can drop out of the printout. */
body, .print-sheet, .label, .qr-img, .qr-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.sheets-container { gap: 0; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.sheet-label { display: none; }
.label { border: none !important; }
.label.empty { visibility: hidden; }
}
</style>

View File

@@ -0,0 +1,187 @@
<template>
<div>
<div class="no-print">
<button class="print-btn" :disabled="!printer" @click="print">Print QR Code</button>
<label>Position:
<select class="position-select" v-model="position">
<option value="1">1 - Top Left</option>
<option value="2">2 - Top Right</option>
<option value="3">3 - Middle Left</option>
<option value="4">4 - Middle Right</option>
<option value="5">5 - Bottom Left</option>
<option value="6">6 - Bottom Right</option>
</select>
</label>
</div>
<div v-if="loading" class="loading-msg">Loading...</div>
<div v-else-if="printer" class="print-sheet">
<div
v-for="pos in 6"
:key="pos"
class="label"
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
>
<template v-if="pos === parseInt(position)">
<div class="model-name">{{ printer.printer?.modelname || '' }}</div>
<div class="qr-container">
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
</div>
<div class="info-section">
<div class="csf-name">{{ printer.assetnumber }}</div>
<div class="info-inner">
<div v-if="printer.printer?.windowsname" class="info-row">{{ printer.printer.windowsname }}</div>
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
</div>
</div>
</template>
</div>
</div>
<div v-else class="error-msg">Printer not found</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/views/print/qrLogo'
import { buildQrUrl } from '@/utils/qrTarget'
const route = useRoute()
const loading = ref(true)
const printer = ref(null)
const position = ref('1')
// Render QR to a data-URL image, not a live <canvas>: canvases are unreliable
// in print output, images print every time.
const qrImage = ref('')
const ipAddress = computed(() => {
// Check direct ipaddress field first (from list API)
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
// Fall back to communications array (from detail API)
if (!printer.value?.communications?.length) return null
const primary = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0]
return primary?.ipaddress || primary?.address || null
})
onMounted(async () => {
try {
const response = await printersApi.get(route.params.id)
printer.value = response.data.data
} catch (error) {
console.error('Error loading printer:', error)
} finally {
loading.value = false
await nextTick()
generateQR()
}
})
watch(position, async () => {
await nextTick()
generateQR()
})
async function generateQR() {
if (!printer.value) return
const p = printer.value
const detailId = p.printer?.printerid || p.assetid
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
printerid: p.printer?.printerid || '',
assetid: p.assetid || '',
assetnumber: p.assetnumber || '',
serialnumber: p.serialnumber || '',
ip: ipAddress.value || '',
hostname: p.printer?.windowsname || p.name || '',
})
qrImage.value = await renderQrDataUrl(qrUrl)
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; text-align: center; padding: 20px; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
margin: 5px;
}
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.position-select { padding: 8px; font-size: 14px; margin-left: 10px; }
.loading-msg, .error-msg {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
color: var(--text-light);
}
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
}
.label {
width: 3in;
height: 3in;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.1in;
box-sizing: border-box;
}
.label.inactive { border: 1px dashed #ccc; }
.label.active { border: 2px solid var(--primary); }
.qr-img { width: 144px; height: 144px; display: block; }
.pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; }
.pos-3 { top: 4in; left: 1.1875in; }
.pos-4 { top: 4in; left: 4.3125in; }
.pos-5 { top: 7.125in; left: 1.1875in; }
.pos-6 { top: 7.125in; left: 4.3125in; }
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
.qr-container { text-align: center; }
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
@media print {
/* Force rendered images/colors to print even when "Background graphics" is
off, otherwise the QR image can drop out. */
body, .print-sheet, .label, .qr-img, .qr-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.label { border: none !important; }
.label.inactive { visibility: hidden; }
}
</style>

View File

@@ -0,0 +1,167 @@
<template>
<div>
<div class="page-header">
<h2>Printers</h2>
<div class="header-actions">
<router-link to="/print/printer-qr" class="btn btn-secondary" target="_blank">Batch Print QR</router-link>
<router-link to="/print/asset-label-batch/printer" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/printers/new" class="btn btn-primary">Add Printer</router-link>
</div>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search printers..."
@input="debouncedSearch"
/>
<select v-model="typeFilter" class="form-control" @change="onFilterChange">
<option value="">All types</option>
<option v-for="pt in printerTypes" :key="pt.printertypeid" :value="pt.printertypeid">
{{ pt.printertype }}
</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Tag</th>
<th>Name</th>
<th>Business Unit</th>
<th>Type</th>
<th>Model</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<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.name && printer.name.toUpperCase() !== 'NONE' ? printer.name : (printer.printer?.hostname || '-') }}</td>
<td>{{ printer.businessunitname || '-' }}</td>
<td>{{ printer.printer?.printertypename || '-' }}</td>
<td>{{ printer.printer?.modelname || '-' }}</td>
<td>
<span class="badge" :class="getStatusClass(printer.statusname)">
{{ printer.statusname || 'Active' }}
</span>
</td>
<td class="actions" @click.stop>
<router-link
:to="`/printers/${printer.printer?.printerid || printer.assetid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="printers.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
No printers 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 { printersApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const printers = ref([])
const printerTypes = ref([])
const typeFilter = ref('')
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadPrinters })
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
onMounted(async () => {
try {
const response = await printersApi.types.list({ perpage: 100 })
printerTypes.value = response.data.data || []
} catch (error) {
console.error('Error loading printer types:', error)
}
loadPrinters()
})
async function loadPrinters() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
if (typeFilter.value) params.typeid = typeFilter.value
const response = await printersApi.list(params)
printers.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading printers:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadPrinters()
}, 300)
}
function onFilterChange() {
setPage(1)
loadPrinters()
}
function goToPage(p) {
setPage(p)
loadPrinters()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadPrinters()
}
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'
}
</script>