Relocate warranty, measuringtools, network, printers, usb, notifications, computers, and slides into plugins/<name>/frontend/. Each plugin's views are pulled from wherever they lived (own dir, plus the shared views/settings/, views/reports/, views/print/ dirs, and top-level views) into the plugin's frontend/views/, and its route file becomes the self-contained routes.js. Handled the messy cases: - computers: name mismatch (its views live in views/pcs/) - moved by following the route file's own imports, so the dir name did not matter. Its OS/access- protocol/PC-type settings views move with it (only computers.js routed them). - network: NetworkHub's sibling sub-views (NetworkDevicesList, SubnetsBrowse, not directly routed) moved too so its `./` imports resolve. - printers: the qrLogo helper is SHARED with core AssetLabel, so it stays in views/print/ and PrinterQR imports it via @/views/print/qrLogo. - slides: route file is toplevel-only (TVDashboard); SlideManager stays core (core.js routes /settings/slides). frontend/src/views/ now holds only core views; frontend/src/router/routes/ holds only core.js. All 13 plugins are self-contained under plugins/<name>/frontend/. Verified live: Network (hub + moved sub-views), Computers (name mismatch), GE-Enforce (helper), printedparts all render from their staged frontends. Build + 58 vitest + naming green.
439 lines
11 KiB
Vue
439 lines
11 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Model Toners and Supplies</h2>
|
|
<span class="subtitle">Map toner, drum, and waste part numbers to each printer model.</span>
|
|
</div>
|
|
|
|
<div class="supplies-layout">
|
|
<!-- model picker -->
|
|
<div class="card model-panel">
|
|
<div class="filters">
|
|
<input
|
|
v-model="search"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search models..."
|
|
@input="debouncedSearch"
|
|
/>
|
|
<label class="withsupplies">
|
|
<input v-model="onlyWithSupplies" type="checkbox" @change="loadModels" />
|
|
With supplies only
|
|
</label>
|
|
</div>
|
|
<div v-if="loadingModels" class="loading">Loading...</div>
|
|
<div v-else class="model-list">
|
|
<button
|
|
v-for="m in models"
|
|
:key="m.modelnumberid"
|
|
class="model-row"
|
|
:class="{ active: selectedModel && selectedModel.modelnumberid === m.modelnumberid }"
|
|
@click="selectModel(m)"
|
|
>
|
|
<span class="model-name">{{ m.modelnumber }}</span>
|
|
<span class="model-meta">
|
|
<span class="vendor">{{ m.vendor || 'No vendor' }}</span>
|
|
<span class="badge" :class="m.supplycount ? 'badge-success' : ''">
|
|
{{ m.supplycount }}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
<p v-if="!models.length" class="empty-state">No models match.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- supplies for the selected model -->
|
|
<div class="card supply-panel">
|
|
<div v-if="!selectedModel" class="empty-state">
|
|
Select a model to view and edit its supplies.
|
|
</div>
|
|
<template v-else>
|
|
<div class="panel-header">
|
|
<h3>{{ selectedModel.modelnumber }}</h3>
|
|
<button class="btn btn-primary" @click="openModal()">+ Add Supply</button>
|
|
</div>
|
|
|
|
<div v-if="loadingSupplies" class="loading">Loading...</div>
|
|
<div v-else class="table-container">
|
|
<table v-if="supplies.length">
|
|
<thead>
|
|
<tr>
|
|
<th>Type</th>
|
|
<th>Color</th>
|
|
<th>Tier</th>
|
|
<th>Part Number</th>
|
|
<th>Name</th>
|
|
<th>Yield</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="s in supplies" :key="s.modelsupplyid">
|
|
<td>{{ s.supplytype }}</td>
|
|
<td>
|
|
<span v-if="s.color !== 'none'" class="color-dot" :class="'dot-' + s.color"></span>
|
|
{{ s.color === 'none' ? '-' : s.color }}
|
|
</td>
|
|
<td>{{ s.capacitytier }}</td>
|
|
<td class="partnumber">{{ s.partnumber }}</td>
|
|
<td>{{ s.marketingname || '-' }}</td>
|
|
<td>{{ s.pageyield ? s.pageyield.toLocaleString() : '-' }}</td>
|
|
<td class="actions">
|
|
<button class="btn btn-sm btn-secondary" @click="openModal(s)">Edit</button>
|
|
<button class="btn btn-sm btn-danger" @click="remove(s)">Delete</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p v-else class="empty-state">No supplies mapped yet. Add one above.</p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- add / edit modal -->
|
|
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
|
<div class="modal">
|
|
<h3>{{ editing ? 'Edit Supply' : 'Add Supply' }}</h3>
|
|
<div class="form-grid">
|
|
<label>
|
|
Supply Type
|
|
<select v-model="form.supplytype" class="form-control" @change="onSupplyTypeChange">
|
|
<option v-for="t in meta.supplytypes" :key="t" :value="t">{{ t }}</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Color
|
|
<select v-model="form.color" class="form-control">
|
|
<option v-for="c in meta.colors" :key="c" :value="c">{{ c }}</option>
|
|
</select>
|
|
<small v-if="form.supplytype !== 'toner'" class="field-hint">Drums/waste are often colorless - leave as "none" unless per-color</small>
|
|
</label>
|
|
<label>
|
|
Capacity Tier
|
|
<select v-model="form.capacitytier" class="form-control">
|
|
<option v-for="t in meta.capacitytiers" :key="t" :value="t">{{ t }}</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Part Number
|
|
<input v-model="form.partnumber" type="text" class="form-control" placeholder="e.g. W2020A" />
|
|
</label>
|
|
<label class="full">
|
|
Marketing Name
|
|
<input v-model="form.marketingname" type="text" class="form-control" placeholder="e.g. 414A Black" />
|
|
</label>
|
|
<label>
|
|
Page Yield
|
|
<input v-model.number="form.pageyield" type="number" class="form-control" placeholder="e.g. 2400" />
|
|
</label>
|
|
</div>
|
|
<label class="full">
|
|
Notes
|
|
<input v-model="form.notes" type="text" class="form-control" />
|
|
</label>
|
|
<p v-if="formError" class="text-danger">{{ formError }}</p>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-secondary" @click="closeModal">Cancel</button>
|
|
<button class="btn btn-primary" :disabled="saving" @click="save">
|
|
{{ saving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { printersApi } from '@/api'
|
|
import { apiError } from '@/utils/apiError'
|
|
|
|
const models = ref([])
|
|
const loadingModels = ref(true)
|
|
const search = ref('')
|
|
const onlyWithSupplies = ref(false)
|
|
|
|
const selectedModel = ref(null)
|
|
const supplies = ref([])
|
|
const loadingSupplies = ref(false)
|
|
|
|
const meta = ref({ supplytypes: [], colors: [], capacitytiers: [] })
|
|
|
|
const showModal = ref(false)
|
|
const editing = ref(null)
|
|
const saving = ref(false)
|
|
const formError = ref('')
|
|
const form = ref({})
|
|
|
|
let searchTimer = null
|
|
function debouncedSearch() {
|
|
clearTimeout(searchTimer)
|
|
searchTimer = setTimeout(loadModels, 300)
|
|
}
|
|
|
|
async function loadModels() {
|
|
loadingModels.value = true
|
|
try {
|
|
const params = { per_page: 100 }
|
|
if (search.value) params.search = search.value
|
|
if (onlyWithSupplies.value) params.withsupplies = 'true'
|
|
const response = await printersApi.modelSupplies.listModels(params)
|
|
models.value = response.data.data || []
|
|
} finally {
|
|
loadingModels.value = false
|
|
}
|
|
}
|
|
|
|
async function selectModel(model) {
|
|
selectedModel.value = model
|
|
loadingSupplies.value = true
|
|
try {
|
|
const response = await printersApi.modelSupplies.list(model.modelnumberid)
|
|
supplies.value = response.data.data.supplies || []
|
|
} finally {
|
|
loadingSupplies.value = false
|
|
}
|
|
}
|
|
|
|
function openModal(supply = null) {
|
|
editing.value = supply
|
|
formError.value = ''
|
|
if (supply) {
|
|
form.value = { ...supply }
|
|
} else {
|
|
form.value = {
|
|
supplytype: 'toner',
|
|
color: 'black',
|
|
capacitytier: 'standard',
|
|
partnumber: '',
|
|
marketingname: '',
|
|
pageyield: null,
|
|
notes: ''
|
|
}
|
|
}
|
|
showModal.value = true
|
|
}
|
|
|
|
function onSupplyTypeChange() {
|
|
// toner is color-specific; drum/waste/maintenance usually are not. Default
|
|
// the color sensibly on type change but keep it editable (per-color drums).
|
|
if (form.value.supplytype !== 'toner') {
|
|
if (!form.value.color || form.value.color === 'black') form.value.color = 'none'
|
|
} else if (form.value.color === 'none') {
|
|
form.value.color = 'black'
|
|
}
|
|
}
|
|
|
|
function closeModal() {
|
|
showModal.value = false
|
|
editing.value = null
|
|
}
|
|
|
|
async function save() {
|
|
if (!form.value.partnumber) {
|
|
formError.value = 'Part number is required.'
|
|
return
|
|
}
|
|
saving.value = true
|
|
formError.value = ''
|
|
try {
|
|
if (editing.value) {
|
|
await printersApi.modelSupplies.update(editing.value.modelsupplyid, form.value)
|
|
} else {
|
|
await printersApi.modelSupplies.create(selectedModel.value.modelnumberid, form.value)
|
|
}
|
|
closeModal()
|
|
await selectModel(selectedModel.value)
|
|
await loadModels()
|
|
} catch (err) {
|
|
formError.value = apiError(err, 'Save failed.')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function remove(supply) {
|
|
if (!confirm(`Delete ${supply.partnumber}?`)) return
|
|
await printersApi.modelSupplies.delete(supply.modelsupplyid)
|
|
await selectModel(selectedModel.value)
|
|
await loadModels()
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const metaResponse = await printersApi.modelSupplies.meta()
|
|
meta.value = metaResponse.data.data
|
|
await loadModels()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.subtitle {
|
|
color: var(--text-light);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.supplies-layout {
|
|
display: grid;
|
|
grid-template-columns: 340px 1fr;
|
|
gap: 1.5rem;
|
|
align-items: start;
|
|
}
|
|
|
|
.model-panel {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.withsupplies {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-size: 0.85rem;
|
|
color: var(--text-light);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.model-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-height: 70vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.model-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.6rem 0.5rem;
|
|
background: none;
|
|
border: none;
|
|
border-bottom: 1px solid var(--border);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
color: var(--text);
|
|
}
|
|
|
|
.model-row:hover {
|
|
background: var(--bg);
|
|
}
|
|
|
|
.model-row.active {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.model-row.active .vendor {
|
|
color: white;
|
|
}
|
|
|
|
.model-name {
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.model-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.vendor {
|
|
font-size: 0.8rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.partnumber {
|
|
font-family: monospace;
|
|
}
|
|
|
|
.color-dot {
|
|
display: inline-block;
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
margin-right: 0.35rem;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.dot-black { background: #222; }
|
|
.dot-cyan { background: #00b7eb; }
|
|
.dot-magenta { background: #d633a0; }
|
|
.dot-yellow { background: #f0c000; }
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.modal {
|
|
/* bg-card is intentionally translucent in dark mode; modals must be opaque */
|
|
background: var(--bg-card-solid);
|
|
padding: 1.5rem;
|
|
border-radius: 8px;
|
|
width: 520px;
|
|
max-width: 92vw;
|
|
}
|
|
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 0.75rem;
|
|
margin: 1rem 0;
|
|
}
|
|
|
|
.form-grid label,
|
|
.modal > label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
font-size: 0.85rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.form-grid .full,
|
|
.modal > label.full {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.field-hint {
|
|
font-size: 0.75rem;
|
|
color: var(--text-light);
|
|
margin-top: 0.2rem;
|
|
}
|
|
|
|
.text-danger {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: var(--text-light);
|
|
}
|
|
</style>
|