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:
41
frontend/package-lock.json
generated
41
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
BIN
frontend/public/static/images/sitemap2025-dark.png
Normal file
BIN
frontend/public/static/images/sitemap2025-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 832 KiB |
BIN
frontend/public/static/images/sitemap2025-light.png
Normal file
BIN
frontend/public/static/images/sitemap2025-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 828 KiB |
@@ -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
679
frontend/src/components/AssetRelationships.vue
Normal file
679
frontend/src/components/AssetRelationships.vue
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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">×</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">×</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>
|
||||||
145
frontend/src/components/EmbeddedLocationMap.vue
Normal file
145
frontend/src/components/EmbeddedLocationMap.vue
Normal 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>
|
||||||
251
frontend/src/components/EmployeeSearch.vue
Normal file
251
frontend/src/components/EmployeeSearch.vue
Normal 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)">×</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">×</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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
if (typeName && typeName.toLowerCase() !== 'locationonly') {
|
// Asset mode tooltips
|
||||||
tooltipLines.push(`<span style="color: #888;">${typeName}</span>`)
|
const assetTypeLabel = {
|
||||||
}
|
'equipment': 'Equipment',
|
||||||
|
'computer': 'Computer',
|
||||||
// Add vendor and model if available
|
'printer': 'Printer',
|
||||||
if (machine.vendor) {
|
'network_device': 'Network Device'
|
||||||
tooltipLines.push(`<span style="color: #aaa;">${machine.vendor}${machine.model ? ' ' + machine.model : ''}</span>`)
|
|
||||||
} else if (machine.model) {
|
|
||||||
tooltipLines.push(`<span style="color: #aaa;">${machine.model}</span>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category-specific info
|
|
||||||
if (category === 'printer') {
|
|
||||||
// Printers: show IP and hostname
|
|
||||||
if (machine.ipaddress) {
|
|
||||||
tooltipLines.push(`<span style="color: #8cf;">IP: ${machine.ipaddress}</span>`)
|
|
||||||
}
|
}
|
||||||
if (machine.hostname) {
|
tooltipLines.push(`<span style="color: #888;">${assetTypeLabel[item.assettype] || item.assettype}</span>`)
|
||||||
tooltipLines.push(`<span style="color: #8cf;">${machine.hostname}</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 {
|
} else {
|
||||||
// Equipment/PC: show connected PC if available
|
// Legacy machine mode tooltips
|
||||||
if (machine.connected_pc) {
|
const category = item.category?.toLowerCase() || ''
|
||||||
tooltipLines.push(`<span style="color: #fc8;">PC: ${machine.connected_pc}</span>`)
|
|
||||||
|
if (typeName && typeName.toLowerCase() !== 'locationonly') {
|
||||||
|
tooltipLines.push(`<span style="color: #888;">${typeName}</span>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.vendor) {
|
||||||
|
tooltipLines.push(`<span style="color: #aaa;">${item.vendor}${item.model ? ' ' + item.model : ''}</span>`)
|
||||||
|
} else if (item.model) {
|
||||||
|
tooltipLines.push(`<span style="color: #aaa;">${item.model}</span>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category === 'printer') {
|
||||||
|
if (item.ipaddress) {
|
||||||
|
tooltipLines.push(`<span style="color: #8cf;">IP: ${item.ipaddress}</span>`)
|
||||||
|
}
|
||||||
|
if (item.hostname) {
|
||||||
|
tooltipLines.push(`<span style="color: #8cf;">${item.hostname}</span>`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (item.connected_pc) {
|
||||||
|
tooltipLines.push(`<span style="color: #fc8;">PC: ${item.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
|
||||||
<div class="marker-popup">
|
if (props.assetTypeMode) {
|
||||||
<strong>${displayName}</strong>
|
popupContent = `
|
||||||
<div class="popup-details">
|
<div class="marker-popup">
|
||||||
<div><span class="label">Number:</span> ${machine.machinenumber || '-'}</div>
|
<strong>${displayName}</strong>
|
||||||
<div><span class="label">Type:</span> ${typeName || '-'}</div>
|
<div class="popup-details">
|
||||||
<div><span class="label">Category:</span> ${machine.category || '-'}</div>
|
<div><span class="label">Asset #:</span> ${item.assetnumber || '-'}</div>
|
||||||
<div><span class="label">Status:</span> ${machine.status || '-'}</div>
|
<div><span class="label">Type:</span> ${item.assettype || '-'}</div>
|
||||||
<div><span class="label">Vendor:</span> ${machine.vendor || '-'}</div>
|
<div><span class="label">Status:</span> ${item.status || '-'}</div>
|
||||||
<div><span class="label">Model:</span> ${machine.model || '-'}</div>
|
<div><span class="label">Location:</span> ${item.location || '-'}</div>
|
||||||
|
${item.primaryip ? `<div><span class="label">IP:</span> ${item.primaryip}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<a href="${detailRoute}" class="popup-link">View Details</a>
|
||||||
</div>
|
</div>
|
||||||
<a href="${detailRoute}" class="popup-link">View Details</a>
|
`
|
||||||
</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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
39
frontend/src/stores/theme.js
Normal file
39
frontend/src/stores/theme.js
Normal 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
|
||||||
|
}
|
||||||
@@ -17,21 +17,42 @@
|
|||||||
|
|
||||||
<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="user-menu">
|
<div class="sidebar-footer">
|
||||||
<template v-if="authStore.isAuthenticated">
|
<button class="theme-toggle" @click="toggleTheme" :title="currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'">
|
||||||
<div class="username">{{ authStore.username }}</div>
|
<span v-if="currentTheme === 'dark'">☀️ Light</span>
|
||||||
<button class="btn btn-secondary" @click="handleLogout">Logout</button>
|
<span v-else>🌙 Dark</span>
|
||||||
</template>
|
</button>
|
||||||
<router-link v-else to="/login" class="btn btn-primary">Login</router-link>
|
|
||||||
|
<div class="user-menu">
|
||||||
|
<template v-if="authStore.isAuthenticated">
|
||||||
|
<div class="username">{{ authStore.username }}</div>
|
||||||
|
<button class="btn btn-secondary" @click="handleLogout">Logout</button>
|
||||||
|
</template>
|
||||||
|
<router-link v-else to="/login" class="btn btn-primary">Login</router-link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|||||||
379
frontend/src/views/CalendarView.vue
Normal file
379
frontend/src/views/CalendarView.vue
Normal 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>
|
||||||
@@ -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">
|
||||||
|
|||||||
407
frontend/src/views/MapEditor.vue
Normal file
407
frontend/src/views/MapEditor.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
<ShopFloorMap
|
<template v-else>
|
||||||
v-else
|
<!-- Layer Toggles -->
|
||||||
:machines="machines"
|
<div class="layer-toggles">
|
||||||
:machinetypes="machinetypes"
|
<label v-for="t in assetTypes" :key="t.assettypeid" class="layer-toggle">
|
||||||
:businessunits="businessunits"
|
<input
|
||||||
:statuses="statuses"
|
type="checkbox"
|
||||||
@markerClick="handleMarkerClick"
|
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
|
||||||
|
:machines="filteredAssets"
|
||||||
|
:machinetypes="[]"
|
||||||
|
:businessunits="businessunits"
|
||||||
|
:statuses="statuses"
|
||||||
|
:assetTypeMode="true"
|
||||||
|
:theme="currentTheme"
|
||||||
|
@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;
|
||||||
}
|
}
|
||||||
|
|||||||
531
frontend/src/views/ShopfloorDashboard.vue
Normal file
531
frontend/src/views/ShopfloorDashboard.vue
Normal 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">★</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>
|
||||||
179
frontend/src/views/TVDashboard.vue
Normal file
179
frontend/src/views/TVDashboard.vue
Normal 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>
|
||||||
320
frontend/src/views/employees/EmployeeDetail.vue
Normal file
320
frontend/src/views/employees/EmployeeDetail.vue
Normal 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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
348
frontend/src/views/network/NetworkDeviceDetail.vue
Normal file
348
frontend/src/views/network/NetworkDeviceDetail.vue
Normal 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>
|
||||||
497
frontend/src/views/network/NetworkDeviceForm.vue
Normal file
497
frontend/src/views/network/NetworkDeviceForm.vue
Normal 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>
|
||||||
360
frontend/src/views/network/NetworkDevicesList.vue
Normal file
360
frontend/src/views/network/NetworkDevicesList.vue
Normal 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>
|
||||||
601
frontend/src/views/notifications/NotificationForm.vue
Normal file
601
frontend/src/views/notifications/NotificationForm.vue
Normal 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)">×</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>
|
||||||
168
frontend/src/views/notifications/NotificationsList.vue
Normal file
168
frontend/src/views/notifications/NotificationsList.vue
Normal 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>
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
269
frontend/src/views/reports/ReportsIndex.vue
Normal file
269
frontend/src/views/reports/ReportsIndex.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
559
frontend/src/views/settings/SubnetsList.vue
Normal file
559
frontend/src/views/settings/SubnetsList.vue
Normal 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>
|
||||||
369
frontend/src/views/settings/VLANsList.vue
Normal file
369
frontend/src/views/settings/VLANsList.vue
Normal 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>
|
||||||
284
frontend/src/views/usb/USBDetail.vue
Normal file
284
frontend/src/views/usb/USBDetail.vue
Normal 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>
|
||||||
230
frontend/src/views/usb/USBForm.vue
Normal file
230
frontend/src/views/usb/USBForm.vue
Normal 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>
|
||||||
290
frontend/src/views/usb/USBList.vue
Normal file
290
frontend/src/views/usb/USBList.vue
Normal 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>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
plugins/computers/__init__.py
Normal file
5
plugins/computers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Computers plugin for ShopDB."""
|
||||||
|
|
||||||
|
from .plugin import ComputersPlugin
|
||||||
|
|
||||||
|
__all__ = ['ComputersPlugin']
|
||||||
5
plugins/computers/api/__init__.py
Normal file
5
plugins/computers/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Computers plugin API."""
|
||||||
|
|
||||||
|
from .routes import computers_bp
|
||||||
|
|
||||||
|
__all__ = ['computers_bp']
|
||||||
628
plugins/computers/api/routes.py
Normal file
628
plugins/computers/api/routes.py
Normal 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
|
||||||
|
})
|
||||||
23
plugins/computers/manifest.json
Normal file
23
plugins/computers/manifest.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
9
plugins/computers/models/__init__.py
Normal file
9
plugins/computers/models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""Computers plugin models."""
|
||||||
|
|
||||||
|
from .computer import Computer, ComputerType, ComputerInstalledApp
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'Computer',
|
||||||
|
'ComputerType',
|
||||||
|
'ComputerInstalledApp',
|
||||||
|
]
|
||||||
184
plugins/computers/models/computer.py
Normal file
184
plugins/computers/models/computer.py
Normal 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
209
plugins/computers/plugin.py
Normal 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,
|
||||||
|
},
|
||||||
|
]
|
||||||
5
plugins/equipment/__init__.py
Normal file
5
plugins/equipment/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Equipment plugin for ShopDB."""
|
||||||
|
|
||||||
|
from .plugin import EquipmentPlugin
|
||||||
|
|
||||||
|
__all__ = ['EquipmentPlugin']
|
||||||
5
plugins/equipment/api/__init__.py
Normal file
5
plugins/equipment/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Equipment plugin API."""
|
||||||
|
|
||||||
|
from .routes import equipment_bp
|
||||||
|
|
||||||
|
__all__ = ['equipment_bp']
|
||||||
429
plugins/equipment/api/routes.py
Normal file
429
plugins/equipment/api/routes.py
Normal 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]
|
||||||
|
})
|
||||||
22
plugins/equipment/manifest.json
Normal file
22
plugins/equipment/manifest.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
8
plugins/equipment/models/__init__.py
Normal file
8
plugins/equipment/models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""Equipment plugin models."""
|
||||||
|
|
||||||
|
from .equipment import Equipment, EquipmentType
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'Equipment',
|
||||||
|
'EquipmentType',
|
||||||
|
]
|
||||||
109
plugins/equipment/models/equipment.py
Normal file
109
plugins/equipment/models/equipment.py
Normal 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
220
plugins/equipment/plugin.py
Normal 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,
|
||||||
|
},
|
||||||
|
]
|
||||||
5
plugins/network/__init__.py
Normal file
5
plugins/network/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Network plugin for ShopDB."""
|
||||||
|
|
||||||
|
from .plugin import NetworkPlugin
|
||||||
|
|
||||||
|
__all__ = ['NetworkPlugin']
|
||||||
5
plugins/network/api/__init__.py
Normal file
5
plugins/network/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Network plugin API."""
|
||||||
|
|
||||||
|
from .routes import network_bp
|
||||||
|
|
||||||
|
__all__ = ['network_bp']
|
||||||
817
plugins/network/api/routes.py
Normal file
817
plugins/network/api/routes.py
Normal 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')
|
||||||
22
plugins/network/manifest.json
Normal file
22
plugins/network/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
plugins/network/models/__init__.py
Normal file
11
plugins/network/models/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""Network plugin models."""
|
||||||
|
|
||||||
|
from .network_device import NetworkDevice, NetworkDeviceType
|
||||||
|
from .subnet import Subnet, VLAN
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'NetworkDevice',
|
||||||
|
'NetworkDeviceType',
|
||||||
|
'Subnet',
|
||||||
|
'VLAN',
|
||||||
|
]
|
||||||
121
plugins/network/models/network_device.py
Normal file
121
plugins/network/models/network_device.py
Normal 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
|
||||||
146
plugins/network/models/subnet.py
Normal file
146
plugins/network/models/subnet.py
Normal 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
217
plugins/network/plugin.py
Normal 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,
|
||||||
|
},
|
||||||
|
]
|
||||||
5
plugins/notifications/__init__.py
Normal file
5
plugins/notifications/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Notifications plugin package."""
|
||||||
|
|
||||||
|
from .plugin import NotificationsPlugin
|
||||||
|
|
||||||
|
__all__ = ['NotificationsPlugin']
|
||||||
5
plugins/notifications/api/__init__.py
Normal file
5
plugins/notifications/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Notifications plugin API."""
|
||||||
|
|
||||||
|
from .routes import notifications_bp
|
||||||
|
|
||||||
|
__all__ = ['notifications_bp']
|
||||||
617
plugins/notifications/api/routes.py
Normal file
617
plugins/notifications/api/routes.py
Normal 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
|
||||||
|
})
|
||||||
12
plugins/notifications/manifest.json
Normal file
12
plugins/notifications/manifest.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
5
plugins/notifications/models/__init__.py
Normal file
5
plugins/notifications/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Notifications plugin models."""
|
||||||
|
|
||||||
|
from .notification import Notification, NotificationType
|
||||||
|
|
||||||
|
__all__ = ['Notification', 'NotificationType']
|
||||||
157
plugins/notifications/models/notification.py
Normal file
157
plugins/notifications/models/notification.py
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
204
plugins/notifications/plugin.py
Normal file
204
plugins/notifications/plugin.py
Normal 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,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
|||||||
472
plugins/printers/api/asset_routes.py
Normal file
472
plugins/printers/api/asset_routes.py
Normal 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],
|
||||||
|
})
|
||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
122
plugins/printers/models/printer.py
Normal file
122
plugins/printers/models/printer.py
Normal 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
|
||||||
@@ -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
5
plugins/usb/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""USB device checkout plugin."""
|
||||||
|
|
||||||
|
from .plugin import USBPlugin
|
||||||
|
|
||||||
|
__all__ = ['USBPlugin']
|
||||||
5
plugins/usb/api/__init__.py
Normal file
5
plugins/usb/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""USB plugin API."""
|
||||||
|
|
||||||
|
from .routes import usb_bp
|
||||||
|
|
||||||
|
__all__ = ['usb_bp']
|
||||||
275
plugins/usb/api/routes.py
Normal file
275
plugins/usb/api/routes.py
Normal 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)
|
||||||
9
plugins/usb/manifest.json
Normal file
9
plugins/usb/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
5
plugins/usb/models/__init__.py
Normal file
5
plugins/usb/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""USB plugin models."""
|
||||||
|
|
||||||
|
from .usb_checkout import USBCheckout
|
||||||
|
|
||||||
|
__all__ = ['USBCheckout']
|
||||||
38
plugins/usb/models/usb_checkout.py
Normal file
38
plugins/usb/models/usb_checkout.py
Normal 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
|
||||||
|
}
|
||||||
169
plugins/usb/models/usb_device.py
Normal file
169
plugins/usb/models/usb_device.py
Normal 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
80
plugins/usb/plugin.py
Normal 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,
|
||||||
|
},
|
||||||
|
]
|
||||||
1
scripts/migration/__init__.py
Normal file
1
scripts/migration/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Data migration scripts for VBScript ShopDB to Flask migration."""
|
||||||
88
scripts/migration/fix_legacy_schema.sql
Normal file
88
scripts/migration/fix_legacy_schema.sql
Normal 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;
|
||||||
285
scripts/migration/migrate_assets.py
Normal file
285
scripts/migration/migrate_assets.py
Normal 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()
|
||||||
113
scripts/migration/migrate_communications.py
Normal file
113
scripts/migration/migrate_communications.py
Normal 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()
|
||||||
139
scripts/migration/migrate_notifications.py
Normal file
139
scripts/migration/migrate_notifications.py
Normal 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()
|
||||||
199
scripts/migration/migrate_usb.py
Normal file
199
scripts/migration/migrate_usb.py
Normal 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()
|
||||||
139
scripts/migration/run_migration.py
Normal file
139
scripts/migration/run_migration.py
Normal 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()
|
||||||
174
scripts/migration/verify_migration.py
Normal file
174
scripts/migration/verify_migration.py
Normal 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()
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
659
shopdb/core/api/assets.py
Normal 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)
|
||||||
374
shopdb/core/api/collector.py
Normal file
374
shopdb/core/api/collector.py
Normal 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'
|
||||||
|
]
|
||||||
|
})
|
||||||
161
shopdb/core/api/employees.py
Normal file
161
shopdb/core/api/employees.py
Normal 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
|
||||||
|
)
|
||||||
@@ -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
573
shopdb/core/api/reports.py
Normal 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)
|
||||||
|
})
|
||||||
@@ -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,16 +47,21 @@ def global_search():
|
|||||||
search_term = f'%{query}%'
|
search_term = f'%{query}%'
|
||||||
|
|
||||||
# Search Machines (Equipment and PCs)
|
# Search Machines (Equipment and PCs)
|
||||||
machines = Machine.query.filter(
|
try:
|
||||||
Machine.isactive == True,
|
machines = Machine.query.filter(
|
||||||
db.or_(
|
Machine.isactive == True,
|
||||||
Machine.machinenumber.ilike(search_term),
|
db.or_(
|
||||||
Machine.alias.ilike(search_term),
|
Machine.machinenumber.ilike(search_term),
|
||||||
Machine.hostname.ilike(search_term),
|
Machine.alias.ilike(search_term),
|
||||||
Machine.serialnumber.ilike(search_term),
|
Machine.hostname.ilike(search_term),
|
||||||
Machine.notes.ilike(search_term)
|
Machine.serialnumber.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,54 +116,62 @@ def global_search():
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Search Applications
|
# Search Applications
|
||||||
apps = Application.query.filter(
|
try:
|
||||||
Application.isactive == True,
|
apps = Application.query.filter(
|
||||||
db.or_(
|
Application.isactive == True,
|
||||||
Application.appname.ilike(search_term),
|
db.or_(
|
||||||
Application.appdescription.ilike(search_term)
|
Application.appname.ilike(search_term),
|
||||||
)
|
Application.appdescription.ilike(search_term)
|
||||||
).limit(10).all()
|
)
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
for app in apps:
|
for app in apps:
|
||||||
relevance = 20
|
relevance = 20
|
||||||
if query.lower() == app.appname.lower():
|
if query.lower() == app.appname.lower():
|
||||||
relevance = 100
|
relevance = 100
|
||||||
elif query.lower() in app.appname.lower():
|
elif query.lower() in app.appname.lower():
|
||||||
relevance = 50
|
relevance = 50
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
'type': 'application',
|
'type': 'application',
|
||||||
'id': app.appid,
|
'id': app.appid,
|
||||||
'title': app.appname,
|
'title': app.appname,
|
||||||
'subtitle': app.appdescription[:100] if app.appdescription else None,
|
'subtitle': app.appdescription[:100] if app.appdescription else None,
|
||||||
'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
|
||||||
kb_articles = KnowledgeBase.query.filter(
|
try:
|
||||||
KnowledgeBase.isactive == True,
|
kb_articles = KnowledgeBase.query.filter(
|
||||||
db.or_(
|
KnowledgeBase.isactive == True,
|
||||||
KnowledgeBase.shortdescription.ilike(search_term),
|
db.or_(
|
||||||
KnowledgeBase.keywords.ilike(search_term)
|
KnowledgeBase.shortdescription.ilike(search_term),
|
||||||
)
|
KnowledgeBase.keywords.ilike(search_term)
|
||||||
).limit(20).all()
|
)
|
||||||
|
).limit(20).all()
|
||||||
|
|
||||||
for kb in kb_articles:
|
for kb in kb_articles:
|
||||||
# Weight by clicks and keyword match
|
# Weight by clicks and keyword match
|
||||||
relevance = 10 + (kb.clicks or 0) * 0.1
|
relevance = 10 + (kb.clicks or 0) * 0.1
|
||||||
if kb.keywords and query.lower() in kb.keywords.lower():
|
if kb.keywords and query.lower() in kb.keywords.lower():
|
||||||
relevance += 15
|
relevance += 15
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
'type': 'knowledgebase',
|
'type': 'knowledgebase',
|
||||||
'id': kb.linkid,
|
'id': kb.linkid,
|
||||||
'title': kb.shortdescription,
|
'title': kb.shortdescription,
|
||||||
'subtitle': kb.application.appname if kb.application else None,
|
'subtitle': kb.application.appname if kb.application else None,
|
||||||
'url': f"/knowledgebase/{kb.linkid}",
|
'url': f"/knowledgebase/{kb.linkid}",
|
||||||
'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
Reference in New Issue
Block a user