Operators reported losing a part-filled form by clicking slightly outside it. Every data-entry modal closed on a backdrop click with no warning and no way back - the worst possible response to a misplaced click, and it happens most to someone adding their first records at a new site. Close-on-overlay is removed from 35 modals across 30 files: anything containing an input, textarea, select or v-model. They still close by Cancel or the X. Confirmation dialogs keep it, because a delete prompt holds nothing to lose and dismissing one by clicking away is the behaviour people expect. VendorsList shows the distinction - its edit form no longer closes that way, its delete confirmation still does. The shared Modal component now defaults closeOnOverlay to FALSE. Every current caller holds a form, a checkout, a stock adjustment or a map position being picked, and not one passed the prop, so all of them had the same fault. A modal that genuinely wants dismissing that way opts in explicitly. Also regroups the operator console menu, which had grown to numbers 1-9 plus three letters bolted on with no order to them. Actions are now grouped by what they touch, keyed by their first letter, and the old numbers still work so nobody who has used it for months is stopped by a rearrangement. The menu also warns when the server is not fully provisioned and names the key that fixes it, instead of reporting it as ordinary status lines that read as normal unless you already knew what to look for. That check is cached for the session because it shells out to flask twice and the answer does not change while somebody reads the screen.
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">
|
|
<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>
|