218 lines
7.6 KiB
Vue
218 lines
7.6 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h1>Site & Facility</h1>
|
|
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="muted">Loading...</div>
|
|
<div v-else class="form-grid">
|
|
<template v-for="group in groupedItems" :key="group.title">
|
|
<h3 class="group-title">{{ group.title }}</h3>
|
|
<label v-for="s in group.items" :key="s.key" class="field">
|
|
<span>{{ prettyLabel(s.key) }}</span>
|
|
<span v-if="s.valuetype === 'boolean'" class="bool-row">
|
|
<input type="checkbox" :checked="isTrue(s)" @change="s.value = isTrue(s) ? 'false' : 'true'" />
|
|
<span class="muted">{{ isTrue(s) ? 'Enabled' : 'Disabled' }}</span>
|
|
</span>
|
|
<select v-else-if="OPTIONS[s.key]" v-model="s.value">
|
|
<option v-for="opt in OPTIONS[s.key]" :key="opt" :value="opt">{{ opt }}</option>
|
|
</select>
|
|
<input v-else v-model="s.value" type="text" :placeholder="fieldHelp(s.key) ? '' : s.description" />
|
|
<small class="muted">{{ fieldHelp(s.key) || s.description }}</small>
|
|
</label>
|
|
</template>
|
|
|
|
<div v-if="!items.length" class="muted">No site settings found.</div>
|
|
|
|
<div class="actions">
|
|
<button class="btn btn-primary" :disabled="saving || !items.length" @click="save">
|
|
{{ saving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
<span v-if="saved" class="muted">Saved.</span>
|
|
<span v-if="error" class="error">{{ error }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { settingsApi } from '@/api'
|
|
import { apiError } from '../../utils/apiError'
|
|
|
|
const items = ref([])
|
|
|
|
const groupedItems = computed(() => {
|
|
const byKey = Object.fromEntries(items.value.map(s => [s.key, s]))
|
|
const placed = new Set()
|
|
const groups = []
|
|
for (const group of GROUPS) {
|
|
const present = group.keys.filter(k => byKey[k])
|
|
if (!present.length) continue
|
|
present.forEach(k => placed.add(k))
|
|
groups.push({ title: group.title, items: present.map(k => byKey[k]) })
|
|
}
|
|
const leftover = items.value.filter(s => !placed.has(s.key))
|
|
if (leftover.length) groups.push({ title: 'Other', items: leftover })
|
|
return groups
|
|
})
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
const saved = ref(false)
|
|
const error = ref('')
|
|
|
|
const LABELS = {
|
|
site_base_url: 'Site URL / FQDN',
|
|
facility_name: 'Facility Name',
|
|
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',
|
|
site_timezone: 'Site Timezone',
|
|
setup_complete: 'Setup Complete'
|
|
}
|
|
function prettyLabel(key) {
|
|
return LABELS[key] || key
|
|
}
|
|
|
|
// Enumerated settings render as dropdowns to prevent typos.
|
|
const OPTIONS = {
|
|
employee_directory_mode: ['selfhosted', 'external'],
|
|
usb_directory_mode: ['selfhosted', 'external'],
|
|
site_timezone: [
|
|
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Phoenix',
|
|
'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu',
|
|
'America/Indiana/Indianapolis'
|
|
]
|
|
}
|
|
|
|
function isTrue(setting) {
|
|
return String(setting.value).toLowerCase() === 'true'
|
|
}
|
|
|
|
// Deliberate field order: identity first, behavior, naming templates, data
|
|
// sources, system flags last. Unknown future keys fall into Other.
|
|
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', 'contact_email_domain', 'employeeid_pattern'] },
|
|
{ title: 'Data Sources', keys: ['employee_directory_mode', 'usb_directory_mode'] },
|
|
{ title: 'Localization', keys: ['site_timezone'] },
|
|
{ title: 'System', keys: ['setup_complete'] }
|
|
]
|
|
|
|
// Inline help for site fields that need more than the stored description.
|
|
const HELP = {
|
|
employee_directory_mode: 'Where employee records live: selfhosted (managed in this app, with CSV import and photo uploads) or external (a read-only HR database configured via EMPLOYEE_DB_* environment variables).',
|
|
usb_directory_mode: 'Where USB checkout data lives: selfhosted (tables in this app) or external (a separate cmmc_usb database via CMMC_USB_DB_* environment variables).',
|
|
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', // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
|
|
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.',
|
|
site_timezone: 'IANA timezone for this site. Notification start/end times are shown and entered in this zone, and daily-reset notification expiry is computed here. Default: America/New_York.'
|
|
}
|
|
function fieldHelp(key) {
|
|
return HELP[key] || ''
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
try {
|
|
const response = await settingsApi.list()
|
|
const all = response.data?.data || response.data || []
|
|
items.value = all.filter(s => s.category === 'site')
|
|
} catch (err) {
|
|
console.error('Error loading site settings:', err)
|
|
error.value = 'Could not load settings.'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
saved.value = false
|
|
error.value = ''
|
|
try {
|
|
for (const s of items.value) {
|
|
await settingsApi.update(s.key, s.value)
|
|
}
|
|
saved.value = true
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Save failed.')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.form-grid {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
max-width: 640px;
|
|
}
|
|
.field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
.field > span {
|
|
font-weight: 600;
|
|
}
|
|
.field input[type='text'],
|
|
.field select {
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
}
|
|
.bool-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.bool-row input[type='checkbox'] {
|
|
width: 18px;
|
|
height: 18px;
|
|
accent-color: var(--primary);
|
|
}
|
|
.group-title {
|
|
margin: 12px 0 0;
|
|
padding-bottom: 4px;
|
|
border-bottom: 1px solid var(--border);
|
|
font-size: 0.8rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
color: var(--text-light);
|
|
}
|
|
.group-title:first-of-type {
|
|
margin-top: 0;
|
|
}
|
|
.muted {
|
|
color: var(--text-light);
|
|
font-size: 0.85rem;
|
|
}
|
|
.error {
|
|
color: var(--danger);
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-top: 8px;
|
|
}
|
|
</style>
|