Frontend: asset identifiers, statuses, printer types, UI fixes

- Wire gauge/maintenance refs + identifier toggles into equipment pages.
- Repoint status dropdowns and Settings>Statuses to asset statuses.
- Printer type column/filter; require vendor before model; printer-model picker.
- Per-type identifier labels; printer detail title fallback.
- Fixes: truncate long description cells, opaque supply modal, readable
  dark-mode autofill, drum/waste default colorless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 08:35:32 -04:00
parent 0436e8b0af
commit e631564377
22 changed files with 786 additions and 124 deletions

View File

@@ -264,9 +264,26 @@ export const printersApi = {
get(id) {
return api.get(`/printers/${id}`)
},
// create/update write asset core + printer extension in one call (the
// printers plugin owns both). Use these instead of the legacy machinesApi.
create(data) {
return api.post('/printers', data)
},
update(id, data) {
return api.put(`/printers/${id}`, data)
},
updateExtension(id, data) {
return api.put(`/printers/${id}/printerdata`, data)
},
// printer sub-types (Laser, Inkjet, Label, Card, Wide Format, ...)
types: {
list(params = {}) {
return api.get('/printers/types', { params })
},
create(data) {
return api.post('/printers/types', data)
}
},
updateCommunication(id, data) {
return api.put(`/printers/${id}/communication`, data)
},
@@ -279,6 +296,12 @@ export const printersApi = {
lowSupplies() {
return api.get('/printers/lowsupplies')
},
refreshSupplies() {
return api.post('/printers/supplies/refresh')
},
lookup({ ip, fqdn } = {}) {
return api.get('/printers/lookup', { params: { ip, fqdn } })
},
dashboardSummary() {
return api.get('/printers/dashboard/summary')
},
@@ -303,6 +326,27 @@ export const printersApi = {
create(data) {
return api.post('/printers/supplytypes', data)
}
},
// model -> toner/drum/waste part-number management
modelSupplies: {
meta() {
return api.get('/printers/supplies/meta')
},
listModels(params = {}) {
return api.get('/printers/models', { params })
},
list(modelnumberid) {
return api.get(`/printers/models/${modelnumberid}/supplies`)
},
create(modelnumberid, data) {
return api.post(`/printers/models/${modelnumberid}/supplies`, data)
},
update(modelsupplyid, data) {
return api.put(`/printers/supplies/${modelsupplyid}`, data)
},
delete(modelsupplyid) {
return api.delete(`/printers/supplies/${modelsupplyid}`)
}
}
}
@@ -321,6 +365,23 @@ export const modelsApi = {
list(params = {}) {
return api.get('/models', { params })
},
// Backend caps perpage at 100, so page through every model. Returns the
// full array directly (not an axios response). Use in forms whose model
// dropdown must include the editing record's model regardless of page.
async listAll() {
const first = await api.get('/models', { params: { perpage: 100, page: 1 } })
let items = first.data.data || []
const totalpages = first.data.meta?.pagination?.totalpages || 1
if (totalpages > 1) {
const rest = await Promise.all(
Array.from({ length: totalpages - 1 }, (_, i) =>
api.get('/models', { params: { perpage: 100, page: i + 2 } })
)
)
rest.forEach(r => { items = items.concat(r.data.data || []) })
}
return items
},
get(id) {
return api.get(`/models/${id}`)
},
@@ -528,8 +589,17 @@ export const assetsApi = {
}
},
statuses: {
list() {
return api.get('/assets/statuses')
list(params = {}) {
return api.get('/assets/statuses', { params })
},
create(data) {
return api.post('/assets/statuses', data)
},
update(id, data) {
return api.put(`/assets/statuses/${id}`, data)
},
delete(id) {
return api.delete(`/assets/statuses/${id}`)
}
}
}

View File

@@ -372,6 +372,15 @@ th, td {
border-top: 1px solid var(--border);
}
/* Cap a long free-text column (e.g. Description) so it truncates with an
ellipsis instead of widening the row and pushing the Actions column out of
view. Pair with a title attribute to show the full text on hover. */
td.cell-truncate {
max-width: 32rem;
overflow: hidden;
text-overflow: ellipsis;
}
th {
font-weight: 600;
font-size: 11px;

View File

@@ -23,5 +23,12 @@ export default [
name: 'printer-edit',
component: () => import('../../views/printers/PrinterForm.vue'),
meta: { requiresAuth: true }
},
// printer-specific settings
{
path: 'settings/modelsupplies',
name: 'model-supplies',
component: () => import('../../views/settings/ModelSuppliesList.vue'),
meta: { requiresAuth: true }
}
]

View File

@@ -68,6 +68,14 @@
<span class="info-label">Name</span>
<span class="info-value">{{ equipment.name }}</span>
</div>
<div class="info-row" v-if="identifierflags.gaugelabreference && equipment.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ equipment.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="identifierflags.maintenancereference && equipment.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ equipment.maintenancereference }}</span>
</div>
<div class="info-row" v-if="equipment.serialnumber">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ equipment.serialnumber }}</span>
@@ -229,8 +237,10 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { equipmentApi, assetsApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute()
const identifierflags = useIdentifierFlags()
const loading = ref(true)
const equipment = ref(null)

View File

@@ -30,6 +30,31 @@
type="text"
class="form-control"
/>
<small class="form-help">Layperson-friendly label</small>
</div>
</div>
<div class="form-row" v-if="identifierflags.gaugelabreference || identifierflags.maintenancereference">
<div class="form-group" v-if="identifierflags.gaugelabreference">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input
id="gaugelabreference"
v-model="form.gaugelabreference"
type="text"
class="form-control"
/>
<small class="form-help">Authoritative gauge lab asset reference (if tracked)</small>
</div>
<div class="form-group" v-if="identifierflags.maintenancereference">
<label for="maintenancereference">Maintenance Reference</label>
<input
id="maintenancereference"
v-model="form.maintenancereference"
type="text"
class="form-control"
/>
<small class="form-help">Maintenance system asset reference (if tracked)</small>
</div>
</div>
@@ -331,6 +356,9 @@ import { equipmentApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, co
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const identifierflags = useIdentifierFlags()
const route = useRoute()
const router = useRouter()
@@ -346,6 +374,8 @@ const tempMapPosition = ref(null)
const form = ref({
assetnumber: '',
name: '',
gaugelabreference: '',
maintenancereference: '',
serialnumber: '',
statusid: 1,
equipmenttypeid: '',
@@ -410,12 +440,12 @@ watch(() => form.value.controllervendorid, (newVal, oldVal) => {
onMounted(async () => {
try {
// Load reference data in parallel
const [typesRes, statusRes, vendorRes, locRes, modelsRes, buRes, pcsRes, relTypesRes] = await Promise.all([
const [typesRes, statusRes, vendorRes, locRes, allModels, buRes, pcsRes, relTypesRes] = await Promise.all([
equipmentApi.types.list(),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 500 }),
locationsApi.list({ perpage: 500 }),
modelsApi.list({ perpage: 1000 }),
modelsApi.listAll(), // backend caps perpage at 100; page through all
businessunitsApi.list({ perpage: 500 }),
computersApi.list({ perpage: 500 }),
assetsApi.types.list() // Used for relationship types, will fix below
@@ -425,7 +455,7 @@ onMounted(async () => {
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
locations.value = locRes.data.data || []
models.value = modelsRes.data.data || []
models.value = allModels
businessunits.value = buRes.data.data || []
pcs.value = pcsRes.data.data || []
@@ -456,6 +486,8 @@ onMounted(async () => {
form.value = {
assetnumber: data.assetnumber || '',
name: data.name || '',
gaugelabreference: data.gaugelabreference || '',
maintenancereference: data.maintenancereference || '',
serialnumber: data.serialnumber || '',
statusid: data.statusid || 1,
equipmenttypeid: data.equipment?.equipmenttypeid || '',
@@ -526,6 +558,8 @@ async function saveEquipment() {
const data = {
assetnumber: form.value.assetnumber,
name: form.value.name || null,
gaugelabreference: form.value.gaugelabreference || null,
maintenancereference: form.value.maintenancereference || null,
serialnumber: form.value.serialnumber || null,
statusid: form.value.statusid || 1,
equipmenttypeid: form.value.equipmenttypeid || null,

View File

@@ -24,7 +24,7 @@
<table>
<thead>
<tr>
<th>Asset #</th>
<th>Machine #</th>
<th>Name</th>
<th>Serial Number</th>
<th>Type</th>

View File

@@ -54,7 +54,7 @@
<table>
<thead>
<tr>
<th>Asset #</th>
<th>Asset Tag</th>
<th>Hostname</th>
<th>Serial Number</th>
<th>Type</th>

View File

@@ -16,7 +16,7 @@
<div class="hero-content">
<div class="hero-title">
<h1>{{ computer.assetnumber }}</h1>
<span v-if="computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
<span v-if="identifierflags.fqdn && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-info">Computer</span>
@@ -57,7 +57,7 @@
<span class="info-label">Name</span>
<span class="info-value">{{ computer.name }}</span>
</div>
<div class="info-row" v-if="computer.computer?.hostname">
<div class="info-row" v-if="identifierflags.fqdn && computer.computer?.hostname">
<span class="info-label">Hostname</span>
<span class="info-value mono">{{ computer.computer.hostname }}</span>
</div>
@@ -207,8 +207,10 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { computersApi, applicationsApi, assetsApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute()
const identifierflags = useIdentifierFlags()
const loading = ref(true)
const computer = ref(null)

View File

@@ -32,7 +32,7 @@
</div>
<div class="form-row">
<div class="form-group">
<div class="form-group" v-if="identifierflags.fqdn">
<label for="hostname">Hostname</label>
<input
id="hostname"
@@ -268,10 +268,13 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api'
import { machinesApi, machinetypesApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const identifierflags = useIdentifierFlags()
const route = useRoute()
const router = useRouter()
@@ -317,7 +320,9 @@ const filteredModels = computed(() => {
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
return false
}
if (form.value.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
// only exclude models that have a type set and it differs; most models
// have no machinetypeid, so a strict check hides the PC's own model
if (form.value.machinetypeid && m.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
return false
}
return true
@@ -327,19 +332,20 @@ const filteredModels = computed(() => {
onMounted(async () => {
try {
// Load reference data
const [ptRes, statusRes, vendorRes, modelsRes, locRes, osRes] = await Promise.all([
machinetypesApi.list({ category: 'PC' }),
statusesApi.list(),
vendorsApi.list(),
modelsApi.list(),
locationsApi.list(),
operatingsystemsApi.list()
// perpage 100 so dropdowns aren't truncated to the default 20-row page
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes] = await Promise.all([
machinetypesApi.list({ category: 'PC', 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 })
])
pcTypes.value = ptRes.data.data || []
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
models.value = modelsRes.data.data || []
models.value = allModels
locations.value = locRes.data.data || []
operatingsystems.value = osRes.data.data || []

View File

@@ -24,7 +24,7 @@
<table>
<thead>
<tr>
<th>Asset #</th>
<th>Asset Tag</th>
<th>Hostname</th>
<th>Serial Number</th>
<th>Type</th>
@@ -153,13 +153,15 @@ function getStatusClass(status) {
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 {
display: flex;
gap: 0.375rem;
white-space: nowrap;
}
.feature-tag {
display: inline-block;
margin-right: 0.375rem;
padding: 0.3rem 0.625rem;
font-size: 0.875rem;
border-radius: 5px;
@@ -167,6 +169,17 @@ function getStatusClass(status) {
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;

View File

@@ -18,7 +18,7 @@
<div class="hero-card">
<div class="hero-content">
<div class="hero-title">
<h1>{{ printer.name || printer.assetnumber }}</h1>
<h1>{{ displayTitle }}</h1>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-printer">Printer</span>
@@ -63,7 +63,7 @@
<span class="info-label">Windows Name</span>
<span class="info-value mono">{{ printer.printer.windowsname }}</span>
</div>
<div class="info-row" v-if="printer.printer?.hostname">
<div class="info-row" v-if="identifierflags.fqdn && printer.printer?.hostname">
<span class="info-label">Hostname / FQDN</span>
<span class="info-value mono">{{ printer.printer.hostname }}</span>
</div>
@@ -172,23 +172,30 @@
</div>
<div v-else class="supplies-grid">
<div v-for="supply in supplies" :key="supply.supplyid" class="supply-item">
<div v-for="supply in supplies" :key="supply.itemid || supply.name" class="supply-item">
<div class="supply-header">
<span class="supply-name">{{ supply.supplyname }}</span>
<span class="supply-level" :class="getSupplyLevelClass(supply.currentlevel)">
{{ supply.currentlevel !== null ? `${supply.currentlevel}%` : 'N/A' }}
<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="getSupplyLevelClass(supply.currentlevel)"
:style="{ width: `${supply.currentlevel || 0}%` }"
:class="supply.status"
:style="{ width: `${supply.level || 0}%` }"
></div>
</div>
<div class="supply-meta">
<span>{{ supply.supplytypename }}</span>
<span v-if="supply.partnumber">Part: {{ supply.partnumber }}</span>
<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>
@@ -238,14 +245,26 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { printersApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute()
const identifierflags = useIdentifierFlags()
const loading = ref(true)
const printer = ref(null)
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
})
// Get IP address from communications
const ipAddress = computed(() => {
if (!printer.value?.communications) return null
@@ -262,7 +281,8 @@ onMounted(async () => {
])
printer.value = printerRes.data.data
supplies.value = suppliesRes.data.data || []
// supplies endpoint returns {ipaddress, pingstatus, supplies:[...]}
supplies.value = suppliesRes.data.data?.supplies || []
drivers.value = driversRes.data.data || []
} catch (error) {
console.error('Error loading printer:', error)
@@ -280,11 +300,9 @@ function getStatusClass(status) {
return 'badge-info'
}
function getSupplyLevelClass(level) {
if (level === null || level === undefined) return ''
if (level <= 10) return 'critical'
if (level <= 25) return 'low'
return 'ok'
function formatSupplyType(supplytype) {
if (!supplytype) return ''
return supplytype.charAt(0).toUpperCase() + supplytype.slice(1)
}
function formatDate(dateStr) {
@@ -360,4 +378,33 @@ function formatDate(dateStr) {
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

@@ -37,7 +37,7 @@
</div>
<div class="form-row">
<div class="form-group">
<div class="form-group" v-if="identifierflags.fqdn">
<label for="hostname">Hostname (FQDN)</label>
<input
id="hostname"
@@ -73,11 +73,11 @@
>
<option value="">Select type...</option>
<option
v-for="mt in printerTypes"
:key="mt.machinetypeid"
:value="mt.machinetypeid"
v-for="pt in printerTypes"
:key="pt.printertypeid"
:value="pt.printertypeid"
>
{{ mt.machinetype }}
{{ pt.printertype }}
</option>
</select>
</div>
@@ -127,8 +127,9 @@
id="modelnumberid"
v-model="form.modelnumberid"
class="form-control"
:disabled="!form.vendorid"
>
<option value="">Select model...</option>
<option value="">{{ form.vendorid ? 'Select model...' : 'Select a vendor first' }}</option>
<option
v-for="m in filteredModels"
:key="m.modelnumberid"
@@ -137,8 +138,8 @@
{{ m.modelnumber }}
</option>
</select>
<small v-if="!form.vendorid && !form.machinetypeid" class="form-hint">
Select vendor or printer type to filter models
<small v-if="!form.vendorid" class="form-hint">
Select a vendor first to choose a model
</small>
</div>
</div>
@@ -266,10 +267,13 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, printersApi, modelsApi } from '../../api'
import { assetsApi, vendorsApi, locationsApi, printersApi, modelsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const identifierflags = useIdentifierFlags()
const route = useRoute()
const router = useRouter()
@@ -317,8 +321,10 @@ const filteredModels = computed(() => {
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
return false
}
// Filter by printer type if selected
if (form.value.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
// 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
@@ -422,47 +428,53 @@ function onWindowsNameInput() {
onMounted(async () => {
try {
// Load reference data
const [mtRes, statusRes, vendorRes, modelsRes, locRes] = await Promise.all([
machinetypesApi.list({ category: 'Printer' }),
statusesApi.list(),
vendorsApi.list(),
modelsApi.list(),
locationsApi.list()
// 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 = modelsRes.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
// 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 = {
machinenumber: printer.machinenumber || '',
alias: printer.alias || '',
hostname: printer.hostname || '',
// "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 || '',
machinetypeid: printer.machinetype?.machinetypeid || '',
statusid: printer.status?.statusid || '',
vendorid: printer.vendor?.vendorid || '',
modelnumberid: printer.model?.modelnumberid || '',
locationid: printer.location?.locationid || '',
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: printer.printerdata?.sharename || '',
installpath: printer.printerdata?.installpath || '',
pin: printer.printerdata?.pin || ''
csfname: ext.sharename || '',
installpath: ext.installpath || '',
pin: ext.pin || ''
}
// Don't auto-generate for existing printers
@@ -500,48 +512,36 @@ async function savePrinter() {
saving.value = true
try {
const machineData = {
machinenumber: form.value.machinenumber,
alias: form.value.alias,
hostname: form.value.hostname,
serialnumber: form.value.serialnumber,
machinetypeid: form.value.machinetypeid || null,
// 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,
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,
notes: form.value.notes,
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
}
const printerData = {
windowsname: form.value.machinenumber, // Windows name is the machinenumber
sharename: form.value.csfname,
installpath: form.value.installpath,
pin: form.value.pin,
iscsf: !!form.value.csfname // Auto-set based on whether CSF name is filled
// 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
}
// Handle IP address - need to update/create communication record
const communicationData = form.value.ipaddress ? {
ipaddress: form.value.ipaddress,
isprimary: true
} : null
if (isEdit.value) {
await machinesApi.update(route.params.id, machineData)
await printersApi.updateExtension(route.params.id, printerData)
if (communicationData) {
await printersApi.updateCommunication(route.params.id, communicationData)
}
await printersApi.update(route.params.id, payload)
} else {
const response = await machinesApi.create(machineData)
const newId = response.data.data.machineid
await printersApi.updateExtension(newId, printerData)
if (communicationData) {
await printersApi.updateCommunication(newId, communicationData)
}
await printersApi.create(payload)
}
router.push('/printers')
@@ -592,12 +592,11 @@ async function savePrinter() {
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: #f0f7ff;
background-color: rgba(33, 150, 243, 0.12);
border-color: #90caf9;
}
.auto-generated:focus {
background-color: white;
}
</style>

View File

@@ -17,6 +17,12 @@
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">
@@ -27,9 +33,10 @@
<table>
<thead>
<tr>
<th>Asset #</th>
<th>Asset Tag</th>
<th>Name</th>
<th>Business Unit</th>
<th>Type</th>
<th>Model</th>
<th>Status</th>
<th>Actions</th>
@@ -38,8 +45,9 @@
<tbody>
<tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid">
<td>{{ printer.assetnumber }}</td>
<td>{{ printer.name || '-' }}</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)">
@@ -56,7 +64,7 @@
</td>
</tr>
<tr v-if="printers.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">
<td colspan="7" style="text-align: center; color: var(--text-light);">
No printers found
</td>
</tr>
@@ -83,6 +91,8 @@ import { printersApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const printers = ref([])
const printerTypes = ref([])
const typeFilter = ref('')
const loading = ref(true)
const search = ref('')
const page = ref(1)
@@ -91,7 +101,13 @@ const perPage = ref(20)
let searchTimeout = null
onMounted(() => {
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()
})
@@ -103,6 +119,7 @@ async function loadPrinters() {
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 || []
@@ -122,6 +139,11 @@ function debouncedSearch() {
}, 300)
}
function onFilterChange() {
page.value = 1
loadPrinters()
}
function goToPage(p) {
page.value = p
loadPrinters()

View File

@@ -23,7 +23,7 @@
<tr v-for="bu in items" :key="bu.businessunitid">
<td>{{ bu.businessunit }}</td>
<td>{{ bu.code || '-' }}</td>
<td>{{ bu.description || '-' }}</td>
<td class="cell-truncate" :title="bu.description">{{ bu.description || '-' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(bu)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(bu)">Delete</button>

View File

@@ -38,7 +38,7 @@
<td>{{ loc.building || '-' }}</td>
<td>{{ loc.floor || '-' }}</td>
<td>{{ loc.room || '-' }}</td>
<td>{{ loc.description || '-' }}</td>
<td class="cell-truncate" :title="loc.description">{{ loc.description || '-' }}</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"

View File

@@ -27,7 +27,7 @@
{{ mt.category }}
</span>
</td>
<td>{{ mt.description || '-' }}</td>
<td class="cell-truncate" :title="mt.description">{{ mt.description || '-' }}</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"

View File

@@ -0,0 +1,437 @@
<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'
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 = err.response?.data?.error?.message || '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

@@ -21,7 +21,7 @@
<tbody>
<tr v-for="pt in pcTypes" :key="pt.pctypeid">
<td>{{ pt.pctype }}</td>
<td>{{ pt.description || '-' }}</td>
<td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(pt)">Delete</button>

View File

@@ -27,6 +27,12 @@
<p>Manage equipment models by vendor</p>
</router-link>
<router-link to="/settings/modelsupplies" class="settings-card">
<div class="card-icon"><Droplets :size="28" /></div>
<h3>Model Toners and Supplies</h3>
<p>Map toner, drum, and waste part numbers to printer models</p>
</router-link>
<router-link to="/settings/machinetypes" class="settings-card">
<div class="card-icon"><Monitor :size="28" /></div>
<h3>Machine Types</h3>
@@ -85,7 +91,7 @@
</template>
<script setup>
import { Factory, MapPin, Tag, Package, Monitor, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users } from 'lucide-vue-next'
import { Factory, MapPin, Tag, Package, Droplets, Monitor, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users } from 'lucide-vue-next'
</script>
<style scoped>

View File

@@ -1,7 +1,7 @@
<template>
<div>
<div class="page-header">
<h2>Machine Statuses</h2>
<h2>Asset Statuses</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Status</button>
</div>
@@ -30,7 +30,7 @@
<span class="color-preview" :style="{ backgroundColor: s.color || '#6c757d' }"></span>
{{ s.color || 'default' }}
</td>
<td>{{ s.description || '-' }}</td>
<td class="cell-truncate" :title="s.description">{{ s.description || '-' }}</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@@ -135,7 +135,7 @@
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ statusToDelete?.status }}</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
Cannot delete if machines are using this status.
Cannot delete if assets are using this status.
</p>
</div>
<div class="modal-footer">
@@ -149,7 +149,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { statusesApi } from '../../api'
import { assetsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const statuses = ref([])
@@ -184,7 +184,7 @@ async function loadStatuses() {
perpage: perPage.value
}
const response = await statusesApi.list(params)
const response = await assetsApi.statuses.list(params)
statuses.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
@@ -235,9 +235,9 @@ async function saveStatus() {
try {
if (editingStatus.value) {
await statusesApi.update(editingStatus.value.statusid, form.value)
await assetsApi.statuses.update(editingStatus.value.statusid, form.value)
} else {
await statusesApi.create(form.value)
await assetsApi.statuses.create(form.value)
}
closeModal()
loadStatuses()
@@ -256,7 +256,7 @@ function confirmDelete(s) {
async function deleteStatus() {
try {
await statusesApi.delete(statusToDelete.value.statusid)
await assetsApi.statuses.delete(statusToDelete.value.statusid)
showDeleteModal.value = false
statusToDelete.value = null
loadStatuses()

View File

@@ -87,7 +87,7 @@
{{ role.rolename }}
</span>
</td>
<td>{{ role.description || '-' }}</td>
<td class="cell-truncate" :title="role.description">{{ role.description || '-' }}</td>
<td>
<span v-if="role.isadmin" class="text-muted">All permissions</span>
<span v-else-if="role.permissions?.length">{{ role.permissions.length }} permissions</span>

View File

@@ -50,7 +50,7 @@
</span>
<span v-else>-</span>
</td>
<td>{{ vlan.description || '-' }}</td>
<td class="cell-truncate" :title="vlan.description">{{ vlan.description || '-' }}</td>
<td>
<router-link
:to="{ path: '/settings/subnets', query: { vlanid: vlan.vlanid } }"