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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -86,6 +86,8 @@ import { ref, computed, onMounted } from 'vue'
import { computersApi } from '../../api' import { computersApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue' import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle' import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
const toast = useToast()
const pcTypes = ref([]) const pcTypes = ref([])
const showInactive = ref(false) const showInactive = ref(false)
@@ -130,7 +132,7 @@ async function deleteType(pt) {
await computersApi.types.remove(pt.computertypeid) await computersApi.types.remove(pt.computertypeid)
loadData() loadData()
} catch (err) { } 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> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { printersApi } from '../../api' import { printersApi } from '../../api'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([]) const items = ref([])
const models = ref([]) const models = ref([])
@@ -171,7 +173,7 @@ async function deleteDriver(d) {
await printersApi.drivers.delete(d.driverid) await printersApi.drivers.delete(d.driverid)
loadData() loadData()
} catch (err) { } 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> </script>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -168,6 +168,8 @@ import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { usbApi } from '../../api' import { usbApi } from '../../api'
import Modal from '../../components/Modal.vue' import Modal from '../../components/Modal.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const route = useRoute() const route = useRoute()
@@ -217,7 +219,7 @@ async function doCheckout() {
await loadDevice() await loadDevice()
} catch (error) { } catch (error) {
console.error('Checkout error:', 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() await loadDevice()
} catch (error) { } catch (error) {
console.error('Checkin error:', 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 Modal from '../../components/Modal.vue'
import EmployeeSearch from '../../components/EmployeeSearch.vue' import EmployeeSearch from '../../components/EmployeeSearch.vue'
import PaginationBar from '../../components/PaginationBar.vue' import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const devices = ref([]) const devices = ref([])
const loading = ref(true) const loading = ref(true)
@@ -241,7 +243,7 @@ async function doCheckout() {
loadDevices() loadDevices()
} catch (error) { } catch (error) {
console.error('Checkout error:', 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() loadDevices()
} catch (error) { } catch (error) {
console.error('Checkin error:', 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 { ref, onMounted } from 'vue'
import { vendorsApi } from '../../api' import { vendorsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue' import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const vendors = ref([]) const vendors = ref([])
const loading = ref(true) const loading = ref(true)
@@ -321,7 +323,7 @@ async function deleteVendor() {
loadVendors() loadVendors()
} catch (err) { } catch (err) {
console.error('Error deleting vendor:', err) console.error('Error deleting vendor:', err)
alert('Failed to delete vendor') toast.error('Failed to delete vendor')
} }
} }
</script> </script>

View File

@@ -5,6 +5,7 @@
<button class="btn btn-primary" @click="openModal()">+ Add Warranty</button> <button class="btn btn-primary" @click="openModal()">+ Add Warranty</button>
</div> </div>
<div class="filters"> <div class="filters">
<label>Status <label>Status
<select v-model="statusFilter" class="form-control" @change="loadData"> <select v-model="statusFilter" class="form-control" @change="loadData">
@@ -138,6 +139,9 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { colorStyle } from '@/utils/colorStyle' import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi, assetsApi } from '../../api' import { warrantyApi, assetsApi } from '../../api'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([]) const items = ref([])
const loading = ref(true) const loading = ref(true)
@@ -261,7 +265,7 @@ async function deleteWarranty(w) {
await warrantyApi.remove(w.warrantyid) await warrantyApi.remove(w.warrantyid)
loadData() loadData()
} catch (err) { } 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) const response = await warrantyApi.refresh(w.warrantyid)
loadData() loadData()
const updated = response.data?.data 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) { } catch (err) {
alert(apiError(err, 'Refresh failed')) toast.error(apiError(err, 'Refresh failed'))
} }
} }
</script> </script>