Add USB, Notifications, Network plugins and reusable EmployeeSearch component

New Plugins:
- USB plugin: Device checkout/checkin with employee lookup, checkout history
- Notifications plugin: Announcements with types, scheduling, shopfloor display
- Network plugin: Network device management with subnets and VLANs
- Equipment and Computers plugins: Asset type separation

Frontend:
- EmployeeSearch component: Reusable employee lookup with autocomplete
- USB views: List, detail, checkout/checkin modals
- Notifications views: List, form with recognition mode
- Network views: Device list, detail, form
- Calendar view with FullCalendar integration
- Shopfloor and TV dashboard views
- Reports index page
- Map editor for asset positioning
- Light/dark mode fixes for map tooltips

Backend:
- Employee search API with external lookup service
- Collector API for PowerShell data collection
- Reports API endpoints
- Slides API for TV dashboard
- Fixed AppVersion model (removed BaseModel inheritance)
- Added checkout_name column to usbcheckouts table

Styling:
- Unified detail page styles
- Improved pagination (page numbers instead of prev/next)
- Dark/light mode theme improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-01-21 16:37:49 -05:00
parent 02d83335ee
commit 9c220a4194
110 changed files with 17693 additions and 600 deletions

View File

@@ -17,21 +17,42 @@
<nav class="sidebar-nav">
<router-link to="/">Dashboard</router-link>
<router-link to="/calendar">Calendar</router-link>
<router-link to="/map">Map</router-link>
<div class="nav-section">Assets</div>
<router-link to="/machines">Equipment</router-link>
<router-link to="/pcs">PCs</router-link>
<router-link to="/printers">Printers</router-link>
<router-link to="/network">Network Devices</router-link>
<router-link to="/usb">USB Devices</router-link>
<div class="nav-section">Information</div>
<router-link to="/applications">Applications</router-link>
<router-link to="/knowledgebase">Knowledge Base</router-link>
<router-link to="/notifications">Notifications</router-link>
<router-link to="/reports">Reports</router-link>
<div class="nav-section">Displays</div>
<a href="/shopfloor" target="_blank" class="external-link">Shopfloor Dashboard</a>
<a href="/tv" target="_blank" class="external-link">TV Slideshow</a>
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
</nav>
<div class="user-menu">
<template v-if="authStore.isAuthenticated">
<div class="username">{{ authStore.username }}</div>
<button class="btn btn-secondary" @click="handleLogout">Logout</button>
</template>
<router-link v-else to="/login" class="btn btn-primary">Login</router-link>
<div class="sidebar-footer">
<button class="theme-toggle" @click="toggleTheme" :title="currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'">
<span v-if="currentTheme === 'dark'"> Light</span>
<span v-else>🌙 Dark</span>
</button>
<div class="user-menu">
<template v-if="authStore.isAuthenticated">
<div class="username">{{ authStore.username }}</div>
<button class="btn btn-secondary" @click="handleLogout">Logout</button>
</template>
<router-link v-else to="/login" class="btn btn-primary">Login</router-link>
</div>
</div>
</aside>
@@ -45,6 +66,7 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme'
const router = useRouter()
const authStore = useAuthStore()

View File

