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:
6
frontend/src/App.vue
Normal file
6
frontend/src/App.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
392
frontend/src/api/index.js
Normal file
392
frontend/src/api/index.js
Normal file
@@ -0,0 +1,392 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Handle 401 errors (token expired) - only redirect if user was logged in
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
const hadToken = localStorage.getItem('token')
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
// Only redirect if user was previously logged in (session expired)
|
||||
if (hadToken) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
login(username, password) {
|
||||
return api.post('/auth/login', { username, password })
|
||||
},
|
||||
logout() {
|
||||
return api.post('/auth/logout')
|
||||
},
|
||||
me() {
|
||||
return api.get('/auth/me')
|
||||
},
|
||||
refresh() {
|
||||
const refreshToken = localStorage.getItem('refreshToken')
|
||||
return api.post('/auth/refresh', {}, {
|
||||
headers: { Authorization: `Bearer ${refreshToken}` }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Machines API
|
||||
export const machinesApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/machines', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/machines/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/machines', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/machines/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/machines/${id}`)
|
||||
},
|
||||
updateCommunication(id, data) {
|
||||
return api.put(`/machines/${id}/communication`, data)
|
||||
},
|
||||
// Relationships
|
||||
getRelationships(id) {
|
||||
return api.get(`/machines/${id}/relationships`)
|
||||
},
|
||||
createRelationship(id, data) {
|
||||
return api.post(`/machines/${id}/relationships`, data)
|
||||
},
|
||||
deleteRelationship(relationshipId) {
|
||||
return api.delete(`/machines/relationships/${relationshipId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Relationship Types API
|
||||
export const relationshipTypesApi = {
|
||||
list() {
|
||||
return api.get('/machines/relationshiptypes')
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/machines/relationshiptypes', data)
|
||||
}
|
||||
}
|
||||
|
||||
// Machine Types API
|
||||
export const machinetypesApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/machinetypes', { params })
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/machinetypes', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/machinetypes/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/machinetypes/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Statuses API
|
||||
export const statusesApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/statuses', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/statuses/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/statuses', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/statuses/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/statuses/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Vendors API
|
||||
export const vendorsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/vendors', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/vendors/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/vendors', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/vendors/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/vendors/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Locations API
|
||||
export const locationsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/locations', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/locations/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/locations', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/locations/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/locations/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Printers API
|
||||
export const printersApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/printers/', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/printers/${id}`)
|
||||
},
|
||||
updateExtension(id, data) {
|
||||
return api.put(`/printers/${id}/printerdata`, data)
|
||||
},
|
||||
updateCommunication(id, data) {
|
||||
return api.put(`/printers/${id}/communication`, data)
|
||||
},
|
||||
getSupplies(id) {
|
||||
return api.get(`/printers/${id}/supplies`)
|
||||
},
|
||||
getDrivers(id) {
|
||||
return api.get(`/printers/${id}/drivers`)
|
||||
},
|
||||
lowSupplies() {
|
||||
return api.get('/printers/lowsupplies')
|
||||
},
|
||||
dashboardSummary() {
|
||||
return api.get('/printers/dashboard/summary')
|
||||
},
|
||||
drivers: {
|
||||
list() {
|
||||
return api.get('/printers/drivers')
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/printers/drivers', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/printers/drivers/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/printers/drivers/${id}`)
|
||||
}
|
||||
},
|
||||
supplyTypes: {
|
||||
list() {
|
||||
return api.get('/printers/supplytypes')
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/printers/supplytypes', data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard API
|
||||
export const dashboardApi = {
|
||||
summary() {
|
||||
return api.get('/dashboard/summary')
|
||||
}
|
||||
}
|
||||
|
||||
// Models API
|
||||
export const modelsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/models', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/models/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/models', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/models/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/models/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// PC Types API
|
||||
export const pctypesApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/pctypes', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/pctypes/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/pctypes', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/pctypes/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/pctypes/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Operating Systems API
|
||||
export const operatingsystemsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/operatingsystems', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/operatingsystems/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/operatingsystems', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/operatingsystems/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/operatingsystems/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Business Units API
|
||||
export const businessunitsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/businessunits', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/businessunits/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/businessunits', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/businessunits/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/businessunits/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Applications API
|
||||
export const applicationsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/applications', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/applications/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/applications', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/applications/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/applications/${id}`)
|
||||
},
|
||||
// Versions
|
||||
getVersions(appId) {
|
||||
return api.get(`/applications/${appId}/versions`)
|
||||
},
|
||||
createVersion(appId, data) {
|
||||
return api.post(`/applications/${appId}/versions`, data)
|
||||
},
|
||||
// Get PCs that have this app installed
|
||||
getInstalledOn(appId) {
|
||||
return api.get(`/applications/${appId}/installed`)
|
||||
},
|
||||
// Machine applications (installed apps)
|
||||
getMachineApps(machineId) {
|
||||
return api.get(`/applications/machines/${machineId}`)
|
||||
},
|
||||
installApp(machineId, data) {
|
||||
return api.post(`/applications/machines/${machineId}`, data)
|
||||
},
|
||||
uninstallApp(machineId, appId) {
|
||||
return api.delete(`/applications/machines/${machineId}/${appId}`)
|
||||
},
|
||||
updateInstalledApp(machineId, appId, data) {
|
||||
return api.put(`/applications/machines/${machineId}/${appId}`, data)
|
||||
},
|
||||
// Support teams
|
||||
getSupportTeams() {
|
||||
return api.get('/applications/supportteams')
|
||||
},
|
||||
createSupportTeam(data) {
|
||||
return api.post('/applications/supportteams', data)
|
||||
},
|
||||
// App owners
|
||||
getAppOwners() {
|
||||
return api.get('/applications/appowners')
|
||||
},
|
||||
createAppOwner(data) {
|
||||
return api.post('/applications/appowners', data)
|
||||
}
|
||||
}
|
||||
|
||||
// Search API
|
||||
export const searchApi = {
|
||||
search(query) {
|
||||
return api.get('/search', { params: { q: query } })
|
||||
}
|
||||
}
|
||||
|
||||
// Knowledge Base API
|
||||
export const knowledgebaseApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/knowledgebase', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/knowledgebase/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/knowledgebase', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/knowledgebase/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/knowledgebase/${id}`)
|
||||
},
|
||||
trackClick(id) {
|
||||
return api.post(`/knowledgebase/${id}/click`)
|
||||
},
|
||||
getStats() {
|
||||
return api.get('/knowledgebase/stats')
|
||||
}
|
||||
}
|
||||
1054
frontend/src/assets/style.css
Normal file
1054
frontend/src/assets/style.css
Normal file
File diff suppressed because it is too large
Load Diff
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>
|
||||
11
frontend/src/main.js
Normal file
11
frontend/src/main.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
249
frontend/src/router/index.js
Normal file
249
frontend/src/router/index.js
Normal file
@@ -0,0 +1,249 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
// Views
|
||||
import Login from '../views/Login.vue'
|
||||
import AppLayout from '../views/AppLayout.vue'
|
||||
import Dashboard from '../views/Dashboard.vue'
|
||||
import MachinesList from '../views/machines/MachinesList.vue'
|
||||
import MachineDetail from '../views/machines/MachineDetail.vue'
|
||||
import MachineForm from '../views/machines/MachineForm.vue'
|
||||
import PrintersList from '../views/printers/PrintersList.vue'
|
||||
import PrinterDetail from '../views/printers/PrinterDetail.vue'
|
||||
import PrinterForm from '../views/printers/PrinterForm.vue'
|
||||
import PCsList from '../views/pcs/PCsList.vue'
|
||||
import PCDetail from '../views/pcs/PCDetail.vue'
|
||||
import PCForm from '../views/pcs/PCForm.vue'
|
||||
import VendorsList from '../views/vendors/VendorsList.vue'
|
||||
import ApplicationsList from '../views/applications/ApplicationsList.vue'
|
||||
import ApplicationDetail from '../views/applications/ApplicationDetail.vue'
|
||||
import ApplicationForm from '../views/applications/ApplicationForm.vue'
|
||||
import KnowledgeBaseList from '../views/knowledgebase/KnowledgeBaseList.vue'
|
||||
import KnowledgeBaseDetail from '../views/knowledgebase/KnowledgeBaseDetail.vue'
|
||||
import KnowledgeBaseForm from '../views/knowledgebase/KnowledgeBaseForm.vue'
|
||||
import SearchResults from '../views/SearchResults.vue'
|
||||
import SettingsIndex from '../views/settings/SettingsIndex.vue'
|
||||
import MachineTypesList from '../views/settings/MachineTypesList.vue'
|
||||
import LocationsList from '../views/settings/LocationsList.vue'
|
||||
import StatusesList from '../views/settings/StatusesList.vue'
|
||||
import ModelsList from '../views/settings/ModelsList.vue'
|
||||
import PCTypesList from '../views/settings/PCTypesList.vue'
|
||||
import OperatingSystemsList from '../views/settings/OperatingSystemsList.vue'
|
||||
import BusinessUnitsList from '../views/settings/BusinessUnitsList.vue'
|
||||
import MapView from '../views/MapView.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: Login,
|
||||
meta: { guest: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: Dashboard
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
name: 'search',
|
||||
component: SearchResults
|
||||
},
|
||||
{
|
||||
path: 'machines',
|
||||
name: 'machines',
|
||||
component: MachinesList
|
||||
},
|
||||
{
|
||||
path: 'machines/new',
|
||||
name: 'machine-new',
|
||||
component: MachineForm,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'machines/:id',
|
||||
name: 'machine-detail',
|
||||
component: MachineDetail
|
||||
},
|
||||
{
|
||||
path: 'machines/:id/edit',
|
||||
name: 'machine-edit',
|
||||
component: MachineForm,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'printers',
|
||||
name: 'printers',
|
||||
component: PrintersList
|
||||
},
|
||||
{
|
||||
path: 'printers/new',
|
||||
name: 'printer-new',
|
||||
component: PrinterForm,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'printers/:id',
|
||||
name: 'printer-detail',
|
||||
component: PrinterDetail
|
||||
},
|
||||
{
|
||||
path: 'printers/:id/edit',
|
||||
name: 'printer-edit',
|
||||
component: PrinterForm,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'pcs',
|
||||
name: 'pcs',
|
||||
component: PCsList
|
||||
},
|
||||
{
|
||||
path: 'pcs/new',
|
||||
name: 'pc-new',
|
||||
component: PCForm,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'pcs/:id',
|
||||
name: 'pc-detail',
|
||||
component: PCDetail
|
||||
},
|
||||
{
|
||||
path: 'pcs/:id/edit',
|
||||
name: 'pc-edit',
|
||||
component: PCForm,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'map',
|
||||
name: 'map',
|
||||
component: MapView
|
||||
},
|
||||
{
|
||||
path: 'applications',
|
||||
name: 'applications',
|
||||
component: ApplicationsList
|
||||
},
|
||||
{
|
||||
path: 'applications/new',
|
||||
name: 'application-new',
|
||||
component: ApplicationForm,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'applications/:id',
|
||||
name: 'application-detail',
|
||||
component: ApplicationDetail
|
||||
},
|
||||
{
|
||||
path: 'applications/:id/edit',
|
||||
name: 'application-edit',
|
||||
component: ApplicationForm,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase',
|
||||
name: 'knowledgebase',
|
||||
component: KnowledgeBaseList
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/new',
|
||||
name: 'knowledgebase-new',
|
||||
component: KnowledgeBaseForm,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/:id',
|
||||
name: 'knowledgebase-detail',
|
||||
component: KnowledgeBaseDetail
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/:id/edit',
|
||||
name: 'knowledgebase-edit',
|
||||
component: KnowledgeBaseForm,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: SettingsIndex,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/vendors',
|
||||
name: 'vendors',
|
||||
component: VendorsList,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/machinetypes',
|
||||
name: 'machinetypes',
|
||||
component: MachineTypesList,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/locations',
|
||||
name: 'locations',
|
||||
component: LocationsList,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/statuses',
|
||||
name: 'statuses',
|
||||
component: StatusesList,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/models',
|
||||
name: 'models',
|
||||
component: ModelsList,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/pctypes',
|
||||
name: 'pctypes',
|
||||
component: PCTypesList,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/operatingsystems',
|
||||
name: 'operatingsystems',
|
||||
component: OperatingSystemsList,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/businessunits',
|
||||
name: 'businessunits',
|
||||
component: BusinessUnitsList,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// Navigation guard
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next('/login')
|
||||
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
next('/')
|
||||
} else if (to.meta.guest && authStore.isAuthenticated) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
63
frontend/src/stores/auth.js
Normal file
63
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { authApi } from '../api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: JSON.parse(localStorage.getItem('user') || 'null'),
|
||||
token: localStorage.getItem('token') || null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isAuthenticated: (state) => !!state.token,
|
||||
username: (state) => state.user?.username || '',
|
||||
roles: (state) => state.user?.roles || [],
|
||||
hasRole: (state) => (role) => state.user?.roles?.includes(role) || false,
|
||||
isAdmin: (state) => state.user?.roles?.includes('admin') || false
|
||||
},
|
||||
|
||||
actions: {
|
||||
async login(username, password) {
|
||||
try {
|
||||
const response = await authApi.login(username, password)
|
||||
const { access_token, refresh_token, user } = response.data.data
|
||||
|
||||
this.token = access_token
|
||||
this.user = user
|
||||
|
||||
localStorage.setItem('token', access_token)
|
||||
localStorage.setItem('refreshToken', refresh_token)
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || 'Login failed'
|
||||
return { success: false, message }
|
||||
}
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch (e) {
|
||||
// Ignore logout errors
|
||||
}
|
||||
|
||||
this.token = null
|
||||
this.user = null
|
||||
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('user')
|
||||
},
|
||||
|
||||
async fetchUser() {
|
||||
try {
|
||||
const response = await authApi.me()
|
||||
this.user = response.data.data
|
||||
localStorage.setItem('user', JSON.stringify(this.user))
|
||||
} catch (error) {
|
||||
this.logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
65
frontend/src/views/AppLayout.vue
Normal file
65
frontend/src/views/AppLayout.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="/ge-aerospace-logo.svg" alt="GE Aerospace" class="sidebar-logo" />
|
||||
<h1>West Jefferson</h1>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-search">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
@keyup.enter="performSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<router-link to="/">Dashboard</router-link>
|
||||
<router-link to="/map">Map</router-link>
|
||||
<router-link to="/machines">Equipment</router-link>
|
||||
<router-link to="/pcs">PCs</router-link>
|
||||
<router-link to="/printers">Printers</router-link>
|
||||
<router-link to="/applications">Applications</router-link>
|
||||
<router-link to="/knowledgebase">Knowledge Base</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="user-menu">
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<div class="username">{{ authStore.username }}</div>
|
||||
<button class="btn btn-secondary" @click="handleLogout">Logout</button>
|
||||
</template>
|
||||
<router-link v-else to="/login" class="btn btn-primary">Login</router-link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
function performSearch() {
|
||||
if (searchQuery.value.trim()) {
|
||||
router.push({ path: '/search', query: { q: searchQuery.value.trim() } })
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
133
frontend/src/views/Dashboard.vue
Normal file
133
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Dashboard</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Main Stats -->
|
||||
<div class="dashboard-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">Total Machines</div>
|
||||
<div class="value">{{ stats.totalmachines || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<div class="label">Active</div>
|
||||
<div class="value">{{ stats.activemachines || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card warning">
|
||||
<div class="label">In Repair</div>
|
||||
<div class="value">{{ stats.inrepair || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">PCs</div>
|
||||
<div class="value">{{ stats.totalpc || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printer Stats -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Printers</h3>
|
||||
<router-link to="/printers" class="btn btn-secondary btn-sm">View All</router-link>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">Total Printers</div>
|
||||
<div class="value">{{ printerStats.totalprinters || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<div class="label">Online</div>
|
||||
<div class="value">{{ printerStats.online || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card warning">
|
||||
<div class="label">Low Supplies</div>
|
||||
<div class="value">{{ printerStats.lowsupplies || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card danger">
|
||||
<div class="label">Critical</div>
|
||||
<div class="value">{{ printerStats.criticalsupplies || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Machines -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Recent Machines</h3>
|
||||
<router-link to="/machines" class="btn btn-secondary btn-sm">View All</router-link>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine #</th>
|
||||
<th>Alias</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="machine in recentMachines" :key="machine.machineid">
|
||||
<td>{{ machine.machinenumber }}</td>
|
||||
<td>{{ machine.alias || '-' }}</td>
|
||||
<td>{{ machine.machinetype }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="getStatusClass(machine.status)">
|
||||
{{ machine.status || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="recentMachines.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No machines found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { dashboardApi, machinesApi, printersApi } from '../api'
|
||||
|
||||
const loading = ref(true)
|
||||
const stats = ref({})
|
||||
const printerStats = ref({})
|
||||
const recentMachines = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [dashRes, machinesRes, printersRes] = await Promise.all([
|
||||
dashboardApi.summary().catch(() => ({ data: { data: {} } })),
|
||||
machinesApi.list({ perpage: 5 }).catch(() => ({ data: { data: [] } })),
|
||||
printersApi.dashboardSummary().catch(() => ({ data: { data: {} } }))
|
||||
])
|
||||
|
||||
stats.value = dashRes.data.data || {}
|
||||
recentMachines.value = machinesRes.data.data || []
|
||||
printerStats.value = printersRes.data.data || {}
|
||||
} catch (error) {
|
||||
console.error('Dashboard load error:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (!status) return 'badge-info'
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'in use' || s === 'active') return 'badge-success'
|
||||
if (s === 'in repair') return 'badge-warning'
|
||||
if (s === 'retired') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
</script>
|
||||
69
frontend/src/views/Login.vue
Normal file
69
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<h1>ShopDB</h1>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
{{ loading ? 'Logging in...' : 'Login' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
const result = await authStore.login(username.value, password.value)
|
||||
|
||||
loading.value = false
|
||||
|
||||
if (result.success) {
|
||||
router.push('/')
|
||||
} else {
|
||||
error.value = result.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
79
frontend/src/views/MapView.vue
Normal file
79
frontend/src/views/MapView.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="map-page">
|
||||
<div class="page-header">
|
||||
<h2>Shop Floor Map</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<ShopFloorMap
|
||||
v-else
|
||||
:machines="machines"
|
||||
:machinetypes="machinetypes"
|
||||
:businessunits="businessunits"
|
||||
:statuses="statuses"
|
||||
@markerClick="handleMarkerClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ShopFloorMap from '../components/ShopFloorMap.vue'
|
||||
import { machinesApi, machinetypesApi, businessunitsApi, statusesApi } from '../api'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const machines = ref([])
|
||||
const machinetypes = ref([])
|
||||
const businessunits = ref([])
|
||||
const statuses = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [machinesRes, typesRes, busRes, statusRes] = await Promise.all([
|
||||
machinesApi.list({ hasmap: true, all: true }),
|
||||
machinetypesApi.list(),
|
||||
businessunitsApi.list(),
|
||||
statusesApi.list()
|
||||
])
|
||||
|
||||
machines.value = machinesRes.data.data || []
|
||||
machinetypes.value = typesRes.data.data || []
|
||||
businessunits.value = busRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load map data:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function handleMarkerClick(machine) {
|
||||
const category = machine.category?.toLowerCase() || ''
|
||||
const routeMap = {
|
||||
'equipment': '/machines',
|
||||
'pc': '/pcs',
|
||||
'printer': '/printers'
|
||||
}
|
||||
const basePath = routeMap[category] || '/machines'
|
||||
router.push(`${basePath}/${machine.machineid}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 2rem);
|
||||
}
|
||||
|
||||
.map-page .page-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-page :deep(.shopfloor-map) {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
267
frontend/src/views/SearchResults.vue
Normal file
267
frontend/src/views/SearchResults.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Search Results</h2>
|
||||
<span v-if="results.length" class="results-count">
|
||||
{{ results.length }} result{{ results.length !== 1 ? 's' : '' }} for "{{ query }}"
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="search-box">
|
||||
<input
|
||||
v-model="searchInput"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search machines, applications, knowledge base..."
|
||||
@keyup.enter="performSearch"
|
||||
/>
|
||||
<button class="btn btn-primary" @click="performSearch">Search</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Searching...</div>
|
||||
|
||||
<template v-else-if="query">
|
||||
<div v-if="results.length === 0" class="no-results">
|
||||
No results found for "{{ query }}"
|
||||
</div>
|
||||
|
||||
<div v-else class="results-list">
|
||||
<div
|
||||
v-for="result in results"
|
||||
:key="`${result.type}-${result.id}`"
|
||||
class="result-item"
|
||||
>
|
||||
<span class="result-type" :class="result.type">{{ typeLabel(result.type) }}</span>
|
||||
<div class="result-content">
|
||||
<router-link v-if="result.type !== 'knowledgebase'" :to="result.url" class="result-title">
|
||||
{{ result.title }}
|
||||
</router-link>
|
||||
<a
|
||||
v-else
|
||||
href="#"
|
||||
class="result-title"
|
||||
@click.prevent="openKBArticle(result)"
|
||||
>
|
||||
{{ result.title }}
|
||||
</a>
|
||||
<div class="result-meta">
|
||||
<span v-if="result.subtitle" class="result-subtitle">{{ result.subtitle }}</span>
|
||||
<span v-if="result.location" class="result-location">{{ result.location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="no-results">
|
||||
Enter a search term to find machines, applications, printers, and knowledge base articles.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { searchApi, knowledgebaseApi } from '../api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const results = ref([])
|
||||
const query = ref('')
|
||||
const searchInput = ref('')
|
||||
|
||||
const typeLabels = {
|
||||
machine: 'Equipment',
|
||||
pc: 'PC',
|
||||
application: 'App',
|
||||
knowledgebase: 'KB',
|
||||
printer: 'Printer'
|
||||
}
|
||||
|
||||
function typeLabel(type) {
|
||||
return typeLabels[type] || type
|
||||
}
|
||||
|
||||
async function search(q) {
|
||||
if (!q || q.length < 2) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await searchApi.search(q)
|
||||
results.value = response.data.data?.results || []
|
||||
query.value = q
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
results.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function performSearch() {
|
||||
if (searchInput.value.trim()) {
|
||||
router.push({ path: '/search', query: { q: searchInput.value.trim() } })
|
||||
}
|
||||
}
|
||||
|
||||
async function openKBArticle(result) {
|
||||
// Track the click before opening
|
||||
try {
|
||||
await knowledgebaseApi.trackClick(result.id)
|
||||
if (result.linkurl) {
|
||||
window.open(result.linkurl, '_blank')
|
||||
} else {
|
||||
router.push(result.url)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error tracking click:', error)
|
||||
if (result.linkurl) {
|
||||
window.open(result.linkurl, '_blank')
|
||||
} else {
|
||||
router.push(result.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const q = route.query.q
|
||||
if (q) {
|
||||
searchInput.value = q
|
||||
search(q)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => route.query.q, (newQ) => {
|
||||
if (newQ) {
|
||||
searchInput.value = newQ
|
||||
search(newQ)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
color: var(--text-light, #666);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
color: var(--text-light, #666);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5e5e5);
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-type {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
min-width: 70px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.result-type.machine {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.result-type.pc {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.result-type.application {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.result-type.knowledgebase {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.result-type.printer {
|
||||
background: #fce4ec;
|
||||
color: #c2185b;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
color: var(--primary, #1976d2);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-light, #666);
|
||||
}
|
||||
|
||||
.result-subtitle {
|
||||
color: var(--text-light, #666);
|
||||
}
|
||||
|
||||
.result-location {
|
||||
color: var(--text-light, #666);
|
||||
}
|
||||
|
||||
.result-location::before {
|
||||
content: '\1F4CD ';
|
||||
}
|
||||
</style>
|
||||
338
frontend/src/views/applications/ApplicationDetail.vue
Normal file
338
frontend/src/views/applications/ApplicationDetail.vue
Normal file
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Application Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/applications/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/applications" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="app">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="app.image">
|
||||
<img :src="`/images/applications/${app.image}`" :alt="app.appname" @error="handleImageError" />
|
||||
</div>
|
||||
<div class="hero-image placeholder" v-else>
|
||||
<span class="placeholder-icon">📦</span>
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ app.appname }}</h1>
|
||||
</div>
|
||||
<p class="hero-description" v-if="app.appdescription">{{ app.appdescription }}</p>
|
||||
<div class="hero-meta">
|
||||
<span v-if="app.isinstallable" class="badge badge-lg badge-info">Installable</span>
|
||||
<span v-if="app.islicenced" class="badge badge-lg badge-warning">Licensed</span>
|
||||
<span v-if="app.isprinter" class="badge badge-lg badge-secondary">Printer App</span>
|
||||
<span v-if="app.ishidden" class="badge badge-lg badge-dark">Hidden</span>
|
||||
</div>
|
||||
<div class="hero-links" v-if="app.applicationlink || app.installpath || app.documentationpath">
|
||||
<a v-if="app.applicationlink" :href="app.applicationlink" target="_blank" class="hero-link">
|
||||
<span class="link-icon">🔗</span> Launch Application
|
||||
</a>
|
||||
<a v-if="app.installpath" :href="app.installpath" target="_blank" class="hero-link">
|
||||
<span class="link-icon">⬇</span> Download Files
|
||||
</a>
|
||||
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" class="hero-link">
|
||||
<span class="link-icon">📄</span> Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Support Info -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Support Information</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Support Team</span>
|
||||
<span class="info-value">
|
||||
<a v-if="app.supportteam?.teamurl" :href="app.supportteam.teamurl" target="_blank">
|
||||
{{ app.supportteam?.teamname || '-' }}
|
||||
</a>
|
||||
<span v-else>{{ app.supportteam?.teamname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">App Owner</span>
|
||||
<span class="info-value">{{ app.supportteam?.owner?.appowner || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="app.supportteam?.owner?.sso">
|
||||
<span class="info-label">SSO</span>
|
||||
<span class="info-value mono">{{ app.supportteam.owner.sso }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Notes -->
|
||||
<div class="section-card" v-if="app.applicationnotes">
|
||||
<h3 class="section-title">Application Notes</h3>
|
||||
<div class="notes-text" v-html="app.applicationnotes"></div>
|
||||
</div>
|
||||
|
||||
<!-- Versions -->
|
||||
<div class="section-card" v-if="versions.length > 0">
|
||||
<h3 class="section-title">Available Versions</h3>
|
||||
<div class="version-list">
|
||||
<div v-for="ver in versions" :key="ver.appversionid" class="version-item">
|
||||
<span class="version-number">v{{ ver.version }}</span>
|
||||
<span class="version-date" v-if="ver.releasedate">{{ formatDate(ver.releasedate) }}</span>
|
||||
<span class="version-notes" v-if="ver.notes">{{ ver.notes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Installed On PCs -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Installed On ({{ installedOn.length }} PCs)</h3>
|
||||
<div v-if="installedOn.length > 0" class="pc-list">
|
||||
<router-link
|
||||
v-for="install in installedOn"
|
||||
:key="install.id"
|
||||
:to="`/pcs/${install.machineid}`"
|
||||
class="pc-item"
|
||||
>
|
||||
<div class="pc-info">
|
||||
<span class="pc-name">{{ install.machine?.machinenumber || `PC #${install.machineid}` }}</span>
|
||||
<span class="pc-alias" v-if="install.machine?.alias">{{ install.machine.alias }}</span>
|
||||
</div>
|
||||
<div class="pc-version" v-if="install.version">
|
||||
v{{ install.version }}
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>Not installed on any PCs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(app.createddate) }}</span>
|
||||
<span>Modified {{ formatDate(app.modifieddate) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Application not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { applicationsApi } from '../../api'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const app = ref(null)
|
||||
const versions = ref([])
|
||||
const installedOn = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load application details
|
||||
const response = await applicationsApi.get(route.params.id)
|
||||
app.value = response.data.data
|
||||
|
||||
// Load versions
|
||||
try {
|
||||
const versionsRes = await applicationsApi.getVersions(route.params.id)
|
||||
versions.value = versionsRes.data.data || []
|
||||
} catch (e) {
|
||||
console.log('No versions data')
|
||||
}
|
||||
|
||||
// Load installed on which PCs
|
||||
try {
|
||||
const installedRes = await applicationsApi.getInstalledOn(route.params.id)
|
||||
installedOn.value = installedRes.data.data || []
|
||||
} catch (e) {
|
||||
console.log('No installed data')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading application:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
function handleImageError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Application-specific styles - shared styles are in global style.css */
|
||||
|
||||
/* Hero description (app-specific) */
|
||||
.hero-description {
|
||||
color: var(--text-light);
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Hero links (app-specific) */
|
||||
.hero-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.hero-link:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Placeholder image */
|
||||
.hero-image.placeholder {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Version List */
|
||||
.version-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.version-date {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.version-notes {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* PC List */
|
||||
.pc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pc-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.pc-item:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.pc-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pc-name {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pc-alias {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.pc-version {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 1rem;
|
||||
color: var(--primary);
|
||||
background: var(--bg-card);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2.5rem;
|
||||
color: var(--text-light);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Notes styling */
|
||||
.notes-text :deep(a) {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.notes-text :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
271
frontend/src/views/applications/ApplicationForm.vue
Normal file
271
frontend/src/views/applications/ApplicationForm.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Application' : 'New Application' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="saveApplication">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="appname">Application Name *</label>
|
||||
<input
|
||||
id="appname"
|
||||
v-model="form.appname"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="supportteamid">Support Team</label>
|
||||
<select
|
||||
id="supportteamid"
|
||||
v-model="form.supportteamid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select team...</option>
|
||||
<option
|
||||
v-for="team in supportTeams"
|
||||
:key="team.supportteamid"
|
||||
:value="team.supportteamid"
|
||||
>
|
||||
{{ team.teamname }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="appdescription">Description</label>
|
||||
<textarea
|
||||
id="appdescription"
|
||||
v-model="form.appdescription"
|
||||
class="form-control"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Application Flags</h4>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.isinstallable" />
|
||||
Installable
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.islicenced" />
|
||||
Licensed
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.isprinter" />
|
||||
Printer App
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.ishidden" />
|
||||
Hidden
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Links & Paths</h4>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="applicationlink">Application Link</label>
|
||||
<input
|
||||
id="applicationlink"
|
||||
v-model="form.applicationlink"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="documentationpath">Documentation Path</label>
|
||||
<input
|
||||
id="documentationpath"
|
||||
v-model="form.documentationpath"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="URL or file path"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="installpath">Install Path</label>
|
||||
<input
|
||||
id="installpath"
|
||||
v-model="form.installpath"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Network path or URL to install files"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="image">Image Filename</label>
|
||||
<input
|
||||
id="image"
|
||||
v-model="form.image"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., myapp.png"
|
||||
/>
|
||||
<small class="form-hint">Image should be placed in /images/applications/</small>
|
||||
</div>
|
||||
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Notes</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="applicationnotes">Application Notes (HTML supported)</label>
|
||||
<textarea
|
||||
id="applicationnotes"
|
||||
v-model="form.applicationnotes"
|
||||
class="form-control"
|
||||
rows="6"
|
||||
placeholder="Enter notes... HTML tags like <BR>, <a>, <strong> are supported"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Application' }}
|
||||
</button>
|
||||
<router-link to="/applications" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { applicationsApi } from '../../api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({
|
||||
appname: '',
|
||||
appdescription: '',
|
||||
supportteamid: '',
|
||||
isinstallable: false,
|
||||
islicenced: false,
|
||||
isprinter: false,
|
||||
ishidden: false,
|
||||
applicationlink: '',
|
||||
documentationpath: '',
|
||||
installpath: '',
|
||||
image: '',
|
||||
applicationnotes: ''
|
||||
})
|
||||
|
||||
const supportTeams = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load support teams
|
||||
const teamsRes = await applicationsApi.getSupportTeams()
|
||||
supportTeams.value = teamsRes.data.data || []
|
||||
|
||||
// Load application if editing
|
||||
if (isEdit.value) {
|
||||
const response = await applicationsApi.get(route.params.id)
|
||||
const app = response.data.data
|
||||
|
||||
form.value = {
|
||||
appname: app.appname || '',
|
||||
appdescription: app.appdescription || '',
|
||||
supportteamid: app.supportteam?.supportteamid || '',
|
||||
isinstallable: app.isinstallable || false,
|
||||
islicenced: app.islicenced || false,
|
||||
isprinter: app.isprinter || false,
|
||||
ishidden: app.ishidden || false,
|
||||
applicationlink: app.applicationlink || '',
|
||||
documentationpath: app.documentationpath || '',
|
||||
installpath: app.installpath || '',
|
||||
image: app.image || '',
|
||||
applicationnotes: app.applicationnotes || ''
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function saveApplication() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const appData = {
|
||||
appname: form.value.appname,
|
||||
appdescription: form.value.appdescription || null,
|
||||
supportteamid: form.value.supportteamid || null,
|
||||
isinstallable: form.value.isinstallable,
|
||||
islicenced: form.value.islicenced,
|
||||
isprinter: form.value.isprinter,
|
||||
ishidden: form.value.ishidden,
|
||||
applicationlink: form.value.applicationlink || null,
|
||||
documentationpath: form.value.documentationpath || null,
|
||||
installpath: form.value.installpath || null,
|
||||
image: form.value.image || null,
|
||||
applicationnotes: form.value.applicationnotes || null
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await applicationsApi.update(route.params.id, appData)
|
||||
} else {
|
||||
await applicationsApi.create(appData)
|
||||
}
|
||||
|
||||
router.push('/applications')
|
||||
} catch (err) {
|
||||
console.error('Error saving application:', err)
|
||||
error.value = err.response?.data?.message || 'Failed to save application'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light, #666);
|
||||
}
|
||||
</style>
|
||||
219
frontend/src/views/applications/ApplicationsList.vue
Normal file
219
frontend/src/views/applications/ApplicationsList.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Applications</h2>
|
||||
<router-link to="/applications/new" class="btn btn-primary">Add Application</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search applications..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="filter" class="form-control" @change="loadApplications">
|
||||
<option value="installable">Installable Applications</option>
|
||||
<option value="all">All Applications</option>
|
||||
<option value="hidden">Hidden Applications</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50px;">Files</th>
|
||||
<th style="width: 50px;">Docs</th>
|
||||
<th>Application Name</th>
|
||||
<th>Description</th>
|
||||
<th>Support Team</th>
|
||||
<th>App Owner</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="app in applications" :key="app.appid">
|
||||
<td class="icon-cell">
|
||||
<a v-if="app.installpath" :href="app.installpath" target="_blank" title="Download Installation Files" class="icon-download">
|
||||
⬇
|
||||
</a>
|
||||
<a v-else-if="app.applicationlink" :href="app.applicationlink" target="_blank" title="Application Link" class="icon-link">
|
||||
🔗
|
||||
</a>
|
||||
</td>
|
||||
<td class="icon-cell">
|
||||
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" title="View Documentation" class="icon-docs">
|
||||
📄
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<router-link :to="`/applications/${app.appid}`">
|
||||
{{ app.appname }}
|
||||
</router-link>
|
||||
<span class="app-flags">
|
||||
<span v-if="app.isinstallable" class="badge badge-info badge-sm">Installable</span>
|
||||
<span v-if="app.islicenced" class="badge badge-warning badge-sm">Licensed</span>
|
||||
<span v-if="app.isprinter" class="badge badge-secondary badge-sm">Printer</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="description">{{ app.appdescription || '-' }}</td>
|
||||
<td>{{ app.supportteam?.teamname || '-' }}</td>
|
||||
<td>{{ app.supportteam?.owner?.appowner || '-' }}</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/applications/${app.appid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="applications.length === 0">
|
||||
<td colspan="7" style="text-align: center; color: var(--text-light);">
|
||||
No applications found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button
|
||||
v-for="p in visiblePages"
|
||||
:key="p"
|
||||
:class="{ active: p === page }"
|
||||
@click="goToPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { applicationsApi } from '../../api'
|
||||
|
||||
const applications = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const filter = ref('installable')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = 20
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
const start = Math.max(1, page.value - 2)
|
||||
const end = Math.min(totalPages.value, page.value + 2)
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
return pages
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadApplications()
|
||||
})
|
||||
|
||||
async function loadApplications() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
per_page: perPage,
|
||||
search: search.value || undefined
|
||||
}
|
||||
|
||||
// Apply filter
|
||||
if (filter.value === 'installable') {
|
||||
params.installable = true
|
||||
} else if (filter.value === 'hidden') {
|
||||
params.hidden = true
|
||||
}
|
||||
|
||||
const response = await applicationsApi.list(params)
|
||||
applications.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading applications:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadApplications()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadApplications()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Application list specific styles */
|
||||
.icon-cell {
|
||||
text-align: center;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.icon-cell a {
|
||||
font-size: 1.25rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.icon-download {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.icon-link {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.icon-docs {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.icon-cell a:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.app-flags {
|
||||
display: inline-flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
margin-left: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge-sm {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
197
frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue
Normal file
197
frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Knowledge Base Article</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="article">
|
||||
<div class="card article-card">
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Description:</th>
|
||||
<td>{{ article.shortdescription }}</td>
|
||||
</tr>
|
||||
<tr v-if="article.application">
|
||||
<th>Topic:</th>
|
||||
<td>
|
||||
<router-link :to="`/applications/${article.application.appid}`">
|
||||
{{ article.application.appname }}
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="article.linkurl">
|
||||
<th>URL:</th>
|
||||
<td>
|
||||
<a href="#" @click.prevent="openArticle">
|
||||
{{ article.linkurl }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="article.keywords">
|
||||
<th>Keywords:</th>
|
||||
<td>{{ article.keywords }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Clicks:</th>
|
||||
<td>{{ article.clicks }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Last Updated:</th>
|
||||
<td>{{ formatDate(article.lastupdated) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="actions-center">
|
||||
<a
|
||||
v-if="article.linkurl"
|
||||
href="#"
|
||||
class="btn btn-primary btn-lg"
|
||||
@click.prevent="openArticle"
|
||||
>
|
||||
<span class="btn-icon">↗</span> Open Article
|
||||
</a>
|
||||
<div v-else class="alert alert-warning">
|
||||
This article does not have a URL link defined. Please edit the article to add one.
|
||||
</div>
|
||||
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
|
||||
<span class="btn-icon">✎</span> Edit
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Article not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { knowledgebaseApi } from '../../api'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const article = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await knowledgebaseApi.get(route.params.id)
|
||||
article.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading article:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
async function openArticle() {
|
||||
try {
|
||||
await knowledgebaseApi.trackClick(article.value.linkid)
|
||||
article.value.clicks = (article.value.clicks || 0) + 1
|
||||
// Open the URL after tracking
|
||||
if (article.value.linkurl) {
|
||||
window.open(article.value.linkurl, '_blank')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error tracking click:', error)
|
||||
// Still open even if tracking fails
|
||||
if (article.value.linkurl) {
|
||||
window.open(article.value.linkurl, '_blank')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
width: 150px;
|
||||
font-weight: 600;
|
||||
color: var(--text-light, #666);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.info-table td a {
|
||||
color: var(--primary, #1976d2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.info-table td a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1.5rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color, #e5e5e5);
|
||||
}
|
||||
|
||||
.actions-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
</style>
|
||||
166
frontend/src/views/knowledgebase/KnowledgeBaseForm.vue
Normal file
166
frontend/src/views/knowledgebase/KnowledgeBaseForm.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Article' : 'Add Knowledge Base Article' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="saveArticle">
|
||||
<div class="form-group">
|
||||
<label for="shortdescription">Description *</label>
|
||||
<input
|
||||
id="shortdescription"
|
||||
v-model="form.shortdescription"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
maxlength="500"
|
||||
placeholder="Brief description of the article"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="linkurl">URL *</label>
|
||||
<input
|
||||
id="linkurl"
|
||||
v-model="form.linkurl"
|
||||
type="url"
|
||||
class="form-control"
|
||||
required
|
||||
maxlength="2000"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="keywords">Keywords</label>
|
||||
<input
|
||||
id="keywords"
|
||||
v-model="form.keywords"
|
||||
type="text"
|
||||
class="form-control"
|
||||
maxlength="500"
|
||||
placeholder="Space-separated keywords"
|
||||
/>
|
||||
<small class="form-hint">Keywords help with search - separate with spaces</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="appid">Topic (Application)</label>
|
||||
<select
|
||||
id="appid"
|
||||
v-model="form.appid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">-- Select Topic (Optional) --</option>
|
||||
<option
|
||||
v-for="app in applications"
|
||||
:key="app.appid"
|
||||
:value="app.appid"
|
||||
>
|
||||
{{ app.appname }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-hint">Select the application/topic this article relates to</small>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : (isEdit ? 'Update Article' : 'Add Article') }}
|
||||
</button>
|
||||
<router-link to="/knowledgebase" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { knowledgebaseApi, applicationsApi } from '../../api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({
|
||||
shortdescription: '',
|
||||
linkurl: '',
|
||||
keywords: '',
|
||||
appid: ''
|
||||
})
|
||||
|
||||
const applications = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load applications for topic dropdown
|
||||
const appsRes = await applicationsApi.list({ per_page: 1000 })
|
||||
applications.value = appsRes.data.data || []
|
||||
|
||||
// Load article if editing
|
||||
if (isEdit.value) {
|
||||
const response = await knowledgebaseApi.get(route.params.id)
|
||||
const article = response.data.data
|
||||
|
||||
form.value = {
|
||||
shortdescription: article.shortdescription || '',
|
||||
linkurl: article.linkurl || '',
|
||||
keywords: article.keywords || '',
|
||||
appid: article.application?.appid || ''
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function saveArticle() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const articleData = {
|
||||
shortdescription: form.value.shortdescription,
|
||||
linkurl: form.value.linkurl,
|
||||
keywords: form.value.keywords || null,
|
||||
appid: form.value.appid || null
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await knowledgebaseApi.update(route.params.id, articleData)
|
||||
} else {
|
||||
await knowledgebaseApi.create(articleData)
|
||||
}
|
||||
|
||||
router.push('/knowledgebase')
|
||||
} catch (err) {
|
||||
console.error('Error saving article:', err)
|
||||
error.value = err.response?.data?.message || 'Failed to save article'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light, #666);
|
||||
}
|
||||
</style>
|
||||
269
frontend/src/views/knowledgebase/KnowledgeBaseList.vue
Normal file
269
frontend/src/views/knowledgebase/KnowledgeBaseList.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>Knowledge Base</h2>
|
||||
<span v-if="stats" class="stats-badge">
|
||||
{{ stats.totalarticles }} articles | {{ stats.totalclicks.toLocaleString() }} total clicks
|
||||
</span>
|
||||
</div>
|
||||
<router-link to="/knowledgebase/new" class="btn btn-primary">Add Article</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search articles..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="topicFilter" class="form-control" @change="loadArticles">
|
||||
<option value="">All Topics</option>
|
||||
<option
|
||||
v-for="app in topics"
|
||||
:key="app.appid"
|
||||
:value="app.appid"
|
||||
>
|
||||
{{ app.appname }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" @click="toggleSort('topic')">
|
||||
Topic
|
||||
<span v-if="sort === 'topic'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
||||
</th>
|
||||
<th class="sortable" @click="toggleSort('description')">
|
||||
Description
|
||||
<span v-if="sort === 'description'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
||||
</th>
|
||||
<th class="sortable" style="width: 100px;" @click="toggleSort('clicks')">
|
||||
Clicks
|
||||
<span v-if="sort === 'clicks'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
||||
</th>
|
||||
<th style="width: 120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="article in articles" :key="article.linkid">
|
||||
<td>
|
||||
<router-link
|
||||
v-if="article.application"
|
||||
:to="`/applications/${article.application.appid}`"
|
||||
>
|
||||
{{ article.application.appname }}
|
||||
</router-link>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="#"
|
||||
class="article-link"
|
||||
@click.prevent="openArticle(article)"
|
||||
:title="article.linkurl"
|
||||
>
|
||||
{{ truncate(article.shortdescription, 95) }}
|
||||
</a>
|
||||
</td>
|
||||
<td style="text-align: center; font-weight: 500;">{{ article.clicks }}</td>
|
||||
<td class="actions">
|
||||
<router-link :to="`/knowledgebase/${article.linkid}`" class="btn btn-sm btn-secondary">
|
||||
View
|
||||
</router-link>
|
||||
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-sm btn-secondary">
|
||||
Edit
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="articles.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No articles found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button
|
||||
v-for="p in visiblePages"
|
||||
:key="p"
|
||||
:class="{ active: p === page }"
|
||||
@click="goToPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { knowledgebaseApi, applicationsApi } from '../../api'
|
||||
|
||||
const loading = ref(true)
|
||||
const articles = ref([])
|
||||
const topics = ref([])
|
||||
const stats = ref(null)
|
||||
const page = ref(1)
|
||||
const perPage = 20
|
||||
const totalPages = ref(1)
|
||||
const search = ref('')
|
||||
const topicFilter = ref('')
|
||||
const sort = ref('clicks')
|
||||
const order = ref('desc')
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
const start = Math.max(1, page.value - 2)
|
||||
const end = Math.min(totalPages.value, page.value + 2)
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
return pages
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadArticles(),
|
||||
loadTopics(),
|
||||
loadStats()
|
||||
])
|
||||
})
|
||||
|
||||
async function loadArticles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
per_page: perPage,
|
||||
sort: sort.value,
|
||||
order: order.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
if (topicFilter.value) params.appid = topicFilter.value
|
||||
|
||||
const response = await knowledgebaseApi.list(params)
|
||||
articles.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading articles:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopics() {
|
||||
try {
|
||||
const response = await applicationsApi.list({ per_page: 1000 })
|
||||
topics.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading topics:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await knowledgebaseApi.getStats()
|
||||
stats.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadArticles()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadArticles()
|
||||
}
|
||||
|
||||
function toggleSort(column) {
|
||||
if (sort.value === column) {
|
||||
order.value = order.value === 'desc' ? 'asc' : 'desc'
|
||||
} else {
|
||||
sort.value = column
|
||||
order.value = 'desc'
|
||||
}
|
||||
loadArticles()
|
||||
}
|
||||
|
||||
function truncate(text, length) {
|
||||
if (!text) return ''
|
||||
if (text.length <= length) return text
|
||||
return text.substring(0, length) + '...'
|
||||
}
|
||||
|
||||
async function openArticle(article) {
|
||||
try {
|
||||
await knowledgebaseApi.trackClick(article.linkid)
|
||||
article.clicks = (article.clicks || 0) + 1
|
||||
if (stats.value) {
|
||||
stats.value.totalclicks++
|
||||
}
|
||||
if (article.linkurl) {
|
||||
window.open(article.linkurl, '_blank')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error tracking click:', error)
|
||||
if (article.linkurl) {
|
||||
window.open(article.linkurl, '_blank')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Knowledge Base specific styles only */
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats-badge {
|
||||
background: var(--bg);
|
||||
color: var(--link);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.article-link {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.article-link:hover {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
318
frontend/src/views/machines/MachineDetail.vue
Normal file
318
frontend/src/views/machines/MachineDetail.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Equipment Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/machines/${machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
|
||||
Edit
|
||||
</router-link>
|
||||
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="machine">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="machine.model?.imageurl">
|
||||
<img :src="machine.model.imageurl" :alt="machine.model?.modelnumber" />
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ machine.machinenumber }}</h1>
|
||||
<span v-if="machine.alias" class="hero-alias">{{ machine.alias }}</span>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg" :class="getCategoryClass(machine.machinetype?.category)">
|
||||
{{ machine.machinetype?.category || 'Unknown' }}
|
||||
</span>
|
||||
<span class="badge badge-lg" :class="getStatusClass(machine.status?.status)">
|
||||
{{ machine.status?.status || 'Unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="machine.machinetype?.machinetype">
|
||||
<span class="hero-detail-label">Type</span>
|
||||
<span class="hero-detail-value">{{ machine.machinetype.machinetype }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="machine.vendor?.vendor">
|
||||
<span class="hero-detail-label">Vendor</span>
|
||||
<span class="hero-detail-value">{{ machine.vendor.vendor }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="machine.model?.modelnumber">
|
||||
<span class="hero-detail-label">Model</span>
|
||||
<span class="hero-detail-value">{{ machine.model.modelnumber }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="machine.location?.location">
|
||||
<span class="hero-detail-label">Location</span>
|
||||
<span class="hero-detail-value">{{ machine.location.location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Identity Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Identity</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Machine Number</span>
|
||||
<span class="info-value">{{ machine.machinenumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="machine.alias">
|
||||
<span class="info-label">Alias</span>
|
||||
<span class="info-value">{{ machine.alias }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="machine.hostname">
|
||||
<span class="info-label">Hostname</span>
|
||||
<span class="info-value mono">{{ machine.hostname }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="machine.serialnumber">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ machine.serialnumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardware Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Hardware</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Type</span>
|
||||
<span class="info-value">{{ machine.machinetype?.machinetype || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Vendor</span>
|
||||
<span class="info-value">{{ machine.vendor?.vendor || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Model</span>
|
||||
<span class="info-value">{{ machine.model?.modelnumber || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="machine.operatingsystem">
|
||||
<span class="info-label">Operating System</span>
|
||||
<span class="info-value">{{ machine.operatingsystem.osname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PC Information (if PC) -->
|
||||
<div class="section-card" v-if="isPc">
|
||||
<h3 class="section-title">PC Status</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row" v-if="machine.loggedinuser">
|
||||
<span class="info-label">Logged In User</span>
|
||||
<span class="info-value">{{ machine.loggedinuser }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Reported</span>
|
||||
<span class="info-value">{{ formatDate(machine.lastreporteddate) }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Boot</span>
|
||||
<span class="info-value">{{ formatDate(machine.lastboottime) }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Features</span>
|
||||
<span class="info-value">
|
||||
<span class="feature-tag" :class="{ active: machine.isvnc }">VNC</span>
|
||||
<span class="feature-tag" :class="{ active: machine.iswinrm }">WinRM</span>
|
||||
<span class="feature-tag" :class="{ active: machine.isshopfloor }">Shopfloor</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">
|
||||
<LocationMapTooltip
|
||||
v-if="machine.mapleft != null && machine.maptop != null"
|
||||
:left="machine.mapleft"
|
||||
:top="machine.maptop"
|
||||
:machineName="machine.machinenumber"
|
||||
>
|
||||
<span class="location-link">{{ machine.location?.location || 'On Map' }}</span>
|
||||
</LocationMapTooltip>
|
||||
<span v-else>{{ machine.location?.location || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ machine.businessunit?.businessunit || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected PC (for Equipment) -->
|
||||
<div class="section-card" v-if="isEquipment">
|
||||
<h3 class="section-title">Connected PC</h3>
|
||||
<div v-if="!controllingPc" class="empty-message">
|
||||
No controlling PC assigned
|
||||
</div>
|
||||
<div v-else class="connected-device">
|
||||
<router-link :to="getRelatedRoute(controllingPc)" class="device-link">
|
||||
<div class="device-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="device-info">
|
||||
<span class="device-name">{{ controllingPc.relatedmachinenumber }}</span>
|
||||
<span class="device-alias" v-if="controllingPc.relatedmachinealias">{{ controllingPc.relatedmachinealias }}</span>
|
||||
</div>
|
||||
</router-link>
|
||||
<span class="connection-type">{{ controllingPc.relationshiptype }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controlled Equipment (for PCs) -->
|
||||
<div class="section-card" v-if="isPc && controlledMachines.length > 0">
|
||||
<h3 class="section-title">Controlled Equipment</h3>
|
||||
<div class="equipment-list">
|
||||
<router-link
|
||||
v-for="rel in controlledMachines"
|
||||
:key="rel.relationshipid"
|
||||
:to="getRelatedRoute(rel)"
|
||||
class="equipment-item"
|
||||
>
|
||||
<div class="equipment-info">
|
||||
<span class="equipment-name">{{ rel.relatedmachinenumber }}</span>
|
||||
<span class="equipment-alias" v-if="rel.relatedmachinealias">{{ rel.relatedmachinealias }}</span>
|
||||
</div>
|
||||
<span class="connection-tag">{{ rel.relationshiptype }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network -->
|
||||
<div class="section-card" v-if="machine.communications?.length">
|
||||
<h3 class="section-title">Network</h3>
|
||||
<div class="network-list">
|
||||
<div v-for="comm in machine.communications" :key="comm.communicationid" class="network-item">
|
||||
<div class="network-primary">
|
||||
<span class="ip-address">{{ comm.ipaddress || comm.address || '-' }}</span>
|
||||
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
||||
</div>
|
||||
<div class="network-secondary" v-if="comm.macaddress">
|
||||
<span class="mac-address">{{ comm.macaddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="machine.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ machine.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(machine.createddate) }}<template v-if="machine.createdby"> by {{ machine.createdby }}</template></span>
|
||||
<span>Modified {{ formatDate(machine.modifieddate) }}<template v-if="machine.modifiedby"> by {{ machine.modifiedby }}</template></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Machine not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { machinesApi } from '../../api'
|
||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const machine = ref(null)
|
||||
const relationships = ref([])
|
||||
|
||||
const isPc = computed(() => {
|
||||
return machine.value?.machinetype?.category === 'PC'
|
||||
})
|
||||
|
||||
const isEquipment = computed(() => {
|
||||
return machine.value?.machinetype?.category === 'Equipment'
|
||||
})
|
||||
|
||||
const controllingPc = computed(() => {
|
||||
return relationships.value.find(r => r.direction === 'controlled_by' && r.relatedcategory === 'PC')
|
||||
})
|
||||
|
||||
const controlledMachines = computed(() => {
|
||||
return relationships.value.filter(r => r.direction === 'controls')
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await machinesApi.get(route.params.id)
|
||||
machine.value = response.data.data
|
||||
|
||||
const relResponse = await machinesApi.getRelationships(route.params.id)
|
||||
relationships.value = relResponse.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading machine:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function getCategoryClass(category) {
|
||||
if (!category) return 'badge-info'
|
||||
const c = category.toLowerCase()
|
||||
if (c === 'equipment') return 'badge-primary'
|
||||
if (c === 'pc') return 'badge-info'
|
||||
if (c === 'network') return 'badge-warning'
|
||||
if (c === 'printer') return 'badge-secondary'
|
||||
return 'badge-info'
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (!status) return 'badge-info'
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'in use' || s === 'active') return 'badge-success'
|
||||
if (s === 'in repair' || s === 'spare') return 'badge-warning'
|
||||
if (s === 'retired' || s === 'disposed') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function getRelatedRoute(rel) {
|
||||
const category = rel.relatedcategory?.toLowerCase() || ''
|
||||
const routeMap = {
|
||||
'equipment': '/machines',
|
||||
'pc': '/pcs',
|
||||
'printer': '/printers'
|
||||
}
|
||||
const basePath = routeMap[category] || '/machines'
|
||||
return `${basePath}/${rel.relatedmachineid}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Uses global detail page styles from style.css -->
|
||||
523
frontend/src/views/machines/MachineForm.vue
Normal file
523
frontend/src/views/machines/MachineForm.vue
Normal file
@@ -0,0 +1,523 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Equipment' : 'New Equipment' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="saveMachine">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="machinenumber">Machine Number *</label>
|
||||
<input
|
||||
id="machinenumber"
|
||||
v-model="form.machinenumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="alias">Alias</label>
|
||||
<input
|
||||
id="alias"
|
||||
v-model="form.alias"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="hostname">Hostname</label>
|
||||
<input
|
||||
id="hostname"
|
||||
v-model="form.hostname"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number</label>
|
||||
<input
|
||||
id="serialnumber"
|
||||
v-model="form.serialnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="machinetypeid">Equipment Type *</label>
|
||||
<select
|
||||
id="machinetypeid"
|
||||
v-model="form.machinetypeid"
|
||||
class="form-control"
|
||||
required
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
<option
|
||||
v-for="mt in machineTypes"
|
||||
:key="mt.machinetypeid"
|
||||
:value="mt.machinetypeid"
|
||||
>
|
||||
{{ mt.machinetype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="statusid">Status</label>
|
||||
<select
|
||||
id="statusid"
|
||||
v-model="form.statusid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select status...</option>
|
||||
<option
|
||||
v-for="s in statuses"
|
||||
:key="s.statusid"
|
||||
:value="s.statusid"
|
||||
>
|
||||
{{ s.status }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="vendorid">Vendor</label>
|
||||
<select
|
||||
id="vendorid"
|
||||
v-model="form.vendorid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modelnumberid">Model</label>
|
||||
<select
|
||||
id="modelnumberid"
|
||||
v-model="form.modelnumberid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select model...</option>
|
||||
<option
|
||||
v-for="m in filteredModels"
|
||||
:key="m.modelnumberid"
|
||||
:value="m.modelnumberid"
|
||||
>
|
||||
{{ m.modelnumber }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-help">Selecting a model will auto-set the equipment type</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="locationid">Location</label>
|
||||
<select
|
||||
id="locationid"
|
||||
v-model="form.locationid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select location...</option>
|
||||
<option
|
||||
v-for="l in locations"
|
||||
:key="l.locationid"
|
||||
:value="l.locationid"
|
||||
>
|
||||
{{ l.location }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="businessunitid">Business Unit</label>
|
||||
<select
|
||||
id="businessunitid"
|
||||
v-model="form.businessunitid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select business unit...</option>
|
||||
<option
|
||||
v-for="bu in businessunits"
|
||||
:key="bu.businessunitid"
|
||||
:value="bu.businessunitid"
|
||||
>
|
||||
{{ bu.businessunit }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
v-model="form.notes"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Controlling PC Selection -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="controllingpc">Controlling PC</label>
|
||||
<select
|
||||
id="controllingpc"
|
||||
v-model="controllingPcId"
|
||||
class="form-control"
|
||||
>
|
||||
<option :value="null">None (standalone)</option>
|
||||
<option
|
||||
v-for="pc in pcs"
|
||||
:key="pc.machineid"
|
||||
:value="pc.machineid"
|
||||
>
|
||||
{{ pc.machinenumber }}{{ pc.alias ? ` (${pc.alias})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-help">Select the PC that controls this equipment</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="controllingPcId">
|
||||
<label for="connectiontype">Connection Type</label>
|
||||
<select
|
||||
id="connectiontype"
|
||||
v-model="relationshipTypeId"
|
||||
class="form-control"
|
||||
>
|
||||
<option
|
||||
v-for="rt in relationshipTypes"
|
||||
:key="rt.relationshiptypeid"
|
||||
:value="rt.relationshiptypeid"
|
||||
>
|
||||
{{ rt.relationshiptype }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-help">How the PC connects to this equipment</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Location Picker -->
|
||||
<div class="form-group">
|
||||
<label>Map Location</label>
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapleft !== null && form.maptop !== null" class="current-position">
|
||||
Position: {{ form.mapleft }}, {{ form.maptop }}
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
Set Location on Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Picker Modal -->
|
||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||
<div class="map-modal-content">
|
||||
<ShopFloorMap
|
||||
:pickerMode="true"
|
||||
:initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null"
|
||||
@positionPicked="handlePositionPicked"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="showMapPicker = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="confirmMapPosition">Confirm Location</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Equipment' }}
|
||||
</button>
|
||||
<router-link to="/machines" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, relationshipTypesApi, modelsApi, businessunitsApi } from '../../api'
|
||||
import ShopFloorMap from '../../components/ShopFloorMap.vue'
|
||||
import Modal from '../../components/Modal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const showMapPicker = ref(false)
|
||||
const tempMapPosition = ref(null)
|
||||
|
||||
const form = ref({
|
||||
machinenumber: '',
|
||||
alias: '',
|
||||
hostname: '',
|
||||
serialnumber: '',
|
||||
machinetypeid: '',
|
||||
modelnumberid: '',
|
||||
statusid: '',
|
||||
vendorid: '',
|
||||
locationid: '',
|
||||
businessunitid: '',
|
||||
notes: '',
|
||||
mapleft: null,
|
||||
maptop: null
|
||||
})
|
||||
|
||||
const machineTypes = ref([])
|
||||
const statuses = ref([])
|
||||
const vendors = ref([])
|
||||
const locations = ref([])
|
||||
const models = ref([])
|
||||
const businessunits = ref([])
|
||||
const pcs = ref([])
|
||||
const relationshipTypes = ref([])
|
||||
const controllingPcId = ref(null)
|
||||
const relationshipTypeId = ref(null)
|
||||
const existingRelationshipId = ref(null)
|
||||
|
||||
// Filter models by selected vendor
|
||||
const filteredModels = computed(() => {
|
||||
return models.value.filter(m => {
|
||||
// Filter by vendor if selected
|
||||
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
|
||||
return false
|
||||
}
|
||||
// Only show models for Equipment category types
|
||||
const modelType = machineTypes.value.find(t => t.machinetypeid === m.machinetypeid)
|
||||
if (modelType && modelType.category !== 'Equipment') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// When model changes, auto-set the machine type
|
||||
watch(() => form.value.modelnumberid, (newModelId) => {
|
||||
if (newModelId) {
|
||||
const selectedModel = models.value.find(m => m.modelnumberid === newModelId)
|
||||
if (selectedModel && selectedModel.machinetypeid) {
|
||||
form.value.machinetypeid = selectedModel.machinetypeid
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load reference data
|
||||
const [mtRes, statusRes, vendorRes, locRes, modelsRes, buRes, pcsRes, relTypesRes] = await Promise.all([
|
||||
machinetypesApi.list({ category: 'Equipment' }),
|
||||
statusesApi.list(),
|
||||
vendorsApi.list(),
|
||||
locationsApi.list(),
|
||||
modelsApi.list(),
|
||||
businessunitsApi.list(),
|
||||
machinesApi.list({ category: 'PC', perpage: 500 }),
|
||||
relationshipTypesApi.list()
|
||||
])
|
||||
|
||||
machineTypes.value = mtRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
locations.value = locRes.data.data || []
|
||||
models.value = modelsRes.data.data || []
|
||||
businessunits.value = buRes.data.data || []
|
||||
pcs.value = pcsRes.data.data || []
|
||||
relationshipTypes.value = relTypesRes.data.data || []
|
||||
|
||||
// Set default relationship type to "Controls" if available
|
||||
const controlsType = relationshipTypes.value.find(t => t.relationshiptype === 'Controls')
|
||||
if (controlsType) {
|
||||
relationshipTypeId.value = controlsType.relationshiptypeid
|
||||
}
|
||||
|
||||
// Load machine if editing
|
||||
if (isEdit.value) {
|
||||
const response = await machinesApi.get(route.params.id)
|
||||
const machine = response.data.data
|
||||
form.value = {
|
||||
machinenumber: machine.machinenumber || '',
|
||||
alias: machine.alias || '',
|
||||
hostname: machine.hostname || '',
|
||||
serialnumber: machine.serialnumber || '',
|
||||
machinetypeid: machine.machinetype?.machinetypeid || '',
|
||||
modelnumberid: machine.model?.modelnumberid || '',
|
||||
statusid: machine.status?.statusid || '',
|
||||
vendorid: machine.vendor?.vendorid || '',
|
||||
locationid: machine.location?.locationid || '',
|
||||
businessunitid: machine.businessunit?.businessunitid || '',
|
||||
notes: machine.notes || '',
|
||||
mapleft: machine.mapleft ?? null,
|
||||
maptop: machine.maptop ?? null
|
||||
}
|
||||
|
||||
// Load existing relationship (controlling PC)
|
||||
const relResponse = await machinesApi.getRelationships(route.params.id)
|
||||
const relationships = relResponse.data.data || []
|
||||
const pcRelationship = relationships.find(r => r.direction === 'controlled_by' && r.relatedcategory === 'PC')
|
||||
if (pcRelationship) {
|
||||
controllingPcId.value = pcRelationship.relatedmachineid
|
||||
relationshipTypeId.value = pcRelationship.relationshiptypeid
|
||||
existingRelationshipId.value = pcRelationship.relationshipid
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function handlePositionPicked(position) {
|
||||
tempMapPosition.value = position
|
||||
}
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapleft = tempMapPosition.value.left
|
||||
form.value.maptop = tempMapPosition.value.top
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
|
||||
function clearMapPosition() {
|
||||
form.value.mapleft = null
|
||||
form.value.maptop = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
async function saveMachine() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const data = {
|
||||
...form.value,
|
||||
machinetypeid: form.value.machinetypeid || null,
|
||||
modelnumberid: form.value.modelnumberid || null,
|
||||
statusid: form.value.statusid || null,
|
||||
vendorid: form.value.vendorid || null,
|
||||
locationid: form.value.locationid || null,
|
||||
businessunitid: form.value.businessunitid || null,
|
||||
mapleft: form.value.mapleft,
|
||||
maptop: form.value.maptop
|
||||
}
|
||||
|
||||
let machineId = route.params.id
|
||||
|
||||
if (isEdit.value) {
|
||||
await machinesApi.update(route.params.id, data)
|
||||
} else {
|
||||
const response = await machinesApi.create(data)
|
||||
machineId = response.data.data.machineid
|
||||
}
|
||||
|
||||
// Handle relationship (controlling PC)
|
||||
await saveRelationship(machineId)
|
||||
|
||||
router.push('/machines')
|
||||
} catch (err) {
|
||||
console.error('Error saving machine:', err)
|
||||
error.value = err.response?.data?.message || 'Failed to save machine'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRelationship(machineId) {
|
||||
// If no PC selected and no existing relationship, nothing to do
|
||||
if (!controllingPcId.value && !existingRelationshipId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// If clearing the relationship
|
||||
if (!controllingPcId.value && existingRelationshipId.value) {
|
||||
await machinesApi.deleteRelationship(existingRelationshipId.value)
|
||||
return
|
||||
}
|
||||
|
||||
// If PC is selected
|
||||
if (controllingPcId.value) {
|
||||
// If there's an existing relationship, delete it first
|
||||
if (existingRelationshipId.value) {
|
||||
await machinesApi.deleteRelationship(existingRelationshipId.value)
|
||||
}
|
||||
|
||||
// Create new relationship
|
||||
await machinesApi.createRelationship(machineId, {
|
||||
relatedmachineid: controllingPcId.value,
|
||||
relationshiptypeid: relationshipTypeId.value,
|
||||
direction: 'controlled_by'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-location-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.current-position {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.map-modal-content {
|
||||
height: calc(90vh - 140px);
|
||||
}
|
||||
|
||||
.map-modal-content :deep(.shopfloor-map) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-modal-content :deep(.map-container) {
|
||||
height: calc(100% - 50px);
|
||||
}
|
||||
|
||||
.form-help {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
140
frontend/src/views/machines/MachinesList.vue
Normal file
140
frontend/src/views/machines/MachinesList.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Equipment</h2>
|
||||
<router-link to="/machines/new" class="btn btn-primary">Add Equipment</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search equipment..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine #</th>
|
||||
<th>Alias</th>
|
||||
<th>Hostname</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="machine in machines" :key="machine.machineid">
|
||||
<td>{{ machine.machinenumber }}</td>
|
||||
<td>{{ machine.alias || '-' }}</td>
|
||||
<td>{{ machine.hostname || '-' }}</td>
|
||||
<td>{{ machine.machinetype }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="getStatusClass(machine.status)">
|
||||
{{ machine.status || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ machine.location || '-' }}</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/machines/${machine.machineid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="machines.length === 0">
|
||||
<td colspan="7" style="text-align: center; color: var(--text-light);">
|
||||
No equipment found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button
|
||||
v-for="p in totalPages"
|
||||
:key="p"
|
||||
:class="{ active: p === page }"
|
||||
@click="goToPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { machinesApi } from '../../api'
|
||||
|
||||
const machines = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadMachines()
|
||||
})
|
||||
|
||||
async function loadMachines() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: 20,
|
||||
category: 'Equipment'
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
|
||||
const response = await machinesApi.list(params)
|
||||
machines.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading equipment:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadMachines()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadMachines()
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (!status) return 'badge-info'
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'in use' || s === 'active') return 'badge-success'
|
||||
if (s === 'in repair') return 'badge-warning'
|
||||
if (s === 'retired') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
</script>
|
||||
372
frontend/src/views/pcs/PCDetail.vue
Normal file
372
frontend/src/views/pcs/PCDetail.vue
Normal file
@@ -0,0 +1,372 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>PC Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="pc">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="pc.model?.imageurl">
|
||||
<img :src="pc.model.imageurl" :alt="pc.model?.modelnumber" />
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ pc.machinenumber }}</h1>
|
||||
<span v-if="pc.alias" class="hero-alias">{{ pc.alias }}</span>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-info">PC</span>
|
||||
<span class="badge badge-lg" :class="getStatusClass(pc.status?.status)">
|
||||
{{ pc.status?.status || 'Unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="pc.pctype">
|
||||
<span class="hero-detail-label">PC Type</span>
|
||||
<span class="hero-detail-value">{{ pc.pctype.pctype || pc.pctype }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="pc.vendor?.vendor">
|
||||
<span class="hero-detail-label">Vendor</span>
|
||||
<span class="hero-detail-value">{{ pc.vendor.vendor }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="pc.model?.modelnumber">
|
||||
<span class="hero-detail-label">Model</span>
|
||||
<span class="hero-detail-value">{{ pc.model.modelnumber }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="ipAddress">
|
||||
<span class="hero-detail-label">IP Address</span>
|
||||
<span class="hero-detail-value mono">{{ ipAddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Identity Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Identity</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Machine Number</span>
|
||||
<span class="info-value">{{ pc.machinenumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="pc.alias">
|
||||
<span class="info-label">Alias</span>
|
||||
<span class="info-value">{{ pc.alias }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="pc.hostname">
|
||||
<span class="info-label">Hostname</span>
|
||||
<span class="info-value mono">{{ pc.hostname }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="pc.serialnumber">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ pc.serialnumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardware Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Hardware</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row" v-if="pc.pctype">
|
||||
<span class="info-label">PC Type</span>
|
||||
<span class="info-value">{{ pc.pctype.pctype || pc.pctype }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Vendor</span>
|
||||
<span class="info-value">{{ pc.vendor?.vendor || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Model</span>
|
||||
<span class="info-value">{{ pc.model?.modelnumber || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="pc.operatingsystem">
|
||||
<span class="info-label">Operating System</span>
|
||||
<span class="info-value">{{ pc.operatingsystem.osname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PC Status -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Status</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row" v-if="pc.loggedinuser">
|
||||
<span class="info-label">Logged In User</span>
|
||||
<span class="info-value">{{ pc.loggedinuser }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Features</span>
|
||||
<span class="info-value">
|
||||
<span class="feature-tag" :class="{ active: pc.isvnc }">VNC</span>
|
||||
<span class="feature-tag" :class="{ active: pc.iswinrm }">WinRM</span>
|
||||
<span class="feature-tag" :class="{ active: pc.isshopfloor }">Shopfloor</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Location -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">
|
||||
<LocationMapTooltip
|
||||
v-if="pc.mapleft != null && pc.maptop != null"
|
||||
:left="pc.mapleft"
|
||||
:top="pc.maptop"
|
||||
:machineName="pc.machinenumber"
|
||||
>
|
||||
<span class="location-link">{{ pc.location?.location || 'On Map' }}</span>
|
||||
</LocationMapTooltip>
|
||||
<span v-else>{{ pc.location?.location || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controlled Equipment -->
|
||||
<div class="section-card" v-if="controlledMachines.length > 0">
|
||||
<h3 class="section-title">Controlled Equipment</h3>
|
||||
<div class="equipment-list">
|
||||
<router-link
|
||||
v-for="rel in controlledMachines"
|
||||
:key="rel.relationshipid"
|
||||
:to="getRelatedRoute(rel)"
|
||||
class="equipment-item"
|
||||
>
|
||||
<div class="equipment-info">
|
||||
<span class="equipment-name">{{ rel.relatedmachinenumber }}</span>
|
||||
<span class="equipment-alias" v-if="rel.relatedmachinealias">{{ rel.relatedmachinealias }}</span>
|
||||
</div>
|
||||
<div class="equipment-meta">
|
||||
<span class="category-tag">{{ rel.relatedcategory }}</span>
|
||||
<span class="connection-tag">{{ rel.relationshiptype }}</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installed Applications -->
|
||||
<div class="section-card" v-if="installedApps.length > 0">
|
||||
<h3 class="section-title">Installed Applications</h3>
|
||||
<div class="app-list">
|
||||
<router-link
|
||||
v-for="app in installedApps"
|
||||
:key="app.id"
|
||||
:to="`/applications/${app.application?.appid}`"
|
||||
class="app-item"
|
||||
>
|
||||
<div class="app-info">
|
||||
<span class="app-name">{{ app.application?.appname }}</span>
|
||||
<span class="app-version" v-if="app.version">v{{ app.version }}</span>
|
||||
</div>
|
||||
<div class="app-desc" v-if="app.application?.appdescription">
|
||||
{{ app.application.appdescription }}
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network -->
|
||||
<div class="section-card" v-if="pc.communications?.length">
|
||||
<h3 class="section-title">Network Interfaces</h3>
|
||||
<div class="network-list">
|
||||
<div v-for="comm in pc.communications" :key="comm.communicationid" class="network-item">
|
||||
<div class="network-primary">
|
||||
<span class="ip-address">{{ comm.ipaddress || comm.address || '-' }}</span>
|
||||
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
||||
</div>
|
||||
<div class="network-secondary" v-if="comm.macaddress">
|
||||
<span class="mac-address">{{ comm.macaddress }}</span>
|
||||
</div>
|
||||
<div class="network-details" v-if="comm.subnetmask || comm.defaultgateway">
|
||||
<span v-if="comm.subnetmask">Subnet: {{ comm.subnetmask }}</span>
|
||||
<span v-if="comm.defaultgateway">Gateway: {{ comm.defaultgateway }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="pc.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ pc.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(pc.createddate) }}<template v-if="pc.createdby"> by {{ pc.createdby }}</template></span>
|
||||
<span>Modified {{ formatDate(pc.modifieddate) }}<template v-if="pc.modifiedby"> by {{ pc.modifiedby }}</template></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">PC not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { machinesApi, applicationsApi } from '../../api'
|
||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const pc = ref(null)
|
||||
const relationships = ref([])
|
||||
const installedApps = ref([])
|
||||
|
||||
const ipAddress = computed(() => {
|
||||
if (!pc.value?.communications) return null
|
||||
const primaryComm = pc.value.communications.find(c => c.isprimary) || pc.value.communications[0]
|
||||
return primaryComm?.ipaddress || primaryComm?.address || null
|
||||
})
|
||||
|
||||
const controlledMachines = computed(() => {
|
||||
return relationships.value.filter(r => r.direction === 'controls')
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await machinesApi.get(route.params.id)
|
||||
pc.value = response.data.data
|
||||
|
||||
const relResponse = await machinesApi.getRelationships(route.params.id)
|
||||
relationships.value = relResponse.data.data || []
|
||||
|
||||
// Load installed applications
|
||||
try {
|
||||
const appsResponse = await applicationsApi.getMachineApps(route.params.id)
|
||||
installedApps.value = appsResponse.data.data || []
|
||||
} catch (appError) {
|
||||
// Silently handle if no apps table yet
|
||||
console.log('No installed apps data:', appError.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading PC:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function getRelatedRoute(rel) {
|
||||
const category = rel.relatedcategory?.toLowerCase() || ''
|
||||
const routeMap = {
|
||||
'equipment': '/machines',
|
||||
'pc': '/pcs',
|
||||
'printer': '/printers'
|
||||
}
|
||||
const basePath = routeMap[category] || '/machines'
|
||||
return `${basePath}/${rel.relatedmachineid}`
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (!status) return 'badge-info'
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'in use' || s === 'active') return 'badge-success'
|
||||
if (s === 'in repair') return 'badge-warning'
|
||||
if (s === 'retired') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* PC-specific styles - shared styles are in global style.css */
|
||||
|
||||
/* Installed Applications */
|
||||
.app-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.app-item:hover {
|
||||
background: var(--border);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app-version {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
margin-top: 0.375rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Equipment meta */
|
||||
.equipment-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.category-tag {
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Network details */
|
||||
.network-details {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
484
frontend/src/views/pcs/PCForm.vue
Normal file
484
frontend/src/views/pcs/PCForm.vue
Normal file
@@ -0,0 +1,484 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit PC' : 'New PC' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="savePC">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="machinenumber">PC Number *</label>
|
||||
<input
|
||||
id="machinenumber"
|
||||
v-model="form.machinenumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="alias">Alias</label>
|
||||
<input
|
||||
id="alias"
|
||||
v-model="form.alias"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="hostname">Hostname</label>
|
||||
<input
|
||||
id="hostname"
|
||||
v-model="form.hostname"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number</label>
|
||||
<input
|
||||
id="serialnumber"
|
||||
v-model="form.serialnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="machinetypeid">PC Type *</label>
|
||||
<select
|
||||
id="machinetypeid"
|
||||
v-model="form.machinetypeid"
|
||||
class="form-control"
|
||||
required
|
||||
@change="form.modelnumberid = ''"
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
<option
|
||||
v-for="pt in pcTypes"
|
||||
:key="pt.machinetypeid"
|
||||
:value="pt.machinetypeid"
|
||||
>
|
||||
{{ pt.machinetype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="osid">Operating System</label>
|
||||
<select
|
||||
id="osid"
|
||||
v-model="form.osid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select OS...</option>
|
||||
<option
|
||||
v-for="os in operatingsystems"
|
||||
:key="os.osid"
|
||||
:value="os.osid"
|
||||
>
|
||||
{{ os.osname }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="statusid">Status</label>
|
||||
<select
|
||||
id="statusid"
|
||||
v-model="form.statusid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select status...</option>
|
||||
<option
|
||||
v-for="s in statuses"
|
||||
:key="s.statusid"
|
||||
:value="s.statusid"
|
||||
>
|
||||
{{ s.status }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="locationid">Location</label>
|
||||
<select
|
||||
id="locationid"
|
||||
v-model="form.locationid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select location...</option>
|
||||
<option
|
||||
v-for="l in locations"
|
||||
:key="l.locationid"
|
||||
:value="l.locationid"
|
||||
>
|
||||
{{ l.location }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="vendorid">Vendor</label>
|
||||
<select
|
||||
id="vendorid"
|
||||
v-model="form.vendorid"
|
||||
class="form-control"
|
||||
@change="form.modelnumberid = ''"
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modelnumberid">Model</label>
|
||||
<select
|
||||
id="modelnumberid"
|
||||
v-model="form.modelnumberid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select model...</option>
|
||||
<option
|
||||
v-for="m in filteredModels"
|
||||
:key="m.modelnumberid"
|
||||
:value="m.modelnumberid"
|
||||
>
|
||||
{{ m.modelnumber }}
|
||||
</option>
|
||||
</select>
|
||||
<small v-if="!form.vendorid && !form.machinetypeid" class="form-hint">
|
||||
Select vendor or PC type to filter models
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PC-specific fields -->
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Network Settings</h4>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="ipaddress">IP Address</label>
|
||||
<input
|
||||
id="ipaddress"
|
||||
v-model="form.ipaddress"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., 192.168.1.100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="loggedinuser">Logged In User</label>
|
||||
<input
|
||||
id="loggedinuser"
|
||||
v-model="form.loggedinuser"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="display: flex; align-items: flex-end; gap: 1.5rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<input type="checkbox" v-model="form.isvnc" />
|
||||
VNC Enabled
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<input type="checkbox" v-model="form.iswinrm" />
|
||||
WinRM Enabled
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
v-model="form.notes"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Map Location Picker -->
|
||||
<div class="form-group">
|
||||
<label>Map Location</label>
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapleft !== null && form.maptop !== null" class="current-position">
|
||||
Position: {{ form.mapleft }}, {{ form.maptop }}
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
Set Location on Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Picker Modal -->
|
||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||
<div class="map-modal-content">
|
||||
<ShopFloorMap
|
||||
:pickerMode="true"
|
||||
:initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null"
|
||||
@positionPicked="handlePositionPicked"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="showMapPicker = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="confirmMapPosition">Confirm Location</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save PC' }}
|
||||
</button>
|
||||
<router-link to="/pcs" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api'
|
||||
import ShopFloorMap from '../../components/ShopFloorMap.vue'
|
||||
import Modal from '../../components/Modal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const showMapPicker = ref(false)
|
||||
const tempMapPosition = ref(null)
|
||||
|
||||
const form = ref({
|
||||
machinenumber: '',
|
||||
alias: '',
|
||||
hostname: '',
|
||||
serialnumber: '',
|
||||
machinetypeid: '',
|
||||
statusid: '',
|
||||
vendorid: '',
|
||||
modelnumberid: '',
|
||||
locationid: '',
|
||||
osid: '',
|
||||
loggedinuser: '',
|
||||
isvnc: false,
|
||||
iswinrm: false,
|
||||
notes: '',
|
||||
mapleft: null,
|
||||
maptop: null,
|
||||
ipaddress: ''
|
||||
})
|
||||
|
||||
const pcTypes = ref([])
|
||||
const statuses = ref([])
|
||||
const vendors = ref([])
|
||||
const models = ref([])
|
||||
const locations = ref([])
|
||||
const operatingsystems = ref([])
|
||||
|
||||
// Filter models by selected vendor and PC type
|
||||
const filteredModels = computed(() => {
|
||||
return models.value.filter(m => {
|
||||
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
|
||||
return false
|
||||
}
|
||||
if (form.value.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load reference data
|
||||
const [ptRes, statusRes, vendorRes, modelsRes, locRes, osRes] = await Promise.all([
|
||||
machinetypesApi.list({ category: 'PC' }),
|
||||
statusesApi.list(),
|
||||
vendorsApi.list(),
|
||||
modelsApi.list(),
|
||||
locationsApi.list(),
|
||||
operatingsystemsApi.list()
|
||||
])
|
||||
|
||||
pcTypes.value = ptRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
models.value = modelsRes.data.data || []
|
||||
locations.value = locRes.data.data || []
|
||||
operatingsystems.value = osRes.data.data || []
|
||||
|
||||
// Load PC if editing
|
||||
if (isEdit.value) {
|
||||
const response = await machinesApi.get(route.params.id)
|
||||
const pc = response.data.data
|
||||
|
||||
// Get IP from communications
|
||||
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
|
||||
|
||||
form.value = {
|
||||
machinenumber: pc.machinenumber || '',
|
||||
alias: pc.alias || '',
|
||||
hostname: pc.hostname || '',
|
||||
serialnumber: pc.serialnumber || '',
|
||||
machinetypeid: pc.machinetype?.machinetypeid || '',
|
||||
statusid: pc.status?.statusid || '',
|
||||
vendorid: pc.vendor?.vendorid || '',
|
||||
modelnumberid: pc.model?.modelnumberid || '',
|
||||
locationid: pc.location?.locationid || '',
|
||||
osid: pc.operatingsystem?.osid || '',
|
||||
loggedinuser: pc.loggedinuser || '',
|
||||
isvnc: pc.isvnc || false,
|
||||
iswinrm: pc.iswinrm || false,
|
||||
notes: pc.notes || '',
|
||||
mapleft: pc.mapleft ?? null,
|
||||
maptop: pc.maptop ?? null,
|
||||
ipaddress: primaryComm?.ipaddress || ''
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function handlePositionPicked(position) {
|
||||
tempMapPosition.value = position
|
||||
}
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapleft = tempMapPosition.value.left
|
||||
form.value.maptop = tempMapPosition.value.top
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
|
||||
function clearMapPosition() {
|
||||
form.value.mapleft = null
|
||||
form.value.maptop = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
async function savePC() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const machineData = {
|
||||
machinenumber: form.value.machinenumber,
|
||||
alias: form.value.alias,
|
||||
hostname: form.value.hostname,
|
||||
serialnumber: form.value.serialnumber,
|
||||
machinetypeid: form.value.machinetypeid || null,
|
||||
statusid: form.value.statusid || null,
|
||||
vendorid: form.value.vendorid || null,
|
||||
modelnumberid: form.value.modelnumberid || null,
|
||||
locationid: form.value.locationid || null,
|
||||
osid: form.value.osid || null,
|
||||
loggedinuser: form.value.loggedinuser,
|
||||
isvnc: form.value.isvnc,
|
||||
iswinrm: form.value.iswinrm,
|
||||
notes: form.value.notes,
|
||||
mapleft: form.value.mapleft,
|
||||
maptop: form.value.maptop
|
||||
}
|
||||
|
||||
let machineId
|
||||
if (isEdit.value) {
|
||||
await machinesApi.update(route.params.id, machineData)
|
||||
machineId = route.params.id
|
||||
} else {
|
||||
const response = await machinesApi.create(machineData)
|
||||
machineId = response.data.data.machineid
|
||||
}
|
||||
|
||||
// Handle IP address - update communication record
|
||||
if (form.value.ipaddress) {
|
||||
await machinesApi.updateCommunication(machineId, {
|
||||
ipaddress: form.value.ipaddress,
|
||||
isprimary: true
|
||||
})
|
||||
}
|
||||
|
||||
router.push('/pcs')
|
||||
} catch (err) {
|
||||
console.error('Error saving PC:', err)
|
||||
error.value = err.response?.data?.message || 'Failed to save PC'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-location-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.current-position {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.map-modal-content {
|
||||
height: calc(90vh - 140px);
|
||||
}
|
||||
|
||||
.map-modal-content :deep(.shopfloor-map) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-modal-content :deep(.map-container) {
|
||||
height: calc(100% - 50px);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light, #666);
|
||||
}
|
||||
</style>
|
||||
173
frontend/src/views/pcs/PCsList.vue
Normal file
173
frontend/src/views/pcs/PCsList.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>PCs</h2>
|
||||
<router-link to="/pcs/new" class="btn btn-primary">Add PC</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search PCs..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine #</th>
|
||||
<th>Serial Number</th>
|
||||
<th>PC Type</th>
|
||||
<th>Features</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pc in pcs" :key="pc.machineid">
|
||||
<td>{{ pc.machinenumber }}</td>
|
||||
<td class="mono">{{ pc.serialnumber || '-' }}</td>
|
||||
<td>{{ pc.pctype || '-' }}</td>
|
||||
<td class="features">
|
||||
<span v-if="pc.isvnc" class="feature-tag active">VNC</span>
|
||||
<span v-if="pc.iswinrm" class="feature-tag active">WinRM</span>
|
||||
<span v-if="!pc.isvnc && !pc.iswinrm">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="getStatusClass(pc.status)">
|
||||
{{ pc.status || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/pcs/${pc.machineid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="pcs.length === 0">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-light);">
|
||||
No PCs found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button
|
||||
v-for="p in totalPages"
|
||||
:key="p"
|
||||
:class="{ active: p === page }"
|
||||
@click="goToPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { machinesApi } from '../../api'
|
||||
|
||||
const pcs = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadPCs()
|
||||
})
|
||||
|
||||
async function loadPCs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: 20,
|
||||
category: 'PC'
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
|
||||
const response = await machinesApi.list(params)
|
||||
pcs.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading PCs:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadPCs()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadPCs()
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (!status) return 'badge-info'
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'in use' || s === 'active') return 'badge-success'
|
||||
if (s === 'in repair') return 'badge-warning'
|
||||
if (s === 'retired') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.feature-tag.active {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-tag.active {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
340
frontend/src/views/printers/PrinterDetail.vue
Normal file
340
frontend/src/views/printers/PrinterDetail.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Printer Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="printer">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="printer.model?.imageurl">
|
||||
<img :src="printer.model.imageurl" :alt="printer.model?.modelnumber" />
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ printer.machinenumber }}</h1>
|
||||
<span v-if="printer.alias" class="hero-alias">{{ printer.alias }}</span>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-printer">Printer</span>
|
||||
<span class="badge badge-lg" :class="getStatusClass(printer.status?.status)">
|
||||
{{ printer.status?.status || 'Unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="printer.vendor?.vendor">
|
||||
<span class="hero-detail-label">Vendor</span>
|
||||
<span class="hero-detail-value">{{ printer.vendor.vendor }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="printer.model?.modelnumber">
|
||||
<span class="hero-detail-label">Model</span>
|
||||
<span class="hero-detail-value">{{ printer.model.modelnumber }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="printer.location?.locationname">
|
||||
<span class="hero-detail-label">Location</span>
|
||||
<span class="hero-detail-value">{{ printer.location.location }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="ipAddress">
|
||||
<span class="hero-detail-label">IP Address</span>
|
||||
<span class="hero-detail-value mono">{{ ipAddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Identity Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Identity</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Windows Name</span>
|
||||
<span class="info-value">{{ printer.machinenumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="printer.alias">
|
||||
<span class="info-label">Alias</span>
|
||||
<span class="info-value">{{ printer.alias }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="printer.hostname">
|
||||
<span class="info-label">Hostname</span>
|
||||
<span class="info-value mono">{{ printer.hostname }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="printer.serialnumber">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ printer.serialnumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printer Settings -->
|
||||
<div class="section-card" v-if="printer.printerdata?.windowsname || printer.printerdata?.sharename">
|
||||
<h3 class="section-title">Print Server</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row" v-if="printer.printerdata?.windowsname">
|
||||
<span class="info-label">Windows Name</span>
|
||||
<span class="info-value mono">{{ printer.printerdata.windowsname }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="printer.printerdata?.sharename">
|
||||
<span class="info-label">CSF Name</span>
|
||||
<span class="info-value mono">{{ printer.printerdata.sharename }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="printer.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ printer.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Location -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">
|
||||
<LocationMapTooltip
|
||||
v-if="printer.mapleft != null && printer.maptop != null"
|
||||
:left="printer.mapleft"
|
||||
:top="printer.maptop"
|
||||
:machineName="printer.machinenumber"
|
||||
>
|
||||
<span class="location-link">{{ printer.location?.locationname || 'On Map' }}</span>
|
||||
</LocationMapTooltip>
|
||||
<span v-else>{{ printer.location?.locationname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network -->
|
||||
<div class="section-card" v-if="printer.communications?.length">
|
||||
<h3 class="section-title">Network</h3>
|
||||
<div class="network-list">
|
||||
<div v-for="comm in printer.communications" :key="comm.communicationid" class="network-item">
|
||||
<div class="network-primary">
|
||||
<span class="ip-address">{{ comm.ipaddress || comm.address || '-' }}</span>
|
||||
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
||||
</div>
|
||||
<div class="network-secondary" v-if="comm.macaddress">
|
||||
<span class="mac-address">{{ comm.macaddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Supplies Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Supplies</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="supplies.length === 0" class="empty-state">
|
||||
No supply information available
|
||||
</div>
|
||||
|
||||
<div v-else class="supplies-grid">
|
||||
<div v-for="supply in supplies" :key="supply.supplyid" class="supply-item">
|
||||
<div class="supply-header">
|
||||
<span class="supply-name">{{ supply.supplyname }}</span>
|
||||
<span class="supply-level" :class="getSupplyLevelClass(supply.currentlevel)">
|
||||
{{ supply.currentlevel !== null ? `${supply.currentlevel}%` : 'N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="supply-bar">
|
||||
<div
|
||||
class="supply-bar-fill"
|
||||
:class="getSupplyLevelClass(supply.currentlevel)"
|
||||
:style="{ width: `${supply.currentlevel || 0}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="supply-meta">
|
||||
<span>{{ supply.supplytypename }}</span>
|
||||
<span v-if="supply.partnumber">Part: {{ supply.partnumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drivers Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Assigned Drivers</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="drivers.length === 0" class="empty-state">
|
||||
No drivers assigned
|
||||
</div>
|
||||
|
||||
<div v-else class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Driver Name</th>
|
||||
<th>OS Type</th>
|
||||
<th>Version</th>
|
||||
<th>Universal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="driver in drivers" :key="driver.driverid">
|
||||
<td>{{ driver.drivername }}</td>
|
||||
<td>{{ driver.ostype }}</td>
|
||||
<td>{{ driver.version || '-' }}</td>
|
||||
<td>{{ driver.isuniversal ? 'Yes' : 'No' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Printer not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { printersApi } from '../../api'
|
||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const printer = ref(null)
|
||||
const supplies = ref([])
|
||||
const drivers = ref([])
|
||||
|
||||
// Get IP address from communications
|
||||
const ipAddress = computed(() => {
|
||||
if (!printer.value?.communications) return null
|
||||
const primaryComm = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0]
|
||||
return primaryComm?.ipaddress || null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [printerRes, suppliesRes, driversRes] = await Promise.all([
|
||||
printersApi.get(route.params.id),
|
||||
printersApi.getSupplies(route.params.id).catch(() => ({ data: { data: [] } })),
|
||||
printersApi.getDrivers(route.params.id).catch(() => ({ data: { data: [] } }))
|
||||
])
|
||||
|
||||
printer.value = printerRes.data.data
|
||||
supplies.value = suppliesRes.data.data || []
|
||||
drivers.value = driversRes.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading printer:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (!status) return 'badge-info'
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'in use' || s === 'active' || s === 'online') return 'badge-success'
|
||||
if (s === 'in repair' || s === 'offline') return 'badge-warning'
|
||||
if (s === 'retired' || s === 'error') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
|
||||
function getSupplyLevelClass(level) {
|
||||
if (level === null || level === undefined) return ''
|
||||
if (level <= 10) return 'critical'
|
||||
if (level <= 25) return 'low'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Printer-specific styles - shared styles are in global style.css */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
padding: 2.5rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Supplies */
|
||||
.supplies-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.supply-item {
|
||||
background: var(--bg);
|
||||
padding: 1.25rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.supply-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.supply-name {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.supply-level {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.supply-level.ok { color: var(--success); }
|
||||
.supply-level.low { color: var(--warning); }
|
||||
.supply-level.critical { color: var(--danger); }
|
||||
|
||||
.supply-bar {
|
||||
height: 10px;
|
||||
background: var(--border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.supply-bar-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.supply-bar-fill.low { background: var(--warning); }
|
||||
.supply-bar-fill.critical { background: var(--danger); }
|
||||
|
||||
.supply-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
</style>
|
||||
599
frontend/src/views/printers/PrinterForm.vue
Normal file
599
frontend/src/views/printers/PrinterForm.vue
Normal file
@@ -0,0 +1,599 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Printer' : 'New Printer' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="savePrinter">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="machinenumber">Windows Name *</label>
|
||||
<input
|
||||
id="machinenumber"
|
||||
v-model="form.machinenumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
@input="onWindowsNameInput"
|
||||
:class="{ 'auto-generated': !manualWindowsName && form.machinenumber }"
|
||||
/>
|
||||
<small class="form-hint">Auto-generated from CSF Name, Alias, Vendor & Model</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="alias">Alias / Location</label>
|
||||
<input
|
||||
id="alias"
|
||||
v-model="form.alias"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., SpoolsInspection"
|
||||
/>
|
||||
<small class="form-hint">Used in Windows name generation</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="hostname">Hostname (FQDN)</label>
|
||||
<input
|
||||
id="hostname"
|
||||
v-model="form.hostname"
|
||||
type="text"
|
||||
class="form-control"
|
||||
@input="onHostnameInput"
|
||||
:class="{ 'auto-generated': !manualHostname && form.hostname }"
|
||||
/>
|
||||
<small class="form-hint">Auto-generated from IP address</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number</label>
|
||||
<input
|
||||
id="serialnumber"
|
||||
v-model="form.serialnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="machinetypeid">Printer Type *</label>
|
||||
<select
|
||||
id="machinetypeid"
|
||||
v-model="form.machinetypeid"
|
||||
class="form-control"
|
||||
required
|
||||
@change="form.modelnumberid = ''"
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
<option
|
||||
v-for="mt in printerTypes"
|
||||
:key="mt.machinetypeid"
|
||||
:value="mt.machinetypeid"
|
||||
>
|
||||
{{ mt.machinetype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="statusid">Status</label>
|
||||
<select
|
||||
id="statusid"
|
||||
v-model="form.statusid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select status...</option>
|
||||
<option
|
||||
v-for="s in statuses"
|
||||
:key="s.statusid"
|
||||
:value="s.statusid"
|
||||
>
|
||||
{{ s.status }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="vendorid">Vendor</label>
|
||||
<select
|
||||
id="vendorid"
|
||||
v-model="form.vendorid"
|
||||
class="form-control"
|
||||
@change="form.modelnumberid = ''"
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modelnumberid">Model</label>
|
||||
<select
|
||||
id="modelnumberid"
|
||||
v-model="form.modelnumberid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select model...</option>
|
||||
<option
|
||||
v-for="m in filteredModels"
|
||||
:key="m.modelnumberid"
|
||||
:value="m.modelnumberid"
|
||||
>
|
||||
{{ m.modelnumber }}
|
||||
</option>
|
||||
</select>
|
||||
<small v-if="!form.vendorid && !form.machinetypeid" class="form-hint">
|
||||
Select vendor or printer type to filter models
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="locationid">Location</label>
|
||||
<select
|
||||
id="locationid"
|
||||
v-model="form.locationid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select location...</option>
|
||||
<option
|
||||
v-for="l in locations"
|
||||
:key="l.locationid"
|
||||
:value="l.locationid"
|
||||
>
|
||||
{{ l.location }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Printer-specific fields -->
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Printer Settings</h4>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="ipaddress">IP Address</label>
|
||||
<input
|
||||
id="ipaddress"
|
||||
v-model="form.ipaddress"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., 192.168.1.100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="csfname">CSF Name</label>
|
||||
<input
|
||||
id="csfname"
|
||||
v-model="form.csfname"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="pin">PIN</label>
|
||||
<input
|
||||
id="pin"
|
||||
v-model="form.pin"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="installpath">Driver Install Path</label>
|
||||
<input
|
||||
id="installpath"
|
||||
v-model="form.installpath"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Leave empty for universal driver"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
v-model="form.notes"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Map Location Picker -->
|
||||
<div class="form-group">
|
||||
<label>Map Location</label>
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapleft !== null && form.maptop !== null" class="current-position">
|
||||
Position: {{ form.mapleft }}, {{ form.maptop }}
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
Set Location on Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Picker Modal -->
|
||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||
<div class="map-modal-content">
|
||||
<ShopFloorMap
|
||||
:pickerMode="true"
|
||||
:initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null"
|
||||
@positionPicked="handlePositionPicked"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="showMapPicker = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="confirmMapPosition">Confirm Location</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Printer' }}
|
||||
</button>
|
||||
<router-link to="/printers" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, printersApi, modelsApi } from '../../api'
|
||||
import ShopFloorMap from '../../components/ShopFloorMap.vue'
|
||||
import Modal from '../../components/Modal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const manualHostname = ref(false)
|
||||
const manualWindowsName = ref(false)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const showMapPicker = ref(false)
|
||||
const tempMapPosition = ref(null)
|
||||
|
||||
const form = ref({
|
||||
machinenumber: '',
|
||||
alias: '',
|
||||
hostname: '',
|
||||
serialnumber: '',
|
||||
machinetypeid: '',
|
||||
statusid: '',
|
||||
vendorid: '',
|
||||
modelnumberid: '',
|
||||
locationid: '',
|
||||
notes: '',
|
||||
mapleft: null,
|
||||
maptop: null,
|
||||
// Printer-specific
|
||||
ipaddress: '',
|
||||
csfname: '',
|
||||
installpath: '',
|
||||
pin: ''
|
||||
})
|
||||
|
||||
const printerTypes = ref([])
|
||||
const statuses = ref([])
|
||||
const vendors = ref([])
|
||||
const models = ref([])
|
||||
const locations = ref([])
|
||||
|
||||
// Filter models by selected vendor and printer type
|
||||
const filteredModels = computed(() => {
|
||||
return models.value.filter(m => {
|
||||
// Filter by vendor if selected
|
||||
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
|
||||
return false
|
||||
}
|
||||
// Filter by printer type if selected
|
||||
if (form.value.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// Get short description from model number for naming
|
||||
function getModelShortDesc(modelNumber) {
|
||||
if (!modelNumber) return ''
|
||||
const mn = modelNumber.toLowerCase()
|
||||
|
||||
if (mn.includes('colorlaserjet')) return 'ColorLaserJet'
|
||||
if (mn.includes('laserjetpro') || mn.includes('laserjet pro')) return 'LaserJetPro'
|
||||
if (mn.includes('laserjet')) return 'LaserJet'
|
||||
if (mn.includes('altalink')) return 'Altalink'
|
||||
if (mn.includes('versalink')) return 'Versalink'
|
||||
if (mn.includes('designjet')) return 'DesignJet'
|
||||
if (mn.includes('dtc')) return 'DTC'
|
||||
if (mn.includes('officejet')) return 'OfficeJet'
|
||||
if (mn.includes('pagewide')) return 'PageWide'
|
||||
|
||||
// Fallback: get letters before first digit
|
||||
const match = modelNumber.match(/^([A-Za-z]+)/)
|
||||
return match ? match[1] : modelNumber.substring(0, 5)
|
||||
}
|
||||
|
||||
// Auto-generate hostname from IP address
|
||||
function generateHostname(ip) {
|
||||
if (!ip) return ''
|
||||
const ipDashed = ip.replace(/\./g, '-')
|
||||
return `Printer-${ipDashed}.printer.geaerospace.net`
|
||||
}
|
||||
|
||||
// Auto-generate Windows name (machinenumber)
|
||||
function generateWindowsName() {
|
||||
const parts = []
|
||||
|
||||
// 1. CSF Name (if set and not "NONE")
|
||||
const csfName = form.value.csfname?.trim()
|
||||
if (csfName && csfName.toUpperCase() !== 'NONE') {
|
||||
parts.push(csfName.replace(/\s+/g, ''))
|
||||
}
|
||||
|
||||
// 2. Location (from alias, removing spaces and "Machine")
|
||||
const alias = form.value.alias?.trim()
|
||||
if (alias) {
|
||||
const location = alias.replace(/\s+/g, '').replace(/Machine/gi, '')
|
||||
// Skip if same as CSF name
|
||||
if (location.toLowerCase() !== csfName?.toLowerCase()) {
|
||||
parts.push(location)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Vendor + Model short description
|
||||
const selectedModel = models.value.find(m => m.modelnumberid === form.value.modelnumberid)
|
||||
const selectedVendor = vendors.value.find(v => v.vendorid === form.value.vendorid)
|
||||
|
||||
let vendorModel = ''
|
||||
if (selectedVendor) {
|
||||
vendorModel = selectedVendor.vendor.replace(/\s+/g, '')
|
||||
}
|
||||
if (selectedModel) {
|
||||
vendorModel += getModelShortDesc(selectedModel.modelnumber)
|
||||
}
|
||||
if (vendorModel) {
|
||||
parts.push(vendorModel)
|
||||
}
|
||||
|
||||
return parts.join('-')
|
||||
}
|
||||
|
||||
// Watch IP address and auto-generate hostname
|
||||
watch(() => form.value.ipaddress, (newIp) => {
|
||||
if (!manualHostname.value && newIp) {
|
||||
form.value.hostname = generateHostname(newIp)
|
||||
}
|
||||
})
|
||||
|
||||
// Watch fields that affect Windows name generation
|
||||
watch(
|
||||
() => [form.value.csfname, form.value.alias, form.value.vendorid, form.value.modelnumberid],
|
||||
() => {
|
||||
if (!manualWindowsName.value && !isEdit.value) {
|
||||
const generated = generateWindowsName()
|
||||
if (generated) {
|
||||
form.value.machinenumber = generated
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Track manual edits to hostname
|
||||
function onHostnameInput() {
|
||||
manualHostname.value = true
|
||||
}
|
||||
|
||||
// Track manual edits to Windows name
|
||||
function onWindowsNameInput() {
|
||||
manualWindowsName.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load reference data
|
||||
const [mtRes, statusRes, vendorRes, modelsRes, locRes] = await Promise.all([
|
||||
machinetypesApi.list({ category: 'Printer' }),
|
||||
statusesApi.list(),
|
||||
vendorsApi.list(),
|
||||
modelsApi.list(),
|
||||
locationsApi.list()
|
||||
])
|
||||
|
||||
printerTypes.value = mtRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
models.value = modelsRes.data.data || []
|
||||
locations.value = locRes.data.data || []
|
||||
|
||||
// Load printer if editing
|
||||
if (isEdit.value) {
|
||||
const response = await printersApi.get(route.params.id)
|
||||
const printer = response.data.data
|
||||
|
||||
// Get IP from communications
|
||||
const primaryComm = printer.communications?.find(c => c.isprimary) || printer.communications?.[0]
|
||||
|
||||
form.value = {
|
||||
machinenumber: printer.machinenumber || '',
|
||||
alias: printer.alias || '',
|
||||
hostname: printer.hostname || '',
|
||||
serialnumber: printer.serialnumber || '',
|
||||
machinetypeid: printer.machinetype?.machinetypeid || '',
|
||||
statusid: printer.status?.statusid || '',
|
||||
vendorid: printer.vendor?.vendorid || '',
|
||||
modelnumberid: printer.model?.modelnumberid || '',
|
||||
locationid: printer.location?.locationid || '',
|
||||
notes: printer.notes || '',
|
||||
mapleft: printer.mapleft ?? null,
|
||||
maptop: printer.maptop ?? null,
|
||||
// Printer-specific
|
||||
ipaddress: primaryComm?.ipaddress || '',
|
||||
csfname: printer.printerdata?.sharename || '',
|
||||
installpath: printer.printerdata?.installpath || '',
|
||||
pin: printer.printerdata?.pin || ''
|
||||
}
|
||||
|
||||
// Don't auto-generate for existing printers
|
||||
manualWindowsName.value = true
|
||||
manualHostname.value = true
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function handlePositionPicked(position) {
|
||||
tempMapPosition.value = position
|
||||
}
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapleft = tempMapPosition.value.left
|
||||
form.value.maptop = tempMapPosition.value.top
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
|
||||
function clearMapPosition() {
|
||||
form.value.mapleft = null
|
||||
form.value.maptop = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
async function savePrinter() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const machineData = {
|
||||
machinenumber: form.value.machinenumber,
|
||||
alias: form.value.alias,
|
||||
hostname: form.value.hostname,
|
||||
serialnumber: form.value.serialnumber,
|
||||
machinetypeid: form.value.machinetypeid || null,
|
||||
statusid: form.value.statusid || null,
|
||||
vendorid: form.value.vendorid || null,
|
||||
modelnumberid: form.value.modelnumberid || null,
|
||||
locationid: form.value.locationid || null,
|
||||
notes: form.value.notes,
|
||||
mapleft: form.value.mapleft,
|
||||
maptop: form.value.maptop
|
||||
}
|
||||
|
||||
const printerData = {
|
||||
windowsname: form.value.machinenumber, // Windows name is the machinenumber
|
||||
sharename: form.value.csfname,
|
||||
installpath: form.value.installpath,
|
||||
pin: form.value.pin,
|
||||
iscsf: !!form.value.csfname // Auto-set based on whether CSF name is filled
|
||||
}
|
||||
|
||||
// Handle IP address - need to update/create communication record
|
||||
const communicationData = form.value.ipaddress ? {
|
||||
ipaddress: form.value.ipaddress,
|
||||
isprimary: true
|
||||
} : null
|
||||
|
||||
if (isEdit.value) {
|
||||
await machinesApi.update(route.params.id, machineData)
|
||||
await printersApi.updateExtension(route.params.id, printerData)
|
||||
if (communicationData) {
|
||||
await printersApi.updateCommunication(route.params.id, communicationData)
|
||||
}
|
||||
} else {
|
||||
const response = await machinesApi.create(machineData)
|
||||
const newId = response.data.data.machineid
|
||||
await printersApi.updateExtension(newId, printerData)
|
||||
if (communicationData) {
|
||||
await printersApi.updateCommunication(newId, communicationData)
|
||||
}
|
||||
}
|
||||
|
||||
router.push('/printers')
|
||||
} catch (err) {
|
||||
console.error('Error saving printer:', err)
|
||||
error.value = err.response?.data?.message || 'Failed to save printer'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-location-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.current-position {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.map-modal-content {
|
||||
height: calc(90vh - 140px);
|
||||
}
|
||||
|
||||
.map-modal-content :deep(.shopfloor-map) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-modal-content :deep(.map-container) {
|
||||
height: calc(100% - 50px);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light, #666);
|
||||
}
|
||||
|
||||
.auto-generated {
|
||||
background-color: #f0f7ff;
|
||||
border-color: #90caf9;
|
||||
}
|
||||
|
||||
.auto-generated:focus {
|
||||
background-color: white;
|
||||
}
|
||||
</style>
|
||||
136
frontend/src/views/printers/PrintersList.vue
Normal file
136
frontend/src/views/printers/PrintersList.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Printers</h2>
|
||||
<router-link to="/printers/new" class="btn btn-primary">Add Printer</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search printers..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine #</th>
|
||||
<th>Alias</th>
|
||||
<th>Location</th>
|
||||
<th>Model</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="printer in printers" :key="printer.machineid">
|
||||
<td>{{ printer.machinenumber }}</td>
|
||||
<td>{{ printer.alias || '-' }}</td>
|
||||
<td>{{ printer.location || '-' }}</td>
|
||||
<td>{{ printer.model || '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="getStatusClass(printer.status)">
|
||||
{{ printer.status || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/printers/${printer.machineid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="printers.length === 0">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-light);">
|
||||
No printers found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button
|
||||
v-for="p in totalPages"
|
||||
:key="p"
|
||||
:class="{ active: p === page }"
|
||||
@click="goToPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { printersApi } from '../../api'
|
||||
|
||||
const printers = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadPrinters()
|
||||
})
|
||||
|
||||
async function loadPrinters() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: 20
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
|
||||
const response = await printersApi.list(params)
|
||||
printers.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading printers:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadPrinters()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadPrinters()
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (!status) return 'badge-info'
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'in use' || s === 'active') return 'badge-success'
|
||||
if (s === 'in repair') return 'badge-warning'
|
||||
if (s === 'retired') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
</script>
|
||||
176
frontend/src/views/settings/BusinessUnitsList.vue
Normal file
176
frontend/src/views/settings/BusinessUnitsList.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Business Units</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Business Unit</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Business Unit</th>
|
||||
<th>Code</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="bu in items" :key="bu.businessunitid">
|
||||
<td>{{ bu.businessunit }}</td>
|
||||
<td>{{ bu.code || '-' }}</td>
|
||||
<td>{{ bu.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(bu)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="confirmDelete(bu)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No business units found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button v-for="p in totalPages" :key="p" :class="{ active: p === page }" @click="goToPage(p)">
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit Business Unit' : 'Add Business Unit' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="businessunit">Business Unit Name *</label>
|
||||
<input id="businessunit" v-model="form.businessunit" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="code">Code</label>
|
||||
<input id="code" v-model="form.code" type="text" class="form-control" placeholder="e.g., ENGR, MFG" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Delete Business Unit</h3></div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ toDelete?.businessunit }}</strong>?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteItem">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { businessunitsApi } from '../../api'
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const toDelete = ref(null)
|
||||
|
||||
const form = ref({ businessunit: '', code: '', description: '' })
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await businessunitsApi.list({ page: page.value, perpage: 20 })
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading business units:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p) { page.value = p; loadData() }
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item ? {
|
||||
businessunit: item.businessunit || '',
|
||||
code: item.code || '',
|
||||
description: item.description || ''
|
||||
} : { businessunit: '', code: '', description: '' }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await businessunitsApi.update(editing.value.businessunitid, form.value)
|
||||
} else {
|
||||
await businessunitsApi.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || 'Failed to save'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
|
||||
|
||||
async function deleteItem() {
|
||||
try {
|
||||
await businessunitsApi.delete(toDelete.value.businessunitid)
|
||||
showDeleteModal.value = false
|
||||
toDelete.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
alert('Failed to delete')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
324
frontend/src/views/settings/LocationsList.vue
Normal file
324
frontend/src/views/settings/LocationsList.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Locations</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Location</button>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search locations..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Location Name</th>
|
||||
<th>Building</th>
|
||||
<th>Floor</th>
|
||||
<th>Room</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="loc in locations" :key="loc.locationid">
|
||||
<td>{{ loc.locationname }}</td>
|
||||
<td>{{ loc.building || '-' }}</td>
|
||||
<td>{{ loc.floor || '-' }}</td>
|
||||
<td>{{ loc.room || '-' }}</td>
|
||||
<td>{{ loc.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="openModal(loc)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger btn-sm"
|
||||
@click="confirmDelete(loc)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="locations.length === 0">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-light);">
|
||||
No locations found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button
|
||||
v-for="p in totalPages"
|
||||
:key="p"
|
||||
:class="{ active: p === page }"
|
||||
@click="goToPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingLocation ? 'Edit Location' : 'Add Location' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveLocation">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="locationname">Location Name *</label>
|
||||
<input
|
||||
id="locationname"
|
||||
v-model="form.locationname"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="building">Building</label>
|
||||
<input
|
||||
id="building"
|
||||
v-model="form.building"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="floor">Floor</label>
|
||||
<input
|
||||
id="floor"
|
||||
v-model="form.floor"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="room">Room</label>
|
||||
<input
|
||||
id="room"
|
||||
v-model="form.room"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="mapimage">Map Image URL</label>
|
||||
<input
|
||||
id="mapimage"
|
||||
v-model="form.mapimage"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Optional floor plan image URL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Delete Location</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ locationToDelete?.locationname }}</strong>?</p>
|
||||
<p style="color: var(--text-light); font-size: 0.875rem;">
|
||||
This may affect machines assigned to this location.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteLocation">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { locationsApi } from '../../api'
|
||||
|
||||
const locations = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingLocation = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const locationToDelete = ref(null)
|
||||
|
||||
const form = ref({
|
||||
locationname: '',
|
||||
building: '',
|
||||
floor: '',
|
||||
room: '',
|
||||
description: '',
|
||||
mapimage: ''
|
||||
})
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadLocations()
|
||||
})
|
||||
|
||||
async function loadLocations() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: 20
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
|
||||
const response = await locationsApi.list(params)
|
||||
locations.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading locations:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadLocations()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadLocations()
|
||||
}
|
||||
|
||||
function openModal(loc = null) {
|
||||
editingLocation.value = loc
|
||||
if (loc) {
|
||||
form.value = {
|
||||
locationname: loc.locationname || '',
|
||||
building: loc.building || '',
|
||||
floor: loc.floor || '',
|
||||
room: loc.room || '',
|
||||
description: loc.description || '',
|
||||
mapimage: loc.mapimage || ''
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
locationname: '',
|
||||
building: '',
|
||||
floor: '',
|
||||
room: '',
|
||||
description: '',
|
||||
mapimage: ''
|
||||
}
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingLocation.value = null
|
||||
}
|
||||
|
||||
async function saveLocation() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
if (editingLocation.value) {
|
||||
await locationsApi.update(editingLocation.value.locationid, form.value)
|
||||
} else {
|
||||
await locationsApi.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadLocations()
|
||||
} catch (err) {
|
||||
console.error('Error saving location:', err)
|
||||
error.value = err.response?.data?.message || 'Failed to save location'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(loc) {
|
||||
locationToDelete.value = loc
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteLocation() {
|
||||
try {
|
||||
await locationsApi.delete(locationToDelete.value.locationid)
|
||||
showDeleteModal.value = false
|
||||
locationToDelete.value = null
|
||||
loadLocations()
|
||||
} catch (err) {
|
||||
console.error('Error deleting location:', err)
|
||||
alert('Failed to delete location')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
269
frontend/src/views/settings/MachineTypesList.vue
Normal file
269
frontend/src/views/settings/MachineTypesList.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Machine Types</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine Type</th>
|
||||
<th>Category</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="mt in machineTypes" :key="mt.machinetypeid">
|
||||
<td>{{ mt.machinetype }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="getCategoryClass(mt.category)">
|
||||
{{ mt.category }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ mt.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="openModal(mt)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger btn-sm"
|
||||
@click="confirmDelete(mt)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="machineTypes.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No machine types found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button
|
||||
v-for="p in totalPages"
|
||||
:key="p"
|
||||
:class="{ active: p === page }"
|
||||
@click="goToPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingType ? 'Edit Machine Type' : 'Add Machine Type' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveType">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="machinetype">Type Name *</label>
|
||||
<input
|
||||
id="machinetype"
|
||||
v-model="form.machinetype"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="category">Category *</label>
|
||||
<select
|
||||
id="category"
|
||||
v-model="form.category"
|
||||
class="form-control"
|
||||
required
|
||||
>
|
||||
<option value="">Select category...</option>
|
||||
<option value="Equipment">Equipment</option>
|
||||
<option value="PC">PC</option>
|
||||
<option value="Network">Network</option>
|
||||
<option value="Printer">Printer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Delete Machine Type</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ typeToDelete?.machinetype }}</strong>?</p>
|
||||
<p style="color: var(--text-light); font-size: 0.875rem;">
|
||||
This may affect machines using this type.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteType">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { machinetypesApi } from '../../api'
|
||||
|
||||
const machineTypes = ref([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingType = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const typeToDelete = ref(null)
|
||||
|
||||
const form = ref({
|
||||
machinetype: '',
|
||||
category: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTypes()
|
||||
})
|
||||
|
||||
async function loadTypes() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: 20
|
||||
}
|
||||
|
||||
const response = await machinetypesApi.list(params)
|
||||
machineTypes.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading machine types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadTypes()
|
||||
}
|
||||
|
||||
function openModal(mt = null) {
|
||||
editingType.value = mt
|
||||
if (mt) {
|
||||
form.value = {
|
||||
machinetype: mt.machinetype || '',
|
||||
category: mt.category || '',
|
||||
description: mt.description || ''
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
machinetype: '',
|
||||
category: '',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingType.value = null
|
||||
}
|
||||
|
||||
async function saveType() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
if (editingType.value) {
|
||||
await machinetypesApi.update(editingType.value.machinetypeid, form.value)
|
||||
} else {
|
||||
await machinetypesApi.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadTypes()
|
||||
} catch (err) {
|
||||
console.error('Error saving machine type:', err)
|
||||
error.value = err.response?.data?.message || 'Failed to save machine type'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(mt) {
|
||||
typeToDelete.value = mt
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteType() {
|
||||
try {
|
||||
await machinetypesApi.delete(typeToDelete.value.machinetypeid)
|
||||
showDeleteModal.value = false
|
||||
typeToDelete.value = null
|
||||
loadTypes()
|
||||
} catch (err) {
|
||||
console.error('Error deleting machine type:', err)
|
||||
alert('Failed to delete machine type')
|
||||
}
|
||||
}
|
||||
|
||||
function getCategoryClass(category) {
|
||||
if (!category) return 'badge-info'
|
||||
const c = category.toLowerCase()
|
||||
if (c === 'equipment') return 'badge-info'
|
||||
if (c === 'pc') return 'badge-success'
|
||||
if (c === 'network') return 'badge-warning'
|
||||
if (c === 'printer') return 'badge-primary'
|
||||
return 'badge-info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Uses global styles from style.css -->
|
||||
362
frontend/src/views/settings/ModelsList.vue
Normal file
362
frontend/src/views/settings/ModelsList.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Models</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Model</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search models..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="vendorFilter" class="form-control" @change="loadModels">
|
||||
<option value="">All Vendors</option>
|
||||
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th>Vendor</th>
|
||||
<th>Type</th>
|
||||
<th>Documentation</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in models" :key="m.modelnumberid">
|
||||
<td>
|
||||
<div>{{ m.modelnumber }}</div>
|
||||
<small v-if="m.description" class="text-muted">{{ m.description }}</small>
|
||||
</td>
|
||||
<td>{{ m.vendor || '-' }}</td>
|
||||
<td>{{ m.machinetype || '-' }}</td>
|
||||
<td>
|
||||
<a v-if="m.documentationurl" :href="m.documentationurl" target="_blank" class="btn btn-sm btn-link">
|
||||
View Docs
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(m)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="confirmDelete(m)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="models.length === 0">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-light);">
|
||||
No models found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button
|
||||
v-for="p in totalPages"
|
||||
:key="p"
|
||||
:class="{ active: p === page }"
|
||||
@click="goToPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingModel ? 'Edit Model' : 'Add Model' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveModel">
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="modelnumber">Model Number *</label>
|
||||
<input
|
||||
id="modelnumber"
|
||||
v-model="form.modelnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vendorid">Vendor *</label>
|
||||
<select id="vendorid" v-model="form.vendorid" class="form-control" required>
|
||||
<option value="">Select vendor...</option>
|
||||
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="machinetypeid">Machine Type</label>
|
||||
<select id="machinetypeid" v-model="form.machinetypeid" class="form-control">
|
||||
<option value="">Select type...</option>
|
||||
<option v-for="mt in machineTypes" :key="mt.machinetypeid" :value="mt.machinetypeid">
|
||||
{{ mt.machinetype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<input id="description" v-model="form.description" type="text" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="documentationurl">Documentation URL</label>
|
||||
<input
|
||||
id="documentationurl"
|
||||
v-model="form.documentationurl"
|
||||
type="url"
|
||||
class="form-control"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="imageurl">Image URL</label>
|
||||
<input
|
||||
id="imageurl"
|
||||
v-model="form.imageurl"
|
||||
type="url"
|
||||
class="form-control"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea id="notes" v-model="form.notes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Delete Model</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ modelToDelete?.modelnumber }}</strong>?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteModel">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { modelsApi, vendorsApi, machinetypesApi } from '../../api'
|
||||
|
||||
const models = ref([])
|
||||
const vendors = ref([])
|
||||
const machineTypes = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const vendorFilter = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingModel = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const modelToDelete = ref(null)
|
||||
|
||||
const form = ref({
|
||||
modelnumber: '',
|
||||
vendorid: '',
|
||||
machinetypeid: '',
|
||||
description: '',
|
||||
documentationurl: '',
|
||||
imageurl: '',
|
||||
notes: ''
|
||||
})
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadModels(),
|
||||
loadVendors(),
|
||||
loadMachineTypes()
|
||||
])
|
||||
})
|
||||
|
||||
async function loadModels() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, perpage: 20 }
|
||||
if (search.value) params.search = search.value
|
||||
if (vendorFilter.value) params.vendor = vendorFilter.value
|
||||
|
||||
const response = await modelsApi.list(params)
|
||||
models.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading models:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVendors() {
|
||||
try {
|
||||
const response = await vendorsApi.list({ perpage: 100 })
|
||||
vendors.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading vendors:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMachineTypes() {
|
||||
try {
|
||||
const response = await machinetypesApi.list({ perpage: 100 })
|
||||
machineTypes.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading machine types:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadModels()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadModels()
|
||||
}
|
||||
|
||||
function openModal(m = null) {
|
||||
editingModel.value = m
|
||||
if (m) {
|
||||
form.value = {
|
||||
modelnumber: m.modelnumber || '',
|
||||
vendorid: m.vendorid || '',
|
||||
machinetypeid: m.machinetypeid || '',
|
||||
description: m.description || '',
|
||||
documentationurl: m.documentationurl || '',
|
||||
imageurl: m.imageurl || '',
|
||||
notes: m.notes || ''
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
modelnumber: '',
|
||||
vendorid: '',
|
||||
machinetypeid: '',
|
||||
description: '',
|
||||
documentationurl: '',
|
||||
imageurl: '',
|
||||
notes: ''
|
||||
}
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingModel.value = null
|
||||
}
|
||||
|
||||
async function saveModel() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const data = { ...form.value }
|
||||
if (!data.vendorid) data.vendorid = null
|
||||
if (!data.machinetypeid) data.machinetypeid = null
|
||||
|
||||
if (editingModel.value) {
|
||||
await modelsApi.update(editingModel.value.modelnumberid, data)
|
||||
} else {
|
||||
await modelsApi.create(data)
|
||||
}
|
||||
closeModal()
|
||||
loadModels()
|
||||
} catch (err) {
|
||||
console.error('Error saving model:', err)
|
||||
error.value = err.response?.data?.message || 'Failed to save model'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(m) {
|
||||
modelToDelete.value = m
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteModel() {
|
||||
try {
|
||||
await modelsApi.delete(modelToDelete.value.modelnumberid)
|
||||
showDeleteModal.value = false
|
||||
modelToDelete.value = null
|
||||
loadModels()
|
||||
} catch (err) {
|
||||
console.error('Error deleting model:', err)
|
||||
alert('Failed to delete model')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-lg {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
213
frontend/src/views/settings/OperatingSystemsList.vue
Normal file
213
frontend/src/views/settings/OperatingSystemsList.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Operating Systems</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add OS</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>OS Name</th>
|
||||
<th>Version</th>
|
||||
<th>Architecture</th>
|
||||
<th>End of Life</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="os in items" :key="os.osid">
|
||||
<td>{{ os.osname }}</td>
|
||||
<td>{{ os.osversion || '-' }}</td>
|
||||
<td>{{ os.architecture || '-' }}</td>
|
||||
<td>
|
||||
<span v-if="os.endoflife" :class="{ 'text-danger': isPastEol(os.endoflife) }">
|
||||
{{ os.endoflife }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(os)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="confirmDelete(os)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-light);">
|
||||
No operating systems found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button v-for="p in totalPages" :key="p" :class="{ active: p === page }" @click="goToPage(p)">
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit Operating System' : 'Add Operating System' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="osname">OS Name *</label>
|
||||
<input id="osname" v-model="form.osname" type="text" class="form-control" required placeholder="Windows 11" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="osversion">Version</label>
|
||||
<input id="osversion" v-model="form.osversion" type="text" class="form-control" placeholder="23H2" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="architecture">Architecture</label>
|
||||
<select id="architecture" v-model="form.architecture" class="form-control">
|
||||
<option value="">Select...</option>
|
||||
<option value="x64">x64</option>
|
||||
<option value="x86">x86</option>
|
||||
<option value="ARM64">ARM64</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="endoflife">End of Life Date</label>
|
||||
<input id="endoflife" v-model="form.endoflife" type="date" class="form-control" />
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Delete Operating System</h3></div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ toDelete?.osname }}</strong>?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteItem">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { operatingsystemsApi } from '../../api'
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const toDelete = ref(null)
|
||||
|
||||
const form = ref({ osname: '', osversion: '', architecture: '', endoflife: '' })
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await operatingsystemsApi.list({ page: page.value, perpage: 20 })
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading operating systems:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p) { page.value = p; loadData() }
|
||||
|
||||
function isPastEol(date) {
|
||||
return new Date(date) < new Date()
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item ? {
|
||||
osname: item.osname || '',
|
||||
osversion: item.osversion || '',
|
||||
architecture: item.architecture || '',
|
||||
endoflife: item.endoflife || ''
|
||||
} : { osname: '', osversion: '', architecture: '', endoflife: '' }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const data = { ...form.value }
|
||||
if (!data.endoflife) data.endoflife = null
|
||||
if (editing.value) {
|
||||
await operatingsystemsApi.update(editing.value.osid, data)
|
||||
} else {
|
||||
await operatingsystemsApi.create(data)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || 'Failed to save'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
|
||||
|
||||
async function deleteItem() {
|
||||
try {
|
||||
await operatingsystemsApi.delete(toDelete.value.osid)
|
||||
showDeleteModal.value = false
|
||||
toDelete.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
alert('Failed to delete')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.text-danger {
|
||||
color: var(--danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
166
frontend/src/views/settings/PCTypesList.vue
Normal file
166
frontend/src/views/settings/PCTypesList.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>PC Types</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PC Type</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pt in pcTypes" :key="pt.pctypeid">
|
||||
<td>{{ pt.pctype }}</td>
|
||||
<td>{{ pt.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="confirmDelete(pt)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="pcTypes.length === 0">
|
||||
<td colspan="3" style="text-align: center; color: var(--text-light);">
|
||||
No PC types found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button v-for="p in totalPages" :key="p" :class="{ active: p === page }" @click="goToPage(p)">
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit PC Type' : 'Add PC Type' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="pctype">PC Type *</label>
|
||||
<input id="pctype" v-model="form.pctype" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Delete PC Type</h3></div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ toDelete?.pctype }}</strong>?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteItem">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { pctypesApi } from '../../api'
|
||||
|
||||
const pcTypes = ref([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const toDelete = ref(null)
|
||||
|
||||
const form = ref({ pctype: '', description: '' })
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await pctypesApi.list({ page: page.value, perpage: 20 })
|
||||
pcTypes.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading PC types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p) { page.value = p; loadData() }
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item ? { pctype: item.pctype || '', description: item.description || '' } : { pctype: '', description: '' }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await pctypesApi.update(editing.value.pctypeid, form.value)
|
||||
} else {
|
||||
await pctypesApi.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || 'Failed to save'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
|
||||
|
||||
async function deleteItem() {
|
||||
try {
|
||||
await pctypesApi.delete(toDelete.value.pctypeid)
|
||||
showDeleteModal.value = false
|
||||
toDelete.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
alert('Failed to delete')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
102
frontend/src/views/settings/SettingsIndex.vue
Normal file
102
frontend/src/views/settings/SettingsIndex.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<h1>Settings</h1>
|
||||
|
||||
<div class="settings-grid">
|
||||
<router-link to="/settings/vendors" class="settings-card">
|
||||
<div class="card-icon">🏭</div>
|
||||
<h3>Vendors</h3>
|
||||
<p>Manage equipment vendors and manufacturers</p>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/settings/locations" class="settings-card">
|
||||
<div class="card-icon">📍</div>
|
||||
<h3>Locations</h3>
|
||||
<p>Manage physical locations and sites</p>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/settings/statuses" class="settings-card">
|
||||
<div class="card-icon">🏷️</div>
|
||||
<h3>Statuses</h3>
|
||||
<p>Manage equipment status types</p>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/settings/models" class="settings-card">
|
||||
<div class="card-icon">📦</div>
|
||||
<h3>Models</h3>
|
||||
<p>Manage equipment models by vendor</p>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/settings/machinetypes" class="settings-card">
|
||||
<div class="card-icon">🖥️</div>
|
||||
<h3>Machine Types</h3>
|
||||
<p>Manage machine type categories</p>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/settings/pctypes" class="settings-card">
|
||||
<div class="card-icon">💻</div>
|
||||
<h3>PC Types</h3>
|
||||
<p>Manage PC form factors</p>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/settings/operatingsystems" class="settings-card">
|
||||
<div class="card-icon">⚙️</div>
|
||||
<h3>Operating Systems</h3>
|
||||
<p>Manage OS versions and EOL dates</p>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/settings/businessunits" class="settings-card">
|
||||
<div class="card-icon">🏢</div>
|
||||
<h3>Business Units</h3>
|
||||
<p>Manage organizational units</p>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page h1 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.settings-card:hover {
|
||||
border-color: #1976d2;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-card h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.settings-card p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
322
frontend/src/views/settings/StatusesList.vue
Normal file
322
frontend/src/views/settings/StatusesList.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Machine Statuses</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Status</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Color</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in statuses" :key="s.statusid">
|
||||
<td>
|
||||
<span class="status-badge" :style="getStatusStyle(s.color)">
|
||||
{{ s.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="color-preview" :style="{ backgroundColor: s.color || '#6c757d' }"></span>
|
||||
{{ s.color || 'default' }}
|
||||
</td>
|
||||
<td>{{ s.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="openModal(s)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger btn-sm"
|
||||
@click="confirmDelete(s)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="statuses.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No statuses found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button
|
||||
v-for="p in totalPages"
|
||||
:key="p"
|
||||
:class="{ active: p === page }"
|
||||
@click="goToPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingStatus ? 'Edit Status' : 'Add Status' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveStatus">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="status">Status Name *</label>
|
||||
<input
|
||||
id="status"
|
||||
v-model="form.status"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="color">Color</label>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
id="color"
|
||||
v-model="form.color"
|
||||
type="color"
|
||||
class="color-picker"
|
||||
/>
|
||||
<input
|
||||
v-model="form.color"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</div>
|
||||
<small class="form-hint">Used in UI to visually distinguish statuses</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Delete Status</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ statusToDelete?.status }}</strong>?</p>
|
||||
<p style="color: var(--text-light); font-size: 0.875rem;">
|
||||
Cannot delete if machines are using this status.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteStatus">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { statusesApi } from '../../api'
|
||||
|
||||
const statuses = ref([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingStatus = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const statusToDelete = ref(null)
|
||||
|
||||
const form = ref({
|
||||
status: '',
|
||||
color: '#6c757d',
|
||||
description: ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadStatuses()
|
||||
})
|
||||
|
||||
async function loadStatuses() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: 20
|
||||
}
|
||||
|
||||
const response = await statusesApi.list(params)
|
||||
statuses.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading statuses:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadStatuses()
|
||||
}
|
||||
|
||||
function openModal(s = null) {
|
||||
editingStatus.value = s
|
||||
if (s) {
|
||||
form.value = {
|
||||
status: s.status || '',
|
||||
color: s.color || '#6c757d',
|
||||
description: s.description || ''
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
status: '',
|
||||
color: '#6c757d',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingStatus.value = null
|
||||
}
|
||||
|
||||
async function saveStatus() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
if (editingStatus.value) {
|
||||
await statusesApi.update(editingStatus.value.statusid, form.value)
|
||||
} else {
|
||||
await statusesApi.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadStatuses()
|
||||
} catch (err) {
|
||||
console.error('Error saving status:', err)
|
||||
error.value = err.response?.data?.message || 'Failed to save status'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(s) {
|
||||
statusToDelete.value = s
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteStatus() {
|
||||
try {
|
||||
await statusesApi.delete(statusToDelete.value.statusid)
|
||||
showDeleteModal.value = false
|
||||
statusToDelete.value = null
|
||||
loadStatuses()
|
||||
} catch (err) {
|
||||
console.error('Error deleting status:', err)
|
||||
error.value = err.response?.data?.message || 'Failed to delete status'
|
||||
alert(error.value)
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusStyle(color) {
|
||||
const bgColor = color || '#6c757d'
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
color: isLightColor(bgColor) ? '#000' : '#fff'
|
||||
}
|
||||
}
|
||||
|
||||
function isLightColor(color) {
|
||||
if (!color) return false
|
||||
const hex = color.replace('#', '')
|
||||
const r = parseInt(hex.substr(0, 2), 16)
|
||||
const g = parseInt(hex.substr(2, 2), 16)
|
||||
const b = parseInt(hex.substr(4, 2), 16)
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000
|
||||
return brightness > 128
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.color-input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
width: 50px;
|
||||
height: 38px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
322
frontend/src/views/vendors/VendorsList.vue
vendored
Normal file
322
frontend/src/views/vendors/VendorsList.vue
vendored
Normal file
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Vendors</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Vendor</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search vendors..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Vendor Name</th>
|
||||
<th>Contact</th>
|
||||
<th>Phone</th>
|
||||
<th>Email</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="vendor in vendors" :key="vendor.vendorid">
|
||||
<td>{{ vendor.vendor }}</td>
|
||||
<td>{{ vendor.contact || '-' }}</td>
|
||||
<td>{{ vendor.phone || '-' }}</td>
|
||||
<td>{{ vendor.email || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="openModal(vendor)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger btn-sm"
|
||||
@click="confirmDelete(vendor)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="vendors.length === 0">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-light);">
|
||||
No vendors found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button
|
||||
v-for="p in totalPages"
|
||||
:key="p"
|
||||
:class="{ active: p === page }"
|
||||
@click="goToPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingVendor ? 'Edit Vendor' : 'Add Vendor' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveVendor">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="vendor">Vendor Name *</label>
|
||||
<input
|
||||
id="vendor"
|
||||
v-model="form.vendor"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="contact">Contact Person</label>
|
||||
<input
|
||||
id="contact"
|
||||
v-model="form.contact"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="phone">Phone</label>
|
||||
<input
|
||||
id="phone"
|
||||
v-model="form.phone"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="address">Address</label>
|
||||
<textarea
|
||||
id="address"
|
||||
v-model="form.address"
|
||||
class="form-control"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="website">Website</label>
|
||||
<input
|
||||
id="website"
|
||||
v-model="form.website"
|
||||
type="url"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
v-model="form.notes"
|
||||
class="form-control"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Delete Vendor</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ vendorToDelete?.vendor }}</strong>?</p>
|
||||
<p style="color: var(--text-light); font-size: 0.875rem;">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteVendor">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { vendorsApi } from '../../api'
|
||||
|
||||
const vendors = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingVendor = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const vendorToDelete = ref(null)
|
||||
|
||||
const form = ref({
|
||||
vendor: '',
|
||||
contact: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
website: '',
|
||||
notes: ''
|
||||
})
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadVendors()
|
||||
})
|
||||
|
||||
async function loadVendors() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: 20
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
|
||||
const response = await vendorsApi.list(params)
|
||||
vendors.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading vendors:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadVendors()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadVendors()
|
||||
}
|
||||
|
||||
function openModal(vendor = null) {
|
||||
editingVendor.value = vendor
|
||||
if (vendor) {
|
||||
form.value = {
|
||||
vendor: vendor.vendor || '',
|
||||
contact: vendor.contact || '',
|
||||
phone: vendor.phone || '',
|
||||
email: vendor.email || '',
|
||||
address: vendor.address || '',
|
||||
website: vendor.website || '',
|
||||
notes: vendor.notes || ''
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
vendor: '',
|
||||
contact: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
website: '',
|
||||
notes: ''
|
||||
}
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingVendor.value = null
|
||||
}
|
||||
|
||||
async function saveVendor() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
if (editingVendor.value) {
|
||||
await vendorsApi.update(editingVendor.value.vendorid, form.value)
|
||||
} else {
|
||||
await vendorsApi.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadVendors()
|
||||
} catch (err) {
|
||||
console.error('Error saving vendor:', err)
|
||||
error.value = err.response?.data?.message || 'Failed to save vendor'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(vendor) {
|
||||
vendorToDelete.value = vendor
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteVendor() {
|
||||
try {
|
||||
await vendorsApi.delete(vendorToDelete.value.vendorid)
|
||||
showDeleteModal.value = false
|
||||
vendorToDelete.value = null
|
||||
loadVendors()
|
||||
} catch (err) {
|
||||
console.error('Error deleting vendor:', err)
|
||||
alert('Failed to delete vendor')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user