Add USB, Notifications, Network plugins and reusable EmployeeSearch component
New Plugins: - USB plugin: Device checkout/checkin with employee lookup, checkout history - Notifications plugin: Announcements with types, scheduling, shopfloor display - Network plugin: Network device management with subnets and VLANs - Equipment and Computers plugins: Asset type separation Frontend: - EmployeeSearch component: Reusable employee lookup with autocomplete - USB views: List, detail, checkout/checkin modals - Notifications views: List, form with recognition mode - Network views: Device list, detail, form - Calendar view with FullCalendar integration - Shopfloor and TV dashboard views - Reports index page - Map editor for asset positioning - Light/dark mode fixes for map tooltips Backend: - Employee search API with external lookup service - Collector API for PowerShell data collection - Reports API endpoints - Slides API for TV dashboard - Fixed AppVersion model (removed BaseModel inheritance) - Added checkout_name column to usbcheckouts table Styling: - Unified detail page styles - Improved pagination (page numbers instead of prev/next) - Dark/light mode theme improvements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
679
frontend/src/components/AssetRelationships.vue
Normal file
679
frontend/src/components/AssetRelationships.vue
Normal file
@@ -0,0 +1,679 @@
|
||||
<template>
|
||||
<div class="relationships-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">Relationships</h3>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="btn btn-sm btn-primary"
|
||||
@click="showAddModal = true"
|
||||
>
|
||||
Add Relationship
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading relationships...</div>
|
||||
|
||||
<div v-else-if="!hasRelationships" class="empty-state">
|
||||
No relationships defined.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Outgoing relationships (this asset controls/connects to...) -->
|
||||
<div v-if="outgoing.length > 0" class="relationship-group">
|
||||
<h4 class="group-title">Outgoing</h4>
|
||||
<div class="relationship-list">
|
||||
<div
|
||||
v-for="rel in outgoing"
|
||||
:key="rel.relationshipid"
|
||||
class="relationship-item"
|
||||
>
|
||||
<div class="rel-icon">{{ getAssetIcon(rel.target_asset?.assettype) }}</div>
|
||||
<div class="rel-content">
|
||||
<router-link :to="getAssetRoute(rel.target_asset)" class="rel-name">
|
||||
{{ rel.target_asset?.name || rel.target_asset?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
<div class="rel-meta">
|
||||
<span class="badge badge-outline">{{ rel.relationship_type_name }}</span>
|
||||
<span class="rel-type-badge">{{ rel.target_asset?.assettype }}</span>
|
||||
</div>
|
||||
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="btn-icon delete"
|
||||
@click="deleteRelationship(rel.relationshipid)"
|
||||
title="Remove relationship"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incoming relationships (...controls/connects to this asset) -->
|
||||
<div v-if="incoming.length > 0" class="relationship-group">
|
||||
<h4 class="group-title">Incoming</h4>
|
||||
<div class="relationship-list">
|
||||
<div
|
||||
v-for="rel in incoming"
|
||||
:key="rel.relationshipid"
|
||||
class="relationship-item"
|
||||
>
|
||||
<div class="rel-icon">{{ getAssetIcon(rel.source_asset?.assettype) }}</div>
|
||||
<div class="rel-content">
|
||||
<router-link :to="getAssetRoute(rel.source_asset)" class="rel-name">
|
||||
{{ rel.source_asset?.name || rel.source_asset?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
<div class="rel-meta">
|
||||
<span class="badge badge-outline">{{ rel.relationship_type_name }}</span>
|
||||
<span class="rel-type-badge">{{ rel.source_asset?.assettype }}</span>
|
||||
</div>
|
||||
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="btn-icon delete"
|
||||
@click="deleteRelationship(rel.relationshipid)"
|
||||
title="Remove relationship"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add Relationship Modal -->
|
||||
<div v-if="showAddModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Add Relationship</h3>
|
||||
<button class="btn-icon" @click="closeModal">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Direction</label>
|
||||
<select v-model="newRel.direction" class="form-control">
|
||||
<option value="outgoing">This asset → Target asset</option>
|
||||
<option value="incoming">Source asset → This asset</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Relationship Type</label>
|
||||
<select v-model="newRel.relationshiptypeid" class="form-control" required>
|
||||
<option value="">Select type...</option>
|
||||
<option
|
||||
v-for="t in relationshipTypes"
|
||||
:key="t.relationshiptypeid"
|
||||
:value="t.relationshiptypeid"
|
||||
>
|
||||
{{ t.relationshiptype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ newRel.direction === 'outgoing' ? 'Target Asset' : 'Source Asset' }}</label>
|
||||
<div class="asset-search">
|
||||
<input
|
||||
type="text"
|
||||
v-model="assetSearchQuery"
|
||||
class="form-control"
|
||||
placeholder="Search assets..."
|
||||
@input="searchAssets"
|
||||
/>
|
||||
<div v-if="assetSearchResults.length > 0" class="search-results">
|
||||
<div
|
||||
v-for="asset in assetSearchResults"
|
||||
:key="asset.assetid"
|
||||
class="search-result-item"
|
||||
:class="{ selected: newRel.targetAssetId === asset.assetid }"
|
||||
@click="selectAsset(asset)"
|
||||
>
|
||||
<span class="result-icon">{{ getAssetIcon(asset.assettype) }}</span>
|
||||
<span class="result-name">{{ asset.name || asset.assetnumber }}</span>
|
||||
<span class="result-type">{{ asset.assettype }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedAsset" class="selected-asset">
|
||||
<span class="result-icon">{{ getAssetIcon(selectedAsset.assettype) }}</span>
|
||||
<span>{{ selectedAsset.name || selectedAsset.assetnumber }}</span>
|
||||
<button class="btn-icon" @click="clearSelectedAsset">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Notes (optional)</label>
|
||||
<textarea
|
||||
v-model="newRel.notes"
|
||||
class="form-control"
|
||||
rows="2"
|
||||
placeholder="Additional notes about this relationship..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!canSave"
|
||||
@click="saveRelationship"
|
||||
>
|
||||
Add Relationship
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { assetsApi, relationshipTypesApi } from '../api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const props = defineProps({
|
||||
assetId: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
// Alternative: lookup by machine/asset number
|
||||
machineNumber: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['updated'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const resolvedAssetId = ref(null)
|
||||
const lookupFailed = ref(false)
|
||||
const outgoing = ref([])
|
||||
const incoming = ref([])
|
||||
const relationshipTypes = ref([])
|
||||
const showAddModal = ref(false)
|
||||
|
||||
// New relationship form
|
||||
const newRel = ref({
|
||||
direction: 'outgoing',
|
||||
relationshiptypeid: '',
|
||||
targetAssetId: null,
|
||||
notes: ''
|
||||
})
|
||||
|
||||
const assetSearchQuery = ref('')
|
||||
const assetSearchResults = ref([])
|
||||
const selectedAsset = ref(null)
|
||||
let searchTimeout = null
|
||||
|
||||
const hasRelationships = computed(() => outgoing.value.length > 0 || incoming.value.length > 0)
|
||||
|
||||
const canSave = computed(() => {
|
||||
return newRel.value.relationshiptypeid && newRel.value.targetAssetId && resolvedAssetId.value
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await resolveAssetId()
|
||||
if (resolvedAssetId.value) {
|
||||
await Promise.all([loadRelationships(), loadRelationshipTypes()])
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.assetId, async () => {
|
||||
await resolveAssetId()
|
||||
if (resolvedAssetId.value) {
|
||||
await loadRelationships()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.machineNumber, async () => {
|
||||
await resolveAssetId()
|
||||
if (resolvedAssetId.value) {
|
||||
await loadRelationships()
|
||||
}
|
||||
})
|
||||
|
||||
async function resolveAssetId() {
|
||||
// If assetId is provided directly, use it
|
||||
if (props.assetId) {
|
||||
resolvedAssetId.value = props.assetId
|
||||
lookupFailed.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, try to look up by machine number
|
||||
if (props.machineNumber) {
|
||||
try {
|
||||
const response = await assetsApi.lookup(props.machineNumber)
|
||||
resolvedAssetId.value = response.data.data?.assetid
|
||||
lookupFailed.value = !resolvedAssetId.value
|
||||
} catch (error) {
|
||||
console.log('Asset lookup failed for:', props.machineNumber)
|
||||
resolvedAssetId.value = null
|
||||
lookupFailed.value = true
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRelationships() {
|
||||
if (!resolvedAssetId.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await assetsApi.getRelationships(resolvedAssetId.value)
|
||||
outgoing.value = response.data.data?.outgoing || []
|
||||
incoming.value = response.data.data?.incoming || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load relationships:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRelationshipTypes() {
|
||||
try {
|
||||
const response = await relationshipTypesApi.list()
|
||||
relationshipTypes.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load relationship types:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function searchAssets() {
|
||||
clearTimeout(searchTimeout)
|
||||
if (!assetSearchQuery.value || assetSearchQuery.value.length < 2) {
|
||||
assetSearchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await assetsApi.search(assetSearchQuery.value, { per_page: 10 })
|
||||
// Filter out the current asset
|
||||
assetSearchResults.value = (response.data.data || []).filter(
|
||||
a => a.assetid !== resolvedAssetId.value
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to search assets:', error)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectAsset(asset) {
|
||||
selectedAsset.value = asset
|
||||
newRel.value.targetAssetId = asset.assetid
|
||||
assetSearchQuery.value = ''
|
||||
assetSearchResults.value = []
|
||||
}
|
||||
|
||||
function clearSelectedAsset() {
|
||||
selectedAsset.value = null
|
||||
newRel.value.targetAssetId = null
|
||||
}
|
||||
|
||||
async function saveRelationship() {
|
||||
if (!canSave.value) return
|
||||
|
||||
try {
|
||||
const data = {
|
||||
relationshiptypeid: parseInt(newRel.value.relationshiptypeid),
|
||||
notes: newRel.value.notes || null
|
||||
}
|
||||
|
||||
if (newRel.value.direction === 'outgoing') {
|
||||
data.source_assetid = resolvedAssetId.value
|
||||
data.target_assetid = newRel.value.targetAssetId
|
||||
} else {
|
||||
data.source_assetid = newRel.value.targetAssetId
|
||||
data.target_assetid = resolvedAssetId.value
|
||||
}
|
||||
|
||||
await assetsApi.createRelationship(data)
|
||||
await loadRelationships()
|
||||
closeModal()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
console.error('Failed to create relationship:', error)
|
||||
alert('Failed to create relationship: ' + (error.response?.data?.message || error.message))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRelationship(relationshipId) {
|
||||
if (!confirm('Remove this relationship?')) return
|
||||
|
||||
try {
|
||||
await assetsApi.deleteRelationship(relationshipId)
|
||||
await loadRelationships()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete relationship:', error)
|
||||
alert('Failed to delete relationship')
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showAddModal.value = false
|
||||
newRel.value = {
|
||||
direction: 'outgoing',
|
||||
relationshiptypeid: '',
|
||||
targetAssetId: null,
|
||||
notes: ''
|
||||
}
|
||||
selectedAsset.value = null
|
||||
assetSearchQuery.value = ''
|
||||
assetSearchResults.value = []
|
||||
}
|
||||
|
||||
function getAssetIcon(assettype) {
|
||||
const icons = {
|
||||
'equipment': '⚙',
|
||||
'computer': '💻',
|
||||
'printer': '🖨',
|
||||
'network_device': '🌐'
|
||||
}
|
||||
return icons[assettype] || '📦'
|
||||
}
|
||||
|
||||
function getAssetRoute(asset) {
|
||||
if (!asset) return '#'
|
||||
|
||||
const routeMap = {
|
||||
'equipment': '/machines',
|
||||
'computer': '/pcs',
|
||||
'printer': '/printers',
|
||||
'network_device': '/network'
|
||||
}
|
||||
|
||||
const basePath = routeMap[asset.assettype] || '/assets'
|
||||
|
||||
// Use typedata ID if available
|
||||
if (asset.assettype === 'network_device' && asset.typedata?.networkdeviceid) {
|
||||
return `/network/${asset.typedata.networkdeviceid}`
|
||||
}
|
||||
|
||||
// For equipment/computer/printer, use machineid from typedata or assetid
|
||||
const id = asset.typedata?.machineid || asset.assetid
|
||||
return `${basePath}/${id}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.relationships-section {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-light);
|
||||
font-style: italic;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.relationship-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.relationship-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 0.75rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.relationship-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.relationship-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rel-icon {
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rel-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rel-name {
|
||||
font-weight: 500;
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rel-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rel-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.badge-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--primary);
|
||||
color: var(--primary);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.rel-type-badge {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.rel-notes {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-icon.delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--danger);
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-icon.delete:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-card-solid);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.modal-header .btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.25rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Asset Search */
|
||||
.asset-search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card-solid);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.search-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.search-result-item.selected {
|
||||
background: rgba(65, 129, 255, 0.15);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-type {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.selected-asset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(65, 129, 255, 0.1);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.selected-asset .btn-icon {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
145
frontend/src/components/EmbeddedLocationMap.vue
Normal file
145
frontend/src/components/EmbeddedLocationMap.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="embedded-map" ref="mapContainer"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
const props = defineProps({
|
||||
left: { type: Number, default: null },
|
||||
top: { type: Number, default: null },
|
||||
markerColor: { type: String, default: '#ff0000' },
|
||||
markerLabel: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const mapContainer = ref(null)
|
||||
let map = null
|
||||
let marker = null
|
||||
|
||||
// Map dimensions
|
||||
const MAP_WIDTH = 3300
|
||||
const MAP_HEIGHT = 2550
|
||||
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||
|
||||
// Detect system color scheme
|
||||
function getTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function initMap() {
|
||||
if (!mapContainer.value || props.left === null || props.top === null) return
|
||||
|
||||
map = L.map(mapContainer.value, {
|
||||
crs: L.CRS.Simple,
|
||||
minZoom: -3,
|
||||
maxZoom: 2,
|
||||
attributionControl: false,
|
||||
zoomControl: true
|
||||
})
|
||||
|
||||
const theme = getTheme()
|
||||
const blueprintUrl = theme === 'light'
|
||||
? '/static/images/sitemap2025-light.png'
|
||||
: '/static/images/sitemap2025-dark.png'
|
||||
|
||||
L.imageOverlay(blueprintUrl, bounds).addTo(map)
|
||||
|
||||
// Convert database coordinates to Leaflet (y is inverted)
|
||||
const leafletY = MAP_HEIGHT - props.top
|
||||
const leafletX = props.left
|
||||
|
||||
// Create marker
|
||||
const icon = L.divIcon({
|
||||
html: `<div class="location-marker-dot" style="background: ${props.markerColor};"></div>`,
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10],
|
||||
className: 'location-marker'
|
||||
})
|
||||
|
||||
marker = L.marker([leafletY, leafletX], { icon })
|
||||
|
||||
if (props.markerLabel) {
|
||||
marker.bindTooltip(props.markerLabel, {
|
||||
permanent: true,
|
||||
direction: 'top',
|
||||
offset: [0, -10],
|
||||
className: 'location-label'
|
||||
})
|
||||
}
|
||||
|
||||
marker.addTo(map)
|
||||
|
||||
// Center on marker with appropriate zoom
|
||||
map.setView([leafletY, leafletX], -1)
|
||||
map.setMaxBounds(bounds)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initMap()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
})
|
||||
|
||||
watch([() => props.left, () => props.top], () => {
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
initMap()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.embedded-map {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
:deep(.location-marker) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
:deep(.location-marker-dot) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px rgba(255,0,0,0.2), 0 2px 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.location-label) {
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
:deep(.location-label::before) {
|
||||
border-top-color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
</style>
|
||||
251
frontend/src/components/EmployeeSearch.vue
Normal file
251
frontend/src/components/EmployeeSearch.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="employee-search">
|
||||
<div class="employee-search-container">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="onSearch"
|
||||
@keydown.enter.prevent="addCustom"
|
||||
/>
|
||||
<div v-if="results.length" class="employee-dropdown">
|
||||
<div
|
||||
v-for="emp in results"
|
||||
:key="emp.SSO"
|
||||
class="employee-option"
|
||||
@click="selectEmployee(emp)"
|
||||
>
|
||||
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
|
||||
<span class="emp-sso">{{ emp.SSO }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="allowCustom" class="form-hint">Search for employees or press Enter to add a custom name</small>
|
||||
|
||||
<!-- Selected employee(s) display -->
|
||||
<div v-if="multiple && selectedList.length" class="selected-employees">
|
||||
<div
|
||||
v-for="(emp, idx) in selectedList"
|
||||
:key="idx"
|
||||
class="selected-employee"
|
||||
>
|
||||
<span>{{ emp.name }}</span>
|
||||
<span v-if="emp.sso && !emp.sso.startsWith('NAME:')" class="emp-sso-tag">{{ emp.sso }}</span>
|
||||
<button v-if="!disabled" type="button" class="btn-remove" @click="removeEmployee(idx)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!multiple && selected" class="selected-employee-display">
|
||||
<span>{{ selected.name }}</span>
|
||||
<span v-if="selected.sso" class="emp-sso-tag">{{ selected.sso }}</span>
|
||||
<button v-if="!disabled" type="button" class="btn-remove" @click="clearSelection">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { employeesApi } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Object, Array], default: null },
|
||||
multiple: { type: Boolean, default: false },
|
||||
allowCustom: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: 'Search by name...' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
const results = ref([])
|
||||
const selected = ref(null)
|
||||
const selectedList = ref([])
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
// Initialize from modelValue
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (props.multiple) {
|
||||
selectedList.value = val || []
|
||||
} else {
|
||||
selected.value = val
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function onSearch() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
|
||||
const query = searchQuery.value.trim()
|
||||
if (query.length < 2) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const res = await employeesApi.search(query)
|
||||
results.value = res.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Employee search error:', err)
|
||||
results.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectEmployee(emp) {
|
||||
const employee = {
|
||||
sso: String(emp.SSO),
|
||||
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
// Check if already selected
|
||||
if (selectedList.value.some(e => e.sso === employee.sso)) {
|
||||
return
|
||||
}
|
||||
selectedList.value.push(employee)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
} else {
|
||||
selected.value = employee
|
||||
emit('update:modelValue', employee)
|
||||
}
|
||||
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
function addCustom() {
|
||||
if (!props.allowCustom) return
|
||||
|
||||
const name = searchQuery.value.trim()
|
||||
if (!name) return
|
||||
|
||||
const employee = {
|
||||
sso: `NAME:${name}`,
|
||||
name: name
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
selectedList.value.push(employee)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
} else {
|
||||
selected.value = employee
|
||||
emit('update:modelValue', employee)
|
||||
}
|
||||
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
function removeEmployee(idx) {
|
||||
selectedList.value.splice(idx, 1)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selected.value = null
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.employee-search-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.employee-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card-solid, #1a1a1a);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.employee-option {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.employee-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.emp-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.emp-sso {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Multiple selection */
|
||||
.selected-employees {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.selected-employee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Single selection */
|
||||
.selected-employee-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.emp-sso-tag {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -41,13 +41,28 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { ref, computed, nextTick, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
left: { type: Number, default: null },
|
||||
top: { type: Number, default: null },
|
||||
machineName: { type: String, default: '' },
|
||||
theme: { type: String, default: 'dark' }
|
||||
machineName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
// Auto-detect system theme with reactive updates
|
||||
const systemTheme = ref(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
function handleThemeChange(e) {
|
||||
systemTheme.value = e.matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mediaQuery.addEventListener('change', handleThemeChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mediaQuery.removeEventListener('change', handleThemeChange)
|
||||
})
|
||||
|
||||
const visible = ref(false)
|
||||
@@ -67,7 +82,7 @@ const hasPosition = computed(() => {
|
||||
})
|
||||
|
||||
const blueprintUrl = computed(() => {
|
||||
return props.theme === 'light'
|
||||
return systemTheme.value === 'light'
|
||||
? '/static/images/sitemap2025-light.png'
|
||||
: '/static/images/sitemap2025-dark.png'
|
||||
})
|
||||
@@ -202,9 +217,9 @@ watch(visible, (newVal) => {
|
||||
}
|
||||
|
||||
.map-tooltip-content {
|
||||
background: var(--bg-card);
|
||||
background: var(--bg-card, #ffffff);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border, #e0e0e0);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -214,7 +229,7 @@ watch(visible, (newVal) => {
|
||||
width: 500px;
|
||||
height: 385px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
background: var(--bg, #f5f5f5);
|
||||
}
|
||||
|
||||
.map-transform {
|
||||
@@ -235,7 +250,7 @@ watch(visible, (newVal) => {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #ff0000;
|
||||
border: 2px solid var(--border);
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000;
|
||||
pointer-events: none;
|
||||
@@ -243,8 +258,8 @@ watch(visible, (newVal) => {
|
||||
|
||||
.map-tooltip-footer {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg, #f5f5f5);
|
||||
border-top: 1px solid var(--border, #e0e0e0);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -253,11 +268,11 @@ watch(visible, (newVal) => {
|
||||
.coordinates {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-light);
|
||||
color: var(--text-light, #666666);
|
||||
}
|
||||
|
||||
.zoom-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light);
|
||||
color: var(--text-light, #666666);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -72,13 +72,14 @@ function handleEscape(e) {
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: white;
|
||||
background: var(--bg-card-solid);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-small {
|
||||
@@ -106,7 +107,7 @@ function handleEscape(e) {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
@@ -119,13 +120,13 @@ function handleEscape(e) {
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
color: var(--text-light);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: #333;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@@ -136,7 +137,7 @@ function handleEscape(e) {
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -67,7 +67,8 @@ const props = defineProps({
|
||||
statuses: { type: Array, default: () => [] },
|
||||
theme: { type: String, default: 'dark' },
|
||||
pickerMode: { type: Boolean, default: false },
|
||||
initialPosition: { type: Object, default: null } // { left, top }
|
||||
initialPosition: { type: Object, default: null }, // { left, top }
|
||||
assetTypeMode: { type: Boolean, default: false } // When true, use unified asset format
|
||||
})
|
||||
|
||||
const emit = defineEmits(['markerClick', 'positionPicked'])
|
||||
@@ -91,6 +92,14 @@ const MAP_WIDTH = 3300
|
||||
const MAP_HEIGHT = 2550
|
||||
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||
|
||||
// Asset type colors (for unified map mode)
|
||||
const assetTypeColors = {
|
||||
'equipment': '#F44336', // Red
|
||||
'computer': '#2196F3', // Blue
|
||||
'printer': '#4CAF50', // Green
|
||||
'network_device': '#FF9800' // Orange
|
||||
}
|
||||
|
||||
// Type colors - distinct colors for each machine type
|
||||
const typeColors = {
|
||||
// Machining
|
||||
@@ -158,7 +167,8 @@ function initMap() {
|
||||
map = L.map(mapContainer.value, {
|
||||
crs: L.CRS.Simple,
|
||||
minZoom: -4,
|
||||
maxZoom: 2
|
||||
maxZoom: 2,
|
||||
attributionControl: false
|
||||
})
|
||||
|
||||
const blueprintUrl = props.theme === 'light'
|
||||
@@ -168,8 +178,8 @@ function initMap() {
|
||||
imageOverlay = L.imageOverlay(blueprintUrl, bounds)
|
||||
imageOverlay.addTo(map)
|
||||
|
||||
// Set initial view
|
||||
const initialZoom = -1
|
||||
// Set initial view - zoom out to show full floor plan
|
||||
const initialZoom = -2
|
||||
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], initialZoom)
|
||||
map.setMaxBounds(bounds)
|
||||
|
||||
@@ -263,15 +273,29 @@ function renderMarkers() {
|
||||
markers.value.forEach(m => m.marker.remove())
|
||||
markers.value = []
|
||||
|
||||
props.machines.forEach(machine => {
|
||||
if (machine.mapleft == null || machine.maptop == null) return
|
||||
props.machines.forEach(item => {
|
||||
if (item.mapleft == null || item.maptop == null) return
|
||||
|
||||
// Transform coordinates (database Y is top-down, Leaflet is bottom-up)
|
||||
const leafletY = MAP_HEIGHT - machine.maptop
|
||||
const leafletX = machine.mapleft
|
||||
const leafletY = MAP_HEIGHT - item.maptop
|
||||
const leafletX = item.mapleft
|
||||
|
||||
const typeName = machine.machinetype || ''
|
||||
const color = getTypeColor(typeName)
|
||||
// Determine color based on mode
|
||||
let color, typeName, displayName, detailRoute
|
||||
|
||||
if (props.assetTypeMode) {
|
||||
// Unified asset mode
|
||||
color = assetTypeColors[item.assettype] || '#BDBDBD'
|
||||
typeName = item.assettype || ''
|
||||
displayName = item.displayname || item.name || item.assetnumber || 'Unknown'
|
||||
detailRoute = getAssetDetailRoute(item)
|
||||
} else {
|
||||
// Legacy machine mode
|
||||
typeName = item.machinetype || ''
|
||||
color = getTypeColor(typeName)
|
||||
displayName = item.alias || item.machinenumber || 'Unknown'
|
||||
detailRoute = getDetailRoute(item)
|
||||
}
|
||||
|
||||
const icon = L.divIcon({
|
||||
html: `<div class="machine-marker-dot" style="background: ${color};"></div>`,
|
||||
@@ -283,45 +307,56 @@ function renderMarkers() {
|
||||
|
||||
const marker = L.marker([leafletY, leafletX], { icon })
|
||||
|
||||
// Display name for tooltips and popups
|
||||
const displayName = machine.alias || machine.machinenumber || 'Unknown'
|
||||
const detailRoute = getDetailRoute(machine)
|
||||
const category = machine.category?.toLowerCase() || ''
|
||||
|
||||
// Build tooltip content based on category
|
||||
// Build tooltip content
|
||||
let tooltipLines = [`<strong>${displayName}</strong>`]
|
||||
|
||||
// Don't show "LocationOnly" as machine type
|
||||
if (typeName && typeName.toLowerCase() !== 'locationonly') {
|
||||
tooltipLines.push(`<span style="color: #888;">${typeName}</span>`)
|
||||
}
|
||||
|
||||
// Add vendor and model if available
|
||||
if (machine.vendor) {
|
||||
tooltipLines.push(`<span style="color: #aaa;">${machine.vendor}${machine.model ? ' ' + machine.model : ''}</span>`)
|
||||
} else if (machine.model) {
|
||||
tooltipLines.push(`<span style="color: #aaa;">${machine.model}</span>`)
|
||||
}
|
||||
|
||||
// Category-specific info
|
||||
if (category === 'printer') {
|
||||
// Printers: show IP and hostname
|
||||
if (machine.ipaddress) {
|
||||
tooltipLines.push(`<span style="color: #8cf;">IP: ${machine.ipaddress}</span>`)
|
||||
if (props.assetTypeMode) {
|
||||
// Asset mode tooltips
|
||||
const assetTypeLabel = {
|
||||
'equipment': 'Equipment',
|
||||
'computer': 'Computer',
|
||||
'printer': 'Printer',
|
||||
'network_device': 'Network Device'
|
||||
}
|
||||
if (machine.hostname) {
|
||||
tooltipLines.push(`<span style="color: #8cf;">${machine.hostname}</span>`)
|
||||
tooltipLines.push(`<span style="color: #888;">${assetTypeLabel[item.assettype] || item.assettype}</span>`)
|
||||
|
||||
if (item.primaryip) {
|
||||
tooltipLines.push(`<span style="color: #8cf;">IP: ${item.primaryip}</span>`)
|
||||
}
|
||||
if (item.typedata?.hostname) {
|
||||
tooltipLines.push(`<span style="color: #8cf;">${item.typedata.hostname}</span>`)
|
||||
}
|
||||
} else {
|
||||
// Equipment/PC: show connected PC if available
|
||||
if (machine.connected_pc) {
|
||||
tooltipLines.push(`<span style="color: #fc8;">PC: ${machine.connected_pc}</span>`)
|
||||
// Legacy machine mode tooltips
|
||||
const category = item.category?.toLowerCase() || ''
|
||||
|
||||
if (typeName && typeName.toLowerCase() !== 'locationonly') {
|
||||
tooltipLines.push(`<span style="color: #888;">${typeName}</span>`)
|
||||
}
|
||||
|
||||
if (item.vendor) {
|
||||
tooltipLines.push(`<span style="color: #aaa;">${item.vendor}${item.model ? ' ' + item.model : ''}</span>`)
|
||||
} else if (item.model) {
|
||||
tooltipLines.push(`<span style="color: #aaa;">${item.model}</span>`)
|
||||
}
|
||||
|
||||
if (category === 'printer') {
|
||||
if (item.ipaddress) {
|
||||
tooltipLines.push(`<span style="color: #8cf;">IP: ${item.ipaddress}</span>`)
|
||||
}
|
||||
if (item.hostname) {
|
||||
tooltipLines.push(`<span style="color: #8cf;">${item.hostname}</span>`)
|
||||
}
|
||||
} else {
|
||||
if (item.connected_pc) {
|
||||
tooltipLines.push(`<span style="color: #fc8;">PC: ${item.connected_pc}</span>`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Business unit
|
||||
if (machine.businessunit) {
|
||||
tooltipLines.push(`<span style="color: #ccc;">${machine.businessunit}</span>`)
|
||||
if (item.businessunit) {
|
||||
tooltipLines.push(`<span style="color: #ccc;">${item.businessunit}</span>`)
|
||||
}
|
||||
|
||||
const tooltipContent = tooltipLines.join('<br>')
|
||||
@@ -332,36 +367,76 @@ function renderMarkers() {
|
||||
})
|
||||
|
||||
// Click popup (detailed info)
|
||||
const popupContent = `
|
||||
<div class="marker-popup">
|
||||
<strong>${displayName}</strong>
|
||||
<div class="popup-details">
|
||||
<div><span class="label">Number:</span> ${machine.machinenumber || '-'}</div>
|
||||
<div><span class="label">Type:</span> ${typeName || '-'}</div>
|
||||
<div><span class="label">Category:</span> ${machine.category || '-'}</div>
|
||||
<div><span class="label">Status:</span> ${machine.status || '-'}</div>
|
||||
<div><span class="label">Vendor:</span> ${machine.vendor || '-'}</div>
|
||||
<div><span class="label">Model:</span> ${machine.model || '-'}</div>
|
||||
let popupContent
|
||||
if (props.assetTypeMode) {
|
||||
popupContent = `
|
||||
<div class="marker-popup">
|
||||
<strong>${displayName}</strong>
|
||||
<div class="popup-details">
|
||||
<div><span class="label">Asset #:</span> ${item.assetnumber || '-'}</div>
|
||||
<div><span class="label">Type:</span> ${item.assettype || '-'}</div>
|
||||
<div><span class="label">Status:</span> ${item.status || '-'}</div>
|
||||
<div><span class="label">Location:</span> ${item.location || '-'}</div>
|
||||
${item.primaryip ? `<div><span class="label">IP:</span> ${item.primaryip}</div>` : ''}
|
||||
</div>
|
||||
<a href="${detailRoute}" class="popup-link">View Details</a>
|
||||
</div>
|
||||
<a href="${detailRoute}" class="popup-link">View Details</a>
|
||||
</div>
|
||||
`
|
||||
`
|
||||
} else {
|
||||
popupContent = `
|
||||
<div class="marker-popup">
|
||||
<strong>${displayName}</strong>
|
||||
<div class="popup-details">
|
||||
<div><span class="label">Number:</span> ${item.machinenumber || '-'}</div>
|
||||
<div><span class="label">Type:</span> ${typeName || '-'}</div>
|
||||
<div><span class="label">Category:</span> ${item.category || '-'}</div>
|
||||
<div><span class="label">Status:</span> ${item.status || '-'}</div>
|
||||
<div><span class="label">Vendor:</span> ${item.vendor || '-'}</div>
|
||||
<div><span class="label">Model:</span> ${item.model || '-'}</div>
|
||||
</div>
|
||||
<a href="${detailRoute}" class="popup-link">View Details</a>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
marker.bindPopup(popupContent)
|
||||
marker.on('click', () => emit('markerClick', machine))
|
||||
marker.on('click', () => emit('markerClick', item))
|
||||
|
||||
marker.addTo(map)
|
||||
|
||||
// Build search data
|
||||
const searchData = props.assetTypeMode
|
||||
? `${item.assetnumber} ${item.name} ${item.displayname} ${item.assettype} ${item.status} ${item.businessunit} ${item.typedata?.hostname || ''} ${item.primaryip || ''}`.toLowerCase()
|
||||
: `${item.machinenumber} ${item.alias} ${typeName} ${item.vendor} ${item.model} ${item.serialnumber} ${item.businessunit}`.toLowerCase()
|
||||
|
||||
markers.value.push({
|
||||
marker,
|
||||
machine,
|
||||
searchData: `${machine.machinenumber} ${machine.alias} ${typeName} ${machine.vendor} ${machine.model} ${machine.serialnumber} ${machine.businessunit}`.toLowerCase()
|
||||
machine: item,
|
||||
searchData
|
||||
})
|
||||
})
|
||||
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
// Get detail route for unified asset format
|
||||
function getAssetDetailRoute(asset) {
|
||||
const routeMap = {
|
||||
'equipment': '/machines',
|
||||
'computer': '/pcs',
|
||||
'printer': '/printers',
|
||||
'network_device': '/network'
|
||||
}
|
||||
const basePath = routeMap[asset.assettype] || '/machines'
|
||||
|
||||
if (asset.assettype === 'network_device' && asset.typedata?.networkdeviceid) {
|
||||
return `/network/${asset.typedata.networkdeviceid}`
|
||||
}
|
||||
|
||||
const id = asset.typedata?.machineid || asset.assetid
|
||||
return `${basePath}/${id}`
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const searchTerm = filters.value.search.toLowerCase()
|
||||
|
||||
@@ -445,15 +520,30 @@ onUnmounted(() => {
|
||||
padding: 0.625rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 1.125rem;
|
||||
font-size: 1rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.filters select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
padding-right: 2.25rem;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.filters input {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.filters input::placeholder {
|
||||
color: var(--text-light);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.filters input::placeholder {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user