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:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } }"
|
||||
|
||||
Reference in New Issue
Block a user