Global toast notifications; replace all alert() calls

Add a useToast() composable + a single ToastHost mounted in AppLayout. Convert
every alert() across views/components (29 call sites, all error paths) to
toast.error, and use toast.success to confirm a warranty refresh. Kills the
native "localhost says" dialog and gives consistent, dismissable feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-09 16:56:25 -04:00
parent b90a13c7e5
commit d6142b10b4
29 changed files with 4774 additions and 4606 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
<template>
<div class="toast-host">
<transition-group name="toast">
<div
v-for="t in toast.items"
:key="t.id"
class="toast"
:class="`toast-${t.type}`"
@click="toast.dismiss(t.id)"
>
<span class="toast-message">{{ t.message }}</span>
<button class="toast-close" @click.stop="toast.dismiss(t.id)" aria-label="Dismiss">&times;</button>
</div>
</transition-group>
</div>
</template>
<script setup>
import { useToast } from '../composables/toast'
const toast = useToast()
</script>
<style scoped>
.toast-host {
position: fixed;
bottom: 1.25rem;
right: 1.25rem;
z-index: 3000;
display: flex;
flex-direction: column;
gap: 0.6rem;
max-width: min(420px, calc(100vw - 2.5rem));
}
.toast {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 0.9rem;
border-radius: 8px;
background: var(--bg-card);
color: var(--text);
border-left: 4px solid var(--secondary);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
cursor: pointer;
font-size: 0.9rem;
line-height: 1.35;
}
.toast-success { border-left-color: var(--success); }
.toast-error { border-left-color: var(--danger); }
.toast-info { border-left-color: var(--primary); }
.toast-message { flex: 1; }
.toast-close {
border: none;
background: none;
color: var(--text-light);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0;
}
.toast-enter-active,
.toast-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(20px);
}
</style>

View File

@@ -0,0 +1,35 @@
// Global toast notifications. Import useToast() anywhere and call
// toast.success('...') / toast.error('...') / toast.info('...'). A single
// <ToastHost /> mounted in AppLayout renders the stack.
import { reactive } from 'vue'
const state = reactive({
items: [],
})
let nextId = 1
function dismiss(id) {
const index = state.items.findIndex(t => t.id === id)
if (index !== -1) state.items.splice(index, 1)
}
function push(message, type = 'info', duration = 5000) {
if (!message) return
const id = nextId++
state.items.push({ id, message, type })
if (duration > 0) {
setTimeout(() => dismiss(id), duration)
}
return id
}
export function useToast() {
return {
items: state.items,
dismiss,
success: (message, duration) => push(message, 'success', duration),
error: (message, duration) => push(message, 'error', duration ?? 8000),
info: (message, duration) => push(message, 'info', duration),
}
}

View File

