Add USB, Notifications, Network plugins and reusable EmployeeSearch component

New Plugins:
- USB plugin: Device checkout/checkin with employee lookup, checkout history
- Notifications plugin: Announcements with types, scheduling, shopfloor display
- Network plugin: Network device management with subnets and VLANs
- Equipment and Computers plugins: Asset type separation

Frontend:
- EmployeeSearch component: Reusable employee lookup with autocomplete
- USB views: List, detail, checkout/checkin modals
- Notifications views: List, form with recognition mode
- Network views: Device list, detail, form
- Calendar view with FullCalendar integration
- Shopfloor and TV dashboard views
- Reports index page
- Map editor for asset positioning
- Light/dark mode fixes for map tooltips

Backend:
- Employee search API with external lookup service
- Collector API for PowerShell data collection
- Reports API endpoints
- Slides API for TV dashboard
- Fixed AppVersion model (removed BaseModel inheritance)
- Added checkout_name column to usbcheckouts table

Styling:
- Unified detail page styles
- Improved pagination (page numbers instead of prev/next)
- Dark/light mode theme improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-01-21 16:37:49 -05:00
parent 02d83335ee
commit 9c220a4194
110 changed files with 17693 additions and 600 deletions

View File

@@ -8,6 +8,9 @@
"name": "shopdb-frontend", "name": "shopdb-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/vue3": "^6.1.20",
"axios": "^1.6.0", "axios": "^1.6.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"pinia": "^2.1.0", "pinia": "^2.1.0",
@@ -456,6 +459,34 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@fullcalendar/core": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
"license": "MIT",
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
"integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/vue3": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.20.tgz",
"integrity": "sha512-8qg6pS27II9QBwFkkJC+7SfflMpWqOe7i3ii5ODq9KpLAjwQAd/zjfq8RvKR1Yryoh5UmMCmvRbMB7i4RGtqog==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20",
"vue": "^3.0.11"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -1379,6 +1410,16 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",

View File

@@ -9,6 +9,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/vue3": "^6.1.20",
"axios": "^1.6.0", "axios": "^1.6.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"pinia": "^2.1.0", "pinia": "^2.1.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 KiB

View File

@@ -172,7 +172,7 @@ export const locationsApi = {
// Printers API // Printers API
export const printersApi = { export const printersApi = {
list(params = {}) { list(params = {}) {
return api.get('/printers/', { params }) return api.get('/printers', { params })
}, },
get(id) { get(id) {
return api.get(`/printers/${id}`) return api.get(`/printers/${id}`)
@@ -390,3 +390,267 @@ export const knowledgebaseApi = {
return api.get('/knowledgebase/stats') return api.get('/knowledgebase/stats')
} }
} }
// Assets API (unified)
export const assetsApi = {
list(params = {}) {
return api.get('/assets', { params })
},
get(id) {
return api.get(`/assets/${id}`)
},
create(data) {
return api.post('/assets', data)
},
update(id, data) {
return api.put(`/assets/${id}`, data)
},
delete(id) {
return api.delete(`/assets/${id}`)
},
getMap(params = {}) {
return api.get('/assets/map', { params })
},
// Relationships
getRelationships(id) {
return api.get(`/assets/${id}/relationships`)
},
createRelationship(data) {
return api.post('/assets/relationships', data)
},
deleteRelationship(relationshipId) {
return api.delete(`/assets/relationships/${relationshipId}`)
},
// Search assets (for relationship picker)
search(query, params = {}) {
return api.get('/assets', { params: { search: query, ...params } })
},
// Lookup asset by asset/machine number
lookup(assetnumber) {
return api.get(`/assets/lookup/${encodeURIComponent(assetnumber)}`)
},
types: {
list() {
return api.get('/assets/types')
},
get(id) {
return api.get(`/assets/types/${id}`)
}
},
statuses: {
list() {
return api.get('/assets/statuses')
}
}
}
// Notifications API
export const notificationsApi = {
list(params = {}) {
return api.get('/notifications', { params })
},
get(id) {
return api.get(`/notifications/${id}`)
},
create(data) {
return api.post('/notifications', data)
},
update(id, data) {
return api.put(`/notifications/${id}`, data)
},
delete(id) {
return api.delete(`/notifications/${id}`)
},
getActive() {
return api.get('/notifications/active')
},
getCalendar(params = {}) {
return api.get('/notifications/calendar', { params })
},
dashboardSummary() {
return api.get('/notifications/dashboard/summary')
},
getShopfloor(params = {}) {
return api.get('/notifications/shopfloor', { params })
},
getEmployeeRecognitions(sso) {
return api.get(`/notifications/employee/${sso}`)
},
types: {
list() {
return api.get('/notifications/types')
},
create(data) {
return api.post('/notifications/types', data)
}
}
}
// USB Devices API
export const usbApi = {
list(params = {}) {
return api.get('/usb', { params })
},
get(id) {
return api.get(`/usb/${id}`)
},
create(data) {
return api.post('/usb', data)
},
update(id, data) {
return api.put(`/usb/${id}`, data)
},
delete(id) {
return api.delete(`/usb/${id}`)
},
checkout(id, data) {
return api.post(`/usb/${id}/checkout`, data)
},
checkin(id, data = {}) {
return api.post(`/usb/${id}/checkin`, data)
},
getHistory(id, params = {}) {
return api.get(`/usb/${id}/history`, { params })
},
getAvailable() {
return api.get('/usb/available')
},
getCheckedOut() {
return api.get('/usb/checkedout')
},
getUserCheckouts(userId) {
return api.get(`/usb/user/${userId}`)
},
dashboardSummary() {
return api.get('/usb/dashboard/summary')
},
types: {
list() {
return api.get('/usb/types')
},
create(data) {
return api.post('/usb/types', data)
}
}
}
// Reports API
export const reportsApi = {
list() {
return api.get('/reports')
},
equipmentByType(params = {}) {
return api.get('/reports/equipment-by-type', { params })
},
assetsByStatus(params = {}) {
return api.get('/reports/assets-by-status', { params })
},
kbPopularity(params = {}) {
return api.get('/reports/kb-popularity', { params })
},
warrantyStatus(params = {}) {
return api.get('/reports/warranty-status', { params })
},
softwareCompliance(params = {}) {
return api.get('/reports/software-compliance', { params })
},
assetInventory(params = {}) {
return api.get('/reports/asset-inventory', { params })
}
}
// Employees API (wjf_employees database)
export const employeesApi = {
search(query, limit = 10) {
return api.get('/employees/search', { params: { q: query, limit } })
},
lookup(sso) {
return api.get(`/employees/lookup/${sso}`)
},
lookupMultiple(ssoList) {
return api.get('/employees/lookup', { params: { sso: ssoList } })
}
}
// Alias for different casing
export const businessUnitsApi = businessunitsApi
// Network API (devices, subnets, and VLANs)
export const networkApi = {
// Network devices
list(params = {}) {
return api.get('/network', { params })
},
get(id) {
return api.get(`/network/${id}`)
},
getByAsset(assetId) {
return api.get(`/network/by-asset/${assetId}`)
},
getByHostname(hostname) {
return api.get(`/network/by-hostname/${hostname}`)
},
create(data) {
return api.post('/network', data)
},
update(id, data) {
return api.put(`/network/${id}`, data)
},
delete(id) {
return api.delete(`/network/${id}`)
},
dashboardSummary() {
return api.get('/network/dashboard/summary')
},
// Network device types
types: {
list(params = {}) {
return api.get('/network/types', { params })
},
get(id) {
return api.get(`/network/types/${id}`)
},
create(data) {
return api.post('/network/types', data)
},
update(id, data) {
return api.put(`/network/types/${id}`, data)
}
},
// VLANs
vlans: {
list(params = {}) {
return api.get('/network/vlans', { params })
},
get(id) {
return api.get(`/network/vlans/${id}`)
},
create(data) {
return api.post('/network/vlans', data)
},
update(id, data) {
return api.put(`/network/vlans/${id}`, data)
},
delete(id) {
return api.delete(`/network/vlans/${id}`)
}
},
// Subnets
subnets: {
list(params = {}) {
return api.get('/network/subnets', { params })
},
get(id) {
return api.get(`/network/subnets/${id}`)
},
create(data) {
return api.post('/network/subnets', data)
},
update(id, data) {
return api.put(`/network/subnets/${id}`, data)
},
delete(id) {
return api.delete(`/network/subnets/${id}`)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,679 @@
<template>
<div class="relationships-section">
<div class="section-header">
<h3 class="section-title">Relationships</h3>
<button
v-if="authStore.isAuthenticated"
class="btn btn-sm btn-primary"
@click="showAddModal = true"
>
Add Relationship
</button>
</div>
<div v-if="loading" class="loading">Loading relationships...</div>
<div v-else-if="!hasRelationships" class="empty-state">
No relationships defined.
</div>
<template v-else>
<!-- Outgoing relationships (this asset controls/connects to...) -->
<div v-if="outgoing.length > 0" class="relationship-group">
<h4 class="group-title">Outgoing</h4>
<div class="relationship-list">
<div
v-for="rel in outgoing"
:key="rel.relationshipid"
class="relationship-item"
>
<div class="rel-icon">{{ getAssetIcon(rel.target_asset?.assettype) }}</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>
<div class="rel-meta">
<span class="badge badge-outline">{{ rel.relationship_type_name }}</span>
<span class="rel-type-badge">{{ rel.target_asset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
</div>
<button
v-if="authStore.isAuthenticated"
class="btn-icon delete"
@click="deleteRelationship(rel.relationshipid)"
title="Remove relationship"
>
&times;
</button>
</div>
</div>
</div>
<!-- Incoming relationships (...controls/connects to this asset) -->
<div v-if="incoming.length > 0" class="relationship-group">
<h4 class="group-title">Incoming</h4>
<div class="relationship-list">
<div
v-for="rel in incoming"
:key="rel.relationshipid"
class="relationship-item"
>
<div class="rel-icon">{{ getAssetIcon(rel.source_asset?.assettype) }}</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>
<div class="rel-meta">
<span class="badge badge-outline">{{ rel.relationship_type_name }}</span>
<span class="rel-type-badge">{{ rel.source_asset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
</div>
<button
v-if="authStore.isAuthenticated"
class="btn-icon delete"
@click="deleteRelationship(rel.relationshipid)"
title="Remove relationship"
>
&times;
</button>
</div>
</div>
</div>
</template>
<!-- Add Relationship Modal -->
<div v-if="showAddModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<div class="modal-header">
<h3>Add Relationship</h3>
<button class="btn-icon" @click="closeModal">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Direction</label>
<select v-model="newRel.direction" class="form-control">
<option value="outgoing">This asset Target asset</option>
<option value="incoming">Source asset This asset</option>
</select>
</div>
<div class="form-group">
<label>Relationship Type</label>
<select v-model="newRel.relationshiptypeid" class="form-control" required>
<option value="">Select type...</option>
<option
v-for="t in relationshipTypes"
:key="t.relationshiptypeid"
:value="t.relationshiptypeid"
>
{{ t.relationshiptype }}
</option>
</select>
</div>
<div class="form-group">
<label>{{ newRel.direction === 'outgoing' ? 'Target Asset' : 'Source Asset' }}</label>
<div class="asset-search">
<input
type="text"
v-model="assetSearchQuery"
class="form-control"
placeholder="Search assets..."
@input="searchAssets"
/>
<div v-if="assetSearchResults.length > 0" class="search-results">
<div
v-for="asset in assetSearchResults"
:key="asset.assetid"
class="search-result-item"
:class="{ selected: newRel.targetAssetId === asset.assetid }"
@click="selectAsset(asset)"
>
<span class="result-icon">{{ getAssetIcon(asset.assettype) }}</span>
<span class="result-name">{{ asset.name || asset.assetnumber }}</span>
<span class="result-type">{{ asset.assettype }}</span>
</div>
</div>
</div>
<div v-if="selectedAsset" class="selected-asset">
<span class="result-icon">{{ getAssetIcon(selectedAsset.assettype) }}</span>
<span>{{ selectedAsset.name || selectedAsset.assetnumber }}</span>
<button class="btn-icon" @click="clearSelectedAsset">&times;</button>
</div>
</div>
<div class="form-group">
<label>Notes (optional)</label>
<textarea
v-model="newRel.notes"
class="form-control"
rows="2"
placeholder="Additional notes about this relationship..."
></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="closeModal">Cancel</button>
<button
class="btn btn-primary"
:disabled="!canSave"
@click="saveRelationship"
>
Add Relationship
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { assetsApi, relationshipTypesApi } from '../api'
import { useAuthStore } from '../stores/auth'
const props = defineProps({
assetId: {
type: Number,
default: null
},
// Alternative: lookup by machine/asset number
machineNumber: {
type: String,
default: null
}
})
const emit = defineEmits(['updated'])
const authStore = useAuthStore()
const loading = ref(true)
const resolvedAssetId = ref(null)
const lookupFailed = ref(false)
const outgoing = ref([])
const incoming = ref([])
const relationshipTypes = ref([])
const showAddModal = ref(false)
// New relationship form
const newRel = ref({
direction: 'outgoing',
relationshiptypeid: '',
targetAssetId: null,
notes: ''
})
const assetSearchQuery = ref('')
const assetSearchResults = ref([])
const selectedAsset = ref(null)
let searchTimeout = null
const hasRelationships = computed(() => outgoing.value.length > 0 || incoming.value.length > 0)
const canSave = computed(() => {
return newRel.value.relationshiptypeid && newRel.value.targetAssetId && resolvedAssetId.value
})
onMounted(async () => {
await resolveAssetId()
if (resolvedAssetId.value) {
await Promise.all([loadRelationships(), loadRelationshipTypes()])
}
})
watch(() => props.assetId, async () => {
await resolveAssetId()
if (resolvedAssetId.value) {
await loadRelationships()
}
})
watch(() => props.machineNumber, async () => {
await resolveAssetId()
if (resolvedAssetId.value) {
await loadRelationships()
}
})
async function resolveAssetId() {
// If assetId is provided directly, use it
if (props.assetId) {
resolvedAssetId.value = props.assetId
lookupFailed.value = false
return
}
// Otherwise, try to look up by machine number
if (props.machineNumber) {
try {
const response = await assetsApi.lookup(props.machineNumber)
resolvedAssetId.value = response.data.data?.assetid
lookupFailed.value = !resolvedAssetId.value
} catch (error) {
console.log('Asset lookup failed for:', props.machineNumber)
resolvedAssetId.value = null
lookupFailed.value = true
loading.value = false
}
}
}
async function loadRelationships() {
if (!resolvedAssetId.value) return
loading.value = true
try {
const response = await assetsApi.getRelationships(resolvedAssetId.value)
outgoing.value = response.data.data?.outgoing || []
incoming.value = response.data.data?.incoming || []
} catch (error) {
console.error('Failed to load relationships:', error)
} finally {
loading.value = false
}
}
async function loadRelationshipTypes() {
try {
const response = await relationshipTypesApi.list()
relationshipTypes.value = response.data.data || []
} catch (error) {
console.error('Failed to load relationship types:', error)
}
}
function searchAssets() {
clearTimeout(searchTimeout)
if (!assetSearchQuery.value || assetSearchQuery.value.length < 2) {
assetSearchResults.value = []
return
}
searchTimeout = setTimeout(async () => {
try {
const response = await assetsApi.search(assetSearchQuery.value, { per_page: 10 })
// Filter out the current asset
assetSearchResults.value = (response.data.data || []).filter(
a => a.assetid !== resolvedAssetId.value
)
} catch (error) {
console.error('Failed to search assets:', error)
}
}, 300)
}
function selectAsset(asset) {
selectedAsset.value = asset
newRel.value.targetAssetId = asset.assetid
assetSearchQuery.value = ''
assetSearchResults.value = []
}
function clearSelectedAsset() {
selectedAsset.value = null
newRel.value.targetAssetId = null
}
async function saveRelationship() {
if (!canSave.value) return
try {
const data = {
relationshiptypeid: parseInt(newRel.value.relationshiptypeid),
notes: newRel.value.notes || null
}
if (newRel.value.direction === 'outgoing') {
data.source_assetid = resolvedAssetId.value
data.target_assetid = newRel.value.targetAssetId
} else {
data.source_assetid = newRel.value.targetAssetId
data.target_assetid = resolvedAssetId.value
}
await assetsApi.createRelationship(data)
await loadRelationships()
closeModal()
emit('updated')
} catch (error) {
console.error('Failed to create relationship:', error)
alert('Failed to create relationship: ' + (error.response?.data?.message || error.message))
}
}
async function deleteRelationship(relationshipId) {
if (!confirm('Remove this relationship?')) return
try {
await assetsApi.deleteRelationship(relationshipId)
await loadRelationships()
emit('updated')
} catch (error) {
console.error('Failed to delete relationship:', error)
alert('Failed to delete relationship')
}
}
function closeModal() {
showAddModal.value = false
newRel.value = {
direction: 'outgoing',
relationshiptypeid: '',
targetAssetId: null,
notes: ''
}
selectedAsset.value = null
assetSearchQuery.value = ''
assetSearchResults.value = []
}
function getAssetIcon(assettype) {
const icons = {
'equipment': '⚙',
'computer': '💻',
'printer': '🖨',
'network_device': '🌐'
}
return icons[assettype] || '📦'
}
function getAssetRoute(asset) {
if (!asset) return '#'
const routeMap = {
'equipment': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network'
}
const basePath = routeMap[asset.assettype] || '/assets'
// Use typedata ID if available
if (asset.assettype === 'network_device' && asset.typedata?.networkdeviceid) {
return `/network/${asset.typedata.networkdeviceid}`
}
// For equipment/computer/printer, use machineid from typedata or assetid
const id = asset.typedata?.machineid || asset.assetid
return `${basePath}/${id}`
}
</script>
<style scoped>
.relationships-section {
background: var(--bg-card);
border-radius: 8px;
border: 1px solid var(--border);
padding: 1.25rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.empty-state {
color: var(--text-light);
font-style: italic;
padding: 1rem 0;
}
.relationship-group {
margin-bottom: 1.5rem;
}
.relationship-group:last-child {
margin-bottom: 0;
}
.group-title {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.75rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.relationship-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.relationship-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
}
.rel-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.rel-content {
flex: 1;
min-width: 0;
}
.rel-name {
font-weight: 500;
color: var(--link);
text-decoration: none;
}
.rel-name:hover {
text-decoration: underline;
}
.rel-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.badge-outline {
background: transparent;
border: 1px solid var(--primary);
color: var(--primary);
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
border-radius: 4px;
}
.rel-type-badge {
font-size: 0.75rem;
color: var(--text-light);
}
.rel-notes {
font-size: 0.8rem;
color: var(--text-light);
margin-top: 0.25rem;
}
.btn-icon.delete {
background: none;
border: none;
color: var(--danger);
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
opacity: 0.6;
}
.btn-icon.delete:hover {
opacity: 1;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--bg-card-solid);
border-radius: 12px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--border);
color: var(--text);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
margin: 0;
font-size: 1.1rem;
}
.modal-header .btn-icon {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-light);
line-height: 1;
}
.modal-body {
padding: 1.25rem;
overflow-y: auto;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border);
}
.form-group {
margin-bottom: 1rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
/* Asset Search */
.asset-search {
position: relative;
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card-solid);
border: 1px solid var(--border);
border-radius: 6px;
max-height: 200px;
overflow-y: auto;
z-index: 10;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.search-result-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover {
background: var(--bg);
}
.search-result-item.selected {
background: rgba(65, 129, 255, 0.15);
}
.result-icon {
font-size: 1rem;
}
.result-name {
flex: 1;
font-weight: 500;
}
.result-type {
font-size: 0.75rem;
color: var(--text-light);
}
.selected-asset {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(65, 129, 255, 0.1);
border: 1px solid var(--primary);
border-radius: 6px;
}
.selected-asset .btn-icon {
margin-left: auto;
background: none;
border: none;
font-size: 1rem;
cursor: pointer;
color: var(--text-light);
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<div class="embedded-map" ref="mapContainer"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
const props = defineProps({
left: { type: Number, default: null },
top: { type: Number, default: null },
markerColor: { type: String, default: '#ff0000' },
markerLabel: { type: String, default: '' }
})
const mapContainer = ref(null)
let map = null
let marker = null
// Map dimensions
const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
// Detect system color scheme
function getTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function initMap() {
if (!mapContainer.value || props.left === null || props.top === null) return
map = L.map(mapContainer.value, {
crs: L.CRS.Simple,
minZoom: -3,
maxZoom: 2,
attributionControl: false,
zoomControl: true
})
const theme = getTheme()
const blueprintUrl = theme === 'light'
? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png'
L.imageOverlay(blueprintUrl, bounds).addTo(map)
// Convert database coordinates to Leaflet (y is inverted)
const leafletY = MAP_HEIGHT - props.top
const leafletX = props.left
// Create marker
const icon = L.divIcon({
html: `<div class="location-marker-dot" style="background: ${props.markerColor};"></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10],
className: 'location-marker'
})
marker = L.marker([leafletY, leafletX], { icon })
if (props.markerLabel) {
marker.bindTooltip(props.markerLabel, {
permanent: true,
direction: 'top',
offset: [0, -10],
className: 'location-label'
})
}
marker.addTo(map)
// Center on marker with appropriate zoom
map.setView([leafletY, leafletX], -1)
map.setMaxBounds(bounds)
}
onMounted(() => {
initMap()
})
onUnmounted(() => {
if (map) {
map.remove()
map = null
}
})
watch([() => props.left, () => props.top], () => {
if (map) {
map.remove()
map = null
}
initMap()
})
</script>
<style scoped>
.embedded-map {
width: 100%;
height: 300px;
border-radius: 8px;
overflow: hidden;
background: var(--bg);
}
:deep(.location-marker) {
background: transparent !important;
border: none !important;
}
:deep(.location-marker-dot) {
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid #fff;
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
}
50% {
box-shadow: 0 0 0 6px rgba(255,0,0,0.2), 0 2px 8px rgba(0,0,0,0.4);
}
}
:deep(.location-label) {
background: rgba(0, 0, 0, 0.85);
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 0.875rem;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
:deep(.location-label::before) {
border-top-color: rgba(0, 0, 0, 0.85);
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<div class="employee-search">
<div class="employee-search-container">
<input
v-model="searchQuery"
type="text"
class="form-control"
:placeholder="placeholder"
:disabled="disabled"
@input="onSearch"
@keydown.enter.prevent="addCustom"
/>
<div v-if="results.length" class="employee-dropdown">
<div
v-for="emp in results"
:key="emp.SSO"
class="employee-option"
@click="selectEmployee(emp)"
>
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
<span class="emp-sso">{{ emp.SSO }}</span>
</div>
</div>
</div>
<small v-if="allowCustom" class="form-hint">Search for employees or press Enter to add a custom name</small>
<!-- Selected employee(s) display -->
<div v-if="multiple && selectedList.length" class="selected-employees">
<div
v-for="(emp, idx) in selectedList"
:key="idx"
class="selected-employee"
>
<span>{{ emp.name }}</span>
<span v-if="emp.sso && !emp.sso.startsWith('NAME:')" class="emp-sso-tag">{{ emp.sso }}</span>
<button v-if="!disabled" type="button" class="btn-remove" @click="removeEmployee(idx)">&times;</button>
</div>
</div>
<div v-else-if="!multiple && selected" class="selected-employee-display">
<span>{{ selected.name }}</span>
<span v-if="selected.sso" class="emp-sso-tag">{{ selected.sso }}</span>
<button v-if="!disabled" type="button" class="btn-remove" @click="clearSelection">&times;</button>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { employeesApi } from '../api'
const props = defineProps({
modelValue: { type: [Object, Array], default: null },
multiple: { type: Boolean, default: false },
allowCustom: { type: Boolean, default: false },
placeholder: { type: String, default: 'Search by name...' },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
const searchQuery = ref('')
const results = ref([])
const selected = ref(null)
const selectedList = ref([])
let searchTimeout = null
// Initialize from modelValue
watch(() => props.modelValue, (val) => {
if (props.multiple) {
selectedList.value = val || []
} else {
selected.value = val
}
}, { immediate: true })
async function onSearch() {
if (searchTimeout) clearTimeout(searchTimeout)
const query = searchQuery.value.trim()
if (query.length < 2) {
results.value = []
return
}
searchTimeout = setTimeout(async () => {
try {
const res = await employeesApi.search(query)
results.value = res.data.data || []
} catch (err) {
console.error('Employee search error:', err)
results.value = []
}
}, 300)
}
function selectEmployee(emp) {
const employee = {
sso: String(emp.SSO),
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
}
if (props.multiple) {
// Check if already selected
if (selectedList.value.some(e => e.sso === employee.sso)) {
return
}
selectedList.value.push(employee)
emit('update:modelValue', selectedList.value)
} else {
selected.value = employee
emit('update:modelValue', employee)
}
searchQuery.value = ''
results.value = []
}
function addCustom() {
if (!props.allowCustom) return
const name = searchQuery.value.trim()
if (!name) return
const employee = {
sso: `NAME:${name}`,
name: name
}
if (props.multiple) {
selectedList.value.push(employee)
emit('update:modelValue', selectedList.value)
} else {
selected.value = employee
emit('update:modelValue', employee)
}
searchQuery.value = ''
results.value = []
}
function removeEmployee(idx) {
selectedList.value.splice(idx, 1)
emit('update:modelValue', selectedList.value)
}
function clearSelection() {
selected.value = null
emit('update:modelValue', null)
}
</script>
<style scoped>
.employee-search-container {
position: relative;
}
.employee-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card-solid, #1a1a1a);
border: 1px solid var(--border);
border-radius: 0.25rem;
max-height: 200px;
overflow-y: auto;
z-index: 100;
}
.employee-option {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.employee-option:hover {
background: rgba(255, 255, 255, 0.1);
}
.emp-name {
font-weight: 500;
}
.emp-sso {
font-size: 0.85rem;
color: var(--text-light);
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
/* Multiple selection */
.selected-employees {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.selected-employee {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--primary);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.9rem;
}
/* Single selection */
.selected-employee-display {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--primary);
color: white;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
margin-top: 0.5rem;
}
.emp-sso-tag {
font-size: 0.8rem;
opacity: 0.8;
}
.btn-remove {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
font-size: 1.2rem;
line-height: 1;
opacity: 0.7;
margin-left: auto;
}
.btn-remove:hover {
opacity: 1;
}
</style>

View File

@@ -41,13 +41,28 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, nextTick, watch } from 'vue' import { ref, computed, nextTick, watch, onMounted, onUnmounted } from 'vue'
const props = defineProps({ const props = defineProps({
left: { type: Number, default: null }, left: { type: Number, default: null },
top: { type: Number, default: null }, top: { type: Number, default: null },
machineName: { type: String, default: '' }, machineName: { type: String, default: '' }
theme: { type: String, default: 'dark' } })
// Auto-detect system theme with reactive updates
const systemTheme = ref(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
function handleThemeChange(e) {
systemTheme.value = e.matches ? 'dark' : 'light'
}
onMounted(() => {
mediaQuery.addEventListener('change', handleThemeChange)
})
onUnmounted(() => {
mediaQuery.removeEventListener('change', handleThemeChange)
}) })
const visible = ref(false) const visible = ref(false)
@@ -67,7 +82,7 @@ const hasPosition = computed(() => {
}) })
const blueprintUrl = computed(() => { const blueprintUrl = computed(() => {
return props.theme === 'light' return systemTheme.value === 'light'
? '/static/images/sitemap2025-light.png' ? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png' : '/static/images/sitemap2025-dark.png'
}) })
@@ -202,9 +217,9 @@ watch(visible, (newVal) => {
} }
.map-tooltip-content { .map-tooltip-content {
background: var(--bg-card); background: var(--bg-card, #ffffff);
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border); border: 1px solid var(--border, #e0e0e0);
box-shadow: 0 4px 20px rgba(0,0,0,0.25); box-shadow: 0 4px 20px rgba(0,0,0,0.25);
overflow: hidden; overflow: hidden;
} }
@@ -214,7 +229,7 @@ watch(visible, (newVal) => {
width: 500px; width: 500px;
height: 385px; height: 385px;
overflow: hidden; overflow: hidden;
background: var(--bg); background: var(--bg, #f5f5f5);
} }
.map-transform { .map-transform {
@@ -235,7 +250,7 @@ watch(visible, (newVal) => {
width: 16px; width: 16px;
height: 16px; height: 16px;
background: #ff0000; background: #ff0000;
border: 2px solid var(--border); border: 2px solid #ffffff;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000; box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000;
pointer-events: none; pointer-events: none;
@@ -243,8 +258,8 @@ watch(visible, (newVal) => {
.map-tooltip-footer { .map-tooltip-footer {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: var(--bg); background: var(--bg, #f5f5f5);
border-top: 1px solid var(--border); border-top: 1px solid var(--border, #e0e0e0);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -253,11 +268,11 @@ watch(visible, (newVal) => {
.coordinates { .coordinates {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-light); color: var(--text-light, #666666);
} }
.zoom-hint { .zoom-hint {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-light); color: var(--text-light, #666666);
} }
</style> </style>

View File

@@ -72,13 +72,14 @@ function handleEscape(e) {
} }
.modal-container { .modal-container {
background: white; background: var(--bg-card-solid);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: 90vh; max-height: 90vh;
overflow: hidden; overflow: hidden;
color: var(--text);
} }
.modal-small { .modal-small {
@@ -106,7 +107,7 @@ function handleEscape(e) {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border);
} }
.modal-header h3 { .modal-header h3 {
@@ -119,13 +120,13 @@ function handleEscape(e) {
border: none; border: none;
font-size: 1.5rem; font-size: 1.5rem;
cursor: pointer; cursor: pointer;
color: #666; color: var(--text-light);
padding: 0; padding: 0;
line-height: 1; line-height: 1;
} }
.modal-close:hover { .modal-close:hover {
color: #333; color: var(--text);
} }
.modal-body { .modal-body {
@@ -136,7 +137,7 @@ function handleEscape(e) {
.modal-footer { .modal-footer {
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-top: 1px solid #e0e0e0; border-top: 1px solid var(--border);
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 0.5rem; gap: 0.5rem;

View File

@@ -67,7 +67,8 @@ const props = defineProps({
statuses: { type: Array, default: () => [] }, statuses: { type: Array, default: () => [] },
theme: { type: String, default: 'dark' }, theme: { type: String, default: 'dark' },
pickerMode: { type: Boolean, default: false }, pickerMode: { type: Boolean, default: false },
initialPosition: { type: Object, default: null } // { left, top } initialPosition: { type: Object, default: null }, // { left, top }
assetTypeMode: { type: Boolean, default: false } // When true, use unified asset format
}) })
const emit = defineEmits(['markerClick', 'positionPicked']) const emit = defineEmits(['markerClick', 'positionPicked'])
@@ -91,6 +92,14 @@ const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550 const MAP_HEIGHT = 2550
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]] const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
// Asset type colors (for unified map mode)
const assetTypeColors = {
'equipment': '#F44336', // Red
'computer': '#2196F3', // Blue
'printer': '#4CAF50', // Green
'network_device': '#FF9800' // Orange
}
// Type colors - distinct colors for each machine type // Type colors - distinct colors for each machine type
const typeColors = { const typeColors = {
// Machining // Machining
@@ -158,7 +167,8 @@ function initMap() {
map = L.map(mapContainer.value, { map = L.map(mapContainer.value, {
crs: L.CRS.Simple, crs: L.CRS.Simple,
minZoom: -4, minZoom: -4,
maxZoom: 2 maxZoom: 2,
attributionControl: false
}) })
const blueprintUrl = props.theme === 'light' const blueprintUrl = props.theme === 'light'
@@ -168,8 +178,8 @@ function initMap() {
imageOverlay = L.imageOverlay(blueprintUrl, bounds) imageOverlay = L.imageOverlay(blueprintUrl, bounds)
imageOverlay.addTo(map) imageOverlay.addTo(map)
// Set initial view // Set initial view - zoom out to show full floor plan
const initialZoom = -1 const initialZoom = -2
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], initialZoom) map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], initialZoom)
map.setMaxBounds(bounds) map.setMaxBounds(bounds)
@@ -263,15 +273,29 @@ function renderMarkers() {
markers.value.forEach(m => m.marker.remove()) markers.value.forEach(m => m.marker.remove())
markers.value = [] markers.value = []
props.machines.forEach(machine => { props.machines.forEach(item => {
if (machine.mapleft == null || machine.maptop == null) return if (item.mapleft == null || item.maptop == null) return
// Transform coordinates (database Y is top-down, Leaflet is bottom-up) // Transform coordinates (database Y is top-down, Leaflet is bottom-up)
const leafletY = MAP_HEIGHT - machine.maptop const leafletY = MAP_HEIGHT - item.maptop
const leafletX = machine.mapleft const leafletX = item.mapleft
const typeName = machine.machinetype || '' // Determine color based on mode
const color = getTypeColor(typeName) let color, typeName, displayName, detailRoute
if (props.assetTypeMode) {
// Unified asset mode
color = assetTypeColors[item.assettype] || '#BDBDBD'
typeName = item.assettype || ''
displayName = item.displayname || item.name || item.assetnumber || 'Unknown'
detailRoute = getAssetDetailRoute(item)
} else {
// Legacy machine mode
typeName = item.machinetype || ''
color = getTypeColor(typeName)
displayName = item.alias || item.machinenumber || 'Unknown'
detailRoute = getDetailRoute(item)
}
const icon = L.divIcon({ const icon = L.divIcon({
html: `<div class="machine-marker-dot" style="background: ${color};"></div>`, html: `<div class="machine-marker-dot" style="background: ${color};"></div>`,
@@ -283,45 +307,56 @@ function renderMarkers() {
const marker = L.marker([leafletY, leafletX], { icon }) const marker = L.marker([leafletY, leafletX], { icon })
// Display name for tooltips and popups // Build tooltip content
const displayName = machine.alias || machine.machinenumber || 'Unknown'
const detailRoute = getDetailRoute(machine)
const category = machine.category?.toLowerCase() || ''
// Build tooltip content based on category
let tooltipLines = [`<strong>${displayName}</strong>`] let tooltipLines = [`<strong>${displayName}</strong>`]
// Don't show "LocationOnly" as machine type if (props.assetTypeMode) {
// Asset mode tooltips
const assetTypeLabel = {
'equipment': 'Equipment',
'computer': 'Computer',
'printer': 'Printer',
'network_device': 'Network Device'
}
tooltipLines.push(`<span style="color: #888;">${assetTypeLabel[item.assettype] || item.assettype}</span>`)
if (item.primaryip) {
tooltipLines.push(`<span style="color: #8cf;">IP: ${item.primaryip}</span>`)
}
if (item.typedata?.hostname) {
tooltipLines.push(`<span style="color: #8cf;">${item.typedata.hostname}</span>`)
}
} else {
// Legacy machine mode tooltips
const category = item.category?.toLowerCase() || ''
if (typeName && typeName.toLowerCase() !== 'locationonly') { if (typeName && typeName.toLowerCase() !== 'locationonly') {
tooltipLines.push(`<span style="color: #888;">${typeName}</span>`) tooltipLines.push(`<span style="color: #888;">${typeName}</span>`)
} }
// Add vendor and model if available if (item.vendor) {
if (machine.vendor) { tooltipLines.push(`<span style="color: #aaa;">${item.vendor}${item.model ? ' ' + item.model : ''}</span>`)
tooltipLines.push(`<span style="color: #aaa;">${machine.vendor}${machine.model ? ' ' + machine.model : ''}</span>`) } else if (item.model) {
} else if (machine.model) { tooltipLines.push(`<span style="color: #aaa;">${item.model}</span>`)
tooltipLines.push(`<span style="color: #aaa;">${machine.model}</span>`)
} }
// Category-specific info
if (category === 'printer') { if (category === 'printer') {
// Printers: show IP and hostname if (item.ipaddress) {
if (machine.ipaddress) { tooltipLines.push(`<span style="color: #8cf;">IP: ${item.ipaddress}</span>`)
tooltipLines.push(`<span style="color: #8cf;">IP: ${machine.ipaddress}</span>`)
} }
if (machine.hostname) { if (item.hostname) {
tooltipLines.push(`<span style="color: #8cf;">${machine.hostname}</span>`) tooltipLines.push(`<span style="color: #8cf;">${item.hostname}</span>`)
} }
} else { } else {
// Equipment/PC: show connected PC if available if (item.connected_pc) {
if (machine.connected_pc) { tooltipLines.push(`<span style="color: #fc8;">PC: ${item.connected_pc}</span>`)
tooltipLines.push(`<span style="color: #fc8;">PC: ${machine.connected_pc}</span>`) }
} }
} }
// Business unit // Business unit
if (machine.businessunit) { if (item.businessunit) {
tooltipLines.push(`<span style="color: #ccc;">${machine.businessunit}</span>`) tooltipLines.push(`<span style="color: #ccc;">${item.businessunit}</span>`)
} }
const tooltipContent = tooltipLines.join('<br>') const tooltipContent = tooltipLines.join('<br>')
@@ -332,36 +367,76 @@ function renderMarkers() {
}) })
// Click popup (detailed info) // Click popup (detailed info)
const popupContent = ` let popupContent
if (props.assetTypeMode) {
popupContent = `
<div class="marker-popup"> <div class="marker-popup">
<strong>${displayName}</strong> <strong>${displayName}</strong>
<div class="popup-details"> <div class="popup-details">
<div><span class="label">Number:</span> ${machine.machinenumber || '-'}</div> <div><span class="label">Asset #:</span> ${item.assetnumber || '-'}</div>
<div><span class="label">Type:</span> ${typeName || '-'}</div> <div><span class="label">Type:</span> ${item.assettype || '-'}</div>
<div><span class="label">Category:</span> ${machine.category || '-'}</div> <div><span class="label">Status:</span> ${item.status || '-'}</div>
<div><span class="label">Status:</span> ${machine.status || '-'}</div> <div><span class="label">Location:</span> ${item.location || '-'}</div>
<div><span class="label">Vendor:</span> ${machine.vendor || '-'}</div> ${item.primaryip ? `<div><span class="label">IP:</span> ${item.primaryip}</div>` : ''}
<div><span class="label">Model:</span> ${machine.model || '-'}</div>
</div> </div>
<a href="${detailRoute}" class="popup-link">View Details</a> <a href="${detailRoute}" class="popup-link">View Details</a>
</div> </div>
` `
} else {
popupContent = `
<div class="marker-popup">
<strong>${displayName}</strong>
<div class="popup-details">
<div><span class="label">Number:</span> ${item.machinenumber || '-'}</div>
<div><span class="label">Type:</span> ${typeName || '-'}</div>
<div><span class="label">Category:</span> ${item.category || '-'}</div>
<div><span class="label">Status:</span> ${item.status || '-'}</div>
<div><span class="label">Vendor:</span> ${item.vendor || '-'}</div>
<div><span class="label">Model:</span> ${item.model || '-'}</div>
</div>
<a href="${detailRoute}" class="popup-link">View Details</a>
</div>
`
}
marker.bindPopup(popupContent) marker.bindPopup(popupContent)
marker.on('click', () => emit('markerClick', machine)) marker.on('click', () => emit('markerClick', item))
marker.addTo(map) marker.addTo(map)
// Build search data
const searchData = props.assetTypeMode
? `${item.assetnumber} ${item.name} ${item.displayname} ${item.assettype} ${item.status} ${item.businessunit} ${item.typedata?.hostname || ''} ${item.primaryip || ''}`.toLowerCase()
: `${item.machinenumber} ${item.alias} ${typeName} ${item.vendor} ${item.model} ${item.serialnumber} ${item.businessunit}`.toLowerCase()
markers.value.push({ markers.value.push({
marker, marker,
machine, machine: item,
searchData: `${machine.machinenumber} ${machine.alias} ${typeName} ${machine.vendor} ${machine.model} ${machine.serialnumber} ${machine.businessunit}`.toLowerCase() searchData
}) })
}) })
applyFilters() applyFilters()
} }
// Get detail route for unified asset format
function getAssetDetailRoute(asset) {
const routeMap = {
'equipment': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network'
}
const basePath = routeMap[asset.assettype] || '/machines'
if (asset.assettype === 'network_device' && asset.typedata?.networkdeviceid) {
return `/network/${asset.typedata.networkdeviceid}`
}
const id = asset.typedata?.machineid || asset.assetid
return `${basePath}/${id}`
}
function applyFilters() { function applyFilters() {
const searchTerm = filters.value.search.toLowerCase() const searchTerm = filters.value.search.toLowerCase()
@@ -445,15 +520,30 @@ onUnmounted(() => {
padding: 0.625rem 1rem; padding: 0.625rem 1rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
font-size: 1.125rem; font-size: 1rem;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
} }
.filters select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2.25rem;
min-width: 160px;
}
.filters input { .filters input {
width: 280px; width: 280px;
} }
.filters input::placeholder {
color: var(--text-light);
opacity: 0.7;
}
.filters input::placeholder { .filters input::placeholder {
color: var(--text-light); color: var(--text-light);
} }

View File

@@ -3,6 +3,9 @@ import { createPinia } from 'pinia'
import router from './router' import router from './router'
import App from './App.vue' import App from './App.vue'
// Initialize theme on app load
import './stores/theme'
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) app.use(createPinia())

View File

@@ -22,6 +22,16 @@ import KnowledgeBaseList from '../views/knowledgebase/KnowledgeBaseList.vue'
import KnowledgeBaseDetail from '../views/knowledgebase/KnowledgeBaseDetail.vue' import KnowledgeBaseDetail from '../views/knowledgebase/KnowledgeBaseDetail.vue'
import KnowledgeBaseForm from '../views/knowledgebase/KnowledgeBaseForm.vue' import KnowledgeBaseForm from '../views/knowledgebase/KnowledgeBaseForm.vue'
import SearchResults from '../views/SearchResults.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 SettingsIndex from '../views/settings/SettingsIndex.vue'
import MachineTypesList from '../views/settings/MachineTypesList.vue' import MachineTypesList from '../views/settings/MachineTypesList.vue'
import LocationsList from '../views/settings/LocationsList.vue' import LocationsList from '../views/settings/LocationsList.vue'
@@ -30,7 +40,13 @@ import ModelsList from '../views/settings/ModelsList.vue'
import PCTypesList from '../views/settings/PCTypesList.vue' import PCTypesList from '../views/settings/PCTypesList.vue'
import OperatingSystemsList from '../views/settings/OperatingSystemsList.vue' import OperatingSystemsList from '../views/settings/OperatingSystemsList.vue'
import BusinessUnitsList from '../views/settings/BusinessUnitsList.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 MapView from '../views/MapView.vue'
import MapEditor from '../views/MapEditor.vue'
import ShopfloorDashboard from '../views/ShopfloorDashboard.vue'
import TVDashboard from '../views/TVDashboard.vue'
import EmployeeDetail from '../views/employees/EmployeeDetail.vue'
const routes = [ const routes = [
{ {
@@ -39,6 +55,17 @@ const routes = [
component: Login, component: Login,
meta: { guest: true } meta: { guest: true }
}, },
// Standalone full-screen dashboards (no sidebar, no auth required)
{
path: '/shopfloor',
name: 'shopfloor',
component: ShopfloorDashboard
},
{
path: '/tv',
name: 'tv',
component: TVDashboard
},
{ {
path: '/', path: '/',
component: AppLayout, component: AppLayout,
@@ -124,6 +151,12 @@ const routes = [
name: 'map', name: 'map',
component: MapView component: MapView
}, },
{
path: 'map/editor',
name: 'map-editor',
component: MapEditor,
meta: { requiresAuth: true }
},
{ {
path: 'applications', path: 'applications',
name: 'applications', name: 'applications',
@@ -168,6 +201,82 @@ const routes = [
component: KnowledgeBaseForm, component: KnowledgeBaseForm,
meta: { requiresAuth: true } 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', path: 'settings',
name: 'settings', name: 'settings',
@@ -221,6 +330,23 @@ const routes = [
name: 'businessunits', name: 'businessunits',
component: BusinessUnitsList, component: BusinessUnitsList,
meta: { requiresAuth: true, requiresAdmin: true } meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/vlans',
name: 'vlans',
component: VLANsList,
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/subnets',
name: 'subnets',
component: SubnetsList,
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'employees/:sso',
name: 'employee-detail',
component: EmployeeDetail
} }
] ]
} }

View File

@@ -0,0 +1,39 @@
import { ref, watch } from 'vue'
const STORAGE_KEY = 'shopdb-theme'
// Get initial theme from localStorage or system preference
function getInitialTheme() {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) return stored
// Fall back to system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark'
}
return 'light'
}
export const currentTheme = ref(getInitialTheme())
// Apply theme to document
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem(STORAGE_KEY, theme)
}
// Initialize on load
applyTheme(currentTheme.value)
// Watch for changes
watch(currentTheme, (newTheme) => {
applyTheme(newTheme)
})
export function toggleTheme() {
currentTheme.value = currentTheme.value === 'dark' ? 'light' : 'dark'
}
export function setTheme(theme) {
currentTheme.value = theme
}

View File

@@ -17,15 +17,35 @@
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<router-link to="/">Dashboard</router-link> <router-link to="/">Dashboard</router-link>
<router-link to="/calendar">Calendar</router-link>
<router-link to="/map">Map</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="/machines">Equipment</router-link>
<router-link to="/pcs">PCs</router-link> <router-link to="/pcs">PCs</router-link>
<router-link to="/printers">Printers</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="/applications">Applications</router-link>
<router-link to="/knowledgebase">Knowledge Base</router-link> <router-link to="/knowledgebase">Knowledge Base</router-link>
<router-link to="/notifications">Notifications</router-link>
<router-link to="/reports">Reports</router-link>
<div class="nav-section">Displays</div>
<a href="/shopfloor" target="_blank" class="external-link">Shopfloor Dashboard</a>
<a href="/tv" target="_blank" class="external-link">TV Slideshow</a>
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link> <router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
</nav> </nav>
<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>
</button>
<div class="user-menu"> <div class="user-menu">
<template v-if="authStore.isAuthenticated"> <template v-if="authStore.isAuthenticated">
<div class="username">{{ authStore.username }}</div> <div class="username">{{ authStore.username }}</div>
@@ -33,6 +53,7 @@
</template> </template>
<router-link v-else to="/login" class="btn btn-primary">Login</router-link> <router-link v-else to="/login" class="btn btn-primary">Login</router-link>
</div> </div>
</div>
</aside> </aside>
<main class="main-content"> <main class="main-content">
@@ -45,6 +66,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()

View File

@@ -0,0 +1,379 @@
<template>
<div class="page-header">
<h1>Calendar</h1>
</div>
<div class="calendar-container card">
<FullCalendar :options="calendarOptions" />
</div>
<!-- More events tooltip -->
<div
v-if="moreTooltipData.length"
ref="moreTooltip"
class="fc-more-tooltip"
:style="{ left: tooltipPosition.left + 'px', top: tooltipPosition.top + 'px' }"
@mouseleave="hideMoreTooltip"
>
<div
v-for="(evt, idx) in moreTooltipData"
:key="idx"
class="fc-more-tooltip-event"
:style="{ borderLeftColor: evt.color }"
@click="openEventFromTooltip(evt)"
>
{{ evt.title }}
</div>
</div>
<!-- Event details modal -->
<div v-if="selectedEvent" class="modal-overlay" @click.self="closeEventModal">
<div class="modal">
<!-- Recognition event with employee highlight -->
<div v-if="selectedEvent.extendedProps?.typecolor === 'recognition'" class="recognition-header">
<div class="recognition-badge">
<span class="recognition-icon">🏆</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-name">{{ selectedEvent.extendedProps.employeename || selectedEvent.extendedProps.employeesso }}</span>
</div>
</div>
</div>
<!-- Regular event header -->
<h2 v-else>{{ selectedEvent.title }}</h2>
<div class="event-details">
<p v-if="selectedEvent.extendedProps?.typename && selectedEvent.extendedProps?.typecolor !== 'recognition'">
<strong>Type:</strong> {{ selectedEvent.extendedProps.typename }}
</p>
<p>
<strong>Start:</strong> {{ formatDate(selectedEvent.start) }}
</p>
<p v-if="selectedEvent.end">
<strong>End:</strong> {{ formatDate(selectedEvent.end) }}
</p>
<p v-if="selectedEvent.extendedProps?.message && selectedEvent.extendedProps?.typecolor !== 'recognition'" class="message-block">
<strong>Details:</strong>
<span class="message-text">{{ selectedEvent.extendedProps.message }}</span>
</p>
<p v-if="selectedEvent.extendedProps?.ticketnumber">
<strong>Ticket:</strong> {{ selectedEvent.extendedProps.ticketnumber }}
</p>
<p v-if="selectedEvent.extendedProps?.linkurl">
<a :href="selectedEvent.extendedProps.linkurl" target="_blank" class="btn btn-link">
More Info
</a>
</p>
</div>
<div class="modal-actions">
<router-link
v-if="selectedEvent.extendedProps?.notificationid"
:to="`/notifications/${selectedEvent.extendedProps.notificationid}`"
class="btn btn-secondary"
>
View Notification
</router-link>
<button class="btn btn-primary" @click="closeEventModal">Close</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import { notificationsApi } from '@/api'
const events = ref([])
const selectedEvent = ref(null)
const calendarRef = ref(null)
const moreTooltip = ref(null)
const moreTooltipData = ref([])
const tooltipPosition = ref({ left: 0, top: 0 })
// Store events by date for hover lookup
const eventsByDate = ref({})
const calendarOptions = ref({
plugins: [dayGridPlugin],
initialView: 'dayGridMonth',
events: [],
eventClick: (info) => {
selectedEvent.value = info.event
},
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,dayGridWeek'
},
height: 'auto',
dayMaxEvents: 3,
moreLinkClick: () => 'none' // Disable click, we use hover
})
function hideMoreTooltip() {
moreTooltipData.value = []
}
function handleMoreLinkHover(e) {
const moreLink = e.target.closest('.fc-daygrid-more-link')
if (!moreLink) {
return
}
// Find the day cell and get its date
const dayCell = moreLink.closest('.fc-daygrid-day')
if (!dayCell) return
const dateStr = dayCell.getAttribute('data-date')
if (!dateStr || !eventsByDate.value[dateStr]) return
// Get events for this date that would be hidden (after first 3)
const dayEvents = eventsByDate.value[dateStr]
const hiddenEvents = dayEvents.slice(3).map(evt => ({
title: evt.title,
color: evt.backgroundColor || '#14abef',
// Include full event data for modal
start: evt.start,
end: evt.end,
extendedProps: evt.extendedProps || {}
}))
if (hiddenEvents.length === 0) return
moreTooltipData.value = hiddenEvents
// Position tooltip
const rect = moreLink.getBoundingClientRect()
tooltipPosition.value = {
left: rect.left,
top: rect.bottom + 5
}
}
function handleMoreLinkLeave(e) {
const related = e.relatedTarget
// Don't hide if moving to the tooltip itself
if (related && (related.closest('.fc-more-tooltip') || related.closest('.fc-daygrid-more-link'))) {
return
}
hideMoreTooltip()
}
function buildEventsByDate() {
const byDate = {}
for (const evt of events.value) {
const dateStr = evt.start ? evt.start.split('T')[0] : null
if (dateStr) {
if (!byDate[dateStr]) byDate[dateStr] = []
byDate[dateStr].push(evt)
}
}
eventsByDate.value = byDate
}
onMounted(async () => {
await loadEvents()
// Add event delegation for more links
await nextTick()
const container = document.querySelector('.calendar-container')
if (container) {
container.addEventListener('mouseenter', handleMoreLinkHover, true)
container.addEventListener('mouseleave', handleMoreLinkLeave, true)
}
})
onUnmounted(() => {
const container = document.querySelector('.calendar-container')
if (container) {
container.removeEventListener('mouseenter', handleMoreLinkHover, true)
container.removeEventListener('mouseleave', handleMoreLinkLeave, true)
}
})
onMounted(async () => {
await loadEvents()
})
async function loadEvents() {
try {
const response = await notificationsApi.getCalendar()
events.value = response.data.data
calendarOptions.value.events = events.value
buildEventsByDate()
} catch (error) {
console.error('Error loading calendar events:', error)
}
}
function closeEventModal() {
selectedEvent.value = null
}
function openEventFromTooltip(evt) {
// Create an event-like object that matches FullCalendar's event structure
selectedEvent.value = {
title: evt.title,
start: evt.start,
end: evt.end,
extendedProps: evt.extendedProps
}
hideMoreTooltip()
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<style scoped>
.calendar-container {
min-height: 500px;
}
.modal {
padding: 1.5rem;
}
.modal h2 {
margin: 0 0 1rem 0;
font-size: 18px;
color: var(--text);
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.event-details {
padding: 0.5rem 0;
}
.event-details p {
margin-bottom: 0.75rem;
font-size: 14px;
line-height: 1.5;
}
.event-details p:last-child {
margin-bottom: 0;
}
.event-details strong {
color: var(--text-light);
display: inline-block;
min-width: 70px;
}
.message-block {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.message-block strong {
min-width: auto;
}
.message-text {
display: block;
padding: 0.5rem 0.75rem;
background: var(--bg);
border-radius: 0.25rem;
white-space: pre-wrap;
line-height: 1.5;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
/* Recognition event styling */
.recognition-header {
display: flex;
gap: 1rem;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.recognition-badge {
flex-shrink: 0;
width: 60px;
height: 60px;
background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
}
.recognition-icon {
font-size: 28px;
}
.recognition-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.recognition-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--primary);
font-weight: 600;
margin-bottom: 0.25rem;
}
.recognition-title {
margin: 0 !important;
padding: 0 !important;
border: none !important;
font-size: 16px !important;
line-height: 1.4;
color: var(--text) !important;
}
.recognition-employee {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(65, 129, 255, 0.1);
border-radius: 0.25rem;
border-left: 3px solid var(--primary);
}
.employee-icon {
font-size: 18px;
}
.employee-name {
font-weight: 600;
color: var(--text);
font-size: 15px;
}
</style>

View File

@@ -1,6 +1,7 @@
<template> <template>
<div class="login-container"> <div class="login-container">
<div class="login-box"> <div class="login-box">
<img src="/ge-aerospace-logo.svg" alt="GE Aerospace" class="login-logo" />
<h1>ShopDB</h1> <h1>ShopDB</h1>
<div v-if="error" class="error-message"> <div v-if="error" class="error-message">

View File

@@ -0,0 +1,407 @@
<template>
<div class="map-editor">
<div class="page-header">
<h2>Map Editor</h2>
<div class="header-actions">
<router-link to="/map" class="btn btn-secondary">Back to Map</router-link>
</div>
</div>
<div class="editor-layout">
<!-- Asset List Panel -->
<div class="asset-panel">
<div class="panel-header">
<h3>Assets</h3>
<select v-model="filterType" class="form-control">
<option value="">All Types</option>
<option value="equipment">Equipment</option>
<option value="computer">Computers</option>
<option value="printer">Printers</option>
<option value="network_device">Network Devices</option>
</select>
</div>
<div class="asset-filter">
<label class="filter-checkbox">
<input type="checkbox" v-model="showUnplacedOnly" />
Show unplaced only
</label>
</div>
<div class="asset-list">
<div
v-for="asset in filteredAssets"
:key="asset.assetid"
class="asset-item"
:class="{
selected: selectedAsset?.assetid === asset.assetid,
placed: asset.mapleft && asset.maptop,
unplaced: !asset.mapleft || !asset.maptop
}"
@click="selectAsset(asset)"
>
<span class="asset-icon">{{ getTypeIcon(asset.assettype) }}</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>
</div>
</div>
</div>
<div v-if="filteredAssets.length === 0" class="empty">
No assets found.
</div>
</div>
</div>
<!-- Map Panel -->
<div class="map-panel">
<div class="map-toolbar" v-if="selectedAsset">
<span class="selected-info">
<strong>Selected:</strong> {{ selectedAsset.name || selectedAsset.assetnumber }}
</span>
<span class="position-info" v-if="pickedPosition">
Position: ({{ Math.round(pickedPosition.left) }}, {{ Math.round(pickedPosition.top) }})
</span>
<div class="toolbar-actions">
<button
class="btn btn-primary"
:disabled="!pickedPosition"
@click="savePosition"
>
Save Position
</button>
<button
class="btn btn-danger"
v-if="selectedAsset.mapleft && selectedAsset.maptop"
@click="clearPosition"
>
Remove from Map
</button>
<button class="btn btn-secondary" @click="cancelEdit">Cancel</button>
</div>
</div>
<div class="map-toolbar" v-else>
<span class="instruction">Select an asset from the list to place it on the map</span>
</div>
<ShopFloorMap
ref="mapRef"
:machines="placedAssets"
:assetTypeMode="true"
:theme="currentTheme"
:pickerMode="!!selectedAsset"
:initialPosition="selectedAsset ? { left: selectedAsset.mapleft, top: selectedAsset.maptop } : null"
@positionPicked="handlePositionPicked"
@markerClick="handleMarkerClick"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
const assets = ref([])
const loading = ref(true)
const selectedAsset = ref(null)
const pickedPosition = ref(null)
const filterType = ref('')
const showUnplacedOnly = ref(false)
const filteredAssets = computed(() => {
let result = assets.value
if (filterType.value) {
result = result.filter(a => a.assettype === filterType.value)
}
if (showUnplacedOnly.value) {
result = result.filter(a => !a.mapleft || !a.maptop)
}
return result
})
const placedAssets = computed(() => {
return assets.value.filter(a => a.mapleft && a.maptop)
})
onMounted(async () => {
await loadAssets()
})
async function loadAssets() {
loading.value = true
try {
const response = await assetsApi.getMap()
assets.value = response.data.data?.assets || []
} catch (error) {
console.error('Failed to load assets:', error)
} finally {
loading.value = false
}
}
function getTypeIcon(assettype) {
const icons = {
'equipment': '⚙',
'computer': '💻',
'printer': '🖨',
'network_device': '🌐'
}
return icons[assettype] || '📦'
}
function selectAsset(asset) {
selectedAsset.value = asset
pickedPosition.value = asset.mapleft && asset.maptop
? { left: asset.mapleft, top: asset.maptop }
: null
}
function handlePositionPicked(position) {
pickedPosition.value = position
}
function handleMarkerClick(asset) {
selectAsset(asset)
}
async function savePosition() {
if (!selectedAsset.value || !pickedPosition.value) return
try {
await assetsApi.update(selectedAsset.value.assetid, {
mapleft: Math.round(pickedPosition.value.left),
maptop: Math.round(pickedPosition.value.top)
})
// Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) {
asset.mapleft = Math.round(pickedPosition.value.left)
asset.maptop = Math.round(pickedPosition.value.top)
}
selectedAsset.value = null
pickedPosition.value = null
} catch (error) {
console.error('Failed to save position:', error)
alert('Failed to save position')
}
}
async function clearPosition() {
if (!selectedAsset.value) return
if (!confirm(`Remove ${selectedAsset.value.name || selectedAsset.value.assetnumber} from the map?`)) return
try {
await assetsApi.update(selectedAsset.value.assetid, {
mapleft: null,
maptop: null
})
// Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) {
asset.mapleft = null
asset.maptop = null
}
selectedAsset.value = null
pickedPosition.value = null
} catch (error) {
console.error('Failed to clear position:', error)
alert('Failed to clear position')
}
}
function cancelEdit() {
selectedAsset.value = null
pickedPosition.value = null
}
</script>
<style scoped>
.map-editor {
display: flex;
flex-direction: column;
height: calc(100vh - 40px);
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.editor-layout {
display: flex;
flex: 1;
gap: 1rem;
min-height: 0;
}
.asset-panel {
width: 320px;
display: flex;
flex-direction: column;
background: var(--bg-card);
border-radius: 8px;
border: 1px solid var(--border);
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.panel-header h3 {
margin: 0;
font-size: 1rem;
}
.panel-header select {
width: auto;
padding: 0.375rem 0.5rem;
font-size: 0.8rem;
}
.asset-filter {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
.asset-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.asset-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.asset-item:hover {
background: var(--bg);
}
.asset-item.selected {
background: rgba(65, 129, 255, 0.2);
border: 1px solid var(--primary);
}
.asset-item.unplaced {
opacity: 0.7;
}
.asset-icon {
font-size: 1.5rem;
}
.asset-info {
flex: 1;
min-width: 0;
}
.asset-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.asset-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.badge-sm {
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
}
.placed-indicator {
font-size: 0.875rem;
}
.map-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.map-toolbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border-radius: 8px;
border: 1px solid var(--border);
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.selected-info {
font-size: 0.9rem;
}
.position-info {
font-size: 0.875rem;
color: var(--text-light);
font-family: monospace;
}
.toolbar-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.instruction {
color: var(--text-light);
font-style: italic;
}
.map-panel :deep(.shopfloor-map) {
flex: 1;
border-radius: 8px;
overflow: hidden;
}
.empty {
padding: 2rem;
text-align: center;
color: var(--text-light);
}
</style>

View File

@@ -2,47 +2,76 @@
<div class="map-page"> <div class="map-page">
<div class="page-header"> <div class="page-header">
<h2>Shop Floor Map</h2> <h2>Shop Floor Map</h2>
<router-link v-if="authStore.isAuthenticated" to="/map/editor" class="btn btn-primary">
Edit Map
</router-link>
</div> </div>
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else>
<!-- Layer Toggles -->
<div class="layer-toggles">
<label v-for="t in assetTypes" :key="t.assettypeid" class="layer-toggle">
<input
type="checkbox"
v-model="visibleTypes"
:value="t.assettype"
@change="updateMapLayers"
/>
<span class="layer-icon">{{ getTypeIcon(t.assettype) }}</span>
<span>{{ t.assettype }}</span>
<span class="layer-count">({{ getTypeCount(t.assettype) }})</span>
</label>
</div>
<ShopFloorMap <ShopFloorMap
v-else :machines="filteredAssets"
:machines="machines" :machinetypes="[]"
:machinetypes="machinetypes"
:businessunits="businessunits" :businessunits="businessunits"
:statuses="statuses" :statuses="statuses"
:assetTypeMode="true"
:theme="currentTheme"
@markerClick="handleMarkerClick" @markerClick="handleMarkerClick"
/> />
</template>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ShopFloorMap from '../components/ShopFloorMap.vue' import ShopFloorMap from '../components/ShopFloorMap.vue'
import { machinesApi, machinetypesApi, businessunitsApi, statusesApi } from '../api' import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
import { useAuthStore } from '../stores/auth'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore()
const loading = ref(true) const loading = ref(true)
const machines = ref([]) const assets = ref([])
const machinetypes = ref([]) const assetTypes = ref([])
const businessunits = ref([]) const businessunits = ref([])
const statuses = ref([]) const statuses = ref([])
const visibleTypes = ref([])
const filteredAssets = computed(() => {
if (visibleTypes.value.length === 0) return assets.value
return assets.value.filter(a => visibleTypes.value.includes(a.assettype))
})
onMounted(async () => { onMounted(async () => {
try { try {
const [machinesRes, typesRes, busRes, statusRes] = await Promise.all([ const response = await assetsApi.getMap()
machinesApi.list({ hasmap: true, all: true }), const data = response.data.data || {}
machinetypesApi.list(),
businessunitsApi.list(),
statusesApi.list()
])
machines.value = machinesRes.data.data || [] assets.value = data.assets || []
machinetypes.value = typesRes.data.data || [] assetTypes.value = data.filters?.assettypes || []
businessunits.value = busRes.data.data || [] businessunits.value = data.filters?.businessunits || []
statuses.value = statusRes.data.data || [] statuses.value = data.filters?.statuses || []
// Default: show all types
visibleTypes.value = assetTypes.value.map(t => t.assettype)
} catch (error) { } catch (error) {
console.error('Failed to load map data:', error) console.error('Failed to load map data:', error)
} finally { } finally {
@@ -50,15 +79,43 @@ onMounted(async () => {
} }
}) })
function handleMarkerClick(machine) { function getTypeIcon(assettype) {
const category = machine.category?.toLowerCase() || '' const icons = {
'equipment': '⚙',
'computer': '💻',
'printer': '🖨',
'network_device': '🌐'
}
return icons[assettype] || '📦'
}
function getTypeCount(assettype) {
return assets.value.filter(a => a.assettype === assettype).length
}
function updateMapLayers() {
// Filter is reactive via computed property
}
function handleMarkerClick(asset) {
// Route based on asset type
const routeMap = { const routeMap = {
'equipment': '/machines', 'equipment': '/machines',
'pc': '/pcs', 'computer': '/pcs',
'printer': '/printers' 'printer': '/printers',
'network_device': '/network'
}
const basePath = routeMap[asset.assettype] || '/machines'
// For network devices, use the networkdeviceid from typedata
if (asset.assettype === 'network_device' && asset.typedata?.networkdeviceid) {
router.push(`/network/${asset.typedata.networkdeviceid}`)
} else {
// For machines (equipment, computer, printer), use machineid from typedata
const id = asset.typedata?.machineid || asset.assetid
router.push(`${basePath}/${id}`)
} }
const basePath = routeMap[category] || '/machines'
router.push(`${basePath}/${machine.machineid}`)
} }
</script> </script>
@@ -73,6 +130,45 @@ function handleMarkerClick(machine) {
flex-shrink: 0; flex-shrink: 0;
} }
.layer-toggles {
display: flex;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.layer-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.5rem 0.75rem;
border-radius: 6px;
transition: background 0.2s;
}
.layer-toggle:hover {
background: var(--bg);
}
.layer-toggle input[type="checkbox"] {
width: 1.125rem;
height: 1.125rem;
}
.layer-icon {
font-size: 1.25rem;
}
.layer-count {
color: var(--text-light);
font-size: 0.875rem;
}
.map-page :deep(.shopfloor-map) { .map-page :deep(.shopfloor-map) {
flex: 1; flex: 1;
} }

View File

@@ -0,0 +1,531 @@
<template>
<div class="shopfloor-dashboard">
<header class="dashboard-header">
<div class="logo-container">
<img src="/ge-aerospace-logo.svg" alt="GE Aerospace" class="logo" />
</div>
<div class="header-center">
<div class="location-title">West Jefferson</div>
<h1>Shopfloor Dashboard</h1>
</div>
<div class="header-right">
<div class="clock">{{ currentTime }}</div>
<select v-model="businessUnit" class="filter-select" @change="loadData">
<option value="">All Business Units</option>
<option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
{{ bu.businessunit }}
</option>
</select>
</div>
</header>
<main class="dashboard-content">
<!-- Recognition Carousel -->
<section v-if="recognitions.length" class="recognition-section">
<div class="section-title recognition">Employee Recognition</div>
<div class="recognition-carousel">
<div
v-for="(rec, idx) in recognitions"
:key="`${rec.notificationid}-${rec.employeesso}`"
class="recognition-card"
:class="{ active: idx === currentRecognition }"
>
<div class="recognition-photo-container">
<img
v-if="rec.employeepicture"
:src="`/static/employees/${rec.employeepicture}`"
:alt="rec.employeename"
class="recognition-photo"
@error="handlePhotoError"
/>
<img
v-else
src="/ge-aerospace-logo.svg"
alt="GE Aerospace"
class="recognition-photo ge-logo-fallback"
/>
</div>
<div class="recognition-content">
<div class="recognition-header">
<span class="recognition-star">&#9733;</span>
<div class="recognition-name">{{ rec.employeename }}</div>
</div>
<div class="recognition-message">{{ rec.notification }}</div>
</div>
</div>
</div>
</section>
<!-- Current Notifications -->
<section v-if="currentNotifications.length" class="notifications-section">
<div class="section-title" :class="getSectionClass(currentNotifications)">
Current Notifications
</div>
<div class="events-list">
<div
v-for="n in currentNotifications"
:key="n.notificationid"
class="event-card"
:class="{ resolved: n.resolved }"
>
<div class="event-indicator" :style="{ backgroundColor: getTypeColor(n.typecolor) }"></div>
<div class="event-content">
<div class="event-title">{{ n.notification }}</div>
<div class="event-time">
<template v-if="n.resolved">
<strong>RESOLVED</strong>
</template>
<template v-else>
<strong>{{ formatTime(n.starttime) }}</strong>
<span v-if="n.endtime"> - {{ formatTime(n.endtime) }}</span>
</template>
</div>
</div>
<a v-if="n.ticketnumber" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
{{ n.ticketnumber }}
</a>
</div>
</div>
</section>
<!-- Upcoming Notifications -->
<section v-if="upcomingNotifications.length" class="notifications-section">
<div class="section-title upcoming">Upcoming</div>
<div class="events-list">
<div
v-for="n in upcomingNotifications"
:key="n.notificationid"
class="event-card"
>
<div class="event-indicator" :style="{ backgroundColor: getTypeColor(n.typecolor) }"></div>
<div class="event-content">
<div class="event-title">{{ n.notification }}</div>
<div class="event-time">
<strong>{{ formatDateTime(n.starttime) }}</strong>
</div>
</div>
<a v-if="n.ticketnumber" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
{{ n.ticketnumber }}
</a>
</div>
</div>
</section>
<!-- No notifications -->
<div v-if="!loading && !currentNotifications.length && !upcomingNotifications.length && !recognitions.length" class="no-events">
No active notifications
</div>
<div v-if="loading" class="loading">Loading...</div>
</main>
<footer class="dashboard-footer">
Auto-refreshes every 30 seconds
</footer>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { notificationsApi, businessUnitsApi } from '@/api'
const loading = ref(true)
const businessUnit = ref('')
const businessUnits = ref([])
const notifications = ref({ current: [], upcoming: [] })
const currentRecognition = ref(0)
let refreshInterval = null
let recognitionInterval = null
// Separate recognition notifications from others
const recognitions = computed(() =>
notifications.value.current.filter(n => n.typecolor === 'recognition')
)
const currentNotifications = computed(() =>
notifications.value.current.filter(n => n.typecolor !== 'recognition')
)
const upcomingNotifications = computed(() =>
notifications.value.upcoming.filter(n => n.typecolor !== 'recognition')
)
// Clock
const currentTime = ref('')
function updateClock() {
const now = new Date()
currentTime.value = now.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
onMounted(async () => {
updateClock()
setInterval(updateClock, 1000)
// Load business units
try {
const res = await businessUnitsApi.list()
businessUnits.value = res.data.data || []
} catch (err) {
console.error('Error loading business units:', err)
}
await loadData()
// Auto-refresh every 30 seconds
refreshInterval = setInterval(loadData, 30000)
// Rotate recognition carousel every 8 seconds
recognitionInterval = setInterval(() => {
if (recognitions.value.length > 1) {
currentRecognition.value = (currentRecognition.value + 1) % recognitions.value.length
}
}, 8000)
})
onUnmounted(() => {
if (refreshInterval) clearInterval(refreshInterval)
if (recognitionInterval) clearInterval(recognitionInterval)
})
async function loadData() {
try {
const params = {}
if (businessUnit.value) {
params.businessunit = businessUnit.value
}
const res = await notificationsApi.getShopfloor(params)
notifications.value = res.data.data || { current: [], upcoming: [] }
} catch (err) {
console.error('Error loading shopfloor data:', err)
} finally {
loading.value = false
}
}
function getTypeColor(typecolor) {
const colors = {
success: '#04b962',
warning: '#ff8800',
danger: '#f5365c',
info: '#14abef',
primary: '#7934f3',
recognition: '#0d6efd'
}
return colors[typecolor] || typecolor || '#14abef'
}
function getSectionClass(notifications) {
// Use danger color if any active incidents
const hasIncident = notifications.some(n => n.typecolor === 'danger' && !n.resolved)
return hasIncident ? 'danger' : ''
}
function formatTime(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})
}
function formatDateTime(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function getTicketUrl(ticketnumber) {
if (!ticketnumber) return '#'
// ServiceNow ticket URLs
if (ticketnumber.startsWith('GEINC')) {
return `https://ge.service-now.com/nav_to.do?uri=incident.do?sysparm_query=number=${ticketnumber}`
}
if (ticketnumber.startsWith('GECHG')) {
return `https://ge.service-now.com/nav_to.do?uri=change_request.do?sysparm_query=number=${ticketnumber}`
}
return '#'
}
function handlePhotoError(e) {
e.target.src = '/ge-aerospace-logo.svg'
e.target.classList.add('ge-logo-fallback')
}
</script>
<style scoped>
.shopfloor-dashboard {
min-height: 100vh;
background: #00003d;
color: #fff;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
overflow: hidden;
}
.dashboard-header {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 30px;
padding: 20px 40px;
border-bottom: 3px solid #4181ff;
}
.logo {
height: 90px;
width: auto;
}
.header-center {
text-align: center;
}
.location-title {
font-size: 18px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 2px;
}
.header-center h1 {
font-size: 28px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 2px;
margin: 0;
}
.header-right {
text-align: right;
}
.clock {
font-size: 28px;
font-weight: 600;
color: #4181ff;
font-variant-numeric: tabular-nums;
margin-bottom: 10px;
}
.filter-select {
padding: 10px 16px;
font-size: 16px;
font-weight: 600;
background: #1a1a5e;
color: #fff;
border: 2px solid #4181ff;
border-radius: 6px;
cursor: pointer;
min-width: 200px;
}
.dashboard-content {
padding: 20px 40px;
max-height: calc(100vh - 180px);
overflow-y: auto;
}
.section-title {
font-size: 22px;
font-weight: 700;
padding: 12px 20px;
border-radius: 8px;
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 15px;
background: #4181ff;
}
.section-title.recognition {
background: #0d6efd;
}
.section-title.danger {
background: #f5365c;
}
.section-title.upcoming {
background: #6c757d;
}
/* Recognition carousel */
.recognition-section {
margin-bottom: 25px;
}
.recognition-carousel {
position: relative;
min-height: 170px;
}
.recognition-card {
position: absolute;
top: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%);
border: 3px solid #0d6efd;
border-radius: 12px;
padding: 20px 25px;
display: flex;
align-items: center;
gap: 25px;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.8s ease, transform 0.8s ease;
}
.recognition-card.active {
opacity: 1;
transform: translateY(0);
position: relative;
}
.recognition-photo {
width: 140px;
height: 140px;
border-radius: 50%;
object-fit: cover;
border: 4px solid #0d6efd;
background: #1a1a2e;
}
.recognition-photo.ge-logo-fallback {
object-fit: contain;
padding: 20px;
background: #fff;
}
.recognition-content {
flex: 1;
}
.recognition-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.recognition-star {
font-size: 48px;
color: #ffc107;
margin-right: 20px;
animation: starPulse 2s ease-in-out infinite;
}
@keyframes starPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.recognition-name {
font-size: 36px;
font-weight: 700;
}
.recognition-message {
font-size: 24px;
color: #ccc;
line-height: 1.4;
}
/* Event cards */
.notifications-section {
margin-bottom: 25px;
}
.events-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.event-card {
display: flex;
align-items: center;
background: #fff;
color: #00003d;
border-radius: 8px;
padding: 15px 20px;
gap: 15px;
}
.event-card.resolved {
opacity: 0.6;
background: #e9ecef;
}
.event-indicator {
width: 8px;
height: 60px;
border-radius: 4px;
flex-shrink: 0;
}
.event-content {
flex: 1;
}
.event-title {
font-size: 24px;
font-weight: 700;
line-height: 1.3;
}
.event-time {
font-size: 18px;
color: #666;
margin-top: 5px;
}
.event-time strong {
color: #00003d;
}
.event-ticket {
font-size: 18px;
font-weight: 700;
background: #00003d;
color: #fff;
padding: 8px 16px;
border-radius: 6px;
text-decoration: none;
text-transform: uppercase;
}
.event-ticket:hover {
background: #4181ff;
}
.no-events, .loading {
text-align: center;
font-size: 28px;
font-weight: 600;
padding: 40px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.dashboard-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.8);
text-align: center;
padding: 12px;
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,179 @@
<template>
<div class="tv-dashboard">
<div class="slideshow-container">
<div
v-for="(slide, idx) in slides"
:key="slide.filename"
class="slide"
:class="{ active: idx === currentSlide }"
>
<img :src="basePath + slide.filename" :alt="slide.filename" />
</div>
<div v-if="error" class="error-message">
<h2>Display Error</h2>
<p>{{ error }}</p>
</div>
<div v-if="!slides.length && !error" class="error-message">
<h2>No Slides</h2>
<p>No slides configured for display</p>
</div>
</div>
<div class="progress-bar" :style="progressStyle"></div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import api from '@/api'
const INTERVAL = 10 // seconds between slides
const slides = ref([])
const basePath = ref('/static/slides/')
const currentSlide = ref(0)
const error = ref('')
const progress = ref(0)
let slideTimer = null
let progressTimer = null
let refreshTimer = null
const progressStyle = computed(() => ({
width: `${progress.value}%`,
transition: progress.value === 0 ? 'none' : `width ${INTERVAL}s linear`
}))
onMounted(async () => {
await fetchSlides()
// Start slideshow if we have slides
if (slides.value.length > 1) {
startSlideshow()
}
// Refresh slide list every 60 seconds
refreshTimer = setInterval(fetchSlides, 60000)
})
onUnmounted(() => {
if (slideTimer) clearTimeout(slideTimer)
if (progressTimer) clearTimeout(progressTimer)
if (refreshTimer) clearInterval(refreshTimer)
})
async function fetchSlides() {
try {
const response = await api.get('/slides')
const data = response.data
if (data.success && data.data?.slides?.length > 0) {
slides.value = data.data.slides
basePath.value = data.data.basepath || '/static/slides/'
error.value = ''
// Restart slideshow if slides changed
if (slides.value.length > 1 && !slideTimer) {
startSlideshow()
}
} else {
error.value = data.message || 'No slides found'
}
} catch (err) {
console.error('Error fetching slides:', err)
error.value = 'Unable to load slides'
}
}
function startSlideshow() {
scheduleNextSlide()
}
function scheduleNextSlide() {
// Reset progress bar
progress.value = 0
// Start progress animation after a small delay
progressTimer = setTimeout(() => {
progress.value = 100
}, 50)
// Schedule next slide
slideTimer = setTimeout(() => {
nextSlide()
scheduleNextSlide()
}, INTERVAL * 1000)
}
function nextSlide() {
if (slides.value.length === 0) return
currentSlide.value = (currentSlide.value + 1) % slides.value.length
}
</script>
<style scoped>
.tv-dashboard {
background: #000;
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
overflow: hidden;
}
.slideshow-container {
position: relative;
width: 100%;
height: 100%;
}
.slide {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 1s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
}
.slide.active {
opacity: 1;
}
.slide img {
width: 100%;
height: 100%;
object-fit: contain;
}
.progress-bar {
position: fixed;
bottom: 0;
left: 0;
height: 4px;
background: #4181ff;
z-index: 100;
}
.error-message {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 24px;
}
.error-message h2 {
color: #ff4444;
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,320 @@
<template>
<div class="detail-page">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error-message">{{ error }}</div>
<template v-else-if="employee">
<div class="hero-card">
<div class="hero-image" v-if="employee.Picture">
<img :src="employee.Picture" :alt="fullName" />
</div>
<div class="hero-image placeholder" v-else>
<span class="initials">{{ initials }}</span>
</div>
<div class="hero-content">
<h1 class="hero-title">{{ fullName }}</h1>
<div class="hero-meta">
<span class="badge">SSO: {{ employee.SSO }}</span>
<span v-if="employee.Team" class="badge badge-primary">{{ employee.Team }}</span>
</div>
<div class="hero-details">
<p v-if="employee.Role"><strong>Role:</strong> {{ employee.Role }}</p>
</div>
<div class="hero-actions">
<router-link to="/" class="btn">Back to Dashboard</router-link>
</div>
</div>
</div>
<!-- Recognitions -->
<div class="section-card">
<h2 class="section-title">
Recognitions
<span v-if="recognitions.length > 0" class="count-badge">{{ recognitions.length }}</span>
</h2>
<div v-if="recognitionsLoading" class="loading">Loading...</div>
<div v-else-if="recognitions.length === 0" class="empty">
No recognitions yet.
</div>
<div v-else class="recognitions-list">
<div v-for="rec in displayedRecognitions" :key="rec.notificationid" class="recognition-card">
<div class="recognition-header">
<span class="badge" :style="{ backgroundColor: rec.typecolor || '#14abef' }">
{{ rec.typename || 'Recognition' }}
</span>
<span class="recognition-date">{{ formatDate(rec.startdate) }}</span>
</div>
<div class="recognition-message">{{ rec.notification }}</div>
</div>
<button
v-if="recognitions.length > recognitionsLimit && !showAllRecognitions"
class="btn btn-secondary show-more-btn"
@click="showAllRecognitions = true"
>
Show {{ recognitions.length - recognitionsLimit }} more
</button>
<button
v-if="showAllRecognitions && recognitions.length > recognitionsLimit"
class="btn btn-secondary show-more-btn"
@click="showAllRecognitions = false"
>
Show less
</button>
</div>
</div>
<!-- Currently Checked Out USB Devices -->
<div class="section-card">
<h2 class="section-title">Checked Out USB Devices</h2>
<div v-if="usbLoading" class="loading">Loading...</div>
<div v-else-if="usbDevices.length === 0" class="empty">
No USB devices currently checked out.
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Device</th>
<th>Serial Number</th>
<th>Checked Out</th>
<th>Purpose</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="device in usbDevices" :key="device.usbdeviceid">
<td>
<router-link :to="`/usb/${device.usbdeviceid}`">
{{ device.displayname }}
</router-link>
</td>
<td>{{ device.serialnumber }}</td>
<td>{{ formatDate(device.checkoutdate) }}</td>
<td>{{ device.checkoutpurpose || '-' }}</td>
<td class="actions">
<button class="btn btn-small btn-success" @click="checkinDevice(device)">
Check In
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- USB Checkout History -->
<div class="section-card">
<h2 class="section-title">USB Checkout History</h2>
<div v-if="historyLoading" class="loading">Loading...</div>
<div v-else-if="checkoutHistory.length === 0" class="empty">
No checkout history.
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Device</th>
<th>Checked Out</th>
<th>Checked In</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr v-for="record in checkoutHistory" :key="record.usbcheckoutid">
<td>
<router-link :to="`/usb/${record.usbdeviceid}`">
{{ record.devicename || `Device #${record.usbdeviceid}` }}
</router-link>
</td>
<td>{{ formatDate(record.checkoutdate) }}</td>
<td>{{ record.checkindate ? formatDate(record.checkindate) : 'Still out' }}</td>
<td>{{ record.purpose || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { employeesApi, usbApi, notificationsApi } from '@/api'
const route = useRoute()
const employee = ref(null)
const recognitions = ref([])
const usbDevices = ref([])
const checkoutHistory = ref([])
const loading = ref(true)
const recognitionsLoading = ref(true)
const usbLoading = ref(true)
const historyLoading = ref(true)
const error = ref('')
const recognitionsLimit = 5
const showAllRecognitions = ref(false)
const displayedRecognitions = computed(() => {
if (showAllRecognitions.value) {
return recognitions.value
}
return recognitions.value.slice(0, recognitionsLimit)
})
const fullName = computed(() => {
if (!employee.value) return ''
return `${employee.value.First_Name?.trim() || ''} ${employee.value.Last_Name?.trim() || ''}`.trim()
})
const initials = computed(() => {
if (!employee.value) return '?'
const first = employee.value.First_Name?.trim()?.[0] || ''
const last = employee.value.Last_Name?.trim()?.[0] || ''
return (first + last).toUpperCase() || '?'
})
onMounted(async () => {
await loadEmployee()
await Promise.all([loadRecognitions(), loadUSBDevices(), loadCheckoutHistory()])
})
async function loadEmployee() {
loading.value = true
try {
const response = await employeesApi.lookup(route.params.sso)
employee.value = response.data.data
} catch (err) {
console.error('Error loading employee:', err)
error.value = 'Employee not found'
} finally {
loading.value = false
}
}
async function loadRecognitions() {
recognitionsLoading.value = true
try {
const response = await notificationsApi.getEmployeeRecognitions(route.params.sso)
recognitions.value = response.data.data?.recognitions || []
} catch (err) {
console.error('Error loading recognitions:', err)
recognitions.value = []
} finally {
recognitionsLoading.value = false
}
}
async function loadUSBDevices() {
usbLoading.value = true
try {
const response = await usbApi.getUserCheckouts(route.params.sso)
usbDevices.value = response.data.data || []
} catch (err) {
console.error('Error loading USB devices:', err)
} finally {
usbLoading.value = false
}
}
async function loadCheckoutHistory() {
historyLoading.value = true
try {
// Get user's checkout history (all past checkouts)
const response = await usbApi.list({ user_id: route.params.sso, include_history: true })
// Filter to only show returned items (not currently checked out)
checkoutHistory.value = (response.data.data || []).filter(d => d.checkindate)
} catch (err) {
console.error('Error loading checkout history:', err)
checkoutHistory.value = []
} finally {
historyLoading.value = false
}
}
async function checkinDevice(device) {
if (!confirm(`Check in ${device.displayname}?`)) return
try {
await usbApi.checkin(device.usbdeviceid)
await loadUSBDevices()
await loadCheckoutHistory()
} catch (err) {
console.error('Error checking in device:', err)
alert('Failed to check in device')
}
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString()
}
</script>
<style scoped>
.hero-image.placeholder {
display: flex;
align-items: center;
justify-content: center;
background: var(--primary);
color: white;
}
.initials {
font-size: 3rem;
font-weight: 600;
}
.recognitions-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.recognition-card {
padding: 1rem;
background: var(--bg);
border-radius: 8px;
border-left: 4px solid var(--primary);
}
.recognition-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.recognition-date {
color: var(--text-light);
font-size: 0.875rem;
}
.recognition-message {
white-space: pre-wrap;
line-height: 1.5;
}
.count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.5rem;
padding: 0 0.5rem;
margin-left: 0.5rem;
background: var(--primary);
color: white;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.show-more-btn {
width: 100%;
margin-top: 0.5rem;
}
</style>

View File

@@ -157,12 +157,20 @@ async function openArticle() {
.info-table td a { .info-table td a {
color: var(--primary, #1976d2); color: var(--primary, #1976d2);
text-decoration: none; text-decoration: none;
word-break: break-all;
overflow-wrap: break-word;
} }
.info-table td a:hover { .info-table td a:hover {
text-decoration: underline; text-decoration: underline;
} }
.info-table td {
word-break: break-word;
overflow-wrap: break-word;
max-width: 500px;
}
hr { hr {
margin: 1.5rem 0; margin: 1.5rem 0;
border: none; border: none;

View File

@@ -239,6 +239,7 @@
<ShopFloorMap <ShopFloorMap
:pickerMode="true" :pickerMode="true"
:initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null" :initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null"
:theme="currentTheme"
@positionPicked="handlePositionPicked" @positionPicked="handlePositionPicked"
/> />
</div> </div>
@@ -267,6 +268,7 @@ import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, relationshipTypesApi, modelsApi, businessunitsApi } from '../../api' import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, relationshipTypesApi, modelsApi, businessunitsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue' import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue' import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

View File

@@ -0,0 +1,348 @@
<template>
<div class="detail-page" v-if="device">
<div class="hero-card">
<div class="hero-image">
<div class="device-icon">
<span class="icon">{{ getDeviceIcon() }}</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>
<router-link
v-if="authStore.isAuthenticated"
:to="`/network/${deviceId}/edit`"
class="btn btn-secondary"
>
Edit
</router-link>
</div>
<div class="hero-meta">
<span class="badge" :class="getStatusClass(device.status_name)">
{{ device.status_name || 'Unknown' }}
</span>
<span v-if="device.network_device?.networkdevicetype_name" class="meta-item">
{{ device.network_device.networkdevicetype_name }}
</span>
<span v-if="device.network_device?.vendor_name" class="meta-item">
{{ device.network_device.vendor_name }}
</span>
</div>
<div class="hero-details">
<div class="detail-item" v-if="device.assetnumber">
<span class="label">Asset #</span>
<span class="value">{{ device.assetnumber }}</span>
</div>
<div class="detail-item" v-if="device.serialnumber">
<span class="label">Serial</span>
<span class="value mono">{{ device.serialnumber }}</span>
</div>
<div class="detail-item" v-if="device.location_name">
<span class="label">Location</span>
<span class="value">{{ device.location_name }}</span>
</div>
<div class="detail-item" v-if="device.businessunit_name">
<span class="label">Business Unit</span>
<span class="value">{{ device.businessunit_name }}</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>
</div>
</div>
<div class="content-grid">
<div class="content-column">
<!-- Network Info -->
<div class="section-card">
<h3 class="section-title">Network Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Hostname</span>
<span class="info-value mono">{{ device.network_device?.hostname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Firmware Version</span>
<span class="info-value">{{ device.network_device?.firmwareversion || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Port Count</span>
<span class="info-value">{{ device.network_device?.portcount || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Rack Unit</span>
<span class="info-value">{{ device.network_device?.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>
</div>
<div class="info-row">
<span class="info-label">Managed Device</span>
<span class="info-value">{{ device.network_device?.ismanaged ? 'Yes' : 'No' }}</span>
</div>
</div>
</div>
<!-- Notes -->
<div class="section-card" v-if="device.notes">
<h3 class="section-title">Notes</h3>
<div class="notes-content">{{ device.notes }}</div>
</div>
</div>
<div class="content-column">
<!-- Asset Info -->
<div class="section-card">
<h3 class="section-title">Asset Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value">{{ device.assetnumber }}</span>
</div>
<div class="info-row">
<span class="info-label">Name</span>
<span class="info-value">{{ device.name || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ device.serialnumber || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ device.network_device?.vendor_name || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Device Type</span>
<span class="info-value">{{ device.network_device?.networkdevicetype_name || '-' }}</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>
</span>
</div>
</div>
</div>
<!-- Relationships -->
<AssetRelationships
v-if="device.assetid"
:assetId="device.assetid"
/>
<!-- Audit Info -->
<div class="section-card audit-card">
<h3 class="section-title">Record Info</h3>
<div class="info-list">
<div class="info-row" v-if="device.datecreated">
<span class="info-label">Created</span>
<span class="info-value">{{ formatDate(device.datecreated) }}</span>
</div>
<div class="info-row" v-if="device.datemodified">
<span class="info-label">Last Modified</span>
<span class="info-value">{{ formatDate(device.datemodified) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="action-bar" v-if="authStore.isAuthenticated">
<router-link :to="`/network/${deviceId}/edit`" class="btn btn-primary">
Edit Device
</router-link>
<button @click="confirmDelete" class="btn btn-danger">
Delete Device
</button>
</div>
</div>
<div v-else-if="loading" class="loading-container">
<div class="loading">Loading device...</div>
</div>
<div v-else class="error-container">
<p>Device not found</p>
<router-link to="/network" class="btn btn-secondary">Back to Network Devices</router-link>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { networkApi } from '../../api'
import AssetRelationships from '../../components/AssetRelationships.vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const deviceId = route.params.id
const device = ref(null)
const loading = ref(true)
onMounted(async () => {
await loadDevice()
})
async function loadDevice() {
loading.value = true
try {
const response = await networkApi.get(deviceId)
device.value = response.data.data
} catch (error) {
console.error('Error loading device:', error)
device.value = null
} finally {
loading.value = false
}
}
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 '🌐'
}
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair' || s === 'maintenance') return 'badge-warning'
if (s === 'retired' || s === 'decommissioned') return 'badge-danger'
return 'badge-info'
}
function formatDate(dateStr) {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
async function confirmDelete() {
if (confirm(`Are you sure you want to delete ${device.value.network_device?.hostname || device.value.assetnumber}?`)) {
try {
await networkApi.delete(deviceId)
router.push('/network')
} catch (error) {
console.error('Error deleting device:', error)
alert('Failed to delete device')
}
}
}
</script>
<style scoped>
.hero-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.5rem;
}
.hero-title {
margin: 0;
}
.device-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: 8px;
}
.device-icon .icon {
font-size: 4rem;
}
.hero-features {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
flex-wrap: wrap;
}
.feature-badge {
display: inline-block;
padding: 0.35rem 0.75rem;
font-size: 0.875rem;
border-radius: 5px;
font-weight: 500;
}
.feature-badge.poe {
background: #d4edda;
color: #155724;
}
.feature-badge.managed {
background: #e3f2fd;
color: #1565c0;
}
.feature-badge.ports {
background: var(--bg);
color: var(--text-light);
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.notes-content {
white-space: pre-wrap;
color: var(--text);
line-height: 1.6;
}
.action-bar {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.loading-container,
.error-container {
text-align: center;
padding: 3rem;
color: var(--text-light);
}
@media (prefers-color-scheme: dark) {
.feature-badge.poe {
background: #1e3a29;
color: #4ade80;
}
.feature-badge.managed {
background: #1e3a5f;
color: #60a5fa;
}
}
</style>

View File

@@ -0,0 +1,497 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Network Device' : 'Add Network Device' }}</h2>
</div>
<div class="card form-card">
<form @submit.prevent="submitForm">
<!-- Asset Information -->
<fieldset>
<legend>Asset Information</legend>
<div class="form-row">
<div class="form-group">
<label for="assetnumber">Asset Number *</label>
<input
id="assetnumber"
v-model="form.assetnumber"
type="text"
class="form-control"
required
:disabled="isEdit"
/>
</div>
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="serialnumber">Serial Number</label>
<input
id="serialnumber"
v-model="form.serialnumber"
type="text"
class="form-control"
/>
</div>
<div class="form-group">
<label for="statusid">Status</label>
<select id="statusid" v-model="form.statusid" class="form-control">
<option value="">Select Status</option>
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">
{{ s.status }}
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="locationid">Location</label>
<select id="locationid" v-model="form.locationid" class="form-control">
<option value="">Select Location</option>
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
{{ loc.location }}
</option>
</select>
</div>
<div class="form-group">
<label for="businessunitid">Business Unit</label>
<select id="businessunitid" v-model="form.businessunitid" class="form-control">
<option value="">Select Business Unit</option>
<option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
{{ bu.businessunit }}
</option>
</select>
</div>
</div>
</fieldset>
<!-- Network Device Information -->
<fieldset>
<legend>Network Device Details</legend>
<div class="form-row">
<div class="form-group">
<label for="hostname">Hostname</label>
<input
id="hostname"
v-model="form.hostname"
type="text"
class="form-control"
placeholder="e.g., sw-bldg1-floor2"
/>
</div>
<div class="form-group">
<label for="networkdevicetypeid">Device Type</label>
<select id="networkdevicetypeid" v-model="form.networkdevicetypeid" class="form-control">
<option value="">Select Type</option>
<option v-for="t in deviceTypes" :key="t.networkdevicetypeid" :value="t.networkdevicetypeid">
{{ t.networkdevicetype }}
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="vendorid">Vendor</label>
<select id="vendorid" v-model="form.vendorid" class="form-control">
<option value="">Select Vendor</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
</div>
<div class="form-group">
<label for="firmwareversion">Firmware Version</label>
<input
id="firmwareversion"
v-model="form.firmwareversion"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="portcount">Port Count</label>
<input
id="portcount"
v-model.number="form.portcount"
type="number"
class="form-control"
min="0"
/>
</div>
<div class="form-group">
<label for="rackunit">Rack Unit</label>
<input
id="rackunit"
v-model="form.rackunit"
type="text"
class="form-control"
placeholder="e.g., U1, U5-U8"
/>
</div>
</div>
<div class="form-row checkboxes">
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.ispoe" />
<span>PoE Capable</span>
</label>
<small>Device supports Power over Ethernet</small>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.ismanaged" />
<span>Managed Device</span>
</label>
<small>Device has SNMP, web interface, or CLI management</small>
</div>
</div>
</fieldset>
<!-- Map Position (optional) -->
<fieldset>
<legend>Map Position (Optional)</legend>
<div class="form-row">
<div class="form-group">
<label for="mapleft">Map X Position</label>
<input
id="mapleft"
v-model.number="form.mapleft"
type="number"
class="form-control"
min="0"
/>
</div>
<div class="form-group">
<label for="maptop">Map Y Position</label>
<input
id="maptop"
v-model.number="form.maptop"
type="number"
class="form-control"
min="0"
/>
</div>
</div>
</fieldset>
<!-- Notes -->
<fieldset>
<legend>Notes</legend>
<div class="form-group">
<textarea
id="notes"
v-model="form.notes"
class="form-control"
rows="4"
placeholder="Additional notes about this device..."
></textarea>
</div>
</fieldset>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Save Changes' : 'Create Device') }}
</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
networkApi,
vendorsApi,
locationsApi,
statusesApi,
businessunitsApi
} from '../../api'
const route = useRoute()
const router = useRouter()
const deviceId = route.params.id
const isEdit = computed(() => !!deviceId)
const form = ref({
assetnumber: '',
name: '',
serialnumber: '',
statusid: '',
locationid: '',
businessunitid: '',
hostname: '',
networkdevicetypeid: '',
vendorid: '',
firmwareversion: '',
portcount: null,
rackunit: '',
ispoe: false,
ismanaged: false,
mapleft: null,
maptop: null,
notes: ''
})
const deviceTypes = ref([])
const vendors = ref([])
const locations = ref([])
const statuses = ref([])
const businessUnits = ref([])
const saving = ref(false)
const error = ref('')
onMounted(async () => {
await Promise.all([
loadDeviceTypes(),
loadVendors(),
loadLocations(),
loadStatuses(),
loadBusinessUnits()
])
if (isEdit.value) {
await loadDevice()
}
})
async function loadDeviceTypes() {
try {
const response = await networkApi.types.list({ per_page: 100 })
deviceTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading device types:', err)
}
}
async function loadVendors() {
try {
const response = await vendorsApi.list({ per_page: 100 })
vendors.value = response.data.data || []
} catch (err) {
console.error('Error loading vendors:', err)
}
}
async function loadLocations() {
try {
const response = await locationsApi.list({ per_page: 100 })
locations.value = response.data.data || []
} catch (err) {
console.error('Error loading locations:', err)
}
}
async function loadStatuses() {
try {
const response = await statusesApi.list({ per_page: 100 })
statuses.value = response.data.data || []
} catch (err) {
console.error('Error loading statuses:', err)
}
}
async function loadBusinessUnits() {
try {
const response = await businessunitsApi.list({ per_page: 100 })
businessUnits.value = response.data.data || []
} catch (err) {
console.error('Error loading business units:', err)
}
}
async function loadDevice() {
try {
const response = await networkApi.get(deviceId)
const data = response.data.data
// Populate form with existing data
form.value.assetnumber = data.assetnumber || ''
form.value.name = data.name || ''
form.value.serialnumber = data.serialnumber || ''
form.value.statusid = data.statusid || ''
form.value.locationid = data.locationid || ''
form.value.businessunitid = data.businessunitid || ''
form.value.mapleft = data.mapleft
form.value.maptop = data.maptop
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
}
} catch (err) {
console.error('Error loading device:', err)
error.value = 'Failed to load device'
}
}
async function submitForm() {
saving.value = true
error.value = ''
try {
const payload = {
assetnumber: form.value.assetnumber,
name: form.value.name || null,
serialnumber: form.value.serialnumber || null,
statusid: form.value.statusid || null,
locationid: form.value.locationid || null,
businessunitid: form.value.businessunitid || null,
hostname: form.value.hostname || null,
networkdevicetypeid: form.value.networkdevicetypeid || null,
vendorid: form.value.vendorid || null,
firmwareversion: form.value.firmwareversion || null,
portcount: form.value.portcount || null,
rackunit: form.value.rackunit || null,
ispoe: form.value.ispoe,
ismanaged: form.value.ismanaged,
mapleft: form.value.mapleft,
maptop: form.value.maptop,
notes: form.value.notes || null
}
if (isEdit.value) {
await networkApi.update(deviceId, payload)
router.push(`/network/${deviceId}`)
} else {
const response = await networkApi.create(payload)
const newId = response.data.data?.network_device?.networkdeviceid
router.push(newId ? `/network/${newId}` : '/network')
}
} catch (err) {
console.error('Error saving device:', err)
error.value = err.response?.data?.message || 'Failed to save device'
} finally {
saving.value = false
}
}
function cancel() {
if (isEdit.value) {
router.push(`/network/${deviceId}`)
} else {
router.push('/network')
}
}
</script>
<style scoped>
.form-card {
max-width: 800px;
}
fieldset {
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
legend {
font-weight: 600;
padding: 0 0.5rem;
color: var(--text);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
margin-bottom: 0.375rem;
font-weight: 500;
color: var(--text);
}
.form-group small {
margin-top: 0.25rem;
color: var(--text-light);
font-size: 0.8rem;
}
.checkboxes {
margin-top: 1rem;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 1.125rem;
height: 1.125rem;
}
.checkbox-group span {
font-weight: 500;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.error-message {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--danger);
color: white;
border-radius: 6px;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,360 @@
<template>
<div>
<div class="page-header">
<h2>Network Devices</h2>
<router-link to="/network/new" class="btn btn-primary">Add Device</router-link>
</div>
<!-- Type Tabs -->
<div class="type-tabs">
<button
:class="{ active: selectedType === null }"
@click="selectType(null)"
>
All ({{ totalCount }})
</button>
<button
v-for="t in deviceTypes"
:key="t.networkdevicetypeid"
:class="{ active: selectedType === t.networkdevicetypeid }"
@click="selectType(t.networkdevicetypeid)"
>
{{ t.networkdevicetype }} ({{ t.count || 0 }})
</button>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search by hostname, asset #, serial..."
@input="debouncedSearch"
/>
<select v-model="vendorFilter" class="form-control" @change="loadDevices">
<option value="">All Vendors</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
<select v-model="locationFilter" class="form-control" @change="loadDevices">
<option value="">All Locations</option>
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
{{ loc.location }}
</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Hostname</th>
<th>Asset #</th>
<th>Type</th>
<th>Vendor</th>
<th>Features</th>
<th>Location</th>
<th>Status</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>
<td>{{ device.assetnumber }}</td>
<td>{{ device.network_device?.networkdevicetype_name || '-' }}</td>
<td>{{ device.network_device?.vendor_name || '-' }}</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>
</td>
<td>{{ device.location_name || '-' }}</td>
<td>
<span class="badge" :class="getStatusClass(device.status_name)">
{{ device.status_name || 'Unknown' }}
</span>
</td>
<td class="actions">
<router-link
:to="`/network/${device.network_device?.networkdeviceid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="devices.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No network devices found
</td>
</tr>
</tbody>
</table>
</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>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { networkApi, vendorsApi, locationsApi } from '../../api'
const devices = ref([])
const deviceTypes = ref([])
const vendors = ref([])
const locations = ref([])
const loading = ref(true)
const search = ref('')
const selectedType = ref(null)
const vendorFilter = ref('')
const locationFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
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(),
loadVendors(),
loadLocations()
])
await loadDevices()
})
async function loadDeviceTypes() {
try {
const response = await networkApi.types.list({ per_page: 100 })
deviceTypes.value = response.data.data || []
// Get counts for each type
await updateTypeCounts()
} catch (error) {
console.error('Error loading device types:', error)
}
}
async function updateTypeCounts() {
// Get summary for type counts
try {
const response = await networkApi.dashboardSummary()
const byType = response.data.data?.by_type || []
totalCount.value = response.data.data?.total || 0
// Map counts to types
deviceTypes.value = deviceTypes.value.map(t => {
const found = byType.find(bt => bt.type === t.networkdevicetype)
return { ...t, count: found?.count || 0 }
})
} catch (error) {
console.error('Error loading type counts:', error)
}
}
async function loadVendors() {
try {
const response = await vendorsApi.list({ per_page: 100 })
vendors.value = response.data.data || []
} catch (error) {
console.error('Error loading vendors:', error)
}
}
async function loadLocations() {
try {
const response = await locationsApi.list({ per_page: 100 })
locations.value = response.data.data || []
} catch (error) {
console.error('Error loading locations:', error)
}
}
async function loadDevices() {
loading.value = true
try {
const params = {
page: page.value,
per_page: 25
}
if (search.value) params.search = search.value
if (selectedType.value) params.type_id = selectedType.value
if (vendorFilter.value) params.vendor_id = vendorFilter.value
if (locationFilter.value) params.location_id = locationFilter.value
const response = await networkApi.list(params)
devices.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading network devices:', error)
} finally {
loading.value = false
}
}
function selectType(typeId) {
selectedType.value = typeId
page.value = 1
loadDevices()
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadDevices()
}, 300)
}
function goToPage(p) {
if (p >= 1 && p <= totalPages.value) {
page.value = p
loadDevices()
}
}
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair' || s === 'maintenance') return 'badge-warning'
if (s === 'retired' || s === 'decommissioned') return 'badge-danger'
return 'badge-info'
}
</script>
<style scoped>
.type-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.type-tabs button {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.type-tabs button:hover {
background: var(--bg);
border-color: var(--primary);
}
.type-tabs button.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters .form-control {
flex: 1;
min-width: 150px;
}
.filters select.form-control {
flex: 0 0 auto;
width: auto;
min-width: 150px;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.features {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.feature-tag {
display: inline-block;
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
border-radius: 4px;
background: var(--bg);
color: var(--text-light);
}
.feature-tag.poe {
background: #d4edda;
color: #155724;
}
.feature-tag.managed {
background: #e3f2fd;
color: #1565c0;
}
.feature-tag.ports {
background: var(--bg);
color: var(--text-light);
}
@media (prefers-color-scheme: dark) {
.feature-tag.poe {
background: #1e3a29;
color: #4ade80;
}
.feature-tag.managed {
background: #1e3a5f;
color: #60a5fa;
}
}
</style>

View File

@@ -0,0 +1,601 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Notification' : (isDetail ? 'Notification Details' : 'New Notification') }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveNotification">
<div class="form-group">
<label for="notificationtypeid">Type *</label>
<select
id="notificationtypeid"
v-model="form.notificationtypeid"
class="form-control"
required
:disabled="isDetail"
@change="onTypeChange"
>
<option value="">-- Select Type --</option>
<option
v-for="type in types"
:key="type.notificationtypeid"
:value="type.notificationtypeid"
>
{{ type.typename }}
</option>
</select>
<small class="form-hint">Classification type for this notification</small>
</div>
<div class="form-group">
<label for="notification">{{ isRecognition ? 'Recognition Message' : 'Notification' }} *</label>
<textarea
id="notification"
v-model="form.notification"
class="form-control"
rows="4"
required
:disabled="isDetail"
:placeholder="isRecognition ? 'Enter the recognition message...' : 'Enter the notification message...'"
></textarea>
</div>
<!-- Employee Search - Only for Recognition -->
<div v-if="isRecognition" class="form-group">
<label>Employee(s) *</label>
<div class="employee-search-container">
<input
v-model="employeeSearch"
type="text"
class="form-control"
placeholder="Search by name..."
:disabled="isDetail"
@input="searchEmployees"
@keydown.enter.prevent="addCustomEmployee"
/>
<div v-if="employeeResults.length" class="employee-dropdown">
<div
v-for="emp in employeeResults"
:key="emp.SSO"
class="employee-option"
@click="selectEmployee(emp)"
>
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
<span class="emp-sso">{{ emp.SSO }}</span>
</div>
</div>
</div>
<small class="form-hint">Search for employees or press Enter to add a custom name</small>
<!-- Selected Employees -->
<div v-if="selectedEmployees.length" class="selected-employees">
<div
v-for="(emp, idx) in selectedEmployees"
:key="idx"
class="selected-employee"
>
<span>{{ emp.name }}</span>
<button v-if="!isDetail" type="button" class="btn-remove" @click="removeEmployee(idx)">&times;</button>
</div>
</div>
</div>
<div class="form-group">
<label for="businessunitid">Business Unit</label>
<select
id="businessunitid"
v-model="form.businessunitid"
class="form-control"
:disabled="isDetail"
>
<option value="">-- All Business Units --</option>
<option
v-for="bu in businessUnits"
:key="bu.businessunitid"
:value="bu.businessunitid"
>
{{ bu.businessunit }}
</option>
</select>
<small class="form-hint">Leave blank to apply to all</small>
</div>
<div class="form-group">
<label for="appid">Related Application</label>
<select
id="appid"
v-model="form.appid"
class="form-control"
:disabled="isDetail"
>
<option value="">-- No Application --</option>
<option
v-for="app in applications"
:key="app.appid"
:value="app.appid"
>
{{ app.appname }}
</option>
</select>
<small class="form-hint">Link to a specific application (e.g., for software updates)</small>
</div>
<div v-if="!isRecognition" class="form-group">
<label for="ticketnumber">Ticket Number</label>
<input
id="ticketnumber"
v-model="form.ticketnumber"
type="text"
class="form-control"
maxlength="50"
:disabled="isDetail"
placeholder="GEINC123456 or GECHG123456"
/>
<small class="form-hint">Optional ServiceNow ticket number</small>
</div>
<div class="form-group">
<label for="link">More Info URL</label>
<input
id="link"
v-model="form.link"
type="url"
class="form-control"
maxlength="500"
:disabled="isDetail"
placeholder="https://..."
/>
</div>
<!-- Time fields - Hidden for Recognition (auto-set) -->
<div v-if="!isRecognition" class="form-row">
<div class="form-group">
<label for="starttime">Start Time *</label>
<div class="input-group">
<input
id="starttime"
v-model="form.starttime"
type="datetime-local"
class="form-control"
required
:disabled="isDetail"
/>
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="setNow('starttime')">
Now
</button>
</div>
<small class="form-hint">When notification becomes visible</small>
</div>
<div class="form-group">
<label for="endtime">End Time</label>
<div class="input-group">
<input
id="endtime"
v-model="form.endtime"
type="datetime-local"
class="form-control"
:disabled="isDetail"
/>
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="setNow('endtime')">
Now
</button>
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="form.endtime = ''">
Clear
</button>
</div>
<small class="form-hint">Leave blank for indefinite</small>
</div>
</div>
<div class="form-row checkbox-row">
<div class="form-group">
<label>
<input
type="checkbox"
v-model="form.isactive"
:disabled="isDetail"
/>
Active
</label>
<small class="form-hint">Uncheck to save as draft</small>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
v-model="form.isshopfloor"
:disabled="isDetail"
/>
Show on Shopfloor Dashboard
</label>
<small class="form-hint">Display on TV dashboard</small>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-actions">
<template v-if="isDetail">
<router-link :to="`/notifications/${route.params.id}/edit`" class="btn btn-primary">
Edit
</router-link>
<router-link to="/notifications" class="btn btn-secondary">Back</router-link>
</template>
<template v-else>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Update' : 'Create') }}
</button>
<router-link to="/notifications" class="btn btn-secondary">Cancel</router-link>
</template>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi } from '@/api'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => route.name === 'notification-edit')
const isDetail = computed(() => route.name === 'notification-detail')
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const types = ref([])
const businessUnits = ref([])
const applications = ref([])
// Employee search
const employeeSearch = ref('')
const employeeResults = ref([])
const selectedEmployees = ref([])
const form = ref({
notification: '',
notificationtypeid: '',
businessunitid: '',
appid: '',
ticketnumber: '',
link: '',
starttime: '',
endtime: '',
isactive: true,
isshopfloor: false,
employeesso: ''
})
// Check if selected type is Recognition
const isRecognition = computed(() => {
const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid))
return selectedType?.typename?.toLowerCase() === 'recognition'
})
onMounted(async () => {
try {
// Load dropdown data in parallel
const [typesRes, buRes, appsRes] = await Promise.all([
notificationsApi.types.list(),
businessUnitsApi.list().catch(() => ({ data: { data: [] } })),
applicationsApi.list({ per_page: 500 }).catch(() => ({ data: { data: [] } }))
])
types.value = typesRes.data.data || []
businessUnits.value = buRes.data?.data || []
applications.value = appsRes.data?.data || []
// Load notification if editing or viewing
if (route.params.id) {
const response = await notificationsApi.get(route.params.id)
const n = response.data.data
form.value = {
notification: n.notification || '',
notificationtypeid: n.notificationtypeid || '',
businessunitid: n.businessunitid || '',
appid: n.appid || '',
ticketnumber: n.ticketnumber || '',
link: n.link || '',
starttime: formatDateForInput(n.starttime),
endtime: formatDateForInput(n.endtime),
isactive: n.isactive !== false,
isshopfloor: n.isshopfloor || false,
employeesso: n.employeesso || ''
}
// Parse existing employee data
if (n.employeesso) {
const ssos = n.employeesso.split(',')
for (const sso of ssos) {
if (sso.trim()) {
// Try to look up the employee name
const name = n.employeename || sso.trim()
selectedEmployees.value.push({ sso: sso.trim(), name })
}
}
}
} else {
// Set default start date to now
form.value.starttime = formatDateForInput(new Date().toISOString())
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
} finally {
loading.value = false
}
})
function formatDateForInput(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toISOString().slice(0, 16)
}
function setNow(field) {
form.value[field] = formatDateForInput(new Date().toISOString())
}
function onTypeChange() {
// If switching to Recognition, auto-set times
if (isRecognition.value) {
const now = new Date()
form.value.starttime = formatDateForInput(now.toISOString())
// End time = now + 30 days
const endDate = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
form.value.endtime = formatDateForInput(endDate.toISOString())
}
}
// Employee search
let searchTimeout = null
async function searchEmployees() {
if (searchTimeout) clearTimeout(searchTimeout)
const query = employeeSearch.value.trim()
if (query.length < 2) {
employeeResults.value = []
return
}
searchTimeout = setTimeout(async () => {
try {
const res = await employeesApi.search(query)
employeeResults.value = res.data.data || []
} catch (err) {
console.error('Employee search error:', err)
employeeResults.value = []
}
}, 300)
}
function selectEmployee(emp) {
// Check if already selected
if (selectedEmployees.value.some(e => e.sso === String(emp.SSO))) {
return
}
selectedEmployees.value.push({
sso: String(emp.SSO),
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
})
employeeSearch.value = ''
employeeResults.value = []
updateEmployeeSso()
}
function addCustomEmployee() {
const name = employeeSearch.value.trim()
if (!name) return
// Add as custom name (no SSO)
selectedEmployees.value.push({
sso: `NAME:${name}`,
name: name
})
employeeSearch.value = ''
employeeResults.value = []
updateEmployeeSso()
}
function removeEmployee(idx) {
selectedEmployees.value.splice(idx, 1)
updateEmployeeSso()
}
function updateEmployeeSso() {
form.value.employeesso = selectedEmployees.value.map(e => e.sso).join(',')
}
async function saveNotification() {
error.value = ''
// Validation for Recognition type
if (isRecognition.value && selectedEmployees.value.length === 0) {
error.value = 'Please select at least one employee for recognition'
return
}
saving.value = true
try {
const data = {
notification: form.value.notification,
notificationtypeid: parseInt(form.value.notificationtypeid) || null,
businessunitid: parseInt(form.value.businessunitid) || null,
appid: parseInt(form.value.appid) || null,
ticketnumber: form.value.ticketnumber || null,
link: form.value.link || null,
starttime: form.value.starttime ? new Date(form.value.starttime).toISOString() : null,
endtime: form.value.endtime ? new Date(form.value.endtime).toISOString() : null,
isactive: form.value.isactive,
isshopfloor: form.value.isshopfloor,
employeesso: form.value.employeesso || null
}
// For recognition, also send employeename
if (isRecognition.value && selectedEmployees.value.length > 0) {
data.employeename = selectedEmployees.value.map(e => e.name).join(', ')
}
if (isEdit.value) {
await notificationsApi.update(route.params.id, data)
} else {
await notificationsApi.create(data)
}
router.push('/notifications')
} catch (err) {
console.error('Error saving notification:', err)
error.value = err.response?.data?.message || 'Failed to save notification'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.input-group {
display: flex;
gap: 0.25rem;
}
.input-group .form-control {
flex: 1;
}
.input-group .btn {
flex-shrink: 0;
}
.checkbox-row {
display: flex;
gap: 2rem;
margin-top: 1rem;
}
.checkbox-row .form-group {
flex: none;
}
.checkbox-row label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
.form-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
}
/* Employee search styles */
.employee-search-container {
position: relative;
}
.employee-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card-solid, #1a1a1a);
border: 1px solid var(--border);
border-radius: 0.25rem;
max-height: 200px;
overflow-y: auto;
z-index: 100;
}
.employee-option {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.employee-option:hover {
background: rgba(255, 255, 255, 0.1);
}
.emp-name {
font-weight: 500;
}
.emp-sso {
font-size: 0.85rem;
color: var(--text-light);
}
.selected-employees {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.selected-employee {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--primary);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.9rem;
}
.btn-remove {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
font-size: 1.2rem;
line-height: 1;
opacity: 0.7;
}
.btn-remove:hover {
opacity: 1;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
.checkbox-row {
flex-direction: column;
gap: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div class="page-header">
<h1>Notifications</h1>
<router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link>
</div>
<div class="filters">
<input
v-model="searchQuery"
type="text"
class="form-control"
placeholder="Search notifications..."
@input="debouncedSearch"
/>
<select v-model="selectedType" class="form-control" @change="loadNotifications">
<option value="">All Types</option>
<option v-for="type in types" :key="type.notificationtypeid" :value="type.notificationtypeid">
{{ type.typename }}
</option>
</select>
<select v-model="currentFilter" class="form-control" @change="loadNotifications">
<option value="">All</option>
<option value="current">Current Only</option>
<option value="pinned">Pinned Only</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="notifications.length === 0" class="empty">
No notifications found.
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th>Start Date</th>
<th>End Date</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="notification in notifications" :key="notification.notificationid">
<td>
<router-link :to="`/notifications/${notification.notificationid}`">
{{ notification.title }}
</router-link>
<span v-if="notification.ispinned" class="badge badge-primary" title="Pinned">Pinned</span>
</td>
<td>
<span class="badge" :style="{ backgroundColor: notification.typecolor }">
{{ notification.typename }}
</span>
</td>
<td>{{ formatDate(notification.startdate) }}</td>
<td>{{ notification.enddate ? formatDate(notification.enddate) : 'No end' }}</td>
<td>
<span :class="['badge', notification.iscurrent ? 'badge-success' : 'badge-secondary']">
{{ notification.iscurrent ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="actions">
<router-link :to="`/notifications/${notification.notificationid}/edit`" class="btn btn-small">
Edit
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination" v-if="totalPages > 1">
<button
v-for="p in totalPages"
:key="p"
:class="{ active: p === page }"
@click="goToPage(p)"
>
{{ p }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { notificationsApi } from '@/api'
const notifications = ref([])
const types = ref([])
const loading = ref(true)
const searchQuery = ref('')
const selectedType = ref('')
const currentFilter = ref('')
const page = ref(1)
const perPage = ref(20)
const total = ref(0)
const totalPages = ref(1)
let searchTimeout = null
onMounted(async () => {
await loadTypes()
await loadNotifications()
})
async function loadTypes() {
try {
const response = await notificationsApi.types.list()
types.value = response.data.data
} catch (error) {
console.error('Error loading types:', error)
}
}
async function loadNotifications() {
loading.value = true
try {
const params = {
page: page.value,
per_page: perPage.value
}
if (searchQuery.value) {
params.search = searchQuery.value
}
if (selectedType.value) {
params.type_id = selectedType.value
}
if (currentFilter.value === 'current') {
params.current = 'true'
} else if (currentFilter.value === 'pinned') {
params.pinned = 'true'
}
const response = await notificationsApi.list(params)
notifications.value = response.data.data
total.value = response.data.meta?.pagination?.total || notifications.value.length
totalPages.value = Math.ceil(total.value / perPage.value)
} catch (error) {
console.error('Error loading notifications:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadNotifications()
}, 300)
}
function goToPage(p) {
page.value = p
loadNotifications()
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString()
}
</script>

View File

@@ -242,6 +242,7 @@
<ShopFloorMap <ShopFloorMap
:pickerMode="true" :pickerMode="true"
:initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null" :initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null"
:theme="currentTheme"
@positionPicked="handlePositionPicked" @positionPicked="handlePositionPicked"
/> />
</div> </div>
@@ -270,6 +271,7 @@ import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api' import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue' import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue' import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

View File

@@ -13,32 +13,28 @@
<template v-else-if="printer"> <template v-else-if="printer">
<!-- Hero Section --> <!-- Hero Section -->
<div class="hero-card"> <div class="hero-card">
<div class="hero-image" v-if="printer.model?.imageurl">
<img :src="printer.model.imageurl" :alt="printer.model?.modelnumber" />
</div>
<div class="hero-content"> <div class="hero-content">
<div class="hero-title"> <div class="hero-title">
<h1>{{ printer.machinenumber }}</h1> <h1>{{ printer.name || printer.assetnumber }}</h1>
<span v-if="printer.alias" class="hero-alias">{{ printer.alias }}</span>
</div> </div>
<div class="hero-meta"> <div class="hero-meta">
<span class="badge badge-lg badge-printer">Printer</span> <span class="badge badge-lg badge-printer">Printer</span>
<span class="badge badge-lg" :class="getStatusClass(printer.status?.status)"> <span v-if="printer.printer?.iscsf" class="badge badge-lg badge-info">CSF</span>
{{ printer.status?.status || 'Unknown' }} <span v-if="printer.printer?.iscolor" class="badge badge-lg badge-success">Color</span>
</span> <span v-if="printer.printer?.isnetwork" class="badge badge-lg badge-secondary">Network</span>
</div> </div>
<div class="hero-details"> <div class="hero-details">
<div class="hero-detail" v-if="printer.vendor?.vendor"> <div class="hero-detail" v-if="printer.printer?.vendor_name">
<span class="hero-detail-label">Vendor</span> <span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ printer.vendor.vendor }}</span> <span class="hero-detail-value">{{ printer.printer.vendor_name }}</span>
</div> </div>
<div class="hero-detail" v-if="printer.model?.modelnumber"> <div class="hero-detail" v-if="printer.printer?.model_name">
<span class="hero-detail-label">Model</span> <span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ printer.model.modelnumber }}</span> <span class="hero-detail-value">{{ printer.printer.model_name }}</span>
</div> </div>
<div class="hero-detail" v-if="printer.location?.locationname"> <div class="hero-detail" v-if="printer.serialnumber">
<span class="hero-detail-label">Location</span> <span class="hero-detail-label">Serial Number</span>
<span class="hero-detail-value">{{ printer.location.location }}</span> <span class="hero-detail-value mono">{{ printer.serialnumber }}</span>
</div> </div>
<div class="hero-detail" v-if="ipAddress"> <div class="hero-detail" v-if="ipAddress">
<span class="hero-detail-label">IP Address</span> <span class="hero-detail-label">IP Address</span>
@@ -57,16 +53,20 @@
<h3 class="section-title">Identity</h3> <h3 class="section-title">Identity</h3>
<div class="info-list"> <div class="info-list">
<div class="info-row"> <div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value mono">{{ printer.assetnumber }}</span>
</div>
<div class="info-row" v-if="printer.printer?.windowsname">
<span class="info-label">Windows Name</span> <span class="info-label">Windows Name</span>
<span class="info-value">{{ printer.machinenumber }}</span> <span class="info-value mono">{{ printer.printer.windowsname }}</span>
</div> </div>
<div class="info-row" v-if="printer.alias"> <div class="info-row" v-if="printer.printer?.hostname">
<span class="info-label">Alias</span> <span class="info-label">Hostname / FQDN</span>
<span class="info-value">{{ printer.alias }}</span> <span class="info-value mono">{{ printer.printer.hostname }}</span>
</div> </div>
<div class="info-row" v-if="printer.hostname"> <div class="info-row" v-if="printer.printer?.sharename">
<span class="info-label">Hostname</span> <span class="info-label">CSF Share Name</span>
<span class="info-value mono">{{ printer.hostname }}</span> <span class="info-value mono">{{ printer.printer.sharename }}</span>
</div> </div>
<div class="info-row" v-if="printer.serialnumber"> <div class="info-row" v-if="printer.serialnumber">
<span class="info-label">Serial Number</span> <span class="info-label">Serial Number</span>
@@ -76,16 +76,32 @@
</div> </div>
<!-- Printer Settings --> <!-- Printer Settings -->
<div class="section-card" v-if="printer.printerdata?.windowsname || printer.printerdata?.sharename"> <div class="section-card">
<h3 class="section-title">Print Server</h3> <h3 class="section-title">Printer Settings</h3>
<div class="info-list"> <div class="info-list">
<div class="info-row" v-if="printer.printerdata?.windowsname"> <div class="info-row" v-if="printer.printer?.installpath">
<span class="info-label">Windows Name</span> <span class="info-label">Install Path</span>
<span class="info-value mono">{{ printer.printerdata.windowsname }}</span> <span class="info-value mono">{{ printer.printer.installpath }}</span>
</div> </div>
<div class="info-row" v-if="printer.printerdata?.sharename"> <div class="info-row">
<span class="info-label">CSF Name</span> <span class="info-label">Color</span>
<span class="info-value mono">{{ printer.printerdata.sharename }}</span> <span class="info-value">{{ printer.printer?.iscolor ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">Duplex</span>
<span class="info-value">{{ printer.printer?.isduplex ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">Network Printer</span>
<span class="info-value">{{ printer.printer?.isnetwork ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">CSF Printer</span>
<span class="info-value">{{ printer.printer?.iscsf ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row" v-if="printer.printer?.pin">
<span class="info-label">PIN</span>
<span class="info-value mono">{{ printer.printer.pin }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -104,19 +120,23 @@
<h3 class="section-title">Location</h3> <h3 class="section-title">Location</h3>
<div class="info-list"> <div class="info-list">
<div class="info-row"> <div class="info-row">
<span class="info-label">Location</span> <span class="info-label">Map Location</span>
<span class="info-value"> <span class="info-value">
<LocationMapTooltip <LocationMapTooltip
v-if="printer.mapleft != null && printer.maptop != null" v-if="printer.mapleft != null && printer.maptop != null"
:left="printer.mapleft" :left="printer.mapleft"
:top="printer.maptop" :top="printer.maptop"
:machineName="printer.machinenumber" :machineName="printer.name || printer.assetnumber"
> >
<span class="location-link">{{ printer.location?.locationname || 'On Map' }}</span> <span class="location-link">View on Map</span>
</LocationMapTooltip> </LocationMapTooltip>
<span v-else>{{ printer.location?.locationname || '-' }}</span> <span v-else>Not mapped</span>
</span> </span>
</div> </div>
<div class="info-row" v-if="printer.businessunit_name">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ printer.businessunit_name }}</span>
</div>
</div> </div>
</div> </div>

View File

@@ -240,6 +240,7 @@
<ShopFloorMap <ShopFloorMap
:pickerMode="true" :pickerMode="true"
:initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null" :initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null"
:theme="currentTheme"
@positionPicked="handlePositionPicked" @positionPicked="handlePositionPicked"
/> />
</div> </div>
@@ -268,6 +269,7 @@ import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, printersApi, modelsApi } from '../../api' import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, printersApi, modelsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue' import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue' import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

View File

@@ -24,28 +24,28 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Machine #</th> <th>Asset #</th>
<th>Alias</th> <th>Name</th>
<th>Location</th> <th>Business Unit</th>
<th>Model</th> <th>Model</th>
<th>Status</th> <th>Status</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="printer in printers" :key="printer.machineid"> <tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid">
<td>{{ printer.machinenumber }}</td> <td>{{ printer.assetnumber }}</td>
<td>{{ printer.alias || '-' }}</td> <td>{{ printer.name || '-' }}</td>
<td>{{ printer.location || '-' }}</td> <td>{{ printer.businessunit_name || '-' }}</td>
<td>{{ printer.model || '-' }}</td> <td>{{ printer.printer?.model_name || '-' }}</td>
<td> <td>
<span class="badge" :class="getStatusClass(printer.status)"> <span class="badge" :class="getStatusClass(printer.status_name)">
{{ printer.status || 'Unknown' }} {{ printer.status_name || 'Active' }}
</span> </span>
</td> </td>
<td class="actions"> <td class="actions">
<router-link <router-link
:to="`/printers/${printer.machineid}`" :to="`/printers/${printer.printer?.printerid || printer.assetid}`"
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"
> >
View View

View File

@@ -0,0 +1,269 @@
<template>
<div class="page-header">
<h1>Reports</h1>
</div>
<div class="reports-grid">
<div v-for="report in reports" :key="report.id" class="report-card card" @click="runReport(report)">
<h3>{{ report.name }}</h3>
<p>{{ report.description }}</p>
<span class="badge">{{ report.category }}</span>
</div>
</div>
<!-- Report Results -->
<div v-if="currentReport" class="report-results card">
<div class="report-header">
<h2>{{ currentReport.name }}</h2>
<div class="report-actions">
<button class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<button class="btn btn-secondary" @click="clearReport">Close</button>
</div>
</div>
<div v-if="loading" class="loading">Loading report...</div>
<div v-else-if="reportData">
<!-- Equipment by Type -->
<div v-if="currentReport.id === 'equipment-by-type'" class="report-content">
<p class="report-summary">Total: {{ reportData.total }}</p>
<table>
<thead>
<tr>
<th>Equipment Type</th>
<th>Description</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.equipmenttype">
<td>{{ item.equipmenttype }}</td>
<td>{{ item.description }}</td>
<td>{{ item.count }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Assets by Status -->
<div v-else-if="currentReport.id === 'assets-by-status'" class="report-content">
<p class="report-summary">Total: {{ reportData.total }}</p>
<table>
<thead>
<tr>
<th>Status</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.status">
<td>
<span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span>
</td>
<td>{{ item.count }}</td>
</tr>
</tbody>
</table>
</div>
<!-- KB Popularity -->
<div v-else-if="currentReport.id === 'kb-popularity'" class="report-content">
<table>
<thead>
<tr>
<th>Article</th>
<th>Application</th>
<th>Clicks</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.linkid">
<td>
<a v-if="item.linkurl" :href="item.linkurl" target="_blank">{{ item.shortdescription }}</a>
<span v-else>{{ item.shortdescription }}</span>
</td>
<td>{{ item.application || '-' }}</td>
<td>{{ item.clicks }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Asset Inventory -->
<div v-else-if="currentReport.id === 'asset-inventory'" class="report-content">
<p class="report-summary">Total Assets: {{ reportData.total }}</p>
<h3>By Type</h3>
<table>
<thead><tr><th>Type</th><th>Count</th></tr></thead>
<tbody>
<tr v-for="item in reportData.data.bytype" :key="item.type">
<td>{{ item.type }}</td><td>{{ item.count }}</td>
</tr>
</tbody>
</table>
<h3>By Status</h3>
<table>
<thead><tr><th>Status</th><th>Count</th></tr></thead>
<tbody>
<tr v-for="item in reportData.data.bystatus" :key="item.status">
<td><span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span></td>
<td>{{ item.count }}</td>
</tr>
</tbody>
</table>
<h3>By Location</h3>
<table>
<thead><tr><th>Location</th><th>Count</th></tr></thead>
<tbody>
<tr v-for="item in reportData.data.bylocation" :key="item.location">
<td>{{ item.location }}</td><td>{{ item.count }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Generic fallback -->
<div v-else class="report-content">
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { reportsApi } from '@/api'
const reports = ref([])
const currentReport = ref(null)
const reportData = ref(null)
const loading = ref(false)
onMounted(async () => {
await loadReports()
})
async function loadReports() {
try {
const response = await reportsApi.list()
reports.value = response.data.data.reports
} catch (error) {
console.error('Error loading reports:', error)
}
}
async function runReport(report) {
currentReport.value = report
loading.value = true
reportData.value = null
try {
let response
switch (report.id) {
case 'equipment-by-type':
response = await reportsApi.equipmentByType()
break
case 'assets-by-status':
response = await reportsApi.assetsByStatus()
break
case 'kb-popularity':
response = await reportsApi.kbPopularity()
break
case 'warranty-status':
response = await reportsApi.warrantyStatus()
break
case 'software-compliance':
response = await reportsApi.softwareCompliance()
break
case 'asset-inventory':
response = await reportsApi.assetInventory()
break
default:
console.error('Unknown report:', report.id)
return
}
reportData.value = response.data.data
} catch (error) {
console.error('Error running report:', error)
} finally {
loading.value = false
}
}
function exportCSV() {
if (!currentReport.value) return
window.open(`/api/reports/${currentReport.value.id}?format=csv`, '_blank')
}
function clearReport() {
currentReport.value = null
reportData.value = null
}
</script>
<style scoped>
.reports-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.report-card {
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.report-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.report-card h3 {
margin-top: 0;
margin-bottom: 0.5rem;
}
.report-card p {
color: var(--text-light);
margin-bottom: 1rem;
}
.report-results {
margin-top: 2rem;
}
.report-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.report-header h2 {
margin: 0;
}
.report-actions {
display: flex;
gap: 0.5rem;
}
.report-summary {
font-size: 1.1rem;
font-weight: 500;
margin-bottom: 1rem;
}
.report-content h3 {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
.report-content table {
width: 100%;
margin-bottom: 1rem;
}
</style>

View File

@@ -50,6 +50,18 @@
<h3>Business Units</h3> <h3>Business Units</h3>
<p>Manage organizational units</p> <p>Manage organizational units</p>
</router-link> </router-link>
<router-link to="/settings/vlans" class="settings-card">
<div class="card-icon">🌐</div>
<h3>VLANs</h3>
<p>Manage virtual LANs</p>
</router-link>
<router-link to="/settings/subnets" class="settings-card">
<div class="card-icon">🔗</div>
<h3>Subnets</h3>
<p>Manage IP subnets and DHCP</p>
</router-link>
</div> </div>
</div> </div>
</template> </template>
@@ -71,8 +83,8 @@
.settings-card { .settings-card {
display: block; display: block;
padding: 1.5rem; padding: 1.5rem;
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
@@ -80,8 +92,8 @@
} }
.settings-card:hover { .settings-card:hover {
border-color: #1976d2; border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0,0,0,0.1); box-shadow: 0 2px 8px rgba(0,0,0,0.15);
} }
.card-icon { .card-icon {
@@ -91,12 +103,12 @@
.settings-card h3 { .settings-card h3 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
color: #333; color: var(--text);
} }
.settings-card p { .settings-card p {
margin: 0; margin: 0;
color: #666; color: var(--text-light);
font-size: 0.9rem; font-size: 0.9rem;
} }
</style> </style>

View File

@@ -0,0 +1,559 @@
<template>
<div>
<div class="page-header">
<h2>Subnets</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Subnet</button>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search subnets..."
@input="debouncedSearch"
/>
<select v-model="vlanFilter" class="form-control" @change="loadSubnets">
<option value="">All VLANs</option>
<option v-for="vlan in vlans" :key="vlan.vlanid" :value="vlan.vlanid">
VLAN {{ vlan.vlannumber }} - {{ vlan.name }}
</option>
</select>
<select v-model="typeFilter" class="form-control" @change="loadSubnets">
<option value="">All Types</option>
<option value="ipv4">IPv4</option>
<option value="ipv6">IPv6</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>CIDR</th>
<th>Name</th>
<th>VLAN</th>
<th>Gateway</th>
<th>DHCP</th>
<th>Location</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="subnet in subnets" :key="subnet.subnetid">
<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>
<span v-else>-</span>
</td>
<td class="mono">{{ subnet.gatewayip || '-' }}</td>
<td>
<span class="badge" :class="subnet.dhcpenabled ? 'badge-success' : 'badge-secondary'">
{{ subnet.dhcpenabled ? 'Enabled' : 'Disabled' }}
</span>
</td>
<td>{{ subnet.location_name || '-' }}</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(subnet)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(subnet)"
>
Delete
</button>
</td>
</tr>
<tr v-if="subnets.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
No subnets found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination" v-if="totalPages > 1">
<button
v-for="p in totalPages"
:key="p"
:class="{ active: p === page }"
@click="goToPage(p)"
>
{{ p }}
</button>
</div>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editingSubnet ? 'Edit Subnet' : 'Add Subnet' }}</h3>
</div>
<form @submit.prevent="saveSubnet">
<div class="modal-body">
<!-- Basic Info -->
<div class="form-section">
<h4>Basic Information</h4>
<div class="form-row">
<div class="form-group">
<label for="cidr">CIDR *</label>
<input
id="cidr"
v-model="form.cidr"
type="text"
class="form-control"
placeholder="e.g., 10.1.1.0/24"
required
/>
</div>
<div class="form-group">
<label for="name">Name *</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
required
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="vlanid">VLAN</label>
<select id="vlanid" v-model="form.vlanid" class="form-control">
<option value="">Select VLAN</option>
<option v-for="vlan in vlans" :key="vlan.vlanid" :value="vlan.vlanid">
VLAN {{ vlan.vlannumber }} - {{ vlan.name }}
</option>
</select>
</div>
<div class="form-group">
<label for="locationid">Location</label>
<select id="locationid" v-model="form.locationid" class="form-control">
<option value="">Select Location</option>
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
{{ loc.location }}
</option>
</select>
</div>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
v-model="form.description"
class="form-control"
rows="2"
></textarea>
</div>
</div>
<!-- Network Details -->
<div class="form-section">
<h4>Network Details</h4>
<div class="form-row">
<div class="form-group">
<label for="gatewayip">Gateway IP</label>
<input
id="gatewayip"
v-model="form.gatewayip"
type="text"
class="form-control"
placeholder="e.g., 10.1.1.1"
/>
</div>
<div class="form-group">
<label for="subnetmask">Subnet Mask</label>
<input
id="subnetmask"
v-model="form.subnetmask"
type="text"
class="form-control"
placeholder="e.g., 255.255.255.0"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="dns1">Primary DNS</label>
<input
id="dns1"
v-model="form.dns1"
type="text"
class="form-control"
/>
</div>
<div class="form-group">
<label for="dns2">Secondary DNS</label>
<input
id="dns2"
v-model="form.dns2"
type="text"
class="form-control"
/>
</div>
</div>
</div>
<!-- DHCP Settings -->
<div class="form-section">
<h4>DHCP Settings</h4>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.dhcpenabled" />
<span>DHCP Enabled</span>
</label>
</div>
<div v-if="form.dhcpenabled" class="form-row">
<div class="form-group">
<label for="dhcprangestart">DHCP Range Start</label>
<input
id="dhcprangestart"
v-model="form.dhcprangestart"
type="text"
class="form-control"
placeholder="e.g., 10.1.1.100"
/>
</div>
<div class="form-group">
<label for="dhcprangeend">DHCP Range End</label>
<input
id="dhcprangeend"
v-model="form.dhcprangeend"
type="text"
class="form-control"
placeholder="e.g., 10.1.1.200"
/>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header">
<h3>Delete Subnet</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete subnet <strong>{{ subnetToDelete?.cidr }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteSubnet">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { networkApi, locationsApi } from '../../api'
const route = useRoute()
const subnets = ref([])
const vlans = ref([])
const locations = ref([])
const loading = ref(true)
const search = ref('')
const vlanFilter = ref('')
const typeFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const showModal = ref(false)
const editingSubnet = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const subnetToDelete = ref(null)
const form = ref({
cidr: '',
name: '',
description: '',
gatewayip: '',
subnetmask: '',
vlanid: '',
locationid: '',
subnettype: '',
dhcpenabled: true,
dhcprangestart: '',
dhcprangeend: '',
dns1: '',
dns2: ''
})
let searchTimeout = null
onMounted(async () => {
// Check for vlanid query parameter
if (route.query.vlanid) {
vlanFilter.value = route.query.vlanid
}
await Promise.all([
loadVLANs(),
loadLocations()
])
await loadSubnets()
})
async function loadVLANs() {
try {
const response = await networkApi.vlans.list({ per_page: 100 })
vlans.value = response.data.data || []
} catch (err) {
console.error('Error loading VLANs:', err)
}
}
async function loadLocations() {
try {
const response = await locationsApi.list({ per_page: 100 })
locations.value = response.data.data || []
} catch (err) {
console.error('Error loading locations:', err)
}
}
async function loadSubnets() {
loading.value = true
try {
const params = {
page: page.value,
per_page: 20
}
if (search.value) params.search = search.value
if (vlanFilter.value) params.vlanid = vlanFilter.value
if (typeFilter.value) params.type = typeFilter.value
const response = await networkApi.subnets.list(params)
subnets.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading subnets:', err)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadSubnets()
}, 300)
}
function goToPage(p) {
page.value = p
loadSubnets()
}
function openModal(subnet = null) {
editingSubnet.value = subnet
if (subnet) {
form.value = {
cidr: subnet.cidr || '',
name: subnet.name || '',
description: subnet.description || '',
gatewayip: subnet.gatewayip || '',
subnetmask: subnet.subnetmask || '',
vlanid: subnet.vlanid || '',
locationid: subnet.locationid || '',
subnettype: subnet.subnettype || '',
dhcpenabled: subnet.dhcpenabled ?? true,
dhcprangestart: subnet.dhcprangestart || '',
dhcprangeend: subnet.dhcprangeend || '',
dns1: subnet.dns1 || '',
dns2: subnet.dns2 || ''
}
} else {
form.value = {
cidr: '',
name: '',
description: '',
gatewayip: '',
subnetmask: '',
vlanid: vlanFilter.value || '',
locationid: '',
subnettype: '',
dhcpenabled: true,
dhcprangestart: '',
dhcprangeend: '',
dns1: '',
dns2: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingSubnet.value = null
}
async function saveSubnet() {
error.value = ''
saving.value = true
try {
const payload = {
cidr: form.value.cidr,
name: form.value.name,
description: form.value.description || null,
gatewayip: form.value.gatewayip || null,
subnetmask: form.value.subnetmask || null,
vlanid: form.value.vlanid || null,
locationid: form.value.locationid || null,
subnettype: form.value.subnettype || null,
dhcpenabled: form.value.dhcpenabled,
dhcprangestart: form.value.dhcprangestart || null,
dhcprangeend: form.value.dhcprangeend || null,
dns1: form.value.dns1 || null,
dns2: form.value.dns2 || null
}
if (editingSubnet.value) {
await networkApi.subnets.update(editingSubnet.value.subnetid, payload)
} else {
await networkApi.subnets.create(payload)
}
closeModal()
loadSubnets()
} catch (err) {
console.error('Error saving subnet:', err)
error.value = err.response?.data?.message || 'Failed to save subnet'
} finally {
saving.value = false
}
}
function confirmDelete(subnet) {
subnetToDelete.value = subnet
showDeleteModal.value = true
}
async function deleteSubnet() {
try {
await networkApi.subnets.delete(subnetToDelete.value.subnetid)
showDeleteModal.value = false
subnetToDelete.value = null
loadSubnets()
} catch (err) {
console.error('Error deleting subnet:', err)
alert(err.response?.data?.message || 'Failed to delete subnet')
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters input {
flex: 1;
min-width: 200px;
}
.filters select {
width: auto;
min-width: 150px;
}
.modal-lg {
max-width: 700px;
}
.form-section {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.form-section:last-of-type {
border-bottom: none;
margin-bottom: 0;
}
.form-section h4 {
margin: 0 0 1rem 0;
font-size: 0.95rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 1.125rem;
height: 1.125rem;
}
.badge-secondary {
background: var(--secondary);
color: var(--text);
}
</style>

View File

@@ -0,0 +1,369 @@
<template>
<div>
<div class="page-header">
<h2>VLANs</h2>
<button class="btn btn-primary" @click="openModal()">+ Add VLAN</button>
</div>
<!-- Search -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search VLANs..."
@input="debouncedSearch"
/>
<select v-model="typeFilter" class="form-control" @change="loadVLANs">
<option value="">All Types</option>
<option value="data">Data</option>
<option value="voice">Voice</option>
<option value="management">Management</option>
<option value="guest">Guest</option>
<option value="iot">IoT</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>VLAN #</th>
<th>Name</th>
<th>Type</th>
<th>Description</th>
<th>Subnets</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="vlan in vlans" :key="vlan.vlanid">
<td class="mono">{{ vlan.vlannumber }}</td>
<td>{{ vlan.name }}</td>
<td>
<span v-if="vlan.vlantype" class="badge" :class="getTypeClass(vlan.vlantype)">
{{ vlan.vlantype }}
</span>
<span v-else>-</span>
</td>
<td>{{ vlan.description || '-' }}</td>
<td>
<router-link
:to="{ path: '/settings/subnets', query: { vlanid: vlan.vlanid } }"
class="subnet-link"
>
View Subnets
</router-link>
</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(vlan)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(vlan)"
>
Delete
</button>
</td>
</tr>
<tr v-if="vlans.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">
No VLANs found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination" v-if="totalPages > 1">
<button
v-for="p in totalPages"
:key="p"
:class="{ active: p === page }"
@click="goToPage(p)"
>
{{ p }}
</button>
</div>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editingVLAN ? 'Edit VLAN' : 'Add VLAN' }}</h3>
</div>
<form @submit.prevent="saveVLAN">
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label for="vlannumber">VLAN Number *</label>
<input
id="vlannumber"
v-model.number="form.vlannumber"
type="number"
class="form-control"
min="1"
max="4094"
required
/>
</div>
<div class="form-group">
<label for="name">Name *</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
required
/>
</div>
</div>
<div class="form-group">
<label for="vlantype">VLAN Type</label>
<select id="vlantype" v-model="form.vlantype" class="form-control">
<option value="">Select Type</option>
<option value="data">Data</option>
<option value="voice">Voice</option>
<option value="management">Management</option>
<option value="guest">Guest</option>
<option value="iot">IoT</option>
</select>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
v-model="form.description"
class="form-control"
rows="3"
></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header">
<h3>Delete VLAN</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete VLAN <strong>{{ vlanToDelete?.vlannumber }} ({{ vlanToDelete?.name }})</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
VLANs with associated subnets cannot be deleted.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteVLAN">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { networkApi } from '../../api'
const vlans = ref([])
const loading = ref(true)
const search = ref('')
const typeFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const showModal = ref(false)
const editingVLAN = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const vlanToDelete = ref(null)
const form = ref({
vlannumber: null,
name: '',
vlantype: '',
description: ''
})
let searchTimeout = null
onMounted(() => {
loadVLANs()
})
async function loadVLANs() {
loading.value = true
try {
const params = {
page: page.value,
per_page: 20
}
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
} catch (err) {
console.error('Error loading VLANs:', err)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadVLANs()
}, 300)
}
function goToPage(p) {
page.value = p
loadVLANs()
}
function getTypeClass(type) {
switch (type) {
case 'data': return 'badge-info'
case 'voice': return 'badge-success'
case 'management': return 'badge-warning'
case 'guest': return 'badge-secondary'
case 'iot': return 'badge-primary'
default: return 'badge-info'
}
}
function openModal(vlan = null) {
editingVLAN.value = vlan
if (vlan) {
form.value = {
vlannumber: vlan.vlannumber,
name: vlan.name || '',
vlantype: vlan.vlantype || '',
description: vlan.description || ''
}
} else {
form.value = {
vlannumber: null,
name: '',
vlantype: '',
description: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingVLAN.value = null
}
async function saveVLAN() {
error.value = ''
saving.value = true
try {
if (editingVLAN.value) {
await networkApi.vlans.update(editingVLAN.value.vlanid, form.value)
} else {
await networkApi.vlans.create(form.value)
}
closeModal()
loadVLANs()
} catch (err) {
console.error('Error saving VLAN:', err)
error.value = err.response?.data?.message || 'Failed to save VLAN'
} finally {
saving.value = false
}
}
function confirmDelete(vlan) {
vlanToDelete.value = vlan
showDeleteModal.value = true
}
async function deleteVLAN() {
try {
await networkApi.vlans.delete(vlanToDelete.value.vlanid)
showDeleteModal.value = false
vlanToDelete.value = null
loadVLANs()
} catch (err) {
console.error('Error deleting VLAN:', err)
alert(err.response?.data?.message || 'Failed to delete VLAN')
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.form-row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
}
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.filters select {
width: auto;
min-width: 150px;
}
.subnet-link {
color: var(--link);
text-decoration: none;
}
.subnet-link:hover {
text-decoration: underline;
}
.badge-primary {
background: var(--primary);
color: white;
}
.badge-secondary {
background: var(--secondary);
color: var(--text);
}
</style>

View File

@@ -0,0 +1,284 @@
<template>
<div class="detail-page">
<div class="page-header">
<h2>USB Device Details</h2>
<div class="header-actions">
<router-link to="/usb" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="device">
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-content">
<div class="hero-title">
<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>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="device.serialnumber">
<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">
<span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ device.vendor_name }}</span>
</div>
<div class="hero-detail" v-if="device.model_name">
<span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ device.model_name }}</span>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<button
v-if="!device.is_checked_out"
class="btn btn-primary btn-lg"
@click="openCheckoutModal"
>
Checkout Device
</button>
<button
v-else
class="btn btn-warning btn-lg"
@click="openCheckinModal"
>
Check In Device
</button>
</div>
<!-- Current Checkout Info -->
<div class="section-card" v-if="device.current_checkout">
<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>
</div>
<div class="info-row">
<span class="info-label">Checkout Time</span>
<span class="info-value">{{ formatDate(device.current_checkout.checkout_time) }}</span>
</div>
<div class="info-row" v-if="device.current_checkout.checkout_reason">
<span class="info-label">Reason</span>
<span class="info-value">{{ device.current_checkout.checkout_reason }}</span>
</div>
</div>
</div>
<!-- Checkout History -->
<div class="card">
<div class="card-header">
<h3>Checkout History</h3>
</div>
<div v-if="!device.checkout_history?.length" class="empty-state">
No checkout history
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>User SSO</th>
<th>Checkout Time</th>
<th>Check-in Time</th>
<th>Wiped</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
<tr v-for="checkout in device.checkout_history" :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>
<span v-if="checkout.checkin_time">{{ checkout.was_wiped ? 'Yes' : 'No' }}</span>
<span v-else>-</span>
</td>
<td>{{ checkout.checkout_reason || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">USB device not found</p>
</div>
<!-- Checkout Modal -->
<Modal v-if="showCheckoutModal" @close="closeModals">
<template #header>
<h3>Checkout USB Device</h3>
</template>
<template #body>
<div class="form-group">
<label>Your SSO *</label>
<input v-model="checkoutForm.sso" type="text" class="form-control" placeholder="Enter your SSO" />
</div>
<div class="form-group">
<label>Reason</label>
<textarea v-model="checkoutForm.reason" class="form-control" rows="3" placeholder="Why do you need this device?"></textarea>
</div>
</template>
<template #footer>
<button class="btn btn-secondary" @click="closeModals">Cancel</button>
<button class="btn btn-primary" @click="doCheckout" :disabled="!checkoutForm.sso">Checkout</button>
</template>
</Modal>
<!-- Checkin Modal -->
<Modal v-if="showCheckinModal" @close="closeModals">
<template #header>
<h3>Check In USB Device</h3>
</template>
<template #body>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="checkinForm.was_wiped" />
Device was wiped
</label>
</div>
<div class="form-group">
<label>Notes</label>
<textarea v-model="checkinForm.notes" class="form-control" rows="3" placeholder="Any notes about this return?"></textarea>
</div>
</template>
<template #footer>
<button class="btn btn-secondary" @click="closeModals">Cancel</button>
<button class="btn btn-primary" @click="doCheckin">Check In</button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { usbApi } from '../../api'
import Modal from '../../components/Modal.vue'
const route = useRoute()
const loading = ref(true)
const device = ref(null)
const showCheckoutModal = ref(false)
const showCheckinModal = ref(false)
const checkoutForm = ref({ sso: '', reason: '' })
const checkinForm = ref({ was_wiped: false, notes: '' })
onMounted(async () => {
await loadDevice()
})
async function loadDevice() {
loading.value = true
try {
const response = await usbApi.get(route.params.id)
device.value = response.data.data
} catch (error) {
console.error('Error loading USB device:', error)
} finally {
loading.value = false
}
}
function openCheckoutModal() {
checkoutForm.value = { sso: '', reason: '' }
showCheckoutModal.value = true
}
function openCheckinModal() {
checkinForm.value = { was_wiped: false, notes: '' }
showCheckinModal.value = true
}
function closeModals() {
showCheckoutModal.value = false
showCheckinModal.value = false
}
async function doCheckout() {
try {
await usbApi.checkout(device.value.machineid, checkoutForm.value)
closeModals()
await loadDevice()
} catch (error) {
console.error('Checkout error:', error)
alert(error.response?.data?.message || 'Checkout failed')
}
}
async function doCheckin() {
try {
await usbApi.checkin(device.value.machineid, checkinForm.value)
closeModals()
await loadDevice()
} catch (error) {
console.error('Checkin error:', error)
alert(error.response?.data?.message || 'Check in failed')
}
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
</script>
<style scoped>
.action-buttons {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1.125rem;
}
.empty-state {
text-align: center;
color: var(--text-light);
padding: 2rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
</style>

View File

@@ -0,0 +1,230 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit USB Device' : 'Add USB Device' }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveDevice">
<div class="form-group">
<label for="serialnumber">Serial Number *</label>
<input
id="serialnumber"
v-model="form.serialnumber"
type="text"
class="form-control"
required
maxlength="100"
/>
</div>
<div class="form-group">
<label for="displayname">Display Name *</label>
<input
id="displayname"
v-model="form.displayname"
type="text"
class="form-control"
required
maxlength="100"
placeholder="e.g., USB Flash Drive #1"
/>
</div>
<div class="form-group">
<label for="label">Label</label>
<input
id="label"
v-model="form.label"
type="text"
class="form-control"
maxlength="100"
placeholder="Physical label on device"
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="usbtypeid">Device Type</label>
<select
id="usbtypeid"
v-model="form.usbtypeid"
class="form-control"
>
<option value="">-- Select Type --</option>
<option
v-for="type in types"
:key="type.usbtypeid"
:value="type.usbtypeid"
>
{{ type.typename }}
</option>
</select>
</div>
<div class="form-group">
<label for="capacitygb">Capacity (GB)</label>
<input
id="capacitygb"
v-model.number="form.capacitygb"
type="number"
class="form-control"
min="0"
step="1"
/>
</div>
</div>
<div class="form-group">
<label for="vendorid">Vendor</label>
<select
id="vendorid"
v-model="form.vendorid"
class="form-control"
>
<option value="">-- Select Vendor --</option>
<option
v-for="vendor in vendors"
:key="vendor.vendorid"
:value="vendor.vendorid"
>
{{ vendor.vendorname }}
</option>
</select>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
v-model="form.notes"
class="form-control"
rows="3"
></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Update Device' : 'Add Device') }}
</button>
<router-link to="/usb" class="btn btn-secondary">Cancel</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usbApi, vendorsApi } from '@/api'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const types = ref([])
const vendors = ref([])
const form = ref({
serialnumber: '',
displayname: '',
label: '',
usbtypeid: '',
capacitygb: null,
vendorid: '',
notes: ''
})
onMounted(async () => {
try {
// Load types and vendors
const [typesRes, vendorsRes] = await Promise.all([
usbApi.types.list(),
vendorsApi.list({ per_page: 1000 })
])
types.value = typesRes.data.data || []
vendors.value = vendorsRes.data.data || []
// Load device if editing
if (isEdit.value) {
const response = await usbApi.get(route.params.id)
const device = response.data.data
form.value = {
serialnumber: device.serialnumber || '',
displayname: device.displayname || '',
label: device.label || '',
usbtypeid: device.usbtypeid || '',
capacitygb: device.capacitygb || null,
vendorid: device.vendorid || '',
notes: device.notes || ''
}
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
} finally {
loading.value = false
}
})
async function saveDevice() {
error.value = ''
saving.value = true
try {
const data = {
serialnumber: form.value.serialnumber,
displayname: form.value.displayname,
label: form.value.label || null,
usbtypeid: form.value.usbtypeid || null,
capacitygb: form.value.capacitygb || null,
vendorid: form.value.vendorid || null,
notes: form.value.notes || null
}
if (isEdit.value) {
await usbApi.update(route.params.id, data)
} else {
await usbApi.create(data)
}
router.push('/usb')
} catch (err) {
console.error('Error saving device:', err)
error.value = err.response?.data?.message || 'Failed to save device'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,290 @@
<template>
<div>
<div class="page-header">
<h2>USB Devices</h2>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search USB devices..."
@input="debouncedSearch"
/>
<label class="checkbox-label">
<input type="checkbox" v-model="showAvailableOnly" @change="loadDevices" />
Available Only
</label>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Device</th>
<th>Serial Number</th>
<th>Status</th>
<th>Checked Out By</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<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>
</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>
</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>
<span v-else>-</span>
</td>
<td class="actions">
<button
v-if="!device.is_checked_out"
class="btn btn-primary btn-sm"
@click="openCheckoutModal(device)"
>
Checkout
</button>
<button
v-else
class="btn btn-secondary btn-sm"
@click="openCheckinModal(device)"
>
Check In
</button>
<router-link
:to="`/usb/${device.machineid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="devices.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No USB devices found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination" v-if="totalPages > 1">
<button
v-for="p in totalPages"
:key="p"
:class="{ active: p === page }"
@click="goToPage(p)"
>
{{ p }}
</button>
</div>
</template>
</div>
<!-- Checkout Modal -->
<Modal v-model="showCheckoutModal" @close="closeModals">
<template #header>
<h3>Checkout USB Device</h3>
</template>
<p>Checking out: <strong>{{ selectedDevice?.alias || selectedDevice?.machinenumber }}</strong></p>
<div class="form-group">
<label>Employee *</label>
<EmployeeSearch v-model="selectedEmployee" placeholder="Search by name..." />
</div>
<div class="form-group">
<label>Reason</label>
<textarea v-model="checkoutForm.reason" class="form-control" rows="3" placeholder="Why do you need this device?"></textarea>
</div>
<template #footer>
<button class="btn btn-secondary" @click="closeModals">Cancel</button>
<button class="btn btn-primary" @click="doCheckout" :disabled="!selectedEmployee">Checkout</button>
</template>
</Modal>
<!-- Checkin Modal -->
<Modal v-model="showCheckinModal" @close="closeModals">
<template #header>
<h3>Check In USB Device</h3>
</template>
<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" />
Device was wiped
</label>
</div>
<div class="form-group">
<label>Notes</label>
<textarea v-model="checkinForm.notes" class="form-control" rows="3" placeholder="Any notes about this return?"></textarea>
</div>
<template #footer>
<button class="btn btn-secondary" @click="closeModals">Cancel</button>
<button class="btn btn-primary" @click="doCheckin">Check In</button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { usbApi } from '../../api'
import Modal from '../../components/Modal.vue'
import EmployeeSearch from '../../components/EmployeeSearch.vue'
const devices = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const totalPages = ref(1)
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 selectedEmployee = ref(null)
let searchTimeout = null
onMounted(() => {
loadDevices()
})
async function loadDevices() {
loading.value = true
try {
const params = {
page: page.value,
perpage: 20
}
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
} catch (error) {
console.error('Error loading USB devices:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadDevices()
}, 300)
}
function goToPage(p) {
page.value = p
loadDevices()
}
function openCheckoutModal(device) {
selectedDevice.value = device
checkoutForm.value = { reason: '' }
selectedEmployee.value = null
showCheckoutModal.value = true
}
function openCheckinModal(device) {
selectedDevice.value = device
checkinForm.value = { was_wiped: false, notes: '' }
showCheckinModal.value = true
}
function closeModals() {
showCheckoutModal.value = false
showCheckinModal.value = false
selectedDevice.value = null
selectedEmployee.value = null
}
async function doCheckout() {
if (!selectedEmployee.value) return
try {
await usbApi.checkout(selectedDevice.value.machineid, {
sso: selectedEmployee.value.sso,
name: selectedEmployee.value.name,
reason: checkoutForm.value.reason
})
closeModals()
loadDevices()
} catch (error) {
console.error('Checkout error:', error)
alert(error.response?.data?.message || 'Checkout failed')
}
}
async function doCheckin() {
try {
await usbApi.checkin(selectedDevice.value.machineid, checkinForm.value)
closeModals()
loadDevices()
} catch (error) {
console.error('Checkin error:', error)
alert(error.response?.data?.message || 'Check in failed')
}
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
</script>
<style scoped>
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
}
.text-muted {
font-size: 0.875rem;
color: var(--text-light);
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
</style>

View File

@@ -1,13 +1,23 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: { server: {
port: 3000, port: 5173,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:5050', target: 'http://localhost:5000',
changeOrigin: true
},
'/static': {
target: 'http://localhost:5000',
changeOrigin: true changeOrigin: true
} }
} }

View File

@@ -0,0 +1,5 @@
"""Computers plugin for ShopDB."""
from .plugin import ComputersPlugin
__all__ = ['ComputersPlugin']

View File

@@ -0,0 +1,5 @@
"""Computers plugin API."""
from .routes import computers_bp
__all__ = ['computers_bp']

View File

@@ -0,0 +1,628 @@
"""Computers plugin API endpoints."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, OperatingSystem, Application, AppVersion
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import Computer, ComputerType, ComputerInstalledApp
computers_bp = Blueprint('computers', __name__)
# =============================================================================
# Computer Types
# =============================================================================
@computers_bp.route('/types', methods=['GET'])
@jwt_required()
def list_computer_types():
"""List all computer types."""
page, per_page = get_pagination_params(request)
query = ComputerType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(ComputerType.isactive == True)
if search := request.args.get('search'):
query = query.filter(ComputerType.computertype.ilike(f'%{search}%'))
query = query.order_by(ComputerType.computertype)
items, total = paginate_query(query, page, per_page)
data = [t.to_dict() for t in items]
return paginated_response(data, page, per_page, total)
@computers_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required()
def get_computer_type(type_id: int):
"""Get a single computer type."""
t = ComputerType.query.get(type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer type with ID {type_id} not found',
http_code=404
)
return success_response(t.to_dict())
@computers_bp.route('/types', methods=['POST'])
@jwt_required()
def create_computer_type():
"""Create a new computer type."""
data = request.get_json()
if not data or not data.get('computertype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'computertype is required')
if ComputerType.query.filter_by(computertype=data['computertype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Computer type '{data['computertype']}' already exists",
http_code=409
)
t = ComputerType(
computertype=data['computertype'],
description=data.get('description'),
icon=data.get('icon')
)
db.session.add(t)
db.session.commit()
return success_response(t.to_dict(), message='Computer type created', http_code=201)
@computers_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
def update_computer_type(type_id: int):
"""Update a computer type."""
t = ComputerType.query.get(type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer type with ID {type_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if 'computertype' in data and data['computertype'] != t.computertype:
if ComputerType.query.filter_by(computertype=data['computertype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Computer type '{data['computertype']}' already exists",
http_code=409
)
for key in ['computertype', 'description', 'icon', 'isactive']:
if key in data:
setattr(t, key, data[key])
db.session.commit()
return success_response(t.to_dict(), message='Computer type updated')
# =============================================================================
# Computers CRUD
# =============================================================================
@computers_bp.route('', methods=['GET'])
@jwt_required()
def list_computers():
"""
List all computers with filtering and pagination.
Query parameters:
- page, per_page: Pagination
- active: Filter by active status
- search: Search by asset number, name, or hostname
- type_id: Filter by computer type ID
- os_id: Filter by operating system ID
- location_id: Filter by location ID
- businessunit_id: Filter by business unit ID
- shopfloor: Filter by shopfloor flag (true/false)
"""
page, per_page = get_pagination_params(request)
# Join Computer with Asset
query = db.session.query(Computer).join(Asset)
# Active filter
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Asset.isactive == True)
# Search filter
if search := request.args.get('search'):
query = query.filter(
db.or_(
Asset.assetnumber.ilike(f'%{search}%'),
Asset.name.ilike(f'%{search}%'),
Asset.serialnumber.ilike(f'%{search}%'),
Computer.hostname.ilike(f'%{search}%')
)
)
# Computer type filter
if type_id := request.args.get('type_id'):
query = query.filter(Computer.computertypeid == int(type_id))
# OS filter
if os_id := request.args.get('os_id'):
query = query.filter(Computer.osid == int(os_id))
# Location filter
if location_id := request.args.get('location_id'):
query = query.filter(Asset.locationid == int(location_id))
# Business unit filter
if bu_id := request.args.get('businessunit_id'):
query = query.filter(Asset.businessunitid == int(bu_id))
# Shopfloor filter
if shopfloor := request.args.get('shopfloor'):
query = query.filter(Computer.isshopfloor == (shopfloor.lower() == 'true'))
# Sorting
sort_by = request.args.get('sort', 'hostname')
sort_dir = request.args.get('dir', 'asc')
if sort_by == 'hostname':
col = Computer.hostname
elif sort_by == 'assetnumber':
col = Asset.assetnumber
elif sort_by == 'name':
col = Asset.name
elif sort_by == 'lastreporteddate':
col = Computer.lastreporteddate
else:
col = Computer.hostname
query = query.order_by(col.desc() if sort_dir == 'desc' else col)
items, total = paginate_query(query, page, per_page)
# Build response with both asset and computer data
data = []
for comp in items:
item = comp.asset.to_dict() if comp.asset else {}
item['computer'] = comp.to_dict()
data.append(item)
return paginated_response(data, page, per_page, total)
@computers_bp.route('/<int:computer_id>', methods=['GET'])
@jwt_required()
def get_computer(computer_id: int):
"""Get a single computer with full details."""
comp = Computer.query.get(computer_id)
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer with ID {computer_id} not found',
http_code=404
)
result = comp.asset.to_dict() if comp.asset else {}
result['computer'] = comp.to_dict()
return success_response(result)
@computers_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required()
def get_computer_by_asset(asset_id: int):
"""Get computer data by asset ID."""
comp = Computer.query.filter_by(assetid=asset_id).first()
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer for asset {asset_id} not found',
http_code=404
)
result = comp.asset.to_dict() if comp.asset else {}
result['computer'] = comp.to_dict()
return success_response(result)
@computers_bp.route('/by-hostname/<hostname>', methods=['GET'])
@jwt_required()
def get_computer_by_hostname(hostname: str):
"""Get computer by hostname."""
comp = Computer.query.filter_by(hostname=hostname).first()
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer with hostname {hostname} not found',
http_code=404
)
result = comp.asset.to_dict() if comp.asset else {}
result['computer'] = comp.to_dict()
return success_response(result)
@computers_bp.route('', methods=['POST'])
@jwt_required()
def create_computer():
"""
Create new computer (creates both Asset and Computer records).
Required fields:
- assetnumber: Business identifier
Optional fields:
- name, serialnumber, statusid, locationid, businessunitid
- computertypeid, hostname, osid
- isvnc, iswinrm, isshopfloor
- mapleft, maptop, notes
"""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if not data.get('assetnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
# Check for duplicate assetnumber
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Check for duplicate hostname
if data.get('hostname'):
if Computer.query.filter_by(hostname=data['hostname']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Computer with hostname '{data['hostname']}' already exists",
http_code=409
)
# Get computer asset type
computer_type = AssetType.query.filter_by(assettype='computer').first()
if not computer_type:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Computer asset type not found. Plugin may not be properly installed.',
http_code=500
)
# Create the core asset
asset = Asset(
assetnumber=data['assetnumber'],
name=data.get('name'),
serialnumber=data.get('serialnumber'),
assettypeid=computer_type.assettypeid,
statusid=data.get('statusid', 1),
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapleft=data.get('mapleft'),
maptop=data.get('maptop'),
notes=data.get('notes')
)
db.session.add(asset)
db.session.flush() # Get the assetid
# Create the computer extension
comp = Computer(
assetid=asset.assetid,
computertypeid=data.get('computertypeid'),
hostname=data.get('hostname'),
osid=data.get('osid'),
loggedinuser=data.get('loggedinuser'),
lastreporteddate=data.get('lastreporteddate'),
lastboottime=data.get('lastboottime'),
isvnc=data.get('isvnc', False),
iswinrm=data.get('iswinrm', False),
isshopfloor=data.get('isshopfloor', False)
)
db.session.add(comp)
db.session.commit()
result = asset.to_dict()
result['computer'] = comp.to_dict()
return success_response(result, message='Computer created', http_code=201)
@computers_bp.route('/<int:computer_id>', methods=['PUT'])
@jwt_required()
def update_computer(computer_id: int):
"""Update computer (both Asset and Computer records)."""
comp = Computer.query.get(computer_id)
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer with ID {computer_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
asset = comp.asset
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Check for conflicting hostname
if 'hostname' in data and data['hostname'] != comp.hostname:
existing = Computer.query.filter_by(hostname=data['hostname']).first()
if existing and existing.computerid != computer_id:
return error_response(
ErrorCodes.CONFLICT,
f"Computer with hostname '{data['hostname']}' already exists",
http_code=409
)
# Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
for key in asset_fields:
if key in data:
setattr(asset, key, data[key])
# Update computer fields
computer_fields = ['computertypeid', 'hostname', 'osid', 'loggedinuser',
'lastreporteddate', 'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor']
for key in computer_fields:
if key in data:
setattr(comp, key, data[key])
db.session.commit()
result = asset.to_dict()
result['computer'] = comp.to_dict()
return success_response(result, message='Computer updated')
@computers_bp.route('/<int:computer_id>', methods=['DELETE'])
@jwt_required()
def delete_computer(computer_id: int):
"""Delete (soft delete) computer."""
comp = Computer.query.get(computer_id)
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer with ID {computer_id} not found',
http_code=404
)
# Soft delete the asset
comp.asset.isactive = False
db.session.commit()
return success_response(message='Computer deleted')
# =============================================================================
# Installed Applications
# =============================================================================
@computers_bp.route('/<int:computer_id>/apps', methods=['GET'])
@jwt_required()
def get_installed_apps(computer_id: int):
"""Get all installed applications for a computer."""
comp = Computer.query.get(computer_id)
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer with ID {computer_id} not found',
http_code=404
)
apps = ComputerInstalledApp.query.filter_by(
computerid=computer_id,
isactive=True
).all()
data = [app.to_dict() for app in apps]
return success_response(data)
@computers_bp.route('/<int:computer_id>/apps', methods=['POST'])
@jwt_required()
def add_installed_app(computer_id: int):
"""Add an installed application to a computer."""
comp = Computer.query.get(computer_id)
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer with ID {computer_id} not found',
http_code=404
)
data = request.get_json()
if not data or not data.get('appid'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'appid is required')
appid = data['appid']
# Validate app exists
if not Application.query.get(appid):
return error_response(ErrorCodes.NOT_FOUND, f'Application {appid} not found', http_code=404)
# Check for duplicate
existing = ComputerInstalledApp.query.filter_by(
computerid=computer_id,
appid=appid
).first()
if existing:
if existing.isactive:
return error_response(
ErrorCodes.CONFLICT,
'This application is already installed on this computer',
http_code=409
)
else:
# Reactivate
existing.isactive = True
existing.appversionid = data.get('appversionid')
db.session.commit()
return success_response(existing.to_dict(), message='Application reinstalled')
# Create new installation record
installed = ComputerInstalledApp(
computerid=computer_id,
appid=appid,
appversionid=data.get('appversionid')
)
db.session.add(installed)
db.session.commit()
return success_response(installed.to_dict(), message='Application installed', http_code=201)
@computers_bp.route('/<int:computer_id>/apps/<int:app_id>', methods=['DELETE'])
@jwt_required()
def remove_installed_app(computer_id: int, app_id: int):
"""Remove an installed application from a computer."""
installed = ComputerInstalledApp.query.filter_by(
computerid=computer_id,
appid=app_id,
isactive=True
).first()
if not installed:
return error_response(
ErrorCodes.NOT_FOUND,
'Installation record not found',
http_code=404
)
installed.isactive = False
db.session.commit()
return success_response(message='Application uninstalled')
# =============================================================================
# Status Reporting
# =============================================================================
@computers_bp.route('/<int:computer_id>/report', methods=['POST'])
@jwt_required(optional=True)
def report_status(computer_id: int):
"""
Report computer status (for agent-based reporting).
This endpoint can be called periodically by a client agent
to update status information.
"""
comp = Computer.query.get(computer_id)
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer with ID {computer_id} not found',
http_code=404
)
data = request.get_json() or {}
# Update status fields
from datetime import datetime
comp.lastreporteddate = datetime.utcnow()
if 'loggedinuser' in data:
comp.loggedinuser = data['loggedinuser']
if 'lastboottime' in data:
comp.lastboottime = data['lastboottime']
db.session.commit()
return success_response(message='Status reported')
# =============================================================================
# Dashboard
# =============================================================================
@computers_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required()
def dashboard_summary():
"""Get computer dashboard summary data."""
# Total active computers
total = db.session.query(Computer).join(Asset).filter(
Asset.isactive == True
).count()
# Count by computer type
by_type = db.session.query(
ComputerType.computertype,
db.func.count(Computer.computerid)
).join(Computer, Computer.computertypeid == ComputerType.computertypeid
).join(Asset, Asset.assetid == Computer.assetid
).filter(Asset.isactive == True
).group_by(ComputerType.computertype
).all()
# Count by OS
by_os = db.session.query(
OperatingSystem.osname,
db.func.count(Computer.computerid)
).join(Computer, Computer.osid == OperatingSystem.osid
).join(Asset, Asset.assetid == Computer.assetid
).filter(Asset.isactive == True
).group_by(OperatingSystem.osname
).all()
# Count shopfloor vs non-shopfloor
shopfloor_count = db.session.query(Computer).join(Asset).filter(
Asset.isactive == True,
Computer.isshopfloor == True
).count()
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],
'shopfloor': shopfloor_count,
'non_shopfloor': total - shopfloor_count
})

View File

@@ -0,0 +1,23 @@
{
"name": "computers",
"version": "1.0.0",
"description": "Computer management plugin for PCs, servers, and workstations with software tracking",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=1.0.0",
"api_prefix": "/api/computers",
"provides": {
"asset_type": "computer",
"features": [
"computer_tracking",
"software_inventory",
"remote_access",
"os_management"
]
},
"settings": {
"enable_winrm": true,
"enable_vnc": true,
"auto_report_interval_hours": 24
}
}

View File

@@ -0,0 +1,9 @@
"""Computers plugin models."""
from .computer import Computer, ComputerType, ComputerInstalledApp
__all__ = [
'Computer',
'ComputerType',
'ComputerInstalledApp',
]

View File

@@ -0,0 +1,184 @@
"""Computer plugin models."""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
class ComputerType(BaseModel):
"""
Computer type classification.
Examples: Shopfloor PC, Engineer Workstation, CMM PC, Server, etc.
"""
__tablename__ = 'computertypes'
computertypeid = db.Column(db.Integer, primary_key=True)
computertype = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
return f"<ComputerType {self.computertype}>"
class Computer(BaseModel):
"""
Computer-specific extension data.
Links to core Asset table via assetid.
Stores computer-specific fields like hostname, OS, logged in user, etc.
"""
__tablename__ = 'computers'
computerid = db.Column(db.Integer, primary_key=True)
# Link to core asset
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
unique=True,
nullable=False,
index=True
)
# Computer classification
computertypeid = db.Column(
db.Integer,
db.ForeignKey('computertypes.computertypeid'),
nullable=True
)
# Network identity
hostname = db.Column(
db.String(100),
index=True,
comment='Network hostname'
)
# Operating system
osid = db.Column(
db.Integer,
db.ForeignKey('operatingsystems.osid'),
nullable=True
)
# Status tracking
loggedinuser = db.Column(db.String(100), nullable=True)
lastreporteddate = db.Column(db.DateTime, nullable=True)
lastboottime = db.Column(db.DateTime, nullable=True)
# Remote access features
isvnc = db.Column(
db.Boolean,
default=False,
comment='VNC remote access enabled'
)
iswinrm = db.Column(
db.Boolean,
default=False,
comment='WinRM enabled'
)
# Classification flags
isshopfloor = db.Column(
db.Boolean,
default=False,
comment='Shopfloor PC (vs office PC)'
)
# Relationships
asset = db.relationship(
'Asset',
backref=db.backref('computer', uselist=False, lazy='joined')
)
computertype = db.relationship('ComputerType', backref='computers')
operatingsystem = db.relationship('OperatingSystem', backref='computers')
# Installed applications (one-to-many)
installedapps = db.relationship(
'ComputerInstalledApp',
back_populates='computer',
cascade='all, delete-orphan',
lazy='dynamic'
)
__table_args__ = (
db.Index('idx_computer_type', 'computertypeid'),
db.Index('idx_computer_hostname', 'hostname'),
db.Index('idx_computer_os', 'osid'),
)
def __repr__(self):
return f"<Computer {self.hostname or self.assetid}>"
def to_dict(self):
"""Convert to dictionary with related names."""
result = super().to_dict()
# Add related object names
if self.computertype:
result['computertype_name'] = self.computertype.computertype
if self.operatingsystem:
result['os_name'] = self.operatingsystem.osname
return result
class ComputerInstalledApp(db.Model):
"""
Junction table for applications installed on computers.
Tracks which applications are installed on which computers,
including version information.
"""
__tablename__ = 'computerinstalledapps'
id = db.Column(db.Integer, primary_key=True)
computerid = db.Column(
db.Integer,
db.ForeignKey('computers.computerid', ondelete='CASCADE'),
nullable=False
)
appid = db.Column(
db.Integer,
db.ForeignKey('applications.appid'),
nullable=False
)
appversionid = db.Column(
db.Integer,
db.ForeignKey('appversions.appversionid'),
nullable=True
)
isactive = db.Column(db.Boolean, default=True, nullable=False)
installeddate = db.Column(db.DateTime, default=db.func.now())
# Relationships
computer = db.relationship('Computer', back_populates='installedapps')
application = db.relationship('Application')
appversion = db.relationship('AppVersion')
__table_args__ = (
db.UniqueConstraint('computerid', 'appid', name='uq_computer_app'),
db.Index('idx_compapp_computer', 'computerid'),
db.Index('idx_compapp_app', 'appid'),
)
def to_dict(self):
"""Convert to dictionary."""
return {
'id': self.id,
'computerid': self.computerid,
'appid': self.appid,
'appversionid': self.appversionid,
'isactive': self.isactive,
'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None,
'application': {
'appid': self.application.appid,
'appname': self.application.appname,
'appdescription': self.application.appdescription,
} if self.application else None,
'version': self.appversion.version if self.appversion else None
}
def __repr__(self):
return f"<ComputerInstalledApp computer={self.computerid} app={self.appid}>"

209
plugins/computers/plugin.py Normal file
View File

@@ -0,0 +1,209 @@
"""Computers plugin main class."""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db
from shopdb.core.models import AssetType, AssetStatus
from .models import Computer, ComputerType, ComputerInstalledApp
from .api import computers_bp
logger = logging.getLogger(__name__)
class ComputersPlugin(BasePlugin):
"""
Computers plugin - manages PC, server, and workstation assets.
Computers include shopfloor PCs, engineer workstations, servers, etc.
Uses the new Asset architecture with Computer extension table.
"""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file."""
manifestpath = Path(__file__).parent / 'manifest.json'
if manifestpath.exists():
with open(manifestpath, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'computers'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get(
'description',
'Computer management for PCs, servers, and workstations'
),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/computers'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return computers_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [Computer, ComputerType, ComputerInstalledApp]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"Computers plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed."""
with app.app_context():
self._ensure_asset_type()
self._ensure_computer_types()
logger.info("Computers plugin installed")
def _ensure_asset_type(self) -> None:
"""Ensure computer asset type exists."""
existing = AssetType.query.filter_by(assettype='computer').first()
if not existing:
at = AssetType(
assettype='computer',
plugin_name='computers',
table_name='computers',
description='PCs, servers, and workstations',
icon='desktop'
)
db.session.add(at)
logger.debug("Created asset type: computer")
db.session.commit()
def _ensure_computer_types(self) -> None:
"""Ensure basic computer types exist."""
computer_types = [
('Shopfloor PC', 'PC located on the shop floor for machine operation', 'desktop'),
('Engineer Workstation', 'Engineering workstation for CAD/CAM work', 'laptop'),
('CMM PC', 'PC dedicated to CMM operation', 'desktop'),
('Server', 'Server system', 'server'),
('Kiosk', 'Kiosk or info display PC', 'tv'),
('Laptop', 'Laptop computer', 'laptop'),
('Virtual Machine', 'Virtual machine', 'cloud'),
('Other', 'Other computer type', 'desktop'),
]
for name, description, icon in computer_types:
existing = ComputerType.query.filter_by(computertype=name).first()
if not existing:
ct = ComputerType(
computertype=name,
description=description,
icon=icon
)
db.session.add(ct)
logger.debug(f"Created computer type: {name}")
db.session.commit()
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled."""
logger.info("Computers plugin uninstalled")
def get_cli_commands(self) -> List:
"""Return CLI commands for this plugin."""
@click.group('computers')
def computerscli():
"""Computers plugin commands."""
pass
@computerscli.command('list-types')
def list_types():
"""List all computer types."""
from flask import current_app
with current_app.app_context():
types = ComputerType.query.filter_by(isactive=True).all()
if not types:
click.echo('No computer types found.')
return
click.echo('Computer Types:')
for t in types:
click.echo(f" [{t.computertypeid}] {t.computertype}")
@computerscli.command('stats')
def stats():
"""Show computer statistics."""
from flask import current_app
from shopdb.core.models import Asset
with current_app.app_context():
total = db.session.query(Computer).join(Asset).filter(
Asset.isactive == True
).count()
click.echo(f"Total active computers: {total}")
# Shopfloor count
shopfloor = db.session.query(Computer).join(Asset).filter(
Asset.isactive == True,
Computer.isshopfloor == True
).count()
click.echo(f" Shopfloor PCs: {shopfloor}")
click.echo(f" Other: {total - shopfloor}")
@computerscli.command('find')
@click.argument('hostname')
def find_by_hostname(hostname):
"""Find a computer by hostname."""
from flask import current_app
with current_app.app_context():
comp = Computer.query.filter(
Computer.hostname.ilike(f'%{hostname}%')
).first()
if not comp:
click.echo(f'No computer found matching hostname: {hostname}')
return
click.echo(f'Found: {comp.hostname}')
click.echo(f' Asset: {comp.asset.assetnumber}')
click.echo(f' Type: {comp.computertype.computertype if comp.computertype else "N/A"}')
click.echo(f' OS: {comp.operatingsystem.osname if comp.operatingsystem else "N/A"}')
click.echo(f' Logged in: {comp.loggedinuser or "N/A"}')
return [computerscli]
def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions."""
return [
{
'name': 'Computer Status',
'component': 'ComputerStatusWidget',
'endpoint': '/api/computers/dashboard/summary',
'size': 'medium',
'position': 6,
},
]
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""
return [
{
'name': 'Computers',
'icon': 'desktop',
'route': '/computers',
'position': 15,
},
]

View File

@@ -0,0 +1,5 @@
"""Equipment plugin for ShopDB."""
from .plugin import EquipmentPlugin
__all__ = ['EquipmentPlugin']

View File

@@ -0,0 +1,5 @@
"""Equipment plugin API."""
from .routes import equipment_bp
__all__ = ['equipment_bp']

View File

@@ -0,0 +1,429 @@
"""Equipment plugin API endpoints."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, Vendor, Model
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import Equipment, EquipmentType
equipment_bp = Blueprint('equipment', __name__)
# =============================================================================
# Equipment Types
# =============================================================================
@equipment_bp.route('/types', methods=['GET'])
@jwt_required()
def list_equipment_types():
"""List all equipment types."""
page, per_page = get_pagination_params(request)
query = EquipmentType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(EquipmentType.isactive == True)
if search := request.args.get('search'):
query = query.filter(EquipmentType.equipmenttype.ilike(f'%{search}%'))
query = query.order_by(EquipmentType.equipmenttype)
items, total = paginate_query(query, page, per_page)
data = [t.to_dict() for t in items]
return paginated_response(data, page, per_page, total)
@equipment_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required()
def get_equipment_type(type_id: int):
"""Get a single equipment type."""
t = EquipmentType.query.get(type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment type with ID {type_id} not found',
http_code=404
)
return success_response(t.to_dict())
@equipment_bp.route('/types', methods=['POST'])
@jwt_required()
def create_equipment_type():
"""Create a new equipment type."""
data = request.get_json()
if not data or not data.get('equipmenttype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'equipmenttype is required')
if EquipmentType.query.filter_by(equipmenttype=data['equipmenttype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Equipment type '{data['equipmenttype']}' already exists",
http_code=409
)
t = EquipmentType(
equipmenttype=data['equipmenttype'],
description=data.get('description'),
icon=data.get('icon')
)
db.session.add(t)
db.session.commit()
return success_response(t.to_dict(), message='Equipment type created', http_code=201)
@equipment_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
def update_equipment_type(type_id: int):
"""Update an equipment type."""
t = EquipmentType.query.get(type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment type with ID {type_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if 'equipmenttype' in data and data['equipmenttype'] != t.equipmenttype:
if EquipmentType.query.filter_by(equipmenttype=data['equipmenttype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Equipment type '{data['equipmenttype']}' already exists",
http_code=409
)
for key in ['equipmenttype', 'description', 'icon', 'isactive']:
if key in data:
setattr(t, key, data[key])
db.session.commit()
return success_response(t.to_dict(), message='Equipment type updated')
# =============================================================================
# Equipment CRUD
# =============================================================================
@equipment_bp.route('', methods=['GET'])
@jwt_required()
def list_equipment():
"""
List all equipment with filtering and pagination.
Query parameters:
- page, per_page: Pagination
- active: Filter by active status
- search: Search by asset number or name
- type_id: Filter by equipment type ID
- vendor_id: Filter by vendor ID
- location_id: Filter by location ID
- businessunit_id: Filter by business unit ID
"""
page, per_page = get_pagination_params(request)
# Join Equipment with Asset
query = db.session.query(Equipment).join(Asset)
# Active filter
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Asset.isactive == True)
# Search filter
if search := request.args.get('search'):
query = query.filter(
db.or_(
Asset.assetnumber.ilike(f'%{search}%'),
Asset.name.ilike(f'%{search}%'),
Asset.serialnumber.ilike(f'%{search}%')
)
)
# Equipment type filter
if type_id := request.args.get('type_id'):
query = query.filter(Equipment.equipmenttypeid == int(type_id))
# Vendor filter
if vendor_id := request.args.get('vendor_id'):
query = query.filter(Equipment.vendorid == int(vendor_id))
# Location filter
if location_id := request.args.get('location_id'):
query = query.filter(Asset.locationid == int(location_id))
# Business unit filter
if bu_id := request.args.get('businessunit_id'):
query = query.filter(Asset.businessunitid == int(bu_id))
# Sorting
sort_by = request.args.get('sort', 'assetnumber')
sort_dir = request.args.get('dir', 'asc')
if sort_by == 'assetnumber':
col = Asset.assetnumber
elif sort_by == 'name':
col = Asset.name
else:
col = Asset.assetnumber
query = query.order_by(col.desc() if sort_dir == 'desc' else col)
items, total = paginate_query(query, page, per_page)
# Build response with both asset and equipment data
data = []
for equip in items:
item = equip.asset.to_dict() if equip.asset else {}
item['equipment'] = equip.to_dict()
data.append(item)
return paginated_response(data, page, per_page, total)
@equipment_bp.route('/<int:equipment_id>', methods=['GET'])
@jwt_required()
def get_equipment(equipment_id: int):
"""Get a single equipment item with full details."""
equip = Equipment.query.get(equipment_id)
if not equip:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment with ID {equipment_id} not found',
http_code=404
)
result = equip.asset.to_dict() if equip.asset else {}
result['equipment'] = equip.to_dict()
return success_response(result)
@equipment_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required()
def get_equipment_by_asset(asset_id: int):
"""Get equipment data by asset ID."""
equip = Equipment.query.filter_by(assetid=asset_id).first()
if not equip:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment for asset {asset_id} not found',
http_code=404
)
result = equip.asset.to_dict() if equip.asset else {}
result['equipment'] = equip.to_dict()
return success_response(result)
@equipment_bp.route('', methods=['POST'])
@jwt_required()
def create_equipment():
"""
Create new equipment (creates both Asset and Equipment records).
Required fields:
- assetnumber: Business identifier
Optional fields:
- name, serialnumber, statusid, locationid, businessunitid
- equipmenttypeid, vendorid, modelnumberid
- requiresmanualconfig, islocationonly
- mapleft, maptop, notes
"""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if not data.get('assetnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
# Check for duplicate assetnumber
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Get equipment asset type
equipment_type = AssetType.query.filter_by(assettype='equipment').first()
if not equipment_type:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Equipment asset type not found. Plugin may not be properly installed.',
http_code=500
)
# Create the core asset
asset = Asset(
assetnumber=data['assetnumber'],
name=data.get('name'),
serialnumber=data.get('serialnumber'),
assettypeid=equipment_type.assettypeid,
statusid=data.get('statusid', 1),
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapleft=data.get('mapleft'),
maptop=data.get('maptop'),
notes=data.get('notes')
)
db.session.add(asset)
db.session.flush() # Get the assetid
# Create the equipment extension
equip = Equipment(
assetid=asset.assetid,
equipmenttypeid=data.get('equipmenttypeid'),
vendorid=data.get('vendorid'),
modelnumberid=data.get('modelnumberid'),
requiresmanualconfig=data.get('requiresmanualconfig', False),
islocationonly=data.get('islocationonly', False),
lastmaintenancedate=data.get('lastmaintenancedate'),
nextmaintenancedate=data.get('nextmaintenancedate'),
maintenanceintervaldays=data.get('maintenanceintervaldays')
)
db.session.add(equip)
db.session.commit()
result = asset.to_dict()
result['equipment'] = equip.to_dict()
return success_response(result, message='Equipment created', http_code=201)
@equipment_bp.route('/<int:equipment_id>', methods=['PUT'])
@jwt_required()
def update_equipment(equipment_id: int):
"""Update equipment (both Asset and Equipment records)."""
equip = Equipment.query.get(equipment_id)
if not equip:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment with ID {equipment_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
asset = equip.asset
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
for key in asset_fields:
if key in data:
setattr(asset, key, data[key])
# Update equipment fields
equipment_fields = ['equipmenttypeid', 'vendorid', 'modelnumberid',
'requiresmanualconfig', 'islocationonly',
'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays']
for key in equipment_fields:
if key in data:
setattr(equip, key, data[key])
db.session.commit()
result = asset.to_dict()
result['equipment'] = equip.to_dict()
return success_response(result, message='Equipment updated')
@equipment_bp.route('/<int:equipment_id>', methods=['DELETE'])
@jwt_required()
def delete_equipment(equipment_id: int):
"""Delete (soft delete) equipment."""
equip = Equipment.query.get(equipment_id)
if not equip:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment with ID {equipment_id} not found',
http_code=404
)
# Soft delete the asset (equipment extension will stay linked)
equip.asset.isactive = False
db.session.commit()
return success_response(message='Equipment deleted')
# =============================================================================
# Dashboard
# =============================================================================
@equipment_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required()
def dashboard_summary():
"""Get equipment dashboard summary data."""
# Total active equipment count
total = db.session.query(Equipment).join(Asset).filter(
Asset.isactive == True
).count()
# Count by equipment type
by_type = db.session.query(
EquipmentType.equipmenttype,
db.func.count(Equipment.equipmentid)
).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
).join(Asset, Asset.assetid == Equipment.assetid
).filter(Asset.isactive == True
).group_by(EquipmentType.equipmenttype
).all()
# Count by status
from shopdb.core.models import AssetStatus
by_status = db.session.query(
AssetStatus.status,
db.func.count(Equipment.equipmentid)
).join(Asset, Asset.assetid == Equipment.assetid
).join(AssetStatus, AssetStatus.statusid == Asset.statusid
).filter(Asset.isactive == True
).group_by(AssetStatus.status
).all()
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]
})

View File

@@ -0,0 +1,22 @@
{
"name": "equipment",
"version": "1.0.0",
"description": "Equipment management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing equipment",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=1.0.0",
"api_prefix": "/api/equipment",
"provides": {
"asset_type": "equipment",
"features": [
"equipment_tracking",
"maintenance_scheduling",
"vendor_management",
"model_catalog"
]
},
"settings": {
"enable_maintenance_alerts": true,
"maintenance_alert_days": 30
}
}

View File

@@ -0,0 +1,8 @@
"""Equipment plugin models."""
from .equipment import Equipment, EquipmentType
__all__ = [
'Equipment',
'EquipmentType',
]

View File

@@ -0,0 +1,109 @@
"""Equipment plugin models."""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
class EquipmentType(BaseModel):
"""
Equipment type classification.
Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc.
"""
__tablename__ = 'equipmenttypes'
equipmenttypeid = db.Column(db.Integer, primary_key=True)
equipmenttype = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
return f"<EquipmentType {self.equipmenttype}>"
class Equipment(BaseModel):
"""
Equipment-specific extension data.
Links to core Asset table via assetid.
Stores equipment-specific fields like type, model, vendor, etc.
"""
__tablename__ = 'equipment'
equipmentid = db.Column(db.Integer, primary_key=True)
# Link to core asset
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
unique=True,
nullable=False,
index=True
)
# Equipment classification
equipmenttypeid = db.Column(
db.Integer,
db.ForeignKey('equipmenttypes.equipmenttypeid'),
nullable=True
)
# Vendor and model
vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),
nullable=True
)
modelnumberid = db.Column(
db.Integer,
db.ForeignKey('models.modelnumberid'),
nullable=True
)
# Equipment-specific fields
requiresmanualconfig = db.Column(
db.Boolean,
default=False,
comment='Multi-PC machine needs manual configuration'
)
islocationonly = db.Column(
db.Boolean,
default=False,
comment='Virtual location marker (not actual equipment)'
)
# Maintenance tracking
lastmaintenancedate = db.Column(db.DateTime, nullable=True)
nextmaintenancedate = db.Column(db.DateTime, nullable=True)
maintenanceintervaldays = db.Column(db.Integer, nullable=True)
# Relationships
asset = db.relationship(
'Asset',
backref=db.backref('equipment', uselist=False, lazy='joined')
)
equipmenttype = db.relationship('EquipmentType', backref='equipment')
vendor = db.relationship('Vendor', backref='equipment_items')
model = db.relationship('Model', backref='equipment_items')
__table_args__ = (
db.Index('idx_equipment_type', 'equipmenttypeid'),
db.Index('idx_equipment_vendor', 'vendorid'),
)
def __repr__(self):
return f"<Equipment {self.assetid}>"
def to_dict(self):
"""Convert to dictionary with related names."""
result = super().to_dict()
# Add related object names
if self.equipmenttype:
result['equipmenttype_name'] = self.equipmenttype.equipmenttype
if self.vendor:
result['vendor_name'] = self.vendor.vendor
if self.model:
result['model_name'] = self.model.modelnumber
return result

220
plugins/equipment/plugin.py Normal file
View File

@@ -0,0 +1,220 @@
"""Equipment plugin main class."""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db
from shopdb.core.models import AssetType, AssetStatus
from .models import Equipment, EquipmentType
from .api import equipment_bp
logger = logging.getLogger(__name__)
class EquipmentPlugin(BasePlugin):
"""
Equipment plugin - manages manufacturing equipment assets.
Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc.
Uses the new Asset architecture with Equipment extension table.
"""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file."""
manifestpath = Path(__file__).parent / 'manifest.json'
if manifestpath.exists():
with open(manifestpath, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'equipment'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get(
'description',
'Equipment management for manufacturing assets'
),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/equipment'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return equipment_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [Equipment, EquipmentType]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"Equipment plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed."""
with app.app_context():
self._ensure_asset_type()
self._ensure_asset_statuses()
self._ensure_equipment_types()
logger.info("Equipment plugin installed")
def _ensure_asset_type(self) -> None:
"""Ensure equipment asset type exists."""
existing = AssetType.query.filter_by(assettype='equipment').first()
if not existing:
at = AssetType(
assettype='equipment',
plugin_name='equipment',
table_name='equipment',
description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)',
icon='cog'
)
db.session.add(at)
logger.debug("Created asset type: equipment")
db.session.commit()
def _ensure_asset_statuses(self) -> None:
"""Ensure standard asset statuses exist."""
statuses = [
('In Use', 'Asset is currently in use', '#28a745'),
('Spare', 'Spare/backup asset', '#17a2b8'),
('Retired', 'Asset has been retired', '#6c757d'),
('Maintenance', 'Asset is under maintenance', '#ffc107'),
('Decommissioned', 'Asset has been decommissioned', '#dc3545'),
]
for name, description, color in statuses:
existing = AssetStatus.query.filter_by(status=name).first()
if not existing:
s = AssetStatus(
status=name,
description=description,
color=color
)
db.session.add(s)
logger.debug(f"Created asset status: {name}")
db.session.commit()
def _ensure_equipment_types(self) -> None:
"""Ensure basic equipment types exist."""
equipment_types = [
('CNC', 'Computer Numerical Control machine', 'cnc'),
('CMM', 'Coordinate Measuring Machine', 'cmm'),
('Lathe', 'Lathe machine', 'lathe'),
('Grinder', 'Grinding machine', 'grinder'),
('EDM', 'Electrical Discharge Machine', 'edm'),
('Part Marker', 'Part marking/engraving equipment', 'marker'),
('Mill', 'Milling machine', 'mill'),
('Press', 'Press machine', 'press'),
('Robot', 'Industrial robot', 'robot'),
('Other', 'Other equipment type', 'cog'),
]
for name, description, icon in equipment_types:
existing = EquipmentType.query.filter_by(equipmenttype=name).first()
if not existing:
et = EquipmentType(
equipmenttype=name,
description=description,
icon=icon
)
db.session.add(et)
logger.debug(f"Created equipment type: {name}")
db.session.commit()
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled."""
logger.info("Equipment plugin uninstalled")
def get_cli_commands(self) -> List:
"""Return CLI commands for this plugin."""
@click.group('equipment')
def equipmentcli():
"""Equipment plugin commands."""
pass
@equipmentcli.command('list-types')
def list_types():
"""List all equipment types."""
from flask import current_app
with current_app.app_context():
types = EquipmentType.query.filter_by(isactive=True).all()
if not types:
click.echo('No equipment types found.')
return
click.echo('Equipment Types:')
for t in types:
click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}")
@equipmentcli.command('stats')
def stats():
"""Show equipment statistics."""
from flask import current_app
from shopdb.core.models import Asset
with current_app.app_context():
total = db.session.query(Equipment).join(Asset).filter(
Asset.isactive == True
).count()
click.echo(f"Total active equipment: {total}")
# By type
by_type = db.session.query(
EquipmentType.equipmenttype,
db.func.count(Equipment.equipmentid)
).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
).join(Asset, Asset.assetid == Equipment.assetid
).filter(Asset.isactive == True
).group_by(EquipmentType.equipmenttype
).all()
if by_type:
click.echo("\nBy Type:")
for t, c in by_type:
click.echo(f" {t}: {c}")
return [equipmentcli]
def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions."""
return [
{
'name': 'Equipment Status',
'component': 'EquipmentStatusWidget',
'endpoint': '/api/equipment/dashboard/summary',
'size': 'medium',
'position': 5,
},
]
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""
return [
{
'name': 'Equipment',
'icon': 'cog',
'route': '/equipment',
'position': 10,
},
]

View File

@@ -0,0 +1,5 @@
"""Network plugin for ShopDB."""
from .plugin import NetworkPlugin
__all__ = ['NetworkPlugin']

View File

@@ -0,0 +1,5 @@
"""Network plugin API."""
from .routes import network_bp
__all__ = ['network_bp']

View File

@@ -0,0 +1,817 @@
"""Network plugin API endpoints."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, Vendor
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
network_bp = Blueprint('network', __name__)
# =============================================================================
# Network Device Types
# =============================================================================
@network_bp.route('/types', methods=['GET'])
@jwt_required()
def list_network_device_types():
"""List all network device types."""
page, per_page = get_pagination_params(request)
query = NetworkDeviceType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(NetworkDeviceType.isactive == True)
if search := request.args.get('search'):
query = query.filter(NetworkDeviceType.networkdevicetype.ilike(f'%{search}%'))
query = query.order_by(NetworkDeviceType.networkdevicetype)
items, total = paginate_query(query, page, per_page)
data = [t.to_dict() for t in items]
return paginated_response(data, page, per_page, total)
@network_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required()
def get_network_device_type(type_id: int):
"""Get a single network device type."""
t = NetworkDeviceType.query.get(type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Network device type with ID {type_id} not found',
http_code=404
)
return success_response(t.to_dict())
@network_bp.route('/types', methods=['POST'])
@jwt_required()
def create_network_device_type():
"""Create a new network device type."""
data = request.get_json()
if not data or not data.get('networkdevicetype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'networkdevicetype is required')
if NetworkDeviceType.query.filter_by(networkdevicetype=data['networkdevicetype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Network device type '{data['networkdevicetype']}' already exists",
http_code=409
)
t = NetworkDeviceType(
networkdevicetype=data['networkdevicetype'],
description=data.get('description'),
icon=data.get('icon')
)
db.session.add(t)
db.session.commit()
return success_response(t.to_dict(), message='Network device type created', http_code=201)
@network_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
def update_network_device_type(type_id: int):
"""Update a network device type."""
t = NetworkDeviceType.query.get(type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Network device type with ID {type_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if 'networkdevicetype' in data and data['networkdevicetype'] != t.networkdevicetype:
if NetworkDeviceType.query.filter_by(networkdevicetype=data['networkdevicetype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Network device type '{data['networkdevicetype']}' already exists",
http_code=409
)
for key in ['networkdevicetype', 'description', 'icon', 'isactive']:
if key in data:
setattr(t, key, data[key])
db.session.commit()
return success_response(t.to_dict(), message='Network device type updated')
# =============================================================================
# Network Devices CRUD
# =============================================================================
@network_bp.route('', methods=['GET'])
@jwt_required()
def list_network_devices():
"""
List all network devices with filtering and pagination.
Query parameters:
- page, per_page: Pagination
- active: Filter by active status
- search: Search by asset number, name, or hostname
- type_id: Filter by network device type ID
- vendor_id: Filter by vendor ID
- location_id: Filter by location ID
- businessunit_id: Filter by business unit ID
- poe: Filter by PoE capability (true/false)
- managed: Filter by managed status (true/false)
"""
page, per_page = get_pagination_params(request)
# Join NetworkDevice with Asset
query = db.session.query(NetworkDevice).join(Asset)
# Active filter
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Asset.isactive == True)
# Search filter
if search := request.args.get('search'):
query = query.filter(
db.or_(
Asset.assetnumber.ilike(f'%{search}%'),
Asset.name.ilike(f'%{search}%'),
Asset.serialnumber.ilike(f'%{search}%'),
NetworkDevice.hostname.ilike(f'%{search}%')
)
)
# Type filter
if type_id := request.args.get('type_id'):
query = query.filter(NetworkDevice.networkdevicetypeid == int(type_id))
# Vendor filter
if vendor_id := request.args.get('vendor_id'):
query = query.filter(NetworkDevice.vendorid == int(vendor_id))
# Location filter
if location_id := request.args.get('location_id'):
query = query.filter(Asset.locationid == int(location_id))
# Business unit filter
if bu_id := request.args.get('businessunit_id'):
query = query.filter(Asset.businessunitid == int(bu_id))
# PoE filter
if poe := request.args.get('poe'):
query = query.filter(NetworkDevice.ispoe == (poe.lower() == 'true'))
# Managed filter
if managed := request.args.get('managed'):
query = query.filter(NetworkDevice.ismanaged == (managed.lower() == 'true'))
# Sorting
sort_by = request.args.get('sort', 'hostname')
sort_dir = request.args.get('dir', 'asc')
if sort_by == 'hostname':
col = NetworkDevice.hostname
elif sort_by == 'assetnumber':
col = Asset.assetnumber
elif sort_by == 'name':
col = Asset.name
else:
col = NetworkDevice.hostname
query = query.order_by(col.desc() if sort_dir == 'desc' else col)
items, total = paginate_query(query, page, per_page)
# Build response with both asset and network device data
data = []
for netdev in items:
item = netdev.asset.to_dict() if netdev.asset else {}
item['network_device'] = netdev.to_dict()
data.append(item)
return paginated_response(data, page, per_page, total)
@network_bp.route('/<int:device_id>', methods=['GET'])
@jwt_required()
def get_network_device(device_id: int):
"""Get a single network device with full details."""
netdev = NetworkDevice.query.get(device_id)
if not netdev:
return error_response(
ErrorCodes.NOT_FOUND,
f'Network device with ID {device_id} not found',
http_code=404
)
result = netdev.asset.to_dict() if netdev.asset else {}
result['network_device'] = netdev.to_dict()
return success_response(result)
@network_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required()
def get_network_device_by_asset(asset_id: int):
"""Get network device data by asset ID."""
netdev = NetworkDevice.query.filter_by(assetid=asset_id).first()
if not netdev:
return error_response(
ErrorCodes.NOT_FOUND,
f'Network device for asset {asset_id} not found',
http_code=404
)
result = netdev.asset.to_dict() if netdev.asset else {}
result['network_device'] = netdev.to_dict()
return success_response(result)
@network_bp.route('/by-hostname/<hostname>', methods=['GET'])
@jwt_required()
def get_network_device_by_hostname(hostname: str):
"""Get network device by hostname."""
netdev = NetworkDevice.query.filter_by(hostname=hostname).first()
if not netdev:
return error_response(
ErrorCodes.NOT_FOUND,
f'Network device with hostname {hostname} not found',
http_code=404
)
result = netdev.asset.to_dict() if netdev.asset else {}
result['network_device'] = netdev.to_dict()
return success_response(result)
@network_bp.route('', methods=['POST'])
@jwt_required()
def create_network_device():
"""
Create new network device (creates both Asset and NetworkDevice records).
Required fields:
- assetnumber: Business identifier
Optional fields:
- name, serialnumber, statusid, locationid, businessunitid
- networkdevicetypeid, vendorid, hostname
- firmwareversion, portcount, ispoe, ismanaged, rackunit
- mapleft, maptop, notes
"""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if not data.get('assetnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
# Check for duplicate assetnumber
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Check for duplicate hostname
if data.get('hostname'):
if NetworkDevice.query.filter_by(hostname=data['hostname']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Network device with hostname '{data['hostname']}' already exists",
http_code=409
)
# Get network device asset type
network_type = AssetType.query.filter_by(assettype='network_device').first()
if not network_type:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Network device asset type not found. Plugin may not be properly installed.',
http_code=500
)
# Create the core asset
asset = Asset(
assetnumber=data['assetnumber'],
name=data.get('name'),
serialnumber=data.get('serialnumber'),
assettypeid=network_type.assettypeid,
statusid=data.get('statusid', 1),
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapleft=data.get('mapleft'),
maptop=data.get('maptop'),
notes=data.get('notes')
)
db.session.add(asset)
db.session.flush() # Get the assetid
# Create the network device extension
netdev = NetworkDevice(
assetid=asset.assetid,
networkdevicetypeid=data.get('networkdevicetypeid'),
vendorid=data.get('vendorid'),
hostname=data.get('hostname'),
firmwareversion=data.get('firmwareversion'),
portcount=data.get('portcount'),
ispoe=data.get('ispoe', False),
ismanaged=data.get('ismanaged', False),
rackunit=data.get('rackunit')
)
db.session.add(netdev)
db.session.commit()
result = asset.to_dict()
result['network_device'] = netdev.to_dict()
return success_response(result, message='Network device created', http_code=201)
@network_bp.route('/<int:device_id>', methods=['PUT'])
@jwt_required()
def update_network_device(device_id: int):
"""Update network device (both Asset and NetworkDevice records)."""
netdev = NetworkDevice.query.get(device_id)
if not netdev:
return error_response(
ErrorCodes.NOT_FOUND,
f'Network device with ID {device_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
asset = netdev.asset
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Check for conflicting hostname
if 'hostname' in data and data['hostname'] != netdev.hostname:
existing = NetworkDevice.query.filter_by(hostname=data['hostname']).first()
if existing and existing.networkdeviceid != device_id:
return error_response(
ErrorCodes.CONFLICT,
f"Network device with hostname '{data['hostname']}' already exists",
http_code=409
)
# Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
for key in asset_fields:
if key in data:
setattr(asset, key, data[key])
# Update network device fields
netdev_fields = ['networkdevicetypeid', 'vendorid', 'hostname',
'firmwareversion', 'portcount', 'ispoe', 'ismanaged', 'rackunit']
for key in netdev_fields:
if key in data:
setattr(netdev, key, data[key])
db.session.commit()
result = asset.to_dict()
result['network_device'] = netdev.to_dict()
return success_response(result, message='Network device updated')
@network_bp.route('/<int:device_id>', methods=['DELETE'])
@jwt_required()
def delete_network_device(device_id: int):
"""Delete (soft delete) network device."""
netdev = NetworkDevice.query.get(device_id)
if not netdev:
return error_response(
ErrorCodes.NOT_FOUND,
f'Network device with ID {device_id} not found',
http_code=404
)
# Soft delete the asset
netdev.asset.isactive = False
db.session.commit()
return success_response(message='Network device deleted')
# =============================================================================
# Dashboard
# =============================================================================
@network_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required()
def dashboard_summary():
"""Get network device dashboard summary data."""
# Total active network devices
total = db.session.query(NetworkDevice).join(Asset).filter(
Asset.isactive == True
).count()
# Count by device type
by_type = db.session.query(
NetworkDeviceType.networkdevicetype,
db.func.count(NetworkDevice.networkdeviceid)
).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid
).join(Asset, Asset.assetid == NetworkDevice.assetid
).filter(Asset.isactive == True
).group_by(NetworkDeviceType.networkdevicetype
).all()
# Count by vendor
by_vendor = db.session.query(
Vendor.vendor,
db.func.count(NetworkDevice.networkdeviceid)
).join(NetworkDevice, NetworkDevice.vendorid == Vendor.vendorid
).join(Asset, Asset.assetid == NetworkDevice.assetid
).filter(Asset.isactive == True
).group_by(Vendor.vendor
).all()
# Count PoE vs non-PoE
poe_count = db.session.query(NetworkDevice).join(Asset).filter(
Asset.isactive == True,
NetworkDevice.ispoe == True
).count()
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],
'poe': poe_count,
'non_poe': total - poe_count
})
# =============================================================================
# VLANs
# =============================================================================
@network_bp.route('/vlans', methods=['GET'])
@jwt_required()
def list_vlans():
"""List all VLANs with filtering and pagination."""
page, per_page = get_pagination_params(request)
query = VLAN.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(VLAN.isactive == True)
# Search filter
if search := request.args.get('search'):
query = query.filter(
db.or_(
VLAN.name.ilike(f'%{search}%'),
VLAN.description.ilike(f'%{search}%'),
db.cast(VLAN.vlannumber, db.String).ilike(f'%{search}%')
)
)
# Type filter
if vlan_type := request.args.get('type'):
query = query.filter(VLAN.vlantype == vlan_type)
query = query.order_by(VLAN.vlannumber)
items, total = paginate_query(query, page, per_page)
data = [v.to_dict() for v in items]
return paginated_response(data, page, per_page, total)
@network_bp.route('/vlans/<int:vlan_id>', methods=['GET'])
@jwt_required()
def get_vlan(vlan_id: int):
"""Get a single VLAN with its subnets."""
vlan = VLAN.query.get(vlan_id)
if not vlan:
return error_response(
ErrorCodes.NOT_FOUND,
f'VLAN with ID {vlan_id} not found',
http_code=404
)
result = vlan.to_dict()
# Include associated subnets
result['subnets'] = [s.to_dict() for s in vlan.subnets.filter_by(isactive=True).all()]
return success_response(result)
@network_bp.route('/vlans', methods=['POST'])
@jwt_required()
def create_vlan():
"""Create a new VLAN."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if not data.get('vlannumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'vlannumber is required')
if not data.get('name'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
# Check for duplicate VLAN number
if VLAN.query.filter_by(vlannumber=data['vlannumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"VLAN {data['vlannumber']} already exists",
http_code=409
)
vlan = VLAN(
vlannumber=data['vlannumber'],
name=data['name'],
description=data.get('description'),
vlantype=data.get('vlantype')
)
db.session.add(vlan)
db.session.commit()
return success_response(vlan.to_dict(), message='VLAN created', http_code=201)
@network_bp.route('/vlans/<int:vlan_id>', methods=['PUT'])
@jwt_required()
def update_vlan(vlan_id: int):
"""Update a VLAN."""
vlan = VLAN.query.get(vlan_id)
if not vlan:
return error_response(
ErrorCodes.NOT_FOUND,
f'VLAN with ID {vlan_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Check for conflicting VLAN number
if 'vlannumber' in data and data['vlannumber'] != vlan.vlannumber:
if VLAN.query.filter_by(vlannumber=data['vlannumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"VLAN {data['vlannumber']} already exists",
http_code=409
)
for key in ['vlannumber', 'name', 'description', 'vlantype', 'isactive']:
if key in data:
setattr(vlan, key, data[key])
db.session.commit()
return success_response(vlan.to_dict(), message='VLAN updated')
@network_bp.route('/vlans/<int:vlan_id>', methods=['DELETE'])
@jwt_required()
def delete_vlan(vlan_id: int):
"""Delete (soft delete) a VLAN."""
vlan = VLAN.query.get(vlan_id)
if not vlan:
return error_response(
ErrorCodes.NOT_FOUND,
f'VLAN with ID {vlan_id} not found',
http_code=404
)
# Check if VLAN has associated subnets
if vlan.subnets.filter_by(isactive=True).count() > 0:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Cannot delete VLAN with associated subnets',
http_code=400
)
vlan.isactive = False
db.session.commit()
return success_response(message='VLAN deleted')
# =============================================================================
# Subnets
# =============================================================================
@network_bp.route('/subnets', methods=['GET'])
@jwt_required()
def list_subnets():
"""List all subnets with filtering and pagination."""
page, per_page = get_pagination_params(request)
query = Subnet.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Subnet.isactive == True)
# Search filter
if search := request.args.get('search'):
query = query.filter(
db.or_(
Subnet.cidr.ilike(f'%{search}%'),
Subnet.name.ilike(f'%{search}%'),
Subnet.description.ilike(f'%{search}%')
)
)
# VLAN filter
if vlan_id := request.args.get('vlanid'):
query = query.filter(Subnet.vlanid == int(vlan_id))
# Location filter
if location_id := request.args.get('locationid'):
query = query.filter(Subnet.locationid == int(location_id))
# Type filter
if subnet_type := request.args.get('type'):
query = query.filter(Subnet.subnettype == subnet_type)
query = query.order_by(Subnet.cidr)
items, total = paginate_query(query, page, per_page)
data = [s.to_dict() for s in items]
return paginated_response(data, page, per_page, total)
@network_bp.route('/subnets/<int:subnet_id>', methods=['GET'])
@jwt_required()
def get_subnet(subnet_id: int):
"""Get a single subnet."""
subnet = Subnet.query.get(subnet_id)
if not subnet:
return error_response(
ErrorCodes.NOT_FOUND,
f'Subnet with ID {subnet_id} not found',
http_code=404
)
return success_response(subnet.to_dict())
@network_bp.route('/subnets', methods=['POST'])
@jwt_required()
def create_subnet():
"""Create a new subnet."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if not data.get('cidr'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr is required')
if not data.get('name'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
# Validate CIDR format (basic check)
cidr = data['cidr']
if '/' not in cidr:
return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr must be in CIDR notation (e.g., 10.1.1.0/24)')
# Check for duplicate CIDR
if Subnet.query.filter_by(cidr=cidr).first():
return error_response(
ErrorCodes.CONFLICT,
f"Subnet {cidr} already exists",
http_code=409
)
# Validate VLAN if provided
if data.get('vlanid'):
if not VLAN.query.get(data['vlanid']):
return error_response(
ErrorCodes.VALIDATION_ERROR,
f"VLAN with ID {data['vlanid']} not found"
)
subnet = Subnet(
cidr=cidr,
name=data['name'],
description=data.get('description'),
gatewayip=data.get('gatewayip'),
subnetmask=data.get('subnetmask'),
networkaddress=data.get('networkaddress'),
broadcastaddress=data.get('broadcastaddress'),
vlanid=data.get('vlanid'),
subnettype=data.get('subnettype'),
locationid=data.get('locationid'),
dhcpenabled=data.get('dhcpenabled', True),
dhcprangestart=data.get('dhcprangestart'),
dhcprangeend=data.get('dhcprangeend'),
dns1=data.get('dns1'),
dns2=data.get('dns2')
)
db.session.add(subnet)
db.session.commit()
return success_response(subnet.to_dict(), message='Subnet created', http_code=201)
@network_bp.route('/subnets/<int:subnet_id>', methods=['PUT'])
@jwt_required()
def update_subnet(subnet_id: int):
"""Update a subnet."""
subnet = Subnet.query.get(subnet_id)
if not subnet:
return error_response(
ErrorCodes.NOT_FOUND,
f'Subnet with ID {subnet_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Check for conflicting CIDR
if 'cidr' in data and data['cidr'] != subnet.cidr:
if Subnet.query.filter_by(cidr=data['cidr']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Subnet {data['cidr']} already exists",
http_code=409
)
allowed_fields = ['cidr', 'name', 'description', 'gatewayip', 'subnetmask',
'networkaddress', 'broadcastaddress', 'vlanid', 'subnettype',
'locationid', 'dhcpenabled', 'dhcprangestart', 'dhcprangeend',
'dns1', 'dns2', 'isactive']
for key in allowed_fields:
if key in data:
setattr(subnet, key, data[key])
db.session.commit()
return success_response(subnet.to_dict(), message='Subnet updated')
@network_bp.route('/subnets/<int:subnet_id>', methods=['DELETE'])
@jwt_required()
def delete_subnet(subnet_id: int):
"""Delete (soft delete) a subnet."""
subnet = Subnet.query.get(subnet_id)
if not subnet:
return error_response(
ErrorCodes.NOT_FOUND,
f'Subnet with ID {subnet_id} not found',
http_code=404
)
subnet.isactive = False
db.session.commit()
return success_response(message='Subnet deleted')

View File

@@ -0,0 +1,22 @@
{
"name": "network",
"version": "1.0.0",
"description": "Network device management plugin for switches, APs, cameras, and IDFs",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=1.0.0",
"api_prefix": "/api/network",
"provides": {
"asset_type": "network_device",
"features": [
"network_device_tracking",
"port_management",
"firmware_tracking",
"poe_monitoring"
]
},
"settings": {
"enable_snmp_polling": false,
"snmp_community": "public"
}
}

View File

@@ -0,0 +1,11 @@
"""Network plugin models."""
from .network_device import NetworkDevice, NetworkDeviceType
from .subnet import Subnet, VLAN
__all__ = [
'NetworkDevice',
'NetworkDeviceType',
'Subnet',
'VLAN',
]

View File

@@ -0,0 +1,121 @@
"""Network device plugin models."""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
class NetworkDeviceType(BaseModel):
"""
Network device type classification.
Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc.
"""
__tablename__ = 'networkdevicetypes'
networkdevicetypeid = db.Column(db.Integer, primary_key=True)
networkdevicetype = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
return f"<NetworkDeviceType {self.networkdevicetype}>"
class NetworkDevice(BaseModel):
"""
Network device-specific extension data.
Links to core Asset table via assetid.
Stores network device-specific fields like hostname, firmware, ports, etc.
"""
__tablename__ = 'networkdevices'
networkdeviceid = db.Column(db.Integer, primary_key=True)
# Link to core asset
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
unique=True,
nullable=False,
index=True
)
# Network device classification
networkdevicetypeid = db.Column(
db.Integer,
db.ForeignKey('networkdevicetypes.networkdevicetypeid'),
nullable=True
)
# Vendor
vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),
nullable=True
)
# Network identity
hostname = db.Column(
db.String(100),
index=True,
comment='Network hostname'
)
# Firmware/software version
firmwareversion = db.Column(db.String(100), nullable=True)
# Physical characteristics
portcount = db.Column(
db.Integer,
nullable=True,
comment='Number of ports (for switches)'
)
# Features
ispoe = db.Column(
db.Boolean,
default=False,
comment='Power over Ethernet capable'
)
ismanaged = db.Column(
db.Boolean,
default=False,
comment='Managed device (SNMP, web interface, etc.)'
)
# For IDF/closet locations
rackunit = db.Column(
db.String(20),
nullable=True,
comment='Rack unit position (e.g., U1, U5)'
)
# Relationships
asset = db.relationship(
'Asset',
backref=db.backref('network_device', uselist=False, lazy='joined')
)
networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices')
vendor = db.relationship('Vendor', backref='network_devices')
__table_args__ = (
db.Index('idx_netdev_type', 'networkdevicetypeid'),
db.Index('idx_netdev_hostname', 'hostname'),
db.Index('idx_netdev_vendor', 'vendorid'),
)
def __repr__(self):
return f"<NetworkDevice {self.hostname or self.assetid}>"
def to_dict(self):
"""Convert to dictionary with related names."""
result = super().to_dict()
# Add related object names
if self.networkdevicetype:
result['networkdevicetype_name'] = self.networkdevicetype.networkdevicetype
if self.vendor:
result['vendor_name'] = self.vendor.vendor
return result

View File

@@ -0,0 +1,146 @@
"""Subnet and VLAN models for network plugin."""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
class VLAN(BaseModel):
"""
VLAN definition.
Represents a virtual LAN for network segmentation.
"""
__tablename__ = 'vlans'
vlanid = db.Column(db.Integer, primary_key=True)
vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number')
name = db.Column(db.String(100), nullable=False, comment='VLAN name')
description = db.Column(db.Text, nullable=True)
# Optional classification
vlantype = db.Column(
db.String(50),
nullable=True,
comment='Type: data, voice, management, guest, etc.'
)
# Relationships
subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic')
__table_args__ = (
db.Index('idx_vlan_number', 'vlannumber'),
)
def __repr__(self):
return f"<VLAN {self.vlannumber} - {self.name}>"
def to_dict(self):
"""Convert to dictionary."""
result = super().to_dict()
result['subnetcount'] = self.subnets.count() if self.subnets else 0
return result
class Subnet(BaseModel):
"""
Subnet/IP network definition.
Represents an IP subnet with optional VLAN association.
"""
__tablename__ = 'subnets'
subnetid = db.Column(db.Integer, primary_key=True)
# Network definition
cidr = db.Column(
db.String(18),
unique=True,
nullable=False,
comment='CIDR notation (e.g., 10.1.1.0/24)'
)
name = db.Column(db.String(100), nullable=False, comment='Subnet name')
description = db.Column(db.Text, nullable=True)
# Network details
gatewayip = db.Column(
db.String(15),
nullable=True,
comment='Default gateway IP address'
)
subnetmask = db.Column(
db.String(15),
nullable=True,
comment='Subnet mask (e.g., 255.255.255.0)'
)
networkaddress = db.Column(
db.String(15),
nullable=True,
comment='Network address (e.g., 10.1.1.0)'
)
broadcastaddress = db.Column(
db.String(15),
nullable=True,
comment='Broadcast address (e.g., 10.1.1.255)'
)
# VLAN association
vlanid = db.Column(
db.Integer,
db.ForeignKey('vlans.vlanid'),
nullable=True
)
# Classification
subnettype = db.Column(
db.String(50),
nullable=True,
comment='Type: production, development, management, dmz, etc.'
)
# Location association
locationid = db.Column(
db.Integer,
db.ForeignKey('locations.locationid'),
nullable=True
)
# DHCP settings
dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet')
dhcprangestart = db.Column(db.String(15), nullable=True, comment='DHCP range start IP')
dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP')
# DNS settings
dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server')
dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server')
# Relationships
location = db.relationship('Location', backref='subnets')
__table_args__ = (
db.Index('idx_subnet_cidr', 'cidr'),
db.Index('idx_subnet_vlan', 'vlanid'),
db.Index('idx_subnet_location', 'locationid'),
)
def __repr__(self):
return f"<Subnet {self.cidr} - {self.name}>"
@property
def vlan_number(self):
"""Get the VLAN number."""
return self.vlan.vlannumber if self.vlan else None
def to_dict(self):
"""Convert to dictionary with related data."""
result = super().to_dict()
# Add VLAN info
if self.vlan:
result['vlannumber'] = self.vlan.vlannumber
result['vlanname'] = self.vlan.name
# Add location info
if self.location:
result['locationname'] = self.location.locationname
return result

217
plugins/network/plugin.py Normal file
View File

@@ -0,0 +1,217 @@
"""Network plugin main class."""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db
from shopdb.core.models import AssetType
from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
from .api import network_bp
logger = logging.getLogger(__name__)
class NetworkPlugin(BasePlugin):
"""
Network plugin - manages network device assets.
Network devices include switches, routers, access points, cameras, IDFs, etc.
Uses the new Asset architecture with NetworkDevice extension table.
"""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file."""
manifestpath = Path(__file__).parent / 'manifest.json'
if manifestpath.exists():
with open(manifestpath, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'network'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get(
'description',
'Network device management for switches, APs, and cameras'
),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/network'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return network_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [NetworkDevice, NetworkDeviceType, Subnet, VLAN]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"Network plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed."""
with app.app_context():
self._ensure_asset_type()
self._ensure_network_device_types()
logger.info("Network plugin installed")
def _ensure_asset_type(self) -> None:
"""Ensure network_device asset type exists."""
existing = AssetType.query.filter_by(assettype='network_device').first()
if not existing:
at = AssetType(
assettype='network_device',
plugin_name='network',
table_name='networkdevices',
description='Network infrastructure devices (switches, APs, cameras, etc.)',
icon='network-wired'
)
db.session.add(at)
logger.debug("Created asset type: network_device")
db.session.commit()
def _ensure_network_device_types(self) -> None:
"""Ensure basic network device types exist."""
device_types = [
('Switch', 'Network switch', 'network-wired'),
('Router', 'Network router', 'router'),
('Access Point', 'Wireless access point', 'wifi'),
('Firewall', 'Network firewall', 'shield'),
('Camera', 'IP camera', 'video'),
('IDF', 'Intermediate Distribution Frame/closet', 'box'),
('MDF', 'Main Distribution Frame', 'building'),
('Patch Panel', 'Patch panel', 'th'),
('UPS', 'Uninterruptible power supply', 'battery'),
('Other', 'Other network device', 'network-wired'),
]
for name, description, icon in device_types:
existing = NetworkDeviceType.query.filter_by(networkdevicetype=name).first()
if not existing:
ndt = NetworkDeviceType(
networkdevicetype=name,
description=description,
icon=icon
)
db.session.add(ndt)
logger.debug(f"Created network device type: {name}")
db.session.commit()
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled."""
logger.info("Network plugin uninstalled")
def get_cli_commands(self) -> List:
"""Return CLI commands for this plugin."""
@click.group('network')
def networkcli():
"""Network plugin commands."""
pass
@networkcli.command('list-types')
def list_types():
"""List all network device types."""
from flask import current_app
with current_app.app_context():
types = NetworkDeviceType.query.filter_by(isactive=True).all()
if not types:
click.echo('No network device types found.')
return
click.echo('Network Device Types:')
for t in types:
click.echo(f" [{t.networkdevicetypeid}] {t.networkdevicetype}")
@networkcli.command('stats')
def stats():
"""Show network device statistics."""
from flask import current_app
from shopdb.core.models import Asset
with current_app.app_context():
total = db.session.query(NetworkDevice).join(Asset).filter(
Asset.isactive == True
).count()
click.echo(f"Total active network devices: {total}")
# By type
by_type = db.session.query(
NetworkDeviceType.networkdevicetype,
db.func.count(NetworkDevice.networkdeviceid)
).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid
).join(Asset, Asset.assetid == NetworkDevice.assetid
).filter(Asset.isactive == True
).group_by(NetworkDeviceType.networkdevicetype
).all()
if by_type:
click.echo("\nBy Type:")
for t, c in by_type:
click.echo(f" {t}: {c}")
@networkcli.command('find')
@click.argument('hostname')
def find_by_hostname(hostname):
"""Find a network device by hostname."""
from flask import current_app
with current_app.app_context():
netdev = NetworkDevice.query.filter(
NetworkDevice.hostname.ilike(f'%{hostname}%')
).first()
if not netdev:
click.echo(f'No network device found matching hostname: {hostname}')
return
click.echo(f'Found: {netdev.hostname}')
click.echo(f' Asset: {netdev.asset.assetnumber}')
click.echo(f' Type: {netdev.networkdevicetype.networkdevicetype if netdev.networkdevicetype else "N/A"}')
click.echo(f' Firmware: {netdev.firmwareversion or "N/A"}')
click.echo(f' PoE: {"Yes" if netdev.ispoe else "No"}')
return [networkcli]
def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions."""
return [
{
'name': 'Network Status',
'component': 'NetworkStatusWidget',
'endpoint': '/api/network/dashboard/summary',
'size': 'medium',
'position': 7,
},
]
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""
return [
{
'name': 'Network',
'icon': 'network-wired',
'route': '/network',
'position': 18,
},
]

View File

@@ -0,0 +1,5 @@
"""Notifications plugin package."""
from .plugin import NotificationsPlugin
__all__ = ['NotificationsPlugin']

View File

@@ -0,0 +1,5 @@
"""Notifications plugin API."""
from .routes import notifications_bp
__all__ = ['notifications_bp']

View File

@@ -0,0 +1,617 @@
"""Notifications plugin API endpoints - adapted to existing schema."""
from datetime import datetime
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import Notification, NotificationType
notifications_bp = Blueprint('notifications', __name__)
# =============================================================================
# Notification Types
# =============================================================================
@notifications_bp.route('/types', methods=['GET'])
def list_notification_types():
"""List all notification types."""
page, per_page = get_pagination_params(request)
query = NotificationType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(NotificationType.isactive == True)
query = query.order_by(NotificationType.typename)
items, total = paginate_query(query, page, per_page)
data = [t.to_dict() for t in items]
return paginated_response(data, page, per_page, total)
@notifications_bp.route('/types', methods=['POST'])
@jwt_required()
def create_notification_type():
"""Create a new notification type."""
data = request.get_json()
if not data or not data.get('typename'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'typename is required')
if NotificationType.query.filter_by(typename=data['typename']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Notification type '{data['typename']}' already exists",
http_code=409
)
t = NotificationType(
typename=data['typename'],
typedescription=data.get('typedescription') or data.get('description'),
typecolor=data.get('typecolor') or data.get('color', '#17a2b8')
)
db.session.add(t)
db.session.commit()
return success_response(t.to_dict(), message='Notification type created', http_code=201)
# =============================================================================
# Notifications CRUD
# =============================================================================
@notifications_bp.route('', methods=['GET'])
def list_notifications():
"""
List all notifications with filtering and pagination.
Query parameters:
- page, per_page: Pagination
- active: Filter by active status (default: true)
- type_id: Filter by notification type ID
- current: Filter to currently active notifications only
- search: Search in notification text
"""
page, per_page = get_pagination_params(request)
query = Notification.query
# Active filter
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Notification.isactive == True)
# Type filter
if type_id := request.args.get('type_id'):
query = query.filter(Notification.notificationtypeid == int(type_id))
# Current filter (active based on dates)
if request.args.get('current', 'false').lower() == 'true':
now = datetime.utcnow()
query = query.filter(
Notification.starttime <= now,
db.or_(
Notification.endtime.is_(None),
Notification.endtime >= now
)
)
# Search filter
if search := request.args.get('search'):
query = query.filter(
Notification.notification.ilike(f'%{search}%')
)
# Sorting by start time (newest first)
query = query.order_by(Notification.starttime.desc())
items, total = paginate_query(query, page, per_page)
data = [n.to_dict() for n in items]
return paginated_response(data, page, per_page, total)
@notifications_bp.route('/<int:notification_id>', methods=['GET'])
def get_notification(notification_id: int):
"""Get a single notification."""
n = Notification.query.get(notification_id)
if not n:
return error_response(
ErrorCodes.NOT_FOUND,
f'Notification with ID {notification_id} not found',
http_code=404
)
return success_response(n.to_dict())
@notifications_bp.route('', methods=['POST'])
@jwt_required()
def create_notification():
"""Create a new notification."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Validate required fields
notification_text = data.get('notification') or data.get('message')
if not notification_text:
return error_response(ErrorCodes.VALIDATION_ERROR, 'notification/message is required')
# Parse dates
starttime = datetime.utcnow()
if data.get('starttime') or data.get('startdate'):
try:
date_str = data.get('starttime') or data.get('startdate')
starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
except ValueError:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')
endtime = None
if data.get('endtime') or data.get('enddate'):
try:
date_str = data.get('endtime') or data.get('enddate')
endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
except ValueError:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')
n = Notification(
notification=notification_text,
notificationtypeid=data.get('notificationtypeid'),
businessunitid=data.get('businessunitid'),
appid=data.get('appid'),
starttime=starttime,
endtime=endtime,
ticketnumber=data.get('ticketnumber'),
link=data.get('link') or data.get('linkurl'),
isactive=True,
isshopfloor=data.get('isshopfloor', False),
employeesso=data.get('employeesso'),
employeename=data.get('employeename')
)
db.session.add(n)
db.session.commit()
return success_response(n.to_dict(), message='Notification created', http_code=201)
@notifications_bp.route('/<int:notification_id>', methods=['PUT'])
@jwt_required()
def update_notification(notification_id: int):
"""Update a notification."""
n = Notification.query.get(notification_id)
if not n:
return error_response(
ErrorCodes.NOT_FOUND,
f'Notification with ID {notification_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Update text content
if 'notification' in data or 'message' in data:
n.notification = data.get('notification') or data.get('message')
# Update simple fields
if 'notificationtypeid' in data:
n.notificationtypeid = data['notificationtypeid']
if 'businessunitid' in data:
n.businessunitid = data['businessunitid']
if 'appid' in data:
n.appid = data['appid']
if 'ticketnumber' in data:
n.ticketnumber = data['ticketnumber']
if 'link' in data or 'linkurl' in data:
n.link = data.get('link') or data.get('linkurl')
if 'isactive' in data:
n.isactive = data['isactive']
if 'isshopfloor' in data:
n.isshopfloor = data['isshopfloor']
if 'employeesso' in data:
n.employeesso = data['employeesso']
if 'employeename' in data:
n.employeename = data['employeename']
# Parse and update dates
if 'starttime' in data or 'startdate' in data:
date_str = data.get('starttime') or data.get('startdate')
if date_str:
try:
n.starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
except ValueError:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')
else:
n.starttime = datetime.utcnow()
if 'endtime' in data or 'enddate' in data:
date_str = data.get('endtime') or data.get('enddate')
if date_str:
try:
n.endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
except ValueError:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')
else:
n.endtime = None
db.session.commit()
return success_response(n.to_dict(), message='Notification updated')
@notifications_bp.route('/<int:notification_id>', methods=['DELETE'])
@jwt_required()
def delete_notification(notification_id: int):
"""Delete (soft delete) a notification."""
n = Notification.query.get(notification_id)
if not n:
return error_response(
ErrorCodes.NOT_FOUND,
f'Notification with ID {notification_id} not found',
http_code=404
)
n.isactive = False
db.session.commit()
return success_response(message='Notification deleted')
# =============================================================================
# Special Endpoints
# =============================================================================
@notifications_bp.route('/active', methods=['GET'])
def get_active_notifications():
"""
Get currently active notifications for display.
"""
now = datetime.utcnow()
notifications = Notification.query.filter(
Notification.isactive == True,
db.or_(
Notification.starttime.is_(None),
Notification.starttime <= now
),
db.or_(
Notification.endtime.is_(None),
Notification.endtime >= now
)
).order_by(Notification.starttime.desc()).all()
data = [n.to_dict() for n in notifications]
return success_response({
'notifications': data,
'total': len(data)
})
@notifications_bp.route('/calendar', methods=['GET'])
def get_calendar_events():
"""
Get notifications in FullCalendar event format.
Query parameters:
- start: Start date (ISO format)
- end: End date (ISO format)
"""
query = Notification.query.filter(Notification.isactive == True)
# Date range filter
if start := request.args.get('start'):
try:
start_date = datetime.fromisoformat(start.replace('Z', '+00:00'))
query = query.filter(
db.or_(
Notification.endtime >= start_date,
Notification.endtime.is_(None)
)
)
except ValueError:
pass
if end := request.args.get('end'):
try:
end_date = datetime.fromisoformat(end.replace('Z', '+00:00'))
query = query.filter(Notification.starttime <= end_date)
except ValueError:
pass
notifications = query.order_by(Notification.starttime).all()
events = [n.to_calendar_event() for n in notifications]
return success_response(events)
@notifications_bp.route('/dashboard/summary', methods=['GET'])
def dashboard_summary():
"""Get notifications dashboard summary."""
now = datetime.utcnow()
# Total active notifications
total_active = Notification.query.filter(
Notification.isactive == True,
db.or_(
Notification.starttime.is_(None),
Notification.starttime <= now
),
db.or_(
Notification.endtime.is_(None),
Notification.endtime >= now
)
).count()
# By type
by_type = db.session.query(
NotificationType.typename,
NotificationType.typecolor,
db.func.count(Notification.notificationid)
).join(Notification
).filter(
Notification.isactive == True
).group_by(NotificationType.typename, NotificationType.typecolor
).all()
return success_response({
'active': total_active,
'by_type': [{'type': t, 'color': c, 'count': n} for t, c, n in by_type]
})
@notifications_bp.route('/employee/<sso>', methods=['GET'])
def get_employee_recognitions(sso):
"""
Get recognitions for a specific employee by SSO.
Returns all recognition-type notifications where the employee is mentioned.
"""
if not sso or not sso.isdigit():
return error_response(ErrorCodes.VALIDATION_ERROR, 'Valid SSO required')
# Find recognition type(s)
recognition_types = NotificationType.query.filter(
db.or_(
NotificationType.typecolor == 'recognition',
NotificationType.typename.ilike('%recognition%')
)
).all()
recognition_type_ids = [rt.notificationtypeid for rt in recognition_types]
# Find notifications where this employee is mentioned
# Check both exact match and comma-separated list
query = Notification.query.filter(
Notification.isactive == True,
db.or_(
Notification.employeesso == sso,
Notification.employeesso.like(f'{sso},%'),
Notification.employeesso.like(f'%,{sso}'),
Notification.employeesso.like(f'%,{sso},%')
)
)
# Optionally filter to recognition types only
if recognition_type_ids:
query = query.filter(Notification.notificationtypeid.in_(recognition_type_ids))
query = query.order_by(Notification.starttime.desc())
notifications = query.all()
data = [n.to_dict() for n in notifications]
return success_response({
'recognitions': data,
'total': len(data)
})
@notifications_bp.route('/shopfloor', methods=['GET'])
def get_shopfloor_notifications():
"""
Get notifications for shopfloor TV dashboard.
Returns current and upcoming notifications with isshopfloor=1.
Splits multi-employee recognition into separate entries.
Query parameters:
- businessunit: Filter by business unit ID (null = all units)
"""
from datetime import timedelta
now = datetime.utcnow()
business_unit = request.args.get('businessunit')
# Base query for shopfloor notifications
base_query = Notification.query.filter(Notification.isshopfloor == True)
# Business unit filter
if business_unit and business_unit.isdigit():
# Specific BU: show that BU's notifications AND null (all units)
base_query = base_query.filter(
db.or_(
Notification.businessunitid == int(business_unit),
Notification.businessunitid.is_(None)
)
)
else:
# All units: only show notifications with NULL businessunitid
base_query = base_query.filter(Notification.businessunitid.is_(None))
# Current notifications (active now or ended within 30 minutes)
thirty_min_ago = now - timedelta(minutes=30)
current_query = base_query.filter(
db.or_(
# Active and currently showing
db.and_(
Notification.isactive == True,
db.or_(Notification.starttime.is_(None), Notification.starttime <= now),
db.or_(Notification.endtime.is_(None), Notification.endtime >= now)
),
# Recently ended (within 30 min) - show as resolved
db.and_(
Notification.endtime.isnot(None),
Notification.endtime >= thirty_min_ago,
Notification.endtime < now
)
)
).order_by(Notification.notificationid.desc())
current_notifications = current_query.all()
# Upcoming notifications (starts within next 5 days)
five_days = now + timedelta(days=5)
upcoming_query = base_query.filter(
Notification.isactive == True,
Notification.starttime > now,
Notification.starttime <= five_days
).order_by(Notification.starttime)
upcoming_notifications = upcoming_query.all()
def notification_to_shopfloor(n, employee_override=None):
"""Convert notification to shopfloor format."""
is_resolved = n.endtime and n.endtime < now
result = {
'notificationid': n.notificationid,
'notification': n.notification,
'starttime': n.starttime.isoformat() if n.starttime else None,
'endtime': n.endtime.isoformat() if n.endtime else None,
'ticketnumber': n.ticketnumber,
'link': n.link,
'isactive': n.isactive,
'isshopfloor': True,
'resolved': is_resolved,
'typename': n.notificationtype.typename if n.notificationtype else None,
'typecolor': n.notificationtype.typecolor if n.notificationtype else None,
}
# Employee info
if employee_override:
result['employeesso'] = employee_override.get('sso')
result['employeename'] = employee_override.get('name')
result['employeepicture'] = employee_override.get('picture')
else:
result['employeesso'] = n.employeesso
result['employeename'] = n.employeename
result['employeepicture'] = None
# Try to get picture from wjf_employees
if n.employeesso and n.employeesso.isdigit():
try:
import pymysql
conn = pymysql.connect(
host='localhost', user='root', password='rootpassword',
database='wjf_employees', cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(n.employeesso),))
emp = cur.fetchone()
if emp and emp.get('Picture'):
result['employeepicture'] = emp['Picture']
conn.close()
except Exception:
pass
return result
# Process current notifications (split multi-employee recognition)
current_data = []
for n in current_notifications:
is_recognition = n.notificationtype and n.notificationtype.typecolor == 'recognition'
if is_recognition and n.employeesso and ',' in n.employeesso:
# Split into individual cards for each employee
ssos = [s.strip() for s in n.employeesso.split(',')]
names = n.employeename.split(', ') if n.employeename else []
for i, sso in enumerate(ssos):
name = names[i] if i < len(names) else sso
# Look up picture
picture = None
if sso.isdigit():
try:
import pymysql
conn = pymysql.connect(
host='localhost', user='root', password='rootpassword',
database='wjf_employees', cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
emp = cur.fetchone()
if emp:
picture = emp.get('Picture')
conn.close()
except Exception:
pass
current_data.append(notification_to_shopfloor(n, {
'sso': sso,
'name': name,
'picture': picture
}))
else:
current_data.append(notification_to_shopfloor(n))
# Process upcoming notifications
upcoming_data = []
for n in upcoming_notifications:
is_recognition = n.notificationtype and n.notificationtype.typecolor == 'recognition'
if is_recognition and n.employeesso and ',' in n.employeesso:
ssos = [s.strip() for s in n.employeesso.split(',')]
names = n.employeename.split(', ') if n.employeename else []
for i, sso in enumerate(ssos):
name = names[i] if i < len(names) else sso
picture = None
if sso.isdigit():
try:
import pymysql
conn = pymysql.connect(
host='localhost', user='root', password='rootpassword',
database='wjf_employees', cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
emp = cur.fetchone()
if emp:
picture = emp.get('Picture')
conn.close()
except Exception:
pass
upcoming_data.append(notification_to_shopfloor(n, {
'sso': sso,
'name': name,
'picture': picture
}))
else:
upcoming_data.append(notification_to_shopfloor(n))
return success_response({
'timestamp': now.isoformat(),
'current': current_data,
'upcoming': upcoming_data
})

View File

@@ -0,0 +1,12 @@
{
"name": "notifications",
"version": "1.0.0",
"description": "Notifications and announcements management plugin",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=1.0.0",
"api_prefix": "/api/notifications",
"provides": {
"features": ["notifications", "announcements", "calendar_events"]
}
}

View File

@@ -0,0 +1,5 @@
"""Notifications plugin models."""
from .notification import Notification, NotificationType
__all__ = ['Notification', 'NotificationType']

View File

@@ -0,0 +1,157 @@
"""Notifications plugin models - adapted to existing database schema."""
from datetime import datetime
from shopdb.extensions import db
class NotificationType(db.Model):
"""
Notification type classification.
Matches existing notificationtypes table.
"""
__tablename__ = 'notificationtypes'
notificationtypeid = db.Column(db.Integer, primary_key=True)
typename = db.Column(db.String(50), nullable=False)
typedescription = db.Column(db.Text)
typecolor = db.Column(db.String(20), default='#17a2b8')
isactive = db.Column(db.Boolean, default=True)
def __repr__(self):
return f"<NotificationType {self.typename}>"
def to_dict(self):
return {
'notificationtypeid': self.notificationtypeid,
'typename': self.typename,
'typedescription': self.typedescription,
'typecolor': self.typecolor,
'isactive': self.isactive
}
class Notification(db.Model):
"""
Notification/announcement model.
Matches existing notifications table schema.
"""
__tablename__ = 'notifications'
notificationid = db.Column(db.Integer, primary_key=True)
notificationtypeid = db.Column(
db.Integer,
db.ForeignKey('notificationtypes.notificationtypeid'),
nullable=True
)
businessunitid = db.Column(db.Integer, nullable=True)
appid = db.Column(db.Integer, nullable=True)
notification = db.Column(db.Text, nullable=False, comment='The message content')
starttime = db.Column(db.DateTime, nullable=True)
endtime = db.Column(db.DateTime, nullable=True)
ticketnumber = db.Column(db.String(50), nullable=True)
link = db.Column(db.String(500), nullable=True)
isactive = db.Column(db.Boolean, default=True)
isshopfloor = db.Column(db.Boolean, default=False)
employeesso = db.Column(db.String(100), nullable=True)
employeename = db.Column(db.String(100), nullable=True)
# Relationships
notificationtype = db.relationship('NotificationType', backref='notifications')
def __repr__(self):
return f"<Notification {self.notificationid}>"
@property
def is_current(self):
"""Check if notification is currently active based on dates."""
now = datetime.utcnow()
if not self.isactive:
return False
if self.starttime and now < self.starttime:
return False
if self.endtime and now > self.endtime:
return False
return True
@property
def title(self):
"""Get title - first line or first 100 chars of notification."""
if not self.notification:
return ''
lines = self.notification.split('\n')
return lines[0][:100] if lines else self.notification[:100]
def to_dict(self):
"""Convert to dictionary with related data."""
result = {
'notificationid': self.notificationid,
'notificationtypeid': self.notificationtypeid,
'businessunitid': self.businessunitid,
'appid': self.appid,
'notification': self.notification,
'title': self.title,
'message': self.notification,
'starttime': self.starttime.isoformat() if self.starttime else None,
'endtime': self.endtime.isoformat() if self.endtime else None,
'startdate': self.starttime.isoformat() if self.starttime else None,
'enddate': self.endtime.isoformat() if self.endtime else None,
'ticketnumber': self.ticketnumber,
'link': self.link,
'linkurl': self.link,
'isactive': bool(self.isactive) if self.isactive is not None else True,
'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False,
'employeesso': self.employeesso,
'employeename': self.employeename,
'iscurrent': self.is_current
}
# Add type info
if self.notificationtype:
result['typename'] = self.notificationtype.typename
result['typecolor'] = self.notificationtype.typecolor
return result
def to_calendar_event(self):
"""Convert to FullCalendar event format."""
# Map Bootstrap color names to hex colors
color_map = {
'success': '#04b962',
'warning': '#ff8800',
'danger': '#f5365c',
'info': '#14abef',
'primary': '#7934f3',
'secondary': '#94614f',
'recognition': '#14abef', # Blue for recognition
}
raw_color = self.notificationtype.typecolor if self.notificationtype else 'info'
# Use mapped color if it's a Bootstrap name, otherwise use as-is (hex)
color = color_map.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef')
# For recognition notifications, include employee name (or SSO as fallback) in title
title = self.title
if raw_color == 'recognition':
employee_display = self.employeename or self.employeesso
if employee_display:
title = f"{employee_display}: {title}"
return {
'id': self.notificationid,
'title': title,
'start': self.starttime.isoformat() if self.starttime else None,
'end': self.endtime.isoformat() if self.endtime else None,
'allDay': True,
'backgroundColor': color,
'borderColor': color,
'extendedProps': {
'notificationid': self.notificationid,
'message': self.notification,
'typename': self.notificationtype.typename if self.notificationtype else None,
'typecolor': raw_color,
'linkurl': self.link,
'ticketnumber': self.ticketnumber,
'employeename': self.employeename,
'employeesso': self.employeesso,
}
}

View File

@@ -0,0 +1,204 @@
"""Notifications plugin main class."""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db
from .models import Notification, NotificationType
from .api import notifications_bp
logger = logging.getLogger(__name__)
class NotificationsPlugin(BasePlugin):
"""
Notifications plugin - manages announcements and notifications.
Provides functionality for:
- Creating and managing notifications/announcements
- Displaying banner notifications
- Calendar view of notifications
"""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file."""
manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists():
with open(manifest_path, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'notifications'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get(
'description',
'Notifications and announcements management'
),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/notifications'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return notifications_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [Notification, NotificationType]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"Notifications plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed."""
with app.app_context():
self._ensure_notification_types()
logger.info("Notifications plugin installed")
def _ensure_notification_types(self) -> None:
"""Ensure default notification types exist."""
default_types = [
('Awareness', 'General awareness notification', '#17a2b8', 'info-circle'),
('Change', 'Planned change notification', '#ffc107', 'exchange-alt'),
('Incident', 'Incident or outage notification', '#dc3545', 'exclamation-triangle'),
('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'wrench'),
('General', 'General announcement', '#28a745', 'bullhorn'),
]
for typename, description, color, icon in default_types:
existing = NotificationType.query.filter_by(typename=typename).first()
if not existing:
t = NotificationType(
typename=typename,
description=description,
color=color,
icon=icon
)
db.session.add(t)
logger.debug(f"Created notification type: {typename}")
db.session.commit()
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled."""
logger.info("Notifications plugin uninstalled")
def get_cli_commands(self) -> List:
"""Return CLI commands for this plugin."""
@click.group('notifications')
def notifications_cli():
"""Notifications plugin commands."""
pass
@notifications_cli.command('list-types')
def list_types():
"""List all notification types."""
from flask import current_app
with current_app.app_context():
types = NotificationType.query.filter_by(isactive=True).all()
if not types:
click.echo('No notification types found.')
return
click.echo('Notification Types:')
for t in types:
click.echo(f" [{t.notificationtypeid}] {t.typename} ({t.color})")
@notifications_cli.command('stats')
def stats():
"""Show notification statistics."""
from flask import current_app
from datetime import datetime
with current_app.app_context():
now = datetime.utcnow()
total = Notification.query.filter(
Notification.isactive == True
).count()
active = Notification.query.filter(
Notification.isactive == True,
Notification.startdate <= now,
db.or_(
Notification.enddate.is_(None),
Notification.enddate >= now
)
).count()
click.echo(f"Total notifications: {total}")
click.echo(f"Currently active: {active}")
@notifications_cli.command('create')
@click.option('--title', required=True, help='Notification title')
@click.option('--message', required=True, help='Notification message')
@click.option('--type', 'type_name', default='General', help='Notification type')
def create_notification(title, message, type_name):
"""Create a new notification."""
from flask import current_app
with current_app.app_context():
ntype = NotificationType.query.filter_by(typename=type_name).first()
if not ntype:
click.echo(f"Error: Notification type '{type_name}' not found.")
return
n = Notification(
title=title,
message=message,
notificationtypeid=ntype.notificationtypeid
)
db.session.add(n)
db.session.commit()
click.echo(f"Created notification #{n.notificationid}: {title}")
return [notifications_cli]
def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions."""
return [
{
'name': 'Active Notifications',
'component': 'NotificationsWidget',
'endpoint': '/api/notifications/dashboard/summary',
'size': 'small',
'position': 1,
},
]
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""
return [
{
'name': 'Notifications',
'icon': 'bell',
'route': '/notifications',
'position': 5,
},
{
'name': 'Calendar',
'icon': 'calendar',
'route': '/calendar',
'position': 6,
},
]

View File

@@ -1,5 +1,9 @@
"""Printers plugin API.""" """Printers plugin API."""
from .routes import printers_bp from .routes import printers_bp # Legacy Machine-based API
from .asset_routes import printers_asset_bp # New Asset-based API
__all__ = ['printers_bp'] __all__ = [
'printers_bp', # Legacy
'printers_asset_bp', # New
]

View File

@@ -0,0 +1,472 @@
"""Printers API routes - new Asset-based architecture."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, Vendor, Model, Communication, CommunicationType
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import Printer, PrinterType
from ..services import ZabbixService
printers_asset_bp = Blueprint('printers_asset', __name__)
# =============================================================================
# Printer Types
# =============================================================================
@printers_asset_bp.route('/types', methods=['GET'])
@jwt_required()
def list_printer_types():
"""List all printer types."""
page, per_page = get_pagination_params(request)
query = PrinterType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(PrinterType.isactive == True)
if search := request.args.get('search'):
query = query.filter(PrinterType.printertype.ilike(f'%{search}%'))
query = query.order_by(PrinterType.printertype)
items, total = paginate_query(query, page, per_page)
data = [t.to_dict() for t in items]
return paginated_response(data, page, per_page, total)
@printers_asset_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required()
def get_printer_type(type_id: int):
"""Get a single printer type."""
t = PrinterType.query.get(type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Printer type with ID {type_id} not found',
http_code=404
)
return success_response(t.to_dict())
@printers_asset_bp.route('/types', methods=['POST'])
@jwt_required()
def create_printer_type():
"""Create a new printer type."""
data = request.get_json()
if not data or not data.get('printertype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'printertype is required')
if PrinterType.query.filter_by(printertype=data['printertype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Printer type '{data['printertype']}' already exists",
http_code=409
)
t = PrinterType(
printertype=data['printertype'],
description=data.get('description'),
icon=data.get('icon')
)
db.session.add(t)
db.session.commit()
return success_response(t.to_dict(), message='Printer type created', http_code=201)
# =============================================================================
# Printers CRUD
# =============================================================================
@printers_asset_bp.route('', methods=['GET'])
@jwt_required()
def list_printers():
"""
List all printers with filtering and pagination.
Query parameters:
- page, per_page: Pagination
- active: Filter by active status
- search: Search by asset number, name, or hostname
- type_id: Filter by printer type ID
- vendor_id: Filter by vendor ID
- location_id: Filter by location ID
- businessunit_id: Filter by business unit ID
"""
page, per_page = get_pagination_params(request)
# Join Printer with Asset
query = db.session.query(Printer).join(Asset)
# Active filter
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Asset.isactive == True)
# Search filter
if search := request.args.get('search'):
query = query.filter(
db.or_(
Asset.assetnumber.ilike(f'%{search}%'),
Asset.name.ilike(f'%{search}%'),
Asset.serialnumber.ilike(f'%{search}%'),
Printer.hostname.ilike(f'%{search}%'),
Printer.windowsname.ilike(f'%{search}%')
)
)
# Type filter
if type_id := request.args.get('type_id'):
query = query.filter(Printer.printertypeid == int(type_id))
# Vendor filter
if vendor_id := request.args.get('vendor_id'):
query = query.filter(Printer.vendorid == int(vendor_id))
# Location filter
if location_id := request.args.get('location_id'):
query = query.filter(Asset.locationid == int(location_id))
# Business unit filter
if bu_id := request.args.get('businessunit_id'):
query = query.filter(Asset.businessunitid == int(bu_id))
# Sorting
sort_by = request.args.get('sort', 'hostname')
sort_dir = request.args.get('dir', 'asc')
if sort_by == 'hostname':
col = Printer.hostname
elif sort_by == 'assetnumber':
col = Asset.assetnumber
elif sort_by == 'name':
col = Asset.name
else:
col = Printer.hostname
query = query.order_by(col.desc() if sort_dir == 'desc' else col)
items, total = paginate_query(query, page, per_page)
# Build response with both asset and printer data
data = []
for printer in items:
item = printer.asset.to_dict() if printer.asset else {}
item['printer'] = printer.to_dict()
# Add primary IP address
if printer.asset:
primary_comm = Communication.query.filter_by(
assetid=printer.asset.assetid,
isprimary=True
).first()
if not primary_comm:
primary_comm = Communication.query.filter_by(
assetid=printer.asset.assetid
).first()
item['ipaddress'] = primary_comm.ipaddress if primary_comm else None
data.append(item)
return paginated_response(data, page, per_page, total)
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
@jwt_required()
def get_printer(printer_id: int):
"""Get a single printer with full details."""
printer = Printer.query.get(printer_id)
if not printer:
return error_response(
ErrorCodes.NOT_FOUND,
f'Printer with ID {printer_id} not found',
http_code=404
)
result = printer.asset.to_dict() if printer.asset else {}
result['printer'] = printer.to_dict()
# Add communications
if printer.asset:
comms = Communication.query.filter_by(assetid=printer.asset.assetid).all()
result['communications'] = [c.to_dict() for c in comms]
return success_response(result)
@printers_asset_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required()
def get_printer_by_asset(asset_id: int):
"""Get printer data by asset ID."""
printer = Printer.query.filter_by(assetid=asset_id).first()
if not printer:
return error_response(
ErrorCodes.NOT_FOUND,
f'Printer for asset {asset_id} not found',
http_code=404
)
result = printer.asset.to_dict() if printer.asset else {}
result['printer'] = printer.to_dict()
return success_response(result)
@printers_asset_bp.route('', methods=['POST'])
@jwt_required()
def create_printer():
"""
Create new printer (creates both Asset and Printer records).
Required fields:
- assetnumber: Business identifier
Optional fields:
- name, serialnumber, statusid, locationid, businessunitid
- printertypeid, vendorid, modelnumberid, hostname
- windowsname, sharename, iscsf, installpath, pin
- iscolor, isduplex, isnetwork
- mapleft, maptop, notes
"""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if not data.get('assetnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
# Check for duplicate assetnumber
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Get printer asset type
printer_type = AssetType.query.filter_by(assettype='printer').first()
if not printer_type:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Printer asset type not found. Plugin may not be properly installed.',
http_code=500
)
# Create the core asset
asset = Asset(
assetnumber=data['assetnumber'],
name=data.get('name'),
serialnumber=data.get('serialnumber'),
assettypeid=printer_type.assettypeid,
statusid=data.get('statusid', 1),
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapleft=data.get('mapleft'),
maptop=data.get('maptop'),
notes=data.get('notes')
)
db.session.add(asset)
db.session.flush() # Get the assetid
# Create the printer extension
printer = Printer(
assetid=asset.assetid,
printertypeid=data.get('printertypeid'),
vendorid=data.get('vendorid'),
modelnumberid=data.get('modelnumberid'),
hostname=data.get('hostname'),
windowsname=data.get('windowsname'),
sharename=data.get('sharename'),
iscsf=data.get('iscsf', False),
installpath=data.get('installpath'),
pin=data.get('pin'),
iscolor=data.get('iscolor', False),
isduplex=data.get('isduplex', False),
isnetwork=data.get('isnetwork', True)
)
db.session.add(printer)
# Create communication record if IP provided
if data.get('ipaddress'):
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
if ip_comtype:
comm = Communication(
assetid=asset.assetid,
comtypeid=ip_comtype.comtypeid,
ipaddress=data['ipaddress'],
isprimary=True
)
db.session.add(comm)
db.session.commit()
result = asset.to_dict()
result['printer'] = printer.to_dict()
return success_response(result, message='Printer created', http_code=201)
@printers_asset_bp.route('/<int:printer_id>', methods=['PUT'])
@jwt_required()
def update_printer(printer_id: int):
"""Update printer (both Asset and Printer records)."""
printer = Printer.query.get(printer_id)
if not printer:
return error_response(
ErrorCodes.NOT_FOUND,
f'Printer with ID {printer_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
asset = printer.asset
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
for key in asset_fields:
if key in data:
setattr(asset, key, data[key])
# Update printer fields
printer_fields = ['printertypeid', 'vendorid', 'modelnumberid', 'hostname',
'windowsname', 'sharename', 'iscsf', 'installpath', 'pin',
'iscolor', 'isduplex', 'isnetwork']
for key in printer_fields:
if key in data:
setattr(printer, key, data[key])
db.session.commit()
result = asset.to_dict()
result['printer'] = printer.to_dict()
return success_response(result, message='Printer updated')
@printers_asset_bp.route('/<int:printer_id>', methods=['DELETE'])
@jwt_required()
def delete_printer(printer_id: int):
"""Delete (soft delete) printer."""
printer = Printer.query.get(printer_id)
if not printer:
return error_response(
ErrorCodes.NOT_FOUND,
f'Printer with ID {printer_id} not found',
http_code=404
)
# Soft delete the asset
printer.asset.isactive = False
db.session.commit()
return success_response(message='Printer deleted')
# =============================================================================
# Supply Levels (Zabbix Integration)
# =============================================================================
@printers_asset_bp.route('/<int:printer_id>/supplies', methods=['GET'])
@jwt_required(optional=True)
def get_printer_supplies(printer_id: int):
"""Get supply levels from Zabbix (real-time lookup)."""
printer = Printer.query.get(printer_id)
if not printer:
return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404)
# Get IP address from communications
comm = Communication.query.filter_by(
assetid=printer.assetid,
isprimary=True
).first()
if not comm:
comm = Communication.query.filter_by(assetid=printer.assetid).first()
if not comm or not comm.ipaddress:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address')
service = ZabbixService()
if not service.isconfigured:
return error_response(ErrorCodes.BAD_REQUEST, 'Zabbix not configured')
supplies = service.getsuppliesbyip(comm.ipaddress)
return success_response({
'ipaddress': comm.ipaddress,
'supplies': supplies or []
})
# =============================================================================
# Dashboard
# =============================================================================
@printers_asset_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required()
def dashboard_summary():
"""Get printer dashboard summary data."""
# Total active printers
total = db.session.query(Printer).join(Asset).filter(
Asset.isactive == True
).count()
# Count by printer type
by_type = db.session.query(
PrinterType.printertype,
db.func.count(Printer.printerid)
).join(Printer, Printer.printertypeid == PrinterType.printertypeid
).join(Asset, Asset.assetid == Printer.assetid
).filter(Asset.isactive == True
).group_by(PrinterType.printertype
).all()
# Count by vendor
by_vendor = db.session.query(
Vendor.vendor,
db.func.count(Printer.printerid)
).join(Printer, Printer.vendorid == Vendor.vendorid
).join(Asset, Asset.assetid == Printer.assetid
).filter(Asset.isactive == True
).group_by(Vendor.vendor
).all()
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],
})

View File

@@ -1,7 +1,10 @@
"""Printers plugin models.""" """Printers plugin models."""
from .printer_extension import PrinterData from .printer_extension import PrinterData # Legacy model for Machine-based architecture
from .printer import Printer, PrinterType # New Asset-based models
__all__ = [ __all__ = [
'PrinterData', 'PrinterData', # Legacy
'Printer', # New
'PrinterType', # New
] ]

View File

@@ -0,0 +1,122 @@
"""Printer plugin models - new Asset-based architecture."""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
class PrinterType(BaseModel):
"""
Printer type classification.
Examples: Laser, Inkjet, Label, MFP, Plotter, etc.
"""
__tablename__ = 'printertypes'
printertypeid = db.Column(db.Integer, primary_key=True)
printertype = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
return f"<PrinterType {self.printertype}>"
class Printer(BaseModel):
"""
Printer-specific extension data (new Asset architecture).
Links to core Asset table via assetid.
Stores printer-specific fields like type, Windows name, share name, etc.
"""
__tablename__ = 'printers'
printerid = db.Column(db.Integer, primary_key=True)
# Link to core asset
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
unique=True,
nullable=False,
index=True
)
# Printer classification
printertypeid = db.Column(
db.Integer,
db.ForeignKey('printertypes.printertypeid'),
nullable=True
)
# Vendor
vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),
nullable=True
)
modelnumberid = db.Column(
db.Integer,
db.ForeignKey('models.modelnumberid'),
nullable=True
)
# Network identity
hostname = db.Column(
db.String(100),
index=True,
comment='Network hostname'
)
# Windows/Network naming
windowsname = db.Column(
db.String(255),
comment='Windows printer name (e.g., \\\\server\\printer)'
)
sharename = db.Column(
db.String(100),
comment='CSF/share name'
)
# Installation
iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer')
installpath = db.Column(db.String(255), comment='Driver install path')
# Printer PIN (for secure print)
pin = db.Column(db.String(20))
# Features
iscolor = db.Column(db.Boolean, default=False, comment='Color capable')
isduplex = db.Column(db.Boolean, default=False, comment='Duplex capable')
isnetwork = db.Column(db.Boolean, default=True, comment='Network connected')
# Relationships
asset = db.relationship(
'Asset',
backref=db.backref('printer', uselist=False, lazy='joined')
)
printertype = db.relationship('PrinterType', backref='printers')
vendor = db.relationship('Vendor', backref='printer_items')
model = db.relationship('Model', backref='printer_items')
__table_args__ = (
db.Index('idx_printer_type', 'printertypeid'),
db.Index('idx_printer_hostname', 'hostname'),
db.Index('idx_printer_windowsname', 'windowsname'),
)
def __repr__(self):
return f"<Printer {self.hostname or self.assetid}>"
def to_dict(self):
"""Convert to dictionary with related names."""
result = super().to_dict()
# Add related object names
if self.printertype:
result['printertype_name'] = self.printertype.printertype
if self.vendor:
result['vendor_name'] = self.vendor.vendor
if self.model:
result['model_name'] = self.model.modelnumber
return result

View File

@@ -11,9 +11,10 @@ import click
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models.machine import MachineType from shopdb.core.models.machine import MachineType
from shopdb.core.models import AssetType
from .models import PrinterData from .models import PrinterData, Printer, PrinterType
from .api import printers_bp from .api import printers_bp, printers_asset_bp
from .services import ZabbixService from .services import ZabbixService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,11 +22,15 @@ logger = logging.getLogger(__name__)
class PrintersPlugin(BasePlugin): class PrintersPlugin(BasePlugin):
""" """
Printers plugin - extends machines with printer-specific functionality. Printers plugin - manages printer assets.
Printers use the unified Machine model with machinetype.category = 'Printer'. Supports both legacy Machine-based architecture and new Asset-based architecture:
This plugin adds: - Legacy: PrinterData table linked to machines
- PrinterData table for printer-specific fields (windowsname, sharename, etc.) - New: Printer table linked to assets
Features:
- PrinterType classification
- Windows/network naming
- Zabbix integration for real-time supply level lookups - Zabbix integration for real-time supply level lookups
""" """
@@ -46,7 +51,7 @@ class PrintersPlugin(BasePlugin):
"""Return plugin metadata.""" """Return plugin metadata."""
return PluginMeta( return PluginMeta(
name=self._manifest.get('name', 'printers'), name=self._manifest.get('name', 'printers'),
version=self._manifest.get('version', '1.0.0'), version=self._manifest.get('version', '2.0.0'),
description=self._manifest.get( description=self._manifest.get(
'description', 'description',
'Printer management with Zabbix integration' 'Printer management with Zabbix integration'
@@ -58,12 +63,21 @@ class PrintersPlugin(BasePlugin):
) )
def get_blueprint(self) -> Optional[Blueprint]: def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes.""" """
return printers_bp Return Flask Blueprint with API routes.
Returns the new Asset-based blueprint.
Legacy Machine-based blueprint is registered separately in init_app.
"""
return printers_asset_bp
def get_models(self) -> List[Type]: def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes.""" """Return list of SQLAlchemy model classes."""
return [PrinterData] return [
PrinterData, # Legacy Machine-based
Printer, # New Asset-based
PrinterType, # New printer type classification
]
def get_services(self) -> Dict[str, Type]: def get_services(self) -> Dict[str, Type]:
"""Return plugin services.""" """Return plugin services."""
@@ -82,16 +96,63 @@ class PrintersPlugin(BasePlugin):
"""Initialize plugin with Flask app.""" """Initialize plugin with Flask app."""
app.config.setdefault('ZABBIX_URL', '') app.config.setdefault('ZABBIX_URL', '')
app.config.setdefault('ZABBIX_TOKEN', '') app.config.setdefault('ZABBIX_TOKEN', '')
# Register legacy blueprint for backward compatibility
app.register_blueprint(printers_bp, url_prefix='/api/printers/legacy')
logger.info(f"Printers plugin initialized (v{self.meta.version})") logger.info(f"Printers plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None: def on_install(self, app: Flask) -> None:
"""Called when plugin is installed.""" """Called when plugin is installed."""
with app.app_context(): with app.app_context():
self._ensureprintertypes() self._ensure_asset_type()
self._ensure_printer_types()
self._ensure_legacy_machine_types()
logger.info("Printers plugin installed") logger.info("Printers plugin installed")
def _ensureprintertypes(self) -> None: def _ensure_asset_type(self) -> None:
"""Ensure basic printer machine types exist.""" """Ensure printer asset type exists."""
existing = AssetType.query.filter_by(assettype='printer').first()
if not existing:
at = AssetType(
assettype='printer',
plugin_name='printers',
table_name='printers',
description='Printers (laser, inkjet, label, MFP, plotter)',
icon='printer'
)
db.session.add(at)
logger.debug("Created asset type: printer")
db.session.commit()
def _ensure_printer_types(self) -> None:
"""Ensure basic printer types exist (new architecture)."""
printer_types = [
('Laser', 'Standard laser printer', 'printer'),
('Inkjet', 'Inkjet printer', 'printer'),
('Label', 'Label/barcode printer', 'barcode'),
('MFP', 'Multifunction printer with scan/copy/fax', 'printer'),
('Plotter', 'Large format plotter', 'drafting-compass'),
('Thermal', 'Thermal printer', 'temperature-high'),
('Dot Matrix', 'Dot matrix printer', 'th'),
('Other', 'Other printer type', 'printer'),
]
for name, description, icon in printer_types:
existing = PrinterType.query.filter_by(printertype=name).first()
if not existing:
pt = PrinterType(
printertype=name,
description=description,
icon=icon
)
db.session.add(pt)
logger.debug(f"Created printer type: {name}")
db.session.commit()
def _ensure_legacy_machine_types(self) -> None:
"""Ensure basic printer machine types exist (legacy architecture)."""
printertypes = [ printertypes = [
('Laser Printer', 'Printer', 'Standard laser printer'), ('Laser Printer', 'Printer', 'Standard laser printer'),
('Inkjet Printer', 'Printer', 'Inkjet printer'), ('Inkjet Printer', 'Printer', 'Inkjet printer'),

5
plugins/usb/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""USB device checkout plugin."""
from .plugin import USBPlugin
__all__ = ['USBPlugin']

View File

@@ -0,0 +1,5 @@
"""USB plugin API."""
from .routes import usb_bp
__all__ = ['usb_bp']

275
plugins/usb/api/routes.py Normal file
View File

@@ -0,0 +1,275 @@
"""USB plugin API endpoints."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from datetime import datetime
from shopdb.extensions import db
from shopdb.core.models import Machine, MachineType, Vendor, Model
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import USBCheckout
usb_bp = Blueprint('usb', __name__)
def get_usb_machinetype_id():
"""Get the USB Device machine type ID dynamically."""
usb_type = MachineType.query.filter(
MachineType.machinetype.ilike('%usb%')
).first()
return usb_type.machinetypeid if usb_type else None
@usb_bp.route('', methods=['GET'])
@jwt_required()
def list_usb_devices():
"""
List all USB devices with checkout status.
Query parameters:
- page, per_page: Pagination
- search: Search by serial number or alias
- available: Filter to only available (not checked out) devices
"""
page, per_page = get_pagination_params(request)
usb_type_id = get_usb_machinetype_id()
if not usb_type_id:
return success_response([]) # No USB type found
# Get USB devices from machines table
query = db.session.query(Machine).filter(
Machine.machinetypeid == usb_type_id,
Machine.isactive == True
)
# Search filter
if search := request.args.get('search'):
query = query.filter(
db.or_(
Machine.serialnumber.ilike(f'%{search}%'),
Machine.alias.ilike(f'%{search}%'),
Machine.machinenumber.ilike(f'%{search}%')
)
)
query = query.order_by(Machine.alias)
items, total = paginate_query(query, page, per_page)
# Build response with checkout status
data = []
for device in items:
# Check if currently checked out
active_checkout = USBCheckout.query.filter_by(
machineid=device.machineid,
checkin_time=None
).first()
item = {
'machineid': device.machineid,
'machinenumber': device.machinenumber,
'alias': device.alias,
'serialnumber': device.serialnumber,
'notes': device.notes,
'vendor_name': device.vendor.vendorname if device.vendor else None,
'model_name': device.model.modelnumber if device.model else None,
'is_checked_out': active_checkout is not None,
'current_checkout': active_checkout.to_dict() if active_checkout else None
}
data.append(item)
# Filter by availability if requested
if request.args.get('available', '').lower() == 'true':
data = [d for d in data if not d['is_checked_out']]
total = len(data)
return paginated_response(data, page, per_page, total)
@usb_bp.route('/<int:device_id>', methods=['GET'])
@jwt_required()
def get_usb_device(device_id: int):
"""Get a single USB device with checkout history."""
device = Machine.query.filter_by(
machineid=device_id,
machinetypeid=get_usb_machinetype_id()
).first()
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device with ID {device_id} not found',
http_code=404
)
# Get checkout history
checkouts = USBCheckout.query.filter_by(
machineid=device_id
).order_by(USBCheckout.checkout_time.desc()).limit(50).all()
# Check current checkout
active_checkout = next((c for c in checkouts if c.checkin_time is None), None)
result = {
'machineid': device.machineid,
'machinenumber': device.machinenumber,
'alias': device.alias,
'serialnumber': device.serialnumber,
'notes': device.notes,
'vendor_name': device.vendor.vendorname if device.vendor else None,
'model_name': device.model.modelnumber if device.model else None,
'is_checked_out': active_checkout is not None,
'current_checkout': active_checkout.to_dict() if active_checkout else None,
'checkout_history': [c.to_dict() for c in checkouts]
}
return success_response(result)
@usb_bp.route('/<int:device_id>/checkout', methods=['POST'])
@jwt_required()
def checkout_device(device_id: int):
"""Check out a USB device."""
device = Machine.query.filter_by(
machineid=device_id,
machinetypeid=get_usb_machinetype_id()
).first()
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device with ID {device_id} not found',
http_code=404
)
# Check if already checked out
active_checkout = USBCheckout.query.filter_by(
machineid=device_id,
checkin_time=None
).first()
if active_checkout:
return error_response(
ErrorCodes.CONFLICT,
f'Device is already checked out by {active_checkout.sso}',
http_code=409
)
data = request.get_json() or {}
if not data.get('sso'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'sso is required')
checkout = USBCheckout(
machineid=device_id,
sso=data['sso'],
checkout_name=data.get('name'),
checkout_reason=data.get('reason'),
checkout_time=datetime.utcnow()
)
db.session.add(checkout)
db.session.commit()
return success_response(checkout.to_dict(), message='Device checked out', http_code=201)
@usb_bp.route('/<int:device_id>/checkin', methods=['POST'])
@jwt_required()
def checkin_device(device_id: int):
"""Check in a USB device."""
device = Machine.query.filter_by(
machineid=device_id,
machinetypeid=get_usb_machinetype_id()
).first()
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device with ID {device_id} not found',
http_code=404
)
# Find active checkout
active_checkout = USBCheckout.query.filter_by(
machineid=device_id,
checkin_time=None
).first()
if not active_checkout:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Device is not currently checked out',
http_code=400
)
data = request.get_json() or {}
active_checkout.checkin_time = datetime.utcnow()
active_checkout.was_wiped = data.get('was_wiped', False)
active_checkout.checkin_notes = data.get('notes')
db.session.commit()
return success_response(active_checkout.to_dict(), message='Device checked in')
@usb_bp.route('/<int:device_id>/history', methods=['GET'])
@jwt_required()
def get_checkout_history(device_id: int):
"""Get checkout history for a USB device."""
page, per_page = get_pagination_params(request)
query = USBCheckout.query.filter_by(
machineid=device_id
).order_by(USBCheckout.checkout_time.desc())
items, total = paginate_query(query, page, per_page)
data = [c.to_dict() for c in items]
return paginated_response(data, page, per_page, total)
@usb_bp.route('/checkouts', methods=['GET'])
@jwt_required()
def list_all_checkouts():
"""List all checkouts (active and historical)."""
page, per_page = get_pagination_params(request)
query = db.session.query(USBCheckout).join(
Machine, USBCheckout.machineid == Machine.machineid
)
# Filter by active only
if request.args.get('active', '').lower() == 'true':
query = query.filter(USBCheckout.checkin_time == None)
# Filter by user
if sso := request.args.get('sso'):
query = query.filter(USBCheckout.sso == sso)
query = query.order_by(USBCheckout.checkout_time.desc())
items, total = paginate_query(query, page, per_page)
# Include device info
data = []
for checkout in items:
device = Machine.query.get(checkout.machineid)
item = checkout.to_dict()
item['device'] = {
'machineid': device.machineid,
'alias': device.alias,
'serialnumber': device.serialnumber
} if device else None
data.append(item)
return paginated_response(data, page, per_page, total)

View File

@@ -0,0 +1,9 @@
{
"name": "usb",
"version": "1.0.0",
"description": "USB device checkout management",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=1.0.0",
"api_prefix": "/api/usb"
}

View File

@@ -0,0 +1,5 @@
"""USB plugin models."""
from .usb_checkout import USBCheckout
__all__ = ['USBCheckout']

View File

@@ -0,0 +1,38 @@
"""USB Checkout model."""
from shopdb.extensions import db
from datetime import datetime
class USBCheckout(db.Model):
"""
USB device checkout tracking.
References machines table (USB devices have machinetypeid=44).
"""
__tablename__ = 'usbcheckouts'
checkoutid = db.Column(db.Integer, primary_key=True)
machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid'), nullable=False, index=True)
sso = db.Column(db.String(20), nullable=False, index=True)
checkout_name = db.Column(db.String(100))
checkout_reason = db.Column(db.Text)
checkout_time = db.Column(db.DateTime, default=datetime.utcnow, index=True)
checkin_time = db.Column(db.DateTime, index=True)
was_wiped = db.Column(db.Boolean, default=False)
checkin_notes = db.Column(db.Text)
def to_dict(self):
"""Convert to dictionary."""
return {
'checkoutid': self.checkoutid,
'machineid': self.machineid,
'sso': self.sso,
'checkout_name': self.checkout_name,
'checkout_reason': self.checkout_reason,
'checkout_time': self.checkout_time.isoformat() if self.checkout_time else None,
'checkin_time': self.checkin_time.isoformat() if self.checkin_time else None,
'was_wiped': self.was_wiped,
'checkin_notes': self.checkin_notes,
'is_checked_out': self.checkin_time is None
}

View File

@@ -0,0 +1,169 @@
"""USB device plugin models."""
from datetime import datetime
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel, AuditMixin
class USBDeviceType(BaseModel):
"""
USB device type classification.
Examples: Flash Drive, External HDD, External SSD, Card Reader
"""
__tablename__ = 'usbdevicetypes'
usbdevicetypeid = db.Column(db.Integer, primary_key=True)
typename = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), default='usb', comment='Icon name for UI')
def __repr__(self):
return f"<USBDeviceType {self.typename}>"
class USBDevice(BaseModel, AuditMixin):
"""
USB device model.
Tracks USB storage devices that can be checked out by users.
"""
__tablename__ = 'usbdevices'
usbdeviceid = db.Column(db.Integer, primary_key=True)
# Identification
serialnumber = db.Column(db.String(100), unique=True, nullable=False)
label = db.Column(db.String(100), nullable=True, comment='Human-readable label')
assetnumber = db.Column(db.String(50), nullable=True, comment='Optional asset tag')
# Classification
usbdevicetypeid = db.Column(
db.Integer,
db.ForeignKey('usbdevicetypes.usbdevicetypeid'),
nullable=True
)
# Specifications
capacitygb = db.Column(db.Integer, nullable=True, comment='Capacity in GB')
vendorid = db.Column(db.String(10), nullable=True, comment='USB Vendor ID (hex)')
productid = db.Column(db.String(10), nullable=True, comment='USB Product ID (hex)')
manufacturer = db.Column(db.String(100), nullable=True)
productname = db.Column(db.String(100), nullable=True)
# Current status
ischeckedout = db.Column(db.Boolean, default=False)
currentuserid = db.Column(db.String(50), nullable=True, comment='SSO of current user')
currentusername = db.Column(db.String(100), nullable=True, comment='Name of current user')
currentcheckoutdate = db.Column(db.DateTime, nullable=True)
# Location
storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out')
# Notes
notes = db.Column(db.Text, nullable=True)
# Relationships
devicetype = db.relationship('USBDeviceType', backref='devices')
# Indexes
__table_args__ = (
db.Index('idx_usb_serial', 'serialnumber'),
db.Index('idx_usb_checkedout', 'ischeckedout'),
db.Index('idx_usb_type', 'usbdevicetypeid'),
db.Index('idx_usb_currentuser', 'currentuserid'),
)
def __repr__(self):
return f"<USBDevice {self.label or self.serialnumber}>"
@property
def display_name(self):
"""Get display name (label if set, otherwise serial number)."""
return self.label or self.serialnumber
def to_dict(self):
"""Convert to dictionary with related data."""
result = super().to_dict()
# Add type info
if self.devicetype:
result['typename'] = self.devicetype.typename
result['typeicon'] = self.devicetype.icon
# Add computed property
result['displayname'] = self.display_name
return result
class USBCheckout(BaseModel):
"""
USB device checkout history.
Tracks when devices are checked out and returned.
"""
__tablename__ = 'usbcheckouts'
usbcheckoutid = db.Column(db.Integer, primary_key=True)
# Device reference
usbdeviceid = db.Column(
db.Integer,
db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'),
nullable=False
)
# User info
userid = db.Column(db.String(50), nullable=False, comment='SSO of user')
username = db.Column(db.String(100), nullable=True, comment='Name of user')
# Checkout details
checkoutdate = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
checkindate = db.Column(db.DateTime, nullable=True)
expectedreturndate = db.Column(db.DateTime, nullable=True)
# Metadata
purpose = db.Column(db.String(500), nullable=True, comment='Reason for checkout')
notes = db.Column(db.Text, nullable=True)
checkedoutby = db.Column(db.String(50), nullable=True, comment='Admin who processed checkout')
checkedinby = db.Column(db.String(50), nullable=True, comment='Admin who processed checkin')
# Relationships
device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))
# Indexes
__table_args__ = (
db.Index('idx_usbcheckout_device', 'usbdeviceid'),
db.Index('idx_usbcheckout_user', 'userid'),
db.Index('idx_usbcheckout_dates', 'checkoutdate', 'checkindate'),
)
def __repr__(self):
return f"<USBCheckout device={self.usbdeviceid} user={self.userid}>"
@property
def is_active(self):
"""Check if this checkout is currently active (not returned)."""
return self.checkindate is None
@property
def duration_days(self):
"""Get duration of checkout in days."""
end = self.checkindate or datetime.utcnow()
delta = end - self.checkoutdate
return delta.days
def to_dict(self):
"""Convert to dictionary with computed fields."""
result = super().to_dict()
result['isactivecheckout'] = self.is_active
result['durationdays'] = self.duration_days
# Add device info if loaded
if self.device:
result['devicelabel'] = self.device.label
result['deviceserialnumber'] = self.device.serialnumber
return result

80
plugins/usb/plugin.py Normal file
View File

@@ -0,0 +1,80 @@
"""USB plugin main class."""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db
from .models import USBCheckout
from .api import usb_bp
logger = logging.getLogger(__name__)
class USBPlugin(BasePlugin):
"""
USB plugin - manages USB device checkouts.
USB devices are stored in the machines table (machinetypeid=44).
This plugin provides checkout/checkin tracking.
"""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file."""
manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists():
with open(manifest_path, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'usb'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get('description', 'USB device checkout management'),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/usb'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return usb_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [USBCheckout]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"USB plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed."""
logger.info("USB plugin installed")
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled."""
logger.info("USB plugin uninstalled")
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""
return [
{
'name': 'USB Devices',
'icon': 'usb',
'route': '/usb',
'position': 45,
},
]

View File

@@ -0,0 +1 @@
"""Data migration scripts for VBScript ShopDB to Flask migration."""

View File

@@ -0,0 +1,88 @@
-- =============================================================================
-- Legacy Schema Migration Script
-- Adds missing columns to make VBScript ShopDB compatible with Flask ShopDB
-- =============================================================================
-- -----------------------------------------------------------------------------
-- appowners table
-- -----------------------------------------------------------------------------
ALTER TABLE appowners
ADD COLUMN IF NOT EXISTS email VARCHAR(100) NULL,
ADD COLUMN IF NOT EXISTS phone VARCHAR(50) NULL,
ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1;
-- -----------------------------------------------------------------------------
-- pctypes / computertypes alignment
-- -----------------------------------------------------------------------------
-- The Flask app uses pctype table but expects certain columns
ALTER TABLE pctype
ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW();
-- Ensure isactive is correct type
ALTER TABLE pctype MODIFY COLUMN isactive TINYINT(1) DEFAULT 1;
-- -----------------------------------------------------------------------------
-- statuses table (if needed - Flask uses assetstatuses)
-- -----------------------------------------------------------------------------
-- assetstatuses already populated earlier
-- -----------------------------------------------------------------------------
-- subnets table
-- -----------------------------------------------------------------------------
ALTER TABLE subnets
ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1;
-- -----------------------------------------------------------------------------
-- vlans table
-- -----------------------------------------------------------------------------
ALTER TABLE vlans
ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1;
-- -----------------------------------------------------------------------------
-- usbdevices table
-- -----------------------------------------------------------------------------
ALTER TABLE usbdevices
ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1;
-- -----------------------------------------------------------------------------
-- usbcheckouts table
-- -----------------------------------------------------------------------------
ALTER TABLE usbcheckouts
ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW();
-- -----------------------------------------------------------------------------
-- notifications table
-- -----------------------------------------------------------------------------
ALTER TABLE notifications
ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW();
-- Copy dates from existing columns if they exist
UPDATE notifications SET createddate = startdate WHERE createddate IS NULL;
UPDATE notifications SET modifieddate = startdate WHERE modifieddate IS NULL;
-- -----------------------------------------------------------------------------
-- Verify key counts
-- -----------------------------------------------------------------------------
SELECT 'Migration complete. Record counts:' as status;
SELECT 'vendors' as tbl, COUNT(*) as cnt FROM vendors
UNION ALL SELECT 'models', COUNT(*) FROM models
UNION ALL SELECT 'machinetypes', COUNT(*) FROM machinetypes
UNION ALL SELECT 'operatingsystems', COUNT(*) FROM operatingsystems
UNION ALL SELECT 'businessunits', COUNT(*) FROM businessunits
UNION ALL SELECT 'applications', COUNT(*) FROM applications
UNION ALL SELECT 'machines', COUNT(*) FROM machines
UNION ALL SELECT 'printers', COUNT(*) FROM printers
UNION ALL SELECT 'assets', COUNT(*) FROM assets
UNION ALL SELECT 'knowledgebase', COUNT(*) FROM knowledgebase
UNION ALL SELECT 'notifications', COUNT(*) FROM notifications;

View File

@@ -0,0 +1,285 @@
"""
Migrate machines table to assets + extension tables.
This script migrates data from the legacy machines table to the new
Asset architecture with plugin-owned extension tables.
Strategy:
1. Preserve IDs: assets.assetid = original machines.machineid
2. Create asset record, then type-specific extension record
3. Map machine types to asset types:
- MachineType = Equipment -> equipment extension
- MachineType = PC -> computers extension
- MachineType = Network/Camera/etc -> network_devices extension
- Printers -> handled separately by printers plugin
Usage:
python -m scripts.migration.migrate_assets --source <connection_string>
"""
import argparse
import logging
from datetime import datetime
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_machine_type_mapping():
"""Map legacy machine type IDs to asset types."""
return {
# Equipment types
'CNC': 'equipment',
'CMM': 'equipment',
'Lathe': 'equipment',
'Grinder': 'equipment',
'EDM': 'equipment',
'Mill': 'equipment',
'Press': 'equipment',
'Robot': 'equipment',
'Part Marker': 'equipment',
# PC types
'PC': 'computer',
'Workstation': 'computer',
'Laptop': 'computer',
'Server': 'computer',
# Network types
'Switch': 'network_device',
'Router': 'network_device',
'Access Point': 'network_device',
'Camera': 'network_device',
'IDF': 'network_device',
'MDF': 'network_device',
'Firewall': 'network_device',
}
def migrate_machine_to_asset(machine_row, asset_type_id, target_session):
"""
Create an Asset record from a Machine record.
Args:
machine_row: Row from source machines table
asset_type_id: Target asset type ID
target_session: SQLAlchemy session for target database
Returns:
Created asset ID
"""
# Insert into assets table
target_session.execute(text("""
INSERT INTO assets (
assetid, assetnumber, name, serialnumber,
assettypeid, statusid, locationid, businessunitid,
mapleft, maptop, notes, isactive, createddate, modifieddate
) VALUES (
:assetid, :assetnumber, :name, :serialnumber,
:assettypeid, :statusid, :locationid, :businessunitid,
:mapleft, :maptop, :notes, :isactive, :createddate, :modifieddate
)
"""), {
'assetid': machine_row['machineid'],
'assetnumber': machine_row['machinenumber'],
'name': machine_row.get('alias'),
'serialnumber': machine_row.get('serialnumber'),
'assettypeid': asset_type_id,
'statusid': machine_row.get('statusid', 1),
'locationid': machine_row.get('locationid'),
'businessunitid': machine_row.get('businessunitid'),
'mapleft': machine_row.get('mapleft'),
'maptop': machine_row.get('maptop'),
'notes': machine_row.get('notes'),
'isactive': machine_row.get('isactive', True),
'createddate': machine_row.get('createddate', datetime.utcnow()),
'modifieddate': machine_row.get('modifieddate', datetime.utcnow()),
})
return machine_row['machineid']
def migrate_equipment(machine_row, asset_id, target_session):
"""Create equipment extension record."""
target_session.execute(text("""
INSERT INTO equipment (
assetid, equipmenttypeid, vendorid, modelnumberid,
requiresmanualconfig, islocationonly, isactive, createddate
) VALUES (
:assetid, :equipmenttypeid, :vendorid, :modelnumberid,
:requiresmanualconfig, :islocationonly, :isactive, :createddate
)
"""), {
'assetid': asset_id,
'equipmenttypeid': machine_row.get('machinetypeid'), # May need mapping
'vendorid': machine_row.get('vendorid'),
'modelnumberid': machine_row.get('modelnumberid'),
'requiresmanualconfig': machine_row.get('requiresmanualconfig', False),
'islocationonly': machine_row.get('islocationonly', False),
'isactive': True,
'createddate': datetime.utcnow(),
})
def migrate_computer(machine_row, asset_id, target_session):
"""Create computer extension record."""
target_session.execute(text("""
INSERT INTO computers (
assetid, computertypeid, vendorid, operatingsystemid,
hostname, currentuserid, lastuserid, lastboottime,
lastzabbixsync, isvnc, isactive, createddate
) VALUES (
:assetid, :computertypeid, :vendorid, :operatingsystemid,
:hostname, :currentuserid, :lastuserid, :lastboottime,
:lastzabbixsync, :isvnc, :isactive, :createddate
)
"""), {
'assetid': asset_id,
'computertypeid': machine_row.get('pctypeid'),
'vendorid': machine_row.get('vendorid'),
'operatingsystemid': machine_row.get('operatingsystemid'),
'hostname': machine_row.get('hostname'),
'currentuserid': machine_row.get('currentuserid'),
'lastuserid': machine_row.get('lastuserid'),
'lastboottime': machine_row.get('lastboottime'),
'lastzabbixsync': machine_row.get('lastzabbixsync'),
'isvnc': machine_row.get('isvnc', False),
'isactive': True,
'createddate': datetime.utcnow(),
})
def migrate_network_device(machine_row, asset_id, target_session):
"""Create network device extension record."""
target_session.execute(text("""
INSERT INTO networkdevices (
assetid, networkdevicetypeid, vendorid, hostname,
firmwareversion, portcount, ispoe, ismanaged,
isactive, createddate
) VALUES (
:assetid, :networkdevicetypeid, :vendorid, :hostname,
:firmwareversion, :portcount, :ispoe, :ismanaged,
:isactive, :createddate
)
"""), {
'assetid': asset_id,
'networkdevicetypeid': machine_row.get('machinetypeid'), # May need mapping
'vendorid': machine_row.get('vendorid'),
'hostname': machine_row.get('hostname'),
'firmwareversion': machine_row.get('firmwareversion'),
'portcount': machine_row.get('portcount'),
'ispoe': machine_row.get('ispoe', False),
'ismanaged': machine_row.get('ismanaged', False),
'isactive': True,
'createddate': datetime.utcnow(),
})
def run_migration(source_conn_str, target_conn_str, dry_run=False):
"""
Run the full migration from machines to assets.
Args:
source_conn_str: Connection string for source (VBScript) database
target_conn_str: Connection string for target (Flask) database
dry_run: If True, don't commit changes
"""
source_engine = create_engine(source_conn_str)
target_engine = create_engine(target_conn_str)
SourceSession = sessionmaker(bind=source_engine)
TargetSession = sessionmaker(bind=target_engine)
source_session = SourceSession()
target_session = TargetSession()
try:
# Get asset type mappings from target database
asset_types = {}
result = target_session.execute(text("SELECT assettypeid, assettype FROM assettypes"))
for row in result:
asset_types[row.assettype] = row.assettypeid
# Get machine type to asset type mapping
type_mapping = get_machine_type_mapping()
# Fetch all machines from source
machines = source_session.execute(text("""
SELECT m.*, mt.machinetype
FROM machines m
LEFT JOIN machinetypes mt ON m.machinetypeid = mt.machinetypeid
"""))
migrated = 0
errors = 0
for machine in machines:
machine_dict = dict(machine._mapping)
try:
# Determine asset type
machine_type_name = machine_dict.get('machinetype', '')
asset_type_name = type_mapping.get(machine_type_name, 'equipment')
asset_type_id = asset_types.get(asset_type_name)
if not asset_type_id:
logger.warning(f"Unknown asset type for machine {machine_dict['machineid']}: {machine_type_name}")
errors += 1
continue
# Create asset record
asset_id = migrate_machine_to_asset(machine_dict, asset_type_id, target_session)
# Create extension record based on type
if asset_type_name == 'equipment':
migrate_equipment(machine_dict, asset_id, target_session)
elif asset_type_name == 'computer':
migrate_computer(machine_dict, asset_id, target_session)
elif asset_type_name == 'network_device':
migrate_network_device(machine_dict, asset_id, target_session)
migrated += 1
if migrated % 100 == 0:
logger.info(f"Migrated {migrated} machines...")
except Exception as e:
logger.error(f"Error migrating machine {machine_dict.get('machineid')}: {e}")
errors += 1
if dry_run:
logger.info("Dry run - rolling back changes")
target_session.rollback()
else:
target_session.commit()
logger.info(f"Migration complete: {migrated} migrated, {errors} errors")
finally:
source_session.close()
target_session.close()
def main():
parser = argparse.ArgumentParser(description='Migrate machines to assets')
parser.add_argument('--source', required=True, help='Source database connection string')
parser.add_argument('--target', help='Target database connection string (default: app config)')
parser.add_argument('--dry-run', action='store_true', help='Dry run without committing')
args = parser.parse_args()
target = args.target
if not target:
# Load from Flask config
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
from shopdb import create_app
app = create_app()
target = app.config['SQLALCHEMY_DATABASE_URI']
run_migration(args.source, target, args.dry_run)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,113 @@
"""
Migrate communications table to use assetid instead of machineid.
This script updates the communications table FK from machineid to assetid.
Since assetid matches the original machineid, this is mostly a schema update.
Usage:
python -m scripts.migration.migrate_communications
"""
import logging
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def run_migration(conn_str, dry_run=False):
"""
Update communications to use assetid.
Args:
conn_str: Database connection string
dry_run: If True, don't commit changes
"""
engine = create_engine(conn_str)
Session = sessionmaker(bind=engine)
session = Session()
try:
# Check if assetid column already exists
result = session.execute(text("""
SELECT COUNT(*) FROM information_schema.columns
WHERE table_name = 'communications' AND column_name = 'assetid'
"""))
has_assetid = result.scalar() > 0
if not has_assetid:
logger.info("Adding assetid column to communications table...")
# Add assetid column
session.execute(text("""
ALTER TABLE communications
ADD COLUMN assetid INT NULL
"""))
# Copy machineid values to assetid
session.execute(text("""
UPDATE communications
SET assetid = machineid
WHERE machineid IS NOT NULL
"""))
# Add FK constraint (optional, depends on DB)
try:
session.execute(text("""
ALTER TABLE communications
ADD CONSTRAINT fk_comm_asset
FOREIGN KEY (assetid) REFERENCES assets(assetid)
"""))
except Exception as e:
logger.warning(f"Could not add FK constraint: {e}")
logger.info("assetid column added and populated")
else:
logger.info("assetid column already exists")
# Count records
result = session.execute(text("""
SELECT COUNT(*) FROM communications WHERE assetid IS NOT NULL
"""))
count = result.scalar()
logger.info(f"Communications with assetid: {count}")
if dry_run:
logger.info("Dry run - rolling back changes")
session.rollback()
else:
session.commit()
logger.info("Migration complete")
except Exception as e:
logger.error(f"Migration error: {e}")
session.rollback()
raise
finally:
session.close()
def main():
import argparse
import os
import sys
parser = argparse.ArgumentParser(description='Migrate communications to use assetid')
parser.add_argument('--connection', help='Database connection string')
parser.add_argument('--dry-run', action='store_true', help='Dry run without committing')
args = parser.parse_args()
conn_str = args.connection
if not conn_str:
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
from shopdb import create_app
app = create_app()
conn_str = app.config['SQLALCHEMY_DATABASE_URI']
run_migration(conn_str, args.dry_run)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,139 @@
"""
Migrate notifications from legacy database.
This script migrates notification data from the VBScript database
to the new notifications plugin schema.
Usage:
python -m scripts.migration.migrate_notifications --source <connection_string>
"""
import argparse
import logging
from datetime import datetime
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_notification_type_mapping(target_session):
"""Get mapping of type names to IDs in target database."""
result = target_session.execute(text(
"SELECT notificationtypeid, typename FROM notificationtypes"
))
return {row.typename.lower(): row.notificationtypeid for row in result}
def run_migration(source_conn_str, target_conn_str, dry_run=False):
"""
Run notification migration.
Args:
source_conn_str: Source database connection string
target_conn_str: Target database connection string
dry_run: If True, don't commit changes
"""
source_engine = create_engine(source_conn_str)
target_engine = create_engine(target_conn_str)
SourceSession = sessionmaker(bind=source_engine)
TargetSession = sessionmaker(bind=target_engine)
source_session = SourceSession()
target_session = TargetSession()
try:
# Get type mappings
type_mapping = get_notification_type_mapping(target_session)
# Default type if not found
default_type_id = type_mapping.get('general', 1)
# Fetch notifications from source
# Adjust column names based on actual legacy schema
notifications = source_session.execute(text("""
SELECT n.*, nt.typename
FROM notifications n
LEFT JOIN notificationtypes nt ON n.notificationtypeid = nt.notificationtypeid
"""))
migrated = 0
errors = 0
for notif in notifications:
notif_dict = dict(notif._mapping)
try:
# Map notification type
type_name = (notif_dict.get('typename') or 'general').lower()
type_id = type_mapping.get(type_name, default_type_id)
# Insert into target
target_session.execute(text("""
INSERT INTO notifications (
title, message, notificationtypeid,
startdate, enddate, ispinned, showbanner, allday,
linkurl, affectedsystems, isactive, createddate
) VALUES (
:title, :message, :notificationtypeid,
:startdate, :enddate, :ispinned, :showbanner, :allday,
:linkurl, :affectedsystems, :isactive, :createddate
)
"""), {
'title': notif_dict.get('title', 'Untitled'),
'message': notif_dict.get('message', ''),
'notificationtypeid': type_id,
'startdate': notif_dict.get('startdate', datetime.utcnow()),
'enddate': notif_dict.get('enddate'),
'ispinned': notif_dict.get('ispinned', False),
'showbanner': notif_dict.get('showbanner', True),
'allday': notif_dict.get('allday', True),
'linkurl': notif_dict.get('linkurl'),
'affectedsystems': notif_dict.get('affectedsystems'),
'isactive': notif_dict.get('isactive', True),
'createddate': notif_dict.get('createddate', datetime.utcnow()),
})
migrated += 1
except Exception as e:
logger.error(f"Error migrating notification: {e}")
errors += 1
if dry_run:
logger.info("Dry run - rolling back changes")
target_session.rollback()
else:
target_session.commit()
logger.info(f"Migration complete: {migrated} migrated, {errors} errors")
finally:
source_session.close()
target_session.close()
def main():
parser = argparse.ArgumentParser(description='Migrate notifications')
parser.add_argument('--source', required=True, help='Source database connection string')
parser.add_argument('--target', help='Target database connection string')
parser.add_argument('--dry-run', action='store_true', help='Dry run without committing')
args = parser.parse_args()
target = args.target
if not target:
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
from shopdb import create_app
app = create_app()
target = app.config['SQLALCHEMY_DATABASE_URI']
run_migration(args.source, target, args.dry_run)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,199 @@
"""
Migrate USB checkout data from legacy database.
This script migrates USB device and checkout data from the VBScript database
to the new USB plugin schema.
Usage:
python -m scripts.migration.migrate_usb --source <connection_string>
"""
import argparse
import logging
from datetime import datetime
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_device_type_mapping(target_session):
"""Get mapping of type names to IDs in target database."""
result = target_session.execute(text(
"SELECT usbdevicetypeid, typename FROM usbdevicetypes"
))
return {row.typename.lower(): row.usbdevicetypeid for row in result}
def run_migration(source_conn_str, target_conn_str, dry_run=False):
"""
Run USB device migration.
Args:
source_conn_str: Source database connection string
target_conn_str: Target database connection string
dry_run: If True, don't commit changes
"""
source_engine = create_engine(source_conn_str)
target_engine = create_engine(target_conn_str)
SourceSession = sessionmaker(bind=source_engine)
TargetSession = sessionmaker(bind=target_engine)
source_session = SourceSession()
target_session = TargetSession()
try:
# Get type mappings
type_mapping = get_device_type_mapping(target_session)
default_type_id = type_mapping.get('flash drive', 1)
# Migrate USB devices
# Adjust table/column names based on actual legacy schema
logger.info("Migrating USB devices...")
try:
devices = source_session.execute(text("""
SELECT * FROM usbdevices
"""))
device_id_map = {} # Map old IDs to new IDs
for device in devices:
device_dict = dict(device._mapping)
# Determine device type
type_name = (device_dict.get('typename') or 'flash drive').lower()
type_id = type_mapping.get(type_name, default_type_id)
result = target_session.execute(text("""
INSERT INTO usbdevices (
serialnumber, label, assetnumber, usbdevicetypeid,
capacitygb, vendorid, productid, manufacturer, productname,
ischeckedout, currentuserid, currentusername,
storagelocation, notes, isactive, createddate
) VALUES (
:serialnumber, :label, :assetnumber, :usbdevicetypeid,
:capacitygb, :vendorid, :productid, :manufacturer, :productname,
:ischeckedout, :currentuserid, :currentusername,
:storagelocation, :notes, :isactive, :createddate
)
"""), {
'serialnumber': device_dict.get('serialnumber', f"UNKNOWN_{device_dict.get('usbdeviceid', 0)}"),
'label': device_dict.get('label'),
'assetnumber': device_dict.get('assetnumber'),
'usbdevicetypeid': type_id,
'capacitygb': device_dict.get('capacitygb'),
'vendorid': device_dict.get('vendorid'),
'productid': device_dict.get('productid'),
'manufacturer': device_dict.get('manufacturer'),
'productname': device_dict.get('productname'),
'ischeckedout': device_dict.get('ischeckedout', False),
'currentuserid': device_dict.get('currentuserid'),
'currentusername': device_dict.get('currentusername'),
'storagelocation': device_dict.get('storagelocation'),
'notes': device_dict.get('notes'),
'isactive': device_dict.get('isactive', True),
'createddate': device_dict.get('createddate', datetime.utcnow()),
})
# Get the new ID
new_id = target_session.execute(text("SELECT LAST_INSERT_ID()")).scalar()
device_id_map[device_dict.get('usbdeviceid')] = new_id
logger.info(f"Migrated {len(device_id_map)} USB devices")
except Exception as e:
logger.warning(f"Could not migrate USB devices: {e}")
device_id_map = {}
# Migrate checkout history
logger.info("Migrating USB checkout history...")
try:
checkouts = source_session.execute(text("""
SELECT * FROM usbcheckouts
"""))
checkout_count = 0
for checkout in checkouts:
checkout_dict = dict(checkout._mapping)
old_device_id = checkout_dict.get('usbdeviceid')
new_device_id = device_id_map.get(old_device_id)
if not new_device_id:
logger.warning(f"Skipping checkout - device ID {old_device_id} not found in mapping")
continue
target_session.execute(text("""
INSERT INTO usbcheckouts (
usbdeviceid, userid, username,
checkoutdate, checkindate, expectedreturndate,
purpose, notes, checkedoutby, checkedinby,
isactive, createddate
) VALUES (
:usbdeviceid, :userid, :username,
:checkoutdate, :checkindate, :expectedreturndate,
:purpose, :notes, :checkedoutby, :checkedinby,
:isactive, :createddate
)
"""), {
'usbdeviceid': new_device_id,
'userid': checkout_dict.get('userid', 'unknown'),
'username': checkout_dict.get('username'),
'checkoutdate': checkout_dict.get('checkoutdate', datetime.utcnow()),
'checkindate': checkout_dict.get('checkindate'),
'expectedreturndate': checkout_dict.get('expectedreturndate'),
'purpose': checkout_dict.get('purpose'),
'notes': checkout_dict.get('notes'),
'checkedoutby': checkout_dict.get('checkedoutby'),
'checkedinby': checkout_dict.get('checkedinby'),
'isactive': True,
'createddate': checkout_dict.get('createddate', datetime.utcnow()),
})
checkout_count += 1
logger.info(f"Migrated {checkout_count} checkout records")
except Exception as e:
logger.warning(f"Could not migrate USB checkouts: {e}")
if dry_run:
logger.info("Dry run - rolling back changes")
target_session.rollback()
else:
target_session.commit()
logger.info("USB migration complete")
finally:
source_session.close()
target_session.close()
def main():
parser = argparse.ArgumentParser(description='Migrate USB devices and checkouts')
parser.add_argument('--source', required=True, help='Source database connection string')
parser.add_argument('--target', help='Target database connection string')
parser.add_argument('--dry-run', action='store_true', help='Dry run without committing')
args = parser.parse_args()
target = args.target
if not target:
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
from shopdb import create_app
app = create_app()
target = app.config['SQLALCHEMY_DATABASE_URI']
run_migration(args.source, target, args.dry_run)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,139 @@
"""
Migration orchestrator - runs all migration steps in order.
This script coordinates the full migration from VBScript ShopDB to Flask.
Usage:
python -m scripts.migration.run_migration --source <connection_string>
"""
import argparse
import logging
import sys
from datetime import datetime
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def run_full_migration(source_conn_str, target_conn_str, dry_run=False, steps=None):
"""
Run the full migration process.
Args:
source_conn_str: Source database connection string
target_conn_str: Target database connection string
dry_run: If True, don't commit changes
steps: List of specific steps to run, or None for all
"""
from . import migrate_assets
from . import migrate_communications
from . import migrate_notifications
from . import migrate_usb
from . import verify_migration
all_steps = [
('assets', 'Migrate machines to assets', migrate_assets.run_migration),
('communications', 'Update communications FKs', migrate_communications.run_migration),
('notifications', 'Migrate notifications', migrate_notifications.run_migration),
('usb', 'Migrate USB devices', migrate_usb.run_migration),
]
logger.info("=" * 60)
logger.info("SHOPDB MIGRATION")
logger.info(f"Started: {datetime.utcnow().isoformat()}")
logger.info(f"Dry Run: {dry_run}")
logger.info("=" * 60)
results = {}
for step_name, description, migration_func in all_steps:
if steps and step_name not in steps:
logger.info(f"\nSkipping: {description}")
continue
logger.info(f"\n{'=' * 40}")
logger.info(f"Step: {description}")
logger.info('=' * 40)
try:
# Different migrations have different signatures
if step_name == 'communications':
migration_func(target_conn_str, dry_run)
else:
migration_func(source_conn_str, target_conn_str, dry_run)
results[step_name] = 'SUCCESS'
logger.info(f"Step completed: {step_name}")
except Exception as e:
results[step_name] = f'FAILED: {e}'
logger.error(f"Step failed: {step_name} - {e}")
# Ask to continue
if not dry_run:
response = input("Continue with next step? (y/n): ")
if response.lower() != 'y':
logger.info("Migration aborted by user")
break
# Run verification
logger.info(f"\n{'=' * 40}")
logger.info("Running verification...")
logger.info('=' * 40)
try:
verify_migration.run_verification(source_conn_str, target_conn_str)
results['verification'] = 'SUCCESS'
except Exception as e:
results['verification'] = f'FAILED: {e}'
logger.error(f"Verification failed: {e}")
# Summary
logger.info("\n" + "=" * 60)
logger.info("MIGRATION SUMMARY")
logger.info("=" * 60)
for step, result in results.items():
status = "OK" if result == 'SUCCESS' else "FAILED"
logger.info(f" {step}: {status}")
if result != 'SUCCESS':
logger.info(f" {result}")
logger.info("=" * 60)
logger.info(f"Completed: {datetime.utcnow().isoformat()}")
return results
def main():
parser = argparse.ArgumentParser(description='Run full ShopDB migration')
parser.add_argument('--source', required=True, help='Source database connection string')
parser.add_argument('--target', help='Target database connection string')
parser.add_argument('--dry-run', action='store_true', help='Dry run without committing')
parser.add_argument('--steps', nargs='+',
choices=['assets', 'communications', 'notifications', 'usb'],
help='Specific steps to run')
args = parser.parse_args()
target = args.target
if not target:
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
from shopdb import create_app
app = create_app()
target = app.config['SQLALCHEMY_DATABASE_URI']
results = run_full_migration(args.source, target, args.dry_run, args.steps)
# Exit with error if any step failed
if any(r != 'SUCCESS' for r in results.values()):
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,174 @@
"""
Verify data migration integrity.
This script compares record counts between source and target databases
and performs spot checks on data integrity.
Usage:
python -m scripts.migration.verify_migration --source <connection_string>
"""
import argparse
import logging
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def verify_counts(source_session, target_session):
"""Compare record counts between source and target."""
results = {}
# Define table mappings (source -> target)
table_mappings = [
('machines', 'assets', 'Machine to Asset'),
('communications', 'communications', 'Communications'),
('vendors', 'vendors', 'Vendors'),
('locations', 'locations', 'Locations'),
('businessunits', 'businessunits', 'Business Units'),
]
for source_table, target_table, description in table_mappings:
try:
source_count = source_session.execute(text(f"SELECT COUNT(*) FROM {source_table}")).scalar()
except Exception as e:
source_count = f"Error: {e}"
try:
target_count = target_session.execute(text(f"SELECT COUNT(*) FROM {target_table}")).scalar()
except Exception as e:
target_count = f"Error: {e}"
match = source_count == target_count if isinstance(source_count, int) and isinstance(target_count, int) else False
results[description] = {
'source': source_count,
'target': target_count,
'match': match
}
return results
def verify_sample_records(source_session, target_session, sample_size=10):
"""Spot-check sample records for data integrity."""
issues = []
# Sample machine -> asset migration
try:
sample_machines = source_session.execute(text(f"""
SELECT machineid, machinenumber, serialnumber, alias
FROM machines
ORDER BY RAND()
LIMIT {sample_size}
"""))
for machine in sample_machines:
machine_dict = dict(machine._mapping)
# Check if asset exists with same ID
asset = target_session.execute(text("""
SELECT assetid, assetnumber, serialnumber, name
FROM assets
WHERE assetid = :assetid
"""), {'assetid': machine_dict['machineid']}).fetchone()
if not asset:
issues.append(f"Machine {machine_dict['machineid']} not found in assets")
continue
asset_dict = dict(asset._mapping)
# Verify data matches
if machine_dict['machinenumber'] != asset_dict['assetnumber']:
issues.append(f"Asset {asset_dict['assetid']}: machinenumber mismatch")
if machine_dict.get('serialnumber') != asset_dict.get('serialnumber'):
issues.append(f"Asset {asset_dict['assetid']}: serialnumber mismatch")
except Exception as e:
issues.append(f"Could not verify machines: {e}")
return issues
def run_verification(source_conn_str, target_conn_str):
"""
Run migration verification.
Args:
source_conn_str: Source database connection string
target_conn_str: Target database connection string
"""
source_engine = create_engine(source_conn_str)
target_engine = create_engine(target_conn_str)
SourceSession = sessionmaker(bind=source_engine)
TargetSession = sessionmaker(bind=target_engine)
source_session = SourceSession()
target_session = TargetSession()
try:
logger.info("=" * 60)
logger.info("MIGRATION VERIFICATION REPORT")
logger.info("=" * 60)
# Verify counts
logger.info("\nRecord Count Comparison:")
logger.info("-" * 40)
counts = verify_counts(source_session, target_session)
all_match = True
for table, result in counts.items():
status = "OK" if result['match'] else "MISMATCH"
if not result['match']:
all_match = False
logger.info(f" {table}: Source={result['source']}, Target={result['target']} [{status}]")
# Verify sample records
logger.info("\nSample Record Verification:")
logger.info("-" * 40)
issues = verify_sample_records(source_session, target_session)
if issues:
for issue in issues:
logger.warning(f" ! {issue}")
else:
logger.info(" All sample records verified OK")
# Summary
logger.info("\n" + "=" * 60)
if all_match and not issues:
logger.info("VERIFICATION PASSED - Migration looks good!")
else:
logger.warning("VERIFICATION FOUND ISSUES - Review above")
logger.info("=" * 60)
finally:
source_session.close()
target_session.close()
def main():
parser = argparse.ArgumentParser(description='Verify migration integrity')
parser.add_argument('--source', required=True, help='Source database connection string')
parser.add_argument('--target', help='Target database connection string')
args = parser.parse_args()
target = args.target
if not target:
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
from shopdb import create_app
app = create_app()
target = app.config['SQLALCHEMY_DATABASE_URI']
run_verification(args.source, target)
if __name__ == '__main__':
main()

View File

@@ -69,6 +69,7 @@ def register_blueprints(app: Flask):
"""Register core API blueprints.""" """Register core API blueprints."""
from .core.api import ( from .core.api import (
auth_bp, auth_bp,
assets_bp,
machines_bp, machines_bp,
machinetypes_bp, machinetypes_bp,
pctypes_bp, pctypes_bp,
@@ -82,11 +83,16 @@ def register_blueprints(app: Flask):
applications_bp, applications_bp,
knowledgebase_bp, knowledgebase_bp,
search_bp, search_bp,
reports_bp,
collector_bp,
employees_bp,
slides_bp,
) )
api_prefix = '/api' api_prefix = '/api'
app.register_blueprint(auth_bp, url_prefix=f'{api_prefix}/auth') app.register_blueprint(auth_bp, url_prefix=f'{api_prefix}/auth')
app.register_blueprint(assets_bp, url_prefix=f'{api_prefix}/assets')
app.register_blueprint(machines_bp, url_prefix=f'{api_prefix}/machines') app.register_blueprint(machines_bp, url_prefix=f'{api_prefix}/machines')
app.register_blueprint(machinetypes_bp, url_prefix=f'{api_prefix}/machinetypes') app.register_blueprint(machinetypes_bp, url_prefix=f'{api_prefix}/machinetypes')
app.register_blueprint(pctypes_bp, url_prefix=f'{api_prefix}/pctypes') app.register_blueprint(pctypes_bp, url_prefix=f'{api_prefix}/pctypes')
@@ -100,6 +106,10 @@ def register_blueprints(app: Flask):
app.register_blueprint(applications_bp, url_prefix=f'{api_prefix}/applications') app.register_blueprint(applications_bp, url_prefix=f'{api_prefix}/applications')
app.register_blueprint(knowledgebase_bp, url_prefix=f'{api_prefix}/knowledgebase') app.register_blueprint(knowledgebase_bp, url_prefix=f'{api_prefix}/knowledgebase')
app.register_blueprint(search_bp, url_prefix=f'{api_prefix}/search') app.register_blueprint(search_bp, url_prefix=f'{api_prefix}/search')
app.register_blueprint(reports_bp, url_prefix=f'{api_prefix}/reports')
app.register_blueprint(collector_bp, url_prefix=f'{api_prefix}/collector')
app.register_blueprint(employees_bp, url_prefix=f'{api_prefix}/employees')
app.register_blueprint(slides_bp, url_prefix=f'{api_prefix}/slides')
def register_cli_commands(app: Flask): def register_cli_commands(app: Flask):

View File

@@ -1,6 +1,7 @@
"""Core API blueprints.""" """Core API blueprints."""
from .auth import auth_bp from .auth import auth_bp
from .assets import assets_bp
from .machines import machines_bp from .machines import machines_bp
from .machinetypes import machinetypes_bp from .machinetypes import machinetypes_bp
from .pctypes import pctypes_bp from .pctypes import pctypes_bp
@@ -14,9 +15,14 @@ from .dashboard import dashboard_bp
from .applications import applications_bp from .applications import applications_bp
from .knowledgebase import knowledgebase_bp from .knowledgebase import knowledgebase_bp
from .search import search_bp from .search import search_bp
from .reports import reports_bp
from .collector import collector_bp
from .employees import employees_bp
from .slides import slides_bp
__all__ = [ __all__ = [
'auth_bp', 'auth_bp',
'assets_bp',
'machines_bp', 'machines_bp',
'machinetypes_bp', 'machinetypes_bp',
'pctypes_bp', 'pctypes_bp',
@@ -30,4 +36,8 @@ __all__ = [
'applications_bp', 'applications_bp',
'knowledgebase_bp', 'knowledgebase_bp',
'search_bp', 'search_bp',
'reports_bp',
'collector_bp',
'employees_bp',
'slides_bp',
] ]

659
shopdb/core/api/assets.py Normal file
View File

@@ -0,0 +1,659 @@
"""Assets API endpoints - unified asset queries."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, AssetStatus, AssetRelationship, RelationshipType
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
assets_bp = Blueprint('assets', __name__)
# =============================================================================
# Asset Types
# =============================================================================
@assets_bp.route('/types', methods=['GET'])
@jwt_required()
def list_asset_types():
"""List all asset types."""
page, per_page = get_pagination_params(request)
query = AssetType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(AssetType.isactive == True)
query = query.order_by(AssetType.assettype)
items, total = paginate_query(query, page, per_page)
data = [t.to_dict() for t in items]
return paginated_response(data, page, per_page, total)
@assets_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required()
def get_asset_type(type_id: int):
"""Get a single asset type."""
t = AssetType.query.get(type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Asset type with ID {type_id} not found',
http_code=404
)
return success_response(t.to_dict())
@assets_bp.route('/types', methods=['POST'])
@jwt_required()
def create_asset_type():
"""Create a new asset type."""
data = request.get_json()
if not data or not data.get('assettype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'assettype is required')
if AssetType.query.filter_by(assettype=data['assettype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset type '{data['assettype']}' already exists",
http_code=409
)
t = AssetType(
assettype=data['assettype'],
plugin_name=data.get('plugin_name'),
table_name=data.get('table_name'),
description=data.get('description'),
icon=data.get('icon')
)
db.session.add(t)
db.session.commit()
return success_response(t.to_dict(), message='Asset type created', http_code=201)
# =============================================================================
# Asset Statuses
# =============================================================================
@assets_bp.route('/statuses', methods=['GET'])
@jwt_required()
def list_asset_statuses():
"""List all asset statuses."""
page, per_page = get_pagination_params(request)
query = AssetStatus.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(AssetStatus.isactive == True)
query = query.order_by(AssetStatus.status)
items, total = paginate_query(query, page, per_page)
data = [s.to_dict() for s in items]
return paginated_response(data, page, per_page, total)
@assets_bp.route('/statuses/<int:status_id>', methods=['GET'])
@jwt_required()
def get_asset_status(status_id: int):
"""Get a single asset status."""
s = AssetStatus.query.get(status_id)
if not s:
return error_response(
ErrorCodes.NOT_FOUND,
f'Asset status with ID {status_id} not found',
http_code=404
)
return success_response(s.to_dict())
@assets_bp.route('/statuses', methods=['POST'])
@jwt_required()
def create_asset_status():
"""Create a new asset status."""
data = request.get_json()
if not data or not data.get('status'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'status is required')
if AssetStatus.query.filter_by(status=data['status']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset status '{data['status']}' already exists",
http_code=409
)
s = AssetStatus(
status=data['status'],
description=data.get('description'),
color=data.get('color')
)
db.session.add(s)
db.session.commit()
return success_response(s.to_dict(), message='Asset status created', http_code=201)
# =============================================================================
# Assets
# =============================================================================
@assets_bp.route('', methods=['GET'])
@jwt_required()
def list_assets():
"""
List all assets with filtering and pagination.
Query parameters:
- page: Page number (default: 1)
- per_page: Items per page (default: 20, max: 100)
- active: Filter by active status (default: true)
- search: Search by assetnumber or name
- type: Filter by asset type name (e.g., 'equipment', 'computer')
- type_id: Filter by asset type ID
- status_id: Filter by status ID
- location_id: Filter by location ID
- businessunit_id: Filter by business unit ID
- include_type_data: Include category-specific extension data (default: false)
"""
page, per_page = get_pagination_params(request)
query = Asset.query
# Active filter
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Asset.isactive == True)
# Search filter
if search := request.args.get('search'):
query = query.filter(
db.or_(
Asset.assetnumber.ilike(f'%{search}%'),
Asset.name.ilike(f'%{search}%'),
Asset.serialnumber.ilike(f'%{search}%')
)
)
# Type filter by name
if type_name := request.args.get('type'):
query = query.join(AssetType).filter(AssetType.assettype == type_name)
# Type filter by ID
if type_id := request.args.get('type_id'):
query = query.filter(Asset.assettypeid == int(type_id))
# Status filter
if status_id := request.args.get('status_id'):
query = query.filter(Asset.statusid == int(status_id))
# Location filter
if location_id := request.args.get('location_id'):
query = query.filter(Asset.locationid == int(location_id))
# Business unit filter
if bu_id := request.args.get('businessunit_id'):
query = query.filter(Asset.businessunitid == int(bu_id))
# Sorting
sort_by = request.args.get('sort', 'assetnumber')
sort_dir = request.args.get('dir', 'asc')
sort_columns = {
'assetnumber': Asset.assetnumber,
'name': Asset.name,
'createddate': Asset.createddate,
'modifieddate': Asset.modifieddate,
}
if sort_by in sort_columns:
col = sort_columns[sort_by]
query = query.order_by(col.desc() if sort_dir == 'desc' else col)
else:
query = query.order_by(Asset.assetnumber)
items, total = paginate_query(query, page, per_page)
# Include type data if requested
include_type_data = request.args.get('include_type_data', 'false').lower() == 'true'
data = [a.to_dict(include_type_data=include_type_data) for a in items]
return paginated_response(data, page, per_page, total)
@assets_bp.route('/<int:asset_id>', methods=['GET'])
@jwt_required()
def get_asset(asset_id: int):
"""
Get a single asset with full details.
Query parameters:
- include_type_data: Include category-specific extension data (default: true)
"""
asset = Asset.query.get(asset_id)
if not asset:
return error_response(
ErrorCodes.NOT_FOUND,
f'Asset with ID {asset_id} not found',
http_code=404
)
include_type_data = request.args.get('include_type_data', 'true').lower() != 'false'
return success_response(asset.to_dict(include_type_data=include_type_data))
@assets_bp.route('', methods=['POST'])
@jwt_required()
def create_asset():
"""Create a new asset."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Validate required fields
if not data.get('assetnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
if not data.get('assettypeid'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'assettypeid is required')
# Check for duplicate assetnumber
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Validate foreign keys exist
if not AssetType.query.get(data['assettypeid']):
return error_response(
ErrorCodes.VALIDATION_ERROR,
f"Asset type with ID {data['assettypeid']} not found"
)
asset = Asset(
assetnumber=data['assetnumber'],
name=data.get('name'),
serialnumber=data.get('serialnumber'),
assettypeid=data['assettypeid'],
statusid=data.get('statusid', 1),
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapleft=data.get('mapleft'),
maptop=data.get('maptop'),
notes=data.get('notes')
)
db.session.add(asset)
db.session.commit()
return success_response(asset.to_dict(), message='Asset created', http_code=201)
@assets_bp.route('/<int:asset_id>', methods=['PUT'])
@jwt_required()
def update_asset(asset_id: int):
"""Update an asset."""
asset = Asset.query.get(asset_id)
if not asset:
return error_response(
ErrorCodes.NOT_FOUND,
f'Asset with ID {asset_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Update allowed fields
allowed_fields = [
'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive'
]
for key in allowed_fields:
if key in data:
setattr(asset, key, data[key])
db.session.commit()
return success_response(asset.to_dict(), message='Asset updated')
@assets_bp.route('/<int:asset_id>', methods=['DELETE'])
@jwt_required()
def delete_asset(asset_id: int):
"""Delete (soft delete) an asset."""
asset = Asset.query.get(asset_id)
if not asset:
return error_response(
ErrorCodes.NOT_FOUND,
f'Asset with ID {asset_id} not found',
http_code=404
)
asset.isactive = False
db.session.commit()
return success_response(message='Asset deleted')
@assets_bp.route('/lookup/<assetnumber>', methods=['GET'])
@jwt_required()
def lookup_asset_by_number(assetnumber: str):
"""
Look up an asset by its asset number.
Useful for finding the asset ID when you only have the machine/asset number.
"""
asset = Asset.query.filter_by(assetnumber=assetnumber, isactive=True).first()
if not asset:
return error_response(
ErrorCodes.NOT_FOUND,
f'Asset with number {assetnumber} not found',
http_code=404
)
return success_response(asset.to_dict(include_type_data=True))
# =============================================================================
# Asset Relationships
# =============================================================================
@assets_bp.route('/<int:asset_id>/relationships', methods=['GET'])
@jwt_required()
def get_asset_relationships(asset_id: int):
"""
Get all relationships for an asset.
Returns both outgoing (source) and incoming (target) relationships.
"""
asset = Asset.query.get(asset_id)
if not asset:
return error_response(
ErrorCodes.NOT_FOUND,
f'Asset with ID {asset_id} not found',
http_code=404
)
# Get outgoing relationships (this asset is source)
outgoing = AssetRelationship.query.filter_by(
source_assetid=asset_id
).filter(AssetRelationship.isactive == True).all()
# Get incoming relationships (this asset is target)
incoming = AssetRelationship.query.filter_by(
target_assetid=asset_id
).filter(AssetRelationship.isactive == True).all()
outgoing_data = []
for rel in outgoing:
r = rel.to_dict()
r['target_asset'] = rel.target_asset.to_dict() if rel.target_asset else None
r['relationship_type_name'] = rel.relationship_type.relationshiptype if rel.relationship_type else None
outgoing_data.append(r)
incoming_data = []
for rel in incoming:
r = rel.to_dict()
r['source_asset'] = rel.source_asset.to_dict() if rel.source_asset else None
r['relationship_type_name'] = rel.relationship_type.relationshiptype if rel.relationship_type else None
incoming_data.append(r)
return success_response({
'outgoing': outgoing_data,
'incoming': incoming_data
})
@assets_bp.route('/relationships', methods=['POST'])
@jwt_required()
def create_asset_relationship():
"""Create a relationship between two assets."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Validate required fields
required = ['source_assetid', 'target_assetid', 'relationshiptypeid']
for field in required:
if not data.get(field):
return error_response(ErrorCodes.VALIDATION_ERROR, f'{field} is required')
source_id = data['source_assetid']
target_id = data['target_assetid']
type_id = data['relationshiptypeid']
# Validate assets exist
if not Asset.query.get(source_id):
return error_response(ErrorCodes.NOT_FOUND, f'Source asset {source_id} not found', http_code=404)
if not Asset.query.get(target_id):
return error_response(ErrorCodes.NOT_FOUND, f'Target asset {target_id} not found', http_code=404)
if not RelationshipType.query.get(type_id):
return error_response(ErrorCodes.NOT_FOUND, f'Relationship type {type_id} not found', http_code=404)
# Check for duplicate relationship
existing = AssetRelationship.query.filter_by(
source_assetid=source_id,
target_assetid=target_id,
relationshiptypeid=type_id
).first()
if existing:
return error_response(
ErrorCodes.CONFLICT,
'This relationship already exists',
http_code=409
)
rel = AssetRelationship(
source_assetid=source_id,
target_assetid=target_id,
relationshiptypeid=type_id,
notes=data.get('notes')
)
db.session.add(rel)
db.session.commit()
return success_response(rel.to_dict(), message='Relationship created', http_code=201)
@assets_bp.route('/relationships/<int:rel_id>', methods=['DELETE'])
@jwt_required()
def delete_asset_relationship(rel_id: int):
"""Delete an asset relationship."""
rel = AssetRelationship.query.get(rel_id)
if not rel:
return error_response(
ErrorCodes.NOT_FOUND,
f'Relationship with ID {rel_id} not found',
http_code=404
)
rel.isactive = False
db.session.commit()
return success_response(message='Relationship deleted')
# =============================================================================
# Asset Communications
# =============================================================================
# =============================================================================
# Unified Asset Map
# =============================================================================
@assets_bp.route('/map', methods=['GET'])
@jwt_required()
def get_assets_map():
"""
Get all assets with map positions for unified floor map display.
Returns assets with mapleft/maptop coordinates, joined with type-specific data.
Query parameters:
- assettype: Filter by asset type name (equipment, computer, network, printer)
- businessunitid: Filter by business unit ID
- statusid: Filter by status ID
- locationid: Filter by location ID
- search: Search by assetnumber, name, or serialnumber
"""
from shopdb.core.models import Location, BusinessUnit
query = Asset.query.filter(
Asset.isactive == True,
Asset.mapleft.isnot(None),
Asset.maptop.isnot(None)
)
# Filter by asset type name
if assettype := request.args.get('assettype'):
types = assettype.split(',')
query = query.join(AssetType).filter(AssetType.assettype.in_(types))
# Filter by business unit
if bu_id := request.args.get('businessunitid'):
query = query.filter(Asset.businessunitid == int(bu_id))
# Filter by status
if status_id := request.args.get('statusid'):
query = query.filter(Asset.statusid == int(status_id))
# Filter by location
if location_id := request.args.get('locationid'):
query = query.filter(Asset.locationid == int(location_id))
# Search filter
if search := request.args.get('search'):
query = query.filter(
db.or_(
Asset.assetnumber.ilike(f'%{search}%'),
Asset.name.ilike(f'%{search}%'),
Asset.serialnumber.ilike(f'%{search}%')
)
)
assets = query.all()
# Build response with type-specific data
data = []
for asset in assets:
item = {
'assetid': asset.assetid,
'assetnumber': asset.assetnumber,
'name': asset.name,
'displayname': asset.display_name,
'serialnumber': asset.serialnumber,
'mapleft': asset.mapleft,
'maptop': asset.maptop,
'assettype': asset.assettype.assettype if asset.assettype else None,
'assettypeid': asset.assettypeid,
'status': asset.status.status if asset.status else None,
'statusid': asset.statusid,
'statuscolor': asset.status.color if asset.status else None,
'location': asset.location.locationname if asset.location else None,
'locationid': asset.locationid,
'businessunit': asset.businessunit.businessunit if asset.businessunit else None,
'businessunitid': asset.businessunitid,
'primaryip': asset.primary_ip,
}
# Add type-specific data
type_data = asset._get_extension_data()
if type_data:
item['typedata'] = type_data
data.append(item)
# Get available asset types for filters
asset_types = AssetType.query.filter(AssetType.isactive == True).all()
types_data = [{'assettypeid': t.assettypeid, 'assettype': t.assettype, 'icon': t.icon} for t in asset_types]
# Get status options
statuses = AssetStatus.query.filter(AssetStatus.isactive == True).all()
status_data = [{'statusid': s.statusid, 'status': s.status, 'color': s.color} for s in statuses]
# Get business units
business_units = BusinessUnit.query.filter(BusinessUnit.isactive == True).all()
bu_data = [{'businessunitid': bu.businessunitid, 'businessunit': bu.businessunit} for bu in business_units]
# Get locations
locations = Location.query.filter(Location.isactive == True).all()
loc_data = [{'locationid': loc.locationid, 'locationname': loc.locationname} for loc in locations]
return success_response({
'assets': data,
'total': len(data),
'filters': {
'assettypes': types_data,
'statuses': status_data,
'businessunits': bu_data,
'locations': loc_data
}
})
@assets_bp.route('/<int:asset_id>/communications', methods=['GET'])
@jwt_required()
def get_asset_communications(asset_id: int):
"""Get all communications for an asset."""
from shopdb.core.models import Communication
asset = Asset.query.get(asset_id)
if not asset:
return error_response(
ErrorCodes.NOT_FOUND,
f'Asset with ID {asset_id} not found',
http_code=404
)
comms = Communication.query.filter_by(
assetid=asset_id,
isactive=True
).all()
data = []
for comm in comms:
c = comm.to_dict()
c['comtype_name'] = comm.comtype.comtype if comm.comtype else None
data.append(c)
return success_response(data)

View File

@@ -0,0 +1,374 @@
"""
PowerShell Data Collection API endpoints.
Compatibility layer for existing PowerShell scripts that update PC data.
Uses API key authentication instead of JWT for automated scripts.
"""
from datetime import datetime
from functools import wraps
from flask import Blueprint, request, current_app
from shopdb.extensions import db
from shopdb.core.models import Machine, Application, InstalledApp
from shopdb.utils.responses import success_response, error_response, ErrorCodes
collector_bp = Blueprint('collector', __name__)
def require_api_key(f):
"""Decorator to require API key authentication."""
@wraps(f)
def decorated(*args, **kwargs):
api_key = request.headers.get('X-API-Key')
if not api_key:
api_key = request.args.get('api_key')
expected_key = current_app.config.get('COLLECTOR_API_KEY')
if not expected_key:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Collector API key not configured',
http_code=500
)
if api_key != expected_key:
return error_response(
ErrorCodes.UNAUTHORIZED,
'Invalid API key',
http_code=401
)
return f(*args, **kwargs)
return decorated
@collector_bp.route('/pc', methods=['POST'])
@require_api_key
def update_pc_info():
"""
Update PC information from PowerShell collection script.
Expected JSON payload:
{
"hostname": "PC-1234",
"osname": "Windows 10 Enterprise",
"osversion": "10.0.19045",
"lastboottime": "2024-01-15T08:30:00",
"currentuser": "jsmith",
"ipaddress": "10.1.2.100",
"macaddress": "00:11:22:33:44:55",
"serialnumber": "ABC123",
"manufacturer": "Dell",
"model": "OptiPlex 7090"
}
"""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
hostname = data.get('hostname')
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required')
# Find the PC by hostname
pc = Machine.query.filter(
Machine.hostname.ilike(hostname),
Machine.pctypeid.isnot(None)
).first()
if not pc:
# Try to find by machine number if hostname not found
pc = Machine.query.filter(
Machine.machinenumber.ilike(hostname),
Machine.pctypeid.isnot(None)
).first()
if not pc:
return error_response(
ErrorCodes.NOT_FOUND,
f'PC with hostname {hostname} not found',
http_code=404
)
# Update PC fields
update_fields = {
'lastzabbixsync': datetime.utcnow(), # Track last collection time
}
if data.get('lastboottime'):
try:
update_fields['lastboottime'] = datetime.fromisoformat(
data['lastboottime'].replace('Z', '+00:00')
)
except ValueError:
pass
if data.get('currentuser'):
# Store previous user before updating
if pc.currentuserid != data['currentuser']:
update_fields['lastuserid'] = pc.currentuserid
update_fields['currentuserid'] = data['currentuser']
if data.get('serialnumber'):
update_fields['serialnumber'] = data['serialnumber']
# Update the record
for key, value in update_fields.items():
if hasattr(pc, key):
setattr(pc, key, value)
db.session.commit()
return success_response({
'machineid': pc.machineid,
'hostname': pc.hostname,
'updated': True
}, message='PC info updated')
@collector_bp.route('/apps', methods=['POST'])
@require_api_key
def update_installed_apps():
"""
Update installed applications for a PC.
Expected JSON payload:
{
"hostname": "PC-1234",
"apps": [
{
"appname": "Microsoft Office",
"version": "16.0.14326.20454",
"installdate": "2024-01-10"
},
...
]
}
"""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
hostname = data.get('hostname')
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required')
apps = data.get('apps', [])
if not apps:
return error_response(ErrorCodes.VALIDATION_ERROR, 'apps list is required')
# Find the PC
pc = Machine.query.filter(
Machine.hostname.ilike(hostname),
Machine.pctypeid.isnot(None)
).first()
if not pc:
return error_response(
ErrorCodes.NOT_FOUND,
f'PC with hostname {hostname} not found',
http_code=404
)
updated_count = 0
created_count = 0
skipped_count = 0
for app_data in apps:
app_name = app_data.get('appname')
if not app_name:
skipped_count += 1
continue
# Find the application in the database
app = Application.query.filter(
Application.appname.ilike(app_name)
).first()
if not app:
# Skip apps not in our tracked list
skipped_count += 1
continue
# Check if already installed
installed = InstalledApp.query.filter_by(
machineid=pc.machineid,
appid=app.appid
).first()
if installed:
# Update version if changed
new_version = app_data.get('version')
if new_version and installed.installedversion != new_version:
installed.installedversion = new_version
installed.modifieddate = datetime.utcnow()
updated_count += 1
else:
# Create new installed app record
installed = InstalledApp(
machineid=pc.machineid,
appid=app.appid,
installedversion=app_data.get('version'),
installdate=datetime.utcnow()
)
db.session.add(installed)
created_count += 1
db.session.commit()
return success_response({
'hostname': hostname,
'machineid': pc.machineid,
'created': created_count,
'updated': updated_count,
'skipped': skipped_count
}, message='Installed apps updated')
@collector_bp.route('/heartbeat', methods=['POST'])
@require_api_key
def pc_heartbeat():
"""
Record PC online status / heartbeat.
Expected JSON payload:
{
"hostname": "PC-1234"
}
Or batch update:
{
"hostnames": ["PC-1234", "PC-1235", "PC-1236"]
}
"""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
hostnames = data.get('hostnames', [])
if not hostnames and data.get('hostname'):
hostnames = [data['hostname']]
if not hostnames:
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname or hostnames required')
updated = 0
not_found = []
for hostname in hostnames:
pc = Machine.query.filter(
Machine.hostname.ilike(hostname),
Machine.pctypeid.isnot(None)
).first()
if pc:
pc.lastzabbixsync = datetime.utcnow()
updated += 1
else:
not_found.append(hostname)
db.session.commit()
return success_response({
'updated': updated,
'not_found': not_found,
'timestamp': datetime.utcnow().isoformat()
}, message=f'{updated} PC(s) heartbeat recorded')
@collector_bp.route('/bulk', methods=['POST'])
@require_api_key
def bulk_update():
"""
Bulk update multiple PCs at once.
Expected JSON payload:
{
"pcs": [
{
"hostname": "PC-1234",
"currentuser": "jsmith",
"lastboottime": "2024-01-15T08:30:00"
},
...
]
}
"""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
pcs = data.get('pcs', [])
if not pcs:
return error_response(ErrorCodes.VALIDATION_ERROR, 'pcs list is required')
updated = 0
not_found = []
errors = []
for pc_data in pcs:
hostname = pc_data.get('hostname')
if not hostname:
continue
pc = Machine.query.filter(
Machine.hostname.ilike(hostname),
Machine.pctypeid.isnot(None)
).first()
if not pc:
not_found.append(hostname)
continue
try:
pc.lastzabbixsync = datetime.utcnow()
if pc_data.get('currentuser'):
if pc.currentuserid != pc_data['currentuser']:
pc.lastuserid = pc.currentuserid
pc.currentuserid = pc_data['currentuser']
if pc_data.get('lastboottime'):
try:
pc.lastboottime = datetime.fromisoformat(
pc_data['lastboottime'].replace('Z', '+00:00')
)
except ValueError:
pass
updated += 1
except Exception as e:
errors.append({'hostname': hostname, 'error': str(e)})
db.session.commit()
return success_response({
'updated': updated,
'not_found': not_found,
'errors': errors,
'timestamp': datetime.utcnow().isoformat()
}, message=f'{updated} PC(s) updated')
@collector_bp.route('/status', methods=['GET'])
@require_api_key
def collector_status():
"""Check collector API status and configuration."""
return success_response({
'status': 'ok',
'timestamp': datetime.utcnow().isoformat(),
'endpoints': [
'POST /api/collector/pc',
'POST /api/collector/apps',
'POST /api/collector/heartbeat',
'POST /api/collector/bulk',
'GET /api/collector/status'
]
})

View File

@@ -0,0 +1,161 @@
"""Employee lookup API endpoints."""
from flask import Blueprint, request
from shopdb.utils.responses import success_response, error_response, ErrorCodes
employees_bp = Blueprint('employees', __name__)
@employees_bp.route('/search', methods=['GET'])
def search_employees():
"""
Search employees by name.
Query parameters:
- q: Search query (searches first and last name)
- limit: Max results (default 10)
"""
query = request.args.get('q', '').strip()
limit = min(int(request.args.get('limit', 10)), 50)
if len(query) < 2:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Search query must be at least 2 characters'
)
try:
import pymysql
conn = pymysql.connect(
host='localhost',
user='root',
password='rootpassword',
database='wjf_employees',
cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cur:
# Search by first name, last name, or SSO
cur.execute('''
SELECT SSO, First_Name, Last_Name, Team, Role, Picture
FROM employees
WHERE First_Name LIKE %s
OR Last_Name LIKE %s
OR CAST(SSO AS CHAR) LIKE %s
ORDER BY Last_Name, First_Name
LIMIT %s
''', (f'%{query}%', f'%{query}%', f'%{query}%', limit))
employees = cur.fetchall()
conn.close()
return success_response(employees)
except Exception as e:
return error_response(
ErrorCodes.DATABASE_ERROR,
f'Employee lookup failed: {str(e)}',
http_code=500
)
@employees_bp.route('/lookup/<sso>', methods=['GET'])
def lookup_employee(sso):
"""Look up a single employee by SSO."""
if not sso.isdigit():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'SSO must be numeric'
)
try:
import pymysql
conn = pymysql.connect(
host='localhost',
user='root',
password='rootpassword',
database='wjf_employees',
cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cur:
cur.execute(
'SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO = %s',
(int(sso),)
)
employee = cur.fetchone()
conn.close()
if not employee:
return error_response(
ErrorCodes.NOT_FOUND,
f'Employee with SSO {sso} not found',
http_code=404
)
return success_response(employee)
except Exception as e:
return error_response(
ErrorCodes.DATABASE_ERROR,
f'Employee lookup failed: {str(e)}',
http_code=500
)
@employees_bp.route('/lookup', methods=['GET'])
def lookup_employees():
"""
Look up multiple employees by SSO list.
Query parameters:
- sso: Comma-separated list of SSOs
"""
sso_list = request.args.get('sso', '')
ssos = [s.strip() for s in sso_list.split(',') if s.strip().isdigit()]
if not ssos:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'At least one valid SSO is required'
)
try:
import pymysql
conn = pymysql.connect(
host='localhost',
user='root',
password='rootpassword',
database='wjf_employees',
cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cur:
placeholders = ','.join(['%s'] * len(ssos))
cur.execute(
f'SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO IN ({placeholders})',
[int(s) for s in ssos]
)
employees = cur.fetchall()
conn.close()
# Build name string
names = ', '.join(
f"{e['First_Name'].strip()} {e['Last_Name'].strip()}"
for e in employees
)
return success_response({
'employees': employees,
'names': names
})
except Exception as e:
return error_response(
ErrorCodes.DATABASE_ERROR,
f'Employee lookup failed: {str(e)}',
http_code=500
)

View File

@@ -1,6 +1,18 @@
"""Machines API endpoints.""" """
Machines API endpoints.
from flask import Blueprint, request DEPRECATED: This API is deprecated and will be removed in a future version.
Please migrate to the new asset-based APIs:
- /api/assets - Unified asset queries
- /api/equipment - Equipment CRUD
- /api/computers - Computers CRUD
- /api/network - Network devices CRUD
- /api/printers - Printers CRUD
"""
import logging
from functools import wraps
from flask import Blueprint, request, g
from flask_jwt_extended import jwt_required, current_user from flask_jwt_extended import jwt_required, current_user
from shopdb.extensions import db from shopdb.extensions import db
@@ -14,11 +26,40 @@ from shopdb.utils.responses import (
) )
from shopdb.utils.pagination import get_pagination_params, paginate_query from shopdb.utils.pagination import get_pagination_params, paginate_query
logger = logging.getLogger(__name__)
machines_bp = Blueprint('machines', __name__) machines_bp = Blueprint('machines', __name__)
def add_deprecation_headers(f):
"""Decorator to add deprecation headers to responses."""
@wraps(f)
def decorated_function(*args, **kwargs):
response = f(*args, **kwargs)
# Add deprecation headers
if hasattr(response, 'headers'):
response.headers['X-Deprecated'] = 'true'
response.headers['X-Deprecated-Message'] = (
'This endpoint is deprecated. '
'Please migrate to /api/assets, /api/equipment, /api/computers, /api/network, or /api/printers.'
)
response.headers['Sunset'] = '2026-12-31' # Target sunset date
# Log deprecation warning (once per request)
if not getattr(g, '_deprecation_logged', False):
logger.warning(
f"Deprecated /api/machines endpoint called: {request.method} {request.path}"
)
g._deprecation_logged = True
return response
return decorated_function
@machines_bp.route('', methods=['GET']) @machines_bp.route('', methods=['GET'])
@jwt_required(optional=True) @jwt_required(optional=True)
@add_deprecation_headers
def list_machines(): def list_machines():
""" """
List all machines with filtering and pagination. List all machines with filtering and pagination.
@@ -149,6 +190,7 @@ def list_machines():
@machines_bp.route('/<int:machine_id>', methods=['GET']) @machines_bp.route('/<int:machine_id>', methods=['GET'])
@jwt_required(optional=True) @jwt_required(optional=True)
@add_deprecation_headers
def get_machine(machine_id: int): def get_machine(machine_id: int):
"""Get a single machine by ID.""" """Get a single machine by ID."""
machine = Machine.query.get(machine_id) machine = Machine.query.get(machine_id)
@@ -180,6 +222,7 @@ def get_machine(machine_id: int):
@machines_bp.route('', methods=['POST']) @machines_bp.route('', methods=['POST'])
@jwt_required() @jwt_required()
@add_deprecation_headers
def create_machine(): def create_machine():
"""Create a new machine.""" """Create a new machine."""
data = request.get_json() data = request.get_json()
@@ -227,6 +270,7 @@ def create_machine():
@machines_bp.route('/<int:machine_id>', methods=['PUT']) @machines_bp.route('/<int:machine_id>', methods=['PUT'])
@jwt_required() @jwt_required()
@add_deprecation_headers
def update_machine(machine_id: int): def update_machine(machine_id: int):
"""Update an existing machine.""" """Update an existing machine."""
machine = Machine.query.get(machine_id) machine = Machine.query.get(machine_id)
@@ -274,6 +318,7 @@ def update_machine(machine_id: int):
@machines_bp.route('/<int:machine_id>', methods=['DELETE']) @machines_bp.route('/<int:machine_id>', methods=['DELETE'])
@jwt_required() @jwt_required()
@add_deprecation_headers
def delete_machine(machine_id: int): def delete_machine(machine_id: int):
"""Soft delete a machine.""" """Soft delete a machine."""
machine = Machine.query.get(machine_id) machine = Machine.query.get(machine_id)
@@ -293,6 +338,7 @@ def delete_machine(machine_id: int):
@machines_bp.route('/<int:machine_id>/communications', methods=['GET']) @machines_bp.route('/<int:machine_id>/communications', methods=['GET'])
@jwt_required() @jwt_required()
@add_deprecation_headers
def get_machine_communications(machine_id: int): def get_machine_communications(machine_id: int):
"""Get all communications for a machine.""" """Get all communications for a machine."""
machine = Machine.query.get(machine_id) machine = Machine.query.get(machine_id)
@@ -310,6 +356,7 @@ def get_machine_communications(machine_id: int):
@machines_bp.route('/<int:machine_id>/communication', methods=['PUT']) @machines_bp.route('/<int:machine_id>/communication', methods=['PUT'])
@jwt_required() @jwt_required()
@add_deprecation_headers
def update_machine_communication(machine_id: int): def update_machine_communication(machine_id: int):
"""Update machine communication (IP address).""" """Update machine communication (IP address)."""
from shopdb.core.models.communication import Communication, CommunicationType from shopdb.core.models.communication import Communication, CommunicationType
@@ -364,6 +411,7 @@ def update_machine_communication(machine_id: int):
@machines_bp.route('/<int:machine_id>/relationships', methods=['GET']) @machines_bp.route('/<int:machine_id>/relationships', methods=['GET'])
@jwt_required(optional=True) @jwt_required(optional=True)
@add_deprecation_headers
def get_machine_relationships(machine_id: int): def get_machine_relationships(machine_id: int):
"""Get all relationships for a machine (both parent and child).""" """Get all relationships for a machine (both parent and child)."""
machine = Machine.query.get(machine_id) machine = Machine.query.get(machine_id)
@@ -429,6 +477,7 @@ def get_machine_relationships(machine_id: int):
@machines_bp.route('/<int:machine_id>/relationships', methods=['POST']) @machines_bp.route('/<int:machine_id>/relationships', methods=['POST'])
@jwt_required() @jwt_required()
@add_deprecation_headers
def create_machine_relationship(machine_id: int): def create_machine_relationship(machine_id: int):
"""Create a relationship for a machine.""" """Create a relationship for a machine."""
machine = Machine.query.get(machine_id) machine = Machine.query.get(machine_id)
@@ -504,6 +553,7 @@ def create_machine_relationship(machine_id: int):
@machines_bp.route('/relationships/<int:relationship_id>', methods=['DELETE']) @machines_bp.route('/relationships/<int:relationship_id>', methods=['DELETE'])
@jwt_required() @jwt_required()
@add_deprecation_headers
def delete_machine_relationship(relationship_id: int): def delete_machine_relationship(relationship_id: int):
"""Delete a machine relationship.""" """Delete a machine relationship."""
relationship = MachineRelationship.query.get(relationship_id) relationship = MachineRelationship.query.get(relationship_id)
@@ -523,6 +573,7 @@ def delete_machine_relationship(relationship_id: int):
@machines_bp.route('/relationshiptypes', methods=['GET']) @machines_bp.route('/relationshiptypes', methods=['GET'])
@jwt_required(optional=True) @jwt_required(optional=True)
@add_deprecation_headers
def list_relationship_types(): def list_relationship_types():
"""List all relationship types.""" """List all relationship types."""
types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all() types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all()
@@ -535,6 +586,7 @@ def list_relationship_types():
@machines_bp.route('/relationshiptypes', methods=['POST']) @machines_bp.route('/relationshiptypes', methods=['POST'])
@jwt_required() @jwt_required()
@add_deprecation_headers
def create_relationship_type(): def create_relationship_type():
"""Create a new relationship type.""" """Create a new relationship type."""
data = request.get_json() data = request.get_json()

573
shopdb/core/api/reports.py Normal file
View File

@@ -0,0 +1,573 @@
"""Reports API endpoints."""
import csv
import io
from datetime import datetime, timedelta
from flask import Blueprint, request, Response
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import (
Asset, AssetType, AssetStatus, Machine, MachineStatus,
Application, KnowledgeBase, InstalledApp
)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
reports_bp = Blueprint('reports', __name__)
def generate_csv(data: list, columns: list) -> str:
"""Generate CSV string from data."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(columns)
for row in data:
writer.writerow([row.get(col, '') for col in columns])
return output.getvalue()
# =============================================================================
# Report: Equipment by Type
# =============================================================================
@reports_bp.route('/equipment-by-type', methods=['GET'])
@jwt_required()
def equipment_by_type():
"""
Report: Equipment count grouped by equipment type.
Query parameters:
- businessunitid: Filter by business unit
- format: 'json' (default) or 'csv'
"""
try:
from plugins.equipment.models import Equipment, EquipmentType
except ImportError:
return error_response(
ErrorCodes.NOT_FOUND,
'Equipment plugin not installed',
http_code=404
)
query = db.session.query(
EquipmentType.equipmenttype,
EquipmentType.description,
db.func.count(Equipment.equipmentid).label('count')
).outerjoin(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
).outerjoin(Asset, Asset.assetid == Equipment.assetid
).filter(
db.or_(Asset.isactive == True, Asset.assetid.is_(None)),
EquipmentType.isactive == True
)
# Business unit filter
if bu_id := request.args.get('businessunitid'):
query = query.filter(Asset.businessunitid == int(bu_id))
query = query.group_by(
EquipmentType.equipmenttypeid,
EquipmentType.equipmenttype,
EquipmentType.description
).order_by(EquipmentType.equipmenttype)
results = query.all()
data = [{'equipmenttype': r.equipmenttype, 'description': r.description or '', 'count': r.count} for r in results]
total = sum(r['count'] for r in data)
if request.args.get('format') == 'csv':
csv_data = generate_csv(data, ['equipmenttype', 'description', 'count'])
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=equipment_by_type.csv'}
)
return success_response({
'report': 'equipment_by_type',
'generated': datetime.utcnow().isoformat(),
'data': data,
'total': total
})
# =============================================================================
# Report: Assets by Status
# =============================================================================
@reports_bp.route('/assets-by-status', methods=['GET'])
@jwt_required()
def assets_by_status():
"""
Report: Asset count grouped by status.
Query parameters:
- assettypeid: Filter by asset type
- businessunitid: Filter by business unit
- format: 'json' (default) or 'csv'
"""
query = db.session.query(
AssetStatus.status,
AssetStatus.color,
db.func.count(Asset.assetid).label('count')
).outerjoin(Asset, Asset.statusid == AssetStatus.statusid
).filter(
db.or_(Asset.isactive == True, Asset.assetid.is_(None)),
AssetStatus.isactive == True
)
# Asset type filter
if type_id := request.args.get('assettypeid'):
query = query.filter(Asset.assettypeid == int(type_id))
# Business unit filter
if bu_id := request.args.get('businessunitid'):
query = query.filter(Asset.businessunitid == int(bu_id))
query = query.group_by(
AssetStatus.statusid,
AssetStatus.status,
AssetStatus.color
).order_by(AssetStatus.status)
results = query.all()
data = [{'status': r.status, 'color': r.color or '', 'count': r.count} for r in results]
total = sum(r['count'] for r in data)
if request.args.get('format') == 'csv':
csv_data = generate_csv(data, ['status', 'count'])
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=assets_by_status.csv'}
)
return success_response({
'report': 'assets_by_status',
'generated': datetime.utcnow().isoformat(),
'data': data,
'total': total
})
# =============================================================================
# Report: KB Popularity
# =============================================================================
@reports_bp.route('/kb-popularity', methods=['GET'])
@jwt_required()
def kb_popularity():
"""
Report: Most clicked knowledge base articles.
Query parameters:
- limit: Number of results (default 20)
- format: 'json' (default) or 'csv'
"""
limit = min(int(request.args.get('limit', 20)), 100)
articles = KnowledgeBase.query.filter(
KnowledgeBase.isactive == True
).order_by(
KnowledgeBase.clicks.desc()
).limit(limit).all()
data = []
for kb in articles:
data.append({
'linkid': kb.linkid,
'shortdescription': kb.shortdescription,
'application': kb.application.appname if kb.application else None,
'clicks': kb.clicks or 0,
'linkurl': kb.linkurl
})
if request.args.get('format') == 'csv':
csv_data = generate_csv(data, ['linkid', 'shortdescription', 'application', 'clicks', 'linkurl'])
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=kb_popularity.csv'}
)
return success_response({
'report': 'kb_popularity',
'generated': datetime.utcnow().isoformat(),
'data': data,
'total': len(data)
})
# =============================================================================
# Report: Warranty Status
# =============================================================================
@reports_bp.route('/warranty-status', methods=['GET'])
@jwt_required()
def warranty_status():
"""
Report: Assets by warranty expiration status.
Categories: Expired, Expiring Soon (90 days), Valid, No Warranty Data
Query parameters:
- assettypeid: Filter by asset type
- format: 'json' (default) or 'csv'
"""
now = datetime.utcnow()
expiring_threshold = now + timedelta(days=90)
# Try to get warranty data from equipment or machines
try:
from plugins.equipment.models import Equipment
from plugins.computers.models import Computer
# Equipment warranty
equipment_query = db.session.query(
Asset.assetid,
Asset.assetnumber,
Asset.name,
Equipment.warrantyenddate
).join(Equipment, Equipment.assetid == Asset.assetid
).filter(Asset.isactive == True)
if type_id := request.args.get('assettypeid'):
equipment_query = equipment_query.filter(Asset.assettypeid == int(type_id))
equipment_data = equipment_query.all()
expired = []
expiring_soon = []
valid = []
no_data = []
for row in equipment_data:
item = {
'assetid': row.assetid,
'assetnumber': row.assetnumber,
'name': row.name,
'warrantyenddate': row.warrantyenddate.isoformat() if row.warrantyenddate else None
}
if row.warrantyenddate is None:
no_data.append(item)
elif row.warrantyenddate < now:
expired.append(item)
elif row.warrantyenddate < expiring_threshold:
expiring_soon.append(item)
else:
valid.append(item)
data = {
'expired': {'count': len(expired), 'items': expired},
'expiringsoon': {'count': len(expiring_soon), 'items': expiring_soon},
'valid': {'count': len(valid), 'items': valid},
'nodata': {'count': len(no_data), 'items': no_data}
}
except (ImportError, AttributeError):
# Fallback: no warranty data available
data = {
'expired': {'count': 0, 'items': []},
'expiringsoon': {'count': 0, 'items': []},
'valid': {'count': 0, 'items': []},
'nodata': {'count': 0, 'items': []}
}
if request.args.get('format') == 'csv':
# Flatten for CSV
flat_data = []
for status, info in data.items():
for item in info['items']:
item['warrantystatus'] = status
flat_data.append(item)
csv_data = generate_csv(flat_data, ['assetid', 'assetnumber', 'name', 'warrantyenddate', 'warrantystatus'])
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=warranty_status.csv'}
)
return success_response({
'report': 'warranty_status',
'generated': datetime.utcnow().isoformat(),
'data': data,
'summary': {
'expired': data['expired']['count'],
'expiringsoon': data['expiringsoon']['count'],
'valid': data['valid']['count'],
'nodata': data['nodata']['count']
}
})
# =============================================================================
# Report: Software Compliance
# =============================================================================
@reports_bp.route('/software-compliance', methods=['GET'])
@jwt_required()
def software_compliance():
"""
Report: Required applications vs installed (per PC).
Shows which PCs have required applications installed.
Query parameters:
- appid: Filter to specific application
- format: 'json' (default) or 'csv'
"""
# Get required applications
required_apps = Application.query.filter(
Application.isactive == True,
Application.isrequired == True
).all()
if not required_apps:
return success_response({
'report': 'software_compliance',
'generated': datetime.utcnow().isoformat(),
'data': [],
'message': 'No required applications defined'
})
app_filter = request.args.get('appid')
data = []
for app in required_apps:
if app_filter and str(app.appid) != app_filter:
continue
# Get all PCs
total_pcs = Machine.query.filter(
Machine.isactive == True,
Machine.pctypeid.isnot(None)
).count()
# Get PCs with this app installed
installed_count = db.session.query(InstalledApp).join(
Machine, Machine.machineid == InstalledApp.machineid
).filter(
InstalledApp.appid == app.appid,
Machine.isactive == True
).count()
# Get list of non-compliant PCs
compliant_pc_ids = db.session.query(InstalledApp.machineid).filter(
InstalledApp.appid == app.appid
).subquery()
non_compliant_pcs = Machine.query.filter(
Machine.isactive == True,
Machine.pctypeid.isnot(None),
~Machine.machineid.in_(compliant_pc_ids)
).limit(100).all()
compliance_rate = (installed_count / total_pcs * 100) if total_pcs > 0 else 0
data.append({
'appid': app.appid,
'appname': app.appname,
'totalpcs': total_pcs,
'installed': installed_count,
'notinstalled': total_pcs - installed_count,
'compliancerate': round(compliance_rate, 1),
'noncompliantpcs': [
{'machineid': pc.machineid, 'hostname': pc.hostname or pc.machinenumber}
for pc in non_compliant_pcs
]
})
if request.args.get('format') == 'csv':
# Simplified CSV (no nested data)
csv_rows = []
for item in data:
csv_rows.append({
'appid': item['appid'],
'appname': item['appname'],
'totalpcs': item['totalpcs'],
'installed': item['installed'],
'notinstalled': item['notinstalled'],
'compliancerate': item['compliancerate']
})
csv_data = generate_csv(csv_rows, ['appid', 'appname', 'totalpcs', 'installed', 'notinstalled', 'compliancerate'])
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=software_compliance.csv'}
)
return success_response({
'report': 'software_compliance',
'generated': datetime.utcnow().isoformat(),
'data': data,
'total': len(data)
})
# =============================================================================
# Report: Asset Inventory Summary
# =============================================================================
@reports_bp.route('/asset-inventory', methods=['GET'])
@jwt_required()
def asset_inventory():
"""
Report: Complete asset inventory summary.
Query parameters:
- businessunitid: Filter by business unit
- locationid: Filter by location
- format: 'json' (default) or 'csv'
"""
# By asset type
by_type = db.session.query(
AssetType.assettype,
db.func.count(Asset.assetid).label('count')
).outerjoin(Asset, Asset.assettypeid == AssetType.assettypeid
).filter(
db.or_(Asset.isactive == True, Asset.assetid.is_(None)),
AssetType.isactive == True
)
if bu_id := request.args.get('businessunitid'):
by_type = by_type.filter(Asset.businessunitid == int(bu_id))
if loc_id := request.args.get('locationid'):
by_type = by_type.filter(Asset.locationid == int(loc_id))
by_type = by_type.group_by(AssetType.assettype).all()
# By status
by_status = db.session.query(
AssetStatus.status,
AssetStatus.color,
db.func.count(Asset.assetid).label('count')
).outerjoin(Asset, Asset.statusid == AssetStatus.statusid
).filter(
db.or_(Asset.isactive == True, Asset.assetid.is_(None)),
AssetStatus.isactive == True
)
if bu_id := request.args.get('businessunitid'):
by_status = by_status.filter(Asset.businessunitid == int(bu_id))
if loc_id := request.args.get('locationid'):
by_status = by_status.filter(Asset.locationid == int(loc_id))
by_status = by_status.group_by(AssetStatus.status, AssetStatus.color).all()
# By location
from shopdb.core.models import Location
by_location = db.session.query(
Location.locationname,
db.func.count(Asset.assetid).label('count')
).outerjoin(Asset, Asset.locationid == Location.locationid
).filter(
db.or_(Asset.isactive == True, Asset.assetid.is_(None)),
Location.isactive == True
)
if bu_id := request.args.get('businessunitid'):
by_location = by_location.filter(Asset.businessunitid == int(bu_id))
by_location = by_location.group_by(Location.locationname).all()
data = {
'bytype': [{'type': r.assettype, 'count': r.count} for r in by_type],
'bystatus': [{'status': r.status, 'color': r.color, 'count': r.count} for r in by_status],
'bylocation': [{'location': r.locationname, 'count': r.count} for r in by_location]
}
total = sum(r['count'] for r in data['bytype'])
if request.args.get('format') == 'csv':
# Create multiple sections in CSV
csv_output = io.StringIO()
csv_output.write("Asset Inventory Report\n")
csv_output.write(f"Generated: {datetime.utcnow().isoformat()}\n\n")
csv_output.write("By Type\n")
csv_output.write("Type,Count\n")
for r in data['bytype']:
csv_output.write(f"{r['type']},{r['count']}\n")
csv_output.write("\nBy Status\n")
csv_output.write("Status,Count\n")
for r in data['bystatus']:
csv_output.write(f"{r['status']},{r['count']}\n")
csv_output.write("\nBy Location\n")
csv_output.write("Location,Count\n")
for r in data['bylocation']:
csv_output.write(f"{r['location']},{r['count']}\n")
return Response(
csv_output.getvalue(),
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=asset_inventory.csv'}
)
return success_response({
'report': 'asset_inventory',
'generated': datetime.utcnow().isoformat(),
'data': data,
'total': total
})
# =============================================================================
# Available Reports List
# =============================================================================
@reports_bp.route('', methods=['GET'])
@jwt_required()
def list_reports():
"""List all available reports."""
reports = [
{
'id': 'equipment-by-type',
'name': 'Equipment by Type',
'description': 'Equipment count grouped by equipment type',
'endpoint': '/api/reports/equipment-by-type',
'category': 'inventory'
},
{
'id': 'assets-by-status',
'name': 'Assets by Status',
'description': 'Asset count grouped by status',
'endpoint': '/api/reports/assets-by-status',
'category': 'inventory'
},
{
'id': 'kb-popularity',
'name': 'KB Popularity',
'description': 'Most clicked knowledge base articles',
'endpoint': '/api/reports/kb-popularity',
'category': 'usage'
},
{
'id': 'warranty-status',
'name': 'Warranty Status',
'description': 'Assets by warranty expiration status',
'endpoint': '/api/reports/warranty-status',
'category': 'compliance'
},
{
'id': 'software-compliance',
'name': 'Software Compliance',
'description': 'Required applications vs installed',
'endpoint': '/api/reports/software-compliance',
'category': 'compliance'
},
{
'id': 'asset-inventory',
'name': 'Asset Inventory Summary',
'description': 'Complete asset inventory breakdown',
'endpoint': '/api/reports/asset-inventory',
'category': 'inventory'
}
]
return success_response({
'reports': reports,
'total': len(reports)
})

View File

@@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import ( from shopdb.core.models import (
Machine, Application, KnowledgeBase Machine, Application, KnowledgeBase,
Asset, AssetType
) )
from shopdb.utils.responses import success_response from shopdb.utils.responses import success_response
@@ -46,6 +47,7 @@ def global_search():
search_term = f'%{query}%' search_term = f'%{query}%'
# Search Machines (Equipment and PCs) # Search Machines (Equipment and PCs)
try:
machines = Machine.query.filter( machines = Machine.query.filter(
Machine.isactive == True, Machine.isactive == True,
db.or_( db.or_(
@@ -56,6 +58,10 @@ def global_search():
Machine.notes.ilike(search_term) Machine.notes.ilike(search_term)
) )
).limit(10).all() ).limit(10).all()
except Exception as e:
import logging
logging.error(f"Machine search failed: {e}")
machines = []
for m in machines: for m in machines:
# Determine type: PC, Printer, or Equipment # Determine type: PC, Printer, or Equipment
@@ -110,6 +116,7 @@ def global_search():
}) })
# Search Applications # Search Applications
try:
apps = Application.query.filter( apps = Application.query.filter(
Application.isactive == True, Application.isactive == True,
db.or_( db.or_(
@@ -133,8 +140,12 @@ def global_search():
'url': f"/applications/{app.appid}", 'url': f"/applications/{app.appid}",
'relevance': relevance 'relevance': relevance
}) })
except Exception as e:
import logging
logging.error(f"Application search failed: {e}")
# Search Knowledge Base # Search Knowledge Base
try:
kb_articles = KnowledgeBase.query.filter( kb_articles = KnowledgeBase.query.filter(
KnowledgeBase.isactive == True, KnowledgeBase.isactive == True,
db.or_( db.or_(
@@ -158,6 +169,9 @@ def global_search():
'linkurl': kb.linkurl, 'linkurl': kb.linkurl,
'relevance': relevance 'relevance': relevance
}) })
except Exception as e:
import logging
logging.error(f"KnowledgeBase search failed: {e}")
# Search Printers (check if printers model exists) # Search Printers (check if printers model exists)
try: try:
@@ -187,17 +201,132 @@ def global_search():
'url': f"/printers/{p.printerid}", 'url': f"/printers/{p.printerid}",
'relevance': relevance 'relevance': relevance
}) })
except ImportError: except Exception as e:
pass # Printers plugin not installed import logging
logging.error(f"Printer search failed: {e}")
# Search Employees (separate database)
try:
import pymysql
emp_conn = pymysql.connect(
host='localhost',
user='root',
password='rootpassword',
database='wjf_employees',
cursorclass=pymysql.cursors.DictCursor
)
with emp_conn.cursor() as cur:
cur.execute('''
SELECT SSO, First_Name, Last_Name, Team, Role
FROM employees
WHERE First_Name LIKE %s
OR Last_Name LIKE %s
OR CAST(SSO AS CHAR) LIKE %s
ORDER BY Last_Name, First_Name
LIMIT 10
''', (search_term, search_term, search_term))
employees = cur.fetchall()
emp_conn.close()
for emp in employees:
full_name = f"{emp['First_Name'].strip()} {emp['Last_Name'].strip()}"
sso_str = str(emp['SSO'])
# Calculate relevance
relevance = 20
if query == sso_str:
relevance = 100
elif query.lower() == full_name.lower():
relevance = 95
elif query.lower() in full_name.lower():
relevance = 60
results.append({
'type': 'employee',
'id': emp['SSO'],
'title': full_name,
'subtitle': emp.get('Team') or emp.get('Role') or f"SSO: {sso_str}",
'url': f"/employees/{emp['SSO']}",
'relevance': relevance
})
except Exception as e:
import logging
logging.error(f"Employee search failed: {e}")
# Search unified Assets table
try:
assets = Asset.query.join(AssetType).filter(
Asset.isactive == True,
db.or_(
Asset.assetnumber.ilike(search_term),
Asset.name.ilike(search_term),
Asset.serialnumber.ilike(search_term),
Asset.notes.ilike(search_term)
)
).limit(10).all()
for asset in assets:
# Calculate relevance
relevance = 15
if asset.assetnumber and query.lower() == asset.assetnumber.lower():
relevance = 100
elif asset.name and query.lower() == asset.name.lower():
relevance = 90
elif asset.serialnumber and query.lower() == asset.serialnumber.lower():
relevance = 85
elif asset.name and query.lower() in asset.name.lower():
relevance = 50
# Determine URL and type based on asset type
asset_type_name = asset.assettype.assettype if asset.assettype else 'asset'
url_map = {
'equipment': f"/equipment/{asset.assetid}",
'computer': f"/pcs/{asset.assetid}",
'network_device': f"/network/{asset.assetid}",
'printer': f"/printers/{asset.assetid}",
}
url = url_map.get(asset_type_name, f"/assets/{asset.assetid}")
display_name = asset.display_name
subtitle = None
if asset.name and asset.assetnumber != asset.name:
subtitle = asset.assetnumber
# Get location name
location_name = asset.location.locationname if asset.location else None
results.append({
'type': asset_type_name,
'id': asset.assetid,
'title': display_name,
'subtitle': subtitle,
'location': location_name,
'url': url,
'relevance': relevance
})
except Exception as e:
import logging
logging.error(f"Asset search failed: {e}")
# Sort by relevance (highest first) # Sort by relevance (highest first)
results.sort(key=lambda x: x['relevance'], reverse=True) results.sort(key=lambda x: x['relevance'], reverse=True)
# Remove duplicates (prefer higher relevance)
seen_ids = {}
unique_results = []
for r in results:
key = (r['type'], r['id'])
if key not in seen_ids:
seen_ids[key] = True
unique_results.append(r)
# Limit total results # Limit total results
results = results[:30] unique_results = unique_results[:30]
return success_response({ return success_response({
'results': results, 'results': unique_results,
'query': query, 'query': query,
'total': len(results) 'total': len(unique_results)
}) })

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