Final-pass polish: support contact UX, audit tooltip, doc refresh
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:
@@ -1320,6 +1320,24 @@ td.actions {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Small email/Teams action buttons for support contacts */
|
||||
.contact-action {
|
||||
display: inline-block;
|
||||
margin-left: 0.35rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--link);
|
||||
background: var(--bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
.contact-action:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Feature Tags */
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
|
||||
@@ -344,7 +344,6 @@ async function resolveAssetId() {
|
||||
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
|
||||
|
||||
@@ -120,6 +120,16 @@ export async function getPrinterHostnameTemplate() {
|
||||
return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net')
|
||||
}
|
||||
|
||||
// Email domain appended to a contact SSO to build email/Teams links
|
||||
// (sso@domain). Unset falls back to geaerospace.com; an explicit blank value
|
||||
// means "disable contact action buttons", so read the raw setting rather than
|
||||
// getSetting (which swaps '' for the fallback).
|
||||
export async function getContactEmailDomain() {
|
||||
const settings = await loadSettings()
|
||||
const value = settings['contact_email_domain']
|
||||
return (value === undefined || value === null) ? 'geaerospace.com' : value
|
||||
}
|
||||
|
||||
// Apply per-site favicon + brand color at bootstrap. Empty settings keep the
|
||||
// shipped defaults. Do not touch the style.css palette here.
|
||||
export async function applyBranding() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -523,7 +523,6 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not load relationships')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user