@@ -69,12 +69,14 @@
</div>
<router-view />
</main>
<ToastHost />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import ToastHost from '../components/ToastHost.vue'
import {
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck

View File

@@ -1,408 +1,410 @@
<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.mapx && asset.mapy,
unplaced: !asset.mapx || !asset.mapy
}"
@click="selectAsset(asset)"
>
<span class="asset-icon"><component :is="getTypeIcon(asset.assettype)" :size="16" /></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.mapx && asset.mapy" class="placed-indicator" title="Placed on map"><MapPin :size="12" /></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.mapx && selectedAsset.mapy"
@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.mapx, top: selectedAsset.mapy } : null"
@positionPicked="handlePositionPicked"
@markerClick="handleMarkerClick"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Cog, Monitor, Printer, Globe, Package, MapPin } from 'lucide-vue-next'
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.mapx || !a.mapy)
}
return result
})
const placedAssets = computed(() => {
return assets.value.filter(a => a.mapx && a.mapy)
})
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': Cog,
'computer': Monitor,
'printer': Printer,
'network_device': Globe
}
return icons[assettype] || Package
}
function selectAsset(asset) {
selectedAsset.value = asset
pickedPosition.value = asset.mapx && asset.mapy
? { left: asset.mapx, top: asset.mapy }
: 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, {
mapx: Math.round(pickedPosition.value.left),
mapy: Math.round(pickedPosition.value.top)
})
// Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) {
asset.mapx = Math.round(pickedPosition.value.left)
asset.mapy = 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, {
mapx: null,
mapy: null
})
// Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) {
asset.mapx = null
asset.mapy = 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>
<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.mapx && asset.mapy,
unplaced: !asset.mapx || !asset.mapy
}"
@click="selectAsset(asset)"
>
<span class="asset-icon"><component :is="getTypeIcon(asset.assettype)" :size="16" /></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.mapx && asset.mapy" class="placed-indicator" title="Placed on map"><MapPin :size="12" /></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.mapx && selectedAsset.mapy"
@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.mapx, top: selectedAsset.mapy } : null"
@positionPicked="handlePositionPicked"
@markerClick="handleMarkerClick"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Cog, Monitor, Printer, Globe, Package, MapPin } from 'lucide-vue-next'
import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
import { useToast } from '../composables/toast'
const toast = useToast()
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.mapx || !a.mapy)
}
return result
})
const placedAssets = computed(() => {
return assets.value.filter(a => a.mapx && a.mapy)
})
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': Cog,
'computer': Monitor,
'printer': Printer,
'network_device': Globe
}
return icons[assettype] || Package
}
function selectAsset(asset) {
selectedAsset.value = asset
pickedPosition.value = asset.mapx && asset.mapy
? { left: asset.mapx, top: asset.mapy }
: 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, {
mapx: Math.round(pickedPosition.value.left),
mapy: Math.round(pickedPosition.value.top)
})
// Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) {
asset.mapx = Math.round(pickedPosition.value.left)
asset.mapy = Math.round(pickedPosition.value.top)
}
selectedAsset.value = null
pickedPosition.value = null
} catch (error) {
console.error('Failed to save position:', error)
toast.error('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, {
mapx: null,
mapy: null
})
// Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) {
asset.mapx = null
asset.mapy = null
}
selectedAsset.value = null
pickedPosition.value = null
} catch (error) {
console.error('Failed to clear position:', error)
toast.error('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

@@ -1,388 +1,390 @@
<template>
<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>
<template v-else>
<!-- Filter Controls -->
<div class="map-filters">
<select v-model="selectedType" @change="onTypeChange">
<option value="">All Asset Types</option>
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettype">
{{ formatTypeName(t.assettype) }} ({{ getTypeCount(t.assettype) }})
</option>
</select>
<select v-model="selectedSubtype" @change="updateMapLayers" :disabled="!selectedType || !currentSubtypes.length">
<option value="">{{ subtypeLabel }}</option>
<option v-for="st in currentSubtypes" :key="st.id" :value="st.id">
{{ st.name }}
</option>
</select>
<select v-model="selectedBusinessUnit" @change="updateMapLayers">
<option value="">All Business Units</option>
<option v-for="bu in businessunits" :key="bu.businessunitid" :value="bu.businessunitid">
{{ bu.businessunit }}
</option>
</select>
<select v-model="selectedStatus" @change="updateMapLayers">
<option value="">All Statuses</option>
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">
{{ s.status }}
</option>
</select>
<input
type="text"
v-model="searchQuery"
placeholder="Search assets..."
@input="debouncedSearch"
/>
<button
class="btn btn-secondary export-btn"
@click="exportPdf"
:disabled="exporting || !filteredAssets.length"
:title="filteredAssets.length ? 'Export the filtered map to PDF' : 'No assets to export'"
>
{{ exporting ? 'Exporting...' : 'Export PDF' }}
</button>
<span class="result-count">{{ filteredAssets.length }} assets</span>
</div>
<ShopFloorMap
:machines="filteredAssets"
:machinetypes="[]"
:businessunits="businessunits"
:statuses="statuses"
:assetTypeMode="true"
:theme="currentTheme"
:selectedAssetType="selectedType"
:subtypeColors="subtypeColorMap"
:subtypeNames="subtypeNameMap"
@markerClick="handleMarkerClick"
/>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
import { useAuthStore } from '../stores/auth'
import { loadMapConfig, state as mapConfig } from '../composables/mapConfig'
import { exportMapPdf } from '../utils/mapPdf'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(true)
const assets = ref([])
const assetTypes = ref([])
const businessunits = ref([])
const statuses = ref([])
const subtypes = ref({})
// Filter state
const selectedType = ref('')
const selectedSubtype = ref('')
const selectedBusinessUnit = ref('')
const selectedStatus = ref('')
const searchQuery = ref('')
const exporting = ref(false)
let searchTimeout = null
// Lookup helper for subtypes. Normalizes case AND underscores-vs-spaces, since
// the asset-type value is 'network_device' but the subtypes key is
// 'Network Device' - without normalizing, network subtypes never match.
function getSubtypesForType(typeName) {
if (!typeName || !subtypes.value) return []
// Try exact match first
if (subtypes.value[typeName]) return subtypes.value[typeName]
const norm = typeName.toLowerCase().replace(/_/g, ' ')
for (const [key, value] of Object.entries(subtypes.value)) {
if (key.toLowerCase().replace(/_/g, ' ') === norm) return value
}
return []
}
const currentSubtypes = computed(() => {
if (!selectedType.value) return []
return getSubtypesForType(selectedType.value)
})
const subtypeLabel = computed(() => {
if (!selectedType.value) return 'Select type first'
if (!currentSubtypes.value.length) return 'No subtypes'
const labels = {
'equipment': 'All Machine Types',
'computer': 'All Computer Types',
'network device': 'All Device Types',
'printer': 'All Printer Types'
}
return labels[selectedType.value.toLowerCase().replace(/_/g, ' ')] || 'All Subtypes'
})
// Generate distinct colors for subtypes
const subtypeColorPalette = [
'#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800',
'#FF5722', '#795548', '#607D8B', '#00ACC1', '#5C6BC0'
]
// Map subtype IDs to colors: prefer each subtype's stored color; fall back to
// the auto palette (by index) for subtypes that have not been given one.
const subtypeColorMap = computed(() => {
const colorMap = {}
const allSubtypes = currentSubtypes.value
allSubtypes.forEach((st, index) => {
colorMap[st.id] = st.color || subtypeColorPalette[index % subtypeColorPalette.length]
})
return colorMap
})
// Map subtype IDs to names
const subtypeNameMap = computed(() => {
const nameMap = {}
currentSubtypes.value.forEach(st => {
nameMap[st.id] = st.name
})
return nameMap
})
const filteredAssets = computed(() => {
let result = assets.value
// Filter by asset type (case-insensitive)
if (selectedType.value) {
const selectedLower = selectedType.value.toLowerCase()
result = result.filter(a => a.assettype && a.assettype.toLowerCase() === selectedLower)
}
// Filter by subtype (normalize network_device -> network device)
if (selectedSubtype.value) {
const subtypeId = parseInt(selectedSubtype.value)
const typeLower = (selectedType.value || '').toLowerCase().replace(/_/g, ' ')
result = result.filter(a => {
if (!a.typedata) return false
// Check different ID fields based on asset type
if (typeLower === 'equipment') {
return a.typedata.equipmenttypeid === subtypeId
} else if (typeLower === 'computer') {
return a.typedata.computertypeid === subtypeId
} else if (typeLower === 'network device') {
return a.typedata.networkdevicetypeid === subtypeId
} else if (typeLower === 'printer') {
return a.typedata.printertypeid === subtypeId
}
return false
})
}
// Filter by business unit
if (selectedBusinessUnit.value) {
result = result.filter(a => a.businessunitid === parseInt(selectedBusinessUnit.value))
}
// Filter by status
if (selectedStatus.value) {
result = result.filter(a => a.statusid === parseInt(selectedStatus.value))
}
// Filter by search query
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
result = result.filter(a =>
(a.assetnumber && a.assetnumber.toLowerCase().includes(q)) ||
(a.name && a.name.toLowerCase().includes(q)) ||
(a.displayname && a.displayname.toLowerCase().includes(q)) ||
(a.serialnumber && a.serialnumber.toLowerCase().includes(q))
)
}
return result
})
// Human-readable labels for the filters currently applied, for the PDF header.
function activeFilterLabels() {
const labels = []
if (selectedType.value) labels.push(`Type: ${formatTypeName(selectedType.value)}`)
if (selectedSubtype.value) {
const st = currentSubtypes.value.find(s => String(s.id) === String(selectedSubtype.value))
if (st) labels.push(`Subtype: ${st.name}`)
}
if (selectedBusinessUnit.value) {
const bu = businessunits.value.find(b => String(b.businessunitid) === String(selectedBusinessUnit.value))
if (bu) labels.push(`Business Unit: ${bu.businessunit}`)
}
if (selectedStatus.value) {
const s = statuses.value.find(x => String(x.statusid) === String(selectedStatus.value))
if (s) labels.push(`Status: ${s.status}`)
}
if (searchQuery.value) labels.push(`Search: "${searchQuery.value}"`)
return labels
}
async function exportPdf() {
if (!filteredAssets.value.length) return
exporting.value = true
try {
await loadMapConfig()
await exportMapPdf({
assets: filteredAssets.value,
blueprintUrl: mapConfig.blueprintLight,
mapWidth: mapConfig.width,
mapHeight: mapConfig.height,
selectedType: selectedType.value,
subtypeColors: subtypeColorMap.value,
subtypeNames: subtypeNameMap.value,
filters: activeFilterLabels()
})
} catch (e) {
console.error('Map PDF export failed:', e)
alert('Failed to export map PDF. See console for details.')
} finally {
exporting.value = false
}
}
onMounted(async () => {
loadMapConfig()
try {
const response = await assetsApi.getMap()
const data = response.data.data || {}
assets.value = data.assets || []
assetTypes.value = data.filters?.assettypes || []
businessunits.value = data.filters?.businessunits || []
statuses.value = data.filters?.statuses || []
subtypes.value = data.filters?.subtypes || {}
} catch (error) {
console.error('Failed to load map data:', error)
} finally {
loading.value = false
}
})
function formatTypeName(assettype) {
return assetTypeLabel(assettype)
}
function getTypeCount(assettype) {
if (!assettype) return 0
const lowerType = assettype.toLowerCase()
return assets.value.filter(a => a.assettype && a.assettype.toLowerCase() === lowerType).length
}
function onTypeChange() {
// Reset subtype when type changes
selectedSubtype.value = ''
updateMapLayers()
}
function updateMapLayers() {
// Filter is reactive via computed property
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
updateMapLayers()
}, 300)
}
function handleMarkerClick(asset) {
router.push(assetDetailRoute(asset))
}
</script>
<style scoped>
.map-page {
display: flex;
flex-direction: column;
height: calc(100vh - 2rem);
}
.map-page .page-header {
flex-shrink: 0;
}
.map-filters {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
.map-filters select,
.map-filters input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-size: 0.875rem;
}
.map-filters select {
min-width: 160px;
}
.map-filters select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.map-filters input {
min-width: 180px;
}
.export-btn {
margin-left: auto;
padding: 0.5rem 0.9rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-size: 0.875rem;
cursor: pointer;
}
.export-btn:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.export-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-count {
color: var(--text-light);
font-size: 0.875rem;
}
.map-page :deep(.shopfloor-map) {
flex: 1;
}
</style>
<template>
<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>
<template v-else>
<!-- Filter Controls -->
<div class="map-filters">
<select v-model="selectedType" @change="onTypeChange">
<option value="">All Asset Types</option>
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettype">
{{ formatTypeName(t.assettype) }} ({{ getTypeCount(t.assettype) }})
</option>
</select>
<select v-model="selectedSubtype" @change="updateMapLayers" :disabled="!selectedType || !currentSubtypes.length">
<option value="">{{ subtypeLabel }}</option>
<option v-for="st in currentSubtypes" :key="st.id" :value="st.id">
{{ st.name }}
</option>
</select>
<select v-model="selectedBusinessUnit" @change="updateMapLayers">
<option value="">All Business Units</option>
<option v-for="bu in businessunits" :key="bu.businessunitid" :value="bu.businessunitid">
{{ bu.businessunit }}
</option>
</select>
<select v-model="selectedStatus" @change="updateMapLayers">
<option value="">All Statuses</option>
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">
{{ s.status }}
</option>
</select>
<input
type="text"
v-model="searchQuery"
placeholder="Search assets..."
@input="debouncedSearch"
/>
<button
class="btn btn-secondary export-btn"
@click="exportPdf"
:disabled="exporting || !filteredAssets.length"
:title="filteredAssets.length ? 'Export the filtered map to PDF' : 'No assets to export'"
>
{{ exporting ? 'Exporting...' : 'Export PDF' }}
</button>
<span class="result-count">{{ filteredAssets.length }} assets</span>
</div>
<ShopFloorMap
:machines="filteredAssets"
:machinetypes="[]"
:businessunits="businessunits"
:statuses="statuses"
:assetTypeMode="true"
:theme="currentTheme"
:selectedAssetType="selectedType"
:subtypeColors="subtypeColorMap"
:subtypeNames="subtypeNameMap"
@markerClick="handleMarkerClick"
/>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
import { useAuthStore } from '../stores/auth'
import { loadMapConfig, state as mapConfig } from '../composables/mapConfig'
import { exportMapPdf } from '../utils/mapPdf'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
import { useToast } from '../composables/toast'
const toast = useToast()
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(true)
const assets = ref([])
const assetTypes = ref([])
const businessunits = ref([])
const statuses = ref([])
const subtypes = ref({})
// Filter state
const selectedType = ref('')
const selectedSubtype = ref('')
const selectedBusinessUnit = ref('')
const selectedStatus = ref('')
const searchQuery = ref('')
const exporting = ref(false)
let searchTimeout = null
// Lookup helper for subtypes. Normalizes case AND underscores-vs-spaces, since
// the asset-type value is 'network_device' but the subtypes key is
// 'Network Device' - without normalizing, network subtypes never match.
function getSubtypesForType(typeName) {
if (!typeName || !subtypes.value) return []
// Try exact match first
if (subtypes.value[typeName]) return subtypes.value[typeName]
const norm = typeName.toLowerCase().replace(/_/g, ' ')
for (const [key, value] of Object.entries(subtypes.value)) {
if (key.toLowerCase().replace(/_/g, ' ') === norm) return value
}
return []
}
const currentSubtypes = computed(() => {
if (!selectedType.value) return []
return getSubtypesForType(selectedType.value)
})
const subtypeLabel = computed(() => {
if (!selectedType.value) return 'Select type first'
if (!currentSubtypes.value.length) return 'No subtypes'
const labels = {
'equipment': 'All Machine Types',
'computer': 'All Computer Types',
'network device': 'All Device Types',
'printer': 'All Printer Types'
}
return labels[selectedType.value.toLowerCase().replace(/_/g, ' ')] || 'All Subtypes'
})
// Generate distinct colors for subtypes
const subtypeColorPalette = [
'#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800',
'#FF5722', '#795548', '#607D8B', '#00ACC1', '#5C6BC0'
]
// Map subtype IDs to colors: prefer each subtype's stored color; fall back to
// the auto palette (by index) for subtypes that have not been given one.
const subtypeColorMap = computed(() => {
const colorMap = {}
const allSubtypes = currentSubtypes.value
allSubtypes.forEach((st, index) => {
colorMap[st.id] = st.color || subtypeColorPalette[index % subtypeColorPalette.length]
})
return colorMap
})
// Map subtype IDs to names
const subtypeNameMap = computed(() => {
const nameMap = {}
currentSubtypes.value.forEach(st => {
nameMap[st.id] = st.name
})
return nameMap
})
const filteredAssets = computed(() => {
let result = assets.value
// Filter by asset type (case-insensitive)
if (selectedType.value) {
const selectedLower = selectedType.value.toLowerCase()
result = result.filter(a => a.assettype && a.assettype.toLowerCase() === selectedLower)
}
// Filter by subtype (normalize network_device -> network device)
if (selectedSubtype.value) {
const subtypeId = parseInt(selectedSubtype.value)
const typeLower = (selectedType.value || '').toLowerCase().replace(/_/g, ' ')
result = result.filter(a => {
if (!a.typedata) return false
// Check different ID fields based on asset type
if (typeLower === 'equipment') {
return a.typedata.equipmenttypeid === subtypeId
} else if (typeLower === 'computer') {
return a.typedata.computertypeid === subtypeId
} else if (typeLower === 'network device') {
return a.typedata.networkdevicetypeid === subtypeId
} else if (typeLower === 'printer') {
return a.typedata.printertypeid === subtypeId
}
return false
})
}
// Filter by business unit
if (selectedBusinessUnit.value) {
result = result.filter(a => a.businessunitid === parseInt(selectedBusinessUnit.value))
}
// Filter by status
if (selectedStatus.value) {
result = result.filter(a => a.statusid === parseInt(selectedStatus.value))
}
// Filter by search query
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
result = result.filter(a =>
(a.assetnumber && a.assetnumber.toLowerCase().includes(q)) ||
(a.name && a.name.toLowerCase().includes(q)) ||
(a.displayname && a.displayname.toLowerCase().includes(q)) ||
(a.serialnumber && a.serialnumber.toLowerCase().includes(q))
)
}
return result
})
// Human-readable labels for the filters currently applied, for the PDF header.
function activeFilterLabels() {
const labels = []
if (selectedType.value) labels.push(`Type: ${formatTypeName(selectedType.value)}`)
if (selectedSubtype.value) {
const st = currentSubtypes.value.find(s => String(s.id) === String(selectedSubtype.value))
if (st) labels.push(`Subtype: ${st.name}`)
}
if (selectedBusinessUnit.value) {
const bu = businessunits.value.find(b => String(b.businessunitid) === String(selectedBusinessUnit.value))
if (bu) labels.push(`Business Unit: ${bu.businessunit}`)
}
if (selectedStatus.value) {
const s = statuses.value.find(x => String(x.statusid) === String(selectedStatus.value))
if (s) labels.push(`Status: ${s.status}`)
}
if (searchQuery.value) labels.push(`Search: "${searchQuery.value}"`)
return labels
}
async function exportPdf() {
if (!filteredAssets.value.length) return
exporting.value = true
try {
await loadMapConfig()
await exportMapPdf({
assets: filteredAssets.value,
blueprintUrl: mapConfig.blueprintLight,
mapWidth: mapConfig.width,
mapHeight: mapConfig.height,
selectedType: selectedType.value,
subtypeColors: subtypeColorMap.value,
subtypeNames: subtypeNameMap.value,
filters: activeFilterLabels()
})
} catch (e) {
console.error('Map PDF export failed:', e)
toast.error('Failed to export map PDF. See console for details.')
} finally {
exporting.value = false
}
}
onMounted(async () => {
loadMapConfig()
try {
const response = await assetsApi.getMap()
const data = response.data.data || {}
assets.value = data.assets || []
assetTypes.value = data.filters?.assettypes || []
businessunits.value = data.filters?.businessunits || []
statuses.value = data.filters?.statuses || []
subtypes.value = data.filters?.subtypes || {}
} catch (error) {
console.error('Failed to load map data:', error)
} finally {
loading.value = false
}
})
function formatTypeName(assettype) {
return assetTypeLabel(assettype)
}
function getTypeCount(assettype) {
if (!assettype) return 0
const lowerType = assettype.toLowerCase()
return assets.value.filter(a => a.assettype && a.assettype.toLowerCase() === lowerType).length
}
function onTypeChange() {
// Reset subtype when type changes
selectedSubtype.value = ''
updateMapLayers()
}
function updateMapLayers() {
// Filter is reactive via computed property
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
updateMapLayers()
}, 300)
}
function handleMarkerClick(asset) {
router.push(assetDetailRoute(asset))
}
</script>
<style scoped>
.map-page {
display: flex;
flex-direction: column;
height: calc(100vh - 2rem);
}
.map-page .page-header {
flex-shrink: 0;
}
.map-filters {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
.map-filters select,
.map-filters input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-size: 0.875rem;
}
.map-filters select {
min-width: 160px;
}
.map-filters select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.map-filters input {
min-width: 180px;
}
.export-btn {
margin-left: auto;
padding: 0.5rem 0.9rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-size: 0.875rem;
cursor: pointer;
}
.export-btn:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.export-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-count {
color: var(--text-light);
font-size: 0.875rem;
}
.map-page :deep(.shopfloor-map) {
flex: 1;
}
</style>

