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:
cproudlock
2026-01-13 16:07:34 -05:00
commit 1196de6e88
188 changed files with 19921 additions and 0 deletions

104
frontend/CLAUDE.md Normal file
View File

@@ -0,0 +1,104 @@
# Frontend Development Standards
## CSS Styling Standards
### Use CSS Variables for ALL Colors
**NEVER hardcode colors in component styles.** Always use CSS variables defined in `src/assets/style.css`.
Available variables:
```css
--primary /* Primary brand color */
--primary-dark /* Darker primary for hover states */
--secondary /* Secondary/muted color */
--success /* Success states (green) */
--warning /* Warning states (orange) */
--danger /* Error/danger states (red) */
--bg /* Page background */
--bg-card /* Card/panel background */
--text /* Primary text color */
--text-light /* Secondary/muted text */
--border /* Border color */
--link /* Link color (bright blue in dark mode) */
```
**Bad:**
```css
.my-card {
background: white;
color: #1a1a1a;
}
```
**Good:**
```css
.my-card {
background: var(--bg-card);
color: var(--text);
}
```
### Detail Pages - Use Global Styles
All detail pages (MachineDetail, PCDetail, PrinterDetail, ApplicationDetail) should use the **unified global styles** from `style.css`:
- `.detail-page` - Container wrapper
- `.hero-card` - Main hero section with image and info
- `.hero-image`, `.hero-content`, `.hero-title`, `.hero-meta`, `.hero-details`
- `.section-card` - Info sections
- `.section-title` - Section headers
- `.info-list`, `.info-row`, `.info-label`, `.info-value`
- `.content-grid`, `.content-column` - Two-column layout
- `.audit-footer` - Created/modified timestamps
**Only add scoped styles for page-specific elements** (e.g., supplies grid for printers, version list for applications).
### PrinterDetail.vue is the Master Template for Detail Pages
Use `PrinterDetail.vue` as the reference for new detail pages. Follow its structure and styling patterns.
### List Pages - Use Global Styles
All list pages should use the **unified global styles** from `style.css`:
- `.page-header` - Header with title and action button
- `.filters` - Search and filter controls
- `.card` - Main content container
- `.table-container` - Scrollable table wrapper
- `table`, `th`, `td` - Table styling
- `.pagination` - Page navigation
- `.badge`, `.badge-success`, etc. - Status badges
- `.actions` - Action button column
**PrintersList.vue is the Master Template for List Pages**
Use `PrintersList.vue` as the reference for new list pages. It has NO scoped styles - everything uses global CSS.
**Only add scoped styles for page-specific elements** (e.g., icon cells for applications, stats badge for knowledge base).
### Dark Mode Support
Dark mode is automatic via `@media (prefers-color-scheme: dark)`. Using CSS variables ensures colors adapt automatically - no extra work needed per page.
## Component Organization
- **Global styles**: `src/assets/style.css`
- **Page-specific styles**: Scoped `<style scoped>` block, only for unique elements
- **Font sizes**: Use `rem` units, base is 18px for readability on 1080p
## File Structure
```
src/
assets/
style.css # Global styles, CSS variables, detail page styles
views/
machines/
MachineDetail.vue # Uses global styles only
pcs/
PCDetail.vue # Global + PC-specific (app-list, etc.)
printers/
PrinterDetail.vue # Global + printer-specific (supplies-grid)
applications/
ApplicationDetail.vue # Global + app-specific (version-list, pc-list)
```

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ShopDB</title>
<link rel="stylesheet" href="/src/assets/style.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1565
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "shopdb-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.6.0",
"leaflet": "^1.9.4",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

6
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup>
</script>

392
frontend/src/api/index.js Normal file
View 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')
}
}

File diff suppressed because it is too large Load Diff

View 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>

View 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">&times;</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>

View 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
View 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')

View 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

View 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()
}
}
}
})

View 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>

View 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>

View 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>

View 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>

View 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>

View 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">&#x1F4E6;</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">&#x1F517;</span> Launch Application
</a>
<a v-if="app.installpath" :href="app.installpath" target="_blank" class="hero-link">
<span class="link-icon">&#x2B07;</span> Download Files
</a>
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" class="hero-link">
<span class="link-icon">&#x1F4C4;</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>

View 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>

View 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">
&#x2B07;
</a>
<a v-else-if="app.applicationlink" :href="app.applicationlink" target="_blank" title="Application Link" class="icon-link">
&#x1F517;
</a>
</td>
<td class="icon-cell">
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" title="View Documentation" class="icon-docs">
&#x1F4C4;
</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>

View 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">&#x2197;</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">&#x270E;</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>

View 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>

View 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' ? '&#9650;' : '&#9660;' }}</span>
</th>
<th class="sortable" @click="toggleSort('description')">
Description
<span v-if="sort === 'description'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span>
</th>
<th class="sortable" style="width: 100px;" @click="toggleSort('clicks')">
Clicks
<span v-if="sort === 'clicks'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</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>

Some files were not shown because too many files have changed in this diff Show More