List pages kept the current page in local state, so clicking into an asset and hitting Back remounted the list at page 1. A shared useListQuery composable now mirrors the page (and search term) into the URL query via router.replace across all 18 list pages, so Back restores the page you were on and lists are deep-linkable. Page 1 with no search stays a bare path; changing a filter resets to page 1; unrelated query keys are preserved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
305 lines
8.8 KiB
Vue
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>
|