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:
@@ -264,9 +264,26 @@ export const printersApi = {
|
|||||||
get(id) {
|
get(id) {
|
||||||
return api.get(`/printers/${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) {
|
updateExtension(id, data) {
|
||||||
return api.put(`/printers/${id}/printerdata`, 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) {
|
updateCommunication(id, data) {
|
||||||
return api.put(`/printers/${id}/communication`, data)
|
return api.put(`/printers/${id}/communication`, data)
|
||||||
},
|
},
|
||||||
@@ -279,6 +296,12 @@ export const printersApi = {
|
|||||||
lowSupplies() {
|
lowSupplies() {
|
||||||
return api.get('/printers/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() {
|
dashboardSummary() {
|
||||||
return api.get('/printers/dashboard/summary')
|
return api.get('/printers/dashboard/summary')
|
||||||
},
|
},
|
||||||
@@ -303,6 +326,27 @@ export const printersApi = {
|
|||||||
create(data) {
|
create(data) {
|
||||||
return api.post('/printers/supplytypes', 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 = {}) {
|
list(params = {}) {
|
||||||
return api.get('/models', { 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) {
|
get(id) {
|
||||||
return api.get(`/models/${id}`)
|
return api.get(`/models/${id}`)
|
||||||
},
|
},
|
||||||
@@ -528,8 +589,17 @@ export const assetsApi = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
statuses: {
|
statuses: {
|
||||||
list() {
|
list(params = {}) {
|
||||||
return api.get('/assets/statuses')
|
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}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,6 +372,15 @@ th, td {
|
|||||||
border-top: 1px solid var(--border);
|
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 {
|
th {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@@ -23,5 +23,12 @@ export default [
|
|||||||
name: 'printer-edit',
|
name: 'printer-edit',
|
||||||
component: () => import('../../views/printers/PrinterForm.vue'),
|
component: () => import('../../views/printers/PrinterForm.vue'),
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
// printer-specific settings
|
||||||
|
{
|
||||||
|
path: 'settings/modelsupplies',
|
||||||
|
name: 'model-supplies',
|
||||||
|
component: () => import('../../views/settings/ModelSuppliesList.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -68,6 +68,14 @@
|
|||||||
<span class="info-label">Name</span>
|
<span class="info-label">Name</span>
|
||||||
<span class="info-value">{{ equipment.name }}</span>
|
<span class="info-value">{{ equipment.name }}</span>
|
||||||
</div>
|
</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">
|
<div class="info-row" v-if="equipment.serialnumber">
|
||||||
<span class="info-label">Serial Number</span>
|
<span class="info-label">Serial Number</span>
|
||||||
<span class="info-value mono">{{ equipment.serialnumber }}</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 { useRoute } from 'vue-router'
|
||||||
import { equipmentApi, assetsApi } from '../../api'
|
import { equipmentApi, assetsApi } from '../../api'
|
||||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||||
|
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const identifierflags = useIdentifierFlags()
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const equipment = ref(null)
|
const equipment = ref(null)
|
||||||
|
|||||||
@@ -30,6 +30,31 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="form-control"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -331,6 +356,9 @@ import { equipmentApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, co
|
|||||||
import ShopFloorMap from '../../components/ShopFloorMap.vue'
|
import ShopFloorMap from '../../components/ShopFloorMap.vue'
|
||||||
import Modal from '../../components/Modal.vue'
|
import Modal from '../../components/Modal.vue'
|
||||||
import { currentTheme } from '../../stores/theme'
|
import { currentTheme } from '../../stores/theme'
|
||||||
|
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||||
|
|
||||||
|
const identifierflags = useIdentifierFlags()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -346,6 +374,8 @@ const tempMapPosition = ref(null)
|
|||||||
const form = ref({
|
const form = ref({
|
||||||
assetnumber: '',
|
assetnumber: '',
|
||||||
name: '',
|
name: '',
|
||||||
|
gaugelabreference: '',
|
||||||
|
maintenancereference: '',
|
||||||
serialnumber: '',
|
serialnumber: '',
|
||||||
statusid: 1,
|
statusid: 1,
|
||||||
equipmenttypeid: '',
|
equipmenttypeid: '',
|
||||||
@@ -410,12 +440,12 @@ watch(() => form.value.controllervendorid, (newVal, oldVal) => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
// Load reference data in parallel
|
// 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(),
|
equipmentApi.types.list(),
|
||||||
assetsApi.statuses.list(),
|
assetsApi.statuses.list(),
|
||||||
vendorsApi.list({ perpage: 500 }),
|
vendorsApi.list({ perpage: 500 }),
|
||||||
locationsApi.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 }),
|
businessunitsApi.list({ perpage: 500 }),
|
||||||
computersApi.list({ perpage: 500 }),
|
computersApi.list({ perpage: 500 }),
|
||||||
assetsApi.types.list() // Used for relationship types, will fix below
|
assetsApi.types.list() // Used for relationship types, will fix below
|
||||||
@@ -425,7 +455,7 @@ onMounted(async () => {
|
|||||||
statuses.value = statusRes.data.data || []
|
statuses.value = statusRes.data.data || []
|
||||||
vendors.value = vendorRes.data.data || []
|
vendors.value = vendorRes.data.data || []
|
||||||
locations.value = locRes.data.data || []
|
locations.value = locRes.data.data || []
|
||||||
models.value = modelsRes.data.data || []
|
models.value = allModels
|
||||||
businessunits.value = buRes.data.data || []
|
businessunits.value = buRes.data.data || []
|
||||||
pcs.value = pcsRes.data.data || []
|
pcs.value = pcsRes.data.data || []
|
||||||
|
|
||||||
@@ -456,6 +486,8 @@ onMounted(async () => {
|
|||||||
form.value = {
|
form.value = {
|
||||||
assetnumber: data.assetnumber || '',
|
assetnumber: data.assetnumber || '',
|
||||||
name: data.name || '',
|
name: data.name || '',
|
||||||
|
gaugelabreference: data.gaugelabreference || '',
|
||||||
|
maintenancereference: data.maintenancereference || '',
|
||||||
serialnumber: data.serialnumber || '',
|
serialnumber: data.serialnumber || '',
|
||||||
statusid: data.statusid || 1,
|
statusid: data.statusid || 1,
|
||||||
equipmenttypeid: data.equipment?.equipmenttypeid || '',
|
equipmenttypeid: data.equipment?.equipmenttypeid || '',
|
||||||
@@ -526,6 +558,8 @@ async function saveEquipment() {
|
|||||||
const data = {
|
const data = {
|
||||||
assetnumber: form.value.assetnumber,
|
assetnumber: form.value.assetnumber,
|
||||||
name: form.value.name || null,
|
name: form.value.name || null,
|
||||||
|
gaugelabreference: form.value.gaugelabreference || null,
|
||||||
|
maintenancereference: form.value.maintenancereference || null,
|
||||||
serialnumber: form.value.serialnumber || null,
|
serialnumber: form.value.serialnumber || null,
|
||||||
statusid: form.value.statusid || 1,
|
statusid: form.value.statusid || 1,
|
||||||
equipmenttypeid: form.value.equipmenttypeid || null,
|
equipmenttypeid: form.value.equipmenttypeid || null,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Asset #</th>
|
<th>Machine #</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Serial Number</th>
|
<th>Serial Number</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Asset #</th>
|
<th>Asset Tag</th>
|
||||||
<th>Hostname</th>
|
<th>Hostname</th>
|
||||||
<th>Serial Number</th>
|
<th>Serial Number</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<div class="hero-title">
|
<div class="hero-title">
|
||||||
<h1>{{ computer.assetnumber }}</h1>
|
<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>
|
||||||
<div class="hero-meta">
|
<div class="hero-meta">
|
||||||
<span class="badge badge-lg badge-info">Computer</span>
|
<span class="badge badge-lg badge-info">Computer</span>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<span class="info-label">Name</span>
|
<span class="info-label">Name</span>
|
||||||
<span class="info-value">{{ computer.name }}</span>
|
<span class="info-value">{{ computer.name }}</span>
|
||||||
</div>
|
</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-label">Hostname</span>
|
||||||
<span class="info-value mono">{{ computer.computer.hostname }}</span>
|
<span class="info-value mono">{{ computer.computer.hostname }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,8 +207,10 @@ import { ref, onMounted, computed } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { computersApi, applicationsApi, assetsApi } from '../../api'
|
import { computersApi, applicationsApi, assetsApi } from '../../api'
|
||||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||||
|
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const identifierflags = useIdentifierFlags()
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const computer = ref(null)
|
const computer = ref(null)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group" v-if="identifierflags.fqdn">
|
||||||
<label for="hostname">Hostname</label>
|
<label for="hostname">Hostname</label>
|
||||||
<input
|
<input
|
||||||
id="hostname"
|
id="hostname"
|
||||||
@@ -268,10 +268,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 ShopFloorMap from '../../components/ShopFloorMap.vue'
|
||||||
import Modal from '../../components/Modal.vue'
|
import Modal from '../../components/Modal.vue'
|
||||||
import { currentTheme } from '../../stores/theme'
|
import { currentTheme } from '../../stores/theme'
|
||||||
|
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||||
|
|
||||||
|
const identifierflags = useIdentifierFlags()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -317,7 +320,9 @@ const filteredModels = computed(() => {
|
|||||||
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
|
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
|
||||||
return false
|
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 false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -327,19 +332,20 @@ const filteredModels = computed(() => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
// Load reference data
|
// Load reference data
|
||||||
const [ptRes, statusRes, vendorRes, modelsRes, locRes, osRes] = await Promise.all([
|
// perpage 100 so dropdowns aren't truncated to the default 20-row page
|
||||||
machinetypesApi.list({ category: 'PC' }),
|
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes] = await Promise.all([
|
||||||
statusesApi.list(),
|
machinetypesApi.list({ category: 'PC', perpage: 100 }),
|
||||||
vendorsApi.list(),
|
assetsApi.statuses.list(),
|
||||||
modelsApi.list(),
|
vendorsApi.list({ perpage: 100 }),
|
||||||
locationsApi.list(),
|
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||||
operatingsystemsApi.list()
|
locationsApi.list({ perpage: 100 }),
|
||||||
|
operatingsystemsApi.list({ perpage: 100 })
|
||||||
])
|
])
|
||||||
|
|
||||||
pcTypes.value = ptRes.data.data || []
|
pcTypes.value = ptRes.data.data || []
|
||||||
statuses.value = statusRes.data.data || []
|
statuses.value = statusRes.data.data || []
|
||||||
vendors.value = vendorRes.data.data || []
|
vendors.value = vendorRes.data.data || []
|
||||||
models.value = modelsRes.data.data || []
|
models.value = allModels
|
||||||
locations.value = locRes.data.data || []
|
locations.value = locRes.data.data || []
|
||||||
operatingsystems.value = osRes.data.data || []
|
operatingsystems.value = osRes.data.data || []
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Asset #</th>
|
<th>Asset Tag</th>
|
||||||
<th>Hostname</th>
|
<th>Hostname</th>
|
||||||
<th>Serial Number</th>
|
<th>Serial Number</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
@@ -153,13 +153,15 @@ function getStatusClass(status) {
|
|||||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* keep the cell as a table-cell; flex on a <td> strips table-cell layout and
|
||||||
|
offsets the row. lay tags out inline instead. */
|
||||||
.features {
|
.features {
|
||||||
display: flex;
|
white-space: nowrap;
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-tag {
|
.feature-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
margin-right: 0.375rem;
|
||||||
padding: 0.3rem 0.625rem;
|
padding: 0.3rem 0.625rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -167,6 +169,17 @@ function getStatusClass(status) {
|
|||||||
color: var(--text-light);
|
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 {
|
.feature-tag.active {
|
||||||
background: #e3f2fd;
|
background: #e3f2fd;
|
||||||
color: #1976d2;
|
color: #1976d2;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<div class="hero-card">
|
<div class="hero-card">
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<div class="hero-title">
|
<div class="hero-title">
|
||||||
<h1>{{ printer.name || printer.assetnumber }}</h1>
|
<h1>{{ displayTitle }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-meta">
|
<div class="hero-meta">
|
||||||
<span class="badge badge-lg badge-printer">Printer</span>
|
<span class="badge badge-lg badge-printer">Printer</span>
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
<span class="info-label">Windows Name</span>
|
<span class="info-label">Windows Name</span>
|
||||||
<span class="info-value mono">{{ printer.printer.windowsname }}</span>
|
<span class="info-value mono">{{ printer.printer.windowsname }}</span>
|
||||||
</div>
|
</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-label">Hostname / FQDN</span>
|
||||||
<span class="info-value mono">{{ printer.printer.hostname }}</span>
|
<span class="info-value mono">{{ printer.printer.hostname }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,23 +172,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="supplies-grid">
|
<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">
|
<div class="supply-header">
|
||||||
<span class="supply-name">{{ supply.supplyname }}</span>
|
<span class="supply-name">{{ supply.name }}</span>
|
||||||
<span class="supply-level" :class="getSupplyLevelClass(supply.currentlevel)">
|
<span class="supply-level" :class="supply.status">
|
||||||
{{ supply.currentlevel !== null ? `${supply.currentlevel}%` : 'N/A' }}
|
{{ supply.level !== null ? `${supply.level}%` : 'N/A' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="supply-bar">
|
<div class="supply-bar">
|
||||||
<div
|
<div
|
||||||
class="supply-bar-fill"
|
class="supply-bar-fill"
|
||||||
:class="getSupplyLevelClass(supply.currentlevel)"
|
:class="supply.status"
|
||||||
:style="{ width: `${supply.currentlevel || 0}%` }"
|
:style="{ width: `${supply.level || 0}%` }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="supply-meta">
|
<div class="supply-meta">
|
||||||
<span>{{ supply.supplytypename }}</span>
|
<span>{{ formatSupplyType(supply.supplytype) }}<template v-if="supply.iswaste"> ({{ supply.remaining }}% remaining)</template></span>
|
||||||
<span v-if="supply.partnumber">Part: {{ supply.partnumber }}</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,14 +245,26 @@ import { ref, onMounted, computed } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { printersApi } from '../../api'
|
import { printersApi } from '../../api'
|
||||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||||
|
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const identifierflags = useIdentifierFlags()
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const printer = ref(null)
|
const printer = ref(null)
|
||||||
const supplies = ref([])
|
const supplies = ref([])
|
||||||
const drivers = 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
|
// Get IP address from communications
|
||||||
const ipAddress = computed(() => {
|
const ipAddress = computed(() => {
|
||||||
if (!printer.value?.communications) return null
|
if (!printer.value?.communications) return null
|
||||||
@@ -262,7 +281,8 @@ onMounted(async () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
printer.value = printerRes.data.data
|
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 || []
|
drivers.value = driversRes.data.data || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading printer:', error)
|
console.error('Error loading printer:', error)
|
||||||
@@ -280,11 +300,9 @@ function getStatusClass(status) {
|
|||||||
return 'badge-info'
|
return 'badge-info'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSupplyLevelClass(level) {
|
function formatSupplyType(supplytype) {
|
||||||
if (level === null || level === undefined) return ''
|
if (!supplytype) return ''
|
||||||
if (level <= 10) return 'critical'
|
return supplytype.charAt(0).toUpperCase() + supplytype.slice(1)
|
||||||
if (level <= 25) return 'low'
|
|
||||||
return 'ok'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
@@ -360,4 +378,33 @@ function formatDate(dateStr) {
|
|||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
margin-top: 0.625rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group" v-if="identifierflags.fqdn">
|
||||||
<label for="hostname">Hostname (FQDN)</label>
|
<label for="hostname">Hostname (FQDN)</label>
|
||||||
<input
|
<input
|
||||||
id="hostname"
|
id="hostname"
|
||||||
@@ -73,11 +73,11 @@
|
|||||||
>
|
>
|
||||||
<option value="">Select type...</option>
|
<option value="">Select type...</option>
|
||||||
<option
|
<option
|
||||||
v-for="mt in printerTypes"
|
v-for="pt in printerTypes"
|
||||||
:key="mt.machinetypeid"
|
:key="pt.printertypeid"
|
||||||
:value="mt.machinetypeid"
|
:value="pt.printertypeid"
|
||||||
>
|
>
|
||||||
{{ mt.machinetype }}
|
{{ pt.printertype }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,8 +127,9 @@
|
|||||||
id="modelnumberid"
|
id="modelnumberid"
|
||||||
v-model="form.modelnumberid"
|
v-model="form.modelnumberid"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
|
:disabled="!form.vendorid"
|
||||||
>
|
>
|
||||||
<option value="">Select model...</option>
|
<option value="">{{ form.vendorid ? 'Select model...' : 'Select a vendor first' }}</option>
|
||||||
<option
|
<option
|
||||||
v-for="m in filteredModels"
|
v-for="m in filteredModels"
|
||||||
:key="m.modelnumberid"
|
:key="m.modelnumberid"
|
||||||
@@ -137,8 +138,8 @@
|
|||||||
{{ m.modelnumber }}
|
{{ m.modelnumber }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<small v-if="!form.vendorid && !form.machinetypeid" class="form-hint">
|
<small v-if="!form.vendorid" class="form-hint">
|
||||||
Select vendor or printer type to filter models
|
Select a vendor first to choose a model
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,10 +267,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed, watch } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 ShopFloorMap from '../../components/ShopFloorMap.vue'
|
||||||
import Modal from '../../components/Modal.vue'
|
import Modal from '../../components/Modal.vue'
|
||||||
import { currentTheme } from '../../stores/theme'
|
import { currentTheme } from '../../stores/theme'
|
||||||
|
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||||
|
|
||||||
|
const identifierflags = useIdentifierFlags()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -317,8 +321,10 @@ const filteredModels = computed(() => {
|
|||||||
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
|
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Filter by printer type if selected
|
// Filter by printer type if selected, but only exclude models that have a
|
||||||
if (form.value.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
|
// 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 false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -422,47 +428,53 @@ function onWindowsNameInput() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
// Load reference data
|
// Load reference data (models paged in full via listAll, see api/index.js)
|
||||||
const [mtRes, statusRes, vendorRes, modelsRes, locRes] = await Promise.all([
|
// perpage 100 so dropdowns aren't truncated to the default 20-row page
|
||||||
machinetypesApi.list({ category: 'Printer' }),
|
// (e.g. 44 vendors; the editing record's vendor can be past row 20)
|
||||||
statusesApi.list(),
|
const [mtRes, statusRes, vendorRes, allModels, locRes] = await Promise.all([
|
||||||
vendorsApi.list(),
|
printersApi.types.list({ perpage: 100 }),
|
||||||
modelsApi.list(),
|
assetsApi.statuses.list(),
|
||||||
locationsApi.list()
|
vendorsApi.list({ perpage: 100 }),
|
||||||
|
modelsApi.listAll(),
|
||||||
|
locationsApi.list({ perpage: 100 })
|
||||||
])
|
])
|
||||||
|
|
||||||
printerTypes.value = mtRes.data.data || []
|
printerTypes.value = mtRes.data.data || []
|
||||||
statuses.value = statusRes.data.data || []
|
statuses.value = statusRes.data.data || []
|
||||||
vendors.value = vendorRes.data.data || []
|
vendors.value = vendorRes.data.data || []
|
||||||
models.value = modelsRes.data.data || []
|
models.value = allModels
|
||||||
locations.value = locRes.data.data || []
|
locations.value = locRes.data.data || []
|
||||||
|
|
||||||
// Load printer if editing
|
// Load printer if editing
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
const response = await printersApi.get(route.params.id)
|
const response = await printersApi.get(route.params.id)
|
||||||
const printer = response.data.data
|
const printer = response.data.data
|
||||||
|
// asset-based shape: printer extension fields live under printer.printer
|
||||||
|
const ext = printer.printer || {}
|
||||||
|
|
||||||
// Get IP from communications
|
// Get IP from communications
|
||||||
const primaryComm = printer.communications?.find(c => c.isprimary) || printer.communications?.[0]
|
const primaryComm = printer.communications?.find(c => c.isprimary) || printer.communications?.[0]
|
||||||
|
|
||||||
form.value = {
|
form.value = {
|
||||||
machinenumber: printer.machinenumber || '',
|
// "Windows Name" is the printer's business identifier (assetnumber).
|
||||||
alias: printer.alias || '',
|
// Prefer the explicit windowsname, then fall back to assetnumber.
|
||||||
hostname: printer.hostname || '',
|
machinenumber: ext.windowsname || printer.assetnumber || '',
|
||||||
|
alias: printer.name && printer.name.toUpperCase() !== 'NONE' ? printer.name : '',
|
||||||
|
hostname: ext.hostname || '',
|
||||||
serialnumber: printer.serialnumber || '',
|
serialnumber: printer.serialnumber || '',
|
||||||
machinetypeid: printer.machinetype?.machinetypeid || '',
|
machinetypeid: ext.printertypeid || '',
|
||||||
statusid: printer.status?.statusid || '',
|
statusid: printer.statusid || '',
|
||||||
vendorid: printer.vendor?.vendorid || '',
|
vendorid: ext.vendorid || '',
|
||||||
modelnumberid: printer.model?.modelnumberid || '',
|
modelnumberid: ext.modelnumberid || '',
|
||||||
locationid: printer.location?.locationid || '',
|
locationid: printer.locationid || '',
|
||||||
notes: printer.notes || '',
|
notes: printer.notes || '',
|
||||||
mapx: printer.mapx ?? null,
|
mapx: printer.mapx ?? null,
|
||||||
mapy: printer.mapy ?? null,
|
mapy: printer.mapy ?? null,
|
||||||
// Printer-specific
|
// Printer-specific
|
||||||
ipaddress: primaryComm?.ipaddress || '',
|
ipaddress: primaryComm?.ipaddress || '',
|
||||||
csfname: printer.printerdata?.sharename || '',
|
csfname: ext.sharename || '',
|
||||||
installpath: printer.printerdata?.installpath || '',
|
installpath: ext.installpath || '',
|
||||||
pin: printer.printerdata?.pin || ''
|
pin: ext.pin || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't auto-generate for existing printers
|
// Don't auto-generate for existing printers
|
||||||
@@ -500,48 +512,36 @@ async function savePrinter() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const machineData = {
|
// One payload for the printers plugin, which owns asset core + extension +
|
||||||
machinenumber: form.value.machinenumber,
|
// primary communication. The "Windows Name" field is the business
|
||||||
alias: form.value.alias,
|
// identifier, written to both assetnumber and the extension windowsname.
|
||||||
hostname: form.value.hostname,
|
const payload = {
|
||||||
serialnumber: form.value.serialnumber,
|
assetnumber: form.value.machinenumber,
|
||||||
machinetypeid: form.value.machinetypeid || null,
|
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,
|
statusid: form.value.statusid || null,
|
||||||
vendorid: form.value.vendorid || null,
|
vendorid: form.value.vendorid || null,
|
||||||
modelnumberid: form.value.modelnumberid || null,
|
modelnumberid: form.value.modelnumberid || null,
|
||||||
locationid: form.value.locationid || 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,
|
mapx: form.value.mapx,
|
||||||
mapy: form.value.mapy
|
mapy: form.value.mapy
|
||||||
}
|
}
|
||||||
|
// only set the display name when an alias is given, so we don't clobber it
|
||||||
const printerData = {
|
if (form.value.alias) {
|
||||||
windowsname: form.value.machinenumber, // Windows name is the machinenumber
|
payload.name = form.value.alias
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle IP address - need to update/create communication record
|
|
||||||
const communicationData = form.value.ipaddress ? {
|
|
||||||
ipaddress: form.value.ipaddress,
|
|
||||||
isprimary: true
|
|
||||||
} : null
|
|
||||||
|
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await machinesApi.update(route.params.id, machineData)
|
await printersApi.update(route.params.id, payload)
|
||||||
await printersApi.updateExtension(route.params.id, printerData)
|
|
||||||
if (communicationData) {
|
|
||||||
await printersApi.updateCommunication(route.params.id, communicationData)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const response = await machinesApi.create(machineData)
|
await printersApi.create(payload)
|
||||||
const newId = response.data.data.machineid
|
|
||||||
await printersApi.updateExtension(newId, printerData)
|
|
||||||
if (communicationData) {
|
|
||||||
await printersApi.updateCommunication(newId, communicationData)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push('/printers')
|
router.push('/printers')
|
||||||
@@ -592,12 +592,11 @@ async function savePrinter() {
|
|||||||
color: var(--text-light, #666);
|
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 {
|
.auto-generated {
|
||||||
background-color: #f0f7ff;
|
background-color: rgba(33, 150, 243, 0.12);
|
||||||
border-color: #90caf9;
|
border-color: #90caf9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auto-generated:focus {
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,6 +17,12 @@
|
|||||||
placeholder="Search printers..."
|
placeholder="Search printers..."
|
||||||
@input="debouncedSearch"
|
@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>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -27,9 +33,10 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Asset #</th>
|
<th>Asset Tag</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Business Unit</th>
|
<th>Business Unit</th>
|
||||||
|
<th>Type</th>
|
||||||
<th>Model</th>
|
<th>Model</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
@@ -38,8 +45,9 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid">
|
<tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid">
|
||||||
<td>{{ printer.assetnumber }}</td>
|
<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.businessunitname || '-' }}</td>
|
||||||
|
<td>{{ printer.printer?.printertypename || '-' }}</td>
|
||||||
<td>{{ printer.printer?.modelname || '-' }}</td>
|
<td>{{ printer.printer?.modelname || '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" :class="getStatusClass(printer.statusname)">
|
<span class="badge" :class="getStatusClass(printer.statusname)">
|
||||||
@@ -56,7 +64,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="printers.length === 0">
|
<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
|
No printers found
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -83,6 +91,8 @@ import { printersApi } from '../../api'
|
|||||||
import PaginationBar from '../../components/PaginationBar.vue'
|
import PaginationBar from '../../components/PaginationBar.vue'
|
||||||
|
|
||||||
const printers = ref([])
|
const printers = ref([])
|
||||||
|
const printerTypes = ref([])
|
||||||
|
const typeFilter = ref('')
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
@@ -91,7 +101,13 @@ const perPage = ref(20)
|
|||||||
|
|
||||||
let searchTimeout = null
|
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()
|
loadPrinters()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -103,6 +119,7 @@ async function loadPrinters() {
|
|||||||
perpage: perPage.value
|
perpage: perPage.value
|
||||||
}
|
}
|
||||||
if (search.value) params.search = search.value
|
if (search.value) params.search = search.value
|
||||||
|
if (typeFilter.value) params.typeid = typeFilter.value
|
||||||
|
|
||||||
const response = await printersApi.list(params)
|
const response = await printersApi.list(params)
|
||||||
printers.value = response.data.data || []
|
printers.value = response.data.data || []
|
||||||
@@ -122,6 +139,11 @@ function debouncedSearch() {
|
|||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onFilterChange() {
|
||||||
|
page.value = 1
|
||||||
|
loadPrinters()
|
||||||
|
}
|
||||||
|
|
||||||
function goToPage(p) {
|
function goToPage(p) {
|
||||||
page.value = p
|
page.value = p
|
||||||
loadPrinters()
|
loadPrinters()
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<tr v-for="bu in items" :key="bu.businessunitid">
|
<tr v-for="bu in items" :key="bu.businessunitid">
|
||||||
<td>{{ bu.businessunit }}</td>
|
<td>{{ bu.businessunit }}</td>
|
||||||
<td>{{ bu.code || '-' }}</td>
|
<td>{{ bu.code || '-' }}</td>
|
||||||
<td>{{ bu.description || '-' }}</td>
|
<td class="cell-truncate" :title="bu.description">{{ bu.description || '-' }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="btn btn-secondary btn-sm" @click="openModal(bu)">Edit</button>
|
<button class="btn btn-secondary btn-sm" @click="openModal(bu)">Edit</button>
|
||||||
<button class="btn btn-danger btn-sm" @click="confirmDelete(bu)">Delete</button>
|
<button class="btn btn-danger btn-sm" @click="confirmDelete(bu)">Delete</button>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<td>{{ loc.building || '-' }}</td>
|
<td>{{ loc.building || '-' }}</td>
|
||||||
<td>{{ loc.floor || '-' }}</td>
|
<td>{{ loc.floor || '-' }}</td>
|
||||||
<td>{{ loc.room || '-' }}</td>
|
<td>{{ loc.room || '-' }}</td>
|
||||||
<td>{{ loc.description || '-' }}</td>
|
<td class="cell-truncate" :title="loc.description">{{ loc.description || '-' }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button
|
<button
|
||||||
class="btn btn-secondary btn-sm"
|
class="btn btn-secondary btn-sm"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
{{ mt.category }}
|
{{ mt.category }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ mt.description || '-' }}</td>
|
<td class="cell-truncate" :title="mt.description">{{ mt.description || '-' }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button
|
<button
|
||||||
class="btn btn-secondary btn-sm"
|
class="btn btn-secondary btn-sm"
|
||||||
|
|||||||
437
frontend/src/views/settings/ModelSuppliesList.vue
Normal file
437
frontend/src/views/settings/ModelSuppliesList.vue
Normal 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>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="pt in pcTypes" :key="pt.pctypeid">
|
<tr v-for="pt in pcTypes" :key="pt.pctypeid">
|
||||||
<td>{{ pt.pctype }}</td>
|
<td>{{ pt.pctype }}</td>
|
||||||
<td>{{ pt.description || '-' }}</td>
|
<td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
|
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
|
||||||
<button class="btn btn-danger btn-sm" @click="confirmDelete(pt)">Delete</button>
|
<button class="btn btn-danger btn-sm" @click="confirmDelete(pt)">Delete</button>
|
||||||
|
|||||||
@@ -27,6 +27,12 @@
|
|||||||
<p>Manage equipment models by vendor</p>
|
<p>Manage equipment models by vendor</p>
|
||||||
</router-link>
|
</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">
|
<router-link to="/settings/machinetypes" class="settings-card">
|
||||||
<div class="card-icon"><Monitor :size="28" /></div>
|
<div class="card-icon"><Monitor :size="28" /></div>
|
||||||
<h3>Machine Types</h3>
|
<h3>Machine Types</h3>
|
||||||
@@ -85,7 +91,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Machine Statuses</h2>
|
<h2>Asset Statuses</h2>
|
||||||
<button class="btn btn-primary" @click="openModal()">+ Add Status</button>
|
<button class="btn btn-primary" @click="openModal()">+ Add Status</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<span class="color-preview" :style="{ backgroundColor: s.color || '#6c757d' }"></span>
|
<span class="color-preview" :style="{ backgroundColor: s.color || '#6c757d' }"></span>
|
||||||
{{ s.color || 'default' }}
|
{{ s.color || 'default' }}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ s.description || '-' }}</td>
|
<td class="cell-truncate" :title="s.description">{{ s.description || '-' }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button
|
<button
|
||||||
class="btn btn-secondary btn-sm"
|
class="btn btn-secondary btn-sm"
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>Are you sure you want to delete <strong>{{ statusToDelete?.status }}</strong>?</p>
|
<p>Are you sure you want to delete <strong>{{ statusToDelete?.status }}</strong>?</p>
|
||||||
<p style="color: var(--text-light); font-size: 0.875rem;">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -149,7 +149,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { statusesApi } from '../../api'
|
import { assetsApi } from '../../api'
|
||||||
import PaginationBar from '../../components/PaginationBar.vue'
|
import PaginationBar from '../../components/PaginationBar.vue'
|
||||||
|
|
||||||
const statuses = ref([])
|
const statuses = ref([])
|
||||||
@@ -184,7 +184,7 @@ async function loadStatuses() {
|
|||||||
perpage: perPage.value
|
perpage: perPage.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await statusesApi.list(params)
|
const response = await assetsApi.statuses.list(params)
|
||||||
statuses.value = response.data.data || []
|
statuses.value = response.data.data || []
|
||||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -235,9 +235,9 @@ async function saveStatus() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingStatus.value) {
|
if (editingStatus.value) {
|
||||||
await statusesApi.update(editingStatus.value.statusid, form.value)
|
await assetsApi.statuses.update(editingStatus.value.statusid, form.value)
|
||||||
} else {
|
} else {
|
||||||
await statusesApi.create(form.value)
|
await assetsApi.statuses.create(form.value)
|
||||||
}
|
}
|
||||||
closeModal()
|
closeModal()
|
||||||
loadStatuses()
|
loadStatuses()
|
||||||
@@ -256,7 +256,7 @@ function confirmDelete(s) {
|
|||||||
|
|
||||||
async function deleteStatus() {
|
async function deleteStatus() {
|
||||||
try {
|
try {
|
||||||
await statusesApi.delete(statusToDelete.value.statusid)
|
await assetsApi.statuses.delete(statusToDelete.value.statusid)
|
||||||
showDeleteModal.value = false
|
showDeleteModal.value = false
|
||||||
statusToDelete.value = null
|
statusToDelete.value = null
|
||||||
loadStatuses()
|
loadStatuses()
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
{{ role.rolename }}
|
{{ role.rolename }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ role.description || '-' }}</td>
|
<td class="cell-truncate" :title="role.description">{{ role.description || '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="role.isadmin" class="text-muted">All permissions</span>
|
<span v-if="role.isadmin" class="text-muted">All permissions</span>
|
||||||
<span v-else-if="role.permissions?.length">{{ role.permissions.length }} permissions</span>
|
<span v-else-if="role.permissions?.length">{{ role.permissions.length }} permissions</span>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ vlan.description || '-' }}</td>
|
<td class="cell-truncate" :title="vlan.description">{{ vlan.description || '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ path: '/settings/subnets', query: { vlanid: vlan.vlanid } }"
|
:to="{ path: '/settings/subnets', query: { vlanid: vlan.vlanid } }"
|
||||||
|
|||||||
Reference in New Issue
Block a user