ADR-013 Phase 4: relocate the remaining 9 plugin frontends (all 13 done)
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.
This commit is contained in:
286
plugins/usb/frontend/views/USBDetail.vue
Normal file
286
plugins/usb/frontend/views/USBDetail.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>USB Device Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link to="/usb" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="device">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ device.device_desc || device.device_id }}</h1>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg" :class="device.status === 'checked-out' ? 'badge-warning' : 'badge-success'">
|
||||
{{ device.status === 'checked-out' ? 'Checked Out' : 'Available' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="device.device_id">
|
||||
<span class="hero-detail-label">Serial Number</span>
|
||||
<span class="hero-detail-value mono">{{ device.device_id }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="device.locker_location">
|
||||
<span class="hero-detail-label">Locker Location</span>
|
||||
<span class="hero-detail-value">{{ device.locker_location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
v-if="device.status !== 'checked-out'"
|
||||
class="btn btn-primary btn-lg"
|
||||
@click="openCheckoutModal"
|
||||
>
|
||||
Checkout Device
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-warning btn-lg"
|
||||
@click="openCheckinModal"
|
||||
>
|
||||
Check In Device
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Current Checkout Info -->
|
||||
<div class="section-card" v-if="device.status === 'checked-out'">
|
||||
<h3 class="section-title">Current Checkout</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Checked Out By</span>
|
||||
<span class="info-value">{{ device.current_holder_name || device.current_holder }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Checkout Time</span>
|
||||
<span class="info-value">{{ formatDate(device.checkout_time) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Check-in/out Log -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Checkout History</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="!device.checkinoutlog?.length" class="empty-state">
|
||||
No checkout history
|
||||
</div>
|
||||
|
||||
<div v-else class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>User</th>
|
||||
<th>Time</th>
|
||||
<th>Sanitized</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in device.checkinoutlog" :key="entry.log_id">
|
||||
<td>{{ entry.action === 'check-in' ? 'Check In' : 'Check Out' }}</td>
|
||||
<td>{{ entry.badge_name || entry.badge_number }}</td>
|
||||
<td>{{ formatDate(entry.timestamp) }}</td>
|
||||
<td>
|
||||
<span v-if="entry.action === 'check-in'">{{ entry.sanitized ? 'Yes' : 'No' }}</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">USB device not found</p>
|
||||
</div>
|
||||
|
||||
<!-- Checkout Modal -->
|
||||
<Modal v-if="showCheckoutModal" @close="closeModals">
|
||||
<template #header>
|
||||
<h3>Checkout USB Device</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="form-group">
|
||||
<label>Your SSO *</label>
|
||||
<input v-model="checkoutForm.sso" type="text" class="form-control" placeholder="Enter your SSO" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Reason</label>
|
||||
<textarea v-model="checkoutForm.reason" class="form-control" rows="3" placeholder="Why do you need this device?"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="closeModals">Cancel</button>
|
||||
<button class="btn btn-primary" @click="doCheckout" :disabled="!checkoutForm.sso">Checkout</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Checkin Modal -->
|
||||
<Modal v-if="showCheckinModal" @close="closeModals">
|
||||
<template #header>
|
||||
<h3>Check In USB Device</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="checkinForm.waswiped" />
|
||||
Device was wiped
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notes</label>
|
||||
<textarea v-model="checkinForm.notes" class="form-control" rows="3" placeholder="Any notes about this return?"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="closeModals">Cancel</button>
|
||||
<button class="btn btn-primary" @click="doCheckin">Check In</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { usbApi } from '@/api'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const device = ref(null)
|
||||
|
||||
const showCheckoutModal = ref(false)
|
||||
const showCheckinModal = ref(false)
|
||||
const checkoutForm = ref({ sso: '', reason: '' })
|
||||
const checkinForm = ref({ waswiped: false, notes: '' })
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDevice()
|
||||
})
|
||||
|
||||
async function loadDevice() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await usbApi.get(route.params.id)
|
||||
device.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading USB device:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCheckoutModal() {
|
||||
checkoutForm.value = { sso: '', reason: '' }
|
||||
showCheckoutModal.value = true
|
||||
}
|
||||
|
||||
function openCheckinModal() {
|
||||
checkinForm.value = { waswiped: false, notes: '' }
|
||||
showCheckinModal.value = true
|
||||
}
|
||||
|
||||
function closeModals() {
|
||||
showCheckoutModal.value = false
|
||||
showCheckinModal.value = false
|
||||
}
|
||||
|
||||
async function doCheckout() {
|
||||
try {
|
||||
// The SSO the operator enters is the badge; API resolves the name.
|
||||
await usbApi.checkout(device.value.device_id, {
|
||||
badge: checkoutForm.value.sso,
|
||||
reason: checkoutForm.value.reason
|
||||
})
|
||||
closeModals()
|
||||
await loadDevice()
|
||||
} catch (error) {
|
||||
console.error('Checkout error:', error)
|
||||
toast.error(apiError(error, 'Checkout failed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function doCheckin() {
|
||||
try {
|
||||
// Check the current holder back in; sanitized mirrors the wiped checkbox.
|
||||
await usbApi.checkin(device.value.device_id, {
|
||||
badge: device.value.current_holder,
|
||||
sanitized: checkinForm.value.waswiped,
|
||||
notes: checkinForm.value.notes
|
||||
})
|
||||
closeModals()
|
||||
await loadDevice()
|
||||
} catch (error) {
|
||||
console.error('Checkin error:', error)
|
||||
toast.error(apiError(error, 'Check in failed'))
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
</style>
|
||||
149
plugins/usb/frontend/views/USBForm.vue
Normal file
149
plugins/usb/frontend/views/USBForm.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit USB Device' : 'Add USB Device' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="saveDevice">
|
||||
<div class="form-group">
|
||||
<label for="device_id">Serial Number *</label>
|
||||
<input
|
||||
id="device_id"
|
||||
v-model="form.device_id"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
maxlength="100"
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="device_desc">Description</label>
|
||||
<input
|
||||
id="device_desc"
|
||||
v-model="form.device_desc"
|
||||
type="text"
|
||||
class="form-control"
|
||||
maxlength="100"
|
||||
placeholder="e.g., USB Flash Drive #1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="locker_location">Locker Location</label>
|
||||
<input
|
||||
id="locker_location"
|
||||
v-model="form.locker_location"
|
||||
type="text"
|
||||
class="form-control"
|
||||
maxlength="200"
|
||||
placeholder="Where the device is stored"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : (isEdit ? 'Update Device' : 'Add Device') }}
|
||||
</button>
|
||||
<router-link to="/usb" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { usbApi } from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({
|
||||
device_id: '',
|
||||
device_desc: '',
|
||||
locker_location: ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load device if editing
|
||||
if (isEdit.value) {
|
||||
const response = await usbApi.get(route.params.id)
|
||||
const device = response.data.data
|
||||
|
||||
form.value = {
|
||||
device_id: device.device_id || '',
|
||||
device_desc: device.device_desc || '',
|
||||
locker_location: device.locker_location || ''
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function saveDevice() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
// Create needs device_id; update keys off the path id so it is omitted.
|
||||
const data = {
|
||||
device_desc: form.value.device_desc || null,
|
||||
locker_location: form.value.locker_location || null
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await usbApi.update(route.params.id, data)
|
||||
} else {
|
||||
data.device_id = form.value.device_id
|
||||
await usbApi.create(data)
|
||||
}
|
||||
|
||||
router.push('/usb')
|
||||
} catch (err) {
|
||||
console.error('Error saving device:', err)
|
||||
error.value = apiError(err, 'Failed to save device')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
425
plugins/usb/frontend/views/USBLabelBatch.vue
Normal file
425
plugins/usb/frontend/views/USBLabelBatch.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="no-print">
|
||||
<div class="controls">
|
||||
<h3>Batch Print USB Barcode Labels</h3>
|
||||
<p>Select USB devices to print (72 labels per page - 6 ULINE labels x 12 mini-labels each, cut after printing):</p>
|
||||
|
||||
<div v-if="loadingDevices" class="loading-msg">Loading USB devices...</div>
|
||||
<div v-else-if="devices.length === 0" class="loading-msg">No USB devices found</div>
|
||||
<div v-else class="usb-grid">
|
||||
<div
|
||||
v-for="device in devices"
|
||||
:key="device.device_id"
|
||||
class="usb-item"
|
||||
:class="{ selected: isSelected(device) }"
|
||||
@click="toggleDevice(device)"
|
||||
>
|
||||
<input type="checkbox" :checked="isSelected(device)" @click.stop />
|
||||
<label>
|
||||
<strong><code>{{ device.device_id || '-' }}</code></strong>
|
||||
<div class="alias">{{ device.device_desc || '' }}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-count">
|
||||
Selected: <span class="count">{{ selectedDevices.length }}</span> USB devices
|
||||
(<span class="pages">{{ pageCount }}</span> pages)
|
||||
<label style="margin-left: 20px;">Start at cell:
|
||||
<select v-model="startCell" style="padding: 5px; font-size: 14px;">
|
||||
<option value="1">1 - Top Left</option>
|
||||
<option value="2">2 - Top Right</option>
|
||||
<option value="3">3 - Middle Left</option>
|
||||
<option value="4">4 - Middle Right</option>
|
||||
<option value="5">5 - Bottom Left</option>
|
||||
<option value="6">6 - Bottom Right</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="print-btn" :disabled="selectedDevices.length === 0" @click="print">Print Labels</button>
|
||||
<button class="clear-btn" @click="clearSelection">Clear All</button>
|
||||
<button class="select-all-btn" @click="selectAll">Select All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheets-container">
|
||||
<div v-for="(page, pageIdx) in sheetPages" :key="pageIdx" class="print-sheet">
|
||||
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
|
||||
<div
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
class="label-cell"
|
||||
:class="[`cell-${cellNum}`, page[cellNum - 1].hasContent ? 'has-content' : 'empty']"
|
||||
>
|
||||
<div v-if="page[cellNum - 1].hasContent" class="mini-grid">
|
||||
<div
|
||||
v-for="(miniItem, miniIdx) in page[cellNum - 1].items"
|
||||
:key="miniIdx"
|
||||
class="mini-label"
|
||||
:class="miniItem ? 'filled' : 'empty'"
|
||||
>
|
||||
<template v-if="miniItem">
|
||||
<img
|
||||
v-if="labelStyle === 'qr'"
|
||||
class="qr-img"
|
||||
:src="qrImages[`${pageIdx}-${cellNum}-${miniIdx}`] || ''"
|
||||
/>
|
||||
<div v-else class="barcode-container">
|
||||
<svg :ref="el => setBarcodeRef(el, pageIdx, cellNum, miniIdx)"></svg>
|
||||
</div>
|
||||
<div class="serial-text">{{ miniItem.device_id }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-cell-text">Empty</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { usbApi } from '@/api'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
import QRCode from 'qrcode'
|
||||
import { getSetting } from '@/utils/siteSettings'
|
||||
import { buildQrUrl } from '@/utils/qrTarget'
|
||||
|
||||
const MINI_LABELS_PER_CELL = 12
|
||||
const CELLS_PER_PAGE = 6
|
||||
|
||||
const devices = ref([])
|
||||
const selectedDevices = ref([])
|
||||
const loadingDevices = ref(true)
|
||||
const startCell = ref('1')
|
||||
const barcodeRefs = ref({})
|
||||
// usb_label_style setting: 'barcode' (CODE128 of the serial) or 'qr' (QR code
|
||||
// linking to the qr_target_usb target, default = the device page).
|
||||
const labelStyle = ref('barcode')
|
||||
// QR data-URL images keyed like barcodeRefs. Images print reliably; live
|
||||
// canvases do not.
|
||||
const qrImages = ref({})
|
||||
|
||||
const pageCount = computed(() => {
|
||||
if (selectedDevices.value.length === 0) return 0
|
||||
const skippedCells = parseInt(startCell.value) - 1
|
||||
const numCells = Math.ceil(selectedDevices.value.length / MINI_LABELS_PER_CELL)
|
||||
const totalCellsNeeded = numCells + skippedCells
|
||||
return Math.ceil(totalCellsNeeded / CELLS_PER_PAGE)
|
||||
})
|
||||
|
||||
const sheetPages = computed(() => {
|
||||
const result = []
|
||||
if (selectedDevices.value.length === 0) return result
|
||||
|
||||
const startCellNum = parseInt(startCell.value)
|
||||
let usbIdx = 0
|
||||
|
||||
for (let page = 0; page < pageCount.value; page++) {
|
||||
const cells = []
|
||||
for (let cellNum = 1; cellNum <= CELLS_PER_PAGE; cellNum++) {
|
||||
const skipThisCell = page === 0 && cellNum < startCellNum
|
||||
const hasContent = !skipThisCell && usbIdx < selectedDevices.value.length
|
||||
|
||||
const items = []
|
||||
if (hasContent) {
|
||||
for (let mini = 0; mini < MINI_LABELS_PER_CELL; mini++) {
|
||||
if (usbIdx < selectedDevices.value.length) {
|
||||
items.push(selectedDevices.value[usbIdx])
|
||||
usbIdx++
|
||||
} else {
|
||||
items.push(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cells.push({ hasContent, items })
|
||||
}
|
||||
result.push(cells)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
labelStyle.value = (await getSetting('usb_label_style', 'barcode')) === 'qr' ? 'qr' : 'barcode'
|
||||
try {
|
||||
const response = await usbApi.list({ perpage: 500 })
|
||||
devices.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading USB devices:', error)
|
||||
} finally {
|
||||
loadingDevices.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch([selectedDevices, startCell], async () => {
|
||||
await nextTick()
|
||||
if (labelStyle.value === 'qr') {
|
||||
await generateQrImages()
|
||||
} else {
|
||||
generateBarcodes()
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
async function generateQrImages() {
|
||||
const next = {}
|
||||
for (let pageIdx = 0; pageIdx < sheetPages.value.length; pageIdx++) {
|
||||
const page = sheetPages.value[pageIdx]
|
||||
for (let cellIdx = 0; cellIdx < page.length; cellIdx++) {
|
||||
const cell = page[cellIdx]
|
||||
if (!cell.hasContent) continue
|
||||
for (let miniIdx = 0; miniIdx < cell.items.length; miniIdx++) {
|
||||
const item = cell.items[miniIdx]
|
||||
if (!item) continue
|
||||
try {
|
||||
const url = await buildQrUrl('qr_target_usb', `/usb/${encodeURIComponent(item.device_id)}`, {
|
||||
id: item.device_id || '',
|
||||
serialnumber: item.device_id || '',
|
||||
alias: item.device_desc || '',
|
||||
})
|
||||
next[`${pageIdx}-${cellIdx + 1}-${miniIdx}`] = await QRCode.toDataURL(url, {
|
||||
margin: 0,
|
||||
width: 150,
|
||||
errorCorrectionLevel: 'M',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('QR error:', item.device_id, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
qrImages.value = next
|
||||
}
|
||||
|
||||
function setBarcodeRef(el, pageIdx, cellNum, miniIdx) {
|
||||
if (el) {
|
||||
barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`] = el
|
||||
}
|
||||
}
|
||||
|
||||
function generateBarcodes() {
|
||||
sheetPages.value.forEach((page, pageIdx) => {
|
||||
page.forEach((cell, cellIdx) => {
|
||||
if (!cell.hasContent) return
|
||||
const cellNum = cellIdx + 1
|
||||
cell.items.forEach((item, miniIdx) => {
|
||||
if (!item) return
|
||||
const el = barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`]
|
||||
if (!el) return
|
||||
try {
|
||||
JsBarcode(el, item.device_id, {
|
||||
format: 'CODE128',
|
||||
width: 1,
|
||||
height: 22,
|
||||
displayValue: false,
|
||||
margin: 0,
|
||||
background: 'transparent'
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Barcode error:', item.device_id, e)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isSelected(device) {
|
||||
return selectedDevices.value.some(d => d.device_id === device.device_id)
|
||||
}
|
||||
|
||||
function toggleDevice(device) {
|
||||
const idx = selectedDevices.value.findIndex(d => d.device_id === device.device_id)
|
||||
if (idx > -1) {
|
||||
selectedDevices.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedDevices.value.push(device)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedDevices.value = []
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedDevices.value = [...devices.value]
|
||||
}
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@page { size: letter; margin: 0; }
|
||||
|
||||
.no-print { margin-bottom: 20px; padding: 20px; }
|
||||
.controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
|
||||
.controls h3 { margin-top: 0; }
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
|
||||
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
|
||||
|
||||
.clear-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.select-all-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.usb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.usb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.usb-item:hover { border-color: var(--primary); }
|
||||
.usb-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
||||
.usb-item input { margin-right: 10px; }
|
||||
.usb-item label { cursor: pointer; flex: 1; }
|
||||
.usb-item .alias { font-size: 11px; color: var(--text-light); }
|
||||
|
||||
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
|
||||
.selected-count .count { color: var(--primary); }
|
||||
.selected-count .pages { color: var(--success); }
|
||||
|
||||
.loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
|
||||
|
||||
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.print-sheet {
|
||||
width: 8.5in;
|
||||
height: 11in;
|
||||
background: white;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
page-break-after: always;
|
||||
}
|
||||
.print-sheet:last-child { page-break-after: auto; }
|
||||
|
||||
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
|
||||
|
||||
.label-cell {
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #ccc;
|
||||
overflow: hidden;
|
||||
}
|
||||
.label-cell.has-content { border: 1px solid var(--primary); }
|
||||
.label-cell.empty { background: #fafafa; }
|
||||
|
||||
.cell-1 { top: 0.875in; left: 1.1875in; }
|
||||
.cell-2 { top: 0.875in; left: 4.3125in; }
|
||||
.cell-3 { top: 4in; left: 1.1875in; }
|
||||
.cell-4 { top: 4in; left: 4.3125in; }
|
||||
.cell-5 { top: 7.125in; left: 1.1875in; }
|
||||
.cell-6 { top: 7.125in; left: 4.3125in; }
|
||||
|
||||
.mini-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1in);
|
||||
grid-template-rows: repeat(4, 0.75in);
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
}
|
||||
|
||||
.mini-label {
|
||||
width: 1in;
|
||||
height: 0.75in;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0.02in;
|
||||
border: 1px dotted #ddd;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-label.filled { border: 1px solid #999; }
|
||||
.mini-label.empty { background: #f8f8f8; border: 1px dotted #eee; }
|
||||
|
||||
.barcode-container { text-align: center; line-height: 0; }
|
||||
.barcode-container svg { max-width: 0.9in; height: 24px; }
|
||||
|
||||
.qr-img { width: 0.48in; height: 0.48in; }
|
||||
|
||||
.serial-text {
|
||||
font-size: 6pt;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
margin-top: 1px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.empty-cell-text {
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media print {
|
||||
/* Force the barcodes/borders to print even when "Background graphics" is
|
||||
off. */
|
||||
body, .print-sheet, .label-cell, .mini-label, .barcode-container {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
body { padding: 0; margin: 0; background: white; }
|
||||
.no-print { display: none !important; }
|
||||
.sheets-container { gap: 0; }
|
||||
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
|
||||
.sheet-label { display: none; }
|
||||
.label-cell { border: none !important; }
|
||||
.label-cell.empty { visibility: hidden; }
|
||||
.mini-label { border: 1px dotted #ccc !important; }
|
||||
.mini-label.empty { visibility: hidden; }
|
||||
}
|
||||
</style>
|
||||
304
plugins/usb/frontend/views/USBList.vue
Normal file
304
plugins/usb/frontend/views/USBList.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>USB Devices</h2>
|
||||
<router-link to="/print/usb-labels" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search USB devices..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="showAvailableOnly" @change="loadDevices" />
|
||||
Available Only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Status</th>
|
||||
<th>Checked Out By</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="device in devices" :key="device.device_id">
|
||||
<td>
|
||||
<strong>{{ device.device_desc || device.device_id }}</strong>
|
||||
<div v-if="device.locker_location" class="text-muted">{{ device.locker_location }}</div>
|
||||
</td>
|
||||
<td class="mono">{{ device.device_id || '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="device.status === 'checked-out' ? 'badge-warning' : 'badge-success'">
|
||||
{{ device.status === 'checked-out' ? 'Checked Out' : 'Available' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="device.status === 'checked-out'">
|
||||
{{ device.current_holder_name || device.current_holder }}
|
||||
<div class="text-muted">{{ formatDate(device.checkout_time) }}</div>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
v-if="device.status !== 'checked-out'"
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="openCheckoutModal(device)"
|
||||
>
|
||||
Checkout
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="openCheckinModal(device)"
|
||||
>
|
||||
Check In
|
||||
</button>
|
||||
<router-link
|
||||
:to="`/usb/${device.device_id}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="devices.length === 0">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-light);">
|
||||
No USB devices found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Checkout Modal -->
|
||||
<Modal v-model="showCheckoutModal" @close="closeModals">
|
||||
<template #header>
|
||||
<h3>Checkout USB Device</h3>
|
||||
</template>
|
||||
<p>Checking out: <strong>{{ selectedDevice?.device_desc || selectedDevice?.device_id }}</strong></p>
|
||||
<div class="form-group">
|
||||
<label>Employee *</label>
|
||||
<EmployeeSearch v-model="selectedEmployee" placeholder="Search by name..." />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Reason</label>
|
||||
<textarea v-model="checkoutForm.reason" class="form-control" rows="3" placeholder="Why do you need this device?"></textarea>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="closeModals">Cancel</button>
|
||||
<button class="btn btn-primary" @click="doCheckout" :disabled="!selectedEmployee">Checkout</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Checkin Modal -->
|
||||
<Modal v-model="showCheckinModal" @close="closeModals">
|
||||
<template #header>
|
||||
<h3>Check In USB Device</h3>
|
||||
</template>
|
||||
<p>Checking in: <strong>{{ selectedDevice?.device_desc || selectedDevice?.device_id }}</strong></p>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="checkinForm.waswiped" />
|
||||
Device was wiped
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notes</label>
|
||||
<textarea v-model="checkinForm.notes" class="form-control" rows="3" placeholder="Any notes about this return?"></textarea>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="closeModals">Cancel</button>
|
||||
<button class="btn btn-primary" @click="doCheckin">Check In</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { usbApi } from '@/api'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import EmployeeSearch from '@/components/EmployeeSearch.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const devices = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
const showAvailableOnly = ref(false)
|
||||
|
||||
const showCheckoutModal = ref(false)
|
||||
const showCheckinModal = ref(false)
|
||||
const selectedDevice = ref(null)
|
||||
const checkoutForm = ref({ reason: '' })
|
||||
const checkinForm = ref({ waswiped: false, notes: '' })
|
||||
const selectedEmployee = ref(null)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadDevices()
|
||||
})
|
||||
|
||||
async function loadDevices() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
if (showAvailableOnly.value) params.available = 'true'
|
||||
|
||||
const response = await usbApi.list(params)
|
||||
devices.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading USB devices:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadDevices()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
function openCheckoutModal(device) {
|
||||
selectedDevice.value = device
|
||||
checkoutForm.value = { reason: '' }
|
||||
selectedEmployee.value = null
|
||||
showCheckoutModal.value = true
|
||||
}
|
||||
|
||||
function openCheckinModal(device) {
|
||||
selectedDevice.value = device
|
||||
checkinForm.value = { waswiped: false, notes: '' }
|
||||
showCheckinModal.value = true
|
||||
}
|
||||
|
||||
function closeModals() {
|
||||
showCheckoutModal.value = false
|
||||
showCheckinModal.value = false
|
||||
selectedDevice.value = null
|
||||
selectedEmployee.value = null
|
||||
}
|
||||
|
||||
async function doCheckout() {
|
||||
if (!selectedEmployee.value) return
|
||||
|
||||
try {
|
||||
// API resolves the holder name from the badge; send badge = employee SSO.
|
||||
await usbApi.checkout(selectedDevice.value.device_id, {
|
||||
badge: selectedEmployee.value.sso,
|
||||
reason: checkoutForm.value.reason
|
||||
})
|
||||
closeModals()
|
||||
loadDevices()
|
||||
} catch (error) {
|
||||
console.error('Checkout error:', error)
|
||||
toast.error(apiError(error, 'Checkout failed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function doCheckin() {
|
||||
try {
|
||||
// Check the current holder back in; sanitized mirrors the wiped checkbox.
|
||||
await usbApi.checkin(selectedDevice.value.device_id, {
|
||||
badge: selectedDevice.value.current_holder,
|
||||
sanitized: checkinForm.value.waswiped,
|
||||
notes: checkinForm.value.notes
|
||||
})
|
||||
closeModals()
|
||||
loadDevices()
|
||||
} catch (error) {
|
||||
console.error('Checkin error:', error)
|
||||
toast.error(apiError(error, 'Check in failed'))
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user