Add print badges, pagination, route splitting, JWT auth fixes, and list page alignment

- Fix equipment badge barcode not rendering (loading race condition)
- Fix printer QR code not rendering on initial load (same race condition)
- Add model image to equipment badge via imageurl from Model table
- Fix white-on-white machine number text on badge, tighten barcode spacing
- Add PaginationBar component used across all list pages
- Split monolithic router into per-plugin route modules
- Fix 25 GET API endpoints returning 401 (jwt_required -> optional=True)
- Align list page columns across Equipment, PCs, and Network pages
- Add print views: EquipmentBadge, PrinterQRSingle, PrinterQRBatch, USBLabelBatch
- Add PC Relationships report, migration docs, and CLAUDE.md project guide
- Various plugin model, API, and frontend refinements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-02-04 07:32:44 -05:00
parent c4bfdc2db2
commit 9efdb5f52d
89 changed files with 3951 additions and 1138 deletions

108
CLAUDE.md Normal file
View File

@@ -0,0 +1,108 @@
# ShopDB Flask Project
## Database Configuration
- **Development database:** `shopdb_flask` (new schema with asset abstraction)
- **Legacy database:** `shopdb` (Classic ASP schema - used for data migration reference only)
- **Connection:** Configured in `.env` file
## Current Tasks
### Data Migration from Legacy Database
Migrate data from `shopdb` to `shopdb_flask` using the new asset-based schema.
**Status:** Pending
**Migration guide:** `migrations/DATA_MIGRATION_GUIDE.md`
**Tables to migrate:**
- [ ] Reference data (vendors, models, locations, business units, subnets)
- [ ] Equipment (machines with category='Equipment')
- [ ] Computers/PCs (machines with pctypeid IS NOT NULL)
- [ ] Network devices (machines with category='Network')
- [ ] Printers (separate printers table)
- [ ] Communications/IP addresses
- [ ] Notifications
- [ ] Machine relationships
### Plugin Architecture Verification
Verify all plugins follow plug-and-play architecture - can be added/removed without impacting core site or other plugins.
**Plugins to verify:**
- [ ] Equipment (`plugins/equipment/`) - frontend: `views/machines/`
- [ ] Computers (`plugins/computers/`) - frontend: `views/pcs/`
- [ ] Printers (`plugins/printers/`) - frontend: `views/printers/`
- [ ] Network (`plugins/network/`) - frontend: `views/network/`
- [ ] USB (`plugins/usb/`) - frontend: `views/usb/`
- [ ] Notifications (`plugins/notifications/`) - frontend: `views/notifications/`
**Architecture checks:**
- [ ] Core API uses try/except ImportError for plugin imports (see `shopdb/core/api/assets.py` for pattern)
- [ ] Frontend router can dynamically add/remove routes per plugin
- [ ] Navigation menu reads from plugin `get_navigation_items()`
- [ ] Document plugin enable/disable mechanism in app factory
**Each plugin must have:**
- `models/__init__.py` - exports all models
- `api/routes.py` - Flask Blueprint with endpoints
- `plugin.py` - implements BasePlugin class
- `manifest.json` - plugin metadata (name, version, dependencies)
- No direct imports from core code (use optional imports)
- Modular frontend components
## Plugin Structure
```
plugins/
{plugin_name}/
__init__.py
plugin.py # BasePlugin implementation
manifest.json # Plugin metadata
models/
__init__.py # Export all models
{model}.py # SQLAlchemy models
api/
__init__.py
routes.py # Flask Blueprint
```
## Key Files
- `shopdb/plugins/base.py` - BasePlugin class and PluginMeta
- `shopdb/core/api/assets.py` - Example of optional plugin imports with try/except
- `frontend/src/router/index.js` - Frontend routing
- `frontend/src/components/AppSidebar.vue` - Navigation menu
## Migration Notes
See `migrations/` folder for:
- `DATA_MIGRATION_GUIDE.md` - Complete guide for migrating from legacy shopdb to new schema
- `MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md` - USB device migration from equipment table
- `FIX_LOCATIONONLY_EQUIPMENT_TYPES.md` - Fix for LocationOnly equipment types
- `PRODUCTION_MIGRATION_GUIDE.md` - Production data import methods
## Quick Start
```bash
# Start dev environment
~/start-dev-env.sh
# Create/update database tables
cd /home/camp/projects/shopdb-flask
source venv/bin/activate
flask db-utils create-all
# Seed reference data
flask seed reference-data
# Restart services after changes
pm2 restart shopdb-flask-api shopdb-flask-ui
```
## Service URLs
- **Flask API:** http://localhost:5001
- **Flask UI:** http://localhost:5173
- **Legacy ASP:** http://192.168.122.151:8080

File diff suppressed because it is too large Load Diff

View File

@@ -13,13 +13,16 @@
"@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/vue3": "^6.1.20", "@fullcalendar/vue3": "^6.1.20",
"axios": "^1.6.0", "axios": "^1.6.0",
"jsbarcode": "^3.12.3",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-vue-next": "^0.563.0",
"pinia": "^2.1.0", "pinia": "^2.1.0",
"qrcode": "^1.5.4",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^4.2.0" "vue-router": "^4.2.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^5.2.4",
"vite": "^5.0.0" "vite": "^6.4.1"
} }
} }

View File

