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/vue3": "^6.1.20",
"axios": "^1.6.0",
"jsbarcode": "^3.12.3",
"leaflet": "^1.9.4",
"lucide-vue-next": "^0.563.0",
"pinia": "^2.1.0",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
"@vitejs/plugin-vue": "^5.2.4",
"vite": "^6.4.1"
}
}

View File

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

View File

@@ -27,14 +27,14 @@
:key="rel.relationshipid"
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">
<router-link :to="getAssetRoute(rel.target_asset)" class="rel-name">
{{ rel.target_asset?.name || rel.target_asset?.assetnumber || 'Unknown' }}
<router-link :to="getAssetRoute(rel.targetasset)" class="rel-name">
{{ rel.targetasset?.name || rel.targetasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge badge-outline">{{ rel.relationship_type_name }}</span>
<span class="rel-type-badge">{{ rel.target_asset?.assettype }}</span>
<span class="badge badge-outline">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.targetasset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
</div>
@@ -59,14 +59,14 @@
:key="rel.relationshipid"
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">
<router-link :to="getAssetRoute(rel.source_asset)" class="rel-name">
{{ rel.source_asset?.name || rel.source_asset?.assetnumber || 'Unknown' }}
<router-link :to="getAssetRoute(rel.sourceasset)" class="rel-name">
{{ rel.sourceasset?.name || rel.sourceasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge badge-outline">{{ rel.relationship_type_name }}</span>
<span class="rel-type-badge">{{ rel.source_asset?.assettype }}</span>
<span class="badge badge-outline">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.sourceasset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
</div>
@@ -173,6 +173,7 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Package } from 'lucide-vue-next'
import { assetsApi, relationshipTypesApi } from '../api'
import { useAuthStore } from '../stores/auth'
@@ -296,7 +297,7 @@ function searchAssets() {
searchTimeout = setTimeout(async () => {
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
assetSearchResults.value = (response.data.data || []).filter(
a => a.assetid !== resolvedAssetId.value
@@ -329,11 +330,11 @@ async function saveRelationship() {
}
if (newRel.value.direction === 'outgoing') {
data.source_assetid = resolvedAssetId.value
data.target_assetid = newRel.value.targetAssetId
data.sourceassetid = resolvedAssetId.value
data.targetassetid = newRel.value.targetAssetId
} else {
data.source_assetid = newRel.value.targetAssetId
data.target_assetid = resolvedAssetId.value
data.sourceassetid = newRel.value.targetAssetId
data.targetassetid = resolvedAssetId.value
}
await assetsApi.createRelationship(data)
@@ -374,12 +375,12 @@ function closeModal() {
function getAssetIcon(assettype) {
const icons = {
'equipment': '⚙',
'computer': '💻',
'printer': '🖨',
'network_device': '🌐'
'equipment': Cog,
'computer': Monitor,
'printer': Printer,
'network_device': Globe
}
return icons[assettype] || '📦'
return icons[assettype] || Package
}
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 { useAuthStore } from '../stores/auth'
// Views
import Login from '../views/Login.vue'
import AppLayout from '../views/AppLayout.vue'
import Dashboard from '../views/Dashboard.vue'
import MachinesList from '../views/machines/MachinesList.vue'
import MachineDetail from '../views/machines/MachineDetail.vue'
import MachineForm from '../views/machines/MachineForm.vue'
import PrintersList from '../views/printers/PrintersList.vue'
import PrinterDetail from '../views/printers/PrinterDetail.vue'
import PrinterForm from '../views/printers/PrinterForm.vue'
import PCsList from '../views/pcs/PCsList.vue'
import PCDetail from '../views/pcs/PCDetail.vue'
import PCForm from '../views/pcs/PCForm.vue'
import VendorsList from '../views/vendors/VendorsList.vue'
import ApplicationsList from '../views/applications/ApplicationsList.vue'
import ApplicationDetail from '../views/applications/ApplicationDetail.vue'
import ApplicationForm from '../views/applications/ApplicationForm.vue'
import KnowledgeBaseList from '../views/knowledgebase/KnowledgeBaseList.vue'
import KnowledgeBaseDetail from '../views/knowledgebase/KnowledgeBaseDetail.vue'
import KnowledgeBaseForm from '../views/knowledgebase/KnowledgeBaseForm.vue'
import SearchResults from '../views/SearchResults.vue'
import 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'
// Auto-discover all route modules from routes/ directory
const routeModules = import.meta.glob('./routes/*.js', { eager: true })
const appChildren = Object.values(routeModules).flatMap(m => m.default)
const routes = [
{
path: '/login',
name: 'login',
component: Login,
component: () => import('../views/Login.vue'),
meta: { guest: true }
},
// Standalone full-screen dashboards (no sidebar, no auth required)
{
path: '/shopfloor',
name: 'shopfloor',
component: ShopfloorDashboard
component: () => import('../views/ShopfloorDashboard.vue')
},
{
path: '/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: '/',
component: AppLayout,
children: [
{
path: '',
name: 'dashboard',
component: Dashboard
},
{
path: 'search',
name: 'search',
component: SearchResults
},
{
path: 'machines',
name: 'machines',
component: MachinesList
},
{
path: 'machines/new',
name: 'machine-new',
component: MachineForm,
meta: { requiresAuth: true }
},
{
path: 'machines/:id',
name: 'machine-detail',
component: MachineDetail
},
{
path: 'machines/:id/edit',
name: 'machine-edit',
component: MachineForm,
meta: { requiresAuth: true }
},
{
path: 'printers',
name: 'printers',
component: PrintersList
},
{
path: 'printers/new',
name: 'printer-new',
component: PrinterForm,
meta: { requiresAuth: true }
},
{
path: 'printers/:id',
name: 'printer-detail',
component: PrinterDetail
},
{
path: 'printers/:id/edit',
name: 'printer-edit',
component: PrinterForm,
meta: { requiresAuth: true }
},
{
path: 'pcs',
name: 'pcs',
component: PCsList
},
{
path: 'pcs/new',
name: 'pc-new',
component: PCForm,
meta: { requiresAuth: true }
},
{
path: 'pcs/:id',
name: 'pc-detail',
component: PCDetail
},
{
path: 'pcs/:id/edit',
name: 'pc-edit',
component: PCForm,
meta: { requiresAuth: true }
},
{
path: 'map',
name: 'map',
component: MapView
},
{
path: '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
}
]
children: appChildren
}
]

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>
<nav class="sidebar-nav">
<router-link to="/">Dashboard</router-link>
<router-link to="/calendar">Calendar</router-link>
<router-link to="/map">Map</router-link>
<div class="nav-section">Assets</div>
<router-link to="/machines">Equipment</router-link>
<router-link to="/pcs">PCs</router-link>
<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>
<template v-for="item in navItems" :key="item.route">
<div v-if="item.section" class="nav-section">{{ item.section }}</div>
<router-link :to="item.route">
<component v-if="item.iconComponent" :is="item.iconComponent" :size="16" />
{{ item.name }}
</router-link>
</template>
<div class="nav-section">Displays</div>
<a href="/shopfloor" target="_blank" class="external-link">Shopfloor Dashboard</a>
@@ -42,8 +33,8 @@
<div class="sidebar-footer">
<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-else>🌙 Dark</span>
<span v-if="currentTheme === 'dark'"><Sun :size="14" /> Light</span>
<span v-else><Moon :size="14" /> Dark</span>
</button>
<div class="user-menu">
@@ -63,15 +54,92 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
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 { currentTheme, toggleTheme } from '../stores/theme'
import { dashboardApi } from '../api'
const router = useRouter()
const authStore = useAuthStore()
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() {
if (searchQuery.value.trim()) {

View File

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

View File

@@ -40,12 +40,12 @@
}"
@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-name">{{ asset.name || asset.assetnumber }}</div>
<div class="asset-meta">
<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>
@@ -105,6 +105,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Cog, Monitor, Printer, Globe, Package, MapPin } from 'lucide-vue-next'
import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
@@ -152,12 +153,12 @@ async function loadAssets() {
function getTypeIcon(assettype) {
const icons = {
'equipment': '⚙',
'computer': '💻',
'printer': '🖨',
'network_device': '🌐'
'equipment': Cog,
'computer': Monitor,
'printer': Printer,
'network_device': Globe
}
return icons[assettype] || '📦'
return icons[assettype] || Package
}
function selectAsset(asset) {

View File

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

View File

@@ -105,7 +105,7 @@ const applications = ref([])
onMounted(async () => {
try {
// 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 || []
// Load article if editing

View File

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

View File

@@ -3,6 +3,9 @@
<div class="page-header">
<h2>Equipment Details</h2>
<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">
Edit
</router-link>
@@ -22,28 +25,28 @@
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-primary">
{{ equipment.assettype_name || 'Equipment' }}
{{ equipment.assettypename || 'Equipment' }}
</span>
<span class="badge badge-lg" :class="getStatusClass(equipment.status_name)">
{{ equipment.status_name || 'Unknown' }}
<span class="badge badge-lg" :class="getStatusClass(equipment.statusname)">
{{ equipment.statusname || 'Unknown' }}
</span>
</div>
<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-value">{{ equipment.equipment.equipmenttype_name }}</span>
<span class="hero-detail-value">{{ equipment.equipment.equipmenttypename }}</span>
</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-value">{{ equipment.equipment.vendor_name }}</span>
<span class="hero-detail-value">{{ equipment.equipment.vendorname }}</span>
</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-value">{{ equipment.equipment.model_name }}</span>
<span class="hero-detail-value">{{ equipment.equipment.modelname }}</span>
</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-value">{{ equipment.location_name }}</span>
<span class="hero-detail-value">{{ equipment.locationname }}</span>
</div>
</div>
</div>
@@ -78,30 +81,30 @@
<div class="info-list">
<div class="info-row">
<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 class="info-row">
<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 class="info-row">
<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>
<!-- 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>
<div class="info-list">
<div class="info-row">
<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 class="info-row">
<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>
@@ -164,14 +167,14 @@
:top="equipment.maptop"
:machineName="equipment.assetnumber"
>
<span class="location-link">{{ equipment.location_name || 'On Map' }}</span>
<span class="location-link">{{ equipment.locationname || 'On Map' }}</span>
</LocationMapTooltip>
<span v-else>{{ equipment.location_name || '-' }}</span>
<span v-else>{{ equipment.locationname || '-' }}</span>
</span>
</div>
<div class="info-row">
<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>
@@ -183,7 +186,7 @@
No controlling PC assigned
</div>
<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">
<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>
@@ -239,20 +242,20 @@ const controllingPc = computed(() => {
// First check incoming - computer as source controlling this equipment
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 {
...rel.source_asset,
relationshipType: rel.relationship_type_name
...rel.sourceasset,
relationshipType: rel.relationshiptypename
}
}
}
// Also check outgoing - legacy data may have equipment -> computer Controls relationships
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 {
...rel.target_asset,
relationshipType: rel.relationship_type_name
...rel.targetasset,
relationshipType: rel.relationshiptypename
}
}
}

View File

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

View File

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

View File

@@ -3,12 +3,12 @@
<div class="hero-card">
<div class="hero-image">
<div class="device-icon">
<span class="icon">{{ getDeviceIcon() }}</span>
<span class="icon"><component :is="getDeviceIcon()" :size="24" /></span>
</div>
</div>
<div class="hero-content">
<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
v-if="authStore.isAuthenticated"
:to="`/network/${deviceId}/edit`"
@@ -18,14 +18,14 @@
</router-link>
</div>
<div class="hero-meta">
<span class="badge" :class="getStatusClass(device.status_name)">
{{ device.status_name || 'Unknown' }}
<span class="badge" :class="getStatusClass(device.statusname)">
{{ device.statusname || 'Unknown' }}
</span>
<span v-if="device.network_device?.networkdevicetype_name" class="meta-item">
{{ device.network_device.networkdevicetype_name }}
<span v-if="device.networkdevice?.networkdevicetypename" class="meta-item">
{{ device.networkdevice.networkdevicetypename }}
</span>
<span v-if="device.network_device?.vendor_name" class="meta-item">
{{ device.network_device.vendor_name }}
<span v-if="device.networkdevice?.vendorname" class="meta-item">
{{ device.networkdevice.vendorname }}
</span>
</div>
<div class="hero-details">
@@ -37,19 +37,19 @@
<span class="label">Serial</span>
<span class="value mono">{{ device.serialnumber }}</span>
</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="value">{{ device.location_name }}</span>
<span class="value">{{ device.locationname }}</span>
</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="value">{{ device.businessunit_name }}</span>
<span class="value">{{ device.businessunitname }}</span>
</div>
</div>
<div class="hero-features" v-if="device.network_device">
<span v-if="device.network_device.ispoe" class="feature-badge poe">PoE</span>
<span v-if="device.network_device.ismanaged" class="feature-badge managed">Managed</span>
<span v-if="device.network_device.portcount" class="feature-badge ports">{{ device.network_device.portcount }} Ports</span>
<div class="hero-features" v-if="device.networkdevice">
<span v-if="device.networkdevice.ispoe" class="feature-badge poe">PoE</span>
<span v-if="device.networkdevice.ismanaged" class="feature-badge managed">Managed</span>
<span v-if="device.networkdevice.portcount" class="feature-badge ports">{{ device.networkdevice.portcount }} Ports</span>
</div>
</div>
</div>
@@ -62,27 +62,27 @@
<div class="info-list">
<div class="info-row">
<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 class="info-row">
<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 class="info-row">
<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 class="info-row">
<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 class="info-row">
<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 class="info-row">
<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>
@@ -113,17 +113,17 @@
</div>
<div class="info-row">
<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 class="info-row">
<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 class="info-row">
<span class="info-label">Status</span>
<span class="info-value">
<span class="badge" :class="getStatusClass(device.status_name)">
{{ device.status_name || 'Unknown' }}
<span class="badge" :class="getStatusClass(device.statusname)">
{{ device.statusname || 'Unknown' }}
</span>
</span>
</div>
@@ -177,6 +177,7 @@
<script setup>
import { ref, onMounted } from 'vue'
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 { networkApi } from '../../api'
import AssetRelationships from '../../components/AssetRelationships.vue'
@@ -207,15 +208,15 @@ async function loadDevice() {
}
function getDeviceIcon() {
const type = device.value?.network_device?.networkdevicetype_name?.toLowerCase() || ''
if (type.includes('switch')) return '⏛'
if (type.includes('router')) return '⇌'
if (type.includes('firewall')) return '🛡'
if (type.includes('access point') || type.includes('ap')) return '📶'
if (type.includes('camera')) return '📷'
if (type.includes('server')) return '🖥'
if (type.includes('idf') || type.includes('closet')) return '🗄'
return '🌐'
const type = device.value?.networkdevice?.networkdevicetypename?.toLowerCase() || ''
if (type.includes('switch')) return Network
if (type.includes('router')) return Router
if (type.includes('firewall')) return Shield
if (type.includes('access point') || type.includes('ap')) return Wifi
if (type.includes('camera')) return Camera
if (type.includes('server')) return Server
if (type.includes('idf') || type.includes('closet')) return Rack
return Globe
}
function getStatusClass(status) {
@@ -240,7 +241,7 @@ function formatDate(dateStr) {
}
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 {
await networkApi.delete(deviceId)
router.push('/network')

View File

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

View File

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

View File

@@ -287,7 +287,7 @@ onMounted(async () => {
const [typesRes, buRes, appsRes] = await Promise.all([
notificationsApi.types.list(),
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 || []

View File

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

View File

@@ -20,22 +20,22 @@
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-info">Computer</span>
<span class="badge badge-lg" :class="getStatusClass(computer.status_name)">
{{ computer.status_name || 'Unknown' }}
<span class="badge badge-lg" :class="getStatusClass(computer.statusname)">
{{ computer.statusname || 'Unknown' }}
</span>
</div>
<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-value">{{ computer.computer.computertype_name }}</span>
<span class="hero-detail-value">{{ computer.computer.computertypename }}</span>
</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-value">{{ computer.computer.os_name }}</span>
<span class="hero-detail-value">{{ computer.computer.osname }}</span>
</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-value">{{ computer.location_name }}</span>
<span class="hero-detail-value">{{ computer.locationname }}</span>
</div>
</div>
</div>
@@ -74,11 +74,11 @@
<div class="info-list">
<div class="info-row">
<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 class="info-row">
<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>
@@ -126,14 +126,14 @@
:top="computer.maptop"
:machineName="computer.assetnumber"
>
<span class="location-link">{{ computer.location_name || 'On Map' }}</span>
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>
</LocationMapTooltip>
<span v-else>{{ computer.location_name || '-' }}</span>
<span v-else>{{ computer.locationname || '-' }}</span>
</span>
</div>
<div class="info-row">
<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>
@@ -145,7 +145,7 @@
<router-link
v-for="item in controlledEquipment"
:key="item.relationshipid"
:to="`/machines/${item.plugin_id || item.assetid}`"
:to="`/machines/${item.pluginid || item.assetid}`"
class="equipment-item"
>
<div class="equipment-info">
@@ -153,7 +153,7 @@
<span class="equipment-alias" v-if="item.name">{{ item.name }}</span>
</div>
<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>
</div>
</router-link>
@@ -221,22 +221,22 @@ const controlledEquipment = computed(() => {
// Check outgoing - computer controls equipment
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({
...rel.target_asset,
...rel.targetasset,
relationshipid: rel.relationshipid,
relationshipType: rel.relationship_type_name
relationshipType: rel.relationshiptypename
})
}
}
// Also check incoming - legacy data may have equipment -> computer Controls relationships
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({
...rel.source_asset,
...rel.sourceasset,
relationshipid: rel.relationshipid,
relationshipType: rel.relationship_type_name
relationshipType: rel.relationshiptypename
})
}
}

View File

@@ -30,6 +30,7 @@
<th>Type</th>
<th>Features</th>
<th>Status</th>
<th>Location</th>
<th>Actions</th>
</tr>
</thead>
@@ -38,17 +39,18 @@
<td>{{ item.assetnumber }}</td>
<td>{{ item.computer?.hostname || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.computer?.computertype_name || '-' }}</td>
<td>{{ item.computer?.computertypename || '-' }}</td>
<td class="features">
<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?.isvnc && !item.computer?.iswinrm">-</span>
</td>
<td>
<span class="badge" :class="getStatusClass(item.status_name)">
{{ item.status_name || 'Unknown' }}
<span class="badge" :class="getStatusClass(item.statusname)">
{{ item.statusname || 'Unknown' }}
</span>
</td>
<td>{{ item.locationname || '-' }}</td>
<td class="actions">
<router-link
:to="`/pcs/${item.computer?.computerid || item.assetid}`"
@@ -59,7 +61,7 @@
</td>
</tr>
<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
</td>
</tr>
@@ -68,16 +70,13 @@
</div>
<!-- Pagination -->
<div class="pagination" v-if="totalPages > 1">
<button
v-for="p in totalPages"
:key="p"
:class="{ active: p === page }"
@click="goToPage(p)"
>
{{ p }}
</button>
</div>
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
@@ -86,12 +85,14 @@
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const computers = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
@@ -104,13 +105,13 @@ async function loadComputers() {
try {
const params = {
page: page.value,
per_page: 20
perpage: perPage.value
}
if (search.value) params.search = search.value
const response = await computersApi.list(params)
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) {
console.error('Error loading computers:', error)
} finally {
@@ -131,6 +132,12 @@ function goToPage(p) {
loadComputers()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadComputers()
}
function getStatusClass(status) {
if (!status) return 'badge-info'
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">
<h2>Printer Details</h2>
<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" class="btn btn-secondary">Back to List</router-link>
</div>
@@ -24,13 +27,13 @@
<span v-if="printer.printer?.isnetwork" class="badge badge-lg badge-secondary">Network</span>
</div>
<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-value">{{ printer.printer.vendor_name }}</span>
<span class="hero-detail-value">{{ printer.printer.vendorname }}</span>
</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-value">{{ printer.printer.model_name }}</span>
<span class="hero-detail-value">{{ printer.printer.modelname }}</span>
</div>
<div class="hero-detail" v-if="printer.serialnumber">
<span class="hero-detail-label">Serial Number</span>
@@ -133,9 +136,9 @@
<span v-else>Not mapped</span>
</span>
</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-value">{{ printer.businessunit_name }}</span>
<span class="info-value">{{ printer.businessunitname }}</span>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -306,8 +306,8 @@ def create_equipment():
lastmaintenancedate=data.get('lastmaintenancedate'),
nextmaintenancedate=data.get('nextmaintenancedate'),
maintenanceintervaldays=data.get('maintenanceintervaldays'),
controller_vendorid=data.get('controller_vendorid'),
controller_modelid=data.get('controller_modelid')
controllervendorid=data.get('controllervendorid'),
controllermodelid=data.get('controllermodelid')
)
db.session.add(equip)
@@ -358,7 +358,7 @@ def update_equipment(equipment_id: int):
equipment_fields = ['equipmenttypeid', 'vendorid', 'modelnumberid',
'requiresmanualconfig', 'islocationonly',
'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays',
'controller_vendorid', 'controller_modelid']
'controllervendorid', 'controllermodelid']
for key in equipment_fields:
if key in data:
setattr(equip, key, data[key])
@@ -427,6 +427,6 @@ def dashboard_summary():
return success_response({
'total': total,
'by_type': [{'type': t, 'count': c} for t, c in by_type],
'by_status': [{'status': s, 'count': c} for s, c in by_status]
'bytype': [{'type': t, 'count': c} for t, c in by_type],
'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)
# Controller info (for CNC machines)
controller_vendorid = db.Column(
controllervendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),
nullable=True,
comment='Controller vendor (e.g., FANUC)'
)
controller_modelid = db.Column(
controllermodelid = db.Column(
db.Integer,
db.ForeignKey('models.modelnumberid'),
nullable=True,
@@ -99,8 +99,8 @@ class Equipment(BaseModel):
equipmenttype = db.relationship('EquipmentType', backref='equipment')
vendor = db.relationship('Vendor', foreign_keys=[vendorid], 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')
controller_model = db.relationship('Model', foreign_keys=[controller_modelid], backref='equipment_controller_models')
controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers')
controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models')
__table_args__ = (
db.Index('idx_equipment_type', 'equipmenttypeid'),
@@ -116,16 +116,18 @@ class Equipment(BaseModel):
# Add related object names
if self.equipmenttype:
result['equipmenttype_name'] = self.equipmenttype.equipmenttype
result['equipmenttypename'] = self.equipmenttype.equipmenttype
if self.vendor:
result['vendor_name'] = self.vendor.vendor
result['vendorname'] = self.vendor.vendor
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
if self.controller_vendor:
result['controller_vendor_name'] = self.controller_vendor.vendor
if self.controller_model:
result['controller_model_name'] = self.controller_model.modelnumber
if self.controllervendor:
result['controllervendorname'] = self.controllervendor.vendor
if self.controllermodel:
result['controllermodelname'] = self.controllermodel.modelnumber
return result

View File

@@ -79,8 +79,8 @@ class EquipmentPlugin(BasePlugin):
if not existing:
at = AssetType(
assettype='equipment',
plugin_name='equipment',
table_name='equipment',
pluginname='equipment',
tablename='equipment',
description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)',
icon='cog'
)
@@ -214,7 +214,7 @@ class EquipmentPlugin(BasePlugin):
{
'name': 'Equipment',
'icon': 'cog',
'route': '/equipment',
'route': '/machines',
'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['network_device'] = netdev.to_dict()
result['networkdevice'] = netdev.to_dict()
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['network_device'] = netdev.to_dict()
result['networkdevice'] = netdev.to_dict()
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['network_device'] = netdev.to_dict()
result['networkdevice'] = netdev.to_dict()
return success_response(result)
@@ -353,7 +353,7 @@ def create_network_device():
db.session.commit()
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)
@@ -413,7 +413,7 @@ def update_network_device(device_id: int):
db.session.commit()
result = asset.to_dict()
result['network_device'] = netdev.to_dict()
result['networkdevice'] = netdev.to_dict()
return success_response(result, message='Network device updated')
@@ -479,10 +479,10 @@ def dashboard_summary():
return success_response({
'total': total,
'by_type': [{'type': t, 'count': c} for t, c in by_type],
'by_vendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
'bytype': [{'type': t, 'count': c} for t, c in by_type],
'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
'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
if self.networkdevicetype:
result['networkdevicetype_name'] = self.networkdevicetype.networkdevicetype
result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype
if self.vendor:
result['vendor_name'] = self.vendor.vendor
result['vendorname'] = self.vendor.vendor
return result

View File

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

View File

@@ -374,7 +374,7 @@ def dashboard_summary():
return success_response({
'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'])
@jwt_required()
@jwt_required(optional=True)
def list_printer_types():
"""List all printer types."""
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'])
@jwt_required()
@jwt_required(optional=True)
def get_printer_type(type_id: int):
"""Get a single printer type."""
t = PrinterType.query.get(type_id)
@@ -471,6 +471,6 @@ def dashboard_summary():
'online': total, # Placeholder - would need monitoring integration
'lowsupplies': 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],
'by_vendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
'bytype': [{'type': t, 'count': c} for t, c in by_type],
'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
"""Dashboard API endpoints."""
from flask import Blueprint
from flask import Blueprint, current_app
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
@@ -60,10 +60,10 @@ def get_dashboard():
'counts': {
'equipment': equipment_count,
'pcs': pc_count,
'network_devices': network_count,
'networkdevices': network_count,
'total': equipment_count + pc_count + network_count
},
'by_status': status_dict,
'bystatus': status_dict,
'recent': [
{
'machineid': m.machineid,
@@ -93,13 +93,51 @@ def get_stats():
).all()
return success_response({
'by_type': [
'bytype': [
{'type': t, 'category': c, 'count': count}
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'])
def health_check():
"""Health check endpoint (no auth required)."""

View File

@@ -17,7 +17,7 @@ locations_bp = Blueprint('locations', __name__)
@locations_bp.route('', methods=['GET'])
@jwt_required()
@jwt_required(optional=True)
def list_locations():
"""List all locations."""
page, per_page = get_pagination_params(request)
@@ -44,7 +44,7 @@ def list_locations():
@locations_bp.route('/<int:location_id>', methods=['GET'])
@jwt_required()
@jwt_required(optional=True)
def get_location(location_id: int):
"""Get a single location."""
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'])
@jwt_required()
@jwt_required(optional=True)
@add_deprecation_headers
def get_machine_communications(machine_id: int):
"""Get all communications for a machine."""

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ pctypes_bp = Blueprint('pctypes', __name__)
@pctypes_bp.route('', methods=['GET'])
@jwt_required()
@jwt_required(optional=True)
def list_pctypes():
"""List all PC types."""
page, per_page = get_pagination_params(request)
@@ -39,7 +39,7 @@ def list_pctypes():
@pctypes_bp.route('/<int:type_id>', methods=['GET'])
@jwt_required()
@jwt_required(optional=True)
def get_pctype(type_id: int):
"""Get a single PC type."""
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'])
@jwt_required()
@jwt_required(optional=True)
def equipment_by_type():
"""
Report: Equipment count grouped by equipment type.
@@ -95,7 +95,7 @@ def equipment_by_type():
# =============================================================================
@reports_bp.route('/assets-by-status', methods=['GET'])
@jwt_required()
@jwt_required(optional=True)
def assets_by_status():
"""
Report: Asset count grouped by status.
@@ -154,7 +154,7 @@ def assets_by_status():
# =============================================================================
@reports_bp.route('/kb-popularity', methods=['GET'])
@jwt_required()
@jwt_required(optional=True)
def kb_popularity():
"""
Report: Most clicked knowledge base articles.
@@ -202,7 +202,7 @@ def kb_popularity():
# =============================================================================
@reports_bp.route('/warranty-status', methods=['GET'])
@jwt_required()
@jwt_required(optional=True)
def warranty_status():
"""
Report: Assets by warranty expiration status.
@@ -305,7 +305,7 @@ def warranty_status():
# =============================================================================
@reports_bp.route('/software-compliance', methods=['GET'])
@jwt_required()
@jwt_required(optional=True)
def software_compliance():
"""
Report: Required applications vs installed (per PC).
@@ -409,7 +409,7 @@ def software_compliance():
# =============================================================================
@reports_bp.route('/asset-inventory', methods=['GET'])
@jwt_required()
@jwt_required(optional=True)
def asset_inventory():
"""
Report: Complete asset inventory summary.
@@ -518,8 +518,68 @@ def asset_inventory():
# 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'])
@jwt_required()
@jwt_required(optional=True)
def list_reports():
"""List all available reports."""
reports = [
@@ -564,6 +624,13 @@ def list_reports():
'description': 'Complete asset inventory breakdown',
'endpoint': '/api/reports/asset-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'])
@jwt_required()
@jwt_required(optional=True)
def list_vendors():
"""List all vendors."""
page, per_page = get_pagination_params(request)
@@ -39,7 +39,7 @@ def list_vendors():
@vendors_bp.route('/<int:vendor_id>', methods=['GET'])
@jwt_required()
@jwt_required(optional=True)
def get_vendor(vendor_id: int):
"""Get a single vendor."""
v = Vendor.query.get(vendor_id)

View File

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

View File

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

View File

@@ -109,7 +109,7 @@ class PluginManager:
'dependencies': meta.dependencies,
'installed': state is not None,
'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:
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
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))
except (TypeError, ValueError):
per_page = default_size

View File

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