@@ -0,0 +1,379 @@
<template>
<div class="page-header">
<h1>Calendar</h1>
</div>
<div class="calendar-container card">
<FullCalendar :options="calendarOptions" />
</div>
<!-- More events tooltip -->
<div
v-if="moreTooltipData.length"
ref="moreTooltip"
class="fc-more-tooltip"
:style="{ left: tooltipPosition.left + 'px', top: tooltipPosition.top + 'px' }"
@mouseleave="hideMoreTooltip"
>
<div
v-for="(evt, idx) in moreTooltipData"
:key="idx"
class="fc-more-tooltip-event"
:style="{ borderLeftColor: evt.color }"
@click="openEventFromTooltip(evt)"
>
{{ evt.title }}
</div>
</div>
<!-- Event details modal -->
<div v-if="selectedEvent" class="modal-overlay" @click.self="closeEventModal">
<div class="modal">
<!-- Recognition event with employee highlight -->
<div v-if="selectedEvent.extendedProps?.typecolor === 'recognition'" class="recognition-header">
<div class="recognition-badge">
<span class="recognition-icon">🏆</span>
</div>
<div class="recognition-info">
<div class="recognition-label">Recognition</div>
<h2 class="recognition-title">{{ selectedEvent.extendedProps?.message || selectedEvent.title }}</h2>
<div v-if="selectedEvent.extendedProps?.employeename || selectedEvent.extendedProps?.employeesso" class="recognition-employee">
<span class="employee-icon">👤</span>
<span class="employee-name">{{ selectedEvent.extendedProps.employeename || selectedEvent.extendedProps.employeesso }}</span>
</div>
</div>
</div>
<!-- Regular event header -->
<h2 v-else>{{ selectedEvent.title }}</h2>
<div class="event-details">
<p v-if="selectedEvent.extendedProps?.typename && selectedEvent.extendedProps?.typecolor !== 'recognition'">
<strong>Type:</strong> {{ selectedEvent.extendedProps.typename }}
</p>
<p>
<strong>Start:</strong> {{ formatDate(selectedEvent.start) }}
</p>
<p v-if="selectedEvent.end">
<strong>End:</strong> {{ formatDate(selectedEvent.end) }}
</p>
<p v-if="selectedEvent.extendedProps?.message && selectedEvent.extendedProps?.typecolor !== 'recognition'" class="message-block">
<strong>Details:</strong>
<span class="message-text">{{ selectedEvent.extendedProps.message }}</span>
</p>
<p v-if="selectedEvent.extendedProps?.ticketnumber">
<strong>Ticket:</strong> {{ selectedEvent.extendedProps.ticketnumber }}
</p>
<p v-if="selectedEvent.extendedProps?.linkurl">
<a :href="selectedEvent.extendedProps.linkurl" target="_blank" class="btn btn-link">
More Info
</a>
</p>
</div>
<div class="modal-actions">
<router-link
v-if="selectedEvent.extendedProps?.notificationid"
:to="`/notifications/${selectedEvent.extendedProps.notificationid}`"
class="btn btn-secondary"
>
View Notification
</router-link>
<button class="btn btn-primary" @click="closeEventModal">Close</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import { notificationsApi } from '@/api'
const events = ref([])
const selectedEvent = ref(null)
const calendarRef = ref(null)
const moreTooltip = ref(null)
const moreTooltipData = ref([])
const tooltipPosition = ref({ left: 0, top: 0 })
// Store events by date for hover lookup
const eventsByDate = ref({})
const calendarOptions = ref({
plugins: [dayGridPlugin],
initialView: 'dayGridMonth',
events: [],
eventClick: (info) => {
selectedEvent.value = info.event
},
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,dayGridWeek'
},
height: 'auto',
dayMaxEvents: 3,
moreLinkClick: () => 'none' // Disable click, we use hover
})
function hideMoreTooltip() {
moreTooltipData.value = []
}
function handleMoreLinkHover(e) {
const moreLink = e.target.closest('.fc-daygrid-more-link')
if (!moreLink) {
return
}
// Find the day cell and get its date
const dayCell = moreLink.closest('.fc-daygrid-day')
if (!dayCell) return
const dateStr = dayCell.getAttribute('data-date')
if (!dateStr || !eventsByDate.value[dateStr]) return
// Get events for this date that would be hidden (after first 3)
const dayEvents = eventsByDate.value[dateStr]
const hiddenEvents = dayEvents.slice(3).map(evt => ({
title: evt.title,
color: evt.backgroundColor || '#14abef',
// Include full event data for modal
start: evt.start,
end: evt.end,
extendedProps: evt.extendedProps || {}
}))
if (hiddenEvents.length === 0) return
moreTooltipData.value = hiddenEvents
// Position tooltip
const rect = moreLink.getBoundingClientRect()
tooltipPosition.value = {
left: rect.left,
top: rect.bottom + 5
}
}
function handleMoreLinkLeave(e) {
const related = e.relatedTarget
// Don't hide if moving to the tooltip itself
if (related && (related.closest('.fc-more-tooltip') || related.closest('.fc-daygrid-more-link'))) {
return
}
hideMoreTooltip()
}
function buildEventsByDate() {
const byDate = {}
for (const evt of events.value) {
const dateStr = evt.start ? evt.start.split('T')[0] : null
if (dateStr) {
if (!byDate[dateStr]) byDate[dateStr] = []
byDate[dateStr].push(evt)
}
}
eventsByDate.value = byDate
}
onMounted(async () => {
await loadEvents()
// Add event delegation for more links
await nextTick()
const container = document.querySelector('.calendar-container')
if (container) {
container.addEventListener('mouseenter', handleMoreLinkHover, true)
container.addEventListener('mouseleave', handleMoreLinkLeave, true)
}
})
onUnmounted(() => {
const container = document.querySelector('.calendar-container')
if (container) {
container.removeEventListener('mouseenter', handleMoreLinkHover, true)
container.removeEventListener('mouseleave', handleMoreLinkLeave, true)
}
})
onMounted(async () => {
await loadEvents()
})
async function loadEvents() {
try {
const response = await notificationsApi.getCalendar()
events.value = response.data.data
calendarOptions.value.events = events.value
buildEventsByDate()
} catch (error) {
console.error('Error loading calendar events:', error)
}
}
function closeEventModal() {
selectedEvent.value = null
}
function openEventFromTooltip(evt) {
// Create an event-like object that matches FullCalendar's event structure
selectedEvent.value = {
title: evt.title,
start: evt.start,
end: evt.end,
extendedProps: evt.extendedProps
}
hideMoreTooltip()
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<style scoped>
.calendar-container {
min-height: 500px;
}
.modal {
padding: 1.5rem;
}
.modal h2 {
margin: 0 0 1rem 0;
font-size: 18px;
color: var(--text);
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.event-details {
padding: 0.5rem 0;
}
.event-details p {
margin-bottom: 0.75rem;
font-size: 14px;
line-height: 1.5;
}
.event-details p:last-child {
margin-bottom: 0;
}
.event-details strong {
color: var(--text-light);
display: inline-block;
min-width: 70px;
}
.message-block {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.message-block strong {
min-width: auto;
}
.message-text {
display: block;
padding: 0.5rem 0.75rem;
background: var(--bg);
border-radius: 0.25rem;
white-space: pre-wrap;
line-height: 1.5;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
/* Recognition event styling */
.recognition-header {
display: flex;
gap: 1rem;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.recognition-badge {
flex-shrink: 0;
width: 60px;
height: 60px;
background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
}
.recognition-icon {
font-size: 28px;
}
.recognition-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.recognition-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--primary);
font-weight: 600;
margin-bottom: 0.25rem;
}
.recognition-title {
margin: 0 !important;
padding: 0 !important;
border: none !important;
font-size: 16px !important;
line-height: 1.4;
color: var(--text) !important;
}
.recognition-employee {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(65, 129, 255, 0.1);
border-radius: 0.25rem;
border-left: 3px solid var(--primary);
}
.employee-icon {
font-size: 18px;
}
.employee-name {
font-weight: 600;
color: var(--text);
font-size: 15px;
}
</style>

View File

@@ -1,6 +1,7 @@
<template>
<div class="login-container">
<div class="login-box">
<img src="/ge-aerospace-logo.svg" alt="GE Aerospace" class="login-logo" />
<h1>ShopDB</h1>
<div v-if="error" class="error-message">

View File

@@ -0,0 +1,407 @@
<template>
<div class="map-editor">
<div class="page-header">
<h2>Map Editor</h2>
<div class="header-actions">
<router-link to="/map" class="btn btn-secondary">Back to Map</router-link>
</div>
</div>
<div class="editor-layout">
<!-- Asset List Panel -->
<div class="asset-panel">
<div class="panel-header">
<h3>Assets</h3>
<select v-model="filterType" class="form-control">
<option value="">All Types</option>
<option value="equipment">Equipment</option>
<option value="computer">Computers</option>
<option value="printer">Printers</option>
<option value="network_device">Network Devices</option>
</select>
</div>
<div class="asset-filter">
<label class="filter-checkbox">
<input type="checkbox" v-model="showUnplacedOnly" />
Show unplaced only
</label>
</div>
<div class="asset-list">
<div
v-for="asset in filteredAssets"
:key="asset.assetid"
class="asset-item"
:class="{
selected: selectedAsset?.assetid === asset.assetid,
placed: asset.mapleft && asset.maptop,
unplaced: !asset.mapleft || !asset.maptop
}"
@click="selectAsset(asset)"
>
<span class="asset-icon">{{ getTypeIcon(asset.assettype) }}</span>
<div class="asset-info">
<div class="asset-name">{{ asset.name || asset.assetnumber }}</div>
<div class="asset-meta">
<span class="badge badge-sm">{{ asset.assettype }}</span>
<span v-if="asset.mapleft && asset.maptop" class="placed-indicator" title="Placed on map">📍</span>
</div>
</div>
</div>
<div v-if="filteredAssets.length === 0" class="empty">
No assets found.
</div>
</div>
</div>
<!-- Map Panel -->
<div class="map-panel">
<div class="map-toolbar" v-if="selectedAsset">
<span class="selected-info">
<strong>Selected:</strong> {{ selectedAsset.name || selectedAsset.assetnumber }}
</span>
<span class="position-info" v-if="pickedPosition">
Position: ({{ Math.round(pickedPosition.left) }}, {{ Math.round(pickedPosition.top) }})
</span>
<div class="toolbar-actions">
<button
class="btn btn-primary"
:disabled="!pickedPosition"
@click="savePosition"
>
Save Position
</button>
<button
class="btn btn-danger"
v-if="selectedAsset.mapleft && selectedAsset.maptop"
@click="clearPosition"
>
Remove from Map
</button>
<button class="btn btn-secondary" @click="cancelEdit">Cancel</button>
</div>
</div>
<div class="map-toolbar" v-else>
<span class="instruction">Select an asset from the list to place it on the map</span>
</div>
<ShopFloorMap
ref="mapRef"
:machines="placedAssets"
:assetTypeMode="true"
:theme="currentTheme"
:pickerMode="!!selectedAsset"
:initialPosition="selectedAsset ? { left: selectedAsset.mapleft, top: selectedAsset.maptop } : null"
@positionPicked="handlePositionPicked"
@markerClick="handleMarkerClick"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
const assets = ref([])
const loading = ref(true)
const selectedAsset = ref(null)
const pickedPosition = ref(null)
const filterType = ref('')
const showUnplacedOnly = ref(false)
const filteredAssets = computed(() => {
let result = assets.value
if (filterType.value) {
result = result.filter(a => a.assettype === filterType.value)
}
if (showUnplacedOnly.value) {
result = result.filter(a => !a.mapleft || !a.maptop)
}
return result
})
const placedAssets = computed(() => {
return assets.value.filter(a => a.mapleft && a.maptop)
})
onMounted(async () => {
await loadAssets()
})
async function loadAssets() {
loading.value = true
try {
const response = await assetsApi.getMap()
assets.value = response.data.data?.assets || []
} catch (error) {
console.error('Failed to load assets:', error)
} finally {
loading.value = false
}
}
function getTypeIcon(assettype) {
const icons = {
'equipment': '⚙',
'computer': '💻',
'printer': '🖨',
'network_device': '🌐'
}
return icons[assettype] || '📦'
}
function selectAsset(asset) {
selectedAsset.value = asset
pickedPosition.value = asset.mapleft && asset.maptop
? { left: asset.mapleft, top: asset.maptop }
: null
}
function handlePositionPicked(position) {
pickedPosition.value = position
}
function handleMarkerClick(asset) {
selectAsset(asset)
}
async function savePosition() {
if (!selectedAsset.value || !pickedPosition.value) return
try {
await assetsApi.update(selectedAsset.value.assetid, {
mapleft: Math.round(pickedPosition.value.left),
maptop: Math.round(pickedPosition.value.top)
})
// Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) {
asset.mapleft = Math.round(pickedPosition.value.left)
asset.maptop = Math.round(pickedPosition.value.top)
}
selectedAsset.value = null
pickedPosition.value = null
} catch (error) {
console.error('Failed to save position:', error)
alert('Failed to save position')
}
}
async function clearPosition() {
if (!selectedAsset.value) return
if (!confirm(`Remove ${selectedAsset.value.name || selectedAsset.value.assetnumber} from the map?`)) return
try {
await assetsApi.update(selectedAsset.value.assetid, {
mapleft: null,
maptop: null
})
// Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) {
asset.mapleft = null
asset.maptop = null
}
selectedAsset.value = null
pickedPosition.value = null
} catch (error) {
console.error('Failed to clear position:', error)
alert('Failed to clear position')
}
}
function cancelEdit() {
selectedAsset.value = null
pickedPosition.value = null
}
</script>
<style scoped>
.map-editor {
display: flex;
flex-direction: column;
height: calc(100vh - 40px);
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.editor-layout {
display: flex;
flex: 1;
gap: 1rem;
min-height: 0;
}
.asset-panel {
width: 320px;
display: flex;
flex-direction: column;
background: var(--bg-card);
border-radius: 8px;
border: 1px solid var(--border);
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.panel-header h3 {
margin: 0;
font-size: 1rem;
}
.panel-header select {
width: auto;
padding: 0.375rem 0.5rem;
font-size: 0.8rem;
}
.asset-filter {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
.asset-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.asset-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.asset-item:hover {
background: var(--bg);
}
.asset-item.selected {
background: rgba(65, 129, 255, 0.2);
border: 1px solid var(--primary);
}
.asset-item.unplaced {
opacity: 0.7;
}
.asset-icon {
font-size: 1.5rem;
}
.asset-info {
flex: 1;
min-width: 0;
}
.asset-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.asset-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.badge-sm {
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
}
.placed-indicator {
font-size: 0.875rem;
}
.map-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.map-toolbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border-radius: 8px;
border: 1px solid var(--border);
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.selected-info {
font-size: 0.9rem;
}
.position-info {
font-size: 0.875rem;
color: var(--text-light);
font-family: monospace;
}
.toolbar-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.instruction {
color: var(--text-light);
font-style: italic;
}
.map-panel :deep(.shopfloor-map) {
flex: 1;
border-radius: 8px;
overflow: hidden;
}
.empty {
padding: 2rem;
text-align: center;
color: var(--text-light);
}
</style>

View File

@@ -2,47 +2,76 @@
<div class="map-page">
<div class="page-header">
<h2>Shop Floor Map</h2>
<router-link v-if="authStore.isAuthenticated" to="/map/editor" class="btn btn-primary">
Edit Map
</router-link>
</div>
<div v-if="loading" class="loading">Loading...</div>
<ShopFloorMap
v-else
:machines="machines"
:machinetypes="machinetypes"
:businessunits="businessunits"
:statuses="statuses"
@markerClick="handleMarkerClick"
/>
<template v-else>
<!-- Layer Toggles -->
<div class="layer-toggles">
<label v-for="t in assetTypes" :key="t.assettypeid" class="layer-toggle">
<input
type="checkbox"
v-model="visibleTypes"
:value="t.assettype"
@change="updateMapLayers"
/>
<span class="layer-icon">{{ getTypeIcon(t.assettype) }}</span>
<span>{{ t.assettype }}</span>
<span class="layer-count">({{ getTypeCount(t.assettype) }})</span>
</label>
</div>
<ShopFloorMap
:machines="filteredAssets"
:machinetypes="[]"
:businessunits="businessunits"
:statuses="statuses"
:assetTypeMode="true"
:theme="currentTheme"
@markerClick="handleMarkerClick"
/>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import ShopFloorMap from '../components/ShopFloorMap.vue'
import { machinesApi, machinetypesApi, businessunitsApi, statusesApi } from '../api'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(true)
const machines = ref([])
const machinetypes = ref([])
const assets = ref([])
const assetTypes = ref([])
const businessunits = ref([])
const statuses = ref([])
const visibleTypes = ref([])
const filteredAssets = computed(() => {
if (visibleTypes.value.length === 0) return assets.value
return assets.value.filter(a => visibleTypes.value.includes(a.assettype))
})
onMounted(async () => {
try {
const [machinesRes, typesRes, busRes, statusRes] = await Promise.all([
machinesApi.list({ hasmap: true, all: true }),
machinetypesApi.list(),
businessunitsApi.list(),
statusesApi.list()
])
const response = await assetsApi.getMap()
const data = response.data.data || {}
machines.value = machinesRes.data.data || []
machinetypes.value = typesRes.data.data || []
businessunits.value = busRes.data.data || []
statuses.value = statusRes.data.data || []
assets.value = data.assets || []
assetTypes.value = data.filters?.assettypes || []
businessunits.value = data.filters?.businessunits || []
statuses.value = data.filters?.statuses || []
// Default: show all types
visibleTypes.value = assetTypes.value.map(t => t.assettype)
} catch (error) {
console.error('Failed to load map data:', error)
} finally {
@@ -50,15 +79,43 @@ onMounted(async () => {
}
})
function handleMarkerClick(machine) {
const category = machine.category?.toLowerCase() || ''
function getTypeIcon(assettype) {
const icons = {
'equipment': '⚙',
'computer': '💻',
'printer': '🖨',
'network_device': '🌐'
}
return icons[assettype] || '📦'
}
function getTypeCount(assettype) {
return assets.value.filter(a => a.assettype === assettype).length
}
function updateMapLayers() {
// Filter is reactive via computed property
}
function handleMarkerClick(asset) {
// Route based on asset type
const routeMap = {
'equipment': '/machines',
'pc': '/pcs',
'printer': '/printers'
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network'
}
const basePath = routeMap[asset.assettype] || '/machines'
// For network devices, use the networkdeviceid from typedata
if (asset.assettype === 'network_device' && asset.typedata?.networkdeviceid) {
router.push(`/network/${asset.typedata.networkdeviceid}`)
} else {
// For machines (equipment, computer, printer), use machineid from typedata
const id = asset.typedata?.machineid || asset.assetid
router.push(`${basePath}/${id}`)
}
const basePath = routeMap[category] || '/machines'
router.push(`${basePath}/${machine.machineid}`)
}
</script>
@@ -73,6 +130,45 @@ function handleMarkerClick(machine) {
flex-shrink: 0;
}
.layer-toggles {
display: flex;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.layer-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.5rem 0.75rem;
border-radius: 6px;
transition: background 0.2s;
}
.layer-toggle:hover {
background: var(--bg);
}
.layer-toggle input[type="checkbox"] {
width: 1.125rem;
height: 1.125rem;
}
.layer-icon {
font-size: 1.25rem;
}
.layer-count {
color: var(--text-light);
font-size: 0.875rem;
}
.map-page :deep(.shopfloor-map) {
flex: 1;
}

View File

@@ -0,0 +1,531 @@
<template>
<div class="shopfloor-dashboard">
<header class="dashboard-header">
<div class="logo-container">
<img src="/ge-aerospace-logo.svg" alt="GE Aerospace" class="logo" />
</div>
<div class="header-center">
<div class="location-title">West Jefferson</div>
<h1>Shopfloor Dashboard</h1>
</div>
<div class="header-right">
<div class="clock">{{ currentTime }}</div>
<select v-model="businessUnit" class="filter-select" @change="loadData">
<option value="">All Business Units</option>
<option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
{{ bu.businessunit }}
</option>
</select>
</div>
</header>
<main class="dashboard-content">
<!-- Recognition Carousel -->
<section v-if="recognitions.length" class="recognition-section">
<div class="section-title recognition">Employee Recognition</div>
<div class="recognition-carousel">
<div
v-for="(rec, idx) in recognitions"
:key="`${rec.notificationid}-${rec.employeesso}`"
class="recognition-card"
:class="{ active: idx === currentRecognition }"
>
<div class="recognition-photo-container">
<img
v-if="rec.employeepicture"
:src="`/static/employees/${rec.employeepicture}`"
:alt="rec.employeename"
class="recognition-photo"
@error="handlePhotoError"
/>
<img
v-else
src="/ge-aerospace-logo.svg"
alt="GE Aerospace"
class="recognition-photo ge-logo-fallback"
/>
</div>
<div class="recognition-content">
<div class="recognition-header">
<span class="recognition-star">&#9733;</span>
<div class="recognition-name">{{ rec.employeename }}</div>
</div>
<div class="recognition-message">{{ rec.notification }}</div>
</div>
</div>
</div>
</section>
<!-- Current Notifications -->
<section v-if="currentNotifications.length" class="notifications-section">
<div class="section-title" :class="getSectionClass(currentNotifications)">
Current Notifications
</div>
<div class="events-list">
<div
v-for="n in currentNotifications"
:key="n.notificationid"
class="event-card"
:class="{ resolved: n.resolved }"
>
<div class="event-indicator" :style="{ backgroundColor: getTypeColor(n.typecolor) }"></div>
<div class="event-content">
<div class="event-title">{{ n.notification }}</div>
<div class="event-time">
<template v-if="n.resolved">
<strong>RESOLVED</strong>
</template>
<template v-else>
<strong>{{ formatTime(n.starttime) }}</strong>
<span v-if="n.endtime"> - {{ formatTime(n.endtime) }}</span>
</template>
</div>
</div>
<a v-if="n.ticketnumber" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
{{ n.ticketnumber }}
</a>
</div>
</div>
</section>
<!-- Upcoming Notifications -->
<section v-if="upcomingNotifications.length" class="notifications-section">
<div class="section-title upcoming">Upcoming</div>
<div class="events-list">
<div
v-for="n in upcomingNotifications"
:key="n.notificationid"
class="event-card"
>
<div class="event-indicator" :style="{ backgroundColor: getTypeColor(n.typecolor) }"></div>
<div class="event-content">
<div class="event-title">{{ n.notification }}</div>
<div class="event-time">
<strong>{{ formatDateTime(n.starttime) }}</strong>
</div>
</div>
<a v-if="n.ticketnumber" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
{{ n.ticketnumber }}
</a>
</div>
</div>
</section>
<!-- No notifications -->
<div v-if="!loading && !currentNotifications.length && !upcomingNotifications.length && !recognitions.length" class="no-events">
No active notifications
</div>
<div v-if="loading" class="loading">Loading...</div>
</main>
<footer class="dashboard-footer">
Auto-refreshes every 30 seconds
</footer>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { notificationsApi, businessUnitsApi } from '@/api'
const loading = ref(true)
const businessUnit = ref('')
const businessUnits = ref([])
const notifications = ref({ current: [], upcoming: [] })
const currentRecognition = ref(0)
let refreshInterval = null
let recognitionInterval = null
// Separate recognition notifications from others
const recognitions = computed(() =>
notifications.value.current.filter(n => n.typecolor === 'recognition')
)
const currentNotifications = computed(() =>
notifications.value.current.filter(n => n.typecolor !== 'recognition')
)
const upcomingNotifications = computed(() =>
notifications.value.upcoming.filter(n => n.typecolor !== 'recognition')
)
// Clock
const currentTime = ref('')
function updateClock() {
const now = new Date()
currentTime.value = now.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
onMounted(async () => {
updateClock()
setInterval(updateClock, 1000)
// Load business units
try {
const res = await businessUnitsApi.list()
businessUnits.value = res.data.data || []
} catch (err) {
console.error('Error loading business units:', err)
}
await loadData()
// Auto-refresh every 30 seconds
refreshInterval = setInterval(loadData, 30000)
// Rotate recognition carousel every 8 seconds
recognitionInterval = setInterval(() => {
if (recognitions.value.length > 1) {
currentRecognition.value = (currentRecognition.value + 1) % recognitions.value.length
}
}, 8000)
})
onUnmounted(() => {
if (refreshInterval) clearInterval(refreshInterval)
if (recognitionInterval) clearInterval(recognitionInterval)
})
async function loadData() {
try {
const params = {}
if (businessUnit.value) {
params.businessunit = businessUnit.value
}
const res = await notificationsApi.getShopfloor(params)
notifications.value = res.data.data || { current: [], upcoming: [] }
} catch (err) {
console.error('Error loading shopfloor data:', err)
} finally {
loading.value = false
}
}
function getTypeColor(typecolor) {
const colors = {
success: '#04b962',
warning: '#ff8800',
danger: '#f5365c',
info: '#14abef',
primary: '#7934f3',
recognition: '#0d6efd'
}
return colors[typecolor] || typecolor || '#14abef'
}
function getSectionClass(notifications) {
// Use danger color if any active incidents
const hasIncident = notifications.some(n => n.typecolor === 'danger' && !n.resolved)
return hasIncident ? 'danger' : ''
}
function formatTime(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})
}
function formatDateTime(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function getTicketUrl(ticketnumber) {
if (!ticketnumber) return '#'
// ServiceNow ticket URLs
if (ticketnumber.startsWith('GEINC')) {
return `https://ge.service-now.com/nav_to.do?uri=incident.do?sysparm_query=number=${ticketnumber}`
}
if (ticketnumber.startsWith('GECHG')) {
return `https://ge.service-now.com/nav_to.do?uri=change_request.do?sysparm_query=number=${ticketnumber}`
}
return '#'
}
function handlePhotoError(e) {
e.target.src = '/ge-aerospace-logo.svg'
e.target.classList.add('ge-logo-fallback')
}
</script>
<style scoped>
.shopfloor-dashboard {
min-height: 100vh;
background: #00003d;
color: #fff;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
overflow: hidden;
}
.dashboard-header {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 30px;
padding: 20px 40px;
border-bottom: 3px solid #4181ff;
}
.logo {
height: 90px;
width: auto;
}
.header-center {
text-align: center;
}
.location-title {
font-size: 18px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 2px;
}
.header-center h1 {
font-size: 28px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 2px;
margin: 0;
}
.header-right {
text-align: right;
}
.clock {
font-size: 28px;
font-weight: 600;
color: #4181ff;
font-variant-numeric: tabular-nums;
margin-bottom: 10px;
}
.filter-select {
padding: 10px 16px;
font-size: 16px;
font-weight: 600;
background: #1a1a5e;
color: #fff;
border: 2px solid #4181ff;
border-radius: 6px;
cursor: pointer;
min-width: 200px;
}
.dashboard-content {
padding: 20px 40px;
max-height: calc(100vh - 180px);
overflow-y: auto;
}
.section-title {
font-size: 22px;
font-weight: 700;
padding: 12px 20px;
border-radius: 8px;
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 15px;
background: #4181ff;
}
.section-title.recognition {
background: #0d6efd;
}
.section-title.danger {
background: #f5365c;
}
.section-title.upcoming {
background: #6c757d;
}
/* Recognition carousel */
.recognition-section {
margin-bottom: 25px;
}
.recognition-carousel {
position: relative;
min-height: 170px;
}
.recognition-card {
position: absolute;
top: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%);
border: 3px solid #0d6efd;
border-radius: 12px;
padding: 20px 25px;
display: flex;
align-items: center;
gap: 25px;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.8s ease, transform 0.8s ease;
}
.recognition-card.active {
opacity: 1;
transform: translateY(0);
position: relative;
}
.recognition-photo {
width: 140px;
height: 140px;
border-radius: 50%;
object-fit: cover;
border: 4px solid #0d6efd;
background: #1a1a2e;
}
.recognition-photo.ge-logo-fallback {
object-fit: contain;
padding: 20px;
background: #fff;
}
.recognition-content {
flex: 1;
}
.recognition-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.recognition-star {
font-size: 48px;
color: #ffc107;
margin-right: 20px;
animation: starPulse 2s ease-in-out infinite;
}
@keyframes starPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.recognition-name {
font-size: 36px;
font-weight: 700;
}
.recognition-message {
font-size: 24px;
color: #ccc;
line-height: 1.4;
}
/* Event cards */
.notifications-section {
margin-bottom: 25px;
}
.events-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.event-card {
display: flex;
align-items: center;
background: #fff;
color: #00003d;
border-radius: 8px;
padding: 15px 20px;
gap: 15px;
}
.event-card.resolved {
opacity: 0.6;
background: #e9ecef;
}
.event-indicator {
width: 8px;
height: 60px;
border-radius: 4px;
flex-shrink: 0;
}
.event-content {
flex: 1;
}
.event-title {
font-size: 24px;
font-weight: 700;
line-height: 1.3;
}
.event-time {
font-size: 18px;
color: #666;
margin-top: 5px;
}
.event-time strong {
color: #00003d;
}
.event-ticket {
font-size: 18px;
font-weight: 700;
background: #00003d;
color: #fff;
padding: 8px 16px;
border-radius: 6px;
text-decoration: none;
text-transform: uppercase;
}
.event-ticket:hover {
background: #4181ff;
}
.no-events, .loading {
text-align: center;
font-size: 28px;
font-weight: 600;
padding: 40px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.dashboard-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.8);
text-align: center;
padding: 12px;
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,179 @@
<template>
<div class="tv-dashboard">
<div class="slideshow-container">
<div
v-for="(slide, idx) in slides"
:key="slide.filename"
class="slide"
:class="{ active: idx === currentSlide }"
>
<img :src="basePath + slide.filename" :alt="slide.filename" />
</div>
<div v-if="error" class="error-message">
<h2>Display Error</h2>
<p>{{ error }}</p>
</div>
<div v-if="!slides.length && !error" class="error-message">
<h2>No Slides</h2>
<p>No slides configured for display</p>
</div>
</div>
<div class="progress-bar" :style="progressStyle"></div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import api from '@/api'
const INTERVAL = 10 // seconds between slides
const slides = ref([])
const basePath = ref('/static/slides/')
const currentSlide = ref(0)
const error = ref('')
const progress = ref(0)
let slideTimer = null
let progressTimer = null
let refreshTimer = null
const progressStyle = computed(() => ({
width: `${progress.value}%`,
transition: progress.value === 0 ? 'none' : `width ${INTERVAL}s linear`
}))
onMounted(async () => {
await fetchSlides()
// Start slideshow if we have slides
if (slides.value.length > 1) {
startSlideshow()
}
// Refresh slide list every 60 seconds
refreshTimer = setInterval(fetchSlides, 60000)
})
onUnmounted(() => {
if (slideTimer) clearTimeout(slideTimer)
if (progressTimer) clearTimeout(progressTimer)
if (refreshTimer) clearInterval(refreshTimer)
})
async function fetchSlides() {
try {
const response = await api.get('/slides')
const data = response.data
if (data.success && data.data?.slides?.length > 0) {
slides.value = data.data.slides
basePath.value = data.data.basepath || '/static/slides/'
error.value = ''
// Restart slideshow if slides changed
if (slides.value.length > 1 && !slideTimer) {
startSlideshow()
}
} else {
error.value = data.message || 'No slides found'
}
} catch (err) {
console.error('Error fetching slides:', err)
error.value = 'Unable to load slides'
}
}
function startSlideshow() {
scheduleNextSlide()
}
function scheduleNextSlide() {
// Reset progress bar
progress.value = 0
// Start progress animation after a small delay
progressTimer = setTimeout(() => {
progress.value = 100
}, 50)
// Schedule next slide
slideTimer = setTimeout(() => {
nextSlide()
scheduleNextSlide()
}, INTERVAL * 1000)
}
function nextSlide() {
if (slides.value.length === 0) return
currentSlide.value = (currentSlide.value + 1) % slides.value.length
}
</script>
<style scoped>
.tv-dashboard {
background: #000;
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
overflow: hidden;
}
.slideshow-container {
position: relative;
width: 100%;
height: 100%;
}
.slide {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 1s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
}
.slide.active {
opacity: 1;
}
.slide img {
width: 100%;
height: 100%;
object-fit: contain;
}
.progress-bar {
position: fixed;
bottom: 0;
left: 0;
height: 4px;
background: #4181ff;
z-index: 100;
}
.error-message {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 24px;
}
.error-message h2 {
color: #ff4444;
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,320 @@
<template>
<div class="detail-page">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error-message">{{ error }}</div>
<template v-else-if="employee">
<div class="hero-card">
<div class="hero-image" v-if="employee.Picture">
<img :src="employee.Picture" :alt="fullName" />
</div>
<div class="hero-image placeholder" v-else>
<span class="initials">{{ initials }}</span>
</div>
<div class="hero-content">
<h1 class="hero-title">{{ fullName }}</h1>
<div class="hero-meta">
<span class="badge">SSO: {{ employee.SSO }}</span>
<span v-if="employee.Team" class="badge badge-primary">{{ employee.Team }}</span>
</div>
<div class="hero-details">
<p v-if="employee.Role"><strong>Role:</strong> {{ employee.Role }}</p>
</div>
<div class="hero-actions">
<router-link to="/" class="btn">Back to Dashboard</router-link>
</div>
</div>
</div>
<!-- Recognitions -->
<div class="section-card">
<h2 class="section-title">
Recognitions
<span v-if="recognitions.length > 0" class="count-badge">{{ recognitions.length }}</span>
</h2>
<div v-if="recognitionsLoading" class="loading">Loading...</div>
<div v-else-if="recognitions.length === 0" class="empty">
No recognitions yet.
</div>
<div v-else class="recognitions-list">
<div v-for="rec in displayedRecognitions" :key="rec.notificationid" class="recognition-card">
<div class="recognition-header">
<span class="badge" :style="{ backgroundColor: rec.typecolor || '#14abef' }">
{{ rec.typename || 'Recognition' }}
</span>
<span class="recognition-date">{{ formatDate(rec.startdate) }}</span>
</div>
<div class="recognition-message">{{ rec.notification }}</div>
</div>
<button
v-if="recognitions.length > recognitionsLimit && !showAllRecognitions"
class="btn btn-secondary show-more-btn"
@click="showAllRecognitions = true"
>
Show {{ recognitions.length - recognitionsLimit }} more
</button>
<button
v-if="showAllRecognitions && recognitions.length > recognitionsLimit"
class="btn btn-secondary show-more-btn"
@click="showAllRecognitions = false"
>
Show less
</button>
</div>
</div>
<!-- Currently Checked Out USB Devices -->
<div class="section-card">
<h2 class="section-title">Checked Out USB Devices</h2>
<div v-if="usbLoading" class="loading">Loading...</div>
<div v-else-if="usbDevices.length === 0" class="empty">
No USB devices currently checked out.
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Device</th>
<th>Serial Number</th>
<th>Checked Out</th>
<th>Purpose</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="device in usbDevices" :key="device.usbdeviceid">
<td>
<router-link :to="`/usb/${device.usbdeviceid}`">
{{ device.displayname }}
</router-link>
</td>
<td>{{ device.serialnumber }}</td>
<td>{{ formatDate(device.checkoutdate) }}</td>
<td>{{ device.checkoutpurpose || '-' }}</td>
<td class="actions">
<button class="btn btn-small btn-success" @click="checkinDevice(device)">
Check In
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- USB Checkout History -->
<div class="section-card">
<h2 class="section-title">USB Checkout History</h2>
<div v-if="historyLoading" class="loading">Loading...</div>
<div v-else-if="checkoutHistory.length === 0" class="empty">
No checkout history.
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Device</th>
<th>Checked Out</th>
<th>Checked In</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr v-for="record in checkoutHistory" :key="record.usbcheckoutid">
<td>
<router-link :to="`/usb/${record.usbdeviceid}`">
{{ record.devicename || `Device #${record.usbdeviceid}` }}
</router-link>
</td>
<td>{{ formatDate(record.checkoutdate) }}</td>
<td>{{ record.checkindate ? formatDate(record.checkindate) : 'Still out' }}</td>
<td>{{ record.purpose || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { employeesApi, usbApi, notificationsApi } from '@/api'
const route = useRoute()
const employee = ref(null)
const recognitions = ref([])
const usbDevices = ref([])
const checkoutHistory = ref([])
const loading = ref(true)
const recognitionsLoading = ref(true)
const usbLoading = ref(true)
const historyLoading = ref(true)
const error = ref('')
const recognitionsLimit = 5
const showAllRecognitions = ref(false)
const displayedRecognitions = computed(() => {
if (showAllRecognitions.value) {
return recognitions.value
}
return recognitions.value.slice(0, recognitionsLimit)
})
const fullName = computed(() => {
if (!employee.value) return ''
return `${employee.value.First_Name?.trim() || ''} ${employee.value.Last_Name?.trim() || ''}`.trim()
})
const initials = computed(() => {
if (!employee.value) return '?'
const first = employee.value.First_Name?.trim()?.[0] || ''
const last = employee.value.Last_Name?.trim()?.[0] || ''
return (first + last).toUpperCase() || '?'
})
onMounted(async () => {
await loadEmployee()
await Promise.all([loadRecognitions(), loadUSBDevices(), loadCheckoutHistory()])
})
async function loadEmployee() {
loading.value = true
try {
const response = await employeesApi.lookup(route.params.sso)
employee.value = response.data.data
} catch (err) {
console.error('Error loading employee:', err)
error.value = 'Employee not found'
} finally {
loading.value = false
}
}
async function loadRecognitions() {
recognitionsLoading.value = true
try {
const response = await notificationsApi.getEmployeeRecognitions(route.params.sso)
recognitions.value = response.data.data?.recognitions || []
} catch (err) {
console.error('Error loading recognitions:', err)
recognitions.value = []
} finally {
recognitionsLoading.value = false
}
}
async function loadUSBDevices() {
usbLoading.value = true
try {
const response = await usbApi.getUserCheckouts(route.params.sso)
usbDevices.value = response.data.data || []
} catch (err) {
console.error('Error loading USB devices:', err)
} finally {
usbLoading.value = false
}
}
async function loadCheckoutHistory() {
historyLoading.value = true
try {
// Get user's checkout history (all past checkouts)
const response = await usbApi.list({ user_id: route.params.sso, include_history: true })
// Filter to only show returned items (not currently checked out)
checkoutHistory.value = (response.data.data || []).filter(d => d.checkindate)
} catch (err) {
console.error('Error loading checkout history:', err)
checkoutHistory.value = []
} finally {
historyLoading.value = false
}
}
async function checkinDevice(device) {
if (!confirm(`Check in ${device.displayname}?`)) return
try {
await usbApi.checkin(device.usbdeviceid)
await loadUSBDevices()
await loadCheckoutHistory()
} catch (err) {
console.error('Error checking in device:', err)
alert('Failed to check in device')
}
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString()
}
</script>
<style scoped>
.hero-image.placeholder {
display: flex;
align-items: center;
justify-content: center;
background: var(--primary);
color: white;
}
.initials {
font-size: 3rem;
font-weight: 600;
}
.recognitions-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.recognition-card {
padding: 1rem;
background: var(--bg);
border-radius: 8px;
border-left: 4px solid var(--primary);
}
.recognition-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.recognition-date {
color: var(--text-light);
font-size: 0.875rem;
}
.recognition-message {
white-space: pre-wrap;
line-height: 1.5;
}
.count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.5rem;
padding: 0 0.5rem;
margin-left: 0.5rem;
background: var(--primary);
color: white;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.show-more-btn {
width: 100%;
margin-top: 0.5rem;
}
</style>

View File

@@ -157,12 +157,20 @@ async function openArticle() {
.info-table td a {
color: var(--primary, #1976d2);
text-decoration: none;
word-break: break-all;
overflow-wrap: break-word;
}
.info-table td a:hover {
text-decoration: underline;
}
.info-table td {
word-break: break-word;
overflow-wrap: break-word;
max-width: 500px;
}
hr {
margin: 1.5rem 0;
border: none;

View File

@@ -239,6 +239,7 @@
<ShopFloorMap
:pickerMode="true"
:initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null"
:theme="currentTheme"
@positionPicked="handlePositionPicked"
/>
</div>
@@ -267,6 +268,7 @@ import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, relationshipTypesApi, modelsApi, businessunitsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme'
const route = useRoute()
const router = useRouter()

View File

@@ -0,0 +1,348 @@
<template>
<div class="detail-page" v-if="device">
<div class="hero-card">
<div class="hero-image">
<div class="device-icon">
<span class="icon">{{ getDeviceIcon() }}</span>
</div>
</div>
<div class="hero-content">
<div class="hero-title-row">
<h1 class="hero-title">{{ device.network_device?.hostname || device.name || device.assetnumber }}</h1>
<router-link
v-if="authStore.isAuthenticated"
:to="`/network/${deviceId}/edit`"
class="btn btn-secondary"
>
Edit
</router-link>
</div>
<div class="hero-meta">
<span class="badge" :class="getStatusClass(device.status_name)">
{{ device.status_name || 'Unknown' }}
</span>
<span v-if="device.network_device?.networkdevicetype_name" class="meta-item">
{{ device.network_device.networkdevicetype_name }}
</span>
<span v-if="device.network_device?.vendor_name" class="meta-item">
{{ device.network_device.vendor_name }}
</span>
</div>
<div class="hero-details">
<div class="detail-item" v-if="device.assetnumber">
<span class="label">Asset #</span>
<span class="value">{{ device.assetnumber }}</span>
</div>
<div class="detail-item" v-if="device.serialnumber">
<span class="label">Serial</span>
<span class="value mono">{{ device.serialnumber }}</span>
</div>
<div class="detail-item" v-if="device.location_name">
<span class="label">Location</span>
<span class="value">{{ device.location_name }}</span>
</div>
<div class="detail-item" v-if="device.businessunit_name">
<span class="label">Business Unit</span>
<span class="value">{{ device.businessunit_name }}</span>
</div>
</div>
<div class="hero-features" v-if="device.network_device">
<span v-if="device.network_device.ispoe" class="feature-badge poe">PoE</span>
<span v-if="device.network_device.ismanaged" class="feature-badge managed">Managed</span>
<span v-if="device.network_device.portcount" class="feature-badge ports">{{ device.network_device.portcount }} Ports</span>
</div>
</div>
</div>
<div class="content-grid">
<div class="content-column">
<!-- Network Info -->
<div class="section-card">
<h3 class="section-title">Network Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Hostname</span>
<span class="info-value mono">{{ device.network_device?.hostname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Firmware Version</span>
<span class="info-value">{{ device.network_device?.firmwareversion || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Port Count</span>
<span class="info-value">{{ device.network_device?.portcount || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Rack Unit</span>
<span class="info-value">{{ device.network_device?.rackunit || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">PoE Capable</span>
<span class="info-value">{{ device.network_device?.ispoe ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">Managed Device</span>
<span class="info-value">{{ device.network_device?.ismanaged ? 'Yes' : 'No' }}</span>
</div>
</div>
</div>
<!-- Notes -->
<div class="section-card" v-if="device.notes">
<h3 class="section-title">Notes</h3>
<div class="notes-content">{{ device.notes }}</div>
</div>
</div>
<div class="content-column">
<!-- Asset Info -->
<div class="section-card">
<h3 class="section-title">Asset Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value">{{ device.assetnumber }}</span>
</div>
<div class="info-row">
<span class="info-label">Name</span>
<span class="info-value">{{ device.name || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ device.serialnumber || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ device.network_device?.vendor_name || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Device Type</span>
<span class="info-value">{{ device.network_device?.networkdevicetype_name || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Status</span>
<span class="info-value">
<span class="badge" :class="getStatusClass(device.status_name)">
{{ device.status_name || 'Unknown' }}
</span>
</span>
</div>
</div>
</div>
<!-- Relationships -->
<AssetRelationships
v-if="device.assetid"
:assetId="device.assetid"
/>
<!-- Audit Info -->
<div class="section-card audit-card">
<h3 class="section-title">Record Info</h3>
<div class="info-list">
<div class="info-row" v-if="device.datecreated">
<span class="info-label">Created</span>
<span class="info-value">{{ formatDate(device.datecreated) }}</span>
</div>
<div class="info-row" v-if="device.datemodified">
<span class="info-label">Last Modified</span>
<span class="info-value">{{ formatDate(device.datemodified) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="action-bar" v-if="authStore.isAuthenticated">
<router-link :to="`/network/${deviceId}/edit`" class="btn btn-primary">
Edit Device
</router-link>
<button @click="confirmDelete" class="btn btn-danger">
Delete Device
</button>
</div>
</div>
<div v-else-if="loading" class="loading-container">
<div class="loading">Loading device...</div>
</div>
<div v-else class="error-container">
<p>Device not found</p>
<router-link to="/network" class="btn btn-secondary">Back to Network Devices</router-link>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { networkApi } from '../../api'
import AssetRelationships from '../../components/AssetRelationships.vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const deviceId = route.params.id
const device = ref(null)
const loading = ref(true)
onMounted(async () => {
await loadDevice()
})
async function loadDevice() {
loading.value = true
try {
const response = await networkApi.get(deviceId)
device.value = response.data.data
} catch (error) {
console.error('Error loading device:', error)
device.value = null
} finally {
loading.value = false
}
}
function getDeviceIcon() {
const type = device.value?.network_device?.networkdevicetype_name?.toLowerCase() || ''
if (type.includes('switch')) return '⏛'
if (type.includes('router')) return '⇌'
if (type.includes('firewall')) return '🛡'
if (type.includes('access point') || type.includes('ap')) return '📶'
if (type.includes('camera')) return '📷'
if (type.includes('server')) return '🖥'
if (type.includes('idf') || type.includes('closet')) return '🗄'
return '🌐'
}
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair' || s === 'maintenance') return 'badge-warning'
if (s === 'retired' || s === 'decommissioned') return 'badge-danger'
return 'badge-info'
}
function formatDate(dateStr) {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
async function confirmDelete() {
if (confirm(`Are you sure you want to delete ${device.value.network_device?.hostname || device.value.assetnumber}?`)) {
try {
await networkApi.delete(deviceId)
router.push('/network')
} catch (error) {
console.error('Error deleting device:', error)
alert('Failed to delete device')
}
}
}
</script>
<style scoped>
.hero-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.5rem;
}
.hero-title {
margin: 0;
}
.device-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: 8px;
}
.device-icon .icon {
font-size: 4rem;
}
.hero-features {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
flex-wrap: wrap;
}
.feature-badge {
display: inline-block;
padding: 0.35rem 0.75rem;
font-size: 0.875rem;
border-radius: 5px;
font-weight: 500;
}
.feature-badge.poe {
background: #d4edda;
color: #155724;
}
.feature-badge.managed {
background: #e3f2fd;
color: #1565c0;
}
.feature-badge.ports {
background: var(--bg);
color: var(--text-light);
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.notes-content {
white-space: pre-wrap;
color: var(--text);
line-height: 1.6;
}
.action-bar {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.loading-container,
.error-container {
text-align: center;
padding: 3rem;
color: var(--text-light);
}
@media (prefers-color-scheme: dark) {
.feature-badge.poe {
background: #1e3a29;
color: #4ade80;
}
.feature-badge.managed {
background: #1e3a5f;
color: #60a5fa;
}
}
</style>

View File

@@ -0,0 +1,497 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Network Device' : 'Add Network Device' }}</h2>
</div>
<div class="card form-card">
<form @submit.prevent="submitForm">
<!-- Asset Information -->
<fieldset>
<legend>Asset Information</legend>
<div class="form-row">
<div class="form-group">
<label for="assetnumber">Asset Number *</label>
<input
id="assetnumber"
v-model="form.assetnumber"
type="text"
class="form-control"
required
:disabled="isEdit"
/>
</div>
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="serialnumber">Serial Number</label>
<input
id="serialnumber"
v-model="form.serialnumber"
type="text"
class="form-control"
/>
</div>
<div class="form-group">
<label for="statusid">Status</label>
<select id="statusid" v-model="form.statusid" class="form-control">
<option value="">Select Status</option>
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">
{{ s.status }}
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="locationid">Location</label>
<select id="locationid" v-model="form.locationid" class="form-control">
<option value="">Select Location</option>
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
{{ loc.location }}
</option>
</select>
</div>
<div class="form-group">
<label for="businessunitid">Business Unit</label>
<select id="businessunitid" v-model="form.businessunitid" class="form-control">
<option value="">Select Business Unit</option>
<option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
{{ bu.businessunit }}
</option>
</select>
</div>
</div>
</fieldset>
<!-- Network Device Information -->
<fieldset>
<legend>Network Device Details</legend>
<div class="form-row">
<div class="form-group">
<label for="hostname">Hostname</label>
<input
id="hostname"
v-model="form.hostname"
type="text"
class="form-control"
placeholder="e.g., sw-bldg1-floor2"
/>
</div>
<div class="form-group">
<label for="networkdevicetypeid">Device Type</label>
<select id="networkdevicetypeid" v-model="form.networkdevicetypeid" class="form-control">
<option value="">Select Type</option>
<option v-for="t in deviceTypes" :key="t.networkdevicetypeid" :value="t.networkdevicetypeid">
{{ t.networkdevicetype }}
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="vendorid">Vendor</label>
<select id="vendorid" v-model="form.vendorid" class="form-control">
<option value="">Select Vendor</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
</div>
<div class="form-group">
<label for="firmwareversion">Firmware Version</label>
<input
id="firmwareversion"
v-model="form.firmwareversion"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="portcount">Port Count</label>
<input
id="portcount"
v-model.number="form.portcount"
type="number"
class="form-control"
min="0"
/>
</div>
<div class="form-group">
<label for="rackunit">Rack Unit</label>
<input
id="rackunit"
v-model="form.rackunit"
type="text"
class="form-control"
placeholder="e.g., U1, U5-U8"
/>
</div>
</div>
<div class="form-row checkboxes">
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.ispoe" />
<span>PoE Capable</span>
</label>
<small>Device supports Power over Ethernet</small>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.ismanaged" />
<span>Managed Device</span>
</label>
<small>Device has SNMP, web interface, or CLI management</small>
</div>
</div>
</fieldset>
<!-- Map Position (optional) -->
<fieldset>
<legend>Map Position (Optional)</legend>
<div class="form-row">
<div class="form-group">
<label for="mapleft">Map X Position</label>
<input
id="mapleft"
v-model.number="form.mapleft"
type="number"
class="form-control"
min="0"
/>
</div>
<div class="form-group">
<label for="maptop">Map Y Position</label>
<input
id="maptop"
v-model.number="form.maptop"
type="number"
class="form-control"
min="0"
/>
</div>
</div>
</fieldset>
<!-- Notes -->
<fieldset>
<legend>Notes</legend>
<div class="form-group">
<textarea
id="notes"
v-model="form.notes"
class="form-control"
rows="4"
placeholder="Additional notes about this device..."
></textarea>
</div>
</fieldset>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Save Changes' : 'Create Device') }}
</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
networkApi,
vendorsApi,
locationsApi,
statusesApi,
businessunitsApi
} from '../../api'
const route = useRoute()
const router = useRouter()
const deviceId = route.params.id
const isEdit = computed(() => !!deviceId)
const form = ref({
assetnumber: '',
name: '',
serialnumber: '',
statusid: '',
locationid: '',
businessunitid: '',
hostname: '',
networkdevicetypeid: '',
vendorid: '',
firmwareversion: '',
portcount: null,
rackunit: '',
ispoe: false,
ismanaged: false,
mapleft: null,
maptop: null,
notes: ''
})
const deviceTypes = ref([])
const vendors = ref([])
const locations = ref([])
const statuses = ref([])
const businessUnits = ref([])
const saving = ref(false)
const error = ref('')
onMounted(async () => {
await Promise.all([
loadDeviceTypes(),
loadVendors(),
loadLocations(),
loadStatuses(),
loadBusinessUnits()
])
if (isEdit.value) {
await loadDevice()
}
})
async function loadDeviceTypes() {
try {
const response = await networkApi.types.list({ per_page: 100 })
deviceTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading device types:', err)
}
}
async function loadVendors() {
try {
const response = await vendorsApi.list({ per_page: 100 })
vendors.value = response.data.data || []
} catch (err) {
console.error('Error loading vendors:', err)
}
}
async function loadLocations() {
try {
const response = await locationsApi.list({ per_page: 100 })
locations.value = response.data.data || []
} catch (err) {
console.error('Error loading locations:', err)
}
}
async function loadStatuses() {
try {
const response = await statusesApi.list({ per_page: 100 })
statuses.value = response.data.data || []
} catch (err) {
console.error('Error loading statuses:', err)
}
}
async function loadBusinessUnits() {
try {
const response = await businessunitsApi.list({ per_page: 100 })
businessUnits.value = response.data.data || []
} catch (err) {
console.error('Error loading business units:', err)
}
}
async function loadDevice() {
try {
const response = await networkApi.get(deviceId)
const data = response.data.data
// Populate form with existing data
form.value.assetnumber = data.assetnumber || ''
form.value.name = data.name || ''
form.value.serialnumber = data.serialnumber || ''
form.value.statusid = data.statusid || ''
form.value.locationid = data.locationid || ''
form.value.businessunitid = data.businessunitid || ''
form.value.mapleft = data.mapleft
form.value.maptop = data.maptop
form.value.notes = data.notes || ''
// Network device specific
if (data.network_device) {
form.value.hostname = data.network_device.hostname || ''
form.value.networkdevicetypeid = data.network_device.networkdevicetypeid || ''
form.value.vendorid = data.network_device.vendorid || ''
form.value.firmwareversion = data.network_device.firmwareversion || ''
form.value.portcount = data.network_device.portcount
form.value.rackunit = data.network_device.rackunit || ''
form.value.ispoe = data.network_device.ispoe || false
form.value.ismanaged = data.network_device.ismanaged || false
}
} catch (err) {
console.error('Error loading device:', err)
error.value = 'Failed to load device'
}
}
async function submitForm() {
saving.value = true
error.value = ''
try {
const payload = {
assetnumber: form.value.assetnumber,
name: form.value.name || null,
serialnumber: form.value.serialnumber || null,
statusid: form.value.statusid || null,
locationid: form.value.locationid || null,
businessunitid: form.value.businessunitid || null,
hostname: form.value.hostname || null,
networkdevicetypeid: form.value.networkdevicetypeid || null,
vendorid: form.value.vendorid || null,
firmwareversion: form.value.firmwareversion || null,
portcount: form.value.portcount || null,
rackunit: form.value.rackunit || null,
ispoe: form.value.ispoe,
ismanaged: form.value.ismanaged,
mapleft: form.value.mapleft,
maptop: form.value.maptop,
notes: form.value.notes || null
}
if (isEdit.value) {
await networkApi.update(deviceId, payload)
router.push(`/network/${deviceId}`)
} else {
const response = await networkApi.create(payload)
const newId = response.data.data?.network_device?.networkdeviceid
router.push(newId ? `/network/${newId}` : '/network')
}
} catch (err) {
console.error('Error saving device:', err)
error.value = err.response?.data?.message || 'Failed to save device'
} finally {
saving.value = false
}
}
function cancel() {
if (isEdit.value) {
router.push(`/network/${deviceId}`)
} else {
router.push('/network')
}
}
</script>
<style scoped>
.form-card {
max-width: 800px;
}
fieldset {
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
legend {
font-weight: 600;
padding: 0 0.5rem;
color: var(--text);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
margin-bottom: 0.375rem;
font-weight: 500;
color: var(--text);
}
.form-group small {
margin-top: 0.25rem;
color: var(--text-light);
font-size: 0.8rem;
}
.checkboxes {
margin-top: 1rem;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 1.125rem;
height: 1.125rem;
}
.checkbox-group span {
font-weight: 500;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.error-message {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--danger);
color: white;
border-radius: 6px;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,360 @@
<template>
<div>
<div class="page-header">
<h2>Network Devices</h2>
<router-link to="/network/new" class="btn btn-primary">Add Device</router-link>
</div>
<!-- Type Tabs -->
<div class="type-tabs">
<button
:class="{ active: selectedType === null }"
@click="selectType(null)"
>
All ({{ totalCount }})
</button>
<button
v-for="t in deviceTypes"
:key="t.networkdevicetypeid"
:class="{ active: selectedType === t.networkdevicetypeid }"
@click="selectType(t.networkdevicetypeid)"
>
{{ t.networkdevicetype }} ({{ t.count || 0 }})
</button>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search by hostname, asset #, serial..."
@input="debouncedSearch"
/>
<select v-model="vendorFilter" class="form-control" @change="loadDevices">
<option value="">All Vendors</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
<select v-model="locationFilter" class="form-control" @change="loadDevices">
<option value="">All Locations</option>
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
{{ loc.location }}
</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Hostname</th>
<th>Asset #</th>
<th>Type</th>
<th>Vendor</th>
<th>Features</th>
<th>Location</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="device in devices" :key="device.network_device?.networkdeviceid || device.assetid">
<td class="mono">{{ device.network_device?.hostname || '-' }}</td>
<td>{{ device.assetnumber }}</td>
<td>{{ device.network_device?.networkdevicetype_name || '-' }}</td>
<td>{{ device.network_device?.vendor_name || '-' }}</td>
<td class="features">
<span v-if="device.network_device?.ispoe" class="feature-tag poe">PoE</span>
<span v-if="device.network_device?.ismanaged" class="feature-tag managed">Managed</span>
<span v-if="device.network_device?.portcount" class="feature-tag ports">{{ device.network_device.portcount }} ports</span>
<span v-if="!device.network_device?.ispoe && !device.network_device?.ismanaged && !device.network_device?.portcount">-</span>
</td>
<td>{{ device.location_name || '-' }}</td>
<td>
<span class="badge" :class="getStatusClass(device.status_name)">
{{ device.status_name || 'Unknown' }}
</span>
</td>
<td class="actions">
<router-link
:to="`/network/${device.network_device?.networkdeviceid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="devices.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No network devices found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination" v-if="totalPages > 1">
<button
:disabled="page === 1"
@click="goToPage(page - 1)"
>
Prev
</button>
<button
v-for="p in visiblePages"
:key="p"
:class="{ active: p === page }"
@click="goToPage(p)"
>
{{ p }}
</button>
<button
:disabled="page === totalPages"
@click="goToPage(page + 1)"
>
Next
</button>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { networkApi, vendorsApi, locationsApi } from '../../api'
const devices = ref([])
const deviceTypes = ref([])
const vendors = ref([])
const locations = ref([])
const loading = ref(true)
const search = ref('')
const selectedType = ref(null)
const vendorFilter = ref('')
const locationFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const totalCount = ref(0)
let searchTimeout = null
const visiblePages = computed(() => {
const pages = []
const start = Math.max(1, page.value - 2)
const end = Math.min(totalPages.value, page.value + 2)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
onMounted(async () => {
await Promise.all([
loadDeviceTypes(),
loadVendors(),
loadLocations()
])
await loadDevices()
})
async function loadDeviceTypes() {
try {
const response = await networkApi.types.list({ per_page: 100 })
deviceTypes.value = response.data.data || []
// Get counts for each type
await updateTypeCounts()
} catch (error) {
console.error('Error loading device types:', error)
}
}
async function updateTypeCounts() {
// Get summary for type counts
try {
const response = await networkApi.dashboardSummary()
const byType = response.data.data?.by_type || []
totalCount.value = response.data.data?.total || 0
// Map counts to types
deviceTypes.value = deviceTypes.value.map(t => {
const found = byType.find(bt => bt.type === t.networkdevicetype)
return { ...t, count: found?.count || 0 }
})
} catch (error) {
console.error('Error loading type counts:', error)
}
}
async function loadVendors() {
try {
const response = await vendorsApi.list({ per_page: 100 })
vendors.value = response.data.data || []
} catch (error) {
console.error('Error loading vendors:', error)
}
}
async function loadLocations() {
try {
const response = await locationsApi.list({ per_page: 100 })
locations.value = response.data.data || []
} catch (error) {
console.error('Error loading locations:', error)
}
}
async function loadDevices() {
loading.value = true
try {
const params = {
page: page.value,
per_page: 25
}
if (search.value) params.search = search.value
if (selectedType.value) params.type_id = selectedType.value
if (vendorFilter.value) params.vendor_id = vendorFilter.value
if (locationFilter.value) params.location_id = locationFilter.value
const response = await networkApi.list(params)
devices.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading network devices:', error)
} finally {
loading.value = false
}
}
function selectType(typeId) {
selectedType.value = typeId
page.value = 1
loadDevices()
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadDevices()
}, 300)
}
function goToPage(p) {
if (p >= 1 && p <= totalPages.value) {
page.value = p
loadDevices()
}
}
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair' || s === 'maintenance') return 'badge-warning'
if (s === 'retired' || s === 'decommissioned') return 'badge-danger'
return 'badge-info'
}
</script>
<style scoped>
.type-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.type-tabs button {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.type-tabs button:hover {
background: var(--bg);
border-color: var(--primary);
}
.type-tabs button.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters .form-control {
flex: 1;
min-width: 150px;
}
.filters select.form-control {
flex: 0 0 auto;
width: auto;
min-width: 150px;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.features {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.feature-tag {
display: inline-block;
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
border-radius: 4px;
background: var(--bg);
color: var(--text-light);
}
.feature-tag.poe {
background: #d4edda;
color: #155724;
}
.feature-tag.managed {
background: #e3f2fd;
color: #1565c0;
}
.feature-tag.ports {
background: var(--bg);
color: var(--text-light);
}
@media (prefers-color-scheme: dark) {
.feature-tag.poe {
background: #1e3a29;
color: #4ade80;
}
.feature-tag.managed {
background: #1e3a5f;
color: #60a5fa;
}
}
</style>

View File

@@ -0,0 +1,601 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Notification' : (isDetail ? 'Notification Details' : 'New Notification') }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveNotification">
<div class="form-group">
<label for="notificationtypeid">Type *</label>
<select
id="notificationtypeid"
v-model="form.notificationtypeid"
class="form-control"
required
:disabled="isDetail"
@change="onTypeChange"
>
<option value="">-- Select Type --</option>
<option
v-for="type in types"
:key="type.notificationtypeid"
:value="type.notificationtypeid"
>
{{ type.typename }}
</option>
</select>
<small class="form-hint">Classification type for this notification</small>
</div>
<div class="form-group">
<label for="notification">{{ isRecognition ? 'Recognition Message' : 'Notification' }} *</label>
<textarea
id="notification"
v-model="form.notification"
class="form-control"
rows="4"
required
:disabled="isDetail"
:placeholder="isRecognition ? 'Enter the recognition message...' : 'Enter the notification message...'"
></textarea>
</div>
<!-- Employee Search - Only for Recognition -->
<div v-if="isRecognition" class="form-group">
<label>Employee(s) *</label>
<div class="employee-search-container">
<input
v-model="employeeSearch"
type="text"
class="form-control"
placeholder="Search by name..."
:disabled="isDetail"
@input="searchEmployees"
@keydown.enter.prevent="addCustomEmployee"
/>
<div v-if="employeeResults.length" class="employee-dropdown">
<div
v-for="emp in employeeResults"
:key="emp.SSO"
class="employee-option"
@click="selectEmployee(emp)"
>
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
<span class="emp-sso">{{ emp.SSO }}</span>
</div>
</div>
</div>
<small class="form-hint">Search for employees or press Enter to add a custom name</small>
<!-- Selected Employees -->
<div v-if="selectedEmployees.length" class="selected-employees">
<div
v-for="(emp, idx) in selectedEmployees"
:key="idx"
class="selected-employee"
>
<span>{{ emp.name }}</span>
<button v-if="!isDetail" type="button" class="btn-remove" @click="removeEmployee(idx)">&times;</button>
</div>
</div>
</div>
<div class="form-group">
<label for="businessunitid">Business Unit</label>
<select
id="businessunitid"
v-model="form.businessunitid"
class="form-control"
:disabled="isDetail"
>
<option value="">-- All Business Units --</option>
<option
v-for="bu in businessUnits"
:key="bu.businessunitid"
:value="bu.businessunitid"
>
{{ bu.businessunit }}
</option>
</select>
<small class="form-hint">Leave blank to apply to all</small>
</div>
<div class="form-group">
<label for="appid">Related Application</label>
<select
id="appid"
v-model="form.appid"
class="form-control"
:disabled="isDetail"
>
<option value="">-- No Application --</option>
<option
v-for="app in applications"
:key="app.appid"
:value="app.appid"
>
{{ app.appname }}
</option>
</select>
<small class="form-hint">Link to a specific application (e.g., for software updates)</small>
</div>
<div v-if="!isRecognition" class="form-group">
<label for="ticketnumber">Ticket Number</label>
<input
id="ticketnumber"
v-model="form.ticketnumber"
type="text"
class="form-control"
maxlength="50"
:disabled="isDetail"
placeholder="GEINC123456 or GECHG123456"
/>
<small class="form-hint">Optional ServiceNow ticket number</small>
</div>
<div class="form-group">
<label for="link">More Info URL</label>
<input
id="link"
v-model="form.link"
type="url"
class="form-control"
maxlength="500"
:disabled="isDetail"
placeholder="https://..."
/>
</div>
<!-- Time fields - Hidden for Recognition (auto-set) -->
<div v-if="!isRecognition" class="form-row">
<div class="form-group">
<label for="starttime">Start Time *</label>
<div class="input-group">
<input
id="starttime"
v-model="form.starttime"
type="datetime-local"
class="form-control"
required
:disabled="isDetail"
/>
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="setNow('starttime')">
Now
</button>
</div>
<small class="form-hint">When notification becomes visible</small>
</div>
<div class="form-group">
<label for="endtime">End Time</label>
<div class="input-group">
<input
id="endtime"
v-model="form.endtime"
type="datetime-local"
class="form-control"
:disabled="isDetail"
/>
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="setNow('endtime')">
Now
</button>
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="form.endtime = ''">
Clear
</button>
</div>
<small class="form-hint">Leave blank for indefinite</small>
</div>
</div>
<div class="form-row checkbox-row">
<div class="form-group">
<label>
<input
type="checkbox"
v-model="form.isactive"
:disabled="isDetail"
/>
Active
</label>
<small class="form-hint">Uncheck to save as draft</small>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
v-model="form.isshopfloor"
:disabled="isDetail"
/>
Show on Shopfloor Dashboard
</label>
<small class="form-hint">Display on TV dashboard</small>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-actions">
<template v-if="isDetail">
<router-link :to="`/notifications/${route.params.id}/edit`" class="btn btn-primary">
Edit
</router-link>
<router-link to="/notifications" class="btn btn-secondary">Back</router-link>
</template>
<template v-else>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Update' : 'Create') }}
</button>
<router-link to="/notifications" class="btn btn-secondary">Cancel</router-link>
</template>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi } from '@/api'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => route.name === 'notification-edit')
const isDetail = computed(() => route.name === 'notification-detail')
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const types = ref([])
const businessUnits = ref([])
const applications = ref([])
// Employee search
const employeeSearch = ref('')
const employeeResults = ref([])
const selectedEmployees = ref([])
const form = ref({
notification: '',
notificationtypeid: '',
businessunitid: '',
appid: '',
ticketnumber: '',
link: '',
starttime: '',
endtime: '',
isactive: true,
isshopfloor: false,
employeesso: ''
})
// Check if selected type is Recognition
const isRecognition = computed(() => {
const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid))
return selectedType?.typename?.toLowerCase() === 'recognition'
})
onMounted(async () => {
try {
// Load dropdown data in parallel
const [typesRes, buRes, appsRes] = await Promise.all([
notificationsApi.types.list(),
businessUnitsApi.list().catch(() => ({ data: { data: [] } })),
applicationsApi.list({ per_page: 500 }).catch(() => ({ data: { data: [] } }))
])
types.value = typesRes.data.data || []
businessUnits.value = buRes.data?.data || []
applications.value = appsRes.data?.data || []
// Load notification if editing or viewing
if (route.params.id) {
const response = await notificationsApi.get(route.params.id)
const n = response.data.data
form.value = {
notification: n.notification || '',
notificationtypeid: n.notificationtypeid || '',
businessunitid: n.businessunitid || '',
appid: n.appid || '',
ticketnumber: n.ticketnumber || '',
link: n.link || '',
starttime: formatDateForInput(n.starttime),
endtime: formatDateForInput(n.endtime),
isactive: n.isactive !== false,
isshopfloor: n.isshopfloor || false,
employeesso: n.employeesso || ''
}
// Parse existing employee data
if (n.employeesso) {
const ssos = n.employeesso.split(',')
for (const sso of ssos) {
if (sso.trim()) {
// Try to look up the employee name
const name = n.employeename || sso.trim()
selectedEmployees.value.push({ sso: sso.trim(), name })
}
}
}
} else {
// Set default start date to now
form.value.starttime = formatDateForInput(new Date().toISOString())
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
} finally {
loading.value = false
}
})
function formatDateForInput(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toISOString().slice(0, 16)
}
function setNow(field) {
form.value[field] = formatDateForInput(new Date().toISOString())
}
function onTypeChange() {
// If switching to Recognition, auto-set times
if (isRecognition.value) {
const now = new Date()
form.value.starttime = formatDateForInput(now.toISOString())
// End time = now + 30 days
const endDate = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
form.value.endtime = formatDateForInput(endDate.toISOString())
}
}
// Employee search
let searchTimeout = null
async function searchEmployees() {
if (searchTimeout) clearTimeout(searchTimeout)
const query = employeeSearch.value.trim()
if (query.length < 2) {
employeeResults.value = []
return
}
searchTimeout = setTimeout(async () => {
try {
const res = await employeesApi.search(query)
employeeResults.value = res.data.data || []
} catch (err) {
console.error('Employee search error:', err)
employeeResults.value = []
}
}, 300)
}
function selectEmployee(emp) {
// Check if already selected
if (selectedEmployees.value.some(e => e.sso === String(emp.SSO))) {
return
}
selectedEmployees.value.push({
sso: String(emp.SSO),
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
})
employeeSearch.value = ''
employeeResults.value = []
updateEmployeeSso()
}
function addCustomEmployee() {
const name = employeeSearch.value.trim()
if (!name) return
// Add as custom name (no SSO)
selectedEmployees.value.push({
sso: `NAME:${name}`,
name: name
})
employeeSearch.value = ''
employeeResults.value = []
updateEmployeeSso()
}
function removeEmployee(idx) {
selectedEmployees.value.splice(idx, 1)
updateEmployeeSso()
}
function updateEmployeeSso() {
form.value.employeesso = selectedEmployees.value.map(e => e.sso).join(',')
}
async function saveNotification() {
error.value = ''
// Validation for Recognition type
if (isRecognition.value && selectedEmployees.value.length === 0) {
error.value = 'Please select at least one employee for recognition'
return
}
saving.value = true
try {
const data = {
notification: form.value.notification,
notificationtypeid: parseInt(form.value.notificationtypeid) || null,
businessunitid: parseInt(form.value.businessunitid) || null,
appid: parseInt(form.value.appid) || null,
ticketnumber: form.value.ticketnumber || null,
link: form.value.link || null,
starttime: form.value.starttime ? new Date(form.value.starttime).toISOString() : null,
endtime: form.value.endtime ? new Date(form.value.endtime).toISOString() : null,
isactive: form.value.isactive,
isshopfloor: form.value.isshopfloor,
employeesso: form.value.employeesso || null
}
// For recognition, also send employeename
if (isRecognition.value && selectedEmployees.value.length > 0) {
data.employeename = selectedEmployees.value.map(e => e.name).join(', ')
}
if (isEdit.value) {
await notificationsApi.update(route.params.id, data)
} else {
await notificationsApi.create(data)
}
router.push('/notifications')
} catch (err) {
console.error('Error saving notification:', err)
error.value = err.response?.data?.message || 'Failed to save notification'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.input-group {
display: flex;
gap: 0.25rem;
}
.input-group .form-control {
flex: 1;
}
.input-group .btn {
flex-shrink: 0;
}
.checkbox-row {
display: flex;
gap: 2rem;
margin-top: 1rem;
}
.checkbox-row .form-group {
flex: none;
}
.checkbox-row label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
.form-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
}
/* Employee search styles */
.employee-search-container {
position: relative;
}
.employee-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card-solid, #1a1a1a);
border: 1px solid var(--border);
border-radius: 0.25rem;
max-height: 200px;
overflow-y: auto;
z-index: 100;
}
.employee-option {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.employee-option:hover {
background: rgba(255, 255, 255, 0.1);
}
.emp-name {
font-weight: 500;
}
.emp-sso {
font-size: 0.85rem;
color: var(--text-light);
}
.selected-employees {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.selected-employee {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--primary);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.9rem;
}
.btn-remove {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
font-size: 1.2rem;
line-height: 1;
opacity: 0.7;
}
.btn-remove:hover {
opacity: 1;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
.checkbox-row {
flex-direction: column;
gap: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div class="page-header">
<h1>Notifications</h1>
<router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link>
</div>
<div class="filters">
<input
v-model="searchQuery"
type="text"
class="form-control"
placeholder="Search notifications..."
@input="debouncedSearch"
/>
<select v-model="selectedType" class="form-control" @change="loadNotifications">
<option value="">All Types</option>
<option v-for="type in types" :key="type.notificationtypeid" :value="type.notificationtypeid">
{{ type.typename }}
</option>
</select>
<select v-model="currentFilter" class="form-control" @change="loadNotifications">
<option value="">All</option>
<option value="current">Current Only</option>
<option value="pinned">Pinned Only</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="notifications.length === 0" class="empty">
No notifications found.
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th>Start Date</th>
<th>End Date</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="notification in notifications" :key="notification.notificationid">
<td>
<router-link :to="`/notifications/${notification.notificationid}`">
{{ notification.title }}
</router-link>
<span v-if="notification.ispinned" class="badge badge-primary" title="Pinned">Pinned</span>
</td>
<td>
<span class="badge" :style="{ backgroundColor: notification.typecolor }">
{{ notification.typename }}
</span>
</td>
<td>{{ formatDate(notification.startdate) }}</td>
<td>{{ notification.enddate ? formatDate(notification.enddate) : 'No end' }}</td>
<td>
<span :class="['badge', notification.iscurrent ? 'badge-success' : 'badge-secondary']">
{{ notification.iscurrent ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="actions">
<router-link :to="`/notifications/${notification.notificationid}/edit`" class="btn btn-small">
Edit
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination" v-if="totalPages > 1">
<button
v-for="p in totalPages"
:key="p"
:class="{ active: p === page }"
@click="goToPage(p)"
>
{{ p }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { notificationsApi } from '@/api'
const notifications = ref([])
const types = ref([])
const loading = ref(true)
const searchQuery = ref('')
const selectedType = ref('')
const currentFilter = ref('')
const page = ref(1)
const perPage = ref(20)
const total = ref(0)
const totalPages = ref(1)
let searchTimeout = null
onMounted(async () => {
await loadTypes()
await loadNotifications()
})
async function loadTypes() {
try {
const response = await notificationsApi.types.list()
types.value = response.data.data
} catch (error) {
console.error('Error loading types:', error)
}
}
async function loadNotifications() {
loading.value = true
try {
const params = {
page: page.value,
per_page: perPage.value
}
if (searchQuery.value) {
params.search = searchQuery.value
}
if (selectedType.value) {
params.type_id = selectedType.value
}
if (currentFilter.value === 'current') {
params.current = 'true'
} else if (currentFilter.value === 'pinned') {
params.pinned = 'true'
}
const response = await notificationsApi.list(params)
notifications.value = response.data.data
total.value = response.data.meta?.pagination?.total || notifications.value.length
totalPages.value = Math.ceil(total.value / perPage.value)
} catch (error) {
console.error('Error loading notifications:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadNotifications()
}, 300)
}
function goToPage(p) {
page.value = p
loadNotifications()
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString()
}
</script>

View File

@@ -242,6 +242,7 @@
<ShopFloorMap
:pickerMode="true"
:initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null"
:theme="currentTheme"
@positionPicked="handlePositionPicked"
/>
</div>
@@ -270,6 +271,7 @@ import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme'
const route = useRoute()
const router = useRouter()

View File

@@ -13,32 +13,28 @@
<template v-else-if="printer">
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-image" v-if="printer.model?.imageurl">
<img :src="printer.model.imageurl" :alt="printer.model?.modelnumber" />
</div>
<div class="hero-content">
<div class="hero-title">
<h1>{{ printer.machinenumber }}</h1>
<span v-if="printer.alias" class="hero-alias">{{ printer.alias }}</span>
<h1>{{ printer.name || printer.assetnumber }}</h1>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-printer">Printer</span>
<span class="badge badge-lg" :class="getStatusClass(printer.status?.status)">
{{ printer.status?.status || 'Unknown' }}
</span>
<span v-if="printer.printer?.iscsf" class="badge badge-lg badge-info">CSF</span>
<span v-if="printer.printer?.iscolor" class="badge badge-lg badge-success">Color</span>
<span v-if="printer.printer?.isnetwork" class="badge badge-lg badge-secondary">Network</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="printer.vendor?.vendor">
<div class="hero-detail" v-if="printer.printer?.vendor_name">
<span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ printer.vendor.vendor }}</span>
<span class="hero-detail-value">{{ printer.printer.vendor_name }}</span>
</div>
<div class="hero-detail" v-if="printer.model?.modelnumber">
<div class="hero-detail" v-if="printer.printer?.model_name">
<span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ printer.model.modelnumber }}</span>
<span class="hero-detail-value">{{ printer.printer.model_name }}</span>
</div>
<div class="hero-detail" v-if="printer.location?.locationname">
<span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ printer.location.location }}</span>
<div class="hero-detail" v-if="printer.serialnumber">
<span class="hero-detail-label">Serial Number</span>
<span class="hero-detail-value mono">{{ printer.serialnumber }}</span>
</div>
<div class="hero-detail" v-if="ipAddress">
<span class="hero-detail-label">IP Address</span>
@@ -57,16 +53,20 @@
<h3 class="section-title">Identity</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value mono">{{ printer.assetnumber }}</span>
</div>
<div class="info-row" v-if="printer.printer?.windowsname">
<span class="info-label">Windows Name</span>
<span class="info-value">{{ printer.machinenumber }}</span>
<span class="info-value mono">{{ printer.printer.windowsname }}</span>
</div>
<div class="info-row" v-if="printer.alias">
<span class="info-label">Alias</span>
<span class="info-value">{{ printer.alias }}</span>
<div class="info-row" v-if="printer.printer?.hostname">
<span class="info-label">Hostname / FQDN</span>
<span class="info-value mono">{{ printer.printer.hostname }}</span>
</div>
<div class="info-row" v-if="printer.hostname">
<span class="info-label">Hostname</span>
<span class="info-value mono">{{ printer.hostname }}</span>
<div class="info-row" v-if="printer.printer?.sharename">
<span class="info-label">CSF Share Name</span>
<span class="info-value mono">{{ printer.printer.sharename }}</span>
</div>
<div class="info-row" v-if="printer.serialnumber">
<span class="info-label">Serial Number</span>
@@ -76,16 +76,32 @@
</div>
<!-- Printer Settings -->
<div class="section-card" v-if="printer.printerdata?.windowsname || printer.printerdata?.sharename">
<h3 class="section-title">Print Server</h3>
<div class="section-card">
<h3 class="section-title">Printer Settings</h3>
<div class="info-list">
<div class="info-row" v-if="printer.printerdata?.windowsname">
<span class="info-label">Windows Name</span>
<span class="info-value mono">{{ printer.printerdata.windowsname }}</span>
<div class="info-row" v-if="printer.printer?.installpath">
<span class="info-label">Install Path</span>
<span class="info-value mono">{{ printer.printer.installpath }}</span>
</div>
<div class="info-row" v-if="printer.printerdata?.sharename">
<span class="info-label">CSF Name</span>
<span class="info-value mono">{{ printer.printerdata.sharename }}</span>
<div class="info-row">
<span class="info-label">Color</span>
<span class="info-value">{{ printer.printer?.iscolor ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">Duplex</span>
<span class="info-value">{{ printer.printer?.isduplex ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">Network Printer</span>
<span class="info-value">{{ printer.printer?.isnetwork ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">CSF Printer</span>
<span class="info-value">{{ printer.printer?.iscsf ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row" v-if="printer.printer?.pin">
<span class="info-label">PIN</span>
<span class="info-value mono">{{ printer.printer.pin }}</span>
</div>
</div>
</div>
@@ -104,19 +120,23 @@
<h3 class="section-title">Location</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Location</span>
<span class="info-label">Map Location</span>
<span class="info-value">
<LocationMapTooltip
v-if="printer.mapleft != null && printer.maptop != null"
:left="printer.mapleft"
:top="printer.maptop"
:machineName="printer.machinenumber"
:machineName="printer.name || printer.assetnumber"
>
<span class="location-link">{{ printer.location?.locationname || 'On Map' }}</span>
<span class="location-link">View on Map</span>
</LocationMapTooltip>
<span v-else>{{ printer.location?.locationname || '-' }}</span>
<span v-else>Not mapped</span>
</span>
</div>
<div class="info-row" v-if="printer.businessunit_name">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ printer.businessunit_name }}</span>
</div>
</div>
</div>

View File

@@ -240,6 +240,7 @@
<ShopFloorMap
:pickerMode="true"
:initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null"
:theme="currentTheme"
@positionPicked="handlePositionPicked"
/>
</div>
@@ -268,6 +269,7 @@ import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, printersApi, modelsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme'
const route = useRoute()
const router = useRouter()

View File

@@ -24,28 +24,28 @@
<table>
<thead>
<tr>
<th>Machine #</th>
<th>Alias</th>
<th>Location</th>
<th>Asset #</th>
<th>Name</th>
<th>Business Unit</th>
<th>Model</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="printer in printers" :key="printer.machineid">
<td>{{ printer.machinenumber }}</td>
<td>{{ printer.alias || '-' }}</td>
<td>{{ printer.location || '-' }}</td>
<td>{{ printer.model || '-' }}</td>
<tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid">
<td>{{ printer.assetnumber }}</td>
<td>{{ printer.name || '-' }}</td>
<td>{{ printer.businessunit_name || '-' }}</td>
<td>{{ printer.printer?.model_name || '-' }}</td>
<td>
<span class="badge" :class="getStatusClass(printer.status)">
{{ printer.status || 'Unknown' }}
<span class="badge" :class="getStatusClass(printer.status_name)">
{{ printer.status_name || 'Active' }}
</span>
</td>
<td class="actions">
<router-link
:to="`/printers/${printer.machineid}`"
:to="`/printers/${printer.printer?.printerid || printer.assetid}`"
class="btn btn-secondary btn-sm"
>
View

View File

@@ -0,0 +1,269 @@
<template>
<div class="page-header">
<h1>Reports</h1>
</div>
<div class="reports-grid">
<div v-for="report in reports" :key="report.id" class="report-card card" @click="runReport(report)">
<h3>{{ report.name }}</h3>
<p>{{ report.description }}</p>
<span class="badge">{{ report.category }}</span>
</div>
</div>
<!-- Report Results -->
<div v-if="currentReport" class="report-results card">
<div class="report-header">
<h2>{{ currentReport.name }}</h2>
<div class="report-actions">
<button class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<button class="btn btn-secondary" @click="clearReport">Close</button>
</div>
</div>
<div v-if="loading" class="loading">Loading report...</div>
<div v-else-if="reportData">
<!-- Equipment by Type -->
<div v-if="currentReport.id === 'equipment-by-type'" class="report-content">
<p class="report-summary">Total: {{ reportData.total }}</p>
<table>
<thead>
<tr>
<th>Equipment Type</th>
<th>Description</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.equipmenttype">
<td>{{ item.equipmenttype }}</td>
<td>{{ item.description }}</td>
<td>{{ item.count }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Assets by Status -->
<div v-else-if="currentReport.id === 'assets-by-status'" class="report-content">
<p class="report-summary">Total: {{ reportData.total }}</p>
<table>
<thead>
<tr>
<th>Status</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.status">
<td>
<span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span>
</td>
<td>{{ item.count }}</td>
</tr>
</tbody>
</table>
</div>
<!-- KB Popularity -->
<div v-else-if="currentReport.id === 'kb-popularity'" class="report-content">
<table>
<thead>
<tr>
<th>Article</th>
<th>Application</th>
<th>Clicks</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.linkid">
<td>
<a v-if="item.linkurl" :href="item.linkurl" target="_blank">{{ item.shortdescription }}</a>
<span v-else>{{ item.shortdescription }}</span>
</td>
<td>{{ item.application || '-' }}</td>
<td>{{ item.clicks }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Asset Inventory -->
<div v-else-if="currentReport.id === 'asset-inventory'" class="report-content">
<p class="report-summary">Total Assets: {{ reportData.total }}</p>
<h3>By Type</h3>
<table>
<thead><tr><th>Type</th><th>Count</th></tr></thead>
<tbody>
<tr v-for="item in reportData.data.bytype" :key="item.type">
<td>{{ item.type }}</td><td>{{ item.count }}</td>
</tr>
</tbody>
</table>
<h3>By Status</h3>
<table>
<thead><tr><th>Status</th><th>Count</th></tr></thead>
<tbody>
<tr v-for="item in reportData.data.bystatus" :key="item.status">
<td><span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span></td>
<td>{{ item.count }}</td>
</tr>
</tbody>
</table>
<h3>By Location</h3>
<table>
<thead><tr><th>Location</th><th>Count</th></tr></thead>
<tbody>
<tr v-for="item in reportData.data.bylocation" :key="item.location">
<td>{{ item.location }}</td><td>{{ item.count }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Generic fallback -->
<div v-else class="report-content">
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { reportsApi } from '@/api'
const reports = ref([])
const currentReport = ref(null)
const reportData = ref(null)
const loading = ref(false)
onMounted(async () => {
await loadReports()
})
async function loadReports() {
try {
const response = await reportsApi.list()
reports.value = response.data.data.reports
} catch (error) {
console.error('Error loading reports:', error)
}
}
async function runReport(report) {
currentReport.value = report
loading.value = true
reportData.value = null
try {
let response
switch (report.id) {
case 'equipment-by-type':
response = await reportsApi.equipmentByType()
break
case 'assets-by-status':
response = await reportsApi.assetsByStatus()
break
case 'kb-popularity':
response = await reportsApi.kbPopularity()
break
case 'warranty-status':
response = await reportsApi.warrantyStatus()
break
case 'software-compliance':
response = await reportsApi.softwareCompliance()
break
case 'asset-inventory':
response = await reportsApi.assetInventory()
break
default:
console.error('Unknown report:', report.id)
return
}
reportData.value = response.data.data
} catch (error) {
console.error('Error running report:', error)
} finally {
loading.value = false
}
}
function exportCSV() {
if (!currentReport.value) return
window.open(`/api/reports/${currentReport.value.id}?format=csv`, '_blank')
}
function clearReport() {
currentReport.value = null
reportData.value = null
}
</script>
<style scoped>
.reports-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.report-card {
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.report-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.report-card h3 {
margin-top: 0;
margin-bottom: 0.5rem;
}
.report-card p {
color: var(--text-light);
margin-bottom: 1rem;
}
.report-results {
margin-top: 2rem;
}
.report-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.report-header h2 {
margin: 0;
}
.report-actions {
display: flex;
gap: 0.5rem;
}
.report-summary {
font-size: 1.1rem;
font-weight: 500;
margin-bottom: 1rem;
}
.report-content h3 {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
.report-content table {
width: 100%;
margin-bottom: 1rem;
}
</style>

View File

@@ -50,6 +50,18 @@
<h3>Business Units</h3>
<p>Manage organizational units</p>
</router-link>
<router-link to="/settings/vlans" class="settings-card">
<div class="card-icon">🌐</div>
<h3>VLANs</h3>
<p>Manage virtual LANs</p>
</router-link>
<router-link to="/settings/subnets" class="settings-card">
<div class="card-icon">🔗</div>
<h3>Subnets</h3>
<p>Manage IP subnets and DHCP</p>
</router-link>
</div>
</div>
</template>
@@ -71,8 +83,8 @@
.settings-card {
display: block;
padding: 1.5rem;
background: white;
border: 1px solid #e0e0e0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
text-decoration: none;
color: inherit;
@@ -80,8 +92,8 @@
}
.settings-card:hover {
border-color: #1976d2;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.card-icon {
@@ -91,12 +103,12 @@
.settings-card h3 {
margin: 0 0 0.5rem 0;
color: #333;
color: var(--text);
}
.settings-card p {
margin: 0;
color: #666;
color: var(--text-light);
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,559 @@
<template>
<div>
<div class="page-header">
<h2>Subnets</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Subnet</button>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search subnets..."
@input="debouncedSearch"
/>
<select v-model="vlanFilter" class="form-control" @change="loadSubnets">
<option value="">All VLANs</option>
<option v-for="vlan in vlans" :key="vlan.vlanid" :value="vlan.vlanid">
VLAN {{ vlan.vlannumber }} - {{ vlan.name }}
</option>
</select>
<select v-model="typeFilter" class="form-control" @change="loadSubnets">
<option value="">All Types</option>
<option value="ipv4">IPv4</option>
<option value="ipv6">IPv6</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>CIDR</th>
<th>Name</th>
<th>VLAN</th>
<th>Gateway</th>
<th>DHCP</th>
<th>Location</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="subnet in subnets" :key="subnet.subnetid">
<td class="mono">{{ subnet.cidr }}</td>
<td>{{ subnet.name }}</td>
<td>
<span v-if="subnet.vlan_name">
VLAN {{ subnet.vlan_number }} - {{ subnet.vlan_name }}
</span>
<span v-else>-</span>
</td>
<td class="mono">{{ subnet.gatewayip || '-' }}</td>
<td>
<span class="badge" :class="subnet.dhcpenabled ? 'badge-success' : 'badge-secondary'">
{{ subnet.dhcpenabled ? 'Enabled' : 'Disabled' }}
</span>
</td>
<td>{{ subnet.location_name || '-' }}</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(subnet)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(subnet)"
>
Delete
</button>
</td>
</tr>
<tr v-if="subnets.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
No subnets found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination" v-if="totalPages > 1">
<button
v-for="p in totalPages"
:key="p"
:class="{ active: p === page }"
@click="goToPage(p)"
>
{{ p }}
</button>
</div>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editingSubnet ? 'Edit Subnet' : 'Add Subnet' }}</h3>
</div>
<form @submit.prevent="saveSubnet">
<div class="modal-body">
<!-- Basic Info -->
<div class="form-section">
<h4>Basic Information</h4>
<div class="form-row">
<div class="form-group">
<label for="cidr">CIDR *</label>
<input
id="cidr"
v-model="form.cidr"
type="text"
class="form-control"
placeholder="e.g., 10.1.1.0/24"
required
/>
</div>
<div class="form-group">
<label for="name">Name *</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
required
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="vlanid">VLAN</label>
<select id="vlanid" v-model="form.vlanid" class="form-control">
<option value="">Select VLAN</option>
<option v-for="vlan in vlans" :key="vlan.vlanid" :value="vlan.vlanid">
VLAN {{ vlan.vlannumber }} - {{ vlan.name }}
</option>
</select>
</div>
<div class="form-group">
<label for="locationid">Location</label>
<select id="locationid" v-model="form.locationid" class="form-control">
<option value="">Select Location</option>
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
{{ loc.location }}
</option>
</select>
</div>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
v-model="form.description"
class="form-control"
rows="2"
></textarea>
</div>
</div>
<!-- Network Details -->
<div class="form-section">
<h4>Network Details</h4>
<div class="form-row">
<div class="form-group">
<label for="gatewayip">Gateway IP</label>
<input
id="gatewayip"
v-model="form.gatewayip"
type="text"
class="form-control"
placeholder="e.g., 10.1.1.1"
/>
</div>
<div class="form-group">
<label for="subnetmask">Subnet Mask</label>
<input
id="subnetmask"
v-model="form.subnetmask"
type="text"
class="form-control"
placeholder="e.g., 255.255.255.0"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="dns1">Primary DNS</label>
<input
id="dns1"
v-model="form.dns1"
type="text"
class="form-control"
/>
</div>
<div class="form-group">
<label for="dns2">Secondary DNS</label>
<input
id="dns2"
v-model="form.dns2"
type="text"
class="form-control"
/>
</div>
</div>
</div>
<!-- DHCP Settings -->
<div class="form-section">
<h4>DHCP Settings</h4>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.dhcpenabled" />
<span>DHCP Enabled</span>
</label>
</div>
<div v-if="form.dhcpenabled" class="form-row">
<div class="form-group">
<label for="dhcprangestart">DHCP Range Start</label>
<input
id="dhcprangestart"
v-model="form.dhcprangestart"
type="text"
class="form-control"
placeholder="e.g., 10.1.1.100"
/>
</div>
<div class="form-group">
<label for="dhcprangeend">DHCP Range End</label>
<input
id="dhcprangeend"
v-model="form.dhcprangeend"
type="text"
class="form-control"
placeholder="e.g., 10.1.1.200"
/>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header">
<h3>Delete Subnet</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete subnet <strong>{{ subnetToDelete?.cidr }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteSubnet">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { networkApi, locationsApi } from '../../api'
const route = useRoute()
const subnets = ref([])
const vlans = ref([])
const locations = ref([])
const loading = ref(true)
const search = ref('')
const vlanFilter = ref('')
const typeFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const showModal = ref(false)
const editingSubnet = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const subnetToDelete = ref(null)
const form = ref({
cidr: '',
name: '',
description: '',
gatewayip: '',
subnetmask: '',
vlanid: '',
locationid: '',
subnettype: '',
dhcpenabled: true,
dhcprangestart: '',
dhcprangeend: '',
dns1: '',
dns2: ''
})
let searchTimeout = null
onMounted(async () => {
// Check for vlanid query parameter
if (route.query.vlanid) {
vlanFilter.value = route.query.vlanid
}
await Promise.all([
loadVLANs(),
loadLocations()
])
await loadSubnets()
})
async function loadVLANs() {
try {
const response = await networkApi.vlans.list({ per_page: 100 })
vlans.value = response.data.data || []
} catch (err) {
console.error('Error loading VLANs:', err)
}
}
async function loadLocations() {
try {
const response = await locationsApi.list({ per_page: 100 })
locations.value = response.data.data || []
} catch (err) {
console.error('Error loading locations:', err)
}
}
async function loadSubnets() {
loading.value = true
try {
const params = {
page: page.value,
per_page: 20
}
if (search.value) params.search = search.value
if (vlanFilter.value) params.vlanid = vlanFilter.value
if (typeFilter.value) params.type = typeFilter.value
const response = await networkApi.subnets.list(params)
subnets.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading subnets:', err)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadSubnets()
}, 300)
}
function goToPage(p) {
page.value = p
loadSubnets()
}
function openModal(subnet = null) {
editingSubnet.value = subnet
if (subnet) {
form.value = {
cidr: subnet.cidr || '',
name: subnet.name || '',
description: subnet.description || '',
gatewayip: subnet.gatewayip || '',
subnetmask: subnet.subnetmask || '',
vlanid: subnet.vlanid || '',
locationid: subnet.locationid || '',
subnettype: subnet.subnettype || '',
dhcpenabled: subnet.dhcpenabled ?? true,
dhcprangestart: subnet.dhcprangestart || '',
dhcprangeend: subnet.dhcprangeend || '',
dns1: subnet.dns1 || '',
dns2: subnet.dns2 || ''
}
} else {
form.value = {
cidr: '',
name: '',
description: '',
gatewayip: '',
subnetmask: '',
vlanid: vlanFilter.value || '',
locationid: '',
subnettype: '',
dhcpenabled: true,
dhcprangestart: '',
dhcprangeend: '',
dns1: '',
dns2: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingSubnet.value = null
}
async function saveSubnet() {
error.value = ''
saving.value = true
try {
const payload = {
cidr: form.value.cidr,
name: form.value.name,
description: form.value.description || null,
gatewayip: form.value.gatewayip || null,
subnetmask: form.value.subnetmask || null,
vlanid: form.value.vlanid || null,
locationid: form.value.locationid || null,
subnettype: form.value.subnettype || null,
dhcpenabled: form.value.dhcpenabled,
dhcprangestart: form.value.dhcprangestart || null,
dhcprangeend: form.value.dhcprangeend || null,
dns1: form.value.dns1 || null,
dns2: form.value.dns2 || null
}
if (editingSubnet.value) {
await networkApi.subnets.update(editingSubnet.value.subnetid, payload)
} else {
await networkApi.subnets.create(payload)
}
closeModal()
loadSubnets()
} catch (err) {
console.error('Error saving subnet:', err)
error.value = err.response?.data?.message || 'Failed to save subnet'
} finally {
saving.value = false
}
}
function confirmDelete(subnet) {
subnetToDelete.value = subnet
showDeleteModal.value = true
}
async function deleteSubnet() {
try {
await networkApi.subnets.delete(subnetToDelete.value.subnetid)
showDeleteModal.value = false
subnetToDelete.value = null
loadSubnets()
} catch (err) {
console.error('Error deleting subnet:', err)
alert(err.response?.data?.message || 'Failed to delete subnet')
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters input {
flex: 1;
min-width: 200px;
}
.filters select {
width: auto;
min-width: 150px;
}
.modal-lg {
max-width: 700px;
}
.form-section {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.form-section:last-of-type {
border-bottom: none;
margin-bottom: 0;
}
.form-section h4 {
margin: 0 0 1rem 0;
font-size: 0.95rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 1.125rem;
height: 1.125rem;
}
.badge-secondary {
background: var(--secondary);
color: var(--text);
}
</style>

View File

@@ -0,0 +1,369 @@
<template>
<div>
<div class="page-header">
<h2>VLANs</h2>
<button class="btn btn-primary" @click="openModal()">+ Add VLAN</button>
</div>
<!-- Search -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search VLANs..."
@input="debouncedSearch"
/>
<select v-model="typeFilter" class="form-control" @change="loadVLANs">
<option value="">All Types</option>
<option value="data">Data</option>
<option value="voice">Voice</option>
<option value="management">Management</option>
<option value="guest">Guest</option>
<option value="iot">IoT</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>VLAN #</th>
<th>Name</th>
<th>Type</th>
<th>Description</th>
<th>Subnets</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="vlan in vlans" :key="vlan.vlanid">
<td class="mono">{{ vlan.vlannumber }}</td>
<td>{{ vlan.name }}</td>
<td>
<span v-if="vlan.vlantype" class="badge" :class="getTypeClass(vlan.vlantype)">
{{ vlan.vlantype }}
</span>
<span v-else>-</span>
</td>
<td>{{ vlan.description || '-' }}</td>
<td>
<router-link
:to="{ path: '/settings/subnets', query: { vlanid: vlan.vlanid } }"
class="subnet-link"
>
View Subnets
</router-link>
</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(vlan)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(vlan)"
>
Delete
</button>
</td>
</tr>
<tr v-if="vlans.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">
No VLANs found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination" v-if="totalPages > 1">
<button
v-for="p in totalPages"
:key="p"
:class="{ active: p === page }"
@click="goToPage(p)"
>
{{ p }}
</button>
</div>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editingVLAN ? 'Edit VLAN' : 'Add VLAN' }}</h3>
</div>
<form @submit.prevent="saveVLAN">
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label for="vlannumber">VLAN Number *</label>
<input
id="vlannumber"
v-model.number="form.vlannumber"
type="number"
class="form-control"
min="1"
max="4094"
required
/>
</div>
<div class="form-group">
<label for="name">Name *</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
required
/>
</div>
</div>
<div class="form-group">
<label for="vlantype">VLAN Type</label>
<select id="vlantype" v-model="form.vlantype" class="form-control">
<option value="">Select Type</option>
<option value="data">Data</option>
<option value="voice">Voice</option>
<option value="management">Management</option>
<option value="guest">Guest</option>
<option value="iot">IoT</option>
</select>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
v-model="form.description"
class="form-control"
rows="3"
></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header">
<h3>Delete VLAN</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete VLAN <strong>{{ vlanToDelete?.vlannumber }} ({{ vlanToDelete?.name }})</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
VLANs with associated subnets cannot be deleted.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteVLAN">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { networkApi } from '../../api'
const vlans = ref([])
const loading = ref(true)
const search = ref('')
const typeFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const showModal = ref(false)
const editingVLAN = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const vlanToDelete = ref(null)
const form = ref({
vlannumber: null,
name: '',
vlantype: '',
description: ''
})
let searchTimeout = null
onMounted(() => {
loadVLANs()
})
async function loadVLANs() {
loading.value = true
try {
const params = {
page: page.value,
per_page: 20
}
if (search.value) params.search = search.value
if (typeFilter.value) params.type = typeFilter.value
const response = await networkApi.vlans.list(params)
vlans.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading VLANs:', err)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadVLANs()
}, 300)
}
function goToPage(p) {
page.value = p
loadVLANs()
}
function getTypeClass(type) {
switch (type) {
case 'data': return 'badge-info'
case 'voice': return 'badge-success'
case 'management': return 'badge-warning'
case 'guest': return 'badge-secondary'
case 'iot': return 'badge-primary'
default: return 'badge-info'
}
}
function openModal(vlan = null) {
editingVLAN.value = vlan
if (vlan) {
form.value = {
vlannumber: vlan.vlannumber,
name: vlan.name || '',
vlantype: vlan.vlantype || '',
description: vlan.description || ''
}
} else {
form.value = {
vlannumber: null,
name: '',
vlantype: '',
description: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingVLAN.value = null
}
async function saveVLAN() {
error.value = ''
saving.value = true
try {
if (editingVLAN.value) {
await networkApi.vlans.update(editingVLAN.value.vlanid, form.value)
} else {
await networkApi.vlans.create(form.value)
}
closeModal()
loadVLANs()
} catch (err) {
console.error('Error saving VLAN:', err)
error.value = err.response?.data?.message || 'Failed to save VLAN'
} finally {
saving.value = false
}
}
function confirmDelete(vlan) {
vlanToDelete.value = vlan
showDeleteModal.value = true
}
async function deleteVLAN() {
try {
await networkApi.vlans.delete(vlanToDelete.value.vlanid)
showDeleteModal.value = false
vlanToDelete.value = null
loadVLANs()
} catch (err) {
console.error('Error deleting VLAN:', err)
alert(err.response?.data?.message || 'Failed to delete VLAN')
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.form-row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
}
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.filters select {
width: auto;
min-width: 150px;
}
.subnet-link {
color: var(--link);
text-decoration: none;
}
.subnet-link:hover {
text-decoration: underline;
}
.badge-primary {
background: var(--primary);
color: white;
}
.badge-secondary {
background: var(--secondary);
color: var(--text);
}
</style>

View File

@@ -0,0 +1,284 @@
<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.alias || device.machinenumber }}</h1>
</div>
<div class="hero-meta">
<span class="badge badge-lg" :class="device.is_checked_out ? 'badge-warning' : 'badge-success'">
{{ device.is_checked_out ? 'Checked Out' : 'Available' }}
</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="device.serialnumber">
<span class="hero-detail-label">Serial Number</span>
<span class="hero-detail-value mono">{{ device.serialnumber }}</span>
</div>
<div class="hero-detail" v-if="device.vendor_name">
<span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ device.vendor_name }}</span>
</div>
<div class="hero-detail" v-if="device.model_name">
<span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ device.model_name }}</span>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<button
v-if="!device.is_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.current_checkout">
<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_checkout.sso }}</span>
</div>
<div class="info-row">
<span class="info-label">Checkout Time</span>
<span class="info-value">{{ formatDate(device.current_checkout.checkout_time) }}</span>
</div>
<div class="info-row" v-if="device.current_checkout.checkout_reason">
<span class="info-label">Reason</span>
<span class="info-value">{{ device.current_checkout.checkout_reason }}</span>
</div>
</div>
</div>
<!-- Checkout History -->
<div class="card">
<div class="card-header">
<h3>Checkout History</h3>
</div>
<div v-if="!device.checkout_history?.length" class="empty-state">
No checkout history
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>User SSO</th>
<th>Checkout Time</th>
<th>Check-in Time</th>
<th>Wiped</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
<tr v-for="checkout in device.checkout_history" :key="checkout.checkoutid">
<td>{{ checkout.sso }}</td>
<td>{{ formatDate(checkout.checkout_time) }}</td>
<td>{{ checkout.checkin_time ? formatDate(checkout.checkin_time) : 'Still out' }}</td>
<td>
<span v-if="checkout.checkin_time">{{ checkout.was_wiped ? 'Yes' : 'No' }}</span>
<span v-else>-</span>
</td>
<td>{{ checkout.checkout_reason || '-' }}</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.was_wiped" />
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'
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({ was_wiped: 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 = { was_wiped: false, notes: '' }
showCheckinModal.value = true
}
function closeModals() {
showCheckoutModal.value = false
showCheckinModal.value = false
}
async function doCheckout() {
try {
await usbApi.checkout(device.value.machineid, checkoutForm.value)
closeModals()
await loadDevice()
} catch (error) {
console.error('Checkout error:', error)
alert(error.response?.data?.message || 'Checkout failed')
}
}
async function doCheckin() {
try {
await usbApi.checkin(device.value.machineid, checkinForm.value)
closeModals()
await loadDevice()
} catch (error) {
console.error('Checkin error:', error)
alert(error.response?.data?.message || '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>

View File

@@ -0,0 +1,230 @@
<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="serialnumber">Serial Number *</label>
<input
id="serialnumber"
v-model="form.serialnumber"
type="text"
class="form-control"
required
maxlength="100"
/>
</div>
<div class="form-group">
<label for="displayname">Display Name *</label>
<input
id="displayname"
v-model="form.displayname"
type="text"
class="form-control"
required
maxlength="100"
placeholder="e.g., USB Flash Drive #1"
/>
</div>
<div class="form-group">
<label for="label">Label</label>
<input
id="label"
v-model="form.label"
type="text"
class="form-control"
maxlength="100"
placeholder="Physical label on device"
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="usbtypeid">Device Type</label>
<select
id="usbtypeid"
v-model="form.usbtypeid"
class="form-control"
>
<option value="">-- Select Type --</option>
<option
v-for="type in types"
:key="type.usbtypeid"
:value="type.usbtypeid"
>
{{ type.typename }}
</option>
</select>
</div>
<div class="form-group">
<label for="capacitygb">Capacity (GB)</label>
<input
id="capacitygb"
v-model.number="form.capacitygb"
type="number"
class="form-control"
min="0"
step="1"
/>
</div>
</div>
<div class="form-group">
<label for="vendorid">Vendor</label>
<select
id="vendorid"
v-model="form.vendorid"
class="form-control"
>
<option value="">-- Select Vendor --</option>
<option
v-for="vendor in vendors"
:key="vendor.vendorid"
:value="vendor.vendorid"
>
{{ vendor.vendorname }}
</option>
</select>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
v-model="form.notes"
class="form-control"
rows="3"
></textarea>
</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, vendorsApi } from '@/api'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const types = ref([])
const vendors = ref([])
const form = ref({
serialnumber: '',
displayname: '',
label: '',
usbtypeid: '',
capacitygb: null,
vendorid: '',
notes: ''
})
onMounted(async () => {
try {
// Load types and vendors
const [typesRes, vendorsRes] = await Promise.all([
usbApi.types.list(),
vendorsApi.list({ per_page: 1000 })
])
types.value = typesRes.data.data || []
vendors.value = vendorsRes.data.data || []
// Load device if editing
if (isEdit.value) {
const response = await usbApi.get(route.params.id)
const device = response.data.data
form.value = {
serialnumber: device.serialnumber || '',
displayname: device.displayname || '',
label: device.label || '',
usbtypeid: device.usbtypeid || '',
capacitygb: device.capacitygb || null,
vendorid: device.vendorid || '',
notes: device.notes || ''
}
}
} 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 {
const data = {
serialnumber: form.value.serialnumber,
displayname: form.value.displayname,
label: form.value.label || null,
usbtypeid: form.value.usbtypeid || null,
capacitygb: form.value.capacitygb || null,
vendorid: form.value.vendorid || null,
notes: form.value.notes || null
}
if (isEdit.value) {
await usbApi.update(route.params.id, data)
} else {
await usbApi.create(data)
}
router.push('/usb')
} catch (err) {
console.error('Error saving device:', err)
error.value = err.response?.data?.message || '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>

View File

@@ -0,0 +1,290 @@
<template>
<div>
<div class="page-header">
<h2>USB Devices</h2>
</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.machineid">
<td>
<strong>{{ device.alias || device.machinenumber }}</strong>
<div v-if="device.model_name" class="text-muted">{{ device.model_name }}</div>
</td>
<td class="mono">{{ device.serialnumber || '-' }}</td>
<td>
<span class="badge" :class="device.is_checked_out ? 'badge-warning' : 'badge-success'">
{{ device.is_checked_out ? 'Checked Out' : 'Available' }}
</span>
</td>
<td>
<template v-if="device.current_checkout">
{{ device.current_checkout.checkout_name || device.current_checkout.sso }}
<div class="text-muted">{{ formatDate(device.current_checkout.checkout_time) }}</div>
</template>
<span v-else>-</span>
</td>
<td class="actions">
<button
v-if="!device.is_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.machineid}`"
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 -->
<div class="pagination" v-if="totalPages > 1">
<button
v-for="p in totalPages"
:key="p"
:class="{ active: p === page }"
@click="goToPage(p)"
>
{{ p }}
</button>
</div>
</template>
</div>
<!-- Checkout Modal -->
<Modal v-model="showCheckoutModal" @close="closeModals">
<template #header>
<h3>Checkout USB Device</h3>
</template>
<p>Checking out: <strong>{{ selectedDevice?.alias || selectedDevice?.machinenumber }}</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?.alias || selectedDevice?.machinenumber }}</strong></p>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="checkinForm.was_wiped" />
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'
const devices = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const totalPages = ref(1)
const showAvailableOnly = ref(false)
const showCheckoutModal = ref(false)
const showCheckinModal = ref(false)
const selectedDevice = ref(null)
const checkoutForm = ref({ reason: '' })
const checkinForm = ref({ was_wiped: false, notes: '' })
const selectedEmployee = ref(null)
let searchTimeout = null
onMounted(() => {
loadDevices()
})
async function loadDevices() {
loading.value = true
try {
const params = {
page: page.value,
perpage: 20
}
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?.total_pages || 1
} catch (error) {
console.error('Error loading USB devices:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadDevices()
}, 300)
}
function goToPage(p) {
page.value = p
loadDevices()
}
function openCheckoutModal(device) {
selectedDevice.value = device
checkoutForm.value = { reason: '' }
selectedEmployee.value = null
showCheckoutModal.value = true
}
function openCheckinModal(device) {
selectedDevice.value = device
checkinForm.value = { was_wiped: 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 {
await usbApi.checkout(selectedDevice.value.machineid, {
sso: selectedEmployee.value.sso,
name: selectedEmployee.value.name,
reason: checkoutForm.value.reason
})
closeModals()
loadDevices()
} catch (error) {
console.error('Checkout error:', error)
alert(error.response?.data?.message || 'Checkout failed')
}
}
async function doCheckin() {
try {
await usbApi.checkin(selectedDevice.value.machineid, checkinForm.value)
closeModals()
loadDevices()
} catch (error) {
console.error('Checkin error:', error)
alert(error.response?.data?.message || '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>