@@ -33,6 +33,17 @@
--sidebar-bg: #00003d; --sidebar-bg: #00003d;
--sidebar-text: #ffffff; --sidebar-text: #ffffff;
--sidebar-width: 250px; --sidebar-width: 250px;
/* Hover variants */
--secondary-dark: #82503f;
--success-dark: #019e4c;
--info-dark: #039ce0;
--warning-dark: #e67c02;
--danger-dark: #e62c51;
/* Additional semantic colors */
--purple: #9c27b0;
--bg-popover: #111111;
} }
/* Dark Mode - via data-theme attribute or system preference fallback */ /* Dark Mode - via data-theme attribute or system preference fallback */
@@ -146,7 +157,7 @@ h1, h2, h3, h4, h5, h6 {
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
text-transform: uppercase; text-transform: uppercase;
color: #ffffff; color: var(--sidebar-text);
margin: 0.5rem 0 0 0; margin: 0.5rem 0 0 0;
letter-spacing: 1px; letter-spacing: 1px;
} }
@@ -189,6 +200,7 @@ h1, h2, h3, h4, h5, h6 {
.sidebar-nav a { .sidebar-nav a {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem;
padding: 0.7rem 1.25rem; padding: 0.7rem 1.25rem;
color: rgba(255,255,255,0.85); color: rgba(255,255,255,0.85);
text-decoration: none; text-decoration: none;
@@ -196,6 +208,11 @@ h1, h2, h3, h4, h5, h6 {
font-size: 14px; font-size: 14px;
} }
.sidebar-nav a svg {
margin-right: 0.5rem;
flex-shrink: 0;
}
.sidebar-nav a:hover, .sidebar-nav a:hover,
.sidebar-nav a.active { .sidebar-nav a.active {
background: rgba(255,255,255,0.1); background: rgba(255,255,255,0.1);
@@ -431,8 +448,8 @@ tr:hover {
} }
.btn-secondary:hover { .btn-secondary:hover {
background: #82503f; background: var(--secondary-dark);
border-color: #82503f; border-color: var(--secondary-dark);
color: white; color: white;
} }
@@ -443,8 +460,8 @@ tr:hover {
} }
.btn-success:hover { .btn-success:hover {
background: #019e4c; background: var(--success-dark);
border-color: #019e4c; border-color: var(--success-dark);
} }
.btn-info { .btn-info {
@@ -454,8 +471,8 @@ tr:hover {
} }
.btn-info:hover { .btn-info:hover {
background: #039ce0; background: var(--info-dark);
border-color: #039ce0; border-color: var(--info-dark);
} }
.btn-warning { .btn-warning {
@@ -465,8 +482,8 @@ tr:hover {
} }
.btn-warning:hover { .btn-warning:hover {
background: #e67c02; background: var(--warning-dark);
border-color: #e67c02; border-color: var(--warning-dark);
} }
.btn-danger { .btn-danger {
@@ -476,8 +493,8 @@ tr:hover {
} }
.btn-danger:hover { .btn-danger:hover {
background: #e62c51; background: var(--danger-dark);
border-color: #e62c51; border-color: var(--danger-dark);
} }
.btn-link { .btn-link {
@@ -614,7 +631,7 @@ input[type="radio"] {
} }
select.form-control option { select.form-control option {
background: #1a1a2e; background: var(--text);
} }
} }
@@ -690,7 +707,7 @@ input[type="radio"] {
.error-message { .error-message {
background: rgba(245, 54, 92, 0.2); background: rgba(245, 54, 92, 0.2);
color: #f5365c; color: var(--danger);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-radius: 0.25rem; border-radius: 0.25rem;
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -789,11 +806,35 @@ input[type="radio"] {
} }
/* Pagination */ /* Pagination */
.pagination-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.pagination-info {
display: flex;
align-items: center;
}
.perpage-select {
padding: 0.375rem 0.5rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
background: var(--bg-card);
color: var(--text);
font-size: 13px;
cursor: pointer;
}
.pagination { .pagination {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 0.25rem; gap: 0.25rem;
margin-top: 1rem; margin-top: 0;
} }
.pagination button { .pagination button {
@@ -806,8 +847,14 @@ input[type="radio"] {
color: var(--text); color: var(--text);
} }
.pagination button:hover { .pagination button:hover:not(:disabled):not(.ellipsis) {
background: rgba(255, 255, 255, 0.1); background: var(--bg);
border-color: var(--primary);
}
.pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
} }
.pagination button.active { .pagination button.active {
@@ -816,6 +863,12 @@ input[type="radio"] {
border-color: var(--primary); border-color: var(--primary);
} }
.pagination button.ellipsis {
border: none;
cursor: default;
padding: 0.5rem 0.25rem;
}
/* Loading */ /* Loading */
.loading { .loading {
text-align: center; text-align: center;
@@ -1260,7 +1313,7 @@ td.actions {
} }
.badge-printer { .badge-printer {
background: #9c27b0; background: var(--purple);
color: white; color: white;
} }
@@ -1354,7 +1407,7 @@ td.actions {
} }
.fc .fc-popover { .fc .fc-popover {
background: #111111; background: var(--bg-popover);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 0.25rem; border-radius: 0.25rem;
box-shadow: 0 16px 38px -12px rgba(0,0,0,.56), 0 4px 25px 0 rgba(0,0,0,.12), 0 8px 10px -5px rgba(0,0,0,.2); box-shadow: 0 16px 38px -12px rgba(0,0,0,.56), 0 4px 25px 0 rgba(0,0,0,.12), 0 8px 10px -5px rgba(0,0,0,.2);
@@ -1413,18 +1466,18 @@ td.actions {
LEAFLET DARK THEME OVERRIDES LEAFLET DARK THEME OVERRIDES
============================================ */ ============================================ */
.leaflet-container { .leaflet-container {
background: #111111; background: var(--bg-popover);
} }
.leaflet-popup-content-wrapper { .leaflet-popup-content-wrapper {
background: #111111; background: var(--bg-popover);
color: var(--text); color: var(--text);
border-radius: 0.25rem; border-radius: 0.25rem;
box-shadow: 0 16px 38px -12px rgba(0,0,0,.56), 0 4px 25px 0 rgba(0,0,0,.12), 0 8px 10px -5px rgba(0,0,0,.2); box-shadow: 0 16px 38px -12px rgba(0,0,0,.56), 0 4px 25px 0 rgba(0,0,0,.12), 0 8px 10px -5px rgba(0,0,0,.2);
} }
.leaflet-popup-tip { .leaflet-popup-tip {
background: #111111; background: var(--bg-popover);
} }
.leaflet-popup-content { .leaflet-popup-content {

View File

@@ -27,14 +27,14 @@
:key="rel.relationshipid" :key="rel.relationshipid"
class="relationship-item" class="relationship-item"
> >
<div class="rel-icon">{{ getAssetIcon(rel.target_asset?.assettype) }}</div> <div class="rel-icon"><component :is="getAssetIcon(rel.targetasset?.assettype)" :size="16" /></div>
<div class="rel-content"> <div class="rel-content">
<router-link :to="getAssetRoute(rel.target_asset)" class="rel-name"> <router-link :to="getAssetRoute(rel.targetasset)" class="rel-name">
{{ rel.target_asset?.name || rel.target_asset?.assetnumber || 'Unknown' }} {{ rel.targetasset?.name || rel.targetasset?.assetnumber || 'Unknown' }}
</router-link> </router-link>
<div class="rel-meta"> <div class="rel-meta">
<span class="badge badge-outline">{{ rel.relationship_type_name }}</span> <span class="badge badge-outline">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.target_asset?.assettype }}</span> <span class="rel-type-badge">{{ rel.targetasset?.assettype }}</span>
</div> </div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div> <div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
</div> </div>
@@ -59,14 +59,14 @@
:key="rel.relationshipid" :key="rel.relationshipid"
class="relationship-item" class="relationship-item"
> >
<div class="rel-icon">{{ getAssetIcon(rel.source_asset?.assettype) }}</div> <div class="rel-icon"><component :is="getAssetIcon(rel.sourceasset?.assettype)" :size="16" /></div>
<div class="rel-content"> <div class="rel-content">
<router-link :to="getAssetRoute(rel.source_asset)" class="rel-name"> <router-link :to="getAssetRoute(rel.sourceasset)" class="rel-name">
{{ rel.source_asset?.name || rel.source_asset?.assetnumber || 'Unknown' }} {{ rel.sourceasset?.name || rel.sourceasset?.assetnumber || 'Unknown' }}
</router-link> </router-link>
<div class="rel-meta"> <div class="rel-meta">
<span class="badge badge-outline">{{ rel.relationship_type_name }}</span> <span class="badge badge-outline">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.source_asset?.assettype }}</span> <span class="rel-type-badge">{{ rel.sourceasset?.assettype }}</span>
</div> </div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div> <div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
</div> </div>
@@ -173,6 +173,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Package } from 'lucide-vue-next'
import { assetsApi, relationshipTypesApi } from '../api' import { assetsApi, relationshipTypesApi } from '../api'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -296,7 +297,7 @@ function searchAssets() {
searchTimeout = setTimeout(async () => { searchTimeout = setTimeout(async () => {
try { try {
const response = await assetsApi.search(assetSearchQuery.value, { per_page: 10 }) const response = await assetsApi.search(assetSearchQuery.value, { perpage: 10 })
// Filter out the current asset // Filter out the current asset
assetSearchResults.value = (response.data.data || []).filter( assetSearchResults.value = (response.data.data || []).filter(
a => a.assetid !== resolvedAssetId.value a => a.assetid !== resolvedAssetId.value
@@ -329,11 +330,11 @@ async function saveRelationship() {
} }
if (newRel.value.direction === 'outgoing') { if (newRel.value.direction === 'outgoing') {
data.source_assetid = resolvedAssetId.value data.sourceassetid = resolvedAssetId.value
data.target_assetid = newRel.value.targetAssetId data.targetassetid = newRel.value.targetAssetId
} else { } else {
data.source_assetid = newRel.value.targetAssetId data.sourceassetid = newRel.value.targetAssetId
data.target_assetid = resolvedAssetId.value data.targetassetid = resolvedAssetId.value
} }
await assetsApi.createRelationship(data) await assetsApi.createRelationship(data)
@@ -374,12 +375,12 @@ function closeModal() {
function getAssetIcon(assettype) { function getAssetIcon(assettype) {
const icons = { const icons = {
'equipment': '⚙', 'equipment': Cog,
'computer': '💻', 'computer': Monitor,
'printer': '🖨', 'printer': Printer,
'network_device': '🌐' 'network_device': Globe
} }
return icons[assettype] || '📦' return icons[assettype] || Package
} }
function getAssetRoute(asset) { function getAssetRoute(asset) {

View File

@@ -0,0 +1,83 @@
<template>
<div class="pagination-bar" v-if="totalPages > 1 || showPerPage">
<div class="pagination-info">
<select
v-if="showPerPage"
:value="perPage"
class="perpage-select"
@change="$emit('update:perPage', Number($event.target.value))"
>
<option v-for="opt in perPageOptions" :key="opt" :value="opt">
{{ opt }} per page
</option>
</select>
</div>
<div class="pagination" v-if="totalPages > 1">
<button :disabled="page === 1" @click="$emit('update:page', page - 1)">
Prev
</button>
<button
v-for="p in visiblePages"
:key="p"
:class="{ active: p === page, ellipsis: p === '...' }"
:disabled="p === '...'"
@click="p !== '...' && $emit('update:page', p)"
>
{{ p }}
</button>
<button :disabled="page === totalPages" @click="$emit('update:page', page + 1)">
Next
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
page: { type: Number, required: true },
totalPages: { type: Number, required: true },
perPage: { type: Number, default: 20 },
showPerPage: { type: Boolean, default: true }
})
defineEmits(['update:page', 'update:perPage'])
const perPageOptions = [10, 20, 50, 100]
const visiblePages = computed(() => {
const total = props.totalPages
const current = props.page
const pages = []
if (total <= 7) {
for (let i = 1; i <= total; i++) pages.push(i)
return pages
}
pages.push(1)
if (current > 3) {
pages.push('...')
}
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (current < total - 2) {
pages.push('...')
}
pages.push(total)
return pages
})
</script>

View File

@@ -1,354 +1,54 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
// Views
import Login from '../views/Login.vue'
import AppLayout from '../views/AppLayout.vue' import AppLayout from '../views/AppLayout.vue'
import Dashboard from '../views/Dashboard.vue'
import MachinesList from '../views/machines/MachinesList.vue' // Auto-discover all route modules from routes/ directory
import MachineDetail from '../views/machines/MachineDetail.vue' const routeModules = import.meta.glob('./routes/*.js', { eager: true })
import MachineForm from '../views/machines/MachineForm.vue' const appChildren = Object.values(routeModules).flatMap(m => m.default)
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 NotificationsList from '../views/notifications/NotificationsList.vue'
import NotificationForm from '../views/notifications/NotificationForm.vue'
import USBList from '../views/usb/USBList.vue'
import USBDetail from '../views/usb/USBDetail.vue'
import USBForm from '../views/usb/USBForm.vue'
import NetworkDevicesList from '../views/network/NetworkDevicesList.vue'
import NetworkDeviceDetail from '../views/network/NetworkDeviceDetail.vue'
import NetworkDeviceForm from '../views/network/NetworkDeviceForm.vue'
import ReportsIndex from '../views/reports/ReportsIndex.vue'
import CalendarView from '../views/CalendarView.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 VLANsList from '../views/settings/VLANsList.vue'
import SubnetsList from '../views/settings/SubnetsList.vue'
import MapView from '../views/MapView.vue'
import MapEditor from '../views/MapEditor.vue'
import ShopfloorDashboard from '../views/ShopfloorDashboard.vue'
import TVDashboard from '../views/TVDashboard.vue'
import EmployeeDetail from '../views/employees/EmployeeDetail.vue'
const routes = [ const routes = [
{ {
path: '/login', path: '/login',
name: 'login', name: 'login',
component: Login, component: () => import('../views/Login.vue'),
meta: { guest: true } meta: { guest: true }
}, },
// Standalone full-screen dashboards (no sidebar, no auth required) // Standalone full-screen dashboards (no sidebar, no auth required)
{ {
path: '/shopfloor', path: '/shopfloor',
name: 'shopfloor', name: 'shopfloor',
component: ShopfloorDashboard component: () => import('../views/ShopfloorDashboard.vue')
}, },
{ {
path: '/tv', path: '/tv',
name: 'tv', name: 'tv',
component: TVDashboard component: () => import('../views/TVDashboard.vue')
},
// Print pages (standalone, no sidebar/header)
{
path: '/print/equipment-badge/:id',
name: 'print-equipment-badge',
component: () => import('../views/print/EquipmentBadge.vue')
},
{
path: '/print/printer-qr',
name: 'print-printer-qr-batch',
component: () => import('../views/print/PrinterQRBatch.vue')
},
{
path: '/print/printer-qr/:id',
name: 'print-printer-qr-single',
component: () => import('../views/print/PrinterQRSingle.vue')
},
{
path: '/print/usb-labels',
name: 'print-usb-labels',
component: () => import('../views/print/USBLabelBatch.vue')
}, },
{ {
path: '/', path: '/',
component: AppLayout, component: AppLayout,
children: [ children: appChildren
{
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: 'map/editor',
name: 'map-editor',
component: MapEditor,
meta: { requiresAuth: true }
},
{
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: 'notifications',
name: 'notifications',
component: NotificationsList
},
{
path: 'notifications/new',
name: 'notification-new',
component: NotificationForm,
meta: { requiresAuth: true }
},
{
path: 'notifications/:id',
name: 'notification-detail',
component: NotificationForm
},
{
path: 'notifications/:id/edit',
name: 'notification-edit',
component: NotificationForm,
meta: { requiresAuth: true }
},
{
path: 'usb',
name: 'usb',
component: USBList
},
{
path: 'usb/new',
name: 'usb-new',
component: USBForm,
meta: { requiresAuth: true }
},
{
path: 'usb/:id',
name: 'usb-detail',
component: USBDetail
},
{
path: 'usb/:id/edit',
name: 'usb-edit',
component: USBForm,
meta: { requiresAuth: true }
},
{
path: 'network',
name: 'network',
component: NetworkDevicesList
},
{
path: 'network/new',
name: 'network-new',
component: NetworkDeviceForm,
meta: { requiresAuth: true }
},
{
path: 'network/:id',
name: 'network-detail',
component: NetworkDeviceDetail
},
{
path: 'network/:id/edit',
name: 'network-edit',
component: NetworkDeviceForm,
meta: { requiresAuth: true }
},
{
path: 'reports',
name: 'reports',
component: ReportsIndex
},
{
path: 'calendar',
name: 'calendar',
component: CalendarView
},
{
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 }
},
{
path: 'settings/vlans',
name: 'vlans',
component: VLANsList,
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/subnets',
name: 'subnets',
component: SubnetsList,
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'employees/:sso',
name: 'employee-detail',
component: EmployeeDetail
}
]
} }
] ]

View File

@@ -0,0 +1,27 @@
/**
* Applications routes (core feature)
*/
export default [
{
path: 'applications',
name: 'applications',
component: () => import('../../views/applications/ApplicationsList.vue')
},
{
path: 'applications/new',
name: 'application-new',
component: () => import('../../views/applications/ApplicationForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'applications/:id',
name: 'application-detail',
component: () => import('../../views/applications/ApplicationDetail.vue')
},
{
path: 'applications/:id/edit',
name: 'application-edit',
component: () => import('../../views/applications/ApplicationForm.vue'),
meta: { requiresAuth: true }
}
]

View File

@@ -0,0 +1,40 @@
/**
* Computers plugin routes
*/
export default [
{
path: 'pcs',
name: 'pcs',
component: () => import('../../views/pcs/PCsList.vue')
},
{
path: 'pcs/new',
name: 'pc-new',
component: () => import('../../views/pcs/PCForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'pcs/:id',
name: 'pc-detail',
component: () => import('../../views/pcs/PCDetail.vue')
},
{
path: 'pcs/:id/edit',
name: 'pc-edit',
component: () => import('../../views/pcs/PCForm.vue'),
meta: { requiresAuth: true }
},
// Computer-specific settings
{
path: 'settings/pctypes',
name: 'pctypes',
component: () => import('../../views/settings/PCTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/operatingsystems',
name: 'operatingsystems',
component: () => import('../../views/settings/OperatingSystemsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]

View File

@@ -0,0 +1,84 @@
/**
* Core routes - Dashboard, Search, Map, Reports, Settings, Employees
*/
export default [
{
path: '',
name: 'dashboard',
component: () => import('../../views/Dashboard.vue')
},
{
path: 'search',
name: 'search',
component: () => import('../../views/SearchResults.vue')
},
{
path: 'map',
name: 'map',
component: () => import('../../views/MapView.vue')
},
{
path: 'map/editor',
name: 'map-editor',
component: () => import('../../views/MapEditor.vue'),
meta: { requiresAuth: true }
},
{
path: 'reports',
name: 'reports',
component: () => import('../../views/reports/ReportsIndex.vue')
},
{
path: 'reports/pc-relationships',
name: 'report-pc-relationships',
component: () => import('../../views/reports/PCRelationshipsReport.vue')
},
{
path: 'employees/:sso',
name: 'employee-detail',
component: () => import('../../views/employees/EmployeeDetail.vue')
},
// Settings
{
path: 'settings',
name: 'settings',
component: () => import('../../views/settings/SettingsIndex.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/vendors',
name: 'vendors',
component: () => import('../../views/vendors/VendorsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/machinetypes',
name: 'machinetypes',
component: () => import('../../views/settings/MachineTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/locations',
name: 'locations',
component: () => import('../../views/settings/LocationsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/statuses',
name: 'statuses',
component: () => import('../../views/settings/StatusesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/models',
name: 'models',
component: () => import('../../views/settings/ModelsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/businessunits',
name: 'businessunits',
component: () => import('../../views/settings/BusinessUnitsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]

View File

@@ -0,0 +1,27 @@
/**
* Equipment plugin routes
*/
export default [
{
path: 'machines',
name: 'machines',
component: () => import('../../views/machines/MachinesList.vue')
},
{
path: 'machines/new',
name: 'machine-new',
component: () => import('../../views/machines/MachineForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'machines/:id',
name: 'machine-detail',
component: () => import('../../views/machines/MachineDetail.vue')
},
{
path: 'machines/:id/edit',
name: 'machine-edit',
component: () => import('../../views/machines/MachineForm.vue'),
meta: { requiresAuth: true }
}
]

View File

@@ -0,0 +1,27 @@
/**
* Knowledge Base routes (core feature)
*/
export default [
{
path: 'knowledgebase',
name: 'knowledgebase',
component: () => import('../../views/knowledgebase/KnowledgeBaseList.vue')
},
{
path: 'knowledgebase/new',
name: 'knowledgebase-new',
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'knowledgebase/:id',
name: 'knowledgebase-detail',
component: () => import('../../views/knowledgebase/KnowledgeBaseDetail.vue')
},
{
path: 'knowledgebase/:id/edit',
name: 'knowledgebase-edit',
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
meta: { requiresAuth: true }
}
]

View File

@@ -0,0 +1,40 @@
/**
* Network plugin routes
*/
export default [
{
path: 'network',
name: 'network',
component: () => import('../../views/network/NetworkDevicesList.vue')
},
{
path: 'network/new',
name: 'network-new',
component: () => import('../../views/network/NetworkDeviceForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'network/:id',
name: 'network-detail',
component: () => import('../../views/network/NetworkDeviceDetail.vue')
},
{
path: 'network/:id/edit',
name: 'network-edit',
component: () => import('../../views/network/NetworkDeviceForm.vue'),
meta: { requiresAuth: true }
},
// Network-specific settings
{
path: 'settings/vlans',
name: 'vlans',
component: () => import('../../views/settings/VLANsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/subnets',
name: 'subnets',
component: () => import('../../views/settings/SubnetsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]

View File

@@ -0,0 +1,32 @@
/**
* Notifications plugin routes
*/
export default [
{
path: 'notifications',
name: 'notifications',
component: () => import('../../views/notifications/NotificationsList.vue')
},
{
path: 'notifications/new',
name: 'notification-new',
component: () => import('../../views/notifications/NotificationForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'notifications/:id',
name: 'notification-detail',
component: () => import('../../views/notifications/NotificationForm.vue')
},
{
path: 'notifications/:id/edit',
name: 'notification-edit',
component: () => import('../../views/notifications/NotificationForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'calendar',
name: 'calendar',
component: () => import('../../views/CalendarView.vue')
}
]

View File

@@ -0,0 +1,27 @@
/**
* Printers plugin routes
*/
export default [
{
path: 'printers',
name: 'printers',
component: () => import('../../views/printers/PrintersList.vue')
},
{
path: 'printers/new',
name: 'printer-new',
component: () => import('../../views/printers/PrinterForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'printers/:id',
name: 'printer-detail',
component: () => import('../../views/printers/PrinterDetail.vue')
},
{
path: 'printers/:id/edit',
name: 'printer-edit',
component: () => import('../../views/printers/PrinterForm.vue'),
meta: { requiresAuth: true }
}
]

View File

@@ -0,0 +1,27 @@
/**
* USB plugin routes
*/
export default [
{
path: 'usb',
name: 'usb',
component: () => import('../../views/usb/USBList.vue')
},
{
path: 'usb/new',
name: 'usb-new',
component: () => import('../../views/usb/USBForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'usb/:id',
name: 'usb-detail',
component: () => import('../../views/usb/USBDetail.vue')
},
{
path: 'usb/:id/edit',
name: 'usb-edit',
component: () => import('../../views/usb/USBForm.vue'),
meta: { requiresAuth: true }
}
]

View File

@@ -16,22 +16,13 @@
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<router-link to="/">Dashboard</router-link> <template v-for="item in navItems" :key="item.route">
<router-link to="/calendar">Calendar</router-link> <div v-if="item.section" class="nav-section">{{ item.section }}</div>
<router-link to="/map">Map</router-link> <router-link :to="item.route">
<component v-if="item.iconComponent" :is="item.iconComponent" :size="16" />
<div class="nav-section">Assets</div> {{ item.name }}
<router-link to="/machines">Equipment</router-link> </router-link>
<router-link to="/pcs">PCs</router-link> </template>
<router-link to="/printers">Printers</router-link>
<router-link to="/network">Network Devices</router-link>
<router-link to="/usb">USB Devices</router-link>
<div class="nav-section">Information</div>
<router-link to="/applications">Applications</router-link>
<router-link to="/knowledgebase">Knowledge Base</router-link>
<router-link to="/notifications">Notifications</router-link>
<router-link to="/reports">Reports</router-link>
<div class="nav-section">Displays</div> <div class="nav-section">Displays</div>
<a href="/shopfloor" target="_blank" class="external-link">Shopfloor Dashboard</a> <a href="/shopfloor" target="_blank" class="external-link">Shopfloor Dashboard</a>
@@ -42,8 +33,8 @@
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="theme-toggle" @click="toggleTheme" :title="currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"> <button class="theme-toggle" @click="toggleTheme" :title="currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'">
<span v-if="currentTheme === 'dark'"> Light</span> <span v-if="currentTheme === 'dark'"><Sun :size="14" /> Light</span>
<span v-else>🌙 Dark</span> <span v-else><Moon :size="14" /> Dark</span>
</button> </button>
<div class="user-menu"> <div class="user-menu">
@@ -63,15 +54,92 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import {
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell
} from 'lucide-vue-next'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme' import { currentTheme, toggleTheme } from '../stores/theme'
import { dashboardApi } from '../api'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const searchQuery = ref('') const searchQuery = ref('')
const navItems = ref([])
// Map backend icon names to Lucide components
const iconMap = {
'layout-dashboard': LayoutDashboard,
'calendar': Calendar,
'map': Map,
'cog': Cog,
'desktop': Monitor,
'printer': Printer,
'network-wired': Globe,
'usb': Usb,
'bell': Bell,
'app-window': AppWindow,
'book-open': BookOpen,
'bar-chart-3': BarChart3,
}
// Default navigation (used as fallback if API fails)
const defaultNav = [
{ name: 'Dashboard', icon: 'layout-dashboard', route: '/', position: 0 },
{ name: 'Notifications', icon: 'bell', route: '/notifications', position: 5 },
{ name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6 },
{ name: 'Map', icon: 'map', route: '/map', position: 4 },
{ name: 'Equipment', icon: 'cog', route: '/machines', position: 10 },
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 },
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18 },
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20 },
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 45 },
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information' },
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35 },
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40 },
]
function buildNavItems(items) {
// Sort by position
const sorted = [...items].sort((a, b) => (a.position || 99) - (b.position || 99))
// Assign section headers based on position ranges
const result = []
let currentSection = null
for (const item of sorted) {
let section = null
if (item.position >= 10 && item.position < 30 && currentSection !== 'Assets') {
section = 'Assets'
currentSection = 'Assets'
} else if (item.section === 'information' || (item.position >= 30 && item.position < 50 && currentSection !== 'Information')) {
if (currentSection !== 'Information') {
section = 'Information'
currentSection = 'Information'
}
}
result.push({
...item,
section,
iconComponent: iconMap[item.icon] || null
})
}
return result
}
onMounted(async () => {
try {
const response = await dashboardApi.navigation()
navItems.value = buildNavItems(response.data.data || [])
} catch (err) {
// Fall back to default navigation if API unavailable
navItems.value = buildNavItems(defaultNav)
}
})
function performSearch() { function performSearch() {
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {

View File

@@ -32,13 +32,13 @@
<!-- Recognition event with employee highlight --> <!-- Recognition event with employee highlight -->
<div v-if="selectedEvent.extendedProps?.typecolor === 'recognition'" class="recognition-header"> <div v-if="selectedEvent.extendedProps?.typecolor === 'recognition'" class="recognition-header">
<div class="recognition-badge"> <div class="recognition-badge">
<span class="recognition-icon">🏆</span> <span class="recognition-icon"><Trophy :size="24" /></span>
</div> </div>
<div class="recognition-info"> <div class="recognition-info">
<div class="recognition-label">Recognition</div> <div class="recognition-label">Recognition</div>
<h2 class="recognition-title">{{ selectedEvent.extendedProps?.message || selectedEvent.title }}</h2> <h2 class="recognition-title">{{ selectedEvent.extendedProps?.message || selectedEvent.title }}</h2>
<div v-if="selectedEvent.extendedProps?.employeename || selectedEvent.extendedProps?.employeesso" class="recognition-employee"> <div v-if="selectedEvent.extendedProps?.employeename || selectedEvent.extendedProps?.employeesso" class="recognition-employee">
<span class="employee-icon">👤</span> <span class="employee-icon"><User :size="16" /></span>
<span class="employee-name">{{ selectedEvent.extendedProps.employeename || selectedEvent.extendedProps.employeesso }}</span> <span class="employee-name">{{ selectedEvent.extendedProps.employeename || selectedEvent.extendedProps.employeesso }}</span>
</div> </div>
</div> </div>
@@ -88,6 +88,7 @@
import { ref, onMounted, onUnmounted, nextTick } from 'vue' import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import FullCalendar from '@fullcalendar/vue3' import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid' import dayGridPlugin from '@fullcalendar/daygrid'
import { Trophy, User } from 'lucide-vue-next'
import { notificationsApi } from '@/api' import { notificationsApi } from '@/api'
const events = ref([]) const events = ref([])

View File

@@ -40,12 +40,12 @@
}" }"
@click="selectAsset(asset)" @click="selectAsset(asset)"
> >
<span class="asset-icon">{{ getTypeIcon(asset.assettype) }}</span> <span class="asset-icon"><component :is="getTypeIcon(asset.assettype)" :size="16" /></span>
<div class="asset-info"> <div class="asset-info">
<div class="asset-name">{{ asset.name || asset.assetnumber }}</div> <div class="asset-name">{{ asset.name || asset.assetnumber }}</div>
<div class="asset-meta"> <div class="asset-meta">
<span class="badge badge-sm">{{ asset.assettype }}</span> <span class="badge badge-sm">{{ asset.assettype }}</span>
<span v-if="asset.mapleft && asset.maptop" class="placed-indicator" title="Placed on map">📍</span> <span v-if="asset.mapleft && asset.maptop" class="placed-indicator" title="Placed on map"><MapPin :size="12" /></span>
</div> </div>
</div> </div>
</div> </div>
@@ -105,6 +105,7 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { Cog, Monitor, Printer, Globe, Package, MapPin } from 'lucide-vue-next'
import ShopFloorMap from '../components/ShopFloorMap.vue' import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api' import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme' import { currentTheme } from '../stores/theme'
@@ -152,12 +153,12 @@ async function loadAssets() {
function getTypeIcon(assettype) { function getTypeIcon(assettype) {
const icons = { const icons = {
'equipment': '⚙', 'equipment': Cog,
'computer': '💻', 'computer': Monitor,
'printer': '🖨', 'printer': Printer,
'network_device': '🌐' 'network_device': Globe
} }
return icons[assettype] || '📦' return icons[assettype] || Package
} }
function selectAsset(asset) { function selectAsset(asset) {

View File

@@ -85,24 +85,22 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="totalPages > 1"> <PaginationBar
<button :page="page"
v-for="p in visiblePages" :totalPages="totalPages"
:key="p" :perPage="perPage"
:class="{ active: p === page }" @update:page="goToPage"
@click="goToPage(p)" @update:perPage="changePerPage"
> />
{{ p }}
</button>
</div>
</template> </template>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { applicationsApi } from '../../api' import { applicationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const applications = ref([]) const applications = ref([])
const loading = ref(true) const loading = ref(true)
@@ -110,20 +108,10 @@ const search = ref('')
const filter = ref('installable') const filter = ref('installable')
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = 20 const perPage = ref(20)
let searchTimeout = null 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(() => { onMounted(() => {
loadApplications() loadApplications()
}) })
@@ -133,7 +121,7 @@ async function loadApplications() {
try { try {
const params = { const params = {
page: page.value, page: page.value,
per_page: perPage, perpage: perPage.value,
search: search.value || undefined search: search.value || undefined
} }
@@ -146,7 +134,7 @@ async function loadApplications() {
const response = await applicationsApi.list(params) const response = await applicationsApi.list(params)
applications.value = response.data.data || [] applications.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading applications:', error) console.error('Error loading applications:', error)
} finally { } finally {
@@ -166,6 +154,12 @@ function goToPage(p) {
page.value = p page.value = p
loadApplications() loadApplications()
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadApplications()
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -105,7 +105,7 @@ const applications = ref([])
onMounted(async () => { onMounted(async () => {
try { try {
// Load applications for topic dropdown // Load applications for topic dropdown
const appsRes = await applicationsApi.list({ per_page: 1000 }) const appsRes = await applicationsApi.list({ perpage: 1000 })
applications.value = appsRes.data.data || [] applications.value = appsRes.data.data || []
// Load article if editing // Load article if editing

View File

@@ -95,31 +95,29 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="totalPages > 1"> <PaginationBar
<button :page="page"
v-for="p in visiblePages" :totalPages="totalPages"
:key="p" :perPage="perPage"
:class="{ active: p === page }" @update:page="goToPage"
@click="goToPage(p)" @update:perPage="changePerPage"
> />
{{ p }}
</button>
</div>
</template> </template>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted } from 'vue'
import { knowledgebaseApi, applicationsApi } from '../../api' import { knowledgebaseApi, applicationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const loading = ref(true) const loading = ref(true)
const articles = ref([]) const articles = ref([])
const topics = ref([]) const topics = ref([])
const stats = ref(null) const stats = ref(null)
const page = ref(1) const page = ref(1)
const perPage = 20 const perPage = ref(20)
const totalPages = ref(1) const totalPages = ref(1)
const search = ref('') const search = ref('')
const topicFilter = ref('') const topicFilter = ref('')
@@ -128,16 +126,6 @@ const order = ref('desc')
let searchTimeout = null 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 () => { onMounted(async () => {
await Promise.all([ await Promise.all([
loadArticles(), loadArticles(),
@@ -151,7 +139,7 @@ async function loadArticles() {
try { try {
const params = { const params = {
page: page.value, page: page.value,
per_page: perPage, perpage: perPage.value,
sort: sort.value, sort: sort.value,
order: order.value order: order.value
} }
@@ -160,7 +148,7 @@ async function loadArticles() {
const response = await knowledgebaseApi.list(params) const response = await knowledgebaseApi.list(params)
articles.value = response.data.data || [] articles.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading articles:', error) console.error('Error loading articles:', error)
} finally { } finally {
@@ -170,7 +158,7 @@ async function loadArticles() {
async function loadTopics() { async function loadTopics() {
try { try {
const response = await applicationsApi.list({ per_page: 1000 }) const response = await applicationsApi.list({ perpage: 1000 })
topics.value = response.data.data || [] topics.value = response.data.data || []
} catch (error) { } catch (error) {
console.error('Error loading topics:', error) console.error('Error loading topics:', error)
@@ -199,6 +187,12 @@ function goToPage(p) {
loadArticles() loadArticles()
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadArticles()
}
function toggleSort(column) { function toggleSort(column) {
if (sort.value === column) { if (sort.value === column) {
order.value = order.value === 'desc' ? 'asc' : 'desc' order.value = order.value === 'desc' ? 'asc' : 'desc'

View File

@@ -3,6 +3,9 @@
<div class="page-header"> <div class="page-header">
<h2>Equipment Details</h2> <h2>Equipment Details</h2>
<div class="header-actions"> <div class="header-actions">
<router-link :to="`/print/equipment-badge/${equipment?.equipment?.equipmentid}`" class="btn btn-secondary" v-if="equipment" target="_blank">
Print Badge
</router-link>
<router-link :to="`/machines/${equipment?.equipment?.equipmentid}/edit`" class="btn btn-primary" v-if="equipment"> <router-link :to="`/machines/${equipment?.equipment?.equipmentid}/edit`" class="btn btn-primary" v-if="equipment">
Edit Edit
</router-link> </router-link>
@@ -22,28 +25,28 @@
</div> </div>
<div class="hero-meta"> <div class="hero-meta">
<span class="badge badge-lg badge-primary"> <span class="badge badge-lg badge-primary">
{{ equipment.assettype_name || 'Equipment' }} {{ equipment.assettypename || 'Equipment' }}
</span> </span>
<span class="badge badge-lg" :class="getStatusClass(equipment.status_name)"> <span class="badge badge-lg" :class="getStatusClass(equipment.statusname)">
{{ equipment.status_name || 'Unknown' }} {{ equipment.statusname || 'Unknown' }}
</span> </span>
</div> </div>
<div class="hero-details"> <div class="hero-details">
<div class="hero-detail" v-if="equipment.equipment?.equipmenttype_name"> <div class="hero-detail" v-if="equipment.equipment?.equipmenttypename">
<span class="hero-detail-label">Type</span> <span class="hero-detail-label">Type</span>
<span class="hero-detail-value">{{ equipment.equipment.equipmenttype_name }}</span> <span class="hero-detail-value">{{ equipment.equipment.equipmenttypename }}</span>
</div> </div>
<div class="hero-detail" v-if="equipment.equipment?.vendor_name"> <div class="hero-detail" v-if="equipment.equipment?.vendorname">
<span class="hero-detail-label">Vendor</span> <span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ equipment.equipment.vendor_name }}</span> <span class="hero-detail-value">{{ equipment.equipment.vendorname }}</span>
</div> </div>
<div class="hero-detail" v-if="equipment.equipment?.model_name"> <div class="hero-detail" v-if="equipment.equipment?.modelname">
<span class="hero-detail-label">Model</span> <span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ equipment.equipment.model_name }}</span> <span class="hero-detail-value">{{ equipment.equipment.modelname }}</span>
</div> </div>
<div class="hero-detail" v-if="equipment.location_name"> <div class="hero-detail" v-if="equipment.locationname">
<span class="hero-detail-label">Location</span> <span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ equipment.location_name }}</span> <span class="hero-detail-value">{{ equipment.locationname }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -78,30 +81,30 @@
<div class="info-list"> <div class="info-list">
<div class="info-row"> <div class="info-row">
<span class="info-label">Type</span> <span class="info-label">Type</span>
<span class="info-value">{{ equipment.equipment?.equipmenttype_name || '-' }}</span> <span class="info-value">{{ equipment.equipment?.equipmenttypename || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Vendor</span> <span class="info-label">Vendor</span>
<span class="info-value">{{ equipment.equipment?.vendor_name || '-' }}</span> <span class="info-value">{{ equipment.equipment?.vendorname || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Model</span> <span class="info-label">Model</span>
<span class="info-value">{{ equipment.equipment?.model_name || '-' }}</span> <span class="info-value">{{ equipment.equipment?.modelname || '-' }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Controller Section (for CNC machines) --> <!-- Controller Section (for CNC machines) -->
<div class="section-card" v-if="equipment.equipment?.controller_vendor_name || equipment.equipment?.controller_model_name"> <div class="section-card" v-if="equipment.equipment?.controllervendorname || equipment.equipment?.controllermodelname">
<h3 class="section-title">Controller</h3> <h3 class="section-title">Controller</h3>
<div class="info-list"> <div class="info-list">
<div class="info-row"> <div class="info-row">
<span class="info-label">Vendor</span> <span class="info-label">Vendor</span>
<span class="info-value">{{ equipment.equipment?.controller_vendor_name || '-' }}</span> <span class="info-value">{{ equipment.equipment?.controllervendorname || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Model</span> <span class="info-label">Model</span>
<span class="info-value">{{ equipment.equipment?.controller_model_name || '-' }}</span> <span class="info-value">{{ equipment.equipment?.controllermodelname || '-' }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -164,14 +167,14 @@
:top="equipment.maptop" :top="equipment.maptop"
:machineName="equipment.assetnumber" :machineName="equipment.assetnumber"
> >
<span class="location-link">{{ equipment.location_name || 'On Map' }}</span> <span class="location-link">{{ equipment.locationname || 'On Map' }}</span>
</LocationMapTooltip> </LocationMapTooltip>
<span v-else>{{ equipment.location_name || '-' }}</span> <span v-else>{{ equipment.locationname || '-' }}</span>
</span> </span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Business Unit</span> <span class="info-label">Business Unit</span>
<span class="info-value">{{ equipment.businessunit_name || '-' }}</span> <span class="info-value">{{ equipment.businessunitname || '-' }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -183,7 +186,7 @@
No controlling PC assigned No controlling PC assigned
</div> </div>
<div v-else class="connected-device"> <div v-else class="connected-device">
<router-link :to="`/pcs/${controllingPc.plugin_id || controllingPc.assetid}`" class="device-link"> <router-link :to="`/pcs/${controllingPc.pluginid || controllingPc.assetid}`" class="device-link">
<div class="device-icon"> <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"> <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> <rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
@@ -239,20 +242,20 @@ const controllingPc = computed(() => {
// First check incoming - computer as source controlling this equipment // First check incoming - computer as source controlling this equipment
for (const rel of relationships.value.incoming || []) { for (const rel of relationships.value.incoming || []) {
if (rel.source_asset?.assettype_name === 'computer' && rel.relationship_type_name === 'Controls') { if (rel.sourceasset?.assettypename === 'computer' && rel.relationshiptypename === 'Controls') {
return { return {
...rel.source_asset, ...rel.sourceasset,
relationshipType: rel.relationship_type_name relationshipType: rel.relationshiptypename
} }
} }
} }
// Also check outgoing - legacy data may have equipment -> computer Controls relationships // Also check outgoing - legacy data may have equipment -> computer Controls relationships
for (const rel of relationships.value.outgoing || []) { for (const rel of relationships.value.outgoing || []) {
if (rel.target_asset?.assettype_name === 'computer' && rel.relationship_type_name === 'Controls') { if (rel.targetasset?.assettypename === 'computer' && rel.relationshiptypename === 'Controls') {
return { return {
...rel.target_asset, ...rel.targetasset,
relationshipType: rel.relationship_type_name relationshipType: rel.relationshiptypename
} }
} }
} }

View File

@@ -130,10 +130,10 @@
<h3 class="form-section-title">Controller</h3> <h3 class="form-section-title">Controller</h3>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="controller_vendorid">Controller Vendor</label> <label for="controllervendorid">Controller Vendor</label>
<select <select
id="controller_vendorid" id="controllervendorid"
v-model="form.controller_vendorid" v-model="form.controllervendorid"
class="form-control" class="form-control"
> >
<option value="">Select vendor...</option> <option value="">Select vendor...</option>
@@ -149,10 +149,10 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="controller_modelid">Controller Model</label> <label for="controllermodelid">Controller Model</label>
<select <select
id="controller_modelid" id="controllermodelid"
v-model="form.controller_modelid" v-model="form.controllermodelid"
class="form-control" class="form-control"
> >
<option value="">Select model...</option> <option value="">Select model...</option>
@@ -164,7 +164,7 @@
{{ m.modelnumber }} {{ m.modelnumber }}
</option> </option>
</select> </select>
<small class="form-help" v-if="form.controller_vendorid">Filtered by controller vendor</small> <small class="form-help" v-if="form.controllervendorid">Filtered by controller vendor</small>
</div> </div>
</div> </div>
@@ -351,8 +351,8 @@ const form = ref({
equipmenttypeid: '', equipmenttypeid: '',
vendorid: '', vendorid: '',
modelnumberid: '', modelnumberid: '',
controller_vendorid: '', controllervendorid: '',
controller_modelid: '', controllermodelid: '',
locationid: '', locationid: '',
businessunitid: '', businessunitid: '',
requiresmanualconfig: false, requiresmanualconfig: false,
@@ -383,8 +383,8 @@ const filteredModels = computed(() => {
// Filter models by selected controller vendor // Filter models by selected controller vendor
const filteredControllerModels = computed(() => { const filteredControllerModels = computed(() => {
if (!form.value.controller_vendorid) return models.value if (!form.value.controllervendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.controller_vendorid) return models.value.filter(m => m.vendorid === form.value.controllervendorid)
}) })
// Clear model selection when vendor changes // Clear model selection when vendor changes
@@ -398,11 +398,11 @@ watch(() => form.value.vendorid, (newVal, oldVal) => {
}) })
// Clear controller model when controller vendor changes // Clear controller model when controller vendor changes
watch(() => form.value.controller_vendorid, (newVal, oldVal) => { watch(() => form.value.controllervendorid, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) { if (oldVal && newVal !== oldVal) {
const currentModel = models.value.find(m => m.modelnumberid === form.value.controller_modelid) const currentModel = models.value.find(m => m.modelnumberid === form.value.controllermodelid)
if (currentModel && currentModel.vendorid !== newVal) { if (currentModel && currentModel.vendorid !== newVal) {
form.value.controller_modelid = '' form.value.controllermodelid = ''
} }
} }
}) })
@@ -413,11 +413,11 @@ onMounted(async () => {
const [typesRes, statusRes, vendorRes, locRes, modelsRes, buRes, pcsRes, relTypesRes] = await Promise.all([ const [typesRes, statusRes, vendorRes, locRes, modelsRes, buRes, pcsRes, relTypesRes] = await Promise.all([
equipmentApi.types.list(), equipmentApi.types.list(),
assetsApi.statuses.list(), assetsApi.statuses.list(),
vendorsApi.list({ per_page: 500 }), vendorsApi.list({ perpage: 500 }),
locationsApi.list({ per_page: 500 }), locationsApi.list({ perpage: 500 }),
modelsApi.list({ per_page: 1000 }), modelsApi.list({ perpage: 1000 }),
businessunitsApi.list({ per_page: 500 }), businessunitsApi.list({ perpage: 500 }),
computersApi.list({ per_page: 500 }), computersApi.list({ perpage: 500 }),
assetsApi.types.list() // Used for relationship types, will fix below assetsApi.types.list() // Used for relationship types, will fix below
]) ])
@@ -461,8 +461,8 @@ onMounted(async () => {
equipmenttypeid: data.equipment?.equipmenttypeid || '', equipmenttypeid: data.equipment?.equipmenttypeid || '',
vendorid: data.equipment?.vendorid || '', vendorid: data.equipment?.vendorid || '',
modelnumberid: data.equipment?.modelnumberid || '', modelnumberid: data.equipment?.modelnumberid || '',
controller_vendorid: data.equipment?.controller_vendorid || '', controllervendorid: data.equipment?.controllervendorid || '',
controller_modelid: data.equipment?.controller_modelid || '', controllermodelid: data.equipment?.controllermodelid || '',
locationid: data.locationid || '', locationid: data.locationid || '',
businessunitid: data.businessunitid || '', businessunitid: data.businessunitid || '',
requiresmanualconfig: data.equipment?.requiresmanualconfig || false, requiresmanualconfig: data.equipment?.requiresmanualconfig || false,
@@ -480,8 +480,8 @@ onMounted(async () => {
// Check incoming relationships for a controlling PC // Check incoming relationships for a controlling PC
for (const rel of relationships.incoming || []) { for (const rel of relationships.incoming || []) {
if (rel.source_asset?.assettype_name === 'computer' && rel.relationship_type_name === 'Controls') { if (rel.sourceasset?.assettypename === 'computer' && rel.relationshiptypename === 'Controls') {
controllingPcId.value = rel.source_asset.assetid controllingPcId.value = rel.sourceasset.assetid
relationshipTypeId.value = rel.relationshiptypeid relationshipTypeId.value = rel.relationshiptypeid
existingRelationshipId.value = rel.assetrelationshipid existingRelationshipId.value = rel.assetrelationshipid
break break
@@ -531,8 +531,8 @@ async function saveEquipment() {
equipmenttypeid: form.value.equipmenttypeid || null, equipmenttypeid: form.value.equipmenttypeid || null,
vendorid: form.value.vendorid || null, vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null, modelnumberid: form.value.modelnumberid || null,
controller_vendorid: form.value.controller_vendorid || null, controllervendorid: form.value.controllervendorid || null,
controller_modelid: form.value.controller_modelid || null, controllermodelid: form.value.controllermodelid || null,
locationid: form.value.locationid || null, locationid: form.value.locationid || null,
businessunitid: form.value.businessunitid || null, businessunitid: form.value.businessunitid || null,
requiresmanualconfig: form.value.requiresmanualconfig, requiresmanualconfig: form.value.requiresmanualconfig,
@@ -588,8 +588,8 @@ async function saveRelationship(assetId) {
// Create new relationship (PC controls Equipment, so PC is source, Equipment is target) // Create new relationship (PC controls Equipment, so PC is source, Equipment is target)
await assetsApi.createRelationship({ await assetsApi.createRelationship({
source_assetid: controllingPcId.value, sourceassetid: controllingPcId.value,
target_assetid: assetId, targetassetid: assetId,
relationshiptypeid: relationshipTypeId.value relationshiptypeid: relationshipTypeId.value
}) })
} }

View File

@@ -28,6 +28,7 @@
<th>Name</th> <th>Name</th>
<th>Serial Number</th> <th>Serial Number</th>
<th>Type</th> <th>Type</th>
<th>Vendor</th>
<th>Status</th> <th>Status</th>
<th>Location</th> <th>Location</th>
<th>Actions</th> <th>Actions</th>
@@ -38,13 +39,14 @@
<td>{{ item.assetnumber }}</td> <td>{{ item.assetnumber }}</td>
<td>{{ item.name || '-' }}</td> <td>{{ item.name || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td> <td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.equipment?.equipmenttype_name || '-' }}</td> <td>{{ item.equipment?.equipmenttypename || '-' }}</td>
<td>{{ item.equipment?.vendorname || '-' }}</td>
<td> <td>
<span class="badge" :class="getStatusClass(item.status_name)"> <span class="badge" :class="getStatusClass(item.statusname)">
{{ item.status_name || 'Unknown' }} {{ item.statusname || 'Unknown' }}
</span> </span>
</td> </td>
<td>{{ item.location_name || '-' }}</td> <td>{{ item.locationname || '-' }}</td>
<td class="actions"> <td class="actions">
<router-link <router-link
:to="`/machines/${item.equipment?.equipmentid || item.assetid}`" :to="`/machines/${item.equipment?.equipmentid || item.assetid}`"
@@ -55,7 +57,7 @@
</td> </td>
</tr> </tr>
<tr v-if="equipment.length === 0"> <tr v-if="equipment.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);"> <td colspan="8" style="text-align: center; color: var(--text-light);">
No equipment found No equipment found
</td> </td>
</tr> </tr>
@@ -64,16 +66,13 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="totalPages > 1"> <PaginationBar
<button :page="page"
v-for="p in totalPages" :totalPages="totalPages"
:key="p" :perPage="perPage"
:class="{ active: p === page }" @update:page="goToPage"
@click="goToPage(p)" @update:perPage="changePerPage"
> />
{{ p }}
</button>
</div>
</template> </template>
</div> </div>
@@ -83,12 +82,14 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { equipmentApi } from '../../api' import { equipmentApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const equipment = ref([]) const equipment = ref([])
const loading = ref(true) const loading = ref(true)
const search = ref('') const search = ref('')
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null let searchTimeout = null
@@ -101,13 +102,13 @@ async function loadEquipment() {
try { try {
const params = { const params = {
page: page.value, page: page.value,
per_page: 20 perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
const response = await equipmentApi.list(params) const response = await equipmentApi.list(params)
equipment.value = response.data.data || [] equipment.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading equipment:', error) console.error('Error loading equipment:', error)
} finally { } finally {
@@ -128,6 +129,12 @@ function goToPage(p) {
loadEquipment() loadEquipment()
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadEquipment()
}
function getStatusClass(status) { function getStatusClass(status) {
if (!status) return 'badge-info' if (!status) return 'badge-info'
const s = status.toLowerCase() const s = status.toLowerCase()

View File

@@ -3,12 +3,12 @@
<div class="hero-card"> <div class="hero-card">
<div class="hero-image"> <div class="hero-image">
<div class="device-icon"> <div class="device-icon">
<span class="icon">{{ getDeviceIcon() }}</span> <span class="icon"><component :is="getDeviceIcon()" :size="24" /></span>
</div> </div>
</div> </div>
<div class="hero-content"> <div class="hero-content">
<div class="hero-title-row"> <div class="hero-title-row">
<h1 class="hero-title">{{ device.network_device?.hostname || device.name || device.assetnumber }}</h1> <h1 class="hero-title">{{ device.networkdevice?.hostname || device.name || device.assetnumber }}</h1>
<router-link <router-link
v-if="authStore.isAuthenticated" v-if="authStore.isAuthenticated"
:to="`/network/${deviceId}/edit`" :to="`/network/${deviceId}/edit`"
@@ -18,14 +18,14 @@
</router-link> </router-link>
</div> </div>
<div class="hero-meta"> <div class="hero-meta">
<span class="badge" :class="getStatusClass(device.status_name)"> <span class="badge" :class="getStatusClass(device.statusname)">
{{ device.status_name || 'Unknown' }} {{ device.statusname || 'Unknown' }}
</span> </span>
<span v-if="device.network_device?.networkdevicetype_name" class="meta-item"> <span v-if="device.networkdevice?.networkdevicetypename" class="meta-item">
{{ device.network_device.networkdevicetype_name }} {{ device.networkdevice.networkdevicetypename }}
</span> </span>
<span v-if="device.network_device?.vendor_name" class="meta-item"> <span v-if="device.networkdevice?.vendorname" class="meta-item">
{{ device.network_device.vendor_name }} {{ device.networkdevice.vendorname }}
</span> </span>
</div> </div>
<div class="hero-details"> <div class="hero-details">
@@ -37,19 +37,19 @@
<span class="label">Serial</span> <span class="label">Serial</span>
<span class="value mono">{{ device.serialnumber }}</span> <span class="value mono">{{ device.serialnumber }}</span>
</div> </div>
<div class="detail-item" v-if="device.location_name"> <div class="detail-item" v-if="device.locationname">
<span class="label">Location</span> <span class="label">Location</span>
<span class="value">{{ device.location_name }}</span> <span class="value">{{ device.locationname }}</span>
</div> </div>
<div class="detail-item" v-if="device.businessunit_name"> <div class="detail-item" v-if="device.businessunitname">
<span class="label">Business Unit</span> <span class="label">Business Unit</span>
<span class="value">{{ device.businessunit_name }}</span> <span class="value">{{ device.businessunitname }}</span>
</div> </div>
</div> </div>
<div class="hero-features" v-if="device.network_device"> <div class="hero-features" v-if="device.networkdevice">
<span v-if="device.network_device.ispoe" class="feature-badge poe">PoE</span> <span v-if="device.networkdevice.ispoe" class="feature-badge poe">PoE</span>
<span v-if="device.network_device.ismanaged" class="feature-badge managed">Managed</span> <span v-if="device.networkdevice.ismanaged" class="feature-badge managed">Managed</span>
<span v-if="device.network_device.portcount" class="feature-badge ports">{{ device.network_device.portcount }} Ports</span> <span v-if="device.networkdevice.portcount" class="feature-badge ports">{{ device.networkdevice.portcount }} Ports</span>
</div> </div>
</div> </div>
</div> </div>
@@ -62,27 +62,27 @@
<div class="info-list"> <div class="info-list">
<div class="info-row"> <div class="info-row">
<span class="info-label">Hostname</span> <span class="info-label">Hostname</span>
<span class="info-value mono">{{ device.network_device?.hostname || '-' }}</span> <span class="info-value mono">{{ device.networkdevice?.hostname || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Firmware Version</span> <span class="info-label">Firmware Version</span>
<span class="info-value">{{ device.network_device?.firmwareversion || '-' }}</span> <span class="info-value">{{ device.networkdevice?.firmwareversion || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Port Count</span> <span class="info-label">Port Count</span>
<span class="info-value">{{ device.network_device?.portcount || '-' }}</span> <span class="info-value">{{ device.networkdevice?.portcount || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Rack Unit</span> <span class="info-label">Rack Unit</span>
<span class="info-value">{{ device.network_device?.rackunit || '-' }}</span> <span class="info-value">{{ device.networkdevice?.rackunit || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">PoE Capable</span> <span class="info-label">PoE Capable</span>
<span class="info-value">{{ device.network_device?.ispoe ? 'Yes' : 'No' }}</span> <span class="info-value">{{ device.networkdevice?.ispoe ? 'Yes' : 'No' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Managed Device</span> <span class="info-label">Managed Device</span>
<span class="info-value">{{ device.network_device?.ismanaged ? 'Yes' : 'No' }}</span> <span class="info-value">{{ device.networkdevice?.ismanaged ? 'Yes' : 'No' }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -113,17 +113,17 @@
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Vendor</span> <span class="info-label">Vendor</span>
<span class="info-value">{{ device.network_device?.vendor_name || '-' }}</span> <span class="info-value">{{ device.networkdevice?.vendorname || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Device Type</span> <span class="info-label">Device Type</span>
<span class="info-value">{{ device.network_device?.networkdevicetype_name || '-' }}</span> <span class="info-value">{{ device.networkdevice?.networkdevicetypename || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Status</span> <span class="info-label">Status</span>
<span class="info-value"> <span class="info-value">
<span class="badge" :class="getStatusClass(device.status_name)"> <span class="badge" :class="getStatusClass(device.statusname)">
{{ device.status_name || 'Unknown' }} {{ device.statusname || 'Unknown' }}
</span> </span>
</span> </span>
</div> </div>
@@ -177,6 +177,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Network, Router, Shield, Wifi, Camera, Server, Server as Rack, Globe } from 'lucide-vue-next'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { networkApi } from '../../api' import { networkApi } from '../../api'
import AssetRelationships from '../../components/AssetRelationships.vue' import AssetRelationships from '../../components/AssetRelationships.vue'
@@ -207,15 +208,15 @@ async function loadDevice() {
} }
function getDeviceIcon() { function getDeviceIcon() {
const type = device.value?.network_device?.networkdevicetype_name?.toLowerCase() || '' const type = device.value?.networkdevice?.networkdevicetypename?.toLowerCase() || ''
if (type.includes('switch')) return '⏛' if (type.includes('switch')) return Network
if (type.includes('router')) return '⇌' if (type.includes('router')) return Router
if (type.includes('firewall')) return '🛡' if (type.includes('firewall')) return Shield
if (type.includes('access point') || type.includes('ap')) return '📶' if (type.includes('access point') || type.includes('ap')) return Wifi
if (type.includes('camera')) return '📷' if (type.includes('camera')) return Camera
if (type.includes('server')) return '🖥' if (type.includes('server')) return Server
if (type.includes('idf') || type.includes('closet')) return '🗄' if (type.includes('idf') || type.includes('closet')) return Rack
return '🌐' return Globe
} }
function getStatusClass(status) { function getStatusClass(status) {
@@ -240,7 +241,7 @@ function formatDate(dateStr) {
} }
async function confirmDelete() { async function confirmDelete() {
if (confirm(`Are you sure you want to delete ${device.value.network_device?.hostname || device.value.assetnumber}?`)) { if (confirm(`Are you sure you want to delete ${device.value.networkdevice?.hostname || device.value.assetnumber}?`)) {
try { try {
await networkApi.delete(deviceId) await networkApi.delete(deviceId)
router.push('/network') router.push('/network')

View File

@@ -280,7 +280,7 @@ onMounted(async () => {
async function loadDeviceTypes() { async function loadDeviceTypes() {
try { try {
const response = await networkApi.types.list({ per_page: 100 }) const response = await networkApi.types.list({ perpage: 100 })
deviceTypes.value = response.data.data || [] deviceTypes.value = response.data.data || []
} catch (err) { } catch (err) {
console.error('Error loading device types:', err) console.error('Error loading device types:', err)
@@ -289,7 +289,7 @@ async function loadDeviceTypes() {
async function loadVendors() { async function loadVendors() {
try { try {
const response = await vendorsApi.list({ per_page: 100 }) const response = await vendorsApi.list({ perpage: 100 })
vendors.value = response.data.data || [] vendors.value = response.data.data || []
} catch (err) { } catch (err) {
console.error('Error loading vendors:', err) console.error('Error loading vendors:', err)
@@ -298,7 +298,7 @@ async function loadVendors() {
async function loadLocations() { async function loadLocations() {
try { try {
const response = await locationsApi.list({ per_page: 100 }) const response = await locationsApi.list({ perpage: 100 })
locations.value = response.data.data || [] locations.value = response.data.data || []
} catch (err) { } catch (err) {
console.error('Error loading locations:', err) console.error('Error loading locations:', err)
@@ -307,7 +307,7 @@ async function loadLocations() {
async function loadStatuses() { async function loadStatuses() {
try { try {
const response = await statusesApi.list({ per_page: 100 }) const response = await statusesApi.list({ perpage: 100 })
statuses.value = response.data.data || [] statuses.value = response.data.data || []
} catch (err) { } catch (err) {
console.error('Error loading statuses:', err) console.error('Error loading statuses:', err)
@@ -316,7 +316,7 @@ async function loadStatuses() {
async function loadBusinessUnits() { async function loadBusinessUnits() {
try { try {
const response = await businessunitsApi.list({ per_page: 100 }) const response = await businessunitsApi.list({ perpage: 100 })
businessUnits.value = response.data.data || [] businessUnits.value = response.data.data || []
} catch (err) { } catch (err) {
console.error('Error loading business units:', err) console.error('Error loading business units:', err)
@@ -340,15 +340,15 @@ async function loadDevice() {
form.value.notes = data.notes || '' form.value.notes = data.notes || ''
// Network device specific // Network device specific
if (data.network_device) { if (data.networkdevice) {
form.value.hostname = data.network_device.hostname || '' form.value.hostname = data.networkdevice.hostname || ''
form.value.networkdevicetypeid = data.network_device.networkdevicetypeid || '' form.value.networkdevicetypeid = data.networkdevice.networkdevicetypeid || ''
form.value.vendorid = data.network_device.vendorid || '' form.value.vendorid = data.networkdevice.vendorid || ''
form.value.firmwareversion = data.network_device.firmwareversion || '' form.value.firmwareversion = data.networkdevice.firmwareversion || ''
form.value.portcount = data.network_device.portcount form.value.portcount = data.networkdevice.portcount
form.value.rackunit = data.network_device.rackunit || '' form.value.rackunit = data.networkdevice.rackunit || ''
form.value.ispoe = data.network_device.ispoe || false form.value.ispoe = data.networkdevice.ispoe || false
form.value.ismanaged = data.network_device.ismanaged || false form.value.ismanaged = data.networkdevice.ismanaged || false
} }
} catch (err) { } catch (err) {
console.error('Error loading device:', err) console.error('Error loading device:', err)
@@ -386,7 +386,7 @@ async function submitForm() {
router.push(`/network/${deviceId}`) router.push(`/network/${deviceId}`)
} else { } else {
const response = await networkApi.create(payload) const response = await networkApi.create(payload)
const newId = response.data.data?.network_device?.networkdeviceid const newId = response.data.data?.networkdevice?.networkdeviceid
router.push(newId ? `/network/${newId}` : '/network') router.push(newId ? `/network/${newId}` : '/network')
} }
} catch (err) { } catch (err) {

View File

@@ -54,37 +54,39 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Hostname</th>
<th>Asset #</th> <th>Asset #</th>
<th>Hostname</th>
<th>Serial Number</th>
<th>Type</th> <th>Type</th>
<th>Vendor</th> <th>Vendor</th>
<th>Features</th> <th>Features</th>
<th>Location</th>
<th>Status</th> <th>Status</th>
<th>Location</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="device in devices" :key="device.network_device?.networkdeviceid || device.assetid"> <tr v-for="device in devices" :key="device.networkdevice?.networkdeviceid || device.assetid">
<td class="mono">{{ device.network_device?.hostname || '-' }}</td>
<td>{{ device.assetnumber }}</td> <td>{{ device.assetnumber }}</td>
<td>{{ device.network_device?.networkdevicetype_name || '-' }}</td> <td class="mono">{{ device.networkdevice?.hostname || '-' }}</td>
<td>{{ device.network_device?.vendor_name || '-' }}</td> <td class="mono">{{ device.serialnumber || '-' }}</td>
<td>{{ device.networkdevice?.networkdevicetypename || '-' }}</td>
<td>{{ device.networkdevice?.vendorname || '-' }}</td>
<td class="features"> <td class="features">
<span v-if="device.network_device?.ispoe" class="feature-tag poe">PoE</span> <span v-if="device.networkdevice?.ispoe" class="feature-tag poe">PoE</span>
<span v-if="device.network_device?.ismanaged" class="feature-tag managed">Managed</span> <span v-if="device.networkdevice?.ismanaged" class="feature-tag managed">Managed</span>
<span v-if="device.network_device?.portcount" class="feature-tag ports">{{ device.network_device.portcount }} ports</span> <span v-if="device.networkdevice?.portcount" class="feature-tag ports">{{ device.networkdevice.portcount }} ports</span>
<span v-if="!device.network_device?.ispoe && !device.network_device?.ismanaged && !device.network_device?.portcount">-</span> <span v-if="!device.networkdevice?.ispoe && !device.networkdevice?.ismanaged && !device.networkdevice?.portcount">-</span>
</td> </td>
<td>{{ device.location_name || '-' }}</td>
<td> <td>
<span class="badge" :class="getStatusClass(device.status_name)"> <span class="badge" :class="getStatusClass(device.statusname)">
{{ device.status_name || 'Unknown' }} {{ device.statusname || 'Unknown' }}
</span> </span>
</td> </td>
<td>{{ device.locationname || '-' }}</td>
<td class="actions"> <td class="actions">
<router-link <router-link
:to="`/network/${device.network_device?.networkdeviceid}`" :to="`/network/${device.networkdevice?.networkdeviceid}`"
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"
> >
View View
@@ -92,7 +94,7 @@
</td> </td>
</tr> </tr>
<tr v-if="devices.length === 0"> <tr v-if="devices.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);"> <td colspan="9" style="text-align: center; color: var(--text-light);">
No network devices found No network devices found
</td> </td>
</tr> </tr>
@@ -101,36 +103,22 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="totalPages > 1"> <PaginationBar
<button :page="page"
:disabled="page === 1" :totalPages="totalPages"
@click="goToPage(page - 1)" :perPage="perPage"
> @update:page="goToPage"
Prev @update:perPage="changePerPage"
</button> />
<button
v-for="p in visiblePages"
:key="p"
:class="{ active: p === page }"
@click="goToPage(p)"
>
{{ p }}
</button>
<button
:disabled="page === totalPages"
@click="goToPage(page + 1)"
>
Next
</button>
</div>
</template> </template>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { networkApi, vendorsApi, locationsApi } from '../../api' import { networkApi, vendorsApi, locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const devices = ref([]) const devices = ref([])
const deviceTypes = ref([]) const deviceTypes = ref([])
@@ -143,20 +131,11 @@ const vendorFilter = ref('')
const locationFilter = ref('') const locationFilter = ref('')
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(25)
const totalCount = ref(0) const totalCount = ref(0)
let searchTimeout = null 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 () => { onMounted(async () => {
await Promise.all([ await Promise.all([
loadDeviceTypes(), loadDeviceTypes(),
@@ -168,7 +147,7 @@ onMounted(async () => {
async function loadDeviceTypes() { async function loadDeviceTypes() {
try { try {
const response = await networkApi.types.list({ per_page: 100 }) const response = await networkApi.types.list({ perpage: 100 })
deviceTypes.value = response.data.data || [] deviceTypes.value = response.data.data || []
// Get counts for each type // Get counts for each type
await updateTypeCounts() await updateTypeCounts()
@@ -181,7 +160,7 @@ async function updateTypeCounts() {
// Get summary for type counts // Get summary for type counts
try { try {
const response = await networkApi.dashboardSummary() const response = await networkApi.dashboardSummary()
const byType = response.data.data?.by_type || [] const byType = response.data.data?.bytype || response.data.data?.by_type || []
totalCount.value = response.data.data?.total || 0 totalCount.value = response.data.data?.total || 0
// Map counts to types // Map counts to types
@@ -196,7 +175,7 @@ async function updateTypeCounts() {
async function loadVendors() { async function loadVendors() {
try { try {
const response = await vendorsApi.list({ per_page: 100 }) const response = await vendorsApi.list({ perpage: 100 })
vendors.value = response.data.data || [] vendors.value = response.data.data || []
} catch (error) { } catch (error) {
console.error('Error loading vendors:', error) console.error('Error loading vendors:', error)
@@ -205,7 +184,7 @@ async function loadVendors() {
async function loadLocations() { async function loadLocations() {
try { try {
const response = await locationsApi.list({ per_page: 100 }) const response = await locationsApi.list({ perpage: 100 })
locations.value = response.data.data || [] locations.value = response.data.data || []
} catch (error) { } catch (error) {
console.error('Error loading locations:', error) console.error('Error loading locations:', error)
@@ -217,7 +196,7 @@ async function loadDevices() {
try { try {
const params = { const params = {
page: page.value, page: page.value,
per_page: 25 perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
if (selectedType.value) params.type_id = selectedType.value if (selectedType.value) params.type_id = selectedType.value
@@ -226,7 +205,7 @@ async function loadDevices() {
const response = await networkApi.list(params) const response = await networkApi.list(params)
devices.value = response.data.data || [] devices.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading network devices:', error) console.error('Error loading network devices:', error)
} finally { } finally {
@@ -255,6 +234,12 @@ function goToPage(p) {
} }
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadDevices()
}
function getStatusClass(status) { function getStatusClass(status) {
if (!status) return 'badge-info' if (!status) return 'badge-info'
const s = status.toLowerCase() const s = status.toLowerCase()

View File

@@ -287,7 +287,7 @@ onMounted(async () => {
const [typesRes, buRes, appsRes] = await Promise.all([ const [typesRes, buRes, appsRes] = await Promise.all([
notificationsApi.types.list(), notificationsApi.types.list(),
businessUnitsApi.list().catch(() => ({ data: { data: [] } })), businessUnitsApi.list().catch(() => ({ data: { data: [] } })),
applicationsApi.list({ per_page: 500 }).catch(() => ({ data: { data: [] } })) applicationsApi.list({ perpage: 500 }).catch(() => ({ data: { data: [] } }))
]) ])
types.value = typesRes.data.data || [] types.value = typesRes.data.data || []

View File

@@ -73,22 +73,20 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="totalPages > 1"> <PaginationBar
<button :page="page"
v-for="p in totalPages" :totalPages="totalPages"
:key="p" :perPage="perPage"
:class="{ active: p === page }" @update:page="goToPage"
@click="goToPage(p)" @update:perPage="changePerPage"
> />
{{ p }}
</button>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { notificationsApi } from '@/api' import { notificationsApi } from '@/api'
import PaginationBar from '../../components/PaginationBar.vue'
const notifications = ref([]) const notifications = ref([])
const types = ref([]) const types = ref([])
@@ -122,7 +120,7 @@ async function loadNotifications() {
try { try {
const params = { const params = {
page: page.value, page: page.value,
per_page: perPage.value perpage: perPage.value
} }
if (searchQuery.value) { if (searchQuery.value) {
@@ -161,6 +159,12 @@ function goToPage(p) {
loadNotifications() loadNotifications()
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadNotifications()
}
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return '' if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString() return new Date(dateStr).toLocaleDateString()

View File

@@ -20,22 +20,22 @@
</div> </div>
<div class="hero-meta"> <div class="hero-meta">
<span class="badge badge-lg badge-info">Computer</span> <span class="badge badge-lg badge-info">Computer</span>
<span class="badge badge-lg" :class="getStatusClass(computer.status_name)"> <span class="badge badge-lg" :class="getStatusClass(computer.statusname)">
{{ computer.status_name || 'Unknown' }} {{ computer.statusname || 'Unknown' }}
</span> </span>
</div> </div>
<div class="hero-details"> <div class="hero-details">
<div class="hero-detail" v-if="computer.computer?.computertype_name"> <div class="hero-detail" v-if="computer.computer?.computertypename">
<span class="hero-detail-label">Type</span> <span class="hero-detail-label">Type</span>
<span class="hero-detail-value">{{ computer.computer.computertype_name }}</span> <span class="hero-detail-value">{{ computer.computer.computertypename }}</span>
</div> </div>
<div class="hero-detail" v-if="computer.computer?.os_name"> <div class="hero-detail" v-if="computer.computer?.osname">
<span class="hero-detail-label">OS</span> <span class="hero-detail-label">OS</span>
<span class="hero-detail-value">{{ computer.computer.os_name }}</span> <span class="hero-detail-value">{{ computer.computer.osname }}</span>
</div> </div>
<div class="hero-detail" v-if="computer.location_name"> <div class="hero-detail" v-if="computer.locationname">
<span class="hero-detail-label">Location</span> <span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ computer.location_name }}</span> <span class="hero-detail-value">{{ computer.locationname }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -74,11 +74,11 @@
<div class="info-list"> <div class="info-list">
<div class="info-row"> <div class="info-row">
<span class="info-label">Computer Type</span> <span class="info-label">Computer Type</span>
<span class="info-value">{{ computer.computer?.computertype_name || '-' }}</span> <span class="info-value">{{ computer.computer?.computertypename || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Operating System</span> <span class="info-label">Operating System</span>
<span class="info-value">{{ computer.computer?.os_name || '-' }}</span> <span class="info-value">{{ computer.computer?.osname || '-' }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -126,14 +126,14 @@
:top="computer.maptop" :top="computer.maptop"
:machineName="computer.assetnumber" :machineName="computer.assetnumber"
> >
<span class="location-link">{{ computer.location_name || 'On Map' }}</span> <span class="location-link">{{ computer.locationname || 'On Map' }}</span>
</LocationMapTooltip> </LocationMapTooltip>
<span v-else>{{ computer.location_name || '-' }}</span> <span v-else>{{ computer.locationname || '-' }}</span>
</span> </span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Business Unit</span> <span class="info-label">Business Unit</span>
<span class="info-value">{{ computer.businessunit_name || '-' }}</span> <span class="info-value">{{ computer.businessunitname || '-' }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -145,7 +145,7 @@
<router-link <router-link
v-for="item in controlledEquipment" v-for="item in controlledEquipment"
:key="item.relationshipid" :key="item.relationshipid"
:to="`/machines/${item.plugin_id || item.assetid}`" :to="`/machines/${item.pluginid || item.assetid}`"
class="equipment-item" class="equipment-item"
> >
<div class="equipment-info"> <div class="equipment-info">
@@ -153,7 +153,7 @@
<span class="equipment-alias" v-if="item.name">{{ item.name }}</span> <span class="equipment-alias" v-if="item.name">{{ item.name }}</span>
</div> </div>
<div class="equipment-meta"> <div class="equipment-meta">
<span class="category-tag">{{ item.assettype_name }}</span> <span class="category-tag">{{ item.assettypename }}</span>
<span class="connection-tag">{{ item.relationshipType }}</span> <span class="connection-tag">{{ item.relationshipType }}</span>
</div> </div>
</router-link> </router-link>
@@ -221,22 +221,22 @@ const controlledEquipment = computed(() => {
// Check outgoing - computer controls equipment // Check outgoing - computer controls equipment
for (const rel of relationships.value.outgoing || []) { for (const rel of relationships.value.outgoing || []) {
if (rel.target_asset?.assettype_name === 'equipment' && rel.relationship_type_name === 'Controls') { if (rel.targetasset?.assettypename === 'equipment' && rel.relationshiptypename === 'Controls') {
items.push({ items.push({
...rel.target_asset, ...rel.targetasset,
relationshipid: rel.relationshipid, relationshipid: rel.relationshipid,
relationshipType: rel.relationship_type_name relationshipType: rel.relationshiptypename
}) })
} }
} }
// Also check incoming - legacy data may have equipment -> computer Controls relationships // Also check incoming - legacy data may have equipment -> computer Controls relationships
for (const rel of relationships.value.incoming || []) { for (const rel of relationships.value.incoming || []) {
if (rel.source_asset?.assettype_name === 'equipment' && rel.relationship_type_name === 'Controls') { if (rel.sourceasset?.assettypename === 'equipment' && rel.relationshiptypename === 'Controls') {
items.push({ items.push({
...rel.source_asset, ...rel.sourceasset,
relationshipid: rel.relationshipid, relationshipid: rel.relationshipid,
relationshipType: rel.relationship_type_name relationshipType: rel.relationshiptypename
}) })
} }
} }

View File

@@ -30,6 +30,7 @@
<th>Type</th> <th>Type</th>
<th>Features</th> <th>Features</th>
<th>Status</th> <th>Status</th>
<th>Location</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -38,17 +39,18 @@
<td>{{ item.assetnumber }}</td> <td>{{ item.assetnumber }}</td>
<td>{{ item.computer?.hostname || '-' }}</td> <td>{{ item.computer?.hostname || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td> <td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.computer?.computertype_name || '-' }}</td> <td>{{ item.computer?.computertypename || '-' }}</td>
<td class="features"> <td class="features">
<span v-if="item.computer?.isvnc" class="feature-tag active">VNC</span> <span v-if="item.computer?.isvnc" class="feature-tag active">VNC</span>
<span v-if="item.computer?.iswinrm" class="feature-tag active">WinRM</span> <span v-if="item.computer?.iswinrm" class="feature-tag active">WinRM</span>
<span v-if="!item.computer?.isvnc && !item.computer?.iswinrm">-</span> <span v-if="!item.computer?.isvnc && !item.computer?.iswinrm">-</span>
</td> </td>
<td> <td>
<span class="badge" :class="getStatusClass(item.status_name)"> <span class="badge" :class="getStatusClass(item.statusname)">
{{ item.status_name || 'Unknown' }} {{ item.statusname || 'Unknown' }}
</span> </span>
</td> </td>
<td>{{ item.locationname || '-' }}</td>
<td class="actions"> <td class="actions">
<router-link <router-link
:to="`/pcs/${item.computer?.computerid || item.assetid}`" :to="`/pcs/${item.computer?.computerid || item.assetid}`"
@@ -59,7 +61,7 @@
</td> </td>
</tr> </tr>
<tr v-if="computers.length === 0"> <tr v-if="computers.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);"> <td colspan="8" style="text-align: center; color: var(--text-light);">
No computers found No computers found
</td> </td>
</tr> </tr>
@@ -68,16 +70,13 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="totalPages > 1"> <PaginationBar
<button :page="page"
v-for="p in totalPages" :totalPages="totalPages"
:key="p" :perPage="perPage"
:class="{ active: p === page }" @update:page="goToPage"
@click="goToPage(p)" @update:perPage="changePerPage"
> />
{{ p }}
</button>
</div>
</template> </template>
</div> </div>
</div> </div>
@@ -86,12 +85,14 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { computersApi } from '../../api' import { computersApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const computers = ref([]) const computers = ref([])
const loading = ref(true) const loading = ref(true)
const search = ref('') const search = ref('')
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null let searchTimeout = null
@@ -104,13 +105,13 @@ async function loadComputers() {
try { try {
const params = { const params = {
page: page.value, page: page.value,
per_page: 20 perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
const response = await computersApi.list(params) const response = await computersApi.list(params)
computers.value = response.data.data || [] computers.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading computers:', error) console.error('Error loading computers:', error)
} finally { } finally {
@@ -131,6 +132,12 @@ function goToPage(p) {
loadComputers() loadComputers()
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadComputers()
}
function getStatusClass(status) { function getStatusClass(status) {
if (!status) return 'badge-info' if (!status) return 'badge-info'
const s = status.toLowerCase() const s = status.toLowerCase()

View File

@@ -0,0 +1,166 @@
<template>
<div>
<button class="print-btn" @click="print" v-if="!loading">Print Badge</button>
<div v-if="loading" class="loading-msg">Loading...</div>
<div v-else-if="equipment" class="badge-container">
<div class="model-name">{{ modelName }}</div>
<img
v-if="isInspection"
class="machine-image"
:src="geLogo"
alt="GE Logo"
/>
<img
v-else-if="imageUrl"
class="machine-image"
:src="imageUrl"
:alt="modelName"
/>
<div class="barcode-container">
<svg ref="barcodeEl"></svg>
<div class="machine-number">*{{ equipment.assetnumber }}*</div>
</div>
</div>
<div v-else class="error-msg">Equipment not found</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { equipmentApi } from '../../api'
import JsBarcode from 'jsbarcode'
const route = useRoute()
const loading = ref(true)
const equipment = ref(null)
const barcodeEl = ref(null)
const geLogo = '/images/applications/GE-Logo.png'
const isInspection = computed(() => {
if (!equipment.value) return false
return equipment.value.assetnumber?.startsWith('06')
})
const modelName = computed(() => {
if (!equipment.value) return ''
if (isInspection.value) return 'Inspection'
return equipment.value.equipment?.modelname || ''
})
const imageUrl = computed(() => {
if (!equipment.value) return null
const img = equipment.value.equipment?.imageurl
if (img) return img.startsWith('/') ? img : `/images/models/machines/${img}`
return null
})
onMounted(async () => {
try {
const response = await equipmentApi.get(route.params.id)
equipment.value = response.data.data
} catch (error) {
console.error('Error loading equipment:', error)
} finally {
loading.value = false
await nextTick()
generateBarcode()
}
})
function generateBarcode() {
if (!barcodeEl.value || !equipment.value) return
try {
JsBarcode(barcodeEl.value, equipment.value.assetnumber, {
format: 'CODE39',
displayValue: false,
width: 2,
height: 70,
margin: 0
})
} catch (e) {
console.error('Barcode generation error:', e)
}
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: 2.13in 3.38in; margin: 0; }
body { font-family: Arial, sans-serif; }
.badge-container {
width: 2.13in;
height: 3.38in;
background: white;
margin: 0 auto;
border: 1px solid #ccc;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.15in;
box-sizing: border-box;
}
.model-name {
font-size: 12pt;
font-weight: bold;
text-align: center;
margin-bottom: 0.1in;
color: #000;
}
.machine-image {
max-width: 1.8in;
max-height: 1.5in;
object-fit: contain;
margin-bottom: 0.1in;
}
.barcode-container {
text-align: center;
margin-top: auto;
}
.barcode-container svg {
width: 1.8in;
height: 0.9in;
}
.machine-number {
font-size: 14pt;
font-weight: bold;
font-family: monospace;
margin-top: -0.1in;
color: #000;
}
.print-btn {
display: block;
margin: 20px auto;
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
}
.loading-msg, .error-msg {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
color: #666;
}
@media print {
.print-btn { display: none; }
.badge-container { border: none; margin: 0; }
}
</style>

View File

@@ -0,0 +1,291 @@
<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Batch Print Printer QR Codes</h3>
<p>Select printers to print (6 per page):</p>
<div v-if="loadingPrinters" class="loading-msg">Loading printers...</div>
<div v-else class="printer-grid">
<div
v-for="printer in printers"
:key="printer.assetid"
class="printer-item"
:class="{ selected: isSelected(printer) }"
@click="togglePrinter(printer)"
>
<input type="checkbox" :checked="isSelected(printer)" @click.stop />
<label>
<strong>{{ displayName(printer) }}</strong>
<div class="model">{{ printer.printer?.modelname || '' }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedPrinters.length }}</span> printers
(<span class="pages">{{ pageCount }}</span> pages)
</div>
<button class="print-btn" :disabled="selectedPrinters.length === 0" @click="print">Print QR Codes</button>
<button class="clear-btn" @click="clearSelection">Clear All</button>
<button class="select-all-btn" @click="selectAll">Select All</button>
</div>
</div>
<div class="sheets-container">
<div v-for="(page, pageIdx) in pages" :key="pageIdx" class="print-sheet">
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
<div
v-for="pos in 6"
:key="pos"
class="label"
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
>
<template v-if="page[pos - 1]">
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
<div class="qr-container">
<canvas :ref="el => setQrRef(el, pageIdx, pos)"></canvas>
</div>
<div class="info-section">
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
<div class="info-inner">
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</div>
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
</div>
</div>
</template>
<span v-else class="empty-label">Empty</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { printersApi } from '../../api'
import QRCode from 'qrcode'
const printers = ref([])
const selectedPrinters = ref([])
const loadingPrinters = ref(true)
const qrRefs = ref({})
const pageCount = computed(() => Math.ceil(selectedPrinters.value.length / 6) || 0)
const pages = computed(() => {
const result = []
for (let i = 0; i < selectedPrinters.value.length; i += 6) {
const page = []
for (let j = 0; j < 6; j++) {
page.push(selectedPrinters.value[i + j] || null)
}
result.push(page)
}
return result
})
onMounted(async () => {
try {
const response = await printersApi.list({ perpage: 500 })
printers.value = response.data.data || []
} catch (error) {
console.error('Error loading printers:', error)
} finally {
loadingPrinters.value = false
}
})
watch(selectedPrinters, async () => {
await nextTick()
generateQRCodes()
}, { deep: true })
function setQrRef(el, pageIdx, pos) {
if (el) {
qrRefs.value[`${pageIdx}-${pos}`] = el
}
}
function generateQRCodes() {
pages.value.forEach((page, pageIdx) => {
page.forEach((printer, idx) => {
if (!printer) return
const pos = idx + 1
const canvas = qrRefs.value[`${pageIdx}-${pos}`]
if (!canvas) return
const qrUrl = `${window.location.origin}/printers/${printer.printer?.printerid || printer.assetid}`
QRCode.toCanvas(canvas, qrUrl, {
width: 144,
margin: 0,
errorCorrectionLevel: 'M'
}).catch(err => console.error('QR error:', err))
})
})
}
function displayName(printer) {
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
}
function getIp(printer) {
if (!printer.communications?.length) return null
const primary = printer.communications.find(c => c.isprimary) || printer.communications[0]
return primary?.ipaddress || primary?.address || null
}
function isSelected(printer) {
return selectedPrinters.value.some(p => p.assetid === printer.assetid)
}
function togglePrinter(printer) {
const idx = selectedPrinters.value.findIndex(p => p.assetid === printer.assetid)
if (idx > -1) {
selectedPrinters.value.splice(idx, 1)
} else {
selectedPrinters.value.push(printer)
}
}
function clearSelection() {
selectedPrinters.value = []
}
function selectAll() {
selectedPrinters.value = [...printers.value]
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; padding: 20px; }
.controls { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
.controls h3 { margin-top: 0; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.print-btn:disabled { background: #ccc; cursor: not-allowed; }
.clear-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: #dc3545;
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.select-all-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: #28a745;
color: white;
border: none;
border-radius: 5px;
}
.printer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 10px;
background: #fafafa;
}
.printer-item {
display: flex;
align-items: center;
padding: 8px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.printer-item:hover { background: #f0f0f0; }
.printer-item.selected { background: #e7f1ff; border-color: #667eea; }
.printer-item input { margin-right: 10px; }
.printer-item label { cursor: pointer; flex: 1; }
.printer-item .model { font-size: 11px; color: #666; }
.selected-count { font-weight: bold; margin: 10px 0; }
.selected-count .count { color: #667eea; }
.selected-count .pages { color: #28a745; }
.loading-msg { text-align: center; padding: 2rem; color: #666; }
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
page-break-after: always;
}
.print-sheet:last-child { page-break-after: auto; }
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
.label {
width: 3in;
height: 3in;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.1in;
box-sizing: border-box;
border: 1px dashed #ccc;
}
.label.filled { border: 2px solid #667eea; }
.label.empty { background: #fafafa; }
.pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; }
.pos-3 { top: 4in; left: 1.1875in; }
.pos-4 { top: 4in; left: 4.3125in; }
.pos-5 { top: 7.125in; left: 1.1875in; }
.pos-6 { top: 7.125in; left: 4.3125in; }
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
.qr-container { text-align: center; }
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; }
.empty-label { color: #999; font-size: 14px; }
@media print {
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.sheets-container { gap: 0; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.sheet-label { display: none; }
.label { border: none !important; }
.label.empty { visibility: hidden; }
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<div>
<div class="no-print">
<button class="print-btn" :disabled="!printer" @click="print">Print QR Code</button>
<label>Position:
<select class="position-select" v-model="position">
<option value="1">1 - Top Left</option>
<option value="2">2 - Top Right</option>
<option value="3">3 - Middle Left</option>
<option value="4">4 - Middle Right</option>
<option value="5">5 - Bottom Left</option>
<option value="6">6 - Bottom Right</option>
</select>
</label>
</div>
<div v-if="loading" class="loading-msg">Loading...</div>
<div v-else-if="printer" class="print-sheet">
<div
v-for="pos in 6"
:key="pos"
class="label"
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
>
<template v-if="pos === parseInt(position)">
<div class="model-name">{{ printer.printer?.modelname || '' }}</div>
<div class="qr-container">
<canvas ref="qrCanvas"></canvas>
</div>
<div class="info-section">
<div class="csf-name">{{ printer.assetnumber }}</div>
<div class="info-inner">
<div v-if="printer.printer?.windowsname" class="info-row">{{ printer.printer.windowsname }}</div>
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
</div>
</div>
</template>
</div>
</div>
<div v-else class="error-msg">Printer not found</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { printersApi } from '../../api'
import QRCode from 'qrcode'
const route = useRoute()
const loading = ref(true)
const printer = ref(null)
const position = ref('1')
const qrCanvas = ref(null)
const ipAddress = computed(() => {
if (!printer.value?.communications?.length) return null
const primary = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0]
return primary?.ipaddress || primary?.address || null
})
onMounted(async () => {
try {
const response = await printersApi.get(route.params.id)
printer.value = response.data.data
} catch (error) {
console.error('Error loading printer:', error)
} finally {
loading.value = false
await nextTick()
generateQR()
}
})
watch(position, async () => {
await nextTick()
generateQR()
})
function generateQR() {
const canvas = Array.isArray(qrCanvas.value) ? qrCanvas.value[0] : qrCanvas.value
if (!canvas || !printer.value) return
const qrUrl = `${window.location.origin}/printers/${printer.value.printer?.printerid || printer.value.assetid}`
QRCode.toCanvas(canvas, qrUrl, {
width: 144,
margin: 0,
errorCorrectionLevel: 'M'
}).catch(err => console.error('QR error:', err))
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; text-align: center; padding: 20px; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
margin: 5px;
}
.print-btn:disabled { background: #ccc; cursor: not-allowed; }
.position-select { padding: 8px; font-size: 14px; margin-left: 10px; }
.loading-msg, .error-msg {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
color: #666;
}
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
}
.label {
width: 3in;
height: 3in;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.1in;
box-sizing: border-box;
}
.label.inactive { border: 1px dashed #ccc; }
.label.active { border: 2px solid #667eea; }
.pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; }
.pos-3 { top: 4in; left: 1.1875in; }
.pos-4 { top: 4in; left: 4.3125in; }
.pos-5 { top: 7.125in; left: 1.1875in; }
.pos-6 { top: 7.125in; left: 4.3125in; }
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
.qr-container { text-align: center; }
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; }
@media print {
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.label { border: none !important; }
.label.inactive { visibility: hidden; }
}
</style>

View File

@@ -0,0 +1,366 @@
<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Batch Print USB Barcode Labels</h3>
<p>Select USB devices to print (72 labels per page - 6 ULINE labels x 12 mini-labels each, cut after printing):</p>
<div v-if="loadingDevices" class="loading-msg">Loading USB devices...</div>
<div v-else-if="devices.length === 0" class="loading-msg">No USB devices found</div>
<div v-else class="usb-grid">
<div
v-for="device in devices"
:key="device.machineid"
class="usb-item"
:class="{ selected: isSelected(device) }"
@click="toggleDevice(device)"
>
<input type="checkbox" :checked="isSelected(device)" @click.stop />
<label>
<strong><code>{{ device.serialnumber || '-' }}</code></strong>
<div class="alias">{{ device.alias || device.machinenumber || '' }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedDevices.length }}</span> USB devices
(<span class="pages">{{ pageCount }}</span> pages)
<label style="margin-left: 20px;">Start at cell:
<select v-model="startCell" style="padding: 5px; font-size: 14px;">
<option value="1">1 - Top Left</option>
<option value="2">2 - Top Right</option>
<option value="3">3 - Middle Left</option>
<option value="4">4 - Middle Right</option>
<option value="5">5 - Bottom Left</option>
<option value="6">6 - Bottom Right</option>
</select>
</label>
</div>
<button class="print-btn" :disabled="selectedDevices.length === 0" @click="print">Print Labels</button>
<button class="clear-btn" @click="clearSelection">Clear All</button>
<button class="select-all-btn" @click="selectAll">Select All</button>
</div>
</div>
<div class="sheets-container">
<div v-for="(page, pageIdx) in sheetPages" :key="pageIdx" class="print-sheet">
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
<div
v-for="cellNum in 6"
:key="cellNum"
class="label-cell"
:class="[`cell-${cellNum}`, page[cellNum - 1].hasContent ? 'has-content' : 'empty']"
>
<div v-if="page[cellNum - 1].hasContent" class="mini-grid">
<div
v-for="(miniItem, miniIdx) in page[cellNum - 1].items"
:key="miniIdx"
class="mini-label"
:class="miniItem ? 'filled' : 'empty'"
>
<template v-if="miniItem">
<div class="barcode-container">
<svg :ref="el => setBarcodeRef(el, pageIdx, cellNum, miniIdx)"></svg>
</div>
<div class="serial-text">{{ miniItem.serialnumber }}</div>
</template>
</div>
</div>
<div v-else class="empty-cell-text">Empty</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { usbApi } from '../../api'
import JsBarcode from 'jsbarcode'
const MINI_LABELS_PER_CELL = 12
const CELLS_PER_PAGE = 6
const devices = ref([])
const selectedDevices = ref([])
const loadingDevices = ref(true)
const startCell = ref('1')
const barcodeRefs = ref({})
const pageCount = computed(() => {
if (selectedDevices.value.length === 0) return 0
const skippedCells = parseInt(startCell.value) - 1
const numCells = Math.ceil(selectedDevices.value.length / MINI_LABELS_PER_CELL)
const totalCellsNeeded = numCells + skippedCells
return Math.ceil(totalCellsNeeded / CELLS_PER_PAGE)
})
const sheetPages = computed(() => {
const result = []
if (selectedDevices.value.length === 0) return result
const startCellNum = parseInt(startCell.value)
let usbIdx = 0
for (let page = 0; page < pageCount.value; page++) {
const cells = []
for (let cellNum = 1; cellNum <= CELLS_PER_PAGE; cellNum++) {
const skipThisCell = page === 0 && cellNum < startCellNum
const hasContent = !skipThisCell && usbIdx < selectedDevices.value.length
const items = []
if (hasContent) {
for (let mini = 0; mini < MINI_LABELS_PER_CELL; mini++) {
if (usbIdx < selectedDevices.value.length) {
items.push(selectedDevices.value[usbIdx])
usbIdx++
} else {
items.push(null)
}
}
}
cells.push({ hasContent, items })
}
result.push(cells)
}
return result
})
onMounted(async () => {
try {
const response = await usbApi.list({ perpage: 500 })
devices.value = response.data.data || []
} catch (error) {
console.error('Error loading USB devices:', error)
} finally {
loadingDevices.value = false
}
})
watch([selectedDevices, startCell], async () => {
await nextTick()
generateBarcodes()
}, { deep: true })
function setBarcodeRef(el, pageIdx, cellNum, miniIdx) {
if (el) {
barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`] = el
}
}
function generateBarcodes() {
sheetPages.value.forEach((page, pageIdx) => {
page.forEach((cell, cellIdx) => {
if (!cell.hasContent) return
const cellNum = cellIdx + 1
cell.items.forEach((item, miniIdx) => {
if (!item) return
const el = barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`]
if (!el) return
try {
JsBarcode(el, item.serialnumber, {
format: 'CODE128',
width: 1,
height: 22,
displayValue: false,
margin: 0,
background: 'transparent'
})
} catch (e) {
console.error('Barcode error:', item.serialnumber, e)
}
})
})
})
}
function isSelected(device) {
return selectedDevices.value.some(d => d.machineid === device.machineid)
}
function toggleDevice(device) {
const idx = selectedDevices.value.findIndex(d => d.machineid === device.machineid)
if (idx > -1) {
selectedDevices.value.splice(idx, 1)
} else {
selectedDevices.value.push(device)
}
}
function clearSelection() {
selectedDevices.value = []
}
function selectAll() {
selectedDevices.value = [...devices.value]
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; padding: 20px; }
.controls { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
.controls h3 { margin-top: 0; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.print-btn:disabled { background: #ccc; cursor: not-allowed; }
.clear-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: #dc3545;
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.select-all-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: #28a745;
color: white;
border: none;
border-radius: 5px;
}
.usb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 10px;
background: #fafafa;
}
.usb-item {
display: flex;
align-items: center;
padding: 8px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.usb-item:hover { background: #f0f0f0; }
.usb-item.selected { background: #e7f1ff; border-color: #667eea; }
.usb-item input { margin-right: 10px; }
.usb-item label { cursor: pointer; flex: 1; }
.usb-item .alias { font-size: 11px; color: #666; }
.selected-count { font-weight: bold; margin: 10px 0; }
.selected-count .count { color: #667eea; }
.selected-count .pages { color: #28a745; }
.loading-msg { text-align: center; padding: 2rem; color: #666; }
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
page-break-after: always;
}
.print-sheet:last-child { page-break-after: auto; }
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
.label-cell {
width: 3in;
height: 3in;
position: absolute;
box-sizing: border-box;
border: 1px dashed #ccc;
overflow: hidden;
}
.label-cell.has-content { border: 1px solid #667eea; }
.label-cell.empty { background: #fafafa; }
.cell-1 { top: 0.875in; left: 1.1875in; }
.cell-2 { top: 0.875in; left: 4.3125in; }
.cell-3 { top: 4in; left: 1.1875in; }
.cell-4 { top: 4in; left: 4.3125in; }
.cell-5 { top: 7.125in; left: 1.1875in; }
.cell-6 { top: 7.125in; left: 4.3125in; }
.mini-grid {
display: grid;
grid-template-columns: repeat(3, 1in);
grid-template-rows: repeat(4, 0.75in);
width: 3in;
height: 3in;
}
.mini-label {
width: 1in;
height: 0.75in;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0.02in;
border: 1px dotted #ddd;
overflow: hidden;
}
.mini-label.filled { border: 1px solid #999; }
.mini-label.empty { background: #f8f8f8; border: 1px dotted #eee; }
.barcode-container { text-align: center; line-height: 0; }
.barcode-container svg { max-width: 0.9in; height: 24px; }
.serial-text {
font-size: 6pt;
font-weight: bold;
font-family: monospace;
text-align: center;
margin-top: 1px;
letter-spacing: 0.3px;
}
.empty-cell-text {
color: #ccc;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
@media print {
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.sheets-container { gap: 0; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.sheet-label { display: none; }
.label-cell { border: none !important; }
.label-cell.empty { visibility: hidden; }
.mini-label { border: 1px dotted #ccc !important; }
.mini-label.empty { visibility: hidden; }
}
</style>

View File

@@ -3,6 +3,9 @@
<div class="page-header"> <div class="page-header">
<h2>Printer Details</h2> <h2>Printer Details</h2>
<div class="header-actions"> <div class="header-actions">
<router-link :to="`/print/printer-qr/${$route.params.id}`" class="btn btn-secondary" target="_blank">
Print QR
</router-link>
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link> <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> <router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
</div> </div>
@@ -24,13 +27,13 @@
<span v-if="printer.printer?.isnetwork" class="badge badge-lg badge-secondary">Network</span> <span v-if="printer.printer?.isnetwork" class="badge badge-lg badge-secondary">Network</span>
</div> </div>
<div class="hero-details"> <div class="hero-details">
<div class="hero-detail" v-if="printer.printer?.vendor_name"> <div class="hero-detail" v-if="printer.printer?.vendorname">
<span class="hero-detail-label">Vendor</span> <span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ printer.printer.vendor_name }}</span> <span class="hero-detail-value">{{ printer.printer.vendorname }}</span>
</div> </div>
<div class="hero-detail" v-if="printer.printer?.model_name"> <div class="hero-detail" v-if="printer.printer?.modelname">
<span class="hero-detail-label">Model</span> <span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ printer.printer.model_name }}</span> <span class="hero-detail-value">{{ printer.printer.modelname }}</span>
</div> </div>
<div class="hero-detail" v-if="printer.serialnumber"> <div class="hero-detail" v-if="printer.serialnumber">
<span class="hero-detail-label">Serial Number</span> <span class="hero-detail-label">Serial Number</span>
@@ -133,9 +136,9 @@
<span v-else>Not mapped</span> <span v-else>Not mapped</span>
</span> </span>
</div> </div>
<div class="info-row" v-if="printer.businessunit_name"> <div class="info-row" v-if="printer.businessunitname">
<span class="info-label">Business Unit</span> <span class="info-label">Business Unit</span>
<span class="info-value">{{ printer.businessunit_name }}</span> <span class="info-value">{{ printer.businessunitname }}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,10 @@
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>Printers</h2> <h2>Printers</h2>
<router-link to="/printers/new" class="btn btn-primary">Add Printer</router-link> <div class="header-actions">
<router-link to="/print/printer-qr" class="btn btn-secondary" target="_blank">Batch Print QR</router-link>
<router-link to="/printers/new" class="btn btn-primary">Add Printer</router-link>
</div>
</div> </div>
<!-- Filters --> <!-- Filters -->
@@ -36,11 +39,11 @@
<tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid"> <tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid">
<td>{{ printer.assetnumber }}</td> <td>{{ printer.assetnumber }}</td>
<td>{{ printer.name || '-' }}</td> <td>{{ printer.name || '-' }}</td>
<td>{{ printer.businessunit_name || '-' }}</td> <td>{{ printer.businessunitname || '-' }}</td>
<td>{{ printer.printer?.model_name || '-' }}</td> <td>{{ printer.printer?.modelname || '-' }}</td>
<td> <td>
<span class="badge" :class="getStatusClass(printer.status_name)"> <span class="badge" :class="getStatusClass(printer.statusname)">
{{ printer.status_name || 'Active' }} {{ printer.statusname || 'Active' }}
</span> </span>
</td> </td>
<td class="actions"> <td class="actions">
@@ -62,16 +65,13 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="totalPages > 1"> <PaginationBar
<button :page="page"
v-for="p in totalPages" :totalPages="totalPages"
:key="p" :perPage="perPage"
:class="{ active: p === page }" @update:page="goToPage"
@click="goToPage(p)" @update:perPage="changePerPage"
> />
{{ p }}
</button>
</div>
</template> </template>
</div> </div>
</div> </div>
@@ -80,12 +80,14 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { printersApi } from '../../api' import { printersApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const printers = ref([]) const printers = ref([])
const loading = ref(true) const loading = ref(true)
const search = ref('') const search = ref('')
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null let searchTimeout = null
@@ -98,13 +100,13 @@ async function loadPrinters() {
try { try {
const params = { const params = {
page: page.value, page: page.value,
perpage: 20 perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
const response = await printersApi.list(params) const response = await printersApi.list(params)
printers.value = response.data.data || [] printers.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading printers:', error) console.error('Error loading printers:', error)
} finally { } finally {
@@ -125,6 +127,12 @@ function goToPage(p) {
loadPrinters() loadPrinters()
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadPrinters()
}
function getStatusClass(status) { function getStatusClass(status) {
if (!status) return 'badge-info' if (!status) return 'badge-info'
const s = status.toLowerCase() const s = status.toLowerCase()

View File

@@ -0,0 +1,144 @@
<template>
<div>
<div class="page-header">
<h2>PC-Machine Relationships</h2>
<div class="header-actions">
<button class="btn btn-primary" @click="copyTable">Copy Table</button>
<button class="btn btn-secondary" @click="copyCSV">Copy CSV</button>
<button class="btn btn-secondary" @click="copyJSON">Copy JSON</button>
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
</div>
<p class="text-muted">PCs with relationships to shop floor machines</p>
<div v-if="copied" class="copy-toast">{{ copiedFormat }} copied to clipboard!</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table id="relationshipsTable">
<thead>
<tr>
<th>Machine #</th>
<th>Vendor</th>
<th>Model</th>
<th>PC Hostname</th>
<th>PC IP</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in data" :key="idx">
<td>{{ row.machine_number }}</td>
<td>{{ row.vendor }}</td>
<td>{{ row.model }}</td>
<td class="mono">{{ row.hostname }}</td>
<td class="mono">{{ row.ip }}</td>
</tr>
<tr v-if="data.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No relationships found
</td>
</tr>
</tbody>
</table>
</div>
<p class="record-count">{{ data.length }} records found</p>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { reportsApi } from '../../api'
const loading = ref(true)
const data = ref([])
const copied = ref(false)
const copiedFormat = ref('')
onMounted(async () => {
try {
const response = await reportsApi.pcRelationships()
data.value = response.data.data?.data || []
} catch (error) {
console.error('Error loading report:', error)
} finally {
loading.value = false
}
})
function showCopied(format) {
copiedFormat.value = format
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
function copyTable() {
const headers = ['Machine #', 'Vendor', 'Model', 'PC Hostname', 'PC IP']
let text = headers.join('\t') + '\n'
for (const row of data.value) {
text += [row.machine_number, row.vendor, row.model, row.hostname, row.ip].join('\t') + '\n'
}
navigator.clipboard.writeText(text).then(() => showCopied('Table'))
}
function copyCSV() {
const headers = ['Machine #', 'Vendor', 'Model', 'PC Hostname', 'PC IP']
let csv = headers.map(h => `"${h}"`).join(',') + '\n'
for (const row of data.value) {
csv += [row.machine_number, row.vendor, row.model, row.hostname, row.ip]
.map(v => `"${v}"`)
.join(',') + '\n'
}
navigator.clipboard.writeText(csv).then(() => showCopied('CSV'))
}
function copyJSON() {
const jsonData = data.value.map(row => ({
Name: row.hostname,
IpAddress: row.ip,
Group: null
}))
navigator.clipboard.writeText(JSON.stringify(jsonData, null, 2)).then(() => showCopied('JSON'))
}
</script>
<style scoped>
.text-muted {
color: var(--text-light);
margin-bottom: 1rem;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.record-count {
color: var(--text-light);
margin-top: 1rem;
font-size: 0.875rem;
}
.copy-toast {
position: fixed;
top: 20px;
right: 20px;
background: #28a745;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 6px;
font-weight: 500;
z-index: 9999;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -134,8 +134,11 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { reportsApi } from '@/api' import { reportsApi } from '@/api'
const router = useRouter()
const reports = ref([]) const reports = ref([])
const currentReport = ref(null) const currentReport = ref(null)
const reportData = ref(null) const reportData = ref(null)
@@ -155,6 +158,12 @@ async function loadReports() {
} }
async function runReport(report) { async function runReport(report) {
// PC Relationships has its own dedicated page
if (report.id === 'pc-relationships') {
router.push('/reports/pc-relationships')
return
}
currentReport.value = report currentReport.value = report
loading.value = true loading.value = true
reportData.value = null reportData.value = null

View File

@@ -38,11 +38,14 @@
</table> </table>
</div> </div>
<div class="pagination" v-if="totalPages > 1"> <!-- Pagination -->
<button v-for="p in totalPages" :key="p" :class="{ active: p === page }" @click="goToPage(p)"> <PaginationBar
{{ p }} :page="page"
</button> :totalPages="totalPages"
</div> :perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template> </template>
</div> </div>
@@ -97,11 +100,13 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { businessunitsApi } from '../../api' import { businessunitsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const items = ref([]) const items = ref([])
const loading = ref(true) const loading = ref(true)
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false) const showModal = ref(false)
const editing = ref(null) const editing = ref(null)
@@ -118,9 +123,9 @@ onMounted(() => loadData())
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const response = await businessunitsApi.list({ page: page.value, perpage: 20 }) const response = await businessunitsApi.list({ page: page.value, perpage: perPage.value })
items.value = response.data.data || [] items.value = response.data.data || []
totalPages.value = response.data.meta?.pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) { } catch (err) {
console.error('Error loading business units:', err) console.error('Error loading business units:', err)
} finally { } finally {
@@ -130,6 +135,12 @@ async function loadData() {
function goToPage(p) { page.value = p; loadData() } function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function openModal(item = null) { function openModal(item = null) {
editing.value = item editing.value = item
form.value = item ? { form.value = item ? {

View File

@@ -64,16 +64,13 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="totalPages > 1"> <PaginationBar
<button :page="page"
v-for="p in totalPages" :totalPages="totalPages"
:key="p" :perPage="perPage"
:class="{ active: p === page }" @update:page="goToPage"
@click="goToPage(p)" @update:perPage="changePerPage"
> />
{{ p }}
</button>
</div>
</template> </template>
</div> </div>
@@ -185,12 +182,14 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { locationsApi } from '../../api' import { locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const locations = ref([]) const locations = ref([])
const loading = ref(true) const loading = ref(true)
const search = ref('') const search = ref('')
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false) const showModal = ref(false)
const editingLocation = ref(null) const editingLocation = ref(null)
@@ -220,13 +219,13 @@ async function loadLocations() {
try { try {
const params = { const params = {
page: page.value, page: page.value,
perpage: 20 perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
const response = await locationsApi.list(params) const response = await locationsApi.list(params)
locations.value = response.data.data || [] locations.value = response.data.data || []
totalPages.value = response.data.meta?.pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) { } catch (err) {
console.error('Error loading locations:', err) console.error('Error loading locations:', err)
} finally { } finally {
@@ -247,6 +246,12 @@ function goToPage(p) {
loadLocations() loadLocations()
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadLocations()
}
function openModal(loc = null) { function openModal(loc = null) {
editingLocation.value = loc editingLocation.value = loc
if (loc) { if (loc) {

View File

@@ -53,16 +53,13 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="totalPages > 1"> <PaginationBar
<button :page="page"
v-for="p in totalPages" :totalPages="totalPages"
:key="p" :perPage="perPage"
:class="{ active: p === page }" @update:page="goToPage"
@click="goToPage(p)" @update:perPage="changePerPage"
> />
{{ p }}
</button>
</div>
</template> </template>
</div> </div>
@@ -147,11 +144,13 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { machinetypesApi } from '../../api' import { machinetypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const machineTypes = ref([]) const machineTypes = ref([])
const loading = ref(true) const loading = ref(true)
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false) const showModal = ref(false)
const editingType = ref(null) const editingType = ref(null)
@@ -176,12 +175,12 @@ async function loadTypes() {
try { try {
const params = { const params = {
page: page.value, page: page.value,
perpage: 20 perpage: perPage.value
} }
const response = await machinetypesApi.list(params) const response = await machinetypesApi.list(params)
machineTypes.value = response.data.data || [] machineTypes.value = response.data.data || []
totalPages.value = response.data.meta?.pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) { } catch (err) {
console.error('Error loading machine types:', err) console.error('Error loading machine types:', err)
} finally { } finally {
@@ -194,6 +193,12 @@ function goToPage(p) {
loadTypes() loadTypes()
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadTypes()
}
function openModal(mt = null) { function openModal(mt = null) {
editingType.value = mt editingType.value = mt
if (mt) { if (mt) {

View File

@@ -65,16 +65,14 @@
</table> </table>
</div> </div>
<div class="pagination" v-if="totalPages > 1"> <!-- Pagination -->
<button <PaginationBar
v-for="p in totalPages" :page="page"
:key="p" :totalPages="totalPages"
:class="{ active: p === page }" :perPage="perPage"
@click="goToPage(p)" @update:page="goToPage"
> @update:perPage="changePerPage"
{{ p }} />
</button>
</div>
</template> </template>
</div> </div>
@@ -184,6 +182,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { modelsApi, vendorsApi, machinetypesApi } from '../../api' import { modelsApi, vendorsApi, machinetypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const models = ref([]) const models = ref([])
const vendors = ref([]) const vendors = ref([])
@@ -193,6 +192,7 @@ const search = ref('')
const vendorFilter = ref('') const vendorFilter = ref('')
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false) const showModal = ref(false)
const editingModel = ref(null) const editingModel = ref(null)
@@ -225,13 +225,13 @@ onMounted(async () => {
async function loadModels() { async function loadModels() {
loading.value = true loading.value = true
try { try {
const params = { page: page.value, perpage: 20 } const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value if (search.value) params.search = search.value
if (vendorFilter.value) params.vendor = vendorFilter.value if (vendorFilter.value) params.vendor = vendorFilter.value
const response = await modelsApi.list(params) const response = await modelsApi.list(params)
models.value = response.data.data || [] models.value = response.data.data || []
totalPages.value = response.data.meta?.pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) { } catch (err) {
console.error('Error loading models:', err) console.error('Error loading models:', err)
} finally { } finally {
@@ -270,6 +270,12 @@ function goToPage(p) {
loadModels() loadModels()
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadModels()
}
function openModal(m = null) { function openModal(m = null) {
editingModel.value = m editingModel.value = m
if (m) { if (m) {

View File

@@ -45,11 +45,14 @@
</table> </table>
</div> </div>
<div class="pagination" v-if="totalPages > 1"> <!-- Pagination -->
<button v-for="p in totalPages" :key="p" :class="{ active: p === page }" @click="goToPage(p)"> <PaginationBar
{{ p }} :page="page"
</button> :totalPages="totalPages"
</div> :perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template> </template>
</div> </div>
@@ -115,11 +118,13 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { operatingsystemsApi } from '../../api' import { operatingsystemsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const items = ref([]) const items = ref([])
const loading = ref(true) const loading = ref(true)
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false) const showModal = ref(false)
const editing = ref(null) const editing = ref(null)
@@ -136,9 +141,9 @@ onMounted(() => loadData())
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const response = await operatingsystemsApi.list({ page: page.value, perpage: 20 }) const response = await operatingsystemsApi.list({ page: page.value, perpage: perPage.value })
items.value = response.data.data || [] items.value = response.data.data || []
totalPages.value = response.data.meta?.pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) { } catch (err) {
console.error('Error loading operating systems:', err) console.error('Error loading operating systems:', err)
} finally { } finally {
@@ -148,6 +153,12 @@ async function loadData() {
function goToPage(p) { page.value = p; loadData() } function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function isPastEol(date) { function isPastEol(date) {
return new Date(date) < new Date() return new Date(date) < new Date()
} }

View File

@@ -36,11 +36,14 @@
</table> </table>
</div> </div>
<div class="pagination" v-if="totalPages > 1"> <!-- Pagination -->
<button v-for="p in totalPages" :key="p" :class="{ active: p === page }" @click="goToPage(p)"> <PaginationBar
{{ p }} :page="page"
</button> :totalPages="totalPages"
</div> :perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template> </template>
</div> </div>
@@ -91,11 +94,13 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { pctypesApi } from '../../api' import { pctypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const pcTypes = ref([]) const pcTypes = ref([])
const loading = ref(true) const loading = ref(true)
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false) const showModal = ref(false)
const editing = ref(null) const editing = ref(null)
@@ -112,9 +117,9 @@ onMounted(() => loadData())
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const response = await pctypesApi.list({ page: page.value, perpage: 20 }) const response = await pctypesApi.list({ page: page.value, perpage: perPage.value })
pcTypes.value = response.data.data || [] pcTypes.value = response.data.data || []
totalPages.value = response.data.meta?.pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) { } catch (err) {
console.error('Error loading PC types:', err) console.error('Error loading PC types:', err)
} finally { } finally {
@@ -124,6 +129,12 @@ async function loadData() {
function goToPage(p) { page.value = p; loadData() } function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function openModal(item = null) { function openModal(item = null) {
editing.value = item editing.value = item
form.value = item ? { pctype: item.pctype || '', description: item.description || '' } : { pctype: '', description: '' } form.value = item ? { pctype: item.pctype || '', description: item.description || '' } : { pctype: '', description: '' }

View File

@@ -4,61 +4,61 @@
<div class="settings-grid"> <div class="settings-grid">
<router-link to="/settings/vendors" class="settings-card"> <router-link to="/settings/vendors" class="settings-card">
<div class="card-icon">🏭</div> <div class="card-icon"><Factory :size="28" /></div>
<h3>Vendors</h3> <h3>Vendors</h3>
<p>Manage equipment vendors and manufacturers</p> <p>Manage equipment vendors and manufacturers</p>
</router-link> </router-link>
<router-link to="/settings/locations" class="settings-card"> <router-link to="/settings/locations" class="settings-card">
<div class="card-icon">📍</div> <div class="card-icon"><MapPin :size="28" /></div>
<h3>Locations</h3> <h3>Locations</h3>
<p>Manage physical locations and sites</p> <p>Manage physical locations and sites</p>
</router-link> </router-link>
<router-link to="/settings/statuses" class="settings-card"> <router-link to="/settings/statuses" class="settings-card">
<div class="card-icon">🏷</div> <div class="card-icon"><Tag :size="28" /></div>
<h3>Statuses</h3> <h3>Statuses</h3>
<p>Manage equipment status types</p> <p>Manage equipment status types</p>
</router-link> </router-link>
<router-link to="/settings/models" class="settings-card"> <router-link to="/settings/models" class="settings-card">
<div class="card-icon">📦</div> <div class="card-icon"><Package :size="28" /></div>
<h3>Models</h3> <h3>Models</h3>
<p>Manage equipment models by vendor</p> <p>Manage equipment models by vendor</p>
</router-link> </router-link>
<router-link to="/settings/machinetypes" class="settings-card"> <router-link to="/settings/machinetypes" class="settings-card">
<div class="card-icon">🖥</div> <div class="card-icon"><Monitor :size="28" /></div>
<h3>Machine Types</h3> <h3>Machine Types</h3>
<p>Manage machine type categories</p> <p>Manage machine type categories</p>
</router-link> </router-link>
<router-link to="/settings/pctypes" class="settings-card"> <router-link to="/settings/pctypes" class="settings-card">
<div class="card-icon">💻</div> <div class="card-icon"><Laptop :size="28" /></div>
<h3>PC Types</h3> <h3>PC Types</h3>
<p>Manage PC form factors</p> <p>Manage PC form factors</p>
</router-link> </router-link>
<router-link to="/settings/operatingsystems" class="settings-card"> <router-link to="/settings/operatingsystems" class="settings-card">
<div class="card-icon"></div> <div class="card-icon"><Cog :size="28" /></div>
<h3>Operating Systems</h3> <h3>Operating Systems</h3>
<p>Manage OS versions and EOL dates</p> <p>Manage OS versions and EOL dates</p>
</router-link> </router-link>
<router-link to="/settings/businessunits" class="settings-card"> <router-link to="/settings/businessunits" class="settings-card">
<div class="card-icon">🏢</div> <div class="card-icon"><Building :size="28" /></div>
<h3>Business Units</h3> <h3>Business Units</h3>
<p>Manage organizational units</p> <p>Manage organizational units</p>
</router-link> </router-link>
<router-link to="/settings/vlans" class="settings-card"> <router-link to="/settings/vlans" class="settings-card">
<div class="card-icon">🌐</div> <div class="card-icon"><Globe :size="28" /></div>
<h3>VLANs</h3> <h3>VLANs</h3>
<p>Manage virtual LANs</p> <p>Manage virtual LANs</p>
</router-link> </router-link>
<router-link to="/settings/subnets" class="settings-card"> <router-link to="/settings/subnets" class="settings-card">
<div class="card-icon">🔗</div> <div class="card-icon"><Link :size="28" /></div>
<h3>Subnets</h3> <h3>Subnets</h3>
<p>Manage IP subnets and DHCP</p> <p>Manage IP subnets and DHCP</p>
</router-link> </router-link>
@@ -67,6 +67,7 @@
</template> </template>
<script setup> <script setup>
import { Factory, MapPin, Tag, Package, Monitor, Laptop, Cog, Building, Globe, Link } from 'lucide-vue-next'
</script> </script>
<style scoped> <style scoped>

View File

@@ -56,16 +56,13 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="totalPages > 1"> <PaginationBar
<button :page="page"
v-for="p in totalPages" :totalPages="totalPages"
:key="p" :perPage="perPage"
:class="{ active: p === page }" @update:page="goToPage"
@click="goToPage(p)" @update:perPage="changePerPage"
> />
{{ p }}
</button>
</div>
</template> </template>
</div> </div>
@@ -153,11 +150,13 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { statusesApi } from '../../api' import { statusesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const statuses = ref([]) const statuses = ref([])
const loading = ref(true) const loading = ref(true)
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false) const showModal = ref(false)
const editingStatus = ref(null) const editingStatus = ref(null)
@@ -182,12 +181,12 @@ async function loadStatuses() {
try { try {
const params = { const params = {
page: page.value, page: page.value,
perpage: 20 perpage: perPage.value
} }
const response = await statusesApi.list(params) const response = await statusesApi.list(params)
statuses.value = response.data.data || [] statuses.value = response.data.data || []
totalPages.value = response.data.meta?.pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) { } catch (err) {
console.error('Error loading statuses:', err) console.error('Error loading statuses:', err)
} finally { } finally {
@@ -200,6 +199,12 @@ function goToPage(p) {
loadStatuses() loadStatuses()
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadStatuses()
}
function openModal(s = null) { function openModal(s = null) {
editingStatus.value = s editingStatus.value = s
if (s) { if (s) {

View File

@@ -49,8 +49,8 @@
<td class="mono">{{ subnet.cidr }}</td> <td class="mono">{{ subnet.cidr }}</td>
<td>{{ subnet.name }}</td> <td>{{ subnet.name }}</td>
<td> <td>
<span v-if="subnet.vlan_name"> <span v-if="subnet.vlanname">
VLAN {{ subnet.vlan_number }} - {{ subnet.vlan_name }} VLAN {{ subnet.vlannumber }} - {{ subnet.vlanname }}
</span> </span>
<span v-else>-</span> <span v-else>-</span>
</td> </td>
@@ -60,7 +60,7 @@
{{ subnet.dhcpenabled ? 'Enabled' : 'Disabled' }} {{ subnet.dhcpenabled ? 'Enabled' : 'Disabled' }}
</span> </span>
</td> </td>
<td>{{ subnet.location_name || '-' }}</td> <td>{{ subnet.locationname || '-' }}</td>
<td class="actions"> <td class="actions">
<button <button
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"
@@ -86,16 +86,13 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="totalPages > 1"> <PaginationBar
<button :page="page"
v-for="p in totalPages" :totalPages="totalPages"
:key="p" :perPage="perPage"
:class="{ active: p === page }" @update:page="goToPage"
@click="goToPage(p)" @update:perPage="changePerPage"
> />
{{ p }}
</button>
</div>
</template> </template>
</div> </div>
@@ -287,6 +284,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { networkApi, locationsApi } from '../../api' import { networkApi, locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const route = useRoute() const route = useRoute()
@@ -299,6 +297,7 @@ const vlanFilter = ref('')
const typeFilter = ref('') const typeFilter = ref('')
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false) const showModal = ref(false)
const editingSubnet = ref(null) const editingSubnet = ref(null)
@@ -341,7 +340,7 @@ onMounted(async () => {
async function loadVLANs() { async function loadVLANs() {
try { try {
const response = await networkApi.vlans.list({ per_page: 100 }) const response = await networkApi.vlans.list({ perpage: 100 })
vlans.value = response.data.data || [] vlans.value = response.data.data || []
} catch (err) { } catch (err) {
console.error('Error loading VLANs:', err) console.error('Error loading VLANs:', err)
@@ -350,7 +349,7 @@ async function loadVLANs() {
async function loadLocations() { async function loadLocations() {
try { try {
const response = await locationsApi.list({ per_page: 100 }) const response = await locationsApi.list({ perpage: 100 })
locations.value = response.data.data || [] locations.value = response.data.data || []
} catch (err) { } catch (err) {
console.error('Error loading locations:', err) console.error('Error loading locations:', err)
@@ -362,7 +361,7 @@ async function loadSubnets() {
try { try {
const params = { const params = {
page: page.value, page: page.value,
per_page: 20 perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
if (vlanFilter.value) params.vlanid = vlanFilter.value if (vlanFilter.value) params.vlanid = vlanFilter.value
@@ -370,7 +369,7 @@ async function loadSubnets() {
const response = await networkApi.subnets.list(params) const response = await networkApi.subnets.list(params)
subnets.value = response.data.data || [] subnets.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) { } catch (err) {
console.error('Error loading subnets:', err) console.error('Error loading subnets:', err)
} finally { } finally {
@@ -391,6 +390,12 @@ function goToPage(p) {
loadSubnets() loadSubnets()
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadSubnets()
}
function openModal(subnet = null) { function openModal(subnet = null) {
editingSubnet.value = subnet editingSubnet.value = subnet
if (subnet) { if (subnet) {

View File

@@ -84,16 +84,13 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="totalPages > 1"> <PaginationBar
<button :page="page"
v-for="p in totalPages" :totalPages="totalPages"
:key="p" :perPage="perPage"
:class="{ active: p === page }" @update:page="goToPage"
@click="goToPage(p)" @update:perPage="changePerPage"
> />
{{ p }}
</button>
</div>
</template> </template>
</div> </div>
@@ -189,6 +186,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { networkApi } from '../../api' import { networkApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const vlans = ref([]) const vlans = ref([])
const loading = ref(true) const loading = ref(true)
@@ -196,6 +194,7 @@ const search = ref('')
const typeFilter = ref('') const typeFilter = ref('')
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false) const showModal = ref(false)
const editingVLAN = ref(null) const editingVLAN = ref(null)
@@ -223,14 +222,14 @@ async function loadVLANs() {
try { try {
const params = { const params = {
page: page.value, page: page.value,
per_page: 20 perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
if (typeFilter.value) params.type = typeFilter.value if (typeFilter.value) params.type = typeFilter.value
const response = await networkApi.vlans.list(params) const response = await networkApi.vlans.list(params)
vlans.value = response.data.data || [] vlans.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) { } catch (err) {
console.error('Error loading VLANs:', err) console.error('Error loading VLANs:', err)
} finally { } finally {
@@ -251,6 +250,12 @@ function goToPage(p) {
loadVLANs() loadVLANs()
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadVLANs()
}
function getTypeClass(type) { function getTypeClass(type) {
switch (type) { switch (type) {
case 'data': return 'badge-info' case 'data': return 'badge-info'

View File

@@ -17,8 +17,8 @@
<h1>{{ device.alias || device.machinenumber }}</h1> <h1>{{ device.alias || device.machinenumber }}</h1>
</div> </div>
<div class="hero-meta"> <div class="hero-meta">
<span class="badge badge-lg" :class="device.is_checked_out ? 'badge-warning' : 'badge-success'"> <span class="badge badge-lg" :class="device.ischeckedout ? 'badge-warning' : 'badge-success'">
{{ device.is_checked_out ? 'Checked Out' : 'Available' }} {{ device.ischeckedout ? 'Checked Out' : 'Available' }}
</span> </span>
</div> </div>
<div class="hero-details"> <div class="hero-details">
@@ -26,13 +26,13 @@
<span class="hero-detail-label">Serial Number</span> <span class="hero-detail-label">Serial Number</span>
<span class="hero-detail-value mono">{{ device.serialnumber }}</span> <span class="hero-detail-value mono">{{ device.serialnumber }}</span>
</div> </div>
<div class="hero-detail" v-if="device.vendor_name"> <div class="hero-detail" v-if="device.vendorname">
<span class="hero-detail-label">Vendor</span> <span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ device.vendor_name }}</span> <span class="hero-detail-value">{{ device.vendorname }}</span>
</div> </div>
<div class="hero-detail" v-if="device.model_name"> <div class="hero-detail" v-if="device.modelname">
<span class="hero-detail-label">Model</span> <span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ device.model_name }}</span> <span class="hero-detail-value">{{ device.modelname }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -41,7 +41,7 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="action-buttons"> <div class="action-buttons">
<button <button
v-if="!device.is_checked_out" v-if="!device.ischeckedout"
class="btn btn-primary btn-lg" class="btn btn-primary btn-lg"
@click="openCheckoutModal" @click="openCheckoutModal"
> >
@@ -57,20 +57,20 @@
</div> </div>
<!-- Current Checkout Info --> <!-- Current Checkout Info -->
<div class="section-card" v-if="device.current_checkout"> <div class="section-card" v-if="device.currentcheckout">
<h3 class="section-title">Current Checkout</h3> <h3 class="section-title">Current Checkout</h3>
<div class="info-list"> <div class="info-list">
<div class="info-row"> <div class="info-row">
<span class="info-label">Checked Out By</span> <span class="info-label">Checked Out By</span>
<span class="info-value">{{ device.current_checkout.sso }}</span> <span class="info-value">{{ device.currentcheckout.sso }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Checkout Time</span> <span class="info-label">Checkout Time</span>
<span class="info-value">{{ formatDate(device.current_checkout.checkout_time) }}</span> <span class="info-value">{{ formatDate(device.currentcheckout.checkouttime) }}</span>
</div> </div>
<div class="info-row" v-if="device.current_checkout.checkout_reason"> <div class="info-row" v-if="device.currentcheckout.checkoutreason">
<span class="info-label">Reason</span> <span class="info-label">Reason</span>
<span class="info-value">{{ device.current_checkout.checkout_reason }}</span> <span class="info-value">{{ device.currentcheckout.checkoutreason }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -81,7 +81,7 @@
<h3>Checkout History</h3> <h3>Checkout History</h3>
</div> </div>
<div v-if="!device.checkout_history?.length" class="empty-state"> <div v-if="!device.checkouthistory?.length" class="empty-state">
No checkout history No checkout history
</div> </div>
@@ -97,15 +97,15 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="checkout in device.checkout_history" :key="checkout.checkoutid"> <tr v-for="checkout in device.checkouthistory" :key="checkout.checkoutid">
<td>{{ checkout.sso }}</td> <td>{{ checkout.sso }}</td>
<td>{{ formatDate(checkout.checkout_time) }}</td> <td>{{ formatDate(checkout.checkouttime) }}</td>
<td>{{ checkout.checkin_time ? formatDate(checkout.checkin_time) : 'Still out' }}</td> <td>{{ checkout.checkintime ? formatDate(checkout.checkintime) : 'Still out' }}</td>
<td> <td>
<span v-if="checkout.checkin_time">{{ checkout.was_wiped ? 'Yes' : 'No' }}</span> <span v-if="checkout.checkintime">{{ checkout.waswiped ? 'Yes' : 'No' }}</span>
<span v-else>-</span> <span v-else>-</span>
</td> </td>
<td>{{ checkout.checkout_reason || '-' }}</td> <td>{{ checkout.checkoutreason || '-' }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -146,7 +146,7 @@
<template #body> <template #body>
<div class="form-group"> <div class="form-group">
<label class="checkbox-label"> <label class="checkbox-label">
<input type="checkbox" v-model="checkinForm.was_wiped" /> <input type="checkbox" v-model="checkinForm.waswiped" />
Device was wiped Device was wiped
</label> </label>
</div> </div>
@@ -177,7 +177,7 @@ const device = ref(null)
const showCheckoutModal = ref(false) const showCheckoutModal = ref(false)
const showCheckinModal = ref(false) const showCheckinModal = ref(false)
const checkoutForm = ref({ sso: '', reason: '' }) const checkoutForm = ref({ sso: '', reason: '' })
const checkinForm = ref({ was_wiped: false, notes: '' }) const checkinForm = ref({ waswiped: false, notes: '' })
onMounted(async () => { onMounted(async () => {
await loadDevice() await loadDevice()
@@ -201,7 +201,7 @@ function openCheckoutModal() {
} }
function openCheckinModal() { function openCheckinModal() {
checkinForm.value = { was_wiped: false, notes: '' } checkinForm.value = { waswiped: false, notes: '' }
showCheckinModal.value = true showCheckinModal.value = true
} }

View File

@@ -150,7 +150,7 @@ onMounted(async () => {
// Load types and vendors // Load types and vendors
const [typesRes, vendorsRes] = await Promise.all([ const [typesRes, vendorsRes] = await Promise.all([
usbApi.types.list(), usbApi.types.list(),
vendorsApi.list({ per_page: 1000 }) vendorsApi.list({ perpage: 1000 })
]) ])
types.value = typesRes.data.data || [] types.value = typesRes.data.data || []
vendors.value = vendorsRes.data.data || [] vendors.value = vendorsRes.data.data || []

View File

@@ -2,6 +2,7 @@
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>USB Devices</h2> <h2>USB Devices</h2>
<router-link to="/print/usb-labels" class="btn btn-secondary" target="_blank">Print Labels</router-link>
</div> </div>
<!-- Filters --> <!-- Filters -->
@@ -38,24 +39,24 @@
<tr v-for="device in devices" :key="device.machineid"> <tr v-for="device in devices" :key="device.machineid">
<td> <td>
<strong>{{ device.alias || device.machinenumber }}</strong> <strong>{{ device.alias || device.machinenumber }}</strong>
<div v-if="device.model_name" class="text-muted">{{ device.model_name }}</div> <div v-if="device.modelname" class="text-muted">{{ device.modelname }}</div>
</td> </td>
<td class="mono">{{ device.serialnumber || '-' }}</td> <td class="mono">{{ device.serialnumber || '-' }}</td>
<td> <td>
<span class="badge" :class="device.is_checked_out ? 'badge-warning' : 'badge-success'"> <span class="badge" :class="device.ischeckedout ? 'badge-warning' : 'badge-success'">
{{ device.is_checked_out ? 'Checked Out' : 'Available' }} {{ device.ischeckedout ? 'Checked Out' : 'Available' }}
</span> </span>
</td> </td>
<td> <td>
<template v-if="device.current_checkout"> <template v-if="device.currentcheckout">
{{ device.current_checkout.checkout_name || device.current_checkout.sso }} {{ device.currentcheckout.checkoutname || device.currentcheckout.sso }}
<div class="text-muted">{{ formatDate(device.current_checkout.checkout_time) }}</div> <div class="text-muted">{{ formatDate(device.currentcheckout.checkouttime) }}</div>
</template> </template>
<span v-else>-</span> <span v-else>-</span>
</td> </td>
<td class="actions"> <td class="actions">
<button <button
v-if="!device.is_checked_out" v-if="!device.ischeckedout"
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"
@click="openCheckoutModal(device)" @click="openCheckoutModal(device)"
> >
@@ -86,16 +87,13 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="totalPages > 1"> <PaginationBar
<button :page="page"
v-for="p in totalPages" :totalPages="totalPages"
:key="p" :perPage="perPage"
:class="{ active: p === page }" @update:page="goToPage"
@click="goToPage(p)" @update:perPage="changePerPage"
> />
{{ p }}
</button>
</div>
</template> </template>
</div> </div>
@@ -127,7 +125,7 @@
<p>Checking in: <strong>{{ selectedDevice?.alias || selectedDevice?.machinenumber }}</strong></p> <p>Checking in: <strong>{{ selectedDevice?.alias || selectedDevice?.machinenumber }}</strong></p>
<div class="form-group"> <div class="form-group">
<label class="checkbox-label"> <label class="checkbox-label">
<input type="checkbox" v-model="checkinForm.was_wiped" /> <input type="checkbox" v-model="checkinForm.waswiped" />
Device was wiped Device was wiped
</label> </label>
</div> </div>
@@ -148,19 +146,21 @@ import { ref, onMounted } from 'vue'
import { usbApi } from '../../api' import { usbApi } from '../../api'
import Modal from '../../components/Modal.vue' import Modal from '../../components/Modal.vue'
import EmployeeSearch from '../../components/EmployeeSearch.vue' import EmployeeSearch from '../../components/EmployeeSearch.vue'
import PaginationBar from '../../components/PaginationBar.vue'
const devices = ref([]) const devices = ref([])
const loading = ref(true) const loading = ref(true)
const search = ref('') const search = ref('')
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20)
const showAvailableOnly = ref(false) const showAvailableOnly = ref(false)
const showCheckoutModal = ref(false) const showCheckoutModal = ref(false)
const showCheckinModal = ref(false) const showCheckinModal = ref(false)
const selectedDevice = ref(null) const selectedDevice = ref(null)
const checkoutForm = ref({ reason: '' }) const checkoutForm = ref({ reason: '' })
const checkinForm = ref({ was_wiped: false, notes: '' }) const checkinForm = ref({ waswiped: false, notes: '' })
const selectedEmployee = ref(null) const selectedEmployee = ref(null)
let searchTimeout = null let searchTimeout = null
@@ -174,14 +174,14 @@ async function loadDevices() {
try { try {
const params = { const params = {
page: page.value, page: page.value,
perpage: 20 perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
if (showAvailableOnly.value) params.available = 'true' if (showAvailableOnly.value) params.available = 'true'
const response = await usbApi.list(params) const response = await usbApi.list(params)
devices.value = response.data.data || [] devices.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading USB devices:', error) console.error('Error loading USB devices:', error)
} finally { } finally {
@@ -202,6 +202,12 @@ function goToPage(p) {
loadDevices() loadDevices()
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadDevices()
}
function openCheckoutModal(device) { function openCheckoutModal(device) {
selectedDevice.value = device selectedDevice.value = device
checkoutForm.value = { reason: '' } checkoutForm.value = { reason: '' }
@@ -211,7 +217,7 @@ function openCheckoutModal(device) {
function openCheckinModal(device) { function openCheckinModal(device) {
selectedDevice.value = device selectedDevice.value = device
checkinForm.value = { was_wiped: false, notes: '' } checkinForm.value = { waswiped: false, notes: '' }
showCheckinModal.value = true showCheckinModal.value = true
} }

View File

@@ -62,16 +62,13 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="totalPages > 1"> <PaginationBar
<button :page="page"
v-for="p in totalPages" :totalPages="totalPages"
:key="p" :perPage="perPage"
:class="{ active: p === page }" @update:page="goToPage"
@click="goToPage(p)" @update:perPage="changePerPage"
> />
{{ p }}
</button>
</div>
</template> </template>
</div> </div>
@@ -188,12 +185,14 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { vendorsApi } from '../../api' import { vendorsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const vendors = ref([]) const vendors = ref([])
const loading = ref(true) const loading = ref(true)
const search = ref('') const search = ref('')
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false) const showModal = ref(false)
const editingVendor = ref(null) const editingVendor = ref(null)
@@ -224,13 +223,13 @@ async function loadVendors() {
try { try {
const params = { const params = {
page: page.value, page: page.value,
perpage: 20 perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
const response = await vendorsApi.list(params) const response = await vendorsApi.list(params)
vendors.value = response.data.data || [] vendors.value = response.data.data || []
totalPages.value = response.data.meta?.pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) { } catch (err) {
console.error('Error loading vendors:', err) console.error('Error loading vendors:', err)
} finally { } finally {
@@ -251,6 +250,12 @@ function goToPage(p) {
loadVendors() loadVendors()
} }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadVendors()
}
function openModal(vendor = null) { function openModal(vendor = null) {
editingVendor.value = vendor editingVendor.value = vendor
if (vendor) { if (vendor) {

View File

@@ -0,0 +1,321 @@
# ShopDB Flask Data Migration Guide
## Overview
This document describes how to migrate data from the legacy `shopdb` database to the new `shopdb_flask` database schema.
## Database Configuration
**Development:**
- Legacy database: `shopdb` (Classic ASP/VBScript schema)
- New database: `shopdb_flask` (Flask/SQLAlchemy schema)
- Connection: `mysql+pymysql://root:rootpassword@127.0.0.1:3306/shopdb_flask`
**Production:**
- Follow the same migration steps on production MySQL server
- Update connection string in `.env` accordingly
## Schema Differences
### Legacy Schema (shopdb)
- `machines` table holds ALL assets (equipment, PCs, network devices)
- `printers` table is separate
- No unified asset abstraction
### New Schema (shopdb_flask)
- `assets` table: Core asset data (shared fields)
- `assettypes` table: Asset category registry
- Plugin extension tables:
- `equipment` - Manufacturing equipment details
- `computers` - PC-specific fields
- `networkdevices` - Network device details
- `printers` - Printer-specific fields
- Each extension links to `assets` via `assetid`
## Migration Steps
### Step 1: Seed Reference Data
```bash
cd /home/camp/projects/shopdb-flask
source venv/bin/activate
flask seed reference-data
```
This creates:
- Asset types (equipment, computer, network_device, printer)
- Asset statuses (In Use, Spare, Retired, etc.)
- Machine types, operating systems, relationship types
### Step 2: Migrate Asset Types
```sql
-- Insert asset types if not exists
INSERT INTO assettypes (assettype, pluginname, tablename, description) VALUES
('equipment', 'equipment', 'equipment', 'Manufacturing equipment'),
('computer', 'computers', 'computers', 'PCs and workstations'),
('network_device', 'network', 'networkdevices', 'Network infrastructure'),
('printer', 'printers', 'printers', 'Printers and MFPs')
ON DUPLICATE KEY UPDATE assettype=assettype;
```
### Step 3: Migrate Equipment
```sql
-- Migrate equipment from legacy machines table
INSERT INTO assets (assetid, assetnumber, name, serialnumber, assettypeid, statusid,
locationid, businessunitid, mapleft, maptop, notes,
createddate, modifieddate, isactive)
SELECT
m.machineid,
m.machinenumber,
m.alias,
m.serialnumber,
(SELECT assettypeid FROM assettypes WHERE assettype = 'equipment'),
m.statusid,
m.locationid,
m.businessunitid,
m.mapleft,
m.maptop,
m.notes,
m.createddate,
m.modifieddate,
m.isactive
FROM shopdb.machines m
JOIN shopdb.machinetypes mt ON m.machinetypeid = mt.machinetypeid
WHERE mt.category = 'Equipment'
AND m.pctypeid IS NULL;
-- Insert equipment extension data
INSERT INTO equipment (assetid, equipmenttypeid, vendorid, modelnumberid,
controllertypeid, controllervendorid, controllermodelid)
SELECT
m.machineid,
m.machinetypeid,
m.vendorid,
m.modelnumberid,
m.controllertypeid,
m.controllervendorid,
m.controllermodelid
FROM shopdb.machines m
JOIN shopdb.machinetypes mt ON m.machinetypeid = mt.machinetypeid
WHERE mt.category = 'Equipment'
AND m.pctypeid IS NULL;
```
### Step 4: Migrate PCs/Computers
```sql
-- Migrate PCs to assets table
INSERT INTO assets (assetid, assetnumber, name, serialnumber, assettypeid, statusid,
locationid, businessunitid, mapleft, maptop, notes,
createddate, modifieddate, isactive)
SELECT
m.machineid,
m.machinenumber,
m.alias,
m.serialnumber,
(SELECT assettypeid FROM assettypes WHERE assettype = 'computer'),
m.statusid,
m.locationid,
m.businessunitid,
m.mapleft,
m.maptop,
m.notes,
m.createddate,
m.modifieddate,
m.isactive
FROM shopdb.machines m
WHERE m.pctypeid IS NOT NULL;
-- Insert computer extension data
INSERT INTO computers (assetid, computertypeid, hostname, osid,
loggedinuser, lastreporteddate, lastboottime,
isvnc, iswinrm, isshopfloor)
SELECT
m.machineid,
m.pctypeid,
m.hostname,
m.osid,
m.loggedinuser,
m.lastreporteddate,
m.lastboottime,
m.isvnc,
m.iswinrm,
m.isshopfloor
FROM shopdb.machines m
WHERE m.pctypeid IS NOT NULL;
```
### Step 5: Migrate Network Devices
```sql
-- Migrate network devices to assets
INSERT INTO assets (assetid, assetnumber, name, serialnumber, assettypeid, statusid,
locationid, businessunitid, mapleft, maptop, notes,
createddate, modifieddate, isactive)
SELECT
m.machineid,
m.machinenumber,
m.alias,
m.serialnumber,
(SELECT assettypeid FROM assettypes WHERE assettype = 'network_device'),
m.statusid,
m.locationid,
m.businessunitid,
m.mapleft,
m.maptop,
m.notes,
m.createddate,
m.modifieddate,
m.isactive
FROM shopdb.machines m
JOIN shopdb.machinetypes mt ON m.machinetypeid = mt.machinetypeid
WHERE mt.category = 'Network';
-- Insert network device extension data
INSERT INTO networkdevices (assetid, networkdevicetypeid, hostname, vendorid, modelnumberid)
SELECT
m.machineid,
m.machinetypeid,
m.hostname,
m.vendorid,
m.modelnumberid
FROM shopdb.machines m
JOIN shopdb.machinetypes mt ON m.machinetypeid = mt.machinetypeid
WHERE mt.category = 'Network';
```
### Step 6: Migrate Printers
```sql
-- Migrate printers to assets (printers are in separate table in legacy)
INSERT INTO assets (assetnumber, name, serialnumber, assettypeid, statusid,
locationid, businessunitid, notes, createddate, modifieddate, isactive)
SELECT
p.hostname,
p.windowsname,
NULL,
(SELECT assettypeid FROM assettypes WHERE assettype = 'printer'),
1, -- Default status
p.locationid,
p.businessunitid,
NULL,
p.createddate,
p.modifieddate,
p.isactive
FROM shopdb.printers p;
-- Insert printer extension data (need to get the new assetid)
INSERT INTO printers (assetid, printertypeid, vendorid, modelnumberid, hostname,
windowsname, sharename, iscsf, installpath, pin,
iscolor, isduplex, isnetwork)
SELECT
a.assetid,
p.printertypeid,
p.vendorid,
p.modelnumberid,
p.hostname,
p.windowsname,
p.sharename,
p.iscsf,
p.installpath,
p.pin,
p.iscolor,
p.isduplex,
p.isnetwork
FROM shopdb.printers p
JOIN assets a ON a.assetnumber = p.hostname
WHERE a.assettypeid = (SELECT assettypeid FROM assettypes WHERE assettype = 'printer');
```
### Step 7: Migrate Communications (IP Addresses)
```sql
-- Migrate communications/IP addresses
INSERT INTO communications (machineid, assetid, comtypeid, address,
subnetid, isprimary, createddate, modifieddate, isactive)
SELECT
c.machineid,
c.machineid, -- assetid = machineid for migrated assets
c.comtypeid,
c.address,
c.subnetid,
c.isprimary,
c.createddate,
c.modifieddate,
c.isactive
FROM shopdb.communications c;
```
### Step 8: Migrate Notifications
```sql
INSERT INTO notifications (notificationid, notificationtypeid, businessunitid, appid,
notification, starttime, endtime, ticketnumber, link,
isactive, isshopfloor, employeesso, employeename)
SELECT * FROM shopdb.notifications;
```
### Step 9: Migrate Supporting Tables
```sql
-- Vendors
INSERT INTO vendors SELECT * FROM shopdb.vendors
ON DUPLICATE KEY UPDATE vendor=VALUES(vendor);
-- Models
INSERT INTO models SELECT * FROM shopdb.models
ON DUPLICATE KEY UPDATE modelnumber=VALUES(modelnumber);
-- Locations
INSERT INTO locations SELECT * FROM shopdb.locations
ON DUPLICATE KEY UPDATE locationname=VALUES(locationname);
-- Business Units
INSERT INTO businessunits SELECT * FROM shopdb.businessunits
ON DUPLICATE KEY UPDATE businessunit=VALUES(businessunit);
-- Subnets
INSERT INTO subnets SELECT * FROM shopdb.subnets
ON DUPLICATE KEY UPDATE subnet=VALUES(subnet);
```
## Verification Queries
```sql
-- Check migration counts
SELECT 'Legacy machines' as source, COUNT(*) as cnt FROM shopdb.machines
UNION ALL SELECT 'New assets', COUNT(*) FROM shopdb_flask.assets
UNION ALL SELECT 'Equipment', COUNT(*) FROM shopdb_flask.equipment
UNION ALL SELECT 'Computers', COUNT(*) FROM shopdb_flask.computers
UNION ALL SELECT 'Network devices', COUNT(*) FROM shopdb_flask.networkdevices
UNION ALL SELECT 'Printers', COUNT(*) FROM shopdb_flask.printers;
-- Verify asset type distribution
SELECT at.assettype, COUNT(a.assetid) as count
FROM shopdb_flask.assets a
JOIN shopdb_flask.assettypes at ON a.assettypeid = at.assettypeid
GROUP BY at.assettype;
```
## Production Deployment Notes
1. **Backup production database first**
2. Create `shopdb_flask` database on production
3. Run `flask db-utils create-all` to create schema
4. Execute migration SQL scripts in order
5. Verify data counts match
6. Update Flask `.env` to point to production database
7. Restart Flask services
## Rollback
If migration fails:
1. Drop all tables in `shopdb_flask`: `flask db-utils drop-all`
2. Recreate schema: `flask db-utils create-all`
3. Investigate and fix migration scripts
4. Re-run migration
---
Last updated: 2026-01-28

View File

@@ -0,0 +1,89 @@
# Fix LocationOnly Equipment Types Migration
## Issue
During the original migration from the VBScript ShopDB site, 110 machines were imported with `LocationOnly` type even though they have models assigned that specify the correct equipment type.
## Root Cause
The migration copied the `machinetypeid` directly from the `machines` table, but many machines had `machinetypeid=1` (LocationOnly) even when they had a model with a proper type.
## Affected Records
- **110 machines** in the `machines` table
- **110 equipment** records in the `equipment` table
## Example
| Machine | Current Type | Should Be | Model |
|---------|-------------|-----------|-------|
| 7502 | LocationOnly | Horizontal Machining Center | a81nx |
| 3108 | LocationOnly | Vertical Lathe | VTM-100 |
| 4001 | LocationOnly | 5-axis Mill | VP9000 |
## Fix SQL
### Step 1: Preview affected records
```sql
-- Preview what will be changed
SELECT
m.machineid,
m.machinenumber,
mt_current.machinetype as current_type,
mt_model.machinetype as correct_type,
mo.modelnumber
FROM machines m
JOIN models mo ON mo.modelnumberid = m.modelnumberid
JOIN machinetypes mt_current ON mt_current.machinetypeid = m.machinetypeid
JOIN machinetypes mt_model ON mt_model.machinetypeid = mo.machinetypeid
WHERE m.machinetypeid = 1 -- Currently LocationOnly
AND mo.machinetypeid != 1; -- Model has a real type
```
### Step 2: Fix the machines table
```sql
-- Update machines table to use the model's machine type
UPDATE machines m
JOIN models mo ON mo.modelnumberid = m.modelnumberid
SET m.machinetypeid = mo.machinetypeid
WHERE m.machinetypeid = 1 -- Currently LocationOnly
AND mo.machinetypeid != 1; -- Model has a real type
```
### Step 3: Fix the equipment table
```sql
-- Update equipment table to match
-- Note: equipmenttypeid values match machinetypeid values
UPDATE equipment e
JOIN assets a ON a.assetid = e.assetid
JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1)
SET e.equipmenttypeid = m.machinetypeid
WHERE e.equipmenttypeid = 1 -- Currently LocationOnly
AND m.machinetypeid != 1; -- Machine now has correct type
```
### Alternative Step 3 (if asset numbers don't match)
```sql
-- Update equipment based on their own model reference
UPDATE equipment e
JOIN models mo ON mo.modelnumberid = e.modelnumberid
SET e.equipmenttypeid = mo.machinetypeid
WHERE e.equipmenttypeid = 1 -- Currently LocationOnly
AND mo.machinetypeid != 1 -- Model has a real type
AND e.modelnumberid IS NOT NULL;
```
## Verification
```sql
-- Count remaining LocationOnly records that should have been fixed
SELECT COUNT(*) as remaining_fixable
FROM machines m
JOIN models mo ON mo.modelnumberid = m.modelnumberid
WHERE m.machinetypeid = 1
AND mo.machinetypeid != 1;
-- Should return 0 after fix
```
## Notes for Future Migrations
1. When importing equipment, always check if the model has a type and use that as the default
2. Only use "LocationOnly" for genuine location markers (rooms, offices) without models
3. Validate after import: any equipment with a model should NOT be LocationOnly
## Date Created
2026-01-27

View File

@@ -0,0 +1,92 @@
# Migrate USB Devices from Equipment Table
## Issue
6 USB devices were incorrectly stored in the `equipment` table instead of the proper `usbdevices` table.
## Status
- [x] `pin` field added to `usbdevices` table for encrypted devices
- [x] `pin` field added to USBDevice model
- [x] USB Device equipment type deactivated (equipmenttypeid=44)
- [ ] Migrate 6 USB devices to usbdevices table
## Affected Records
| Asset Number | Name | Serial Number |
|--------------|------|---------------|
| 82841957-5891 | Green Kingston 64GB | 82841957 |
| 48854302-5892 | Blue Kingston 64GB | 48854302 |
| 75953637-5893 | Blue Kingston 64GB USB 3.0 | 75953637 |
| 41299370-5904 | Lenovo Portable DVD | 41299370 |
| 15492331-5905 | TEAC Portable Floppy Drive | 15492331 |
| 25777358-5906 | Netgear WiFi Adapter | 25777358 |
## Migration SQL
### Step 1: Insert into usbdevices
```sql
INSERT INTO usbdevices (
serialnumber,
label,
assetnumber,
usbdevicetypeid,
storagelocation,
notes,
createddate,
modifieddate,
isactive
)
SELECT
a.serialnumber,
a.name,
a.assetnumber,
NULL, -- Assign proper type later
NULL, -- Storage location TBD
a.notes,
a.createddate,
a.modifieddate,
a.isactive
FROM assets a
JOIN equipment e ON e.assetid = a.assetid
WHERE e.equipmenttypeid = 44; -- USB Device type
```
### Step 2: Delete from equipment table
```sql
DELETE e FROM equipment e
JOIN assets a ON a.assetid = e.assetid
WHERE e.equipmenttypeid = 44;
```
### Step 3: Delete from assets table
```sql
DELETE a FROM assets a
JOIN equipment e ON e.assetid = a.assetid
WHERE e.equipmenttypeid = 44;
-- Note: Run step 2 first since it references assets
```
### Alternative: Manual Migration
Since there are only 6 devices, you may prefer to:
1. Manually create them in the USB Devices section
2. Delete the equipment/asset records
## USB Device Types to Create
```sql
-- Check if types exist, create if needed
INSERT IGNORE INTO usbdevicetypes (typename, description, icon) VALUES
('Flash Drive', 'USB flash drive / thumb drive', 'usb'),
('External HDD', 'External hard disk drive', 'harddisk'),
('External SSD', 'External solid state drive', 'harddisk'),
('Card Reader', 'SD/CF card reader', 'sdcard'),
('Optical Drive', 'External CD/DVD/Blu-ray drive', 'disc'),
('WiFi Adapter', 'USB wireless network adapter', 'wifi'),
('Other', 'Other USB device', 'usb');
```
## Notes
- USB Device equipment type has been deactivated (isactive=0)
- New USB devices should be created in the USB Devices section
- USB devices do NOT need map positions (no mapleft/maptop)
## Date Created
2026-01-27

View File

@@ -0,0 +1,165 @@
# Production Migration Guide
## Overview
This guide documents the process for migrating data from the legacy VBScript ShopDB site to the new Flask-based ShopDB.
## Database Architecture
### Legacy System (VBScript)
- Single `machines` table containing all equipment, PCs, printers
- `machinetypes` table for classification
- `models` table with `machinetypeid` reference
### New System (Flask)
- Core `assets` table (unified asset registry)
- Plugin-specific extension tables:
- `equipment` (equipmenttypeid → equipmenttypes)
- `computers` (computertypeid → computertypes)
- `printers` (printertypeid → printertypes)
- `network_device` (networkdevicetypeid → networkdevicetypes)
## Key Mappings
### Asset Type Mapping
| Legacy Category | New Asset Type | Extension Table |
|-----------------|---------------|-----------------|
| Equipment machines | Equipment | equipment |
| PC/Computer | Computer | computers |
| Printer | Printer | printers |
| Network (IDF, Switch, AP) | Network Device | network_device |
### Type ID Alignment
The `equipmenttypes` table IDs match `machinetypes` IDs for easy migration:
- equipmenttypeid = machinetypeid (where applicable)
## Migration Steps
### Step 1: Export from Legacy Database
```sql
-- Export machines with all related data
SELECT
m.*,
mt.machinetype,
mo.modelnumber,
mo.machinetypeid as model_typeid,
v.vendor,
bu.businessunit,
s.status
FROM machines m
LEFT JOIN machinetypes mt ON mt.machinetypeid = m.machinetypeid
LEFT JOIN models mo ON mo.modelnumberid = m.modelnumberid
LEFT JOIN vendors v ON v.vendorid = m.vendorid
LEFT JOIN businessunits bu ON bu.businessunitid = m.businessunitid
LEFT JOIN statuses s ON s.statusid = m.statusid;
```
### Step 2: Create Assets
For each machine, create an asset record:
```sql
INSERT INTO assets (assetnumber, name, assettypeid, statusid, locationid, businessunitid, mapleft, maptop)
SELECT
CONCAT(machinenumber, '-', machineid), -- Unique asset number
alias,
CASE
WHEN category = 'PC' THEN 2 -- Computer
WHEN category = 'Printer' THEN 4 -- Printer
ELSE 1 -- Equipment
END,
statusid,
locationid,
businessunitid,
mapleft,
maptop
FROM machines;
```
### Step 3: Create Extension Records
```sql
-- For Equipment
INSERT INTO equipment (assetid, equipmenttypeid, vendorid, modelnumberid)
SELECT
a.assetid,
COALESCE(mo.machinetypeid, m.machinetypeid), -- Use model's type if available!
m.vendorid,
m.modelnumberid
FROM machines m
JOIN assets a ON a.assetnumber = CONCAT(m.machinenumber, '-', m.machineid)
LEFT JOIN models mo ON mo.modelnumberid = m.modelnumberid
WHERE m.category = 'Equipment' OR m.category IS NULL;
```
### Step 4: Post-Migration Fixes
#### Fix LocationOnly Equipment Types
Equipment imported with LocationOnly type should inherit type from their model:
```sql
-- Fix equipment that has a model with a proper type
UPDATE equipment e
JOIN assets a ON a.assetid = e.assetid
JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1)
JOIN models mo ON mo.modelnumberid = m.modelnumberid
SET e.equipmenttypeid = mo.machinetypeid
WHERE e.equipmenttypeid = 1 -- LocationOnly
AND mo.machinetypeid != 1; -- Model has real type
```
## Validation Queries
### Check for Orphaned Assets
```sql
SELECT a.* FROM assets a
LEFT JOIN equipment e ON e.assetid = a.assetid
LEFT JOIN computers c ON c.assetid = a.assetid
LEFT JOIN printers p ON p.assetid = a.assetid
WHERE a.assettypeid = 1 -- Equipment type
AND e.assetid IS NULL;
```
### Check LocationOnly with Models
```sql
-- Should return 0 after migration fix
SELECT COUNT(*)
FROM equipment e
JOIN assets a ON a.assetid = e.assetid
JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1)
JOIN models mo ON mo.modelnumberid = m.modelnumberid
WHERE e.equipmenttypeid = 1
AND mo.machinetypeid != 1;
```
### Verify Type Distribution
```sql
SELECT et.equipmenttype, COUNT(*) as count
FROM equipment e
JOIN equipmenttypes et ON et.equipmenttypeid = e.equipmenttypeid
GROUP BY et.equipmenttype
ORDER BY count DESC;
```
## Common Issues
### Issue: Equipment marked as LocationOnly but has model
**Cause**: Migration copied machinetypeid from machines table instead of using model's type
**Fix**: See FIX_LOCATIONONLY_EQUIPMENT_TYPES.md
### Issue: Missing model relationship
**Cause**: Equipment table modelnumberid not populated during migration
**Fix**: Link through machines table using asset number pattern
### Issue: Duplicate asset numbers
**Cause**: Asset number generation didn't account for existing duplicates
**Fix**: Use unique suffix or check before insert
## Scripts Location
- `/migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md` - Fix for LocationOnly type issue
- `/scripts/import_from_mysql.py` - Original import script (may need updates)
- `/scripts/migration/` - Migration utilities
## Notes
- Always backup before running migration fixes
- Test on staging/dev before production
- Verify counts before and after each fix
- Keep legacy `machines` table for reference during transition
## Date Created
2026-01-27

View File

@@ -0,0 +1,23 @@
-- Rename database columns to remove underscores per naming convention
-- Date: 2026-02-03
-- Affects: assettypes, assetrelationships, equipment, usbcheckouts
-- 1. assettypes table
ALTER TABLE assettypes CHANGE COLUMN plugin_name pluginname VARCHAR(100) NULL COMMENT 'Plugin that owns this type';
ALTER TABLE assettypes CHANGE COLUMN table_name tablename VARCHAR(100) NULL COMMENT 'Extension table name for this type';
-- 2. assetrelationships table
ALTER TABLE assetrelationships CHANGE COLUMN source_assetid sourceassetid INT NOT NULL;
ALTER TABLE assetrelationships CHANGE COLUMN target_assetid targetassetid INT NOT NULL;
-- 3. equipment table
ALTER TABLE equipment CHANGE COLUMN controller_vendorid controllervendorid INT NULL COMMENT 'Controller vendor (e.g., FANUC)';
ALTER TABLE equipment CHANGE COLUMN controller_modelid controllermodelid INT NULL COMMENT 'Controller model (e.g., 31B)';
-- 4. usbcheckouts table
ALTER TABLE usbcheckouts CHANGE COLUMN checkout_name checkoutname VARCHAR(100) NULL COMMENT 'Name of user';
ALTER TABLE usbcheckouts CHANGE COLUMN checkout_time checkouttime DATETIME NOT NULL;
ALTER TABLE usbcheckouts CHANGE COLUMN checkin_time checkintime DATETIME NULL;
ALTER TABLE usbcheckouts CHANGE COLUMN checkout_reason checkoutreason TEXT NULL COMMENT 'Reason for checkout';
ALTER TABLE usbcheckouts CHANGE COLUMN checkin_notes checkinnotes TEXT NULL;
ALTER TABLE usbcheckouts CHANGE COLUMN was_wiped waswiped TINYINT(1) NULL COMMENT 'Was device wiped after return';

View File

@@ -621,8 +621,8 @@ def dashboard_summary():
return success_response({ return success_response({
'total': total, 'total': total,
'by_type': [{'type': t, 'count': c} for t, c in by_type], 'bytype': [{'type': t, 'count': c} for t, c in by_type],
'by_os': [{'os': o, 'count': c} for o, c in by_os], 'byos': [{'os': o, 'count': c} for o, c in by_os],
'shopfloor': shopfloor_count, 'shopfloor': shopfloor_count,
'non_shopfloor': total - shopfloor_count 'nonshopfloor': total - shopfloor_count
}) })

View File

@@ -117,9 +117,9 @@ class Computer(BaseModel):
# Add related object names # Add related object names
if self.computertype: if self.computertype:
result['computertype_name'] = self.computertype.computertype result['computertypename'] = self.computertype.computertype
if self.operatingsystem: if self.operatingsystem:
result['os_name'] = self.operatingsystem.osname result['osname'] = self.operatingsystem.osname
return result return result

View File

@@ -78,8 +78,8 @@ class ComputersPlugin(BasePlugin):
if not existing: if not existing:
at = AssetType( at = AssetType(
assettype='computer', assettype='computer',
plugin_name='computers', pluginname='computers',
table_name='computers', tablename='computers',
description='PCs, servers, and workstations', description='PCs, servers, and workstations',
icon='desktop' icon='desktop'
) )
@@ -201,9 +201,9 @@ class ComputersPlugin(BasePlugin):
"""Return navigation menu items.""" """Return navigation menu items."""
return [ return [
{ {
'name': 'Computers', 'name': 'PCs',
'icon': 'desktop', 'icon': 'desktop',
'route': '/computers', 'route': '/pcs',
'position': 15, 'position': 15,
}, },
] ]

View File

@@ -306,8 +306,8 @@ def create_equipment():
lastmaintenancedate=data.get('lastmaintenancedate'), lastmaintenancedate=data.get('lastmaintenancedate'),
nextmaintenancedate=data.get('nextmaintenancedate'), nextmaintenancedate=data.get('nextmaintenancedate'),
maintenanceintervaldays=data.get('maintenanceintervaldays'), maintenanceintervaldays=data.get('maintenanceintervaldays'),
controller_vendorid=data.get('controller_vendorid'), controllervendorid=data.get('controllervendorid'),
controller_modelid=data.get('controller_modelid') controllermodelid=data.get('controllermodelid')
) )
db.session.add(equip) db.session.add(equip)
@@ -358,7 +358,7 @@ def update_equipment(equipment_id: int):
equipment_fields = ['equipmenttypeid', 'vendorid', 'modelnumberid', equipment_fields = ['equipmenttypeid', 'vendorid', 'modelnumberid',
'requiresmanualconfig', 'islocationonly', 'requiresmanualconfig', 'islocationonly',
'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays', 'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays',
'controller_vendorid', 'controller_modelid'] 'controllervendorid', 'controllermodelid']
for key in equipment_fields: for key in equipment_fields:
if key in data: if key in data:
setattr(equip, key, data[key]) setattr(equip, key, data[key])
@@ -427,6 +427,6 @@ def dashboard_summary():
return success_response({ return success_response({
'total': total, 'total': total,
'by_type': [{'type': t, 'count': c} for t, c in by_type], 'bytype': [{'type': t, 'count': c} for t, c in by_type],
'by_status': [{'status': s, 'count': c} for s, c in by_status] 'bystatus': [{'status': s, 'count': c} for s, c in by_status]
}) })

View File

@@ -78,13 +78,13 @@ class Equipment(BaseModel):
maintenanceintervaldays = db.Column(db.Integer, nullable=True) maintenanceintervaldays = db.Column(db.Integer, nullable=True)
# Controller info (for CNC machines) # Controller info (for CNC machines)
controller_vendorid = db.Column( controllervendorid = db.Column(
db.Integer, db.Integer,
db.ForeignKey('vendors.vendorid'), db.ForeignKey('vendors.vendorid'),
nullable=True, nullable=True,
comment='Controller vendor (e.g., FANUC)' comment='Controller vendor (e.g., FANUC)'
) )
controller_modelid = db.Column( controllermodelid = db.Column(
db.Integer, db.Integer,
db.ForeignKey('models.modelnumberid'), db.ForeignKey('models.modelnumberid'),
nullable=True, nullable=True,
@@ -99,8 +99,8 @@ class Equipment(BaseModel):
equipmenttype = db.relationship('EquipmentType', backref='equipment') equipmenttype = db.relationship('EquipmentType', backref='equipment')
vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items') vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items')
model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items') model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items')
controller_vendor = db.relationship('Vendor', foreign_keys=[controller_vendorid], backref='equipment_controllers') controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers')
controller_model = db.relationship('Model', foreign_keys=[controller_modelid], backref='equipment_controller_models') controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models')
__table_args__ = ( __table_args__ = (
db.Index('idx_equipment_type', 'equipmenttypeid'), db.Index('idx_equipment_type', 'equipmenttypeid'),
@@ -116,16 +116,18 @@ class Equipment(BaseModel):
# Add related object names # Add related object names
if self.equipmenttype: if self.equipmenttype:
result['equipmenttype_name'] = self.equipmenttype.equipmenttype result['equipmenttypename'] = self.equipmenttype.equipmenttype
if self.vendor: if self.vendor:
result['vendor_name'] = self.vendor.vendor result['vendorname'] = self.vendor.vendor
if self.model: if self.model:
result['model_name'] = self.model.modelnumber result['modelname'] = self.model.modelnumber
if self.model.imageurl:
result['imageurl'] = self.model.imageurl
# Add controller info # Add controller info
if self.controller_vendor: if self.controllervendor:
result['controller_vendor_name'] = self.controller_vendor.vendor result['controllervendorname'] = self.controllervendor.vendor
if self.controller_model: if self.controllermodel:
result['controller_model_name'] = self.controller_model.modelnumber result['controllermodelname'] = self.controllermodel.modelnumber
return result return result

View File

@@ -79,8 +79,8 @@ class EquipmentPlugin(BasePlugin):
if not existing: if not existing:
at = AssetType( at = AssetType(
assettype='equipment', assettype='equipment',
plugin_name='equipment', pluginname='equipment',
table_name='equipment', tablename='equipment',
description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)', description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)',
icon='cog' icon='cog'
) )
@@ -214,7 +214,7 @@ class EquipmentPlugin(BasePlugin):
{ {
'name': 'Equipment', 'name': 'Equipment',
'icon': 'cog', 'icon': 'cog',
'route': '/equipment', 'route': '/machines',
'position': 10, 'position': 10,
}, },
] ]

View File

@@ -227,7 +227,7 @@ def get_network_device(device_id: int):
) )
result = netdev.asset.to_dict() if netdev.asset else {} result = netdev.asset.to_dict() if netdev.asset else {}
result['network_device'] = netdev.to_dict() result['networkdevice'] = netdev.to_dict()
return success_response(result) return success_response(result)
@@ -246,7 +246,7 @@ def get_network_device_by_asset(asset_id: int):
) )
result = netdev.asset.to_dict() if netdev.asset else {} result = netdev.asset.to_dict() if netdev.asset else {}
result['network_device'] = netdev.to_dict() result['networkdevice'] = netdev.to_dict()
return success_response(result) return success_response(result)
@@ -265,7 +265,7 @@ def get_network_device_by_hostname(hostname: str):
) )
result = netdev.asset.to_dict() if netdev.asset else {} result = netdev.asset.to_dict() if netdev.asset else {}
result['network_device'] = netdev.to_dict() result['networkdevice'] = netdev.to_dict()
return success_response(result) return success_response(result)
@@ -353,7 +353,7 @@ def create_network_device():
db.session.commit() db.session.commit()
result = asset.to_dict() result = asset.to_dict()
result['network_device'] = netdev.to_dict() result['networkdevice'] = netdev.to_dict()
return success_response(result, message='Network device created', http_code=201) return success_response(result, message='Network device created', http_code=201)
@@ -413,7 +413,7 @@ def update_network_device(device_id: int):
db.session.commit() db.session.commit()
result = asset.to_dict() result = asset.to_dict()
result['network_device'] = netdev.to_dict() result['networkdevice'] = netdev.to_dict()
return success_response(result, message='Network device updated') return success_response(result, message='Network device updated')
@@ -479,10 +479,10 @@ def dashboard_summary():
return success_response({ return success_response({
'total': total, 'total': total,
'by_type': [{'type': t, 'count': c} for t, c in by_type], 'bytype': [{'type': t, 'count': c} for t, c in by_type],
'by_vendor': [{'vendor': v, 'count': c} for v, c in by_vendor], 'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
'poe': poe_count, 'poe': poe_count,
'non_poe': total - poe_count 'nonpoe': total - poe_count
}) })

View File

@@ -114,8 +114,8 @@ class NetworkDevice(BaseModel):
# Add related object names # Add related object names
if self.networkdevicetype: if self.networkdevicetype:
result['networkdevicetype_name'] = self.networkdevicetype.networkdevicetype result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype
if self.vendor: if self.vendor:
result['vendor_name'] = self.vendor.vendor result['vendorname'] = self.vendor.vendor
return result return result

View File

@@ -78,8 +78,8 @@ class NetworkPlugin(BasePlugin):
if not existing: if not existing:
at = AssetType( at = AssetType(
assettype='network_device', assettype='network_device',
plugin_name='network', pluginname='network',
table_name='networkdevices', tablename='networkdevices',
description='Network infrastructure devices (switches, APs, cameras, etc.)', description='Network infrastructure devices (switches, APs, cameras, etc.)',
icon='network-wired' icon='network-wired'
) )

View File

@@ -374,7 +374,7 @@ def dashboard_summary():
return success_response({ return success_response({
'active': total_active, 'active': total_active,
'by_type': [{'type': t, 'color': c, 'count': n} for t, c, n in by_type] 'bytype': [{'type': t, 'color': c, 'count': n} for t, c, n in by_type]
}) })

View File

@@ -24,7 +24,7 @@ printers_asset_bp = Blueprint('printers_asset', __name__)
# ============================================================================= # =============================================================================
@printers_asset_bp.route('/types', methods=['GET']) @printers_asset_bp.route('/types', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_printer_types(): def list_printer_types():
"""List all printer types.""" """List all printer types."""
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
@@ -46,7 +46,7 @@ def list_printer_types():
@printers_asset_bp.route('/types/<int:type_id>', methods=['GET']) @printers_asset_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_printer_type(type_id: int): def get_printer_type(type_id: int):
"""Get a single printer type.""" """Get a single printer type."""
t = PrinterType.query.get(type_id) t = PrinterType.query.get(type_id)
@@ -471,6 +471,6 @@ def dashboard_summary():
'online': total, # Placeholder - would need monitoring integration 'online': total, # Placeholder - would need monitoring integration
'lowsupplies': 0, # Placeholder - would need Zabbix integration 'lowsupplies': 0, # Placeholder - would need Zabbix integration
'criticalsupplies': 0, # Placeholder - would need Zabbix integration 'criticalsupplies': 0, # Placeholder - would need Zabbix integration
'by_type': [{'type': t, 'count': c} for t, c in by_type], 'bytype': [{'type': t, 'count': c} for t, c in by_type],
'by_vendor': [{'vendor': v, 'count': c} for v, c in by_vendor], 'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
}) })

View File

@@ -113,10 +113,10 @@ class Printer(BaseModel):
# Add related object names # Add related object names
if self.printertype: if self.printertype:
result['printertype_name'] = self.printertype.printertype result['printertypename'] = self.printertype.printertype
if self.vendor: if self.vendor:
result['vendor_name'] = self.vendor.vendor result['vendorname'] = self.vendor.vendor
if self.model: if self.model:
result['model_name'] = self.model.modelnumber result['modelname'] = self.model.modelnumber
return result return result

View File

@@ -116,8 +116,8 @@ class PrintersPlugin(BasePlugin):
if not existing: if not existing:
at = AssetType( at = AssetType(
assettype='printer', assettype='printer',
plugin_name='printers', pluginname='printers',
table_name='printers', tablename='printers',
description='Printers (laser, inkjet, label, MFP, plotter)', description='Printers (laser, inkjet, label, MFP, plotter)',
icon='printer' icon='printer'
) )

View File

@@ -161,10 +161,10 @@ def get_usb_device(device_id: int):
# Get recent checkout history # Get recent checkout history
checkouts = USBCheckout.query.filter_by( checkouts = USBCheckout.query.filter_by(
usbdeviceid=device_id usbdeviceid=device_id
).order_by(USBCheckout.checkout_time.desc()).limit(20).all() ).order_by(USBCheckout.checkouttime.desc()).limit(20).all()
result = device.to_dict() result = device.to_dict()
result['checkout_history'] = [c.to_dict() for c in checkouts] result['checkouthistory'] = [c.to_dict() for c in checkouts]
return success_response(result) return success_response(result)
@@ -258,16 +258,16 @@ def checkout_device(device_id: int):
usbdeviceid=device_id, usbdeviceid=device_id,
machineid=0, # Legacy field, set to 0 for new checkouts machineid=0, # Legacy field, set to 0 for new checkouts
sso=data['sso'], sso=data['sso'],
checkout_name=data.get('checkout_name'), checkoutname=data.get('checkoutname'),
checkout_time=datetime.utcnow(), checkouttime=datetime.utcnow(),
checkout_reason=data.get('checkout_reason'), checkoutreason=data.get('checkoutreason'),
was_wiped=False waswiped=False
) )
# Update device status # Update device status
device.ischeckedout = True device.ischeckedout = True
device.currentuserid = data['sso'] device.currentuserid = data['sso']
device.currentusername = data.get('checkout_name') device.currentusername = data.get('checkoutname')
device.currentcheckoutdate = datetime.utcnow() device.currentcheckoutdate = datetime.utcnow()
device.modifieddate = datetime.utcnow() device.modifieddate = datetime.utcnow()
@@ -300,15 +300,15 @@ def checkin_device(device_id: int):
# Find active checkout # Find active checkout
active_checkout = USBCheckout.query.filter_by( active_checkout = USBCheckout.query.filter_by(
usbdeviceid=device_id, usbdeviceid=device_id,
checkin_time=None checkintime=None
).first() ).first()
data = request.get_json() or {} data = request.get_json() or {}
if active_checkout: if active_checkout:
active_checkout.checkin_time = datetime.utcnow() active_checkout.checkintime = datetime.utcnow()
active_checkout.checkin_notes = data.get('checkin_notes', active_checkout.checkin_notes) active_checkout.checkinnotes = data.get('checkinnotes', active_checkout.checkinnotes)
active_checkout.was_wiped = data.get('was_wiped', False) active_checkout.waswiped = data.get('waswiped', False)
# Update device status # Update device status
device.ischeckedout = False device.ischeckedout = False
@@ -337,7 +337,7 @@ def get_device_history(device_id: int):
query = USBCheckout.query.filter_by( query = USBCheckout.query.filter_by(
usbdeviceid=device_id usbdeviceid=device_id
).order_by(USBCheckout.checkout_time.desc()) ).order_by(USBCheckout.checkouttime.desc())
items, total = paginate_query(query, page, per_page) items, total = paginate_query(query, page, per_page)
data = [c.to_dict() for c in items] data = [c.to_dict() for c in items]
@@ -361,13 +361,13 @@ def list_all_checkouts():
# Filter by active only # Filter by active only
if request.args.get('active', '').lower() == 'true': if request.args.get('active', '').lower() == 'true':
query = query.filter(USBCheckout.checkin_time == None) query = query.filter(USBCheckout.checkintime == None)
# Filter by user # Filter by user
if sso := request.args.get('sso'): if sso := request.args.get('sso'):
query = query.filter(USBCheckout.sso == sso) query = query.filter(USBCheckout.sso == sso)
query = query.order_by(USBCheckout.checkout_time.desc()) query = query.order_by(USBCheckout.checkouttime.desc())
items, total = paginate_query(query, page, per_page) items, total = paginate_query(query, page, per_page)
data = [c.to_dict() for c in items] data = [c.to_dict() for c in items]
@@ -380,7 +380,7 @@ def list_all_checkouts():
def list_active_checkouts(): def list_active_checkouts():
"""List all currently active checkouts.""" """List all currently active checkouts."""
checkouts = USBCheckout.query.filter( checkouts = USBCheckout.query.filter(
USBCheckout.checkin_time == None USBCheckout.checkintime == None
).order_by(USBCheckout.checkout_time.desc()).all() ).order_by(USBCheckout.checkouttime.desc()).all()
return success_response([c.to_dict() for c in checkouts]) return success_response([c.to_dict() for c in checkouts])

View File

@@ -123,16 +123,16 @@ class USBCheckout(BaseModel):
# User info # User info
sso = db.Column(db.String(20), nullable=False, comment='SSO of user') sso = db.Column(db.String(20), nullable=False, comment='SSO of user')
checkout_name = db.Column(db.String(100), nullable=True, comment='Name of user') checkoutname = db.Column(db.String(100), nullable=True, comment='Name of user')
# Checkout details # Checkout details
checkout_time = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) checkouttime = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
checkin_time = db.Column(db.DateTime, nullable=True) checkintime = db.Column(db.DateTime, nullable=True)
# Metadata # Metadata
checkout_reason = db.Column(db.Text, nullable=True, comment='Reason for checkout') checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout')
checkin_notes = db.Column(db.Text, nullable=True) checkinnotes = db.Column(db.Text, nullable=True)
was_wiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return') waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return')
# Relationships # Relationships
device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic')) device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))
@@ -143,13 +143,13 @@ class USBCheckout(BaseModel):
@property @property
def is_active(self): def is_active(self):
"""Check if this checkout is currently active (not returned).""" """Check if this checkout is currently active (not returned)."""
return self.checkin_time is None return self.checkintime is None
@property @property
def duration_days(self): def duration_days(self):
"""Get duration of checkout in days.""" """Get duration of checkout in days."""
end = self.checkin_time or datetime.utcnow() end = self.checkintime or datetime.utcnow()
delta = end - self.checkout_time delta = end - self.checkouttime
return delta.days return delta.days
def to_dict(self): def to_dict(self):

View File

@@ -22,7 +22,7 @@ assets_bp = Blueprint('assets', __name__)
# ============================================================================= # =============================================================================
@assets_bp.route('/types', methods=['GET']) @assets_bp.route('/types', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_asset_types(): def list_asset_types():
"""List all asset types.""" """List all asset types."""
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
@@ -41,7 +41,7 @@ def list_asset_types():
@assets_bp.route('/types/<int:type_id>', methods=['GET']) @assets_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_asset_type(type_id: int): def get_asset_type(type_id: int):
"""Get a single asset type.""" """Get a single asset type."""
t = AssetType.query.get(type_id) t = AssetType.query.get(type_id)
@@ -91,7 +91,7 @@ def create_asset_type():
# ============================================================================= # =============================================================================
@assets_bp.route('/statuses', methods=['GET']) @assets_bp.route('/statuses', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_asset_statuses(): def list_asset_statuses():
"""List all asset statuses.""" """List all asset statuses."""
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
@@ -110,7 +110,7 @@ def list_asset_statuses():
@assets_bp.route('/statuses/<int:status_id>', methods=['GET']) @assets_bp.route('/statuses/<int:status_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_asset_status(status_id: int): def get_asset_status(status_id: int):
"""Get a single asset status.""" """Get a single asset status."""
s = AssetStatus.query.get(status_id) s = AssetStatus.query.get(status_id)
@@ -158,7 +158,7 @@ def create_asset_status():
# ============================================================================= # =============================================================================
@assets_bp.route('', methods=['GET']) @assets_bp.route('', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_assets(): def list_assets():
""" """
List all assets with filtering and pagination. List all assets with filtering and pagination.
@@ -240,7 +240,7 @@ def list_assets():
@assets_bp.route('/<int:asset_id>', methods=['GET']) @assets_bp.route('/<int:asset_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_asset(asset_id: int): def get_asset(asset_id: int):
""" """
Get a single asset with full details. Get a single asset with full details.
@@ -370,7 +370,7 @@ def delete_asset(asset_id: int):
@assets_bp.route('/lookup/<assetnumber>', methods=['GET']) @assets_bp.route('/lookup/<assetnumber>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def lookup_asset_by_number(assetnumber: str): def lookup_asset_by_number(assetnumber: str):
""" """
Look up an asset by its asset number. Look up an asset by its asset number.
@@ -798,7 +798,7 @@ def get_assets_map():
@assets_bp.route('/<int:asset_id>/communications', methods=['GET']) @assets_bp.route('/<int:asset_id>/communications', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_asset_communications(asset_id: int): def get_asset_communications(asset_id: int):
"""Get all communications for an asset.""" """Get all communications for an asset."""
from shopdb.core.models import Communication from shopdb.core.models import Communication

View File

@@ -276,7 +276,7 @@ def pc_heartbeat():
return success_response({ return success_response({
'updated': updated, 'updated': updated,
'not_found': not_found, 'notfound': not_found,
'timestamp': datetime.utcnow().isoformat() 'timestamp': datetime.utcnow().isoformat()
}, message=f'{updated} PC(s) heartbeat recorded') }, message=f'{updated} PC(s) heartbeat recorded')
@@ -351,7 +351,7 @@ def bulk_update():
return success_response({ return success_response({
'updated': updated, 'updated': updated,
'not_found': not_found, 'notfound': not_found,
'errors': errors, 'errors': errors,
'timestamp': datetime.utcnow().isoformat() 'timestamp': datetime.utcnow().isoformat()
}, message=f'{updated} PC(s) updated') }, message=f'{updated} PC(s) updated')

View File

@@ -1,6 +1,6 @@
"""Dashboard API endpoints.""" """Dashboard API endpoints."""
from flask import Blueprint from flask import Blueprint, current_app
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.extensions import db
@@ -60,10 +60,10 @@ def get_dashboard():
'counts': { 'counts': {
'equipment': equipment_count, 'equipment': equipment_count,
'pcs': pc_count, 'pcs': pc_count,
'network_devices': network_count, 'networkdevices': network_count,
'total': equipment_count + pc_count + network_count 'total': equipment_count + pc_count + network_count
}, },
'by_status': status_dict, 'bystatus': status_dict,
'recent': [ 'recent': [
{ {
'machineid': m.machineid, 'machineid': m.machineid,
@@ -93,13 +93,51 @@ def get_stats():
).all() ).all()
return success_response({ return success_response({
'by_type': [ 'bytype': [
{'type': t, 'category': c, 'count': count} {'type': t, 'category': c, 'count': count}
for t, c, count in type_counts for t, c, count in type_counts
] ]
}) })
@dashboard_bp.route('/navigation', methods=['GET'])
def get_navigation():
"""Get navigation items from all loaded plugins."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return success_response([])
all_items = []
# Core navigation items (always present)
all_items.extend([
{'name': 'Dashboard', 'icon': 'layout-dashboard', 'route': '/', 'position': 0},
{'name': 'Map', 'icon': 'map', 'route': '/map', 'position': 4},
])
# Collect navigation items from all plugins
for name, plugin in pm.get_all_plugins().items():
try:
items = plugin.get_navigation_items()
for item in items:
item['plugin'] = name
all_items.extend(items)
except Exception:
pass
# Add core information section items
all_items.extend([
{'name': 'Applications', 'icon': 'app-window', 'route': '/applications', 'position': 30, 'section': 'information'},
{'name': 'Knowledge Base', 'icon': 'book-open', 'route': '/knowledgebase', 'position': 35, 'section': 'information'},
{'name': 'Reports', 'icon': 'bar-chart-3', 'route': '/reports', 'position': 40, 'section': 'information'},
])
# Sort by position
all_items.sort(key=lambda x: x.get('position', 99))
return success_response(all_items)
@dashboard_bp.route('/health', methods=['GET']) @dashboard_bp.route('/health', methods=['GET'])
def health_check(): def health_check():
"""Health check endpoint (no auth required).""" """Health check endpoint (no auth required)."""

View File

@@ -17,7 +17,7 @@ locations_bp = Blueprint('locations', __name__)
@locations_bp.route('', methods=['GET']) @locations_bp.route('', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_locations(): def list_locations():
"""List all locations.""" """List all locations."""
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
@@ -44,7 +44,7 @@ def list_locations():
@locations_bp.route('/<int:location_id>', methods=['GET']) @locations_bp.route('/<int:location_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_location(location_id: int): def get_location(location_id: int):
"""Get a single location.""" """Get a single location."""
loc = Location.query.get(location_id) loc = Location.query.get(location_id)

View File

@@ -337,7 +337,7 @@ def delete_machine(machine_id: int):
@machines_bp.route('/<int:machine_id>/communications', methods=['GET']) @machines_bp.route('/<int:machine_id>/communications', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
@add_deprecation_headers @add_deprecation_headers
def get_machine_communications(machine_id: int): def get_machine_communications(machine_id: int):
"""Get all communications for a machine.""" """Get all communications for a machine."""

View File

@@ -51,7 +51,7 @@ def list_models():
@models_bp.route('/<int:model_id>', methods=['GET']) @models_bp.route('/<int:model_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_model(model_id: int): def get_model(model_id: int):
"""Get a single model.""" """Get a single model."""
m = Model.query.get(model_id) m = Model.query.get(model_id)

View File

@@ -17,7 +17,7 @@ operatingsystems_bp = Blueprint('operatingsystems', __name__)
@operatingsystems_bp.route('', methods=['GET']) @operatingsystems_bp.route('', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_operatingsystems(): def list_operatingsystems():
"""List all operating systems.""" """List all operating systems."""
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
@@ -39,7 +39,7 @@ def list_operatingsystems():
@operatingsystems_bp.route('/<int:os_id>', methods=['GET']) @operatingsystems_bp.route('/<int:os_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_operatingsystem(os_id: int): def get_operatingsystem(os_id: int):
"""Get a single operating system.""" """Get a single operating system."""
os = OperatingSystem.query.get(os_id) os = OperatingSystem.query.get(os_id)

View File

@@ -17,7 +17,7 @@ pctypes_bp = Blueprint('pctypes', __name__)
@pctypes_bp.route('', methods=['GET']) @pctypes_bp.route('', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_pctypes(): def list_pctypes():
"""List all PC types.""" """List all PC types."""
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
@@ -39,7 +39,7 @@ def list_pctypes():
@pctypes_bp.route('/<int:type_id>', methods=['GET']) @pctypes_bp.route('/<int:type_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_pctype(type_id: int): def get_pctype(type_id: int):
"""Get a single PC type.""" """Get a single PC type."""
pt = PCType.query.get(type_id) pt = PCType.query.get(type_id)

View File

@@ -31,7 +31,7 @@ def generate_csv(data: list, columns: list) -> str:
# ============================================================================= # =============================================================================
@reports_bp.route('/equipment-by-type', methods=['GET']) @reports_bp.route('/equipment-by-type', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def equipment_by_type(): def equipment_by_type():
""" """
Report: Equipment count grouped by equipment type. Report: Equipment count grouped by equipment type.
@@ -95,7 +95,7 @@ def equipment_by_type():
# ============================================================================= # =============================================================================
@reports_bp.route('/assets-by-status', methods=['GET']) @reports_bp.route('/assets-by-status', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def assets_by_status(): def assets_by_status():
""" """
Report: Asset count grouped by status. Report: Asset count grouped by status.
@@ -154,7 +154,7 @@ def assets_by_status():
# ============================================================================= # =============================================================================
@reports_bp.route('/kb-popularity', methods=['GET']) @reports_bp.route('/kb-popularity', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def kb_popularity(): def kb_popularity():
""" """
Report: Most clicked knowledge base articles. Report: Most clicked knowledge base articles.
@@ -202,7 +202,7 @@ def kb_popularity():
# ============================================================================= # =============================================================================
@reports_bp.route('/warranty-status', methods=['GET']) @reports_bp.route('/warranty-status', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def warranty_status(): def warranty_status():
""" """
Report: Assets by warranty expiration status. Report: Assets by warranty expiration status.
@@ -305,7 +305,7 @@ def warranty_status():
# ============================================================================= # =============================================================================
@reports_bp.route('/software-compliance', methods=['GET']) @reports_bp.route('/software-compliance', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def software_compliance(): def software_compliance():
""" """
Report: Required applications vs installed (per PC). Report: Required applications vs installed (per PC).
@@ -409,7 +409,7 @@ def software_compliance():
# ============================================================================= # =============================================================================
@reports_bp.route('/asset-inventory', methods=['GET']) @reports_bp.route('/asset-inventory', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def asset_inventory(): def asset_inventory():
""" """
Report: Complete asset inventory summary. Report: Complete asset inventory summary.
@@ -518,8 +518,68 @@ def asset_inventory():
# Available Reports List # Available Reports List
# ============================================================================= # =============================================================================
# =============================================================================
# Report: PC-Machine Relationships
# =============================================================================
@reports_bp.route('/pc-relationships', methods=['GET'])
@jwt_required(optional=True)
def pc_relationships():
"""
Report: PCs with relationships to shop floor machines.
Returns machine number, vendor, model, PC hostname, and PC IP address
for all active PC-equipment relationships.
Query parameters:
- format: 'json' (default) or 'csv'
"""
sql = db.text("""
SELECT
eq.machinenumber AS machine_number,
v.vendor AS vendor,
mo.modelnumber AS model,
pc.machinenumber AS hostname,
c.ipaddress AS ip
FROM machinerelationships mr
JOIN machines eq ON mr.parentmachineid = eq.machineid
JOIN machines pc ON mr.childmachineid = pc.machineid
LEFT JOIN communications c ON pc.machineid = c.machineid AND c.isprimary = 1 AND c.comtypeid = 1
LEFT JOIN models mo ON eq.modelnumberid = mo.modelnumberid
LEFT JOIN vendors v ON mo.vendorid = v.vendorid
WHERE mr.isactive = 1
AND pc.pctypeid IS NOT NULL
AND eq.machinenumber IS NOT NULL AND eq.machinenumber != ''
ORDER BY eq.machinenumber
""")
results = db.session.execute(sql).fetchall()
data = [{
'machine_number': r.machine_number,
'vendor': r.vendor or '',
'model': r.model or '',
'hostname': r.hostname or '',
'ip': r.ip or ''
} for r in results]
if request.args.get('format') == 'csv':
csv_data = generate_csv(data, ['machine_number', 'vendor', 'model', 'hostname', 'ip'])
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=pc_relationships.csv'}
)
return success_response({
'report': 'pc_relationships',
'generated': datetime.utcnow().isoformat(),
'data': data,
'total': len(data)
})
@reports_bp.route('', methods=['GET']) @reports_bp.route('', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_reports(): def list_reports():
"""List all available reports.""" """List all available reports."""
reports = [ reports = [
@@ -564,6 +624,13 @@ def list_reports():
'description': 'Complete asset inventory breakdown', 'description': 'Complete asset inventory breakdown',
'endpoint': '/api/reports/asset-inventory', 'endpoint': '/api/reports/asset-inventory',
'category': 'inventory' 'category': 'inventory'
},
{
'id': 'pc-relationships',
'name': 'PC-Machine Relationships',
'description': 'PCs with relationships to shop floor machines',
'endpoint': '/api/reports/pc-relationships',
'category': 'inventory'
} }
] ]

View File

@@ -17,7 +17,7 @@ vendors_bp = Blueprint('vendors', __name__)
@vendors_bp.route('', methods=['GET']) @vendors_bp.route('', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_vendors(): def list_vendors():
"""List all vendors.""" """List all vendors."""
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
@@ -39,7 +39,7 @@ def list_vendors():
@vendors_bp.route('/<int:vendor_id>', methods=['GET']) @vendors_bp.route('/<int:vendor_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_vendor(vendor_id: int): def get_vendor(vendor_id: int):
"""Get a single vendor.""" """Get a single vendor."""
v = Vendor.query.get(vendor_id) v = Vendor.query.get(vendor_id)

View File

@@ -20,12 +20,12 @@ class AssetType(BaseModel):
nullable=False, nullable=False,
comment='Category name: equipment, computer, network_device, printer' comment='Category name: equipment, computer, network_device, printer'
) )
plugin_name = db.Column( pluginname = db.Column(
db.String(100), db.String(100),
nullable=True, nullable=True,
comment='Plugin that owns this type' comment='Plugin that owns this type'
) )
table_name = db.Column( tablename = db.Column(
db.String(100), db.String(100),
nullable=True, nullable=True,
comment='Extension table name for this type' comment='Extension table name for this type'
@@ -174,23 +174,23 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
if hasattr(self, 'incoming_relationships'): if hasattr(self, 'incoming_relationships'):
for rel in self.incoming_relationships: for rel in self.incoming_relationships:
if rel.source_asset and rel.isactive: if rel.sourceasset and rel.isactive:
related_assets.append(rel.source_asset) related_assets.append(rel.sourceasset)
if hasattr(self, 'outgoing_relationships'): if hasattr(self, 'outgoing_relationships'):
for rel in self.outgoing_relationships: for rel in self.outgoing_relationships:
if rel.target_asset and rel.isactive: if rel.targetasset and rel.isactive:
related_assets.append(rel.target_asset) related_assets.append(rel.targetasset)
# Find first related asset with location data # Find first related asset with location data
for related in related_assets: for related in related_assets:
if related.locationid is not None or (related.mapleft is not None and related.maptop is not None): if related.locationid is not None or (related.mapleft is not None and related.maptop is not None):
return { return {
'locationid': related.locationid, 'locationid': related.locationid,
'location_name': related.location.locationname if related.location else None, 'locationname': related.location.locationname if related.location else None,
'mapleft': related.mapleft, 'mapleft': related.mapleft,
'maptop': related.maptop, 'maptop': related.maptop,
'inherited_from': related.assetnumber 'inheritedfrom': related.assetnumber
} }
return None return None
@@ -207,33 +207,33 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
# Add related object names for convenience # Add related object names for convenience
if self.assettype: if self.assettype:
result['assettype_name'] = self.assettype.assettype result['assettypename'] = self.assettype.assettype
if self.status: if self.status:
result['status_name'] = self.status.status result['statusname'] = self.status.status
if self.location: if self.location:
result['location_name'] = self.location.locationname result['locationname'] = self.location.locationname
if self.businessunit: if self.businessunit:
result['businessunit_name'] = self.businessunit.businessunit result['businessunitname'] = self.businessunit.businessunit
# Add plugin-specific ID for navigation purposes # Add plugin-specific ID for navigation purposes
if hasattr(self, 'equipment') and self.equipment: if hasattr(self, 'equipment') and self.equipment:
result['plugin_id'] = self.equipment.equipmentid result['pluginid'] = self.equipment.equipmentid
elif hasattr(self, 'computer') and self.computer: elif hasattr(self, 'computer') and self.computer:
result['plugin_id'] = self.computer.computerid result['pluginid'] = self.computer.computerid
elif hasattr(self, 'network_device') and self.network_device: elif hasattr(self, 'network_device') and self.network_device:
result['plugin_id'] = self.network_device.networkdeviceid result['pluginid'] = self.network_device.networkdeviceid
elif hasattr(self, 'printer') and self.printer: elif hasattr(self, 'printer') and self.printer:
result['plugin_id'] = self.printer.printerid result['pluginid'] = self.printer.printerid
# Include inherited location if this asset has no location data # Include inherited location if this asset has no location data
if include_inherited_location: if include_inherited_location:
inherited = self.get_inherited_location() inherited = self.get_inherited_location()
if inherited: if inherited:
result['inherited_location'] = inherited result['inheritedlocation'] = inherited
# Also set the location fields if they're missing # Also set the location fields if they're missing
if result.get('locationid') is None: if result.get('locationid') is None:
result['locationid'] = inherited['locationid'] result['locationid'] = inherited['locationid']
result['location_name'] = inherited['location_name'] result['locationname'] = inherited['locationname']
if result.get('mapleft') is None: if result.get('mapleft') is None:
result['mapleft'] = inherited['mapleft'] result['mapleft'] = inherited['mapleft']
if result.get('maptop') is None: if result.get('maptop') is None:
@@ -243,7 +243,7 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
if include_type_data: if include_type_data:
ext_data = self._get_extension_data() ext_data = self._get_extension_data()
if ext_data: if ext_data:
result['type_data'] = ext_data result['typedata'] = ext_data
return result return result

View File

@@ -34,12 +34,12 @@ class AssetRelationship(BaseModel):
relationshipid = db.Column(db.Integer, primary_key=True) relationshipid = db.Column(db.Integer, primary_key=True)
source_assetid = db.Column( sourceassetid = db.Column(
db.Integer, db.Integer,
db.ForeignKey('assets.assetid'), db.ForeignKey('assets.assetid'),
nullable=False nullable=False
) )
target_assetid = db.Column( targetassetid = db.Column(
db.Integer, db.Integer,
db.ForeignKey('assets.assetid'), db.ForeignKey('assets.assetid'),
nullable=False nullable=False
@@ -53,31 +53,31 @@ class AssetRelationship(BaseModel):
notes = db.Column(db.Text) notes = db.Column(db.Text)
# Relationships # Relationships
source_asset = db.relationship( sourceasset = db.relationship(
'Asset', 'Asset',
foreign_keys=[source_assetid], foreign_keys=[sourceassetid],
backref='outgoing_relationships' backref='outgoing_relationships'
) )
target_asset = db.relationship( targetasset = db.relationship(
'Asset', 'Asset',
foreign_keys=[target_assetid], foreign_keys=[targetassetid],
backref='incoming_relationships' backref='incoming_relationships'
) )
relationship_type = db.relationship('RelationshipType', backref='asset_relationships') relationshiptype = db.relationship('RelationshipType', backref='asset_relationships')
__table_args__ = ( __table_args__ = (
db.UniqueConstraint( db.UniqueConstraint(
'source_assetid', 'sourceassetid',
'target_assetid', 'targetassetid',
'relationshiptypeid', 'relationshiptypeid',
name='uq_asset_relationship' name='uq_asset_relationship'
), ),
db.Index('idx_asset_rel_source', 'source_assetid'), db.Index('idx_asset_rel_source', 'sourceassetid'),
db.Index('idx_asset_rel_target', 'target_assetid'), db.Index('idx_asset_rel_target', 'targetassetid'),
) )
def __repr__(self): def __repr__(self):
return f"<AssetRelationship {self.source_assetid} -> {self.target_assetid}>" return f"<AssetRelationship {self.sourceassetid} -> {self.targetassetid}>"
class MachineRelationship(BaseModel): class MachineRelationship(BaseModel):

View File

@@ -109,7 +109,7 @@ class PluginManager:
'dependencies': meta.dependencies, 'dependencies': meta.dependencies,
'installed': state is not None, 'installed': state is not None,
'enabled': state.enabled if state else False, 'enabled': state.enabled if state else False,
'installed_at': state.installed_at if state else None 'installedat': state.installed_at if state else None
}) })
except Exception as e: except Exception as e:
logger.warning(f"Error inspecting plugin {name}: {e}") logger.warning(f"Error inspecting plugin {name}: {e}")

View File

@@ -23,7 +23,7 @@ def get_pagination_params(req=None) -> Tuple[int, int]:
page = 1 page = 1
try: try:
per_page = int(req.args.get('per_page', default_size)) per_page = int(req.args.get('perpage', req.args.get('per_page', default_size)))
per_page = max(1, min(per_page, max_size)) per_page = max(1, min(per_page, max_size))
except (TypeError, ValueError): except (TypeError, ValueError):
per_page = default_size per_page = default_size

View File

@@ -44,7 +44,7 @@ def api_response(
'status': status, 'status': status,
'meta': { 'meta': {
'timestamp': datetime.utcnow().isoformat() + 'Z', 'timestamp': datetime.utcnow().isoformat() + 'Z',
'request_id': str(uuid.uuid4())[:8], 'requestid': str(uuid.uuid4())[:8],
**(meta or {}) **(meta or {})
} }
} }
@@ -161,11 +161,11 @@ def paginated_response(
meta={ meta={
'pagination': { 'pagination': {
'page': page, 'page': page,
'per_page': per_page, 'perpage': per_page,
'total': total, 'total': total,
'total_pages': total_pages, 'totalpages': total_pages,
'has_next': page < total_pages, 'hasnext': page < total_pages,
'has_prev': page > 1 'hasprev': page > 1
} }
} }
) )