Add application support teams with contacts
Replaces the legacy supportteams/appowners pair: supportteams (teamname unique, teamurl ServiceNow link) + supportteamcontacts (multiple named contacts with SSO per team, the people you reach out to), applications.supportteamid intact. Migration 7d18 migrates each legacy team owner into a contact, drops appowners, and has a validated downgrade. New /api/supportteams CRUD (admin writes, import-mode timestamps, teamname lookup), Support card on application detail, contacts column on the list, and a settings management page. IMPORT-API.md mapping updated to the concrete endpoints. 658 tests pass; live dev migration applied (24 teams / 24 contacts); fresh-install and downgrade round-trips verified on scratch DBs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -470,20 +470,36 @@ export const applicationsApi = {
|
||||
},
|
||||
updateInstalledApp(machineId, appId, data) {
|
||||
return api.put(`/applications/machines/${machineId}/${appId}`, data)
|
||||
}
|
||||
}
|
||||
|
||||
// Support Teams API (teams + nested contacts)
|
||||
export const supportteamsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/supportteams', { params })
|
||||
},
|
||||
// Support teams
|
||||
getSupportTeams() {
|
||||
return api.get('/applications/supportteams')
|
||||
get(id) {
|
||||
return api.get(`/supportteams/${id}`)
|
||||
},
|
||||
createSupportTeam(data) {
|
||||
return api.post('/applications/supportteams', data)
|
||||
create(data) {
|
||||
return api.post('/supportteams', data)
|
||||
},
|
||||
// App owners
|
||||
getAppOwners() {
|
||||
return api.get('/applications/appowners')
|
||||
update(id, data) {
|
||||
return api.put(`/supportteams/${id}`, data)
|
||||
},
|
||||
createAppOwner(data) {
|
||||
return api.post('/applications/appowners', data)
|
||||
remove(id) {
|
||||
return api.delete(`/supportteams/${id}`)
|
||||
},
|
||||
contacts: {
|
||||
add(teamId, data) {
|
||||
return api.post(`/supportteams/${teamId}/contacts`, data)
|
||||
},
|
||||
update(teamId, contactId, data) {
|
||||
return api.put(`/supportteams/${teamId}/contacts/${contactId}`, data)
|
||||
},
|
||||
remove(teamId, contactId) {
|
||||
return api.delete(`/supportteams/${teamId}/contacts/${contactId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,12 @@ export default [
|
||||
component: () => import('../../views/settings/BusinessUnitsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/supportteams',
|
||||
name: 'supportteams',
|
||||
component: () => import('../../views/settings/SupportTeamsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/dashboarddefaults',
|
||||
name: 'dashboarddefaults',
|
||||
|
||||
@@ -48,26 +48,26 @@
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Support Info -->
|
||||
<!-- Support -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Support Information</h3>
|
||||
<h3 class="section-title">Support</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Support Team</span>
|
||||
<span class="info-value">
|
||||
<a v-if="app.supportteam?.teamurl" :href="app.supportteam.teamurl" target="_blank">
|
||||
{{ app.supportteam?.teamname || '-' }}
|
||||
<a v-if="app.teamurl" :href="app.teamurl" target="_blank">
|
||||
{{ app.supportteamname || '-' }}
|
||||
</a>
|
||||
<span v-else>{{ app.supportteam?.teamname || '-' }}</span>
|
||||
<span v-else>{{ app.supportteamname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">App Owner</span>
|
||||
<span class="info-value">{{ app.supportteam?.owner?.appowner || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="app.supportteam?.owner?.sso">
|
||||
<span class="info-label">SSO</span>
|
||||
<span class="info-value mono">{{ app.supportteam.owner.sso }}</span>
|
||||
<div class="info-row" v-if="app.contacts && app.contacts.length">
|
||||
<span class="info-label">Contacts</span>
|
||||
<span class="info-value">
|
||||
<span v-for="(contact, index) in app.contacts" :key="index" class="contact-line">
|
||||
{{ contact.name }}<span v-if="contact.sso" class="mono"> ({{ contact.sso }})</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,6 +326,11 @@ function handleImageError(e) {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Support contacts stack one per line */
|
||||
.contact-line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
|
||||
.notes-text {
|
||||
white-space: pre-wrap;
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { applicationsApi } from '../../api'
|
||||
import { applicationsApi, supportteamsApi } from '../../api'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -187,7 +187,7 @@ const supportTeams = ref([])
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load support teams
|
||||
const teamsRes = await applicationsApi.getSupportTeams()
|
||||
const teamsRes = await supportteamsApi.list()
|
||||
supportTeams.value = teamsRes.data.data || []
|
||||
|
||||
// Load application if editing
|
||||
@@ -198,7 +198,7 @@ onMounted(async () => {
|
||||
form.value = {
|
||||
appname: app.appname || '',
|
||||
appdescription: app.appdescription || '',
|
||||
supportteamid: app.supportteam?.supportteamid || '',
|
||||
supportteamid: app.supportteamid || '',
|
||||
isinstallable: app.isinstallable || false,
|
||||
islicenced: app.islicenced || false,
|
||||
isprinter: app.isprinter || false,
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<th>Application Name</th>
|
||||
<th>Description</th>
|
||||
<th>Support Team</th>
|
||||
<th>App Owner</th>
|
||||
<th>Contacts</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -64,8 +64,8 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="description">{{ app.appdescription || '-' }}</td>
|
||||
<td>{{ app.supportteam?.teamname || '-' }}</td>
|
||||
<td>{{ app.supportteam?.owner?.appowner || '-' }}</td>
|
||||
<td>{{ app.supportteamname || '-' }}</td>
|
||||
<td>{{ app.contacts && app.contacts.length ? app.contacts.map(c => c.name).join(', ') : '-' }}</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/applications/${app.appid}`"
|
||||
|
||||
320
frontend/src/views/settings/SupportTeamsList.vue
Normal file
320
frontend/src/views/settings/SupportTeamsList.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Support Teams</h2>
|
||||
<button class="btn btn-primary" @click="openTeamModal()">+ Add Support Team</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th>Team Name</th>
|
||||
<th>Link</th>
|
||||
<th>Contacts</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="team in teams" :key="team.supportteamid">
|
||||
<tr>
|
||||
<td>
|
||||
<button class="btn btn-secondary btn-sm" @click="toggleExpand(team.supportteamid)">
|
||||
{{ expandedTeamId === team.supportteamid ? '-' : '+' }}
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ team.teamname }}</td>
|
||||
<td>
|
||||
<a v-if="team.teamurl" :href="team.teamurl" target="_blank">Link</a>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>{{ team.contacts ? team.contacts.length : 0 }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openTeamModal(team)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="confirmDeleteTeam(team)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedTeamId === team.supportteamid">
|
||||
<td></td>
|
||||
<td colspan="4">
|
||||
<div class="contacts-panel">
|
||||
<div class="contacts-header">
|
||||
<strong>Contacts</strong>
|
||||
<button class="btn btn-primary btn-sm" @click="openContactModal(team)">+ Add Contact</button>
|
||||
</div>
|
||||
<table class="contacts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>SSO</th>
|
||||
<th>Order</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="contact in team.contacts" :key="contact.contactid">
|
||||
<td>{{ contact.name }}</td>
|
||||
<td class="mono">{{ contact.sso || '-' }}</td>
|
||||
<td>{{ contact.sortorder }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openContactModal(team, contact)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteContact(team, contact)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!team.contacts || team.contacts.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No contacts yet
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="teams.length === 0">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-light);">
|
||||
No support teams found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Team Add/Edit Modal -->
|
||||
<div v-if="showTeamModal" class="modal-overlay" @click.self="showTeamModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingTeam ? 'Edit Support Team' : 'Add Support Team' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveTeam">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="teamname">Team Name *</label>
|
||||
<input id="teamname" v-model="teamForm.teamname" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="teamurl">Team URL</label>
|
||||
<input id="teamurl" v-model="teamForm.teamurl" type="text" class="form-control" placeholder="ServiceNow group link" />
|
||||
</div>
|
||||
<div v-if="teamError" class="error-message">{{ teamError }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showTeamModal = false">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Add/Edit Modal -->
|
||||
<div v-if="showContactModal" class="modal-overlay" @click.self="showContactModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingContact ? 'Edit Contact' : 'Add Contact' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveContact">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="name">Name *</label>
|
||||
<input id="name" v-model="contactForm.name" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sso">SSO</label>
|
||||
<input id="sso" v-model="contactForm.sso" type="text" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sortorder">Sort Order</label>
|
||||
<input id="sortorder" v-model.number="contactForm.sortorder" type="number" class="form-control" />
|
||||
</div>
|
||||
<div v-if="contactError" class="error-message">{{ contactError }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showContactModal = false">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Team Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Delete Support Team</h3></div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ toDelete?.teamname }}</strong>?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteTeam">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supportteamsApi } from '../../api'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const teams = ref([])
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const expandedTeamId = ref(null)
|
||||
|
||||
const showTeamModal = ref(false)
|
||||
const editingTeam = ref(null)
|
||||
const teamError = ref('')
|
||||
const teamForm = ref({ teamname: '', teamurl: '' })
|
||||
|
||||
const showContactModal = ref(false)
|
||||
const editingContact = ref(null)
|
||||
const contactTeam = ref(null)
|
||||
const contactError = ref('')
|
||||
const contactForm = ref({ name: '', sso: '', sortorder: 0 })
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const toDelete = ref(null)
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await supportteamsApi.list({ active: false })
|
||||
teams.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading support teams:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand(teamId) {
|
||||
expandedTeamId.value = expandedTeamId.value === teamId ? null : teamId
|
||||
}
|
||||
|
||||
function openTeamModal(team = null) {
|
||||
editingTeam.value = team
|
||||
teamForm.value = team
|
||||
? { teamname: team.teamname || '', teamurl: team.teamurl || '' }
|
||||
: { teamname: '', teamurl: '' }
|
||||
teamError.value = ''
|
||||
showTeamModal.value = true
|
||||
}
|
||||
|
||||
async function saveTeam() {
|
||||
teamError.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
teamname: teamForm.value.teamname,
|
||||
teamurl: teamForm.value.teamurl || null
|
||||
}
|
||||
if (editingTeam.value) {
|
||||
await supportteamsApi.update(editingTeam.value.supportteamid, payload)
|
||||
} else {
|
||||
await supportteamsApi.create(payload)
|
||||
}
|
||||
showTeamModal.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
teamError.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteTeam(team) {
|
||||
toDelete.value = team
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteTeam() {
|
||||
try {
|
||||
await supportteamsApi.remove(toDelete.value.supportteamid)
|
||||
showDeleteModal.value = false
|
||||
toDelete.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
showDeleteModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openContactModal(team, contact = null) {
|
||||
contactTeam.value = team
|
||||
editingContact.value = contact
|
||||
contactForm.value = contact
|
||||
? { name: contact.name || '', sso: contact.sso || '', sortorder: contact.sortorder || 0 }
|
||||
: { name: '', sso: '', sortorder: 0 }
|
||||
contactError.value = ''
|
||||
showContactModal.value = true
|
||||
}
|
||||
|
||||
async function saveContact() {
|
||||
contactError.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const teamId = contactTeam.value.supportteamid
|
||||
const payload = {
|
||||
name: contactForm.value.name,
|
||||
sso: contactForm.value.sso || null,
|
||||
sortorder: contactForm.value.sortorder || 0
|
||||
}
|
||||
if (editingContact.value) {
|
||||
await supportteamsApi.contacts.update(teamId, editingContact.value.contactid, payload)
|
||||
} else {
|
||||
await supportteamsApi.contacts.add(teamId, payload)
|
||||
}
|
||||
showContactModal.value = false
|
||||
await loadData()
|
||||
expandedTeamId.value = teamId
|
||||
} catch (err) {
|
||||
contactError.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContact(team, contact) {
|
||||
try {
|
||||
await supportteamsApi.contacts.remove(team.supportteamid, contact.contactid)
|
||||
await loadData()
|
||||
expandedTeamId.value = team.supportteamid
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contacts-panel {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.contacts-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.contacts-table {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -22,6 +22,7 @@ export const settingsGroups = [
|
||||
{ to: '/settings/relationshiptypes', icon: Link, title: 'Relationship Types', description: 'Manage asset relationship types (Controls, Contains...) + colors' },
|
||||
{ to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' },
|
||||
{ to: '/settings/customfields', icon: SlidersHorizontal, title: 'Custom Fields', description: 'Define extra attributes per asset type (shown on detail + forms)' },
|
||||
{ to: '/settings/supportteams', icon: Users, title: 'Support Teams', description: 'Application support teams and their contacts (ServiceNow group links)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user