Files
shopdb-flask/plugins/usb/frontend/views/USBList.vue
cproudlock ebca0b00b0 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.
2026-07-18 23:56:07 -04:00

305 lines
8.8 KiB
Vue

<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>