Files
shopdb-flask/frontend/src/components/AssetRelationships.vue
cproudlock 48d3160bc5
All checks were successful
CI / backend (push) Successful in 23s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Rename the equipment domain to machines; retype the models catalog (ADR-011)
The equipment plugin is now the machines plugin, ending the UI-vs-code
vocabulary split while the contract is pre-1.0 and nothing external
depends on the old names.

- plugins/equipment -> plugins/machines: manifest, class, /api/machines,
  machines.* permissions, registry key (with an auto-migrating load shim
  for existing installs).
- Tables: equipment -> machines (equipmentid -> machineid) and
  equipmenttypes -> machinetypes, renamed in the plugin's own migration
  chain (machines0002rename), idempotent for both upgrading and fresh
  installs.
- The legacy core machinetypes lookup actually types the vendor MODELS
  catalog, so it is renamed losslessly to modeltypes
  (models.modeltypeid, /api/modeltypes, Model Types settings page)
  rather than collapsed, freeing the machinetypes name. Core migration
  7d17_machines_rename also flips data in place: assettypes row
  equipment -> machine, auditlog entitytype, identifier_/search_
  settings keys, permission rows, and renames alembic_version_equipment.
- Frontend: machinesApi/modeltypesApi, item.machine response shape,
  assettype value compares 'equipment' -> 'machine' (map, search,
  custom fields, relationships), routes machines.js with plugin gating
  retagged, /print/machine-badge, Machine Types (subtypes) and Model
  Types (catalog) settings pages, machines-by-type report id.
- Docs swept; ADRs left as history per the authoring rule.

Upgrade: flask db upgrade then flask plugin upgrade-all.

Verified: dev DB flipped live (262 machines, 35 modeltypes, 95 models
retyped, zero equipment tables remain); fresh scratch-MySQL install
produces the new names; 341 tests green; naming/style green; frontend
builds; live E2E on machines list/detail, PC relationships, map,
reports, and both settings pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:17:42 -04:00

692 lines
17 KiB
Vue

<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"><component :is="getAssetIcon(rel.targetasset?.assettypename || rel.targetasset?.assettype)" :size="16" /></div>
<div class="rel-content">
<router-link :to="getAssetRoute(rel.targetasset)" class="rel-name">
{{ rel.targetasset?.name || rel.targetasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.targetasset?.assettypename || rel.targetasset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
</div>
<button
v-if="authStore.isAuthenticated"
class="btn-icon delete"
@click="deleteRelationship(rel.relationshipid)"
title="Remove relationship"
>
&times;
</button>
</div>
</div>
</div>
<!-- Incoming relationships (...controls/connects to this asset) -->
<div v-if="incoming.length > 0" class="relationship-group">
<h4 class="group-title">Incoming</h4>
<div class="relationship-list">
<div
v-for="rel in incoming"
:key="rel.relationshipid"
class="relationship-item"
>
<div class="rel-icon"><component :is="getAssetIcon(rel.sourceasset?.assettypename || rel.sourceasset?.assettype)" :size="16" /></div>
<div class="rel-content">
<router-link :to="getAssetRoute(rel.sourceasset)" class="rel-name">
{{ rel.sourceasset?.name || rel.sourceasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.sourceasset?.assettypename || rel.sourceasset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
</div>
<button
v-if="authStore.isAuthenticated"
class="btn-icon delete"
@click="deleteRelationship(rel.relationshipid)"
title="Remove relationship"
>
&times;
</button>
</div>
</div>
</div>
</template>
<!-- Add Relationship Modal -->
<div v-if="showAddModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<div class="modal-header">
<h3>Add Relationship</h3>
<button class="btn-icon" @click="closeModal">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Direction</label>
<select v-model="newRel.direction" class="form-control">
<option value="outgoing">This asset -> Target asset</option>
<option value="incoming">Source asset -> This asset</option>
</select>
</div>
<div class="form-group">
<label>Relationship Type</label>
<select v-model="newRel.relationshiptypeid" class="form-control" required>
<option value="">Select type...</option>
<option
v-for="t in relationshipTypes"
:key="t.relationshiptypeid"
:value="t.relationshiptypeid"
>
{{ t.relationshiptype }}
</option>
</select>
</div>
<div class="form-group">
<label>{{ newRel.direction === 'outgoing' ? 'Target Asset' : 'Source Asset' }}</label>
<div class="asset-search">
<input
type="text"
v-model="assetSearchQuery"
class="form-control"
placeholder="Search assets..."
@input="searchAssets"
/>
<div v-if="assetSearchResults.length > 0" class="search-results">
<div
v-for="asset in assetSearchResults"
:key="asset.assetid"
class="search-result-item"
:class="{ selected: newRel.targetAssetId === asset.assetid }"
@click="selectAsset(asset)"
>
<span class="result-icon">{{ getAssetIcon(asset.assettype) }}</span>
<span class="result-name">{{ asset.name || asset.assetnumber }}</span>
<span class="result-type">{{ asset.assettype }}</span>
</div>
</div>
</div>
<div v-if="selectedAsset" class="selected-asset">
<span class="result-icon">{{ getAssetIcon(selectedAsset.assettype) }}</span>
<span>{{ selectedAsset.name || selectedAsset.assetnumber }}</span>
<button class="btn-icon" @click="clearSelectedAsset">&times;</button>
</div>
</div>
<div class="form-group">
<label>Notes (optional)</label>
<textarea
v-model="newRel.notes"
class="form-control"
rows="2"
placeholder="Additional notes about this relationship..."
></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="closeModal">Cancel</button>
<button
class="btn btn-primary"
:disabled="!canSave"
@click="saveRelationship"
>
Add Relationship
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Package, Ruler } from 'lucide-vue-next'
import { assetsApi, relationshipTypesApi } from '../api'
import { colorStyle } from '@/utils/colorStyle'
import { useAuthStore } from '../stores/auth'
import { useToast } from '../composables/toast'
import { apiError } from '../utils/apiError'
const toast = useToast()
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([])
function colorForType(name) {
const t = relationshipTypes.value.find(rt => rt.relationshiptype === name)
return t?.color || null
}
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, { perpage: 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.sourceassetid = resolvedAssetId.value
data.targetassetid = newRel.value.targetAssetId
} else {
data.sourceassetid = newRel.value.targetAssetId
data.targetassetid = resolvedAssetId.value
}
await assetsApi.createRelationship(data)
await loadRelationships()
closeModal()
emit('updated')
} catch (error) {
console.error('Failed to create relationship:', error)
toast.error(apiError(error, 'Failed to create relationship'))
}
}
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)
toast.error('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 = {
'machine': Cog,
'computer': Monitor,
'printer': Printer,
'network_device': Globe,
'measuring_tool': Ruler
}
return icons[assettype] || Package
}
function getAssetRoute(asset) {
if (!asset) return '#'
const routeMap = {
'machine': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network',
'measuring_tool': '/measuringtools'
}
// serializer sends assettypename; old shape used assettype
const typename = asset.assettypename || asset.assettype
const basePath = routeMap[typename] || '/assets'
// pluginid is the extension-table id the detail routes key on
const id = asset.pluginid
|| asset.typedata?.networkdeviceid
|| 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>