View File

@@ -1,320 +1,322 @@
<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>
<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'
import { useToast } from '../../composables/toast'
const toast = useToast()
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)
toast.error('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

@@ -1,369 +1,371 @@
<template>
<div class="detail-page" v-if="device">
<div class="hero-card">
<div class="hero-image">
<div class="device-icon">
<span class="icon"><component :is="getDeviceIcon()" :size="24" /></span>
</div>
</div>
<div class="hero-content">
<div class="hero-title-row">
<h1 class="hero-title">{{ device.networkdevice?.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" :style="colorStyle(device.statuscolor)">
{{ device.statusname || 'Unknown' }}
</span>
<span v-if="device.networkdevice?.networkdevicetypename" class="meta-item">
{{ device.networkdevice.networkdevicetypename }}
</span>
<span v-if="device.networkdevice?.vendorname" class="meta-item">
{{ device.networkdevice.vendorname }}
</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.locationname">
<span class="label">Location</span>
<span class="value">{{ device.locationname }}</span>
</div>
<div class="detail-item" v-if="device.businessunitname">
<span class="label">Business Unit</span>
<span class="value">{{ device.businessunitname }}</span>
</div>
</div>
<div class="hero-features" v-if="device.networkdevice">
<span v-if="device.networkdevice.ispoe" class="feature-badge poe">PoE</span>
<span v-if="device.networkdevice.ismanaged" class="feature-badge managed">Managed</span>
<span v-if="device.networkdevice.portcount" class="feature-badge ports">{{ device.networkdevice.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.networkdevice?.hostname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Firmware Version</span>
<span class="info-value">{{ device.networkdevice?.firmwareversion || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Port Count</span>
<span class="info-value">{{ device.networkdevice?.portcount || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Rack Unit</span>
<span class="info-value">{{ device.networkdevice?.rackunit || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">PoE Capable</span>
<span class="info-value">{{ device.networkdevice?.ispoe ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">Managed Device</span>
<span class="info-value">{{ device.networkdevice?.ismanaged ? 'Yes' : 'No' }}</span>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="device.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="device.assetid" />
<!-- 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" v-if="isEnabled('gaugelabreference', 'network_device') && device.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ device.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'network_device') && device.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ device.maintenancereference }}</span>
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ device.networkdevice?.vendorname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Device Type</span>
<span class="info-value">{{ device.networkdevice?.networkdevicetypename || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Status</span>
<span class="info-value">
<span class="badge" :style="colorStyle(device.statuscolor)">
{{ device.statusname || '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 { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router'
import { Network, Router, Shield, Wifi, Camera, Server, Server as Rack, Globe } from 'lucide-vue-next'
import { useAuthStore } from '../../stores/auth'
import { networkApi } from '../../api'
import AssetRelationships from '../../components/AssetRelationships.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const { isEnabled } = useIdentifierFlags()
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?.networkdevice?.networkdevicetypename?.toLowerCase() || ''
if (type.includes('switch')) return Network
if (type.includes('router')) return Router
if (type.includes('firewall')) return Shield
if (type.includes('access point') || type.includes('ap')) return Wifi
if (type.includes('camera')) return Camera
if (type.includes('server')) return Server
if (type.includes('idf') || type.includes('closet')) return Rack
return Globe
}
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.networkdevice?.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>
<template>
<div class="detail-page" v-if="device">
<div class="hero-card">
<div class="hero-image">
<div class="device-icon">
<span class="icon"><component :is="getDeviceIcon()" :size="24" /></span>
</div>
</div>
<div class="hero-content">
<div class="hero-title-row">
<h1 class="hero-title">{{ device.networkdevice?.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" :style="colorStyle(device.statuscolor)">
{{ device.statusname || 'Unknown' }}
</span>
<span v-if="device.networkdevice?.networkdevicetypename" class="meta-item">
{{ device.networkdevice.networkdevicetypename }}
</span>
<span v-if="device.networkdevice?.vendorname" class="meta-item">
{{ device.networkdevice.vendorname }}
</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.locationname">
<span class="label">Location</span>
<span class="value">{{ device.locationname }}</span>
</div>
<div class="detail-item" v-if="device.businessunitname">
<span class="label">Business Unit</span>
<span class="value">{{ device.businessunitname }}</span>
</div>
</div>
<div class="hero-features" v-if="device.networkdevice">
<span v-if="device.networkdevice.ispoe" class="feature-badge poe">PoE</span>
<span v-if="device.networkdevice.ismanaged" class="feature-badge managed">Managed</span>
<span v-if="device.networkdevice.portcount" class="feature-badge ports">{{ device.networkdevice.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.networkdevice?.hostname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Firmware Version</span>
<span class="info-value">{{ device.networkdevice?.firmwareversion || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Port Count</span>
<span class="info-value">{{ device.networkdevice?.portcount || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Rack Unit</span>
<span class="info-value">{{ device.networkdevice?.rackunit || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">PoE Capable</span>
<span class="info-value">{{ device.networkdevice?.ispoe ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">Managed Device</span>
<span class="info-value">{{ device.networkdevice?.ismanaged ? 'Yes' : 'No' }}</span>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="device.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="device.assetid" />
<!-- 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" v-if="isEnabled('gaugelabreference', 'network_device') && device.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ device.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'network_device') && device.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ device.maintenancereference }}</span>
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ device.networkdevice?.vendorname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Device Type</span>
<span class="info-value">{{ device.networkdevice?.networkdevicetypename || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Status</span>
<span class="info-value">
<span class="badge" :style="colorStyle(device.statuscolor)">
{{ device.statusname || '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 { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router'
import { Network, Router, Shield, Wifi, Camera, Server, Server as Rack, Globe } from 'lucide-vue-next'
import { useAuthStore } from '../../stores/auth'
import { networkApi } from '../../api'
import AssetRelationships from '../../components/AssetRelationships.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
import { useToast } from '../../composables/toast'
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
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?.networkdevice?.networkdevicetypename?.toLowerCase() || ''
if (type.includes('switch')) return Network
if (type.includes('router')) return Router
if (type.includes('firewall')) return Shield
if (type.includes('access point') || type.includes('ap')) return Wifi
if (type.includes('camera')) return Camera
if (type.includes('server')) return Server
if (type.includes('idf') || type.includes('closet')) return Rack
return Globe
}
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.networkdevice?.hostname || device.value.assetnumber}?`)) {
try {
await networkApi.delete(deviceId)
router.push('/network')
} catch (error) {
console.error('Error deleting device:', error)
toast.error('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

@@ -1,187 +1,189 @@
<template>
<div>
<div class="page-header">
<h2>Business Units</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Business Unit</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Business Unit</th>
<th>Code</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="bu in items" :key="bu.businessunitid">
<td>{{ bu.businessunit }}</td>
<td>{{ bu.code || '-' }}</td>
<td class="cell-truncate" :title="bu.description">{{ bu.description || '-' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(bu)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(bu)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No business units found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit Business Unit' : 'Add Business Unit' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="businessunit">Business Unit Name *</label>
<input id="businessunit" v-model="form.businessunit" type="text" class="form-control" required />
</div>
<div class="form-group">
<label for="code">Code</label>
<input id="code" v-model="form.code" type="text" class="form-control" placeholder="e.g., ENGR, MFG" />
</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 Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header"><h3>Delete Business Unit</h3></div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ toDelete?.businessunit }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteItem">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { businessunitsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const items = ref([])
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const toDelete = ref(null)
const form = ref({ businessunit: '', code: '', description: '' })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await businessunitsApi.list({ page: page.value, perpage: perPage.value })
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading business units:', err)
} finally {
loading.value = false
}
}
function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function openModal(item = null) {
editing.value = item
form.value = item ? {
businessunit: item.businessunit || '',
code: item.code || '',
description: item.description || ''
} : { businessunit: '', code: '', description: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await businessunitsApi.update(editing.value.businessunitid, form.value)
} else {
await businessunitsApi.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
async function deleteItem() {
try {
await businessunitsApi.delete(toDelete.value.businessunitid)
showDeleteModal.value = false
toDelete.value = null
loadData()
} catch (err) {
alert('Failed to delete')
}
}
</script>
<template>
<div>
<div class="page-header">
<h2>Business Units</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Business Unit</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Business Unit</th>
<th>Code</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="bu in items" :key="bu.businessunitid">
<td>{{ bu.businessunit }}</td>
<td>{{ bu.code || '-' }}</td>
<td class="cell-truncate" :title="bu.description">{{ bu.description || '-' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(bu)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(bu)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No business units found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit Business Unit' : 'Add Business Unit' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="businessunit">Business Unit Name *</label>
<input id="businessunit" v-model="form.businessunit" type="text" class="form-control" required />
</div>
<div class="form-group">
<label for="code">Code</label>
<input id="code" v-model="form.code" type="text" class="form-control" placeholder="e.g., ENGR, MFG" />
</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 Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header"><h3>Delete Business Unit</h3></div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ toDelete?.businessunit }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteItem">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { businessunitsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const toDelete = ref(null)
const form = ref({ businessunit: '', code: '', description: '' })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await businessunitsApi.list({ page: page.value, perpage: perPage.value })
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading business units:', err)
} finally {
loading.value = false
}
}
function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function openModal(item = null) {
editing.value = item
form.value = item ? {
businessunit: item.businessunit || '',
code: item.code || '',
description: item.description || ''
} : { businessunit: '', code: '', description: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await businessunitsApi.update(editing.value.businessunitid, form.value)
} else {
await businessunitsApi.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
async function deleteItem() {
try {
await businessunitsApi.delete(toDelete.value.businessunitid)
showDeleteModal.value = false
toDelete.value = null
loadData()
} catch (err) {
toast.error('Failed to delete')
}
}
</script>

View File

@@ -114,6 +114,8 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { assetsApi, customFieldsApi } from '../../api'
import { useToast } from '../../composables/toast'
const toast = useToast()
const assetTypes = ref([])
const assettypeid = ref(null)
@@ -206,7 +208,7 @@ async function deleteField(f) {
await customFieldsApi.remove(f.fieldid)
loadFields()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -104,6 +104,8 @@
<script setup>
import { ref, onMounted } from 'vue'
import { dashboardDefaultsApi, businessUnitsApi } from '../../api'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const businessUnits = ref([])
@@ -181,7 +183,7 @@ async function deleteItem() {
toDelete.value = null
loadData()
} catch (err) {
alert('Failed to delete')
toast.error('Failed to delete')
}
}
</script>

View File

@@ -76,6 +76,8 @@ import { ref, computed, onMounted } from 'vue'
import { equipmentApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const showInactive = ref(false)
@@ -136,7 +138,7 @@ async function deleteType(t) {
await equipmentApi.types.remove(t.equipmenttypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -76,6 +76,8 @@ import { ref, computed, onMounted } from 'vue'
import { locationsApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const showInactive = ref(false)
@@ -136,7 +138,7 @@ async function deleteType(t) {
await locationsApi.types.remove(t.locationtypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -1,385 +1,387 @@
<template>
<div>
<div class="page-header">
<h2>Locations</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Location</button>
</div>
<!-- Search -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search locations..."
@input="debouncedSearch"
/>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Location Name</th>
<th>Type</th>
<th>Building</th>
<th>Floor</th>
<th>Room</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="loc in locations" :key="loc.locationid">
<td>{{ loc.locationname }}</td>
<td>{{ loc.locationtypename || '-' }}</td>
<td>{{ loc.building || '-' }}</td>
<td>{{ loc.floor || '-' }}</td>
<td>{{ loc.room || '-' }}</td>
<td class="cell-truncate" :title="loc.description">{{ loc.description || '-' }}</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(loc)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(loc)"
>
Delete
</button>
</td>
</tr>
<tr v-if="locations.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
No locations found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editingLocation ? 'Edit Location' : 'Add Location' }}</h3>
</div>
<form @submit.prevent="saveLocation">
<div class="modal-body">
<div class="form-group">
<label for="locationname">Location Name *</label>
<input
id="locationname"
v-model="form.locationname"
type="text"
class="form-control"
required
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="building">Building</label>
<input
id="building"
v-model="form.building"
type="text"
class="form-control"
/>
</div>
<div class="form-group">
<label for="floor">Floor</label>
<input
id="floor"
v-model="form.floor"
type="text"
class="form-control"
/>
</div>
<div class="form-group">
<label for="room">Room</label>
<input
id="room"
v-model="form.room"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="locationtypeid">Type</label>
<select id="locationtypeid" v-model="form.locationtypeid" class="form-control">
<option value="">Select type...</option>
<option v-for="t in locationTypes" :key="t.locationtypeid" :value="t.locationtypeid">
{{ t.locationtype }}
</option>
</select>
</div>
<div class="form-group">
<label for="parentlocationid">Parent Location</label>
<select id="parentlocationid" v-model="form.parentlocationid" class="form-control">
<option value="">None</option>
<option
v-for="l in parentOptions"
:key="l.locationid"
:value="l.locationid"
>
{{ l.locationname }}
</option>
</select>
</div>
</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 class="form-group">
<label for="mapimage">Map Image URL</label>
<input
id="mapimage"
v-model="form.mapimage"
type="text"
class="form-control"
placeholder="Optional floor plan image URL"
/>
</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 Location</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ locationToDelete?.locationname }}</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
This may affect machines assigned to this location.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteLocation">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const locations = ref([])
const locationTypes = ref([])
const allLocations = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingLocation = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const locationToDelete = ref(null)
const form = ref({
locationname: '',
building: '',
floor: '',
room: '',
description: '',
locationtypeid: '',
parentlocationid: '',
mapimage: ''
})
// parent options = all locations except the one being edited (no self-parent)
const parentOptions = computed(() =>
allLocations.value.filter(l => l.locationid !== editingLocation.value?.locationid)
)
let searchTimeout = null
onMounted(async () => {
try {
const [typesRes, allRes] = await Promise.all([
locationsApi.types.list(),
locationsApi.list({ perpage: 100 })
])
locationTypes.value = typesRes.data.data || []
allLocations.value = allRes.data.data || []
} catch (err) {
console.error('Error loading location types:', err)
}
loadLocations()
})
async function loadLocations() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
const response = await locationsApi.list(params)
locations.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading locations:', err)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadLocations()
}, 300)
}
function goToPage(p) {
page.value = p
loadLocations()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadLocations()
}
function openModal(loc = null) {
editingLocation.value = loc
if (loc) {
form.value = {
locationname: loc.locationname || '',
building: loc.building || '',
floor: loc.floor || '',
room: loc.room || '',
description: loc.description || '',
locationtypeid: loc.locationtypeid || '',
parentlocationid: loc.parentlocationid || '',
mapimage: loc.mapimage || ''
}
} else {
form.value = {
locationname: '',
building: '',
floor: '',
room: '',
description: '',
locationtypeid: '',
parentlocationid: '',
mapimage: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingLocation.value = null
}
async function saveLocation() {
error.value = ''
saving.value = true
try {
const payload = {
...form.value,
locationtypeid: form.value.locationtypeid || null,
parentlocationid: form.value.parentlocationid || null
}
if (editingLocation.value) {
await locationsApi.update(editingLocation.value.locationid, payload)
} else {
await locationsApi.create(payload)
}
closeModal()
loadLocations()
} catch (err) {
console.error('Error saving location:', err)
error.value = err.response?.data?.message || 'Failed to save location'
} finally {
saving.value = false
}
}
function confirmDelete(loc) {
locationToDelete.value = loc
showDeleteModal.value = true
}
async function deleteLocation() {
try {
await locationsApi.delete(locationToDelete.value.locationid)
showDeleteModal.value = false
locationToDelete.value = null
loadLocations()
} catch (err) {
console.error('Error deleting location:', err)
alert('Failed to delete location')
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
</style>
<template>
<div>
<div class="page-header">
<h2>Locations</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Location</button>
</div>
<!-- Search -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search locations..."
@input="debouncedSearch"
/>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Location Name</th>
<th>Type</th>
<th>Building</th>
<th>Floor</th>
<th>Room</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="loc in locations" :key="loc.locationid">
<td>{{ loc.locationname }}</td>
<td>{{ loc.locationtypename || '-' }}</td>
<td>{{ loc.building || '-' }}</td>
<td>{{ loc.floor || '-' }}</td>
<td>{{ loc.room || '-' }}</td>
<td class="cell-truncate" :title="loc.description">{{ loc.description || '-' }}</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(loc)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(loc)"
>
Delete
</button>
</td>
</tr>
<tr v-if="locations.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
No locations found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editingLocation ? 'Edit Location' : 'Add Location' }}</h3>
</div>
<form @submit.prevent="saveLocation">
<div class="modal-body">
<div class="form-group">
<label for="locationname">Location Name *</label>
<input
id="locationname"
v-model="form.locationname"
type="text"
class="form-control"
required
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="building">Building</label>
<input
id="building"
v-model="form.building"
type="text"
class="form-control"
/>
</div>
<div class="form-group">
<label for="floor">Floor</label>
<input
id="floor"
v-model="form.floor"
type="text"
class="form-control"
/>
</div>
<div class="form-group">
<label for="room">Room</label>
<input
id="room"
v-model="form.room"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="locationtypeid">Type</label>
<select id="locationtypeid" v-model="form.locationtypeid" class="form-control">
<option value="">Select type...</option>
<option v-for="t in locationTypes" :key="t.locationtypeid" :value="t.locationtypeid">
{{ t.locationtype }}
</option>
</select>
</div>
<div class="form-group">
<label for="parentlocationid">Parent Location</label>
<select id="parentlocationid" v-model="form.parentlocationid" class="form-control">
<option value="">None</option>
<option
v-for="l in parentOptions"
:key="l.locationid"
:value="l.locationid"
>
{{ l.locationname }}
</option>
</select>
</div>
</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 class="form-group">
<label for="mapimage">Map Image URL</label>
<input
id="mapimage"
v-model="form.mapimage"
type="text"
class="form-control"
placeholder="Optional floor plan image URL"
/>
</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 Location</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ locationToDelete?.locationname }}</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
This may affect machines assigned to this location.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteLocation">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const locations = ref([])
const locationTypes = ref([])
const allLocations = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingLocation = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const locationToDelete = ref(null)
const form = ref({
locationname: '',
building: '',
floor: '',
room: '',
description: '',
locationtypeid: '',
parentlocationid: '',
mapimage: ''
})
// parent options = all locations except the one being edited (no self-parent)
const parentOptions = computed(() =>
allLocations.value.filter(l => l.locationid !== editingLocation.value?.locationid)
)
let searchTimeout = null
onMounted(async () => {
try {
const [typesRes, allRes] = await Promise.all([
locationsApi.types.list(),
locationsApi.list({ perpage: 100 })
])
locationTypes.value = typesRes.data.data || []
allLocations.value = allRes.data.data || []
} catch (err) {
console.error('Error loading location types:', err)
}
loadLocations()
})
async function loadLocations() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
const response = await locationsApi.list(params)
locations.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading locations:', err)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadLocations()
}, 300)
}
function goToPage(p) {
page.value = p
loadLocations()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadLocations()
}
function openModal(loc = null) {
editingLocation.value = loc
if (loc) {
form.value = {
locationname: loc.locationname || '',
building: loc.building || '',
floor: loc.floor || '',
room: loc.room || '',
description: loc.description || '',
locationtypeid: loc.locationtypeid || '',
parentlocationid: loc.parentlocationid || '',
mapimage: loc.mapimage || ''
}
} else {
form.value = {
locationname: '',
building: '',
floor: '',
room: '',
description: '',
locationtypeid: '',
parentlocationid: '',
mapimage: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingLocation.value = null
}
async function saveLocation() {
error.value = ''
saving.value = true
try {
const payload = {
...form.value,
locationtypeid: form.value.locationtypeid || null,
parentlocationid: form.value.parentlocationid || null
}
if (editingLocation.value) {
await locationsApi.update(editingLocation.value.locationid, payload)
} else {
await locationsApi.create(payload)
}
closeModal()
loadLocations()
} catch (err) {
console.error('Error saving location:', err)
error.value = err.response?.data?.message || 'Failed to save location'
} finally {
saving.value = false
}
}
function confirmDelete(loc) {
locationToDelete.value = loc
showDeleteModal.value = true
}
async function deleteLocation() {
try {
await locationsApi.delete(locationToDelete.value.locationid)
showDeleteModal.value = false
locationToDelete.value = null
loadLocations()
} catch (err) {
console.error('Error deleting location:', err)
toast.error('Failed to delete location')
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
</style>

View File

@@ -145,6 +145,8 @@
import { ref, onMounted } from 'vue'
import { machinetypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const machineTypes = ref([])
const loading = ref(true)
@@ -256,7 +258,7 @@ async function deleteType() {
loadTypes()
} catch (err) {
console.error('Error deleting machine type:', err)
alert('Failed to delete machine type')
toast.error('Failed to delete machine type')
}
}

View File

@@ -1,368 +1,370 @@
<template>
<div>
<div class="page-header">
<h2>Models</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Model</button>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search models..."
@input="debouncedSearch"
/>
<select v-model="vendorFilter" class="form-control" @change="loadModels">
<option value="">All Vendors</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</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>Model</th>
<th>Vendor</th>
<th>Type</th>
<th>Documentation</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="m in models" :key="m.modelnumberid">
<td>
<div>{{ m.modelnumber }}</div>
<small v-if="m.description" class="text-muted">{{ m.description }}</small>
</td>
<td>{{ m.vendor || '-' }}</td>
<td>{{ m.machinetype || '-' }}</td>
<td>
<a v-if="m.documentationurl" :href="m.documentationurl" target="_blank" class="btn btn-sm btn-link">
View Docs
</a>
<span v-else>-</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(m)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(m)">Delete</button>
</td>
</tr>
<tr v-if="models.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No models found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</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>{{ editingModel ? 'Edit Model' : 'Add Model' }}</h3>
</div>
<form @submit.prevent="saveModel">
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label for="modelnumber">Model Number *</label>
<input
id="modelnumber"
v-model="form.modelnumber"
type="text"
class="form-control"
required
/>
</div>
<div class="form-group">
<label for="vendorid">Vendor *</label>
<select id="vendorid" v-model="form.vendorid" class="form-control" required>
<option value="">Select vendor...</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="machinetypeid">Machine Type</label>
<select id="machinetypeid" v-model="form.machinetypeid" class="form-control">
<option value="">Select type...</option>
<option v-for="mt in machineTypes" :key="mt.machinetypeid" :value="mt.machinetypeid">
{{ mt.machinetype }}
</option>
</select>
</div>
<div class="form-group">
<label for="description">Description</label>
<input id="description" v-model="form.description" type="text" class="form-control" />
</div>
</div>
<div class="form-group">
<label for="documentationurl">Documentation URL</label>
<input
id="documentationurl"
v-model="form.documentationurl"
type="url"
class="form-control"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label for="imageurl">Image URL</label>
<input
id="imageurl"
v-model="form.imageurl"
type="url"
class="form-control"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" v-model="form.notes" class="form-control" rows="2"></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 Model</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ modelToDelete?.modelnumber }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteModel">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { modelsApi, vendorsApi, machinetypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const models = ref([])
const vendors = ref([])
const machineTypes = ref([])
const loading = ref(true)
const search = ref('')
const vendorFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingModel = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const modelToDelete = ref(null)
const form = ref({
modelnumber: '',
vendorid: '',
machinetypeid: '',
description: '',
documentationurl: '',
imageurl: '',
notes: ''
})
let searchTimeout = null
onMounted(async () => {
await Promise.all([
loadModels(),
loadVendors(),
loadMachineTypes()
])
})
async function loadModels() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
if (vendorFilter.value) params.vendor = vendorFilter.value
const response = await modelsApi.list(params)
models.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading models:', err)
} finally {
loading.value = false
}
}
async function loadVendors() {
try {
const response = await vendorsApi.list({ perpage: 100 })
vendors.value = response.data.data || []
} catch (err) {
console.error('Error loading vendors:', err)
}
}
async function loadMachineTypes() {
try {
const response = await machinetypesApi.list({ perpage: 100 })
machineTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading machine types:', err)
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadModels()
}, 300)
}
function goToPage(p) {
page.value = p
loadModels()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadModels()
}
function openModal(m = null) {
editingModel.value = m
if (m) {
form.value = {
modelnumber: m.modelnumber || '',
vendorid: m.vendorid || '',
machinetypeid: m.machinetypeid || '',
description: m.description || '',
documentationurl: m.documentationurl || '',
imageurl: m.imageurl || '',
notes: m.notes || ''
}
} else {
form.value = {
modelnumber: '',
vendorid: '',
machinetypeid: '',
description: '',
documentationurl: '',
imageurl: '',
notes: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingModel.value = null
}
async function saveModel() {
error.value = ''
saving.value = true
try {
const data = { ...form.value }
if (!data.vendorid) data.vendorid = null
if (!data.machinetypeid) data.machinetypeid = null
if (editingModel.value) {
await modelsApi.update(editingModel.value.modelnumberid, data)
} else {
await modelsApi.create(data)
}
closeModal()
loadModels()
} catch (err) {
console.error('Error saving model:', err)
error.value = err.response?.data?.message || 'Failed to save model'
} finally {
saving.value = false
}
}
function confirmDelete(m) {
modelToDelete.value = m
showDeleteModal.value = true
}
async function deleteModel() {
try {
await modelsApi.delete(modelToDelete.value.modelnumberid)
showDeleteModal.value = false
modelToDelete.value = null
loadModels()
} catch (err) {
console.error('Error deleting model:', err)
alert('Failed to delete model')
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.modal-lg {
max-width: 600px;
}
.text-muted {
color: var(--text-light);
font-size: 0.85rem;
}
</style>
<template>
<div>
<div class="page-header">
<h2>Models</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Model</button>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search models..."
@input="debouncedSearch"
/>
<select v-model="vendorFilter" class="form-control" @change="loadModels">
<option value="">All Vendors</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</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>Model</th>
<th>Vendor</th>
<th>Type</th>
<th>Documentation</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="m in models" :key="m.modelnumberid">
<td>
<div>{{ m.modelnumber }}</div>
<small v-if="m.description" class="text-muted">{{ m.description }}</small>
</td>
<td>{{ m.vendor || '-' }}</td>
<td>{{ m.machinetype || '-' }}</td>
<td>
<a v-if="m.documentationurl" :href="m.documentationurl" target="_blank" class="btn btn-sm btn-link">
View Docs
</a>
<span v-else>-</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(m)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(m)">Delete</button>
</td>
</tr>
<tr v-if="models.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No models found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</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>{{ editingModel ? 'Edit Model' : 'Add Model' }}</h3>
</div>
<form @submit.prevent="saveModel">
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label for="modelnumber">Model Number *</label>
<input
id="modelnumber"
v-model="form.modelnumber"
type="text"
class="form-control"
required
/>
</div>
<div class="form-group">
<label for="vendorid">Vendor *</label>
<select id="vendorid" v-model="form.vendorid" class="form-control" required>
<option value="">Select vendor...</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="machinetypeid">Machine Type</label>
<select id="machinetypeid" v-model="form.machinetypeid" class="form-control">
<option value="">Select type...</option>
<option v-for="mt in machineTypes" :key="mt.machinetypeid" :value="mt.machinetypeid">
{{ mt.machinetype }}
</option>
</select>
</div>
<div class="form-group">
<label for="description">Description</label>
<input id="description" v-model="form.description" type="text" class="form-control" />
</div>
</div>
<div class="form-group">
<label for="documentationurl">Documentation URL</label>
<input
id="documentationurl"
v-model="form.documentationurl"
type="url"
class="form-control"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label for="imageurl">Image URL</label>
<input
id="imageurl"
v-model="form.imageurl"
type="url"
class="form-control"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" v-model="form.notes" class="form-control" rows="2"></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 Model</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ modelToDelete?.modelnumber }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteModel">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { modelsApi, vendorsApi, machinetypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const models = ref([])
const vendors = ref([])
const machineTypes = ref([])
const loading = ref(true)
const search = ref('')
const vendorFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingModel = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const modelToDelete = ref(null)
const form = ref({
modelnumber: '',
vendorid: '',
machinetypeid: '',
description: '',
documentationurl: '',
imageurl: '',
notes: ''
})
let searchTimeout = null
onMounted(async () => {
await Promise.all([
loadModels(),
loadVendors(),
loadMachineTypes()
])
})
async function loadModels() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
if (vendorFilter.value) params.vendor = vendorFilter.value
const response = await modelsApi.list(params)
models.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading models:', err)
} finally {
loading.value = false
}
}
async function loadVendors() {
try {
const response = await vendorsApi.list({ perpage: 100 })
vendors.value = response.data.data || []
} catch (err) {
console.error('Error loading vendors:', err)
}
}
async function loadMachineTypes() {
try {
const response = await machinetypesApi.list({ perpage: 100 })
machineTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading machine types:', err)
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadModels()
}, 300)
}
function goToPage(p) {
page.value = p
loadModels()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadModels()
}
function openModal(m = null) {
editingModel.value = m
if (m) {
form.value = {
modelnumber: m.modelnumber || '',
vendorid: m.vendorid || '',
machinetypeid: m.machinetypeid || '',
description: m.description || '',
documentationurl: m.documentationurl || '',
imageurl: m.imageurl || '',
notes: m.notes || ''
}
} else {
form.value = {
modelnumber: '',
vendorid: '',
machinetypeid: '',
description: '',
documentationurl: '',
imageurl: '',
notes: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingModel.value = null
}
async function saveModel() {
error.value = ''
saving.value = true
try {
const data = { ...form.value }
if (!data.vendorid) data.vendorid = null
if (!data.machinetypeid) data.machinetypeid = null
if (editingModel.value) {
await modelsApi.update(editingModel.value.modelnumberid, data)
} else {
await modelsApi.create(data)
}
closeModal()
loadModels()
} catch (err) {
console.error('Error saving model:', err)
error.value = err.response?.data?.message || 'Failed to save model'
} finally {
saving.value = false
}
}
function confirmDelete(m) {
modelToDelete.value = m
showDeleteModal.value = true
}
async function deleteModel() {
try {
await modelsApi.delete(modelToDelete.value.modelnumberid)
showDeleteModal.value = false
modelToDelete.value = null
loadModels()
} catch (err) {
console.error('Error deleting model:', err)
toast.error('Failed to delete model')
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.modal-lg {
max-width: 600px;
}
.text-muted {
color: var(--text-light);
font-size: 0.85rem;
}
</style>

View File

@@ -76,6 +76,8 @@ import { ref, computed, onMounted } from 'vue'
import { networkApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const showInactive = ref(false)
@@ -136,7 +138,7 @@ async function deleteType(t) {
await networkApi.types.remove(t.networkdevicetypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -1,224 +1,226 @@
<template>
<div>
<div class="page-header">
<h2>Operating Systems</h2>
<button class="btn btn-primary" @click="openModal()">+ Add OS</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>OS Name</th>
<th>Version</th>
<th>Architecture</th>
<th>End of Life</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="os in items" :key="os.osid">
<td>{{ os.osname }}</td>
<td>{{ os.osversion || '-' }}</td>
<td>{{ os.architecture || '-' }}</td>
<td>
<span v-if="os.endoflife" :class="{ 'text-danger': isPastEol(os.endoflife) }">
{{ os.endoflife }}
</span>
<span v-else>-</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(os)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(os)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No operating systems found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit Operating System' : 'Add Operating System' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="osname">OS Name *</label>
<input id="osname" v-model="form.osname" type="text" class="form-control" required placeholder="Windows 11" />
</div>
<div class="form-row">
<div class="form-group">
<label for="osversion">Version</label>
<input id="osversion" v-model="form.osversion" type="text" class="form-control" placeholder="23H2" />
</div>
<div class="form-group">
<label for="architecture">Architecture</label>
<select id="architecture" v-model="form.architecture" class="form-control">
<option value="">Select...</option>
<option value="x64">x64</option>
<option value="x86">x86</option>
<option value="ARM64">ARM64</option>
</select>
</div>
</div>
<div class="form-group">
<label for="endoflife">End of Life Date</label>
<input id="endoflife" v-model="form.endoflife" type="date" class="form-control" />
</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 Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header"><h3>Delete Operating System</h3></div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ toDelete?.osname }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteItem">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { operatingsystemsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const items = ref([])
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const toDelete = ref(null)
const form = ref({ osname: '', osversion: '', architecture: '', endoflife: '' })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await operatingsystemsApi.list({ page: page.value, perpage: perPage.value })
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading operating systems:', err)
} finally {
loading.value = false
}
}
function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function isPastEol(date) {
return new Date(date) < new Date()
}
function openModal(item = null) {
editing.value = item
form.value = item ? {
osname: item.osname || '',
osversion: item.osversion || '',
architecture: item.architecture || '',
endoflife: item.endoflife || ''
} : { osname: '', osversion: '', architecture: '', endoflife: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
const data = { ...form.value }
if (!data.endoflife) data.endoflife = null
if (editing.value) {
await operatingsystemsApi.update(editing.value.osid, data)
} else {
await operatingsystemsApi.create(data)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
async function deleteItem() {
try {
await operatingsystemsApi.delete(toDelete.value.osid)
showDeleteModal.value = false
toDelete.value = null
loadData()
} catch (err) {
alert('Failed to delete')
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.text-danger {
color: var(--danger);
font-weight: 500;
}
</style>
<template>
<div>
<div class="page-header">
<h2>Operating Systems</h2>
<button class="btn btn-primary" @click="openModal()">+ Add OS</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>OS Name</th>
<th>Version</th>
<th>Architecture</th>
<th>End of Life</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="os in items" :key="os.osid">
<td>{{ os.osname }}</td>
<td>{{ os.osversion || '-' }}</td>
<td>{{ os.architecture || '-' }}</td>
<td>
<span v-if="os.endoflife" :class="{ 'text-danger': isPastEol(os.endoflife) }">
{{ os.endoflife }}
</span>
<span v-else>-</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(os)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(os)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No operating systems found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit Operating System' : 'Add Operating System' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="osname">OS Name *</label>
<input id="osname" v-model="form.osname" type="text" class="form-control" required placeholder="Windows 11" />
</div>
<div class="form-row">
<div class="form-group">
<label for="osversion">Version</label>
<input id="osversion" v-model="form.osversion" type="text" class="form-control" placeholder="23H2" />
</div>
<div class="form-group">
<label for="architecture">Architecture</label>
<select id="architecture" v-model="form.architecture" class="form-control">
<option value="">Select...</option>
<option value="x64">x64</option>
<option value="x86">x86</option>
<option value="ARM64">ARM64</option>
</select>
</div>
</div>
<div class="form-group">
<label for="endoflife">End of Life Date</label>
<input id="endoflife" v-model="form.endoflife" type="date" class="form-control" />
</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 Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header"><h3>Delete Operating System</h3></div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ toDelete?.osname }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteItem">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { operatingsystemsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const toDelete = ref(null)
const form = ref({ osname: '', osversion: '', architecture: '', endoflife: '' })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await operatingsystemsApi.list({ page: page.value, perpage: perPage.value })
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading operating systems:', err)
} finally {
loading.value = false
}
}
function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function isPastEol(date) {
return new Date(date) < new Date()
}
function openModal(item = null) {
editing.value = item
form.value = item ? {
osname: item.osname || '',
osversion: item.osversion || '',
architecture: item.architecture || '',
endoflife: item.endoflife || ''
} : { osname: '', osversion: '', architecture: '', endoflife: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
const data = { ...form.value }
if (!data.endoflife) data.endoflife = null
if (editing.value) {
await operatingsystemsApi.update(editing.value.osid, data)
} else {
await operatingsystemsApi.create(data)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
async function deleteItem() {
try {
await operatingsystemsApi.delete(toDelete.value.osid)
showDeleteModal.value = false
toDelete.value = null
loadData()
} catch (err) {
toast.error('Failed to delete')
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.text-danger {
color: var(--danger);
font-weight: 500;
}
</style>

View File

@@ -86,6 +86,8 @@ import { ref, computed, onMounted } from 'vue'
import { computersApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
const toast = useToast()
const pcTypes = ref([])
const showInactive = ref(false)
@@ -130,7 +132,7 @@ async function deleteType(pt) {
await computersApi.types.remove(pt.computertypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}

View File

@@ -97,6 +97,8 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { printersApi } from '../../api'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const models = ref([])
@@ -171,7 +173,7 @@ async function deleteDriver(d) {
await printersApi.drivers.delete(d.driverid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -76,6 +76,8 @@ import { ref, computed, onMounted } from 'vue'
import { printersApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const showInactive = ref(false)
@@ -136,7 +138,7 @@ async function deleteType(t) {
await printersApi.types.remove(t.printertypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -76,6 +76,8 @@ import { ref, computed, onMounted } from 'vue'
import { relationshipTypesApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const showInactive = ref(false)
@@ -136,7 +138,7 @@ async function deleteType(t) {
await relationshipTypesApi.remove(t.relationshiptypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -1,315 +1,317 @@
<template>
<div>
<div class="page-header">
<h2>Asset Statuses</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Status</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Status</th>
<th>Color</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="s in statuses" :key="s.statusid">
<td>
<span class="status-badge" :style="getStatusStyle(s.color)">
{{ s.status }}
</span>
</td>
<td>
<span class="color-preview" :style="{ backgroundColor: s.color || '#6c757d' }"></span>
{{ s.color || 'default' }}
</td>
<td class="cell-truncate" :title="s.description">{{ s.description || '-' }}</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(s)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(s)"
>
Delete
</button>
</td>
</tr>
<tr v-if="statuses.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No statuses found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editingStatus ? 'Edit Status' : 'Add Status' }}</h3>
</div>
<form @submit.prevent="saveStatus">
<div class="modal-body">
<div class="form-group">
<label for="status">Status Name *</label>
<input
id="status"
v-model="form.status"
type="text"
class="form-control"
required
/>
</div>
<div class="form-group">
<label>Color</label>
<ColorSwatchPicker v-model="form.color" />
<small class="form-hint">Used in UI to visually distinguish statuses</small>
</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 Status</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ statusToDelete?.status }}</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
Cannot delete if assets are using this status.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteStatus">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { assetsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
const statuses = ref([])
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingStatus = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const statusToDelete = ref(null)
const form = ref({
status: '',
color: '#6c757d',
description: ''
})
onMounted(() => {
loadStatuses()
})
async function loadStatuses() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
const response = await assetsApi.statuses.list(params)
statuses.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading statuses:', err)
} finally {
loading.value = false
}
}
function goToPage(p) {
page.value = p
loadStatuses()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadStatuses()
}
function openModal(s = null) {
editingStatus.value = s
if (s) {
form.value = {
status: s.status || '',
color: s.color || '#6c757d',
description: s.description || ''
}
} else {
form.value = {
status: '',
color: '#6c757d',
description: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingStatus.value = null
}
async function saveStatus() {
error.value = ''
saving.value = true
try {
if (editingStatus.value) {
await assetsApi.statuses.update(editingStatus.value.statusid, form.value)
} else {
await assetsApi.statuses.create(form.value)
}
closeModal()
loadStatuses()
} catch (err) {
console.error('Error saving status:', err)
error.value = err.response?.data?.message || 'Failed to save status'
} finally {
saving.value = false
}
}
function confirmDelete(s) {
statusToDelete.value = s
showDeleteModal.value = true
}
async function deleteStatus() {
try {
await assetsApi.statuses.delete(statusToDelete.value.statusid)
showDeleteModal.value = false
statusToDelete.value = null
loadStatuses()
} catch (err) {
console.error('Error deleting status:', err)
error.value = err.response?.data?.message || 'Failed to delete status'
alert(error.value)
}
}
function getStatusStyle(color) {
const bgColor = color || '#6c757d'
return {
backgroundColor: bgColor,
color: isLightColor(bgColor) ? '#000' : '#fff'
}
}
function isLightColor(color) {
if (!color) return false
const hex = color.replace('#', '')
const r = parseInt(hex.substr(0, 2), 16)
const g = parseInt(hex.substr(2, 2), 16)
const b = parseInt(hex.substr(4, 2), 16)
const brightness = (r * 299 + g * 587 + b * 114) / 1000
return brightness > 128
}
</script>
<style scoped>
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
}
.color-preview {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 3px;
margin-right: 0.5rem;
vertical-align: middle;
border: 1px solid var(--border-color);
}
.color-input-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.color-picker {
width: 50px;
height: 38px;
padding: 2px;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
}
.form-hint {
color: var(--text-light);
font-size: 0.75rem;
margin-top: 0.25rem;
}
</style>
<template>
<div>
<div class="page-header">
<h2>Asset Statuses</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Status</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Status</th>
<th>Color</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="s in statuses" :key="s.statusid">
<td>
<span class="status-badge" :style="getStatusStyle(s.color)">
{{ s.status }}
</span>
</td>
<td>
<span class="color-preview" :style="{ backgroundColor: s.color || '#6c757d' }"></span>
{{ s.color || 'default' }}
</td>
<td class="cell-truncate" :title="s.description">{{ s.description || '-' }}</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(s)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(s)"
>
Delete
</button>
</td>
</tr>
<tr v-if="statuses.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No statuses found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editingStatus ? 'Edit Status' : 'Add Status' }}</h3>
</div>
<form @submit.prevent="saveStatus">
<div class="modal-body">
<div class="form-group">
<label for="status">Status Name *</label>
<input
id="status"
v-model="form.status"
type="text"
class="form-control"
required
/>
</div>
<div class="form-group">
<label>Color</label>
<ColorSwatchPicker v-model="form.color" />
<small class="form-hint">Used in UI to visually distinguish statuses</small>
</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 Status</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ statusToDelete?.status }}</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
Cannot delete if assets are using this status.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteStatus">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { assetsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const statuses = ref([])
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingStatus = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const statusToDelete = ref(null)
const form = ref({
status: '',
color: '#6c757d',
description: ''
})
onMounted(() => {
loadStatuses()
})
async function loadStatuses() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
const response = await assetsApi.statuses.list(params)
statuses.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading statuses:', err)
} finally {
loading.value = false
}
}
function goToPage(p) {
page.value = p
loadStatuses()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadStatuses()
}
function openModal(s = null) {
editingStatus.value = s
if (s) {
form.value = {
status: s.status || '',
color: s.color || '#6c757d',
description: s.description || ''
}
} else {
form.value = {
status: '',
color: '#6c757d',
description: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingStatus.value = null
}
async function saveStatus() {
error.value = ''
saving.value = true
try {
if (editingStatus.value) {
await assetsApi.statuses.update(editingStatus.value.statusid, form.value)
} else {
await assetsApi.statuses.create(form.value)
}
closeModal()
loadStatuses()
} catch (err) {
console.error('Error saving status:', err)
error.value = err.response?.data?.message || 'Failed to save status'
} finally {
saving.value = false
}
}
function confirmDelete(s) {
statusToDelete.value = s
showDeleteModal.value = true
}
async function deleteStatus() {
try {
await assetsApi.statuses.delete(statusToDelete.value.statusid)
showDeleteModal.value = false
statusToDelete.value = null
loadStatuses()
} catch (err) {
console.error('Error deleting status:', err)
error.value = err.response?.data?.message || 'Failed to delete status'
toast.error(error.value)
}
}
function getStatusStyle(color) {
const bgColor = color || '#6c757d'
return {
backgroundColor: bgColor,
color: isLightColor(bgColor) ? '#000' : '#fff'
}
}
function isLightColor(color) {
if (!color) return false
const hex = color.replace('#', '')
const r = parseInt(hex.substr(0, 2), 16)
const g = parseInt(hex.substr(2, 2), 16)
const b = parseInt(hex.substr(4, 2), 16)
const brightness = (r * 299 + g * 587 + b * 114) / 1000
return brightness > 128
}
</script>
<style scoped>
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
}
.color-preview {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 3px;
margin-right: 0.5rem;
vertical-align: middle;
border: 1px solid var(--border-color);
}
.color-input-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.color-picker {
width: 50px;
height: 38px;
padding: 2px;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
}
.form-hint {
color: var(--text-light);
font-size: 0.75rem;
margin-top: 0.25rem;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,374 +1,376 @@
<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 class="cell-truncate" :title="vlan.description">{{ 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 -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</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'
import PaginationBar from '../../components/PaginationBar.vue'
const vlans = ref([])
const loading = ref(true)
const search = ref('')
const typeFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
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,
perpage: perPage.value
}
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?.totalpages || 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 changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
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>
<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 class="cell-truncate" :title="vlan.description">{{ 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 -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</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'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const vlans = ref([])
const loading = ref(true)
const search = ref('')
const typeFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
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,
perpage: perPage.value
}
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?.totalpages || 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 changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
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)
toast.error(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

@@ -168,6 +168,8 @@ import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { usbApi } from '../../api'
import Modal from '../../components/Modal.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const route = useRoute()
@@ -217,7 +219,7 @@ async function doCheckout() {
await loadDevice()
} catch (error) {
console.error('Checkout error:', error)
alert(error.response?.data?.message || 'Checkout failed')
toast.error(error.response?.data?.message || 'Checkout failed')
}
}
@@ -228,7 +230,7 @@ async function doCheckin() {
await loadDevice()
} catch (error) {
console.error('Checkin error:', error)
alert(error.response?.data?.message || 'Check in failed')
toast.error(error.response?.data?.message || 'Check in failed')
}
}

View File

@@ -147,6 +147,8 @@ import { usbApi } from '../../api'
import Modal from '../../components/Modal.vue'
import EmployeeSearch from '../../components/EmployeeSearch.vue'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const devices = ref([])
const loading = ref(true)
@@ -241,7 +243,7 @@ async function doCheckout() {
loadDevices()
} catch (error) {
console.error('Checkout error:', error)
alert(error.response?.data?.message || 'Checkout failed')
toast.error(error.response?.data?.message || 'Checkout failed')
}
}
@@ -252,7 +254,7 @@ async function doCheckin() {
loadDevices()
} catch (error) {
console.error('Checkin error:', error)
alert(error.response?.data?.message || 'Check in failed')
toast.error(error.response?.data?.message || 'Check in failed')
}
}

View File

@@ -186,6 +186,8 @@
import { ref, onMounted } from 'vue'
import { vendorsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const vendors = ref([])
const loading = ref(true)
@@ -321,7 +323,7 @@ async function deleteVendor() {
loadVendors()
} catch (err) {
console.error('Error deleting vendor:', err)
alert('Failed to delete vendor')
toast.error('Failed to delete vendor')
}
}
</script>

View File

@@ -5,6 +5,7 @@
<button class="btn btn-primary" @click="openModal()">+ Add Warranty</button>
</div>
<div class="filters">
<label>Status
<select v-model="statusFilter" class="form-control" @change="loadData">
@@ -138,6 +139,9 @@
import { ref, onMounted } from 'vue'
import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi, assetsApi } from '../../api'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const loading = ref(true)
@@ -261,7 +265,7 @@ async function deleteWarranty(w) {
await warrantyApi.remove(w.warrantyid)
loadData()
} catch (err) {
alert(apiError(err, 'Failed to delete'))
toast.error(apiError(err, 'Failed to delete'))
}
}
@@ -270,9 +274,11 @@ async function refresh(w) {
const response = await warrantyApi.refresh(w.warrantyid)
loadData()
const updated = response.data?.data
if (updated) alert(`Refreshed: ${updated.vendor} - ${updated.servicelevel || 'coverage'} ends ${updated.enddate || 'unknown'}`)
if (updated) {
toast.success(`${updated.vendor}: ${updated.servicelevel || 'coverage'} ends ${updated.enddate || 'unknown'}`)
}
} catch (err) {
alert(apiError(err, 'Refresh failed'))
toast.error(apiError(err, 'Refresh failed'))
}
}
</script>