Initial commit: Shop Database Flask Application
Flask backend with Vue 3 frontend for shop floor machine management. Includes database schema export for MySQL shopdb_flask database. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
263
frontend/src/components/LocationMapTooltip.vue
Normal file
263
frontend/src/components/LocationMapTooltip.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div class="location-tooltip-wrapper" @mouseenter="showTooltip" @mouseleave="onWrapperLeave">
|
||||
<slot></slot>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible && hasPosition"
|
||||
class="map-tooltip"
|
||||
:style="tooltipStyle"
|
||||
ref="tooltipRef"
|
||||
@mouseenter="onTooltipEnter"
|
||||
@mouseleave="onTooltipLeave"
|
||||
@wheel.prevent="onWheel"
|
||||
>
|
||||
<div class="map-tooltip-content">
|
||||
<div class="map-preview" ref="mapPreview">
|
||||
<div
|
||||
class="map-transform"
|
||||
:style="transformStyle"
|
||||
>
|
||||
<img
|
||||
:src="blueprintUrl"
|
||||
alt="Shop Floor Map"
|
||||
class="map-image"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
<!-- Marker dot -->
|
||||
<div
|
||||
class="marker-dot"
|
||||
:style="markerStyle"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-tooltip-footer">
|
||||
<span class="coordinates">{{ left }}, {{ top }}</span>
|
||||
<span class="zoom-hint">Scroll to zoom</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
left: { type: Number, default: null },
|
||||
top: { type: Number, default: null },
|
||||
machineName: { type: String, default: '' },
|
||||
theme: { type: String, default: 'dark' }
|
||||
})
|
||||
|
||||
const visible = ref(false)
|
||||
const tooltipRef = ref(null)
|
||||
const mapPreview = ref(null)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
const isOverTooltip = ref(false)
|
||||
const zoom = ref(1)
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
// Map dimensions
|
||||
const MAP_WIDTH = 3300
|
||||
const MAP_HEIGHT = 2550
|
||||
|
||||
const hasPosition = computed(() => {
|
||||
return props.left !== null && props.top !== null
|
||||
})
|
||||
|
||||
const blueprintUrl = computed(() => {
|
||||
return props.theme === 'light'
|
||||
? '/static/images/sitemap2025-light.png'
|
||||
: '/static/images/sitemap2025-dark.png'
|
||||
})
|
||||
|
||||
// Calculate marker position as percentage
|
||||
const markerX = computed(() => {
|
||||
return (props.left / MAP_WIDTH) * 100
|
||||
})
|
||||
|
||||
const markerY = computed(() => {
|
||||
return (props.top / MAP_HEIGHT) * 100
|
||||
})
|
||||
|
||||
// Marker style with counter-scale to maintain constant size
|
||||
const markerStyle = computed(() => ({
|
||||
left: markerX.value + '%',
|
||||
top: markerY.value + '%',
|
||||
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
|
||||
}))
|
||||
|
||||
// Transform style that centers on the marker and zooms toward it
|
||||
const transformStyle = computed(() => {
|
||||
// Calculate translation to center the marker in the preview
|
||||
const translateX = 50 - markerX.value
|
||||
const translateY = 50 - markerY.value
|
||||
|
||||
return {
|
||||
transform: `translate(${translateX}%, ${translateY}%) scale(${zoom.value})`,
|
||||
transformOrigin: `${markerX.value}% ${markerY.value}%`
|
||||
}
|
||||
})
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
left: `${tooltipPosition.value.x}px`,
|
||||
top: `${tooltipPosition.value.y}px`
|
||||
}))
|
||||
|
||||
function onImageLoad() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
function showTooltip(event) {
|
||||
if (!hasPosition.value) return
|
||||
|
||||
visible.value = true
|
||||
zoom.value = 1
|
||||
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
tooltipPosition.value = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.bottom + 10
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
adjustPosition()
|
||||
})
|
||||
}
|
||||
|
||||
function onWrapperLeave() {
|
||||
// Small delay to allow moving to tooltip
|
||||
setTimeout(() => {
|
||||
if (!isOverTooltip.value) {
|
||||
hideTooltip()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function onTooltipEnter() {
|
||||
isOverTooltip.value = true
|
||||
}
|
||||
|
||||
function onTooltipLeave() {
|
||||
isOverTooltip.value = false
|
||||
hideTooltip()
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
visible.value = false
|
||||
zoom.value = 1
|
||||
}
|
||||
|
||||
function onWheel(event) {
|
||||
const delta = event.deltaY > 0 ? -0.3 : 0.3
|
||||
const newZoom = Math.max(1, Math.min(8, zoom.value + delta))
|
||||
zoom.value = newZoom
|
||||
}
|
||||
|
||||
function adjustPosition() {
|
||||
if (!tooltipRef.value) return
|
||||
|
||||
const tooltip = tooltipRef.value
|
||||
const rect = tooltip.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
if (rect.right > viewportWidth - 20) {
|
||||
tooltipPosition.value.x -= (rect.right - viewportWidth + 20)
|
||||
}
|
||||
if (rect.left < 20) {
|
||||
tooltipPosition.value.x += (20 - rect.left)
|
||||
}
|
||||
|
||||
if (rect.bottom > viewportHeight - 20) {
|
||||
tooltipPosition.value.y = rect.top - tooltip.offsetHeight - 20
|
||||
}
|
||||
}
|
||||
|
||||
// Reset zoom when tooltip becomes visible
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal) {
|
||||
zoom.value = 1
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.location-tooltip-wrapper {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-tooltip-wrapper:hover {
|
||||
color: var(--primary, #1976d2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.map-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.map-tooltip-content {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-preview {
|
||||
position: relative;
|
||||
width: 500px;
|
||||
height: 385px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.map-transform {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
|
||||
.map-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.marker-dot {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #ff0000;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-tooltip-footer {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.coordinates {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.zoom-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
144
frontend/src/components/Modal.vue
Normal file
144
frontend/src/components/Modal.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="modelValue" class="modal-overlay" @click.self="closeOnOverlay && close()">
|
||||
<div class="modal-container" :class="sizeClass">
|
||||
<div class="modal-header" v-if="title || $slots.header">
|
||||
<slot name="header">
|
||||
<h3>{{ title }}</h3>
|
||||
</slot>
|
||||
<button class="modal-close" @click="close" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" v-if="$slots.footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
size: { type: String, default: 'medium' }, // small, medium, large, fullscreen
|
||||
closeOnOverlay: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
const sizeClass = computed(() => `modal-${props.size}`)
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Handle escape key
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
function handleEscape(e) {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-small {
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal-medium {
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal-large {
|
||||
width: 900px;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.modal-fullscreen {
|
||||
width: 95vw;
|
||||
height: 90vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
589
frontend/src/components/ShopFloorMap.vue
Normal file
589
frontend/src/components/ShopFloorMap.vue
Normal file
@@ -0,0 +1,589 @@
|
||||
<template>
|
||||
<div class="shopfloor-map">
|
||||
<div class="map-controls" v-if="!pickerMode">
|
||||
<div class="filters">
|
||||
<select v-model="filters.machinetype" @change="applyFilters">
|
||||
<option value="">All Types</option>
|
||||
<option v-for="t in machinetypes" :key="t.machinetypeid" :value="t.machinetypeid">
|
||||
{{ t.machinetype }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-model="filters.businessunit" @change="applyFilters">
|
||||
<option value="">All Business Units</option>
|
||||
<option v-for="bu in businessunits" :key="bu.businessunitid" :value="bu.businessunitid">
|
||||
{{ bu.businessunit }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-model="filters.status" @change="applyFilters">
|
||||
<option value="">All Statuses</option>
|
||||
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">
|
||||
{{ s.status }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
v-model="filters.search"
|
||||
placeholder="Search..."
|
||||
@input="debounceSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span
|
||||
v-for="t in visibleTypes"
|
||||
:key="t.machinetypeid"
|
||||
class="legend-item"
|
||||
>
|
||||
<span class="legend-dot" :style="{ background: getTypeColor(t.machinetype) }"></span>
|
||||
{{ t.machinetype }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="picker-controls" v-if="pickerMode">
|
||||
<span class="picker-message">Click on the map to set location</span>
|
||||
<span v-if="pickedPosition" class="picker-coords">
|
||||
Position: {{ pickedPosition.left }}, {{ pickedPosition.top }}
|
||||
</span>
|
||||
<button class="btn btn-secondary btn-sm" @click="clearPosition">Clear</button>
|
||||
</div>
|
||||
|
||||
<div ref="mapContainer" class="map-container" :class="{ 'picker-active': pickerMode }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
const props = defineProps({
|
||||
machines: { type: Array, default: () => [] },
|
||||
machinetypes: { type: Array, default: () => [] },
|
||||
businessunits: { type: Array, default: () => [] },
|
||||
statuses: { type: Array, default: () => [] },
|
||||
theme: { type: String, default: 'dark' },
|
||||
pickerMode: { type: Boolean, default: false },
|
||||
initialPosition: { type: Object, default: null } // { left, top }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['markerClick', 'positionPicked'])
|
||||
|
||||
const mapContainer = ref(null)
|
||||
let map = null
|
||||
let imageOverlay = null
|
||||
const markers = ref([])
|
||||
const pickedPosition = ref(null)
|
||||
let pickerMarker = null
|
||||
|
||||
const filters = ref({
|
||||
machinetype: '',
|
||||
businessunit: '',
|
||||
status: '',
|
||||
search: ''
|
||||
})
|
||||
|
||||
// Map dimensions (matching old system)
|
||||
const MAP_WIDTH = 3300
|
||||
const MAP_HEIGHT = 2550
|
||||
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||
|
||||
// Type colors - distinct colors for each machine type
|
||||
const typeColors = {
|
||||
// Machining
|
||||
'Mill': '#F44336', // Red
|
||||
'Lathe': '#E91E63', // Pink
|
||||
'Grinder': '#2196F3', // Blue
|
||||
'Broach': '#00BCD4', // Cyan
|
||||
'Hobbing': '#009688', // Teal
|
||||
'Turn': '#FF5722', // Deep Orange (Mill Turn, Vertical Turn)
|
||||
|
||||
// Inspection & Measurement
|
||||
'CMM': '#9C27B0', // Purple
|
||||
'Measuring': '#7B1FA2', // Dark Purple
|
||||
'Eddy': '#673AB7', // Deep Purple
|
||||
'Inspection': '#8BC34A', // Light Green
|
||||
|
||||
// Heat Treatment & Processing
|
||||
'Furnace': '#FF9800', // Orange
|
||||
'Wash': '#4CAF50', // Green
|
||||
'Wax': '#FFEB3B', // Yellow
|
||||
|
||||
// Automation
|
||||
'Robot': '#3F51B5', // Indigo
|
||||
'Deburr': '#5C6BC0', // Indigo Light
|
||||
|
||||
// Welding
|
||||
'Welder': '#795548', // Brown
|
||||
|
||||
// IT/Network
|
||||
'PC': '#607D8B', // Blue Grey
|
||||
'Printer': '#78909C', // Blue Grey Light
|
||||
'Switch': '#546E7A', // Blue Grey Dark
|
||||
'Access Point': '#455A64', // Blue Grey Darker
|
||||
'IDF': '#37474F', // Blue Grey Very Dark
|
||||
|
||||
// Other
|
||||
'Saw': '#8D6E63', // Brown Light
|
||||
'Press': '#A1887F', // Brown Lighter
|
||||
'EDM': '#00ACC1', // Cyan Dark
|
||||
'Drill': '#26A69A', // Teal Light
|
||||
'CNC': '#66BB6A', // Green Light
|
||||
'Assembly': '#CDDC39', // Lime
|
||||
'Other': '#BDBDBD' // Grey
|
||||
}
|
||||
|
||||
function getTypeColor(typeName) {
|
||||
if (!typeName) return '#BDBDBD'
|
||||
for (const [key, color] of Object.entries(typeColors)) {
|
||||
if (typeName.toLowerCase().includes(key.toLowerCase())) {
|
||||
return color
|
||||
}
|
||||
}
|
||||
return '#BDBDBD' // Grey for unknown types
|
||||
}
|
||||
|
||||
// Get unique visible types from machines
|
||||
const visibleTypes = computed(() => {
|
||||
const typeIds = new Set(props.machines.map(m => m.machinetypeid))
|
||||
return props.machinetypes.filter(t => typeIds.has(t.machinetypeid))
|
||||
})
|
||||
|
||||
function initMap() {
|
||||
if (!mapContainer.value) return
|
||||
|
||||
map = L.map(mapContainer.value, {
|
||||
crs: L.CRS.Simple,
|
||||
minZoom: -4,
|
||||
maxZoom: 2
|
||||
})
|
||||
|
||||
const blueprintUrl = props.theme === 'light'
|
||||
? '/static/images/sitemap2025-light.png'
|
||||
: '/static/images/sitemap2025-dark.png'
|
||||
|
||||
imageOverlay = L.imageOverlay(blueprintUrl, bounds)
|
||||
imageOverlay.addTo(map)
|
||||
|
||||
// Set initial view
|
||||
const initialZoom = -1
|
||||
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], initialZoom)
|
||||
map.setMaxBounds(bounds)
|
||||
|
||||
// Picker mode: click to set position
|
||||
if (props.pickerMode) {
|
||||
map.on('click', handleMapClick)
|
||||
|
||||
// Show initial position if provided
|
||||
if (props.initialPosition) {
|
||||
setPickerPosition(props.initialPosition.left, props.initialPosition.top)
|
||||
}
|
||||
} else {
|
||||
renderMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapClick(e) {
|
||||
if (!props.pickerMode) return
|
||||
|
||||
const leafletY = e.latlng.lat
|
||||
const leafletX = e.latlng.lng
|
||||
|
||||
// Convert back to database coordinates
|
||||
const dbLeft = Math.round(leafletX)
|
||||
const dbTop = Math.round(MAP_HEIGHT - leafletY)
|
||||
|
||||
setPickerPosition(dbLeft, dbTop)
|
||||
}
|
||||
|
||||
function setPickerPosition(left, top) {
|
||||
// Remove old picker marker
|
||||
if (pickerMarker) {
|
||||
pickerMarker.remove()
|
||||
}
|
||||
|
||||
pickedPosition.value = { left, top }
|
||||
|
||||
// Convert to Leaflet coordinates
|
||||
const leafletY = MAP_HEIGHT - top
|
||||
const leafletX = left
|
||||
|
||||
const icon = L.divIcon({
|
||||
html: `<div class="picker-marker-dot"></div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
className: 'picker-marker'
|
||||
})
|
||||
|
||||
pickerMarker = L.marker([leafletY, leafletX], { icon, draggable: true })
|
||||
pickerMarker.addTo(map)
|
||||
|
||||
// Allow dragging to fine-tune position
|
||||
pickerMarker.on('dragend', () => {
|
||||
const pos = pickerMarker.getLatLng()
|
||||
const newLeft = Math.round(pos.lng)
|
||||
const newTop = Math.round(MAP_HEIGHT - pos.lat)
|
||||
pickedPosition.value = { left: newLeft, top: newTop }
|
||||
emit('positionPicked', pickedPosition.value)
|
||||
})
|
||||
|
||||
emit('positionPicked', pickedPosition.value)
|
||||
}
|
||||
|
||||
function clearPosition() {
|
||||
if (pickerMarker) {
|
||||
pickerMarker.remove()
|
||||
pickerMarker = null
|
||||
}
|
||||
pickedPosition.value = null
|
||||
emit('positionPicked', null)
|
||||
}
|
||||
|
||||
// Get the detail page route based on machine category
|
||||
// Extensible for future addon types (network, cameras, etc.)
|
||||
function getDetailRoute(machine) {
|
||||
const category = machine.category?.toLowerCase() || ''
|
||||
const routeMap = {
|
||||
'equipment': '/machines',
|
||||
'pc': '/pcs',
|
||||
'printer': '/printers',
|
||||
// Future addon routes can be added here:
|
||||
// 'network': '/network',
|
||||
// 'camera': '/cameras',
|
||||
}
|
||||
const basePath = routeMap[category] || '/machines'
|
||||
return `${basePath}/${machine.machineid}`
|
||||
}
|
||||
|
||||
function renderMarkers() {
|
||||
// Clear existing markers
|
||||
markers.value.forEach(m => m.marker.remove())
|
||||
markers.value = []
|
||||
|
||||
props.machines.forEach(machine => {
|
||||
if (machine.mapleft == null || machine.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 typeName = machine.machinetype || ''
|
||||
const color = getTypeColor(typeName)
|
||||
|
||||
const icon = L.divIcon({
|
||||
html: `<div class="machine-marker-dot" style="background: ${color};"></div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
popupAnchor: [0, -12],
|
||||
className: 'machine-marker'
|
||||
})
|
||||
|
||||
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
|
||||
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 (machine.hostname) {
|
||||
tooltipLines.push(`<span style="color: #8cf;">${machine.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>`)
|
||||
}
|
||||
}
|
||||
|
||||
// Business unit
|
||||
if (machine.businessunit) {
|
||||
tooltipLines.push(`<span style="color: #ccc;">${machine.businessunit}</span>`)
|
||||
}
|
||||
|
||||
const tooltipContent = tooltipLines.join('<br>')
|
||||
marker.bindTooltip(tooltipContent, {
|
||||
direction: 'top',
|
||||
offset: [0, -12],
|
||||
className: 'marker-tooltip'
|
||||
})
|
||||
|
||||
// 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>
|
||||
</div>
|
||||
<a href="${detailRoute}" class="popup-link">View Details</a>
|
||||
</div>
|
||||
`
|
||||
|
||||
marker.bindPopup(popupContent)
|
||||
marker.on('click', () => emit('markerClick', machine))
|
||||
|
||||
marker.addTo(map)
|
||||
|
||||
markers.value.push({
|
||||
marker,
|
||||
machine,
|
||||
searchData: `${machine.machinenumber} ${machine.alias} ${typeName} ${machine.vendor} ${machine.model} ${machine.serialnumber} ${machine.businessunit}`.toLowerCase()
|
||||
})
|
||||
})
|
||||
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const searchTerm = filters.value.search.toLowerCase()
|
||||
|
||||
markers.value.forEach(({ marker, machine, searchData }) => {
|
||||
let visible = true
|
||||
|
||||
if (filters.value.machinetype && machine.machinetypeid !== filters.value.machinetype) {
|
||||
visible = false
|
||||
}
|
||||
if (filters.value.businessunit && machine.businessunitid !== filters.value.businessunit) {
|
||||
visible = false
|
||||
}
|
||||
if (filters.value.status && machine.statusid !== filters.value.status) {
|
||||
visible = false
|
||||
}
|
||||
if (searchTerm && !searchData.includes(searchTerm)) {
|
||||
visible = false
|
||||
}
|
||||
|
||||
marker.setOpacity(visible ? 1 : 0.15)
|
||||
})
|
||||
}
|
||||
|
||||
let searchTimeout = null
|
||||
function debounceSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(applyFilters, 300)
|
||||
}
|
||||
|
||||
watch(() => props.machines, () => {
|
||||
if (map) renderMarkers()
|
||||
}, { deep: true })
|
||||
|
||||
watch(() => props.theme, (newTheme) => {
|
||||
if (imageOverlay && map) {
|
||||
const blueprintUrl = newTheme === 'light'
|
||||
? '/static/images/sitemap2025-light.png'
|
||||
: '/static/images/sitemap2025-dark.png'
|
||||
imageOverlay.setUrl(blueprintUrl)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initMap()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shopfloor-map {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters select,
|
||||
.filters input {
|
||||
padding: 0.625rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 1.125rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.filters input {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.filters input::placeholder {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
min-height: 600px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.map-container.picker-active {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.picker-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem;
|
||||
background: var(--warning);
|
||||
border-bottom: 1px solid var(--warning);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.picker-message {
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.picker-coords {
|
||||
font-family: monospace;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
:deep(.marker-popup) {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
:deep(.marker-popup strong) {
|
||||
display: block;
|
||||
margin-bottom: 0.625rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
:deep(.popup-details) {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
:deep(.popup-details .label) {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.popup-link) {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.popup-link:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:deep(.machine-marker) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
:deep(.machine-marker-dot) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--border);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
:deep(.picker-marker-dot) {
|
||||
background: #ff0000;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
:deep(.marker-tooltip) {
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.7;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
:deep(.marker-tooltip::before) {
|
||||
border-top-color: rgba(0, 0, 0, 0.92);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user