Final-pass polish: support contact UX, audit tooltip, doc refresh
All checks were successful
CI / backend (push) Successful in 1m21s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s

Support teams: contact management moved from a row expander to a modal
(Contacts (N) button per team); application detail Support card and the
modal show Email (mailto) and Teams chat buttons for contacts with an
SSO, derived as sso@ + a new contact_email_domain site setting
(default geaerospace.com, blank hides the buttons).

Audit log: hovering a user SSO shows the full name, resolved
best-effort from the employee directory in either mode.

Docs/hygiene from a standards review: CLAUDE.md active-state,
CONTRACT-STABILITY.md and README brought to contract 0.10.0 / 11
plugins / migration head 7d22; get_asset_panels endpoint path fixed in
the hook docstring; leftover debug console.logs removed.

781 tests pass; contacts modal, action-button hrefs, and the audit
tooltip verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 11:22:01 -04:00
parent 1c5128a5c8
commit 7d309aabeb
18 changed files with 234 additions and 90 deletions

View File

@@ -66,6 +66,10 @@
<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>
<template v-if="contact.sso && contactEmailDomain">
<a class="contact-action" :href="`mailto:${contactEmail(contact)}`" title="Email">Email</a>
<a class="contact-action" :href="`https://teams.microsoft.com/l/chat/0/0?users=${contactEmail(contact)}`" target="_blank" rel="noopener" title="Teams chat">Teams</a>
</template>
</span>
</span>
</div>
@@ -136,6 +140,7 @@
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { applicationsApi } from '../../api'
import { getContactEmailDomain } from '../../utils/siteSettings'
const route = useRoute()
@@ -143,9 +148,16 @@ const loading = ref(true)
const app = ref(null)
const versions = ref([])
const installedOn = ref([])
const contactEmailDomain = ref('')
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
function contactEmail(contact) {
return `${contact.sso}@${contactEmailDomain.value}`
}
onMounted(async () => {
try {
contactEmailDomain.value = await getContactEmailDomain()
// Load application details
const response = await applicationsApi.get(route.params.id)
app.value = response.data.data
@@ -155,7 +167,6 @@ onMounted(async () => {
const versionsRes = await applicationsApi.getVersions(route.params.id)
versions.value = versionsRes.data.data || []
} catch (e) {
console.log('No versions data')
}
// Load installed on which PCs
@@ -163,7 +174,6 @@ onMounted(async () => {
const installedRes = await applicationsApi.getInstalledOn(route.params.id)
installedOn.value = installedRes.data.data || []
} catch (e) {
console.log('No installed data')
}
} catch (error) {
console.error('Error loading application:', error)

View File

@@ -523,7 +523,6 @@ onMounted(async () => {
}
}
} catch (e) {
console.log('Could not load relationships')
}
}
}

View File

@@ -268,7 +268,6 @@ onMounted(async () => {
const appsResponse = await applicationsApi.getMachineApps(route.params.id)
installedApps.value = appsResponse.data.data || []
} catch (appError) {
console.log('No installed apps data:', appError.message)
}
// Warranties load via useWarrantyBadge (watches computer.assetid).
} catch (error) {

View File

@@ -61,7 +61,7 @@
</router-link>
<span v-else>{{ log.entityname || `#${log.entityid}` }}</span>
</td>
<td>{{ log.username || '-' }}</td>
<td :title="log.userfullname || ''">{{ log.username || '-' }}</td>
<td class="ip">{{ log.ipaddress || '-' }}</td>
<td>
<button

View File

@@ -70,6 +70,7 @@ const LABELS = {
pc_access_domain: 'PC Access Domain',
employeeid_pattern: 'Employee ID Pattern',
printer_hostname_template: 'Printer Hostname Template',
contact_email_domain: 'Contact Email Domain',
dualpath_single_machine: 'Dualpath as Single Machine',
employee_directory_mode: 'Employee Directory Mode',
usb_directory_mode: 'USB Directory Mode',
@@ -94,7 +95,7 @@ function isTrue(setting) {
const GROUPS = [
{ title: 'Identity', keys: ['facility_name', 'site_base_url'] },
{ title: 'Behavior', keys: ['dualpath_single_machine'] },
{ title: 'Naming & Patterns', keys: ['pc_access_domain', 'printer_hostname_template', 'employeeid_pattern'] },
{ title: 'Naming & Patterns', keys: ['pc_access_domain', 'printer_hostname_template', 'contact_email_domain', 'employeeid_pattern'] },
{ title: 'Data Sources', keys: ['employee_directory_mode', 'usb_directory_mode'] },
{ title: 'System', keys: ['setup_complete'] }
]
@@ -106,6 +107,7 @@ const HELP = {
setup_complete: 'Set automatically when the first-run setup wizard finishes. Turning it off sends admins back to the /setup wizard on next login.',
employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.',
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net',
contact_email_domain: 'Email domain appended to a support contact SSO to build email (sso@domain) and Microsoft Teams chat links. Example: geaerospace.com. Leave blank to hide the contact action buttons.',
dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.'
}
function fieldHelp(key) {

View File

@@ -13,7 +13,6 @@
<table>
<thead>
<tr>
<th style="width: 40px;"></th>
<th>Team Name</th>
<th>Link</th>
<th>Contacts</th>
@@ -21,64 +20,24 @@
</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-for="team in teams" :key="team.supportteamid">
<td>{{ team.teamname }}</td>
<td>
<a v-if="team.teamurl" :href="team.teamurl" target="_blank">Link</a>
<span v-else>-</span>
</td>
<td>
<button class="btn btn-secondary btn-sm" @click="openContactsModal(team)">
Contacts ({{ team.contacts ? team.contacts.length : 0 }})
</button>
</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="teams.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No support teams found
</td>
</tr>
@@ -116,6 +75,56 @@
</div>
</div>
<!-- Contacts List Modal (per team) -->
<div v-if="showContactsModal && contactsTeam" class="modal-overlay" @click.self="showContactsModal = false">
<div class="modal contacts-modal">
<div class="modal-header">
<h3>{{ contactsTeam.teamname }} - Contacts</h3>
</div>
<div class="modal-body">
<div class="contacts-header">
<strong>Contacts ({{ contactsTeam.contacts ? contactsTeam.contacts.length : 0 }})</strong>
<button class="btn btn-primary btn-sm" @click="openContactModal(contactsTeam)">+ Add Contact</button>
</div>
<table class="contacts-table">
<thead>
<tr>
<th>Name</th>
<th>SSO</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="contact in contactsTeam.contacts" :key="contact.contactid">
<td>
{{ contact.name }}
<template v-if="contact.sso && contactEmailDomain">
<a class="contact-action" :href="`mailto:${contactEmail(contact)}`" title="Email">Email</a>
<a class="contact-action" :href="`https://teams.microsoft.com/l/chat/0/0?users=${contactEmail(contact)}`" target="_blank" rel="noopener" title="Teams chat">Teams</a>
</template>
</td>
<td class="mono">{{ contact.sso || '-' }}</td>
<td>{{ contact.isactive === false ? 'No' : 'Yes' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openContactModal(contactsTeam, contact)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteContact(contactsTeam, contact)">Delete</button>
</td>
</tr>
<tr v-if="!contactsTeam.contacts || contactsTeam.contacts.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No contacts yet
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showContactsModal = false">Close</button>
</div>
</div>
</div>
<!-- Contact Add/Edit Modal -->
<div v-if="showContactModal" class="modal-overlay" @click.self="showContactModal = false">
<div class="modal">
@@ -165,16 +174,30 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { supportteamsApi } from '../../api'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { getContactEmailDomain } from '../../utils/siteSettings'
const toast = useToast()
const teams = ref([])
const loading = ref(true)
const saving = ref(false)
const expandedTeamId = ref(null)
const contactEmailDomain = ref('')
// Contacts list modal. Track the team id (not the object) so the computed
// re-resolves against the freshly loaded teams array after any CRUD reload.
const showContactsModal = ref(false)
const contactsTeamId = ref(null)
const contactsTeam = computed(() =>
teams.value.find(t => t.supportteamid === contactsTeamId.value) || null
)
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
function contactEmail(contact) {
return `${contact.sso}@${contactEmailDomain.value}`
}
const showTeamModal = ref(false)
const editingTeam = ref(null)
@@ -190,7 +213,10 @@ const contactForm = ref({ name: '', sso: '', sortorder: 0 })
const showDeleteModal = ref(false)
const toDelete = ref(null)
onMounted(() => loadData())
onMounted(async () => {
contactEmailDomain.value = await getContactEmailDomain()
loadData()
})
async function loadData() {
loading.value = true
@@ -204,8 +230,9 @@ async function loadData() {
}
}
function toggleExpand(teamId) {
expandedTeamId.value = expandedTeamId.value === teamId ? null : teamId
function openContactsModal(team) {
contactsTeamId.value = team.supportteamid
showContactsModal.value = true
}
function openTeamModal(team = null) {
@@ -283,7 +310,6 @@ async function saveContact() {
}
showContactModal.value = false
await loadData()
expandedTeamId.value = teamId
} catch (err) {
contactError.value = apiError(err, 'Failed to save')
} finally {
@@ -295,7 +321,6 @@ 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'))
}
@@ -303,15 +328,15 @@ async function deleteContact(team, contact) {
</script>
<style scoped>
.contacts-panel {
padding: 0.5rem 0;
.contacts-modal {
max-width: 640px;
}
.contacts-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
margin-bottom: 0.75rem;
}
.contacts-table {