Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
220
frontend/src/views/settings/AccessProtocolsList.vue
Normal file
220
frontend/src/views/settings/AccessProtocolsList.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>PC Access Protocols</h1>
|
||||
<div class="actions">
|
||||
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
|
||||
<button class="btn btn-primary" @click="openNew">New Protocol</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="hint">
|
||||
Remote-access protocols offered on PCs. Links are built as
|
||||
<code>{{ '{scheme}://{hostname}.<pc_access_domain>:{port}' }}</code> from
|
||||
each protocol's template. Set the domain in
|
||||
<router-link to="/settings/site">Site & Facility</router-link>.
|
||||
</p>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Scheme</th>
|
||||
<th>Default port</th>
|
||||
<th>Link template</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in protocols" :key="p.protocolid">
|
||||
<td><strong>{{ p.name }}</strong></td>
|
||||
<td class="mono">{{ p.scheme }}</td>
|
||||
<td>{{ p.defaultport ?? '-' }}</td>
|
||||
<td class="mono">{{ p.linktemplate }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="p.isactive ? 'badge-success' : 'badge-secondary'">
|
||||
{{ p.isactive ? 'yes' : 'no' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-sm btn-secondary" @click="openEdit(p)">Edit</button>
|
||||
<button class="btn btn-sm btn-danger" @click="remove(p)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !protocols.length">
|
||||
<td colspan="6" class="muted" style="text-align:center;">No protocols.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="editing" class="modal-overlay" @click.self="close">
|
||||
<div class="modal-panel">
|
||||
<h2>{{ form.protocolid ? 'Edit' : 'New' }} Protocol</h2>
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input v-model="form.name" type="text" maxlength="50" placeholder="e.g. VNC" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Scheme</span>
|
||||
<input v-model="form.scheme" type="text" maxlength="20" placeholder="vnc / rdp / https / ssh" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Default port</span>
|
||||
<input v-model.number="form.defaultport" type="number" min="1" max="65535" placeholder="5900" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Link template</span>
|
||||
<input v-model="form.linktemplate" type="text" maxlength="255" placeholder="vnc://{host}:{port}" />
|
||||
<small class="muted">Placeholders: <code>{host}</code>, <code>{port}</code>, <code>{scheme}</code></small>
|
||||
</label>
|
||||
<label class="field checkbox">
|
||||
<input v-model="form.isactive" type="checkbox" />
|
||||
<span>Active</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="close">Cancel</button>
|
||||
<button class="btn btn-primary" :disabled="saving || !formValid" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computersApi } from '@/api'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const protocols = ref([])
|
||||
const loading = ref(true)
|
||||
const editing = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({})
|
||||
|
||||
const formValid = computed(() =>
|
||||
form.value.name && form.value.scheme && form.value.linktemplate
|
||||
)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await computersApi.protocols.list({ active: false })
|
||||
protocols.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading protocols:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
error.value = ''
|
||||
form.value = { name: '', scheme: '', defaultport: null, linktemplate: '', isactive: true }
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function openEdit(p) {
|
||||
error.value = ''
|
||||
form.value = {
|
||||
protocolid: p.protocolid,
|
||||
name: p.name,
|
||||
scheme: p.scheme,
|
||||
defaultport: p.defaultport ?? null,
|
||||
linktemplate: p.linktemplate,
|
||||
isactive: p.isactive !== false
|
||||
}
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
if (form.value.protocolid) {
|
||||
await computersApi.protocols.update(form.value.protocolid, form.value)
|
||||
} else {
|
||||
await computersApi.protocols.create(form.value)
|
||||
}
|
||||
editing.value = false
|
||||
await load()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Save failed.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(p) {
|
||||
if (!confirm(`Delete protocol "${p.name}"? (kept but deactivated if any PC uses it)`)) return
|
||||
try {
|
||||
await computersApi.protocols.remove(p.protocolid)
|
||||
await load()
|
||||
} catch (err) {
|
||||
console.error('Error deleting protocol:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.mono { font-family: monospace; font-size: 0.85rem; }
|
||||
.muted { color: var(--text-light); }
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 40px 16px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-panel {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.modal-panel h2 { margin: 0 0 18px; }
|
||||
.form-grid { display: flex; flex-direction: column; gap: 14px; }
|
||||
.field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.field > span { font-size: 0.85rem; color: var(--text-light); }
|
||||
.field input[type="text"],
|
||||
.field input[type="number"] {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.field.checkbox { flex-direction: row; align-items: center; gap: 8px; }
|
||||
.field.checkbox > span { color: var(--text); font-size: 1rem; }
|
||||
.error { color: var(--danger); margin: 12px 0 0; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
|
||||
</style>
|
||||
551
frontend/src/views/settings/ApiTokensList.vue
Normal file
551
frontend/src/views/settings/ApiTokensList.vue
Normal file
@@ -0,0 +1,551 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>API Tokens</h2>
|
||||
<button class="btn btn-primary" @click="openCreate()">+ New Token</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="tokens-intro">
|
||||
Personal access tokens let scripts and integrations authenticate as you
|
||||
without an hourly-expiring login session. Send the token as
|
||||
<code>Authorization: Bearer shopdb_pat_...</code>. Ideal for long-running
|
||||
imports that would otherwise die when the login JWT expires. A token may
|
||||
be restricted to a subset of your permissions; a restricted token cannot
|
||||
reach admin-only (role-gated) endpoints or import mode.
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Token</th>
|
||||
<th>Access</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
<th>Last Used</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="token in myTokens" :key="token.tokenid">
|
||||
<td>{{ token.name }}</td>
|
||||
<td><code :title="token.displayprefix + '...'">{{ token.tokenprefix }}...</code></td>
|
||||
<td><span class="badge" :class="scopeBadgeClass(token)">{{ scopeSummary(token) }}</span></td>
|
||||
<td>{{ formatDate(token.createddate) }}</td>
|
||||
<td>{{ token.expiresat ? formatDate(token.expiresat) : 'Never' }}</td>
|
||||
<td>{{ token.lastusedat ? formatDate(token.lastusedat) : 'Never' }}</td>
|
||||
<td>
|
||||
<span v-if="!token.isactive" class="badge badge-danger">Revoked</span>
|
||||
<span v-else-if="token.isexpired" class="badge badge-warning">Expired</span>
|
||||
<span v-else class="badge badge-success">Active</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button v-if="token.isactive" class="btn btn-secondary btn-sm"
|
||||
@click="openEdit(token)">Edit</button>
|
||||
<button v-if="token.isactive" class="btn btn-danger btn-sm"
|
||||
@click="confirmRevoke(token)">Revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="myTokens.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No tokens yet
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Admin: all tokens across every user -->
|
||||
<div v-if="isAdmin" class="card admin-tokens">
|
||||
<h3 class="section-subtitle">All Tokens (admin)</h3>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Owner</th>
|
||||
<th>Name</th>
|
||||
<th>Token</th>
|
||||
<th>Access</th>
|
||||
<th>Expires</th>
|
||||
<th>Last Used</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="token in allTokens" :key="token.tokenid">
|
||||
<td>{{ token.username || '-' }}</td>
|
||||
<td>{{ token.name }}</td>
|
||||
<td><code :title="token.displayprefix + '...'">{{ token.tokenprefix }}...</code></td>
|
||||
<td><span class="badge" :class="scopeBadgeClass(token)">{{ scopeSummary(token) }}</span></td>
|
||||
<td>{{ token.expiresat ? formatDate(token.expiresat) : 'Never' }}</td>
|
||||
<td>{{ token.lastusedat ? formatDate(token.lastusedat) : 'Never' }}</td>
|
||||
<td>
|
||||
<span v-if="!token.isactive" class="badge badge-danger">Revoked</span>
|
||||
<span v-else-if="token.isexpired" class="badge badge-warning">Expired</span>
|
||||
<span v-else class="badge badge-success">Active</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button v-if="token.isactive" class="btn btn-danger btn-sm"
|
||||
@click="confirmRevoke(token)">Revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="allTokens.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No tokens
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create modal -->
|
||||
<div v-if="showCreate" class="modal-overlay" @click.self="closeCreate">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header"><h3>New API Token</h3></div>
|
||||
<form @submit.prevent="createToken">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="tokenname">Name *</label>
|
||||
<input id="tokenname" v-model="form.name" type="text" class="form-control"
|
||||
placeholder="e.g. legacy import runner" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tokenexpiry">Expiry (optional)</label>
|
||||
<input id="tokenexpiry" v-model="form.expiresat" type="date" class="form-control" />
|
||||
<small class="form-hint">Leave blank for a token that never expires.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="form.restrict" />
|
||||
Restrict permissions
|
||||
</label>
|
||||
<small class="form-hint">
|
||||
Default is full access (the token acts as you). Restrict to grant
|
||||
only the checked permissions. A restricted token cannot use
|
||||
admin-only endpoints or import mode.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div v-if="form.restrict" class="form-group">
|
||||
<label>Allowed permissions</label>
|
||||
<div class="permissions-grid">
|
||||
<div v-for="(perms, category) in availableGrouped" :key="category"
|
||||
class="permission-category">
|
||||
<div class="category-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox"
|
||||
:checked="isCategoryFullySelected(category)"
|
||||
:indeterminate.prop="isCategoryPartiallySelected(category)"
|
||||
@change="toggleCategory(category, $event.target.checked)" />
|
||||
<strong>{{ formatCategory(category) }}</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="category-perms">
|
||||
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item">
|
||||
<input type="checkbox" :value="p.name" v-model="form.scopes" />
|
||||
{{ p.description }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeCreate">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Creating...' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal (scopes) -->
|
||||
<div v-if="editing" class="modal-overlay" @click.self="closeEdit">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header"><h3>Edit token access</h3></div>
|
||||
<form @submit.prevent="saveEdit">
|
||||
<div class="modal-body">
|
||||
<p class="tokens-intro">Editing <strong>{{ editing.name }}</strong>.</p>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="editForm.restrict" />
|
||||
Restrict permissions
|
||||
</label>
|
||||
<small class="form-hint">
|
||||
Uncheck for full access (acts as the owner).
|
||||
</small>
|
||||
</div>
|
||||
<div v-if="editForm.restrict" class="form-group">
|
||||
<label>Allowed permissions</label>
|
||||
<div class="permissions-grid">
|
||||
<div v-for="(perms, category) in availableGrouped" :key="category"
|
||||
class="permission-category">
|
||||
<div class="category-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox"
|
||||
:checked="isEditCategoryFullySelected(category)"
|
||||
:indeterminate.prop="isEditCategoryPartiallySelected(category)"
|
||||
@change="toggleEditCategory(category, $event.target.checked)" />
|
||||
<strong>{{ formatCategory(category) }}</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="category-perms">
|
||||
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item">
|
||||
<input type="checkbox" :value="p.name" v-model="editForm.scopes" />
|
||||
{{ p.description }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="editError" class="error-message">{{ editError }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeEdit">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secret reveal modal (shown once) -->
|
||||
<div v-if="newSecret" class="modal-overlay" @click.self="dismissSecret">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Copy your new token</h3></div>
|
||||
<div class="modal-body">
|
||||
<p class="secret-warning">
|
||||
This is the only time the token is shown. Copy it now and store it
|
||||
somewhere safe. You will not be able to see it again.
|
||||
</p>
|
||||
<div class="secret-box">
|
||||
<code class="secret-value">{{ newSecret }}</code>
|
||||
<button class="btn btn-secondary btn-sm" @click="copySecret">
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" @click="dismissSecret">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revoke confirm -->
|
||||
<div v-if="toRevoke" class="modal-overlay" @click.self="toRevoke = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Revoke Token</h3></div>
|
||||
<div class="modal-body">
|
||||
<p>Revoke <strong>{{ toRevoke.name }}</strong>? Any script using it will
|
||||
immediately lose access.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="toRevoke = null">Cancel</button>
|
||||
<button class="btn btn-danger" @click="revokeToken">Revoke</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { apitokensApi, usersApi, authApi } from '../../api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const isAdmin = computed(() => auth.isAdmin)
|
||||
|
||||
const myTokens = ref([])
|
||||
const allTokens = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
// Permission catalog grouped by category, plus the permissions the current
|
||||
// user actually holds (the create/edit grid never offers more than these).
|
||||
const permissionsGrouped = ref({})
|
||||
const myPermissions = ref([])
|
||||
|
||||
const showCreate = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({ name: '', expiresat: '', restrict: false, scopes: [] })
|
||||
|
||||
const editing = ref(null)
|
||||
const editError = ref('')
|
||||
const editForm = ref({ restrict: false, scopes: [] })
|
||||
|
||||
const newSecret = ref('')
|
||||
const copied = ref(false)
|
||||
const toRevoke = ref(null)
|
||||
|
||||
// Grid limited to permissions the current user holds. Admins see everything.
|
||||
const availableGrouped = computed(() => {
|
||||
const held = new Set(myPermissions.value)
|
||||
const result = {}
|
||||
for (const [category, perms] of Object.entries(permissionsGrouped.value)) {
|
||||
const usable = isAdmin.value ? perms : perms.filter(p => held.has(p.name))
|
||||
if (usable.length) result[category] = usable
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [tokensRes, permsRes, meRes] = await Promise.all([
|
||||
apitokensApi.list(),
|
||||
usersApi.permissions.list(),
|
||||
authApi.me()
|
||||
])
|
||||
myTokens.value = tokensRes.data.data || []
|
||||
permissionsGrouped.value = permsRes.data.data.grouped || {}
|
||||
myPermissions.value = meRes.data.data.permissions || []
|
||||
if (isAdmin.value) {
|
||||
const all = await apitokensApi.list({ all: true })
|
||||
allTokens.value = all.data.data || []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading API tokens:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
function formatCategory(category) {
|
||||
return category.charAt(0).toUpperCase() + category.slice(1)
|
||||
}
|
||||
|
||||
function scopeSummary(token) {
|
||||
if (!token.scopes) return 'Full access'
|
||||
const count = token.scopes.length
|
||||
return count === 1 ? '1 permission' : `${count} permissions`
|
||||
}
|
||||
|
||||
function scopeBadgeClass(token) {
|
||||
return token.scopes ? 'badge-warning' : 'badge-success'
|
||||
}
|
||||
|
||||
// --- Create grid helpers ---
|
||||
function isCategoryFullySelected(category) {
|
||||
const perms = availableGrouped.value[category] || []
|
||||
return perms.length > 0 && perms.every(p => form.value.scopes.includes(p.name))
|
||||
}
|
||||
function isCategoryPartiallySelected(category) {
|
||||
const perms = availableGrouped.value[category] || []
|
||||
const selected = perms.filter(p => form.value.scopes.includes(p.name))
|
||||
return selected.length > 0 && selected.length < perms.length
|
||||
}
|
||||
function toggleCategory(category, checked) {
|
||||
const names = (availableGrouped.value[category] || []).map(p => p.name)
|
||||
if (checked) {
|
||||
for (const name of names) {
|
||||
if (!form.value.scopes.includes(name)) form.value.scopes.push(name)
|
||||
}
|
||||
} else {
|
||||
form.value.scopes = form.value.scopes.filter(n => !names.includes(n))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edit grid helpers ---
|
||||
function isEditCategoryFullySelected(category) {
|
||||
const perms = availableGrouped.value[category] || []
|
||||
return perms.length > 0 && perms.every(p => editForm.value.scopes.includes(p.name))
|
||||
}
|
||||
function isEditCategoryPartiallySelected(category) {
|
||||
const perms = availableGrouped.value[category] || []
|
||||
const selected = perms.filter(p => editForm.value.scopes.includes(p.name))
|
||||
return selected.length > 0 && selected.length < perms.length
|
||||
}
|
||||
function toggleEditCategory(category, checked) {
|
||||
const names = (availableGrouped.value[category] || []).map(p => p.name)
|
||||
if (checked) {
|
||||
for (const name of names) {
|
||||
if (!editForm.value.scopes.includes(name)) editForm.value.scopes.push(name)
|
||||
}
|
||||
} else {
|
||||
editForm.value.scopes = editForm.value.scopes.filter(n => !names.includes(n))
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.value = { name: '', expiresat: '', restrict: false, scopes: [] }
|
||||
error.value = ''
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
function closeCreate() { showCreate.value = false }
|
||||
|
||||
async function createToken() {
|
||||
error.value = ''
|
||||
if (form.value.restrict && form.value.scopes.length === 0) {
|
||||
error.value = 'Select at least one permission, or turn off Restrict permissions.'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { name: form.value.name }
|
||||
if (form.value.expiresat) payload.expiresat = form.value.expiresat
|
||||
if (form.value.restrict) payload.scopes = form.value.scopes
|
||||
const response = await apitokensApi.create(payload)
|
||||
showCreate.value = false
|
||||
newSecret.value = response.data.data.secret
|
||||
copied.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to create token')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(token) {
|
||||
editing.value = token
|
||||
editError.value = ''
|
||||
editForm.value = {
|
||||
restrict: !!token.scopes,
|
||||
scopes: token.scopes ? [...token.scopes] : []
|
||||
}
|
||||
}
|
||||
|
||||
function closeEdit() { editing.value = null }
|
||||
|
||||
async function saveEdit() {
|
||||
editError.value = ''
|
||||
if (editForm.value.restrict && editForm.value.scopes.length === 0) {
|
||||
editError.value = 'Select at least one permission, or turn off Restrict permissions.'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { scopes: editForm.value.restrict ? editForm.value.scopes : null }
|
||||
await apitokensApi.update(editing.value.tokenid, payload)
|
||||
editing.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
editError.value = apiError(err, 'Failed to update token')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copySecret() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(newSecret.value)
|
||||
copied.value = true
|
||||
} catch {
|
||||
toast.error('Copy failed. Select the token and copy manually.')
|
||||
}
|
||||
}
|
||||
|
||||
function dismissSecret() { newSecret.value = ''; copied.value = false }
|
||||
|
||||
function confirmRevoke(token) { toRevoke.value = token }
|
||||
|
||||
async function revokeToken() {
|
||||
try {
|
||||
await apitokensApi.remove(toRevoke.value.tokenid)
|
||||
toRevoke.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error('Failed to revoke token')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tokens-intro {
|
||||
color: var(--text-light);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tokens-intro code {
|
||||
background: var(--bg);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.admin-tokens {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.section-subtitle {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-hint {
|
||||
display: block;
|
||||
color: var(--text-light);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.secret-warning {
|
||||
color: var(--danger);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.secret-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.secret-value {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.permissions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.permission-category {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.category-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.category-perms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.perm-item {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
58
frontend/src/views/settings/AssetIdentifiersSettings.vue
Normal file
58
frontend/src/views/settings/AssetIdentifiersSettings.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Asset Identifiers</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Enable or disable optional asset identifiers per asset type. When disabled
|
||||
for a type, the identifier is hidden from that type's forms and detail
|
||||
pages across the system.
|
||||
</p>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="identifier-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Identifier</th>
|
||||
<th v-for="col in assetTypeCols" :key="col.key">{{ col.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in identifierRows" :key="row.name">
|
||||
<td class="identifier-name">{{ row.label }}</td>
|
||||
<td v-for="col in assetTypeCols" :key="col.key">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: matrixValue(row.name, col.key) }"
|
||||
@click="toggleIdentifier(row.name, col.key)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings, identifierRows, assetTypeCols } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
saving, error, success,
|
||||
loadSettings, matrixValue, toggleIdentifier,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
120
frontend/src/views/settings/AssetTypesList.vue
Normal file
120
frontend/src/views/settings/AssetTypesList.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Asset Type Colors</h2>
|
||||
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="hint">
|
||||
Top-level asset categories are defined by their plugins, so you can set
|
||||
their color + description here (used for map markers/legend), but not add
|
||||
or remove them.
|
||||
</p>
|
||||
<div v-if="loading" class="muted">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset Type</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in items" :key="t.assettypeid">
|
||||
<td><strong>{{ t.assettype }}</strong></td>
|
||||
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
|
||||
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Edit {{ form.assettype }}</h3></div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
|
||||
<ColorSwatchPicker v-model="form.color" />
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { assetsApi } from '../../api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({})
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await assetsApi.types.list()
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading asset types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(t) {
|
||||
form.value = { assettypeid: t.assettypeid, assettype: t.assettype, description: t.description || '', color: t.color || '' }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
await assetsApi.types.update(form.value.assettypeid, { description: form.value.description, color: form.value.color })
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint { color: var(--text-light); font-size: 0.9rem; margin: 0 0 14px; }
|
||||
.muted { color: var(--text-light); }
|
||||
</style>
|
||||
389
frontend/src/views/settings/AuditLogs.vue
Normal file
389
frontend/src/views/settings/AuditLogs.vue
Normal file
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>Audit Logs</h1>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
type="text"
|
||||
v-model="search"
|
||||
placeholder="Search by name or user..."
|
||||
@input="debouncedSearch"
|
||||
>
|
||||
<select v-model="filterAction" @change="loadLogs">
|
||||
<option value="">All Actions</option>
|
||||
<option value="created">Created</option>
|
||||
<option value="updated">Updated</option>
|
||||
<option value="deleted">Deleted</option>
|
||||
</select>
|
||||
<select v-model="filterEntity" @change="loadLogs">
|
||||
<option value="">All Entities</option>
|
||||
<option v-for="e in entityTypes" :key="e" :value="e">{{ e }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 180px">Timestamp</th>
|
||||
<th style="width: 90px">Action</th>
|
||||
<th style="width: 110px">Entity</th>
|
||||
<th>Name/ID</th>
|
||||
<th style="width: 118px">User</th>
|
||||
<th style="width: 108px">IP Address</th>
|
||||
<th style="width: 80px">Changes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="7" class="loading">Loading...</td>
|
||||
</tr>
|
||||
<tr v-else-if="!logs.length">
|
||||
<td colspan="7" class="empty">No audit logs found</td>
|
||||
</tr>
|
||||
<tr v-for="log in logs" :key="log.auditlogid">
|
||||
<td class="timestamp" :title="formatDateFull(log.timestamp)">{{ formatDate(log.timestamp) }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="actionClass(log.action)">
|
||||
{{ log.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ log.entitytype }}</td>
|
||||
<td class="name-cell" :title="log.entityname || `#${log.entityid}`">
|
||||
<router-link
|
||||
v-if="getEntityLink(log)"
|
||||
:to="getEntityLink(log)"
|
||||
class="entity-link"
|
||||
>
|
||||
{{ log.entityname || `#${log.entityid}` }}
|
||||
</router-link>
|
||||
<span v-else>{{ log.entityname || `#${log.entityid}` }}</span>
|
||||
</td>
|
||||
<td :title="log.userfullname || ''">{{ log.username || '-' }}</td>
|
||||
<td class="ip">{{ log.ipaddress || '-' }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="log.changes && Object.keys(log.changes).length"
|
||||
class="changes-btn"
|
||||
@click="showChanges(log)"
|
||||
>
|
||||
{{ Object.keys(log.changes).length }} field(s)
|
||||
</button>
|
||||
<span v-else class="no-changes">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button :disabled="page <= 1" @click="page--; loadLogs()">Prev</button>
|
||||
<span>Page {{ page }} of {{ totalPages }}</span>
|
||||
<button :disabled="page >= totalPages" @click="page++; loadLogs()">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Changes Modal -->
|
||||
<div v-if="selectedLog" class="modal-overlay" @click.self="selectedLog = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Changes - {{ selectedLog.entitytype }} {{ selectedLog.entityname }}</h3>
|
||||
<button class="close-btn" @click="selectedLog = null">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<table class="changes-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Old Value</th>
|
||||
<th>New Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(change, field) in selectedLog.changes" :key="field">
|
||||
<td>{{ field }}</td>
|
||||
<td class="old-value">{{ formatValue(change.old) }}</td>
|
||||
<td class="new-value">{{ formatValue(change.new) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { auditLogsApi } from '../../api'
|
||||
|
||||
const logs = ref([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const perpage = 50
|
||||
const total = ref(0)
|
||||
const search = ref('')
|
||||
const filterAction = ref('')
|
||||
const filterEntity = ref('')
|
||||
const selectedLog = ref(null)
|
||||
|
||||
const entityTypes = ['Asset', 'Printer', 'Computer', 'Machine', 'Network', 'Setting', 'User', 'Application', 'KnowledgeBase']
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / perpage))
|
||||
|
||||
let debounceTimer = null
|
||||
function debouncedSearch() {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadLogs()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
if (filterAction.value) params.action = filterAction.value
|
||||
if (filterEntity.value) params.entitytype = filterEntity.value
|
||||
|
||||
const { data } = await auditLogsApi.list(params)
|
||||
logs.value = data.data
|
||||
total.value = data.meta.total
|
||||
} catch (e) {
|
||||
console.error('Failed to load audit logs:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(isoString) {
|
||||
if (!isoString) return '-'
|
||||
const d = new Date(isoString)
|
||||
// compact for the fixed-width column; the cell title carries the full form
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'numeric', day: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function formatDateFull(isoString) {
|
||||
if (!isoString) return ''
|
||||
return new Date(isoString).toLocaleString()
|
||||
}
|
||||
|
||||
function actionClass(action) {
|
||||
switch (action) {
|
||||
case 'created': return 'badge-success'
|
||||
case 'updated': return 'badge-warning'
|
||||
case 'deleted': return 'badge-danger'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
function getEntityLink(log) {
|
||||
if (!log.entityid) return null
|
||||
const type = log.entitytype?.toLowerCase()
|
||||
switch (type) {
|
||||
case 'printer': return `/printers/${log.entityid}`
|
||||
case 'computer': return `/pcs/${log.entityid}`
|
||||
case 'machine': return `/machines/${log.entityid}`
|
||||
case 'asset': return `/assets/${log.entityid}`
|
||||
case 'application': return `/applications/${log.entityid}`
|
||||
case 'knowledgebase': return `/kb/${log.entityid}`
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function showChanges(log) {
|
||||
selectedLog.value = log
|
||||
}
|
||||
|
||||
function formatValue(val) {
|
||||
if (val === null || val === undefined) return '(empty)'
|
||||
if (typeof val === 'boolean') return val ? 'Yes' : 'No'
|
||||
if (typeof val === 'object') return JSON.stringify(val)
|
||||
return String(val)
|
||||
}
|
||||
|
||||
onMounted(loadLogs)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters input,
|
||||
.filters select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.filters input {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ip {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.badge-success { background: var(--success); color: white; }
|
||||
.badge-warning { background: var(--warning); color: #000; }
|
||||
.badge-danger { background: var(--danger); color: white; }
|
||||
|
||||
.entity-link {
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.entity-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.changes-btn {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.changes-btn:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.table-container > table {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
.table-container > table td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.name-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.no-changes {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
min-width: 500px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.changes-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.changes-table th,
|
||||
.changes-table td {
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.changes-table th {
|
||||
background: var(--bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.old-value {
|
||||
color: var(--danger);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.new-value {
|
||||
color: var(--success);
|
||||
}
|
||||
</style>
|
||||
51
frontend/src/views/settings/AuditSettings.vue
Normal file
51
frontend/src/views/settings/AuditSettings.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Audit & Logging</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<h3>Audit Log Retention</h3>
|
||||
<p class="setting-description">
|
||||
Configure how long audit logs are retained. Older logs will be automatically purged.
|
||||
Set to 0 to keep logs indefinitely.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Retention Period (days)</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model="settings.audit_retention_days"
|
||||
min="0"
|
||||
placeholder="90"
|
||||
@blur="saveSetting('audit_retention_days', settings.audit_retention_days)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">0 = keep forever, recommended: 90 days</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<router-link to="/settings/auditlogs" class="view-logs-link">
|
||||
View Audit Logs →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
163
frontend/src/views/settings/AuthenticationSettings.vue
Normal file
163
frontend/src/views/settings/AuthenticationSettings.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Authentication</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<h3>SAML Single Sign-On</h3>
|
||||
<p class="setting-description">
|
||||
Enable SAML SSO for enterprise authentication. Users can sign in using your organization's
|
||||
identity provider (Azure AD, Okta, etc.). Local login can remain enabled as a fallback.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Enable SAML SSO</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.saml_enabled }"
|
||||
@click="toggleSetting('saml_enabled')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="settings.saml_enabled">
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Identity Provider Metadata URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.saml_idp_metadata_url"
|
||||
placeholder="https://login.microsoftonline.com/.../federationmetadata.xml"
|
||||
@blur="saveSetting('saml_idp_metadata_url', settings.saml_idp_metadata_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Service Provider Entity ID</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.saml_entity_id"
|
||||
placeholder="https://shopdb.example.com"
|
||||
@blur="saveSetting('saml_entity_id', settings.saml_entity_id)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Unique identifier for this application</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Assertion Consumer Service URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.saml_acs_url"
|
||||
placeholder="https://shopdb.example.com/api/auth/saml/acs"
|
||||
@blur="saveSetting('saml_acs_url', settings.saml_acs_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">URL where SAML responses are sent</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Admin Group Name</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.saml_admin_group"
|
||||
placeholder="ShopDB-Admins"
|
||||
@blur="saveSetting('saml_admin_group', settings.saml_admin_group)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">SAML group that grants admin role (leave empty to manage manually)</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Allow Local Login</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.saml_allow_local_login }"
|
||||
@click="toggleSetting('saml_allow_local_login')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
<small class="input-hint toggle-hint">Keep enabled for admin fallback access</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Auto-Create Users</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.saml_auto_create_users }"
|
||||
@click="toggleSetting('saml_auto_create_users')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
<small class="input-hint toggle-hint">Automatically create user accounts on first SAML login</small>
|
||||
</div>
|
||||
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot" :class="samlStatus"></span>
|
||||
<span>{{ samlMessage }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<h3>User Management</h3>
|
||||
<p class="setting-description">
|
||||
Manage local user accounts and roles. Local accounts work alongside SAML when both are enabled.
|
||||
</p>
|
||||
|
||||
<router-link to="/settings/users" class="view-logs-link">
|
||||
Manage Users →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting, toggleSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
// Per-page connection status (stays with the page, not the composable).
|
||||
const samlStatus = computed(() => {
|
||||
if (!settings.saml_enabled) return 'inactive'
|
||||
if (!settings.saml_idp_metadata_url || !settings.saml_entity_id) return 'warning'
|
||||
return 'pending'
|
||||
})
|
||||
|
||||
const samlMessage = computed(() => {
|
||||
if (!settings.saml_enabled) return 'Disabled - using local authentication only'
|
||||
if (!settings.saml_idp_metadata_url) return 'IdP metadata URL not configured'
|
||||
if (!settings.saml_entity_id) return 'Entity ID not configured'
|
||||
return 'Configured - SAML login available'
|
||||
})
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
142
frontend/src/views/settings/BrandingSettings.vue
Normal file
142
frontend/src/views/settings/BrandingSettings.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Branding</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Replace the shipped GE logos with your own site branding. Each logo can
|
||||
be uploaded, or set to a path/URL directly. Leave blank to use the
|
||||
bundled default.
|
||||
</p>
|
||||
|
||||
<div class="setting-row" v-for="logo in brandingLogos" :key="logo.kind">
|
||||
<label>
|
||||
<span>{{ logo.label }}</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings[logo.key]"
|
||||
:placeholder="logo.placeholder"
|
||||
@blur="saveSetting(logo.key, settings[logo.key])"
|
||||
:disabled="saving"
|
||||
>
|
||||
<div class="map-upload-row">
|
||||
<input type="file" :accept="logo.accept" @change="uploadLogo(logo.kind, logo.key, $event)" :disabled="brandingUploading" />
|
||||
<img v-if="settings[logo.key]" :src="settings[logo.key]" class="map-thumb" :alt="logo.label" />
|
||||
</div>
|
||||
<small class="input-hint">{{ logo.hint }}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Primary brand color</span>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="settings.brand_primary_color || '#000000'"
|
||||
@input="settings.brand_primary_color = $event.target.value"
|
||||
@change="saveSetting('brand_primary_color', settings.brand_primary_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.brand_primary_color"
|
||||
placeholder="(blank = built-in)"
|
||||
@blur="saveSetting('brand_primary_color', settings.brand_primary_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</div>
|
||||
<small class="input-hint">Hex color for the primary accent. Leave blank to use the built-in palette.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Primary hover color</span>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="settings.brand_primary_dark_color || '#000000'"
|
||||
@input="settings.brand_primary_dark_color = $event.target.value"
|
||||
@change="saveSetting('brand_primary_dark_color', settings.brand_primary_dark_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.brand_primary_dark_color"
|
||||
placeholder="(blank = derived from primary)"
|
||||
@blur="saveSetting('brand_primary_dark_color', settings.brand_primary_dark_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</div>
|
||||
<small class="input-hint">Hover/active shade of the primary color. Leave blank to auto-darken the primary color ~15%.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Accent color</span>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="settings.brand_accent_color || '#000000'"
|
||||
@input="settings.brand_accent_color = $event.target.value"
|
||||
@change="saveSetting('brand_accent_color', settings.brand_accent_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.brand_accent_color"
|
||||
placeholder="(blank = built-in)"
|
||||
@blur="saveSetting('brand_accent_color', settings.brand_accent_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</div>
|
||||
<small class="input-hint">Accent for secondary buttons and badges. Leave blank to use the built-in palette.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Sidebar color</span>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="settings.brand_sidebar_color || '#000000'"
|
||||
@input="settings.brand_sidebar_color = $event.target.value"
|
||||
@change="saveSetting('brand_sidebar_color', settings.brand_sidebar_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.brand_sidebar_color"
|
||||
placeholder="(blank = built-in)"
|
||||
@blur="saveSetting('brand_sidebar_color', settings.brand_sidebar_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</div>
|
||||
<small class="input-hint">Sidebar background color. Leave blank to use the built-in palette.</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings, brandingLogos } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, brandingUploading, error, success,
|
||||
loadSettings, saveSetting, uploadLogo,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
191
frontend/src/views/settings/BusinessUnitsList.vue
Normal file
191
frontend/src/views/settings/BusinessUnitsList.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Business Units</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Business Unit</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Business Unit</th>
|
||||
<th>Code</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="bu in items" :key="bu.businessunitid">
|
||||
<td>{{ bu.businessunit }}</td>
|
||||
<td>{{ bu.code || '-' }}</td>
|
||||
<td class="cell-truncate" :title="bu.description">{{ bu.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(bu)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="confirmDelete(bu)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No business units found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit Business Unit' : 'Add Business Unit' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="businessunit">Business Unit Name *</label>
|
||||
<input id="businessunit" v-model="form.businessunit" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="code">Code</label>
|
||||
<input id="code" v-model="form.code" type="text" class="form-control" placeholder="e.g., ENGR, MFG" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Delete Business Unit</h3></div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ toDelete?.businessunit }}</strong>?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteItem">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { businessunitsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, setPage } = useListQuery({ onChange: loadData })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const toDelete = ref(null)
|
||||
|
||||
const form = ref({ businessunit: '', code: '', description: '' })
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await businessunitsApi.list({ page: page.value, perpage: perPage.value })
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading business units:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p) { setPage(p); loadData() }
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item ? {
|
||||
businessunit: item.businessunit || '',
|
||||
code: item.code || '',
|
||||
description: item.description || ''
|
||||
} : { businessunit: '', code: '', description: '' }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await businessunitsApi.update(editing.value.businessunitid, form.value)
|
||||
} else {
|
||||
await businessunitsApi.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
|
||||
|
||||
async function deleteItem() {
|
||||
try {
|
||||
await businessunitsApi.delete(toDelete.value.businessunitid)
|
||||
showDeleteModal.value = false
|
||||
toDelete.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
231
frontend/src/views/settings/CustomFieldsList.vue
Normal file
231
frontend/src/views/settings/CustomFieldsList.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Custom Fields</h2>
|
||||
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
|
||||
<button class="btn btn-primary" :disabled="!assettypeid" @click="openModal()">+ Add Field</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="hint">
|
||||
Define extra attributes per asset type. They appear on that asset's detail
|
||||
page and edit form. Use these instead of asking for a schema change.
|
||||
</p>
|
||||
|
||||
<div class="form-group type-picker">
|
||||
<label>Asset Type</label>
|
||||
<select v-model="assettypeid" class="form-control" @change="loadFields">
|
||||
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettypeid">
|
||||
{{ typeLabel(t) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="muted">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Key</th>
|
||||
<th>Type</th>
|
||||
<th>On Detail</th>
|
||||
<th>On Form</th>
|
||||
<th>Search</th>
|
||||
<th>Order</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="f in visibleFields" :key="f.fieldid">
|
||||
<td><strong>{{ f.label }}</strong></td>
|
||||
<td class="mono">{{ f.fieldkey }}</td>
|
||||
<td>
|
||||
{{ f.datatype }}
|
||||
<span v-if="f.datatype === 'select' && f.options.length" class="muted">({{ f.options.join(', ') }})</span>
|
||||
</td>
|
||||
<td>{{ f.showondetail ? 'yes' : '-' }}</td>
|
||||
<td>{{ f.showonform ? 'yes' : '-' }}</td>
|
||||
<td>{{ f.searchable ? 'yes' : '-' }}</td>
|
||||
<td>{{ f.sortorder }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="f.isactive ? 'badge-success' : 'badge-secondary'">{{ f.isactive ? 'yes' : 'no' }}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(f)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteField(f)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleFields.length === 0">
|
||||
<td colspan="9" style="text-align: center; color: var(--text-light);">No custom fields for this asset type</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Field</h3></div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Label *</label>
|
||||
<input v-model="form.label" type="text" class="form-control" maxlength="150" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Data Type</label>
|
||||
<select v-model="form.datatype" class="form-control">
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="boolean">Yes / No</option>
|
||||
<option value="select">Dropdown</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" v-if="form.datatype === 'select'">
|
||||
<label>Options <span class="hint">(one per line or comma-separated)</span></label>
|
||||
<textarea v-model="form.options" class="form-control" rows="3" placeholder="Bronze Silver Gold"></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.showondetail" /> Show on detail page</label>
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.showonform" /> Show on edit form</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.searchable" /> Searchable</label>
|
||||
<span class="hint">Include this field's values in global search.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Sort Order</label>
|
||||
<input v-model.number="form.sortorder" type="number" class="form-control" style="width: 90px;" />
|
||||
</div>
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { assetsApi, customFieldsApi } from '../../api'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const assetTypes = ref([])
|
||||
const assettypeid = ref(null)
|
||||
const items = ref([])
|
||||
const showInactive = ref(false)
|
||||
const visibleFields = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
|
||||
const loading = ref(false)
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref(blankForm())
|
||||
|
||||
function blankForm() {
|
||||
return { label: '', datatype: 'text', options: '', showondetail: true, showonform: true, searchable: false, sortorder: 0, isactive: true }
|
||||
}
|
||||
|
||||
function typeLabel(t) {
|
||||
const name = t.assettype || t.typename || t.name || `Type ${t.assettypeid}`
|
||||
return name.charAt(0).toUpperCase() + name.slice(1).replace('_', ' ')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await assetsApi.types.list()
|
||||
assetTypes.value = response.data.data || []
|
||||
if (assetTypes.value.length) {
|
||||
assettypeid.value = assetTypes.value[0].assettypeid
|
||||
await loadFields()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading asset types:', err)
|
||||
}
|
||||
})
|
||||
|
||||
async function loadFields() {
|
||||
if (!assettypeid.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await customFieldsApi.list({ assettypeid: assettypeid.value, active: false })
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading fields:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? {
|
||||
label: item.label || '',
|
||||
datatype: item.datatype || 'text',
|
||||
options: (item.options || []).join('\n'),
|
||||
showondetail: item.showondetail !== false,
|
||||
showonform: item.showonform !== false,
|
||||
searchable: item.searchable === true,
|
||||
sortorder: item.sortorder || 0,
|
||||
isactive: item.isactive !== false,
|
||||
}
|
||||
: blankForm()
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { ...form.value, assettypeid: assettypeid.value }
|
||||
if (editing.value) {
|
||||
await customFieldsApi.update(editing.value.fieldid, payload)
|
||||
} else {
|
||||
await customFieldsApi.create(payload)
|
||||
}
|
||||
closeModal()
|
||||
loadFields()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteField(f) {
|
||||
if (!confirm(`Delete field "${f.label}"? Stored values for it will be removed.`)) return
|
||||
try {
|
||||
await customFieldsApi.remove(f.fieldid)
|
||||
loadFields()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint { color: var(--text-light); font-size: 0.9rem; margin: 0 0 14px; }
|
||||
.muted { color: var(--text-light); }
|
||||
.mono { font-family: monospace; font-size: 0.85rem; }
|
||||
.type-picker { max-width: 280px; }
|
||||
.form-row { display: flex; gap: 1.25rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
||||
.checkbox-label { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
</style>
|
||||
190
frontend/src/views/settings/DashboardDefaultsList.vue
Normal file
190
frontend/src/views/settings/DashboardDefaultsList.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Dashboard Defaults</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Mapping</button>
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
Map a kiosk/lobby display's IP address to the business unit it should show.
|
||||
The shopfloor dashboard auto-selects this business unit by the display's IP
|
||||
when none is chosen.
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Business Unit</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in items" :key="d.dashboarddefaultid">
|
||||
<td class="mono">{{ d.ipaddress }}</td>
|
||||
<td>{{ d.businessunit || '-' }}</td>
|
||||
<td class="cell-truncate" :title="d.description">{{ d.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(d)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="confirmDelete(d)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No mappings yet
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit Mapping' : 'Add Mapping' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="ipaddress">IP Address *</label>
|
||||
<input id="ipaddress" v-model="form.ipaddress" type="text" class="form-control"
|
||||
placeholder="e.g., 10.20.30.40" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="businessunitid">Business Unit *</label>
|
||||
<select id="businessunitid" v-model="form.businessunitid" class="form-control" required>
|
||||
<option value="">Select business unit...</option>
|
||||
<option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
|
||||
{{ bu.businessunit }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<input id="description" v-model="form.description" type="text" class="form-control"
|
||||
placeholder="e.g., Materials lobby kiosk" />
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Delete Mapping</h3></div>
|
||||
<div class="modal-body">
|
||||
<p>Delete the mapping for <strong>{{ toDelete?.ipaddress }}</strong>?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteItem">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { dashboardDefaultsApi, businessUnitsApi } from '../../api'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const businessUnits = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const toDelete = ref(null)
|
||||
|
||||
const form = ref({ ipaddress: '', businessunitid: '', description: '' })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await businessUnitsApi.list({ perpage: 100 })
|
||||
businessUnits.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading business units:', err)
|
||||
}
|
||||
await loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await dashboardDefaultsApi.list()
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading dashboard defaults:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item ? {
|
||||
ipaddress: item.ipaddress || '',
|
||||
businessunitid: item.businessunitid || '',
|
||||
description: item.description || ''
|
||||
} : { ipaddress: '', businessunitid: '', description: '' }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await dashboardDefaultsApi.update(editing.value.dashboarddefaultid, form.value)
|
||||
} else {
|
||||
await dashboardDefaultsApi.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
|
||||
|
||||
async function deleteItem() {
|
||||
try {
|
||||
await dashboardDefaultsApi.delete(toDelete.value.dashboarddefaultid)
|
||||
showDeleteModal.value = false
|
||||
toDelete.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
117
frontend/src/views/settings/DellWarrantySettings.vue
Normal file
117
frontend/src/views/settings/DellWarrantySettings.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Dell Warranty Lookup</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Look up Dell coverage by service tag via the Dell TechDirect Warranty API.
|
||||
When enabled, the Refresh button on a Dell warranty pulls the current service
|
||||
level and end date. Requires a Dell TechDirect API client id and secret.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Enable Dell Warranty Lookup</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.warranty_dell_enabled }"
|
||||
@click="toggleSetting('warranty_dell_enabled')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="settings.warranty_dell_enabled">
|
||||
<label>
|
||||
<span>Client ID</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.warranty_dell_clientid"
|
||||
placeholder="Dell TechDirect client id"
|
||||
@blur="saveSetting('warranty_dell_clientid', settings.warranty_dell_clientid)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="settings.warranty_dell_enabled">
|
||||
<label>
|
||||
<span>Client Secret</span>
|
||||
<input
|
||||
type="password"
|
||||
v-model="settings.warranty_dell_clientsecret"
|
||||
placeholder="Enter client secret"
|
||||
@blur="saveSetting('warranty_dell_clientsecret', settings.warranty_dell_clientsecret)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="settings.warranty_dell_enabled">
|
||||
<label>
|
||||
<span>Token URL <small>(blank = Dell default)</small></span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.warranty_dell_tokenurl"
|
||||
placeholder="https://apigtwb2c.us.dell.com/auth/oauth/v2/token"
|
||||
@blur="saveSetting('warranty_dell_tokenurl', settings.warranty_dell_tokenurl)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="settings.warranty_dell_enabled">
|
||||
<label>
|
||||
<span>API URL <small>(blank = Dell default)</small></span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.warranty_dell_apiurl"
|
||||
placeholder="https://apigtwb2c.us.dell.com/PROD/sbil/eapi/v5/asset-entitlements"
|
||||
@blur="saveSetting('warranty_dell_apiurl', settings.warranty_dell_apiurl)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="status-indicator" v-if="settings.warranty_dell_enabled">
|
||||
<span class="status-dot" :class="dellStatus"></span>
|
||||
<span>{{ dellMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting, toggleSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
// Per-page connection status (stays with the page, not the composable).
|
||||
const dellStatus = computed(() => {
|
||||
if (!settings.warranty_dell_enabled) return 'inactive'
|
||||
if (!settings.warranty_dell_clientid || !settings.warranty_dell_clientsecret) return 'warning'
|
||||
return 'pending'
|
||||
})
|
||||
|
||||
const dellMessage = computed(() => {
|
||||
if (!settings.warranty_dell_enabled) return 'Disabled'
|
||||
if (!settings.warranty_dell_clientid) return 'Client id not configured'
|
||||
if (!settings.warranty_dell_clientsecret) return 'Client secret not configured'
|
||||
return 'Configured (used when you refresh a Dell warranty)'
|
||||
})
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
216
frontend/src/views/settings/EmailSettings.vue
Normal file
216
frontend/src/views/settings/EmailSettings.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Email / SMTP</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<h3>Email Notifications</h3>
|
||||
<p class="setting-description">
|
||||
Configure SMTP settings to enable email notifications for alerts, toner reports,
|
||||
and other system notifications.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Enable Email Notifications</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.smtp_enabled }"
|
||||
@click="toggleSetting('smtp_enabled')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="settings.smtp_enabled">
|
||||
<div class="settings-grid">
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>SMTP Host</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.smtp_host"
|
||||
placeholder="smtp.example.com"
|
||||
@blur="saveSetting('smtp_host', settings.smtp_host)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>SMTP Port</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model="settings.smtp_port"
|
||||
placeholder="587"
|
||||
@blur="saveSetting('smtp_port', settings.smtp_port)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.smtp_username"
|
||||
placeholder="username"
|
||||
@blur="saveSetting('smtp_username', settings.smtp_username)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
v-model="settings.smtp_password"
|
||||
placeholder="password"
|
||||
@blur="saveSetting('smtp_password', settings.smtp_password)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Use TLS Encryption</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.smtp_use_tls }"
|
||||
@click="toggleSetting('smtp_use_tls')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>From Address</span>
|
||||
<input
|
||||
type="email"
|
||||
v-model="settings.smtp_from_address"
|
||||
placeholder="noreply@example.com"
|
||||
@blur="saveSetting('smtp_from_address', settings.smtp_from_address)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>From Name</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.smtp_from_name"
|
||||
placeholder="ShopDB"
|
||||
@blur="saveSetting('smtp_from_name', settings.smtp_from_name)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row full-width">
|
||||
<label>
|
||||
<span>Default Alert Recipients</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.alert_recipients"
|
||||
placeholder="user1@example.com, user2@example.com"
|
||||
@blur="saveSetting('alert_recipients', settings.alert_recipients)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Comma-separated list of email addresses</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot" :class="smtpStatus"></span>
|
||||
<span>{{ smtpMessage }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="test-btn"
|
||||
@click="testEmail"
|
||||
:disabled="saving || testingEmail || !canTestEmail"
|
||||
>
|
||||
{{ testingEmail ? 'Sending...' : 'Send Test Email' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { settingsApi } from '../../api'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting, toggleSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
const testingEmail = ref(false)
|
||||
|
||||
// Per-page connection status (stays with the page, not the composable).
|
||||
const smtpStatus = computed(() => {
|
||||
if (!settings.smtp_enabled) return 'inactive'
|
||||
if (!settings.smtp_host || !settings.smtp_from_address) return 'warning'
|
||||
return 'pending'
|
||||
})
|
||||
|
||||
const smtpMessage = computed(() => {
|
||||
if (!settings.smtp_enabled) return 'Disabled'
|
||||
if (!settings.smtp_host) return 'SMTP host not configured'
|
||||
if (!settings.smtp_from_address) return 'From address not configured'
|
||||
return 'Configured - use test button to verify'
|
||||
})
|
||||
|
||||
const canTestEmail = computed(() => {
|
||||
return settings.smtp_host && settings.smtp_from_address && settings.alert_recipients
|
||||
})
|
||||
|
||||
async function testEmail() {
|
||||
testingEmail.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
// Send to the configured Alert Recipients (the endpoint splits the list).
|
||||
const response = await settingsApi.testEmail(settings.alert_recipients)
|
||||
const result = response.data?.data || {}
|
||||
if (result.sent) {
|
||||
success.value = `Test email sent to ${settings.alert_recipients}`
|
||||
} else {
|
||||
error.value = result.error
|
||||
? `Test email failed: ${result.error}`
|
||||
: (response.data?.message || 'Email is not configured')
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'Failed to send test email')
|
||||
} finally {
|
||||
testingEmail.value = false
|
||||
setTimeout(() => { success.value = '' }, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
302
frontend/src/views/settings/EmployeeDirectory.vue
Normal file
302
frontend/src/views/settings/EmployeeDirectory.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Employee Directory</h2>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-secondary" @click="showImport = true" :disabled="!selfhosted">Import CSV</button>
|
||||
<button class="btn btn-primary" @click="openModal()" :disabled="!selfhosted">+ Add Person</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!selfhosted" class="card">
|
||||
<p class="hint">
|
||||
The directory is in <strong>external</strong> mode - people come from a
|
||||
separate HR database, so there is nothing to manage here. To manage
|
||||
people in-app, set <code>employee_directory_mode</code> to
|
||||
<code>selfhosted</code> under Site & Facility settings, then reload.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="card">
|
||||
<div class="filters">
|
||||
<input v-model="search" type="text" class="form-control" placeholder="Search name or SSO..." />
|
||||
<span class="result-count">{{ filtered.length }} of {{ items.length }}</span>
|
||||
</div>
|
||||
<div v-if="loading" class="muted">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>SSO</th><th>Name</th><th>Team</th><th>Role</th><th>Photo</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in paginated" :key="e.SSO">
|
||||
<td class="mono">{{ e.SSO }}</td>
|
||||
<td>{{ e.First_Name }} {{ e.Last_Name }}</td>
|
||||
<td>{{ e.Team || '-' }}</td>
|
||||
<td>{{ e.Role || '-' }}</td>
|
||||
<td>
|
||||
<img v-if="e.photourl" :src="e.photourl" alt="Photo" class="photo-thumb" />
|
||||
<span v-else class="mono">-</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(e)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="remove(e)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filtered.length === 0">
|
||||
<td colspan="6" style="text-align:center;color:var(--text-light);">No people yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="totalPages > 1" class="pagination">
|
||||
<button class="btn btn-secondary btn-sm" :disabled="page===1" @click="page--">Prev</button>
|
||||
<span class="page-info">Page {{ page }} of {{ totalPages }}</span>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="page===totalPages" @click="page++">Next</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add / edit modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Person</h3></div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>SSO *</label>
|
||||
<input v-model="form.SSO" type="number" class="form-control" :disabled="!!editing" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Photo filename</label>
|
||||
<input v-model="form.Picture" type="text" class="form-control" placeholder="123456.jpg" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>First name *</label><input v-model="form.First_Name" type="text" class="form-control" required /></div>
|
||||
<div class="form-group"><label>Last name *</label><input v-model="form.Last_Name" type="text" class="form-control" required /></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Team</label><input v-model="form.Team" type="text" class="form-control" /></div>
|
||||
<div class="form-group"><label>Role</label><input v-model="form.Role" type="text" class="form-control" /></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Photo</label>
|
||||
<div class="photo-manage">
|
||||
<img v-if="form.photourl" :src="form.photourl" alt="Employee photo" class="photo-thumb-lg" />
|
||||
<div class="photo-actions">
|
||||
<template v-if="editing">
|
||||
<input
|
||||
ref="photoFileInput"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.webp"
|
||||
style="display: none"
|
||||
@change="onPhotoSelected"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="uploadingPhoto" @click="triggerPhotoUpload">
|
||||
{{ uploadingPhoto ? 'Uploading...' : (form.photourl ? 'Replace' : 'Upload') }}
|
||||
</button>
|
||||
<button v-if="form.photourl" type="button" class="btn btn-danger btn-sm" @click="removePhoto">Remove</button>
|
||||
</template>
|
||||
<small v-else class="hint">Save the person first, then upload a photo.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSV import modal -->
|
||||
<div v-if="showImport" class="modal-overlay" @click.self="showImport = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Import CSV</h3></div>
|
||||
<div class="modal-body">
|
||||
<p class="hint">Headers: <code>SSO,First_Name,Last_Name,Team,Role,Picture</code>. Existing SSOs are updated.</p>
|
||||
<input type="file" accept=".csv,text/csv" @change="onFile" />
|
||||
<textarea v-model="csvText" class="form-control" rows="8" placeholder="or paste CSV here" style="margin-top:0.6rem;"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showImport = false">Cancel</button>
|
||||
<button class="btn btn-primary" :disabled="importing || !csvText.trim()" @click="doImport">{{ importing ? 'Importing...' : 'Import' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { employeesApi, settingsApi } from '../../api'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const toast = useToast()
|
||||
const selfhosted = ref(false)
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const perPage = 25
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref(blank())
|
||||
const showImport = ref(false)
|
||||
const csvText = ref('')
|
||||
const importing = ref(false)
|
||||
const photoFileInput = ref(null)
|
||||
const uploadingPhoto = ref(false)
|
||||
|
||||
function blank() { return { SSO: '', First_Name: '', Last_Name: '', Team: '', Role: '', Picture: '', photourl: '' } }
|
||||
|
||||
const filtered = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
if (!term) return items.value
|
||||
return items.value.filter(e =>
|
||||
`${e.First_Name} ${e.Last_Name}`.toLowerCase().includes(term) ||
|
||||
String(e.SSO).includes(term))
|
||||
})
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / perPage)))
|
||||
const paginated = computed(() => filtered.value.slice((page.value - 1) * perPage, page.value * perPage))
|
||||
watch(search, () => { page.value = 1 })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await settingsApi.get('employee_directory_mode')
|
||||
selfhosted.value = (response.data?.data?.value || 'external') === 'selfhosted'
|
||||
} catch (err) { /* default external */ }
|
||||
if (selfhosted.value) await load()
|
||||
else loading.value = false
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await employeesApi.directory.list()
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Could not load directory'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item ? { ...item } : blank()
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) await employeesApi.directory.update(editing.value.SSO, form.value)
|
||||
else await employeesApi.directory.create(form.value)
|
||||
closeModal()
|
||||
load()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function triggerPhotoUpload() {
|
||||
photoFileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onPhotoSelected(event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file || !editing.value) return
|
||||
uploadingPhoto.value = true
|
||||
try {
|
||||
const response = await employeesApi.directory.uploadPhoto(editing.value.SSO, file)
|
||||
// Backend returns the updated employee with photourl set to the served URL.
|
||||
form.value.photourl = response.data.data.photourl || ''
|
||||
form.value.photofilename = response.data.data.photofilename || ''
|
||||
toast.success('Photo uploaded')
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to upload photo'))
|
||||
} finally {
|
||||
uploadingPhoto.value = false
|
||||
if (photoFileInput.value) photoFileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function removePhoto() {
|
||||
if (!editing.value) return
|
||||
if (!confirm('Remove this photo?')) return
|
||||
try {
|
||||
await employeesApi.directory.removePhoto(editing.value.SSO)
|
||||
form.value.photourl = ''
|
||||
form.value.photofilename = ''
|
||||
toast.success('Photo removed')
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to remove photo'))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(e) {
|
||||
if (!confirm(`Remove ${e.First_Name} ${e.Last_Name}?`)) return
|
||||
try {
|
||||
await employeesApi.directory.remove(e.SSO)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
|
||||
function onFile(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => { csvText.value = reader.result }
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
importing.value = true
|
||||
try {
|
||||
const response = await employeesApi.directory.importCsv(csvText.value)
|
||||
toast.success(response.data?.message || 'Imported')
|
||||
showImport.value = false
|
||||
csvText.value = ''
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Import failed'))
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint { color: var(--text-light); font-size: 0.9rem; }
|
||||
.muted { color: var(--text-light); }
|
||||
.mono { font-family: monospace; font-size: 0.85rem; }
|
||||
.filters { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
|
||||
.filters .form-control { max-width: 320px; }
|
||||
.result-count { color: var(--text-light); font-size: 0.85rem; }
|
||||
.form-row { display: flex; gap: 1rem; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
.pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.9rem 0 0.2rem; }
|
||||
.page-info { color: var(--text-light); font-size: 0.85rem; }
|
||||
.photo-thumb { width: 36px; height: 36px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); }
|
||||
.photo-manage { display: flex; align-items: center; gap: 1rem; }
|
||||
.photo-thumb-lg { width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); }
|
||||
.photo-actions { display: flex; align-items: center; gap: 0.5rem; }
|
||||
</style>
|
||||
100
frontend/src/views/settings/FloorMapSettings.vue
Normal file
100
frontend/src/views/settings/FloorMapSettings.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Floor Map</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<h3>Facility Blueprint</h3>
|
||||
<p class="setting-description">
|
||||
The floor-plan image and its pixel dimensions for this facility. Map
|
||||
markers are positioned against these dimensions, so the width and
|
||||
height must match the native size of the blueprint image. Leave the
|
||||
image paths at their defaults to use the bundled sitemap.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Blueprint image (light theme)</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.map_blueprint_light"
|
||||
placeholder="/static/images/sitemap2025-light.png"
|
||||
@blur="saveSetting('map_blueprint_light', settings.map_blueprint_light)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<div class="map-upload-row">
|
||||
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
|
||||
<img v-if="settings.map_blueprint_light" :src="settings.map_blueprint_light" class="map-thumb" alt="light blueprint" />
|
||||
</div>
|
||||
<small class="input-hint">Upload an image, or type a path/URL to the light-theme floor plan</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Blueprint image (dark theme)</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.map_blueprint_dark"
|
||||
placeholder="/static/images/sitemap2025-dark.png"
|
||||
@blur="saveSetting('map_blueprint_dark', settings.map_blueprint_dark)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<div class="map-upload-row">
|
||||
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
|
||||
<img v-if="settings.map_blueprint_dark" :src="settings.map_blueprint_dark" class="map-thumb map-thumb-dark" alt="dark blueprint" />
|
||||
</div>
|
||||
<small class="input-hint">Upload an image, or type a path/URL to the dark-theme floor plan</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Blueprint width (pixels)</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model="settings.map_width"
|
||||
min="1"
|
||||
placeholder="3300"
|
||||
@blur="saveSetting('map_width', settings.map_width)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Native pixel width of the blueprint image</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Blueprint height (pixels)</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model="settings.map_height"
|
||||
min="1"
|
||||
placeholder="2550"
|
||||
@blur="saveSetting('map_height', settings.map_height)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Native pixel height of the blueprint image</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, mapUploading, error, success,
|
||||
loadSettings, saveSetting, uploadBlueprint,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
45
frontend/src/views/settings/GlobalSearchSettings.vue
Normal file
45
frontend/src/views/settings/GlobalSearchSettings.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Global Search</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Choose which content types appear in global search results. Disabling a
|
||||
type here hides it from search without disabling the plugin elsewhere.
|
||||
</p>
|
||||
|
||||
<div class="setting-row" v-for="domain in searchDomains" :key="domain.key">
|
||||
<label class="toggle-label">
|
||||
<span>{{ domain.label }}</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: searchValue(domain.key) }"
|
||||
@click="toggleSearchDomain(domain.key)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings, searchDomains } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
saving, error, success,
|
||||
loadSettings, searchValue, toggleSearchDomain,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
145
frontend/src/views/settings/LocationTypesList.vue
Normal file
145
frontend/src/views/settings/LocationTypesList.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Location Types</h2>
|
||||
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Location Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Location Type</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in visibleItems" :key="t.locationtypeid">
|
||||
<td>{{ t.locationtype }}</td>
|
||||
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
|
||||
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
|
||||
<td class="actions">
|
||||
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleItems.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">No location types found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Location Type</h3></div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Location Type *</label>
|
||||
<input v-model="form.locationtype" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color <span class="hint">(blank = auto)</span></label>
|
||||
<ColorSwatchPicker v-model="form.color" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { locationsApi } from '../../api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const showInactive = ref(false)
|
||||
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({ locationtype: '', description: '', color: '', isactive: true })
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await locationsApi.types.list({ active: false })
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading location types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? { locationtype: item.locationtype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
|
||||
: { locationtype: '', description: '', color: '', isactive: true }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await locationsApi.types.update(editing.value.locationtypeid, form.value)
|
||||
} else {
|
||||
await locationsApi.types.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteType(t) {
|
||||
if (!confirm(`Delete location type "${t.locationtype}"?`)) return
|
||||
try {
|
||||
await locationsApi.types.remove(t.locationtypeid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
388
frontend/src/views/settings/LocationsList.vue
Normal file
388
frontend/src/views/settings/LocationsList.vue
Normal file
@@ -0,0 +1,388 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Locations</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Location</button>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search locations..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Location Name</th>
|
||||
<th>Type</th>
|
||||
<th>Building</th>
|
||||
<th>Floor</th>
|
||||
<th>Room</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="loc in locations" :key="loc.locationid">
|
||||
<td>{{ loc.locationname }}</td>
|
||||
<td>{{ loc.locationtypename || '-' }}</td>
|
||||
<td>{{ loc.building || '-' }}</td>
|
||||
<td>{{ loc.floor || '-' }}</td>
|
||||
<td>{{ loc.room || '-' }}</td>
|
||||
<td class="cell-truncate" :title="loc.description">{{ loc.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="openModal(loc)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger btn-sm"
|
||||
@click="confirmDelete(loc)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="locations.length === 0">
|
||||
<td colspan="7" style="text-align: center; color: var(--text-light);">
|
||||
No locations found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingLocation ? 'Edit Location' : 'Add Location' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveLocation">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="locationname">Location Name *</label>
|
||||
<input
|
||||
id="locationname"
|
||||
v-model="form.locationname"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="building">Building</label>
|
||||
<input
|
||||
id="building"
|
||||
v-model="form.building"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="floor">Floor</label>
|
||||
<input
|
||||
id="floor"
|
||||
v-model="form.floor"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="room">Room</label>
|
||||
<input
|
||||
id="room"
|
||||
v-model="form.room"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="locationtypeid">Type</label>
|
||||
<select id="locationtypeid" v-model="form.locationtypeid" class="form-control">
|
||||
<option value="">Select type...</option>
|
||||
<option v-for="t in locationTypes" :key="t.locationtypeid" :value="t.locationtypeid">
|
||||
{{ t.locationtype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="parentlocationid">Parent Location</label>
|
||||
<select id="parentlocationid" v-model="form.parentlocationid" class="form-control">
|
||||
<option value="">None</option>
|
||||
<option
|
||||
v-for="l in parentOptions"
|
||||
:key="l.locationid"
|
||||
:value="l.locationid"
|
||||
>
|
||||
{{ l.locationname }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="mapimage">Map Image URL</label>
|
||||
<input
|
||||
id="mapimage"
|
||||
v-model="form.mapimage"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Optional floor plan image URL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Delete Location</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ locationToDelete?.locationname }}</strong>?</p>
|
||||
<p style="color: var(--text-light); font-size: 0.875rem;">
|
||||
This may affect machines assigned to this location.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteLocation">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { locationsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const locations = ref([])
|
||||
const locationTypes = ref([])
|
||||
const allLocations = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadLocations })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingLocation = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const locationToDelete = ref(null)
|
||||
|
||||
const form = ref({
|
||||
locationname: '',
|
||||
building: '',
|
||||
floor: '',
|
||||
room: '',
|
||||
description: '',
|
||||
locationtypeid: '',
|
||||
parentlocationid: '',
|
||||
mapimage: ''
|
||||
})
|
||||
|
||||
// parent options = all locations except the one being edited (no self-parent)
|
||||
const parentOptions = computed(() =>
|
||||
allLocations.value.filter(l => l.locationid !== editingLocation.value?.locationid)
|
||||
)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [typesRes, allRes] = await Promise.all([
|
||||
locationsApi.types.list(),
|
||||
locationsApi.list({ perpage: 100 })
|
||||
])
|
||||
locationTypes.value = typesRes.data.data || []
|
||||
allLocations.value = allRes.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading location types:', err)
|
||||
}
|
||||
loadLocations()
|
||||
})
|
||||
|
||||
async function loadLocations() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
|
||||
const response = await locationsApi.list(params)
|
||||
locations.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading locations:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadLocations()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadLocations()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadLocations()
|
||||
}
|
||||
|
||||
function openModal(loc = null) {
|
||||
editingLocation.value = loc
|
||||
if (loc) {
|
||||
form.value = {
|
||||
locationname: loc.locationname || '',
|
||||
building: loc.building || '',
|
||||
floor: loc.floor || '',
|
||||
room: loc.room || '',
|
||||
description: loc.description || '',
|
||||
locationtypeid: loc.locationtypeid || '',
|
||||
parentlocationid: loc.parentlocationid || '',
|
||||
mapimage: loc.mapimage || ''
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
locationname: '',
|
||||
building: '',
|
||||
floor: '',
|
||||
room: '',
|
||||
description: '',
|
||||
locationtypeid: '',
|
||||
parentlocationid: '',
|
||||
mapimage: ''
|
||||
}
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingLocation.value = null
|
||||
}
|
||||
|
||||
async function saveLocation() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...form.value,
|
||||
locationtypeid: form.value.locationtypeid || null,
|
||||
parentlocationid: form.value.parentlocationid || null
|
||||
}
|
||||
if (editingLocation.value) {
|
||||
await locationsApi.update(editingLocation.value.locationid, payload)
|
||||
} else {
|
||||
await locationsApi.create(payload)
|
||||
}
|
||||
closeModal()
|
||||
loadLocations()
|
||||
} catch (err) {
|
||||
console.error('Error saving location:', err)
|
||||
error.value = apiError(err, 'Failed to save location')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(loc) {
|
||||
locationToDelete.value = loc
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteLocation() {
|
||||
try {
|
||||
await locationsApi.delete(locationToDelete.value.locationid)
|
||||
showDeleteModal.value = false
|
||||
locationToDelete.value = null
|
||||
loadLocations()
|
||||
} catch (err) {
|
||||
console.error('Error deleting location:', err)
|
||||
toast.error('Failed to delete location')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
145
frontend/src/views/settings/MachineTypesList.vue
Normal file
145
frontend/src/views/settings/MachineTypesList.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Machine Types</h2>
|
||||
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Machine Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine Type</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in visibleItems" :key="t.machinetypeid">
|
||||
<td>{{ t.machinetype }}</td>
|
||||
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
|
||||
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
|
||||
<td class="actions">
|
||||
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleItems.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">No machine types found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Machine Type</h3></div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Machine Type *</label>
|
||||
<input v-model="form.machinetype" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
|
||||
<ColorSwatchPicker v-model="form.color" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { machinesApi } from '../../api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const showInactive = ref(false)
|
||||
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({ machinetype: '', description: '', color: '', isactive: true })
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await machinesApi.types.list({ perpage: 200, active: false })
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading machine types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? { machinetype: item.machinetype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
|
||||
: { machinetype: '', description: '', color: '', isactive: true }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await machinesApi.types.update(editing.value.machinetypeid, form.value)
|
||||
} else {
|
||||
await machinesApi.types.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteType(t) {
|
||||
if (!confirm(`Delete machine type "${t.machinetype}"?`)) return
|
||||
try {
|
||||
await machinesApi.types.remove(t.machinetypeid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
157
frontend/src/views/settings/MeasuringToolTypesList.vue
Normal file
157
frontend/src/views/settings/MeasuringToolTypesList.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Measuring Tool Types</h2>
|
||||
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="mt in visibleTypes" :key="mt.measuringtooltypeid">
|
||||
<td>{{ mt.name }}</td>
|
||||
<td class="cell-truncate" :title="mt.description">{{ mt.description || '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(mt.color)">{{ mt.color || 'auto' }}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<span v-if="mt.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(mt)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteType(mt)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleTypes.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No measuring tool types found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit Type' : 'Add Type' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="name">Type *</label>
|
||||
<input id="name" v-model="form.name" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
|
||||
<ColorSwatchPicker v-model="form.color" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { measuringtoolsApi } from '../../api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const types = ref([])
|
||||
const showInactive = ref(false)
|
||||
const visibleTypes = computed(() => showInactive.value ? types.value : types.value.filter(x => x.isactive !== false))
|
||||
const loading = ref(true)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({ name: '', description: '', color: '', isactive: true })
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await measuringtoolsApi.types.list({ perpage: 200, active: false })
|
||||
types.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading measuring tool types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? { name: item.name || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
|
||||
: { name: '', description: '', color: '', isactive: true }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function deleteType(mt) {
|
||||
if (!confirm(`Delete measuring tool type "${mt.name}"?`)) return
|
||||
try {
|
||||
await measuringtoolsApi.types.remove(mt.measuringtooltypeid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await measuringtoolsApi.types.update(editing.value.measuringtooltypeid, form.value)
|
||||
} else {
|
||||
await measuringtoolsApi.types.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
438
frontend/src/views/settings/ModelSuppliesList.vue
Normal file
438
frontend/src/views/settings/ModelSuppliesList.vue
Normal file
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Model Toners and Supplies</h2>
|
||||
<span class="subtitle">Map toner, drum, and waste part numbers to each printer model.</span>
|
||||
</div>
|
||||
|
||||
<div class="supplies-layout">
|
||||
<!-- model picker -->
|
||||
<div class="card model-panel">
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search models..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<label class="withsupplies">
|
||||
<input v-model="onlyWithSupplies" type="checkbox" @change="loadModels" />
|
||||
With supplies only
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="loadingModels" class="loading">Loading...</div>
|
||||
<div v-else class="model-list">
|
||||
<button
|
||||
v-for="m in models"
|
||||
:key="m.modelnumberid"
|
||||
class="model-row"
|
||||
:class="{ active: selectedModel && selectedModel.modelnumberid === m.modelnumberid }"
|
||||
@click="selectModel(m)"
|
||||
>
|
||||
<span class="model-name">{{ m.modelnumber }}</span>
|
||||
<span class="model-meta">
|
||||
<span class="vendor">{{ m.vendor || 'No vendor' }}</span>
|
||||
<span class="badge" :class="m.supplycount ? 'badge-success' : ''">
|
||||
{{ m.supplycount }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<p v-if="!models.length" class="empty-state">No models match.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- supplies for the selected model -->
|
||||
<div class="card supply-panel">
|
||||
<div v-if="!selectedModel" class="empty-state">
|
||||
Select a model to view and edit its supplies.
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="panel-header">
|
||||
<h3>{{ selectedModel.modelnumber }}</h3>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Supply</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingSupplies" class="loading">Loading...</div>
|
||||
<div v-else class="table-container">
|
||||
<table v-if="supplies.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Color</th>
|
||||
<th>Tier</th>
|
||||
<th>Part Number</th>
|
||||
<th>Name</th>
|
||||
<th>Yield</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in supplies" :key="s.modelsupplyid">
|
||||
<td>{{ s.supplytype }}</td>
|
||||
<td>
|
||||
<span v-if="s.color !== 'none'" class="color-dot" :class="'dot-' + s.color"></span>
|
||||
{{ s.color === 'none' ? '-' : s.color }}
|
||||
</td>
|
||||
<td>{{ s.capacitytier }}</td>
|
||||
<td class="partnumber">{{ s.partnumber }}</td>
|
||||
<td>{{ s.marketingname || '-' }}</td>
|
||||
<td>{{ s.pageyield ? s.pageyield.toLocaleString() : '-' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-sm btn-secondary" @click="openModal(s)">Edit</button>
|
||||
<button class="btn btn-sm btn-danger" @click="remove(s)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="empty-state">No supplies mapped yet. Add one above.</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- add / edit modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<h3>{{ editing ? 'Edit Supply' : 'Add Supply' }}</h3>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Supply Type
|
||||
<select v-model="form.supplytype" class="form-control" @change="onSupplyTypeChange">
|
||||
<option v-for="t in meta.supplytypes" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Color
|
||||
<select v-model="form.color" class="form-control">
|
||||
<option v-for="c in meta.colors" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<small v-if="form.supplytype !== 'toner'" class="field-hint">Drums/waste are often colorless - leave as "none" unless per-color</small>
|
||||
</label>
|
||||
<label>
|
||||
Capacity Tier
|
||||
<select v-model="form.capacitytier" class="form-control">
|
||||
<option v-for="t in meta.capacitytiers" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Part Number
|
||||
<input v-model="form.partnumber" type="text" class="form-control" placeholder="e.g. W2020A" />
|
||||
</label>
|
||||
<label class="full">
|
||||
Marketing Name
|
||||
<input v-model="form.marketingname" type="text" class="form-control" placeholder="e.g. 414A Black" />
|
||||
</label>
|
||||
<label>
|
||||
Page Yield
|
||||
<input v-model.number="form.pageyield" type="number" class="form-control" placeholder="e.g. 2400" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="full">
|
||||
Notes
|
||||
<input v-model="form.notes" type="text" class="form-control" />
|
||||
</label>
|
||||
<p v-if="formError" class="text-danger">{{ formError }}</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const models = ref([])
|
||||
const loadingModels = ref(true)
|
||||
const search = ref('')
|
||||
const onlyWithSupplies = ref(false)
|
||||
|
||||
const selectedModel = ref(null)
|
||||
const supplies = ref([])
|
||||
const loadingSupplies = ref(false)
|
||||
|
||||
const meta = ref({ supplytypes: [], colors: [], capacitytiers: [] })
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const formError = ref('')
|
||||
const form = ref({})
|
||||
|
||||
let searchTimer = null
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(loadModels, 300)
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
loadingModels.value = true
|
||||
try {
|
||||
const params = { per_page: 100 }
|
||||
if (search.value) params.search = search.value
|
||||
if (onlyWithSupplies.value) params.withsupplies = 'true'
|
||||
const response = await printersApi.modelSupplies.listModels(params)
|
||||
models.value = response.data.data || []
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectModel(model) {
|
||||
selectedModel.value = model
|
||||
loadingSupplies.value = true
|
||||
try {
|
||||
const response = await printersApi.modelSupplies.list(model.modelnumberid)
|
||||
supplies.value = response.data.data.supplies || []
|
||||
} finally {
|
||||
loadingSupplies.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(supply = null) {
|
||||
editing.value = supply
|
||||
formError.value = ''
|
||||
if (supply) {
|
||||
form.value = { ...supply }
|
||||
} else {
|
||||
form.value = {
|
||||
supplytype: 'toner',
|
||||
color: 'black',
|
||||
capacitytier: 'standard',
|
||||
partnumber: '',
|
||||
marketingname: '',
|
||||
pageyield: null,
|
||||
notes: ''
|
||||
}
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function onSupplyTypeChange() {
|
||||
// toner is color-specific; drum/waste/maintenance usually are not. Default
|
||||
// the color sensibly on type change but keep it editable (per-color drums).
|
||||
if (form.value.supplytype !== 'toner') {
|
||||
if (!form.value.color || form.value.color === 'black') form.value.color = 'none'
|
||||
} else if (form.value.color === 'none') {
|
||||
form.value.color = 'black'
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.partnumber) {
|
||||
formError.value = 'Part number is required.'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
formError.value = ''
|
||||
try {
|
||||
if (editing.value) {
|
||||
await printersApi.modelSupplies.update(editing.value.modelsupplyid, form.value)
|
||||
} else {
|
||||
await printersApi.modelSupplies.create(selectedModel.value.modelnumberid, form.value)
|
||||
}
|
||||
closeModal()
|
||||
await selectModel(selectedModel.value)
|
||||
await loadModels()
|
||||
} catch (err) {
|
||||
formError.value = apiError(err, 'Save failed.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(supply) {
|
||||
if (!confirm(`Delete ${supply.partnumber}?`)) return
|
||||
await printersApi.modelSupplies.delete(supply.modelsupplyid)
|
||||
await selectModel(selectedModel.value)
|
||||
await loadModels()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const metaResponse = await printersApi.modelSupplies.meta()
|
||||
meta.value = metaResponse.data.data
|
||||
await loadModels()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subtitle {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.supplies-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 340px 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.model-panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.withsupplies {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.model-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.model-row:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.model-row.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.model-row.active .vendor {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.model-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.vendor {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.partnumber {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.35rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dot-black { background: #222; }
|
||||
.dot-cyan { background: #00b7eb; }
|
||||
.dot-magenta { background: #d633a0; }
|
||||
.dot-yellow { background: #f0c000; }
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.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 {
|
||||
/* bg-card is intentionally translucent in dark mode; modals must be opaque */
|
||||
background: var(--bg-card-solid);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
width: 520px;
|
||||
max-width: 92vw;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.form-grid label,
|
||||
.modal > label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.form-grid .full,
|
||||
.modal > label.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
278
frontend/src/views/settings/ModelTypesList.vue
Normal file
278
frontend/src/views/settings/ModelTypesList.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Model Types</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model Type</th>
|
||||
<th>Category</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="mt in modelTypes" :key="mt.modeltypeid">
|
||||
<td>{{ mt.modeltype }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="getCategoryClass(mt.category)">
|
||||
{{ mt.category }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="cell-truncate" :title="mt.description">{{ mt.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="openModal(mt)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger btn-sm"
|
||||
@click="confirmDelete(mt)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="modelTypes.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No model types found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingType ? 'Edit Model Type' : 'Add Model Type' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveType">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="modeltype">Type Name *</label>
|
||||
<input
|
||||
id="modeltype"
|
||||
v-model="form.modeltype"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="category">Category *</label>
|
||||
<select
|
||||
id="category"
|
||||
v-model="form.category"
|
||||
class="form-control"
|
||||
required
|
||||
>
|
||||
<option value="">Select category...</option>
|
||||
<option value="Equipment">Equipment</option>
|
||||
<option value="PC">PC</option>
|
||||
<option value="Network">Network</option>
|
||||
<option value="Printer">Printer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Delete Model Type</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ typeToDelete?.modeltype }}</strong>?</p>
|
||||
<p style="color: var(--text-light); font-size: 0.875rem;">
|
||||
This may affect models using this type.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteType">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { modeltypesApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const modelTypes = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, setPage } = useListQuery({ onChange: loadTypes })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingType = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const typeToDelete = ref(null)
|
||||
|
||||
const form = ref({
|
||||
modeltype: '',
|
||||
category: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTypes()
|
||||
})
|
||||
|
||||
async function loadTypes() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
|
||||
const response = await modeltypesApi.list(params)
|
||||
modelTypes.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading model types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadTypes()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadTypes()
|
||||
}
|
||||
|
||||
function openModal(mt = null) {
|
||||
editingType.value = mt
|
||||
if (mt) {
|
||||
form.value = {
|
||||
modeltype: mt.modeltype || '',
|
||||
category: mt.category || '',
|
||||
description: mt.description || ''
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
modeltype: '',
|
||||
category: '',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingType.value = null
|
||||
}
|
||||
|
||||
async function saveType() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
if (editingType.value) {
|
||||
await modeltypesApi.update(editingType.value.modeltypeid, form.value)
|
||||
} else {
|
||||
await modeltypesApi.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadTypes()
|
||||
} catch (err) {
|
||||
console.error('Error saving model type:', err)
|
||||
error.value = apiError(err, 'Failed to save model type')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(mt) {
|
||||
typeToDelete.value = mt
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteType() {
|
||||
try {
|
||||
await modeltypesApi.delete(typeToDelete.value.modeltypeid)
|
||||
showDeleteModal.value = false
|
||||
typeToDelete.value = null
|
||||
loadTypes()
|
||||
} catch (err) {
|
||||
console.error('Error deleting model type:', err)
|
||||
toast.error('Failed to delete model type')
|
||||
}
|
||||
}
|
||||
|
||||
function getCategoryClass(category) {
|
||||
if (!category) return 'badge-info'
|
||||
const c = category.toLowerCase()
|
||||
if (c === 'equipment') return 'badge-info'
|
||||
if (c === 'pc') return 'badge-success'
|
||||
if (c === 'network') return 'badge-warning'
|
||||
if (c === 'printer') return 'badge-primary'
|
||||
return 'badge-info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Uses global styles from style.css -->
|
||||
456
frontend/src/views/settings/ModelsList.vue
Normal file
456
frontend/src/views/settings/ModelsList.vue
Normal file
@@ -0,0 +1,456 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Models</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Model</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search models..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="vendorFilter" class="form-control" @change="loadModels">
|
||||
<option value="">All Vendors</option>
|
||||
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th>Vendor</th>
|
||||
<th>Type</th>
|
||||
<th>Documentation</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in models" :key="m.modelnumberid">
|
||||
<td>
|
||||
<div>{{ m.modelnumber }}</div>
|
||||
<small v-if="m.description" class="text-muted">{{ m.description }}</small>
|
||||
</td>
|
||||
<td>{{ m.vendor || '-' }}</td>
|
||||
<td>{{ m.modeltype || '-' }}</td>
|
||||
<td>
|
||||
<a v-if="m.documentationurl" :href="m.documentationurl" target="_blank" class="btn btn-sm btn-link">
|
||||
View Docs
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(m)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="confirmDelete(m)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="models.length === 0">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-light);">
|
||||
No models found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingModel ? 'Edit Model' : 'Add Model' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveModel">
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="modelnumber">Model Number *</label>
|
||||
<input
|
||||
id="modelnumber"
|
||||
v-model="form.modelnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vendorid">Vendor *</label>
|
||||
<select id="vendorid" v-model="form.vendorid" class="form-control" required>
|
||||
<option value="">Select vendor...</option>
|
||||
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="modeltypeid">Model Type</label>
|
||||
<select id="modeltypeid" v-model="form.modeltypeid" class="form-control">
|
||||
<option value="">Select type...</option>
|
||||
<option v-for="mt in modelTypes" :key="mt.modeltypeid" :value="mt.modeltypeid">
|
||||
{{ mt.modeltype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<input id="description" v-model="form.description" type="text" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="documentationurl">Documentation URL</label>
|
||||
<input
|
||||
id="documentationurl"
|
||||
v-model="form.documentationurl"
|
||||
type="url"
|
||||
class="form-control"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Image</label>
|
||||
<div class="image-manage">
|
||||
<img v-if="form.imageurl" :src="form.imageurl" alt="Model image" class="image-thumb" />
|
||||
<div class="image-actions">
|
||||
<template v-if="editingModel">
|
||||
<input
|
||||
ref="imageFileInput"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.webp,.svg"
|
||||
style="display: none"
|
||||
@change="onImageSelected"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="uploadingImage" @click="triggerImageUpload">
|
||||
{{ uploadingImage ? 'Uploading...' : (form.imageurl ? 'Replace' : 'Upload') }}
|
||||
</button>
|
||||
<button v-if="form.imageurl" type="button" class="btn btn-danger btn-sm" @click="removeImage">Remove</button>
|
||||
</template>
|
||||
<small v-else class="text-muted">Save the model first, then upload a photo.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="imageurl">Image URL</label>
|
||||
<input
|
||||
id="imageurl"
|
||||
v-model="form.imageurl"
|
||||
type="url"
|
||||
class="form-control"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
<small class="text-muted">Manual alternative. Uploading a photo overwrites this URL.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea id="notes" v-model="form.notes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Delete Model</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ modelToDelete?.modelnumber }}</strong>?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteModel">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { modelsApi, vendorsApi, modeltypesApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const models = ref([])
|
||||
const vendors = ref([])
|
||||
const modelTypes = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadModels })
|
||||
const vendorFilter = ref('')
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingModel = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const modelToDelete = ref(null)
|
||||
|
||||
const imageFileInput = ref(null)
|
||||
const uploadingImage = ref(false)
|
||||
|
||||
const form = ref({
|
||||
modelnumber: '',
|
||||
vendorid: '',
|
||||
modeltypeid: '',
|
||||
description: '',
|
||||
documentationurl: '',
|
||||
imageurl: '',
|
||||
notes: ''
|
||||
})
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadModels(),
|
||||
loadVendors(),
|
||||
loadModelTypes()
|
||||
])
|
||||
})
|
||||
|
||||
async function loadModels() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
if (vendorFilter.value) params.vendor = vendorFilter.value
|
||||
|
||||
const response = await modelsApi.list(params)
|
||||
models.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading models:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVendors() {
|
||||
try {
|
||||
const response = await vendorsApi.list({ perpage: 100 })
|
||||
vendors.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading vendors:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModelTypes() {
|
||||
try {
|
||||
const response = await modeltypesApi.list({ perpage: 100 })
|
||||
modelTypes.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading model types:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadModels()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadModels()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadModels()
|
||||
}
|
||||
|
||||
function openModal(m = null) {
|
||||
editingModel.value = m
|
||||
if (m) {
|
||||
form.value = {
|
||||
modelnumber: m.modelnumber || '',
|
||||
vendorid: m.vendorid || '',
|
||||
modeltypeid: m.modeltypeid || '',
|
||||
description: m.description || '',
|
||||
documentationurl: m.documentationurl || '',
|
||||
imageurl: m.imageurl || '',
|
||||
notes: m.notes || ''
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
modelnumber: '',
|
||||
vendorid: '',
|
||||
modeltypeid: '',
|
||||
description: '',
|
||||
documentationurl: '',
|
||||
imageurl: '',
|
||||
notes: ''
|
||||
}
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingModel.value = null
|
||||
}
|
||||
|
||||
async function saveModel() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const data = { ...form.value }
|
||||
if (!data.vendorid) data.vendorid = null
|
||||
if (!data.modeltypeid) data.modeltypeid = null
|
||||
|
||||
if (editingModel.value) {
|
||||
await modelsApi.update(editingModel.value.modelnumberid, data)
|
||||
} else {
|
||||
await modelsApi.create(data)
|
||||
}
|
||||
closeModal()
|
||||
loadModels()
|
||||
} catch (err) {
|
||||
console.error('Error saving model:', err)
|
||||
error.value = apiError(err, 'Failed to save model')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function triggerImageUpload() {
|
||||
imageFileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onImageSelected(event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file || !editingModel.value) return
|
||||
uploadingImage.value = true
|
||||
try {
|
||||
const response = await modelsApi.uploadImage(editingModel.value.modelnumberid, file)
|
||||
// Backend returns the updated model with imageurl set to the served URL.
|
||||
form.value.imageurl = response.data.data.imageurl || ''
|
||||
toast.success('Image uploaded')
|
||||
loadModels()
|
||||
} catch (err) {
|
||||
console.error('Error uploading image:', err)
|
||||
toast.error(apiError(err, 'Failed to upload image'))
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
if (imageFileInput.value) imageFileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function removeImage() {
|
||||
if (!editingModel.value) return
|
||||
if (!confirm('Remove this model image?')) return
|
||||
try {
|
||||
await modelsApi.removeImage(editingModel.value.modelnumberid)
|
||||
form.value.imageurl = ''
|
||||
toast.success('Image removed')
|
||||
loadModels()
|
||||
} catch (err) {
|
||||
console.error('Error removing image:', err)
|
||||
toast.error(apiError(err, 'Failed to remove image'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(m) {
|
||||
modelToDelete.value = m
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteModel() {
|
||||
try {
|
||||
await modelsApi.delete(modelToDelete.value.modelnumberid)
|
||||
showDeleteModal.value = false
|
||||
modelToDelete.value = null
|
||||
loadModels()
|
||||
} catch (err) {
|
||||
console.error('Error deleting model:', err)
|
||||
toast.error('Failed to delete model')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-lg {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.image-manage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.image-thumb {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
145
frontend/src/views/settings/NetworkTypesList.vue
Normal file
145
frontend/src/views/settings/NetworkTypesList.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Network Device Types</h2>
|
||||
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Network Device Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Network Device Type</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in visibleItems" :key="t.networkdevicetypeid">
|
||||
<td>{{ t.networkdevicetype }}</td>
|
||||
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
|
||||
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
|
||||
<td class="actions">
|
||||
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleItems.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">No network device types found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Network Device Type</h3></div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Network Device Type *</label>
|
||||
<input v-model="form.networkdevicetype" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
|
||||
<ColorSwatchPicker v-model="form.color" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { networkApi } from '../../api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const showInactive = ref(false)
|
||||
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({ networkdevicetype: '', description: '', color: '', isactive: true })
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await networkApi.types.list({ perpage: 200, active: false })
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading network device types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? { networkdevicetype: item.networkdevicetype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
|
||||
: { networkdevicetype: '', description: '', color: '', isactive: true }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await networkApi.types.update(editing.value.networkdevicetypeid, form.value)
|
||||
} else {
|
||||
await networkApi.types.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteType(t) {
|
||||
if (!confirm(`Delete network device type "${t.networkdevicetype}"?`)) return
|
||||
try {
|
||||
await networkApi.types.remove(t.networkdevicetypeid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
228
frontend/src/views/settings/OperatingSystemsList.vue
Normal file
228
frontend/src/views/settings/OperatingSystemsList.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Operating Systems</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add OS</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>OS Name</th>
|
||||
<th>Version</th>
|
||||
<th>Architecture</th>
|
||||
<th>End of Life</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="os in items" :key="os.osid">
|
||||
<td>{{ os.osname }}</td>
|
||||
<td>{{ os.osversion || '-' }}</td>
|
||||
<td>{{ os.architecture || '-' }}</td>
|
||||
<td>
|
||||
<span v-if="os.endoflife" :class="{ 'text-danger': isPastEol(os.endoflife) }">
|
||||
{{ os.endoflife }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(os)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="confirmDelete(os)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-light);">
|
||||
No operating systems found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit Operating System' : 'Add Operating System' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="osname">OS Name *</label>
|
||||
<input id="osname" v-model="form.osname" type="text" class="form-control" required placeholder="Windows 11" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="osversion">Version</label>
|
||||
<input id="osversion" v-model="form.osversion" type="text" class="form-control" placeholder="23H2" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="architecture">Architecture</label>
|
||||
<select id="architecture" v-model="form.architecture" class="form-control">
|
||||
<option value="">Select...</option>
|
||||
<option value="x64">x64</option>
|
||||
<option value="x86">x86</option>
|
||||
<option value="ARM64">ARM64</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="endoflife">End of Life Date</label>
|
||||
<input id="endoflife" v-model="form.endoflife" type="date" class="form-control" />
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Delete Operating System</h3></div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ toDelete?.osname }}</strong>?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteItem">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { operatingsystemsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, setPage } = useListQuery({ onChange: loadData })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const toDelete = ref(null)
|
||||
|
||||
const form = ref({ osname: '', osversion: '', architecture: '', endoflife: '' })
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await operatingsystemsApi.list({ page: page.value, perpage: perPage.value })
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading operating systems:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p) { setPage(p); loadData() }
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadData()
|
||||
}
|
||||
|
||||
function isPastEol(date) {
|
||||
return new Date(date) < new Date()
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item ? {
|
||||
osname: item.osname || '',
|
||||
osversion: item.osversion || '',
|
||||
architecture: item.architecture || '',
|
||||
endoflife: item.endoflife || ''
|
||||
} : { osname: '', osversion: '', architecture: '', endoflife: '' }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const data = { ...form.value }
|
||||
if (!data.endoflife) data.endoflife = null
|
||||
if (editing.value) {
|
||||
await operatingsystemsApi.update(editing.value.osid, data)
|
||||
} else {
|
||||
await operatingsystemsApi.create(data)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
|
||||
|
||||
async function deleteItem() {
|
||||
try {
|
||||
await operatingsystemsApi.delete(toDelete.value.osid)
|
||||
showDeleteModal.value = false
|
||||
toDelete.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.text-danger {
|
||||
color: var(--danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
30
frontend/src/views/settings/PCTypeMappingSettings.vue
Normal file
30
frontend/src/views/settings/PCTypeMappingSettings.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Collector PC Types (retired)</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
This page has been retired. Imaging pc-type handling now lives in
|
||||
GE-Enforce, where each imaging PC type is a manifest scope with its own
|
||||
Computer Type. Manage it from the GE-Enforce section instead.
|
||||
</p>
|
||||
<p class="setting-description">
|
||||
The collector still applies the built-in pc-type to Computer Type
|
||||
defaults, so existing enrollment keeps working; there is nothing to
|
||||
configure here anymore.
|
||||
</p>
|
||||
<RouterLink class="btn" to="/geenforce/manifests">Open GE-Enforce</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Deprecated page. Kept only so the /settings/pctypemapping route and old
|
||||
// bookmarks still resolve; GE-Enforce (scope computertypeid) supersedes the old
|
||||
// collector pc-type mapping UI. See ADR-012.
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
157
frontend/src/views/settings/PCTypesList.vue
Normal file
157
frontend/src/views/settings/PCTypesList.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>PC Types</h2>
|
||||
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PC Type</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pt in visiblePcTypes" :key="pt.computertypeid">
|
||||
<td>{{ pt.computertype }}</td>
|
||||
<td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(pt.color)">{{ pt.color || 'auto' }}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<span v-if="pt.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteType(pt)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visiblePcTypes.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No PC types found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit PC Type' : 'Add PC Type' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="computertype">PC Type *</label>
|
||||
<input id="computertype" v-model="form.computertype" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
|
||||
<ColorSwatchPicker v-model="form.color" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computersApi } from '../../api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const pcTypes = ref([])
|
||||
const showInactive = ref(false)
|
||||
const visiblePcTypes = computed(() => showInactive.value ? pcTypes.value : pcTypes.value.filter(x => x.isactive !== false))
|
||||
const loading = ref(true)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({ computertype: '', description: '', color: '', isactive: true })
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await computersApi.types.list({ perpage: 200, active: false })
|
||||
pcTypes.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading PC types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? { computertype: item.computertype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
|
||||
: { computertype: '', description: '', color: '', isactive: true }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function deleteType(pt) {
|
||||
if (!confirm(`Delete PC type "${pt.computertype}"?`)) return
|
||||
try {
|
||||
await computersApi.types.remove(pt.computertypeid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await computersApi.types.update(editing.value.computertypeid, form.value)
|
||||
} else {
|
||||
await computersApi.types.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
140
frontend/src/views/settings/PluginsList.vue
Normal file
140
frontend/src/views/settings/PluginsList.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Plugins</h2>
|
||||
<span v-if="contractVersion" class="contract-badge">contract v{{ contractVersion }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<p class="hint">
|
||||
Disabling a plugin removes its pages and API on the next app restart.
|
||||
</p>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plugin</th>
|
||||
<th>Version</th>
|
||||
<th>Description</th>
|
||||
<th>Enabled</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in plugins" :key="p.name">
|
||||
<td>{{ p.displayname || p.name }}</td>
|
||||
<td class="mono">{{ p.version }}</td>
|
||||
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
|
||||
<td>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: p.enabled }"
|
||||
:disabled="saving"
|
||||
@click="toggle(p)"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="message" class="success-message">{{ message }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { pluginsApi } from '../../api'
|
||||
|
||||
const plugins = ref([])
|
||||
const contractVersion = ref('')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const message = ref('')
|
||||
|
||||
onMounted(() => load())
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await pluginsApi.list()
|
||||
plugins.value = response.data.data.plugins || []
|
||||
contractVersion.value = response.data.data.contract_version || ''
|
||||
} catch (err) {
|
||||
error.value = 'Failed to load plugins'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(p) {
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await pluginsApi.setEnabled(p.name, !p.enabled)
|
||||
p.enabled = !p.enabled
|
||||
message.value = response.data.message || 'Updated'
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.data?.error?.message || 'Failed to update plugin'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-light);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.contract-badge {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
background: var(--bg);
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: var(--border);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn.active .toggle-slider {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
</style>
|
||||
186
frontend/src/views/settings/PrinterDriversList.vue
Normal file
186
frontend/src/views/settings/PrinterDriversList.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Printer Drivers</h2>
|
||||
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Driver</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="hint">
|
||||
Each driver is a name + a link to the driver package - an SMB path
|
||||
(<code>\\server\share\driver</code>) or an HTTP URL. HTTP links open;
|
||||
SMB paths are shown for copy-paste (browsers block file:// SMB).
|
||||
</p>
|
||||
<div v-if="loading" class="muted">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Printer Model</th>
|
||||
<th>Location</th>
|
||||
<th>Description</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in visibleItems" :key="d.driverid">
|
||||
<td><strong>{{ d.name }}</strong></td>
|
||||
<td>{{ d.modelname || '-' }}</td>
|
||||
<td>
|
||||
<a v-if="isHttp(d.location)" :href="d.location" target="_blank" class="mono">{{ d.location }}</a>
|
||||
<span v-else class="mono">{{ d.location }}</span>
|
||||
</td>
|
||||
<td class="cell-truncate" :title="d.description">{{ d.description || '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="d.isactive ? 'badge-success' : 'badge-secondary'">{{ d.isactive ? 'yes' : 'no' }}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(d)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteDriver(d)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleItems.length === 0">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-light);">No drivers</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Driver</h3></div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input v-model="form.name" type="text" class="form-control" maxlength="150" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Printer Model <span class="hint">(links driver to a model so it shows on matching printers)</span></label>
|
||||
<select v-model="form.modelnumberid" class="form-control">
|
||||
<option :value="null">-- none --</option>
|
||||
<option v-for="m in models" :key="m.modelnumberid" :value="m.modelnumberid">
|
||||
{{ m.modelnumber }}<template v-if="m.vendorname"> ({{ m.vendorname }})</template>
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Location * <span class="hint">(SMB path or HTTP URL)</span></label>
|
||||
<input v-model="form.location" type="text" class="form-control" maxlength="500"
|
||||
placeholder="\\server\share\driver or https://..." required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { printersApi } from '../../api'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const models = ref([])
|
||||
const showInactive = ref(false)
|
||||
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({ name: '', location: '', description: '', modelnumberid: null, isactive: true })
|
||||
|
||||
function isHttp(loc) {
|
||||
return typeof loc === 'string' && /^https?:\/\//i.test(loc)
|
||||
}
|
||||
|
||||
onMounted(() => { loadData(); loadModels() })
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await printersApi.drivers.list({ active: false })
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading drivers:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
const response = await printersApi.modelSupplies.listModels({ perpage: 100 })
|
||||
models.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading models:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? { name: item.name || '', location: item.location || '', description: item.description || '', modelnumberid: item.modelnumberid || null, isactive: item.isactive !== false }
|
||||
: { name: '', location: '', description: '', modelnumberid: null, isactive: true }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await printersApi.drivers.update(editing.value.driverid, form.value)
|
||||
} else {
|
||||
await printersApi.drivers.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDriver(d) {
|
||||
if (!confirm(`Delete driver "${d.name}"?`)) return
|
||||
try {
|
||||
await printersApi.drivers.delete(d.driverid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint { color: var(--text-light); font-size: 0.9rem; margin: 0 0 14px; }
|
||||
.muted { color: var(--text-light); }
|
||||
.mono { font-family: monospace; font-size: 0.85rem; word-break: break-all; }
|
||||
</style>
|
||||
145
frontend/src/views/settings/PrinterTypesList.vue
Normal file
145
frontend/src/views/settings/PrinterTypesList.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Printer Types</h2>
|
||||
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Printer Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Printer Type</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in visibleItems" :key="t.printertypeid">
|
||||
<td>{{ t.printertype }}</td>
|
||||
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
|
||||
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
|
||||
<td class="actions">
|
||||
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleItems.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">No printer types found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Printer Type</h3></div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Printer Type *</label>
|
||||
<input v-model="form.printertype" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
|
||||
<ColorSwatchPicker v-model="form.color" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { printersApi } from '../../api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const showInactive = ref(false)
|
||||
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({ printertype: '', description: '', color: '', isactive: true })
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await printersApi.types.list({ perpage: 200, active: false })
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading printer types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? { printertype: item.printertype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
|
||||
: { printertype: '', description: '', color: '', isactive: true }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await printersApi.types.update(editing.value.printertypeid, form.value)
|
||||
} else {
|
||||
await printersApi.types.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteType(t) {
|
||||
if (!confirm(`Delete printer type "${t.printertype}"?`)) return
|
||||
try {
|
||||
await printersApi.types.remove(t.printertypeid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
192
frontend/src/views/settings/PrintingSettings.vue
Normal file
192
frontend/src/views/settings/PrintingSettings.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Printing & Labels</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Where printed QR codes point. Leave a target blank to link to the
|
||||
asset's own page on this instance, or enter a custom URL template
|
||||
with {placeholder} substitution.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Printer QR target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_printer"
|
||||
placeholder="(blank = printer page)"
|
||||
@blur="saveSetting('qr_target_printer', settings.qr_target_printer)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {printerid}, {assetid}, {assetnumber}, {serialnumber}, {ip}, {hostname}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>USB label QR target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_usb"
|
||||
placeholder="(blank = USB device page)"
|
||||
@blur="saveSetting('qr_target_usb', settings.qr_target_usb)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {id}, {serialnumber}, {alias}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Machine label target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_machine"
|
||||
placeholder="(blank = machine page)"
|
||||
@blur="saveSetting('qr_target_machine', settings.qr_target_machine)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Computer label target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_computer"
|
||||
placeholder="(blank = computer page)"
|
||||
@blur="saveSetting('qr_target_computer', settings.qr_target_computer)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Network device label target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_network_device"
|
||||
placeholder="(blank = network device page)"
|
||||
@blur="saveSetting('qr_target_network_device', settings.qr_target_network_device)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Measuring tool label target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_measuring_tool"
|
||||
placeholder="(blank = measuring tool page)"
|
||||
@blur="saveSetting('qr_target_measuring_tool', settings.qr_target_measuring_tool)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}, {locationcode}, {locationname}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Default label style</span>
|
||||
<select
|
||||
v-model="settings.label_default_style"
|
||||
@change="saveSetting('label_default_style', settings.label_default_style)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option value="card">Card (badge with image and identity)</option>
|
||||
<option value="plain">Plain (just the code and a caption)</option>
|
||||
</select>
|
||||
<small class="input-hint">House style used when an asset label first opens.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Default label code type</span>
|
||||
<select
|
||||
v-model="settings.label_default_codetype"
|
||||
@change="saveSetting('label_default_codetype', settings.label_default_codetype)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option value="qr">QR code</option>
|
||||
<option value="barcode">Barcode (CODE128)</option>
|
||||
</select>
|
||||
<small class="input-hint">Code type used when an asset label first opens.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
What each asset type's label encodes by default (still overridable on
|
||||
the label page itself).
|
||||
</p>
|
||||
|
||||
<div class="setting-row" v-for="row in encodesRows" :key="row.key">
|
||||
<label>
|
||||
<span>{{ row.label }} label content</span>
|
||||
<select
|
||||
v-model="settings[row.key]"
|
||||
@change="saveSetting(row.key, settings[row.key])"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option value="assetpage">Link to the asset page</option>
|
||||
<option value="assetnumber">Asset / machine number</option>
|
||||
<option value="serialnumber">Serial number</option>
|
||||
<option v-if="row.hasLocation" value="location">Inspection location code</option>
|
||||
<option value="custom">Custom target template</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>USB label style</span>
|
||||
<select
|
||||
v-model="settings.usb_label_style"
|
||||
@change="saveSetting('usb_label_style', settings.usb_label_style)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option value="barcode">Barcode (CODE128 of the serial number)</option>
|
||||
<option value="qr">QR code (links to the USB label QR target)</option>
|
||||
</select>
|
||||
<small class="input-hint">Applies to the batch USB mini-label print sheet.</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
// Per-asset-type "what does the code encode" defaults.
|
||||
const encodesRows = [
|
||||
{ key: 'label_default_encodes_machine', label: 'Machine' },
|
||||
{ key: 'label_default_encodes_computer', label: 'Computer' },
|
||||
{ key: 'label_default_encodes_printer', label: 'Printer' },
|
||||
{ key: 'label_default_encodes_network_device', label: 'Network device' },
|
||||
{ key: 'label_default_encodes_measuring_tool', label: 'Measuring tool', hasLocation: true },
|
||||
]
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
155
frontend/src/views/settings/RelationshipTypesList.vue
Normal file
155
frontend/src/views/settings/RelationshipTypesList.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Relationship Types</h2>
|
||||
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Relationship Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Relationship Type</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Directional</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in visibleItems" :key="t.relationshiptypeid">
|
||||
<td><span class="badge" :style="colorStyle(t.color)">{{ t.relationshiptype }}</span></td>
|
||||
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
|
||||
<td><span class="mono">{{ t.color || 'auto' }}</span></td>
|
||||
<td>{{ t.isdirectional === false ? 'connection' : 'yes' }}</td>
|
||||
<td class="actions">
|
||||
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleItems.length === 0">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-light);">No relationship types found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Relationship Type</h3></div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Relationship Type *</label>
|
||||
<input v-model="form.relationshiptype" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color <span class="hint">(relationship badges; blank = auto)</span></label>
|
||||
<ColorSwatchPicker v-model="form.color" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.isdirectional" /> Directional</label>
|
||||
<span class="hint">off = shown as a plain connection, no direction</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { relationshipTypesApi } from '../../api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const showInactive = ref(false)
|
||||
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({ relationshiptype: '', description: '', color: '', isactive: true, isdirectional: true })
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await relationshipTypesApi.list()
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading relationship types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? { relationshiptype: item.relationshiptype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false, isdirectional: item.isdirectional !== false }
|
||||
: { relationshiptype: '', description: '', color: '', isactive: true, isdirectional: true }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await relationshipTypesApi.update(editing.value.relationshiptypeid, form.value)
|
||||
} else {
|
||||
await relationshipTypesApi.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteType(t) {
|
||||
if (!confirm(`Delete relationship type "${t.relationshiptype}"?`)) return
|
||||
try {
|
||||
await relationshipTypesApi.remove(t.relationshiptypeid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono { font-family: monospace; font-size: 0.85rem; }
|
||||
</style>
|
||||
105
frontend/src/views/settings/ServiceNowSettings.vue
Normal file
105
frontend/src/views/settings/ServiceNowSettings.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>ServiceNow</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Wire global search and ticket links to your ServiceNow instance. When
|
||||
enabled, matching ticket numbers become clickable links and can trigger
|
||||
a smart-redirect from global search. Defaults ship for GE; change them
|
||||
for your site.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Enable ServiceNow</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.servicenow_enabled }"
|
||||
@click="toggleSetting('servicenow_enabled')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="settings.servicenow_enabled">
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Search URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.servicenow_search_url"
|
||||
placeholder="https://geaerospaceqa.service-now.com/now/nav/ui/search/.../{ticket}/..."
|
||||
@blur="saveSetting('servicenow_search_url', settings.servicenow_search_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Global-search redirect target. Use {ticket} where the ticket number goes.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Ticket Prefixes</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.servicenow_ticket_prefixes"
|
||||
placeholder="GEINC,GECHG,GERIT,GESCT"
|
||||
@blur="saveSetting('servicenow_ticket_prefixes', settings.servicenow_ticket_prefixes)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Comma-separated ticket-number prefixes that this site recognizes.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Incident URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.servicenow_incident_url"
|
||||
placeholder="(blank = plain text; use {ticket} in a URL template)"
|
||||
@blur="saveSetting('servicenow_incident_url', settings.servicenow_incident_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Link template for incident tickets. Use {ticket} where the ticket number goes.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Change URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.servicenow_change_url"
|
||||
placeholder="(blank = plain text; use {ticket} in a URL template)"
|
||||
@blur="saveSetting('servicenow_change_url', settings.servicenow_change_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Link template for change tickets. Use {ticket} where the ticket number goes.</small>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting, toggleSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
100
frontend/src/views/settings/SettingsIndex.vue
Normal file
100
frontend/src/views/settings/SettingsIndex.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="settings-landing">
|
||||
<p class="landing-intro">
|
||||
Pick a section from the left, or choose one below.
|
||||
</p>
|
||||
|
||||
<section v-for="group in visibleGroups" :key="group.title" class="landing-section">
|
||||
<h2 class="section-heading">{{ group.title }}</h2>
|
||||
<div class="landing-grid">
|
||||
<router-link
|
||||
v-for="card in group.cards"
|
||||
:key="card.to"
|
||||
:to="card.to"
|
||||
class="landing-card"
|
||||
>
|
||||
<div class="card-head">
|
||||
<span class="card-icon"><component :is="card.icon" :size="18" /></span>
|
||||
<h3>{{ card.title }}</h3>
|
||||
</div>
|
||||
<p>{{ card.description }}</p>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useSettingsCatalog } from '../../composables/settingsCatalog'
|
||||
|
||||
// Core settings groups merged with plugin-contributed cards (ADR-010).
|
||||
const { groups } = useSettingsCatalog()
|
||||
|
||||
// Drop empty groups (e.g. the Measuring Tools placeholder when its plugin is
|
||||
// disabled) so a bare section heading never renders.
|
||||
const visibleGroups = computed(() => groups.value.filter(g => g.cards.length))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.landing-intro {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.landing-section {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.section-heading {
|
||||
margin: 0 0 0.6rem 0;
|
||||
padding-bottom: 0.35rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.landing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.landing-card {
|
||||
display: block;
|
||||
padding: 0.8rem 0.9rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
}
|
||||
.landing-card:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.card-icon {
|
||||
display: inline-flex;
|
||||
color: var(--primary);
|
||||
}
|
||||
.landing-card h3 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.landing-card p {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
</style>
|
||||
221
frontend/src/views/settings/SettingsLayout.vue
Normal file
221
frontend/src/views/settings/SettingsLayout.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="settings-shell">
|
||||
<!-- Left rail: grouped, searchable nav that stays put while the right pane swaps -->
|
||||
<aside class="settings-rail">
|
||||
<h1 class="rail-title">Settings</h1>
|
||||
<div class="rail-search">
|
||||
<input v-model="search" type="text" class="search-input"
|
||||
placeholder="Search settings..." />
|
||||
</div>
|
||||
|
||||
<nav class="rail-nav">
|
||||
<template v-for="group in visibleGroups" :key="group.title">
|
||||
<button
|
||||
type="button"
|
||||
class="rail-group-heading"
|
||||
:class="{ collapsed: !isExpanded(group.title) }"
|
||||
@click="toggleGroup(group.title)"
|
||||
>
|
||||
<span class="rail-chevron" aria-hidden="true"></span>
|
||||
{{ group.title }}
|
||||
</button>
|
||||
<template v-if="isExpanded(group.title)">
|
||||
<router-link
|
||||
v-for="card in group.cards"
|
||||
:key="card.to"
|
||||
:to="card.to"
|
||||
class="rail-link"
|
||||
:class="{ active: isActive(card.to) }"
|
||||
>
|
||||
<span class="rail-icon"><component :is="card.icon" :size="16" /></span>
|
||||
<span class="rail-label">{{ card.title }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
</template>
|
||||
<p v-if="!visibleGroups.length" class="rail-empty">No matching settings</p>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Right pane: the selected settings page renders here -->
|
||||
<section class="settings-content">
|
||||
<router-view />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useSettingsCatalog } from '../../composables/settingsCatalog'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// Core settings groups merged with plugin-contributed cards (ADR-010).
|
||||
const { groups } = useSettingsCatalog()
|
||||
|
||||
const search = ref('')
|
||||
|
||||
// Filter the rail by title/description; drop groups that end up empty.
|
||||
const visibleGroups = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
// Drop empty groups (e.g. the Measuring Tools placeholder when its plugin is
|
||||
// disabled) so a bare heading never renders.
|
||||
if (!term) return groups.value.filter(g => g.cards.length)
|
||||
return groups.value
|
||||
.map(g => ({
|
||||
title: g.title,
|
||||
cards: g.cards.filter(c =>
|
||||
c.title.toLowerCase().includes(term) ||
|
||||
c.description.toLowerCase().includes(term)),
|
||||
}))
|
||||
.filter(g => g.cards.length)
|
||||
})
|
||||
|
||||
// A rail link is active when the current path matches its base path.
|
||||
// Every settings section is now its own page, so a plain path compare suffices.
|
||||
function isActive(to) {
|
||||
const base = to.split('?')[0]
|
||||
return route.path === base
|
||||
}
|
||||
|
||||
// Collapsible groups so the 13-section rail fits without scrolling: only the
|
||||
// group containing the current page is open; the user can toggle others.
|
||||
// Searching forces every matching group open.
|
||||
const groupOfActive = computed(() => {
|
||||
for (const g of groups.value) {
|
||||
if (g.cards.some(c => isActive(c.to))) return g.title
|
||||
}
|
||||
return groups.value[0]?.title
|
||||
})
|
||||
const manualToggles = ref({})
|
||||
function toggleGroup(title) {
|
||||
manualToggles.value = { ...manualToggles.value, [title]: !isExpanded(title) }
|
||||
}
|
||||
function isExpanded(title) {
|
||||
if (search.value.trim()) return true
|
||||
if (title in manualToggles.value) return manualToggles.value[title]
|
||||
return title === groupOfActive.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-shell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-rail {
|
||||
flex: 0 0 250px;
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.rail-title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.rail-search {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.rail-group-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
margin: 0.35rem 0 0.15rem 0;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-light);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.rail-group-heading:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.rail-group-heading:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* CSS-drawn caret (ASCII-only source): a right-pointing triangle that
|
||||
rotates down when the group is expanded. */
|
||||
.rail-chevron {
|
||||
width: 0;
|
||||
height: 0;
|
||||
flex: 0 0 auto;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 5px solid currentColor;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.rail-group-heading:not(.collapsed) .rail-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.rail-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-size: 0.86rem;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.rail-link:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
.rail-link.active {
|
||||
background: var(--bg);
|
||||
border-left-color: var(--primary);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.rail-icon {
|
||||
display: inline-flex;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.rail-link.active .rail-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
.rail-label {
|
||||
line-height: 1.2;
|
||||
}
|
||||
.rail-empty {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.settings-shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
.settings-rail {
|
||||
position: static;
|
||||
flex-basis: auto;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
209
frontend/src/views/settings/SiteSettings.vue
Normal file
209
frontend/src/views/settings/SiteSettings.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<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',
|
||||
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']
|
||||
}
|
||||
|
||||
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: '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',
|
||||
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) {
|
||||
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>
|
||||
209
frontend/src/views/settings/SlideManager.vue
Normal file
209
frontend/src/views/settings/SlideManager.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Slides</h1>
|
||||
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="s in surfaces"
|
||||
:key="s.key"
|
||||
class="tab"
|
||||
:class="{ active: surface === s.key }"
|
||||
@click="switchSurface(s.key)"
|
||||
>{{ s.label }}</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<label class="btn btn-primary upload-btn">
|
||||
{{ uploading ? 'Uploading...' : 'Upload Images' }}
|
||||
<input type="file" accept="image/*" multiple hidden :disabled="uploading" @change="onUpload" />
|
||||
</label>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
:disabled="!selected.length"
|
||||
@click="deleteSelected"
|
||||
>Delete Selected ({{ selected.length }})</button>
|
||||
<span class="hint">Order top-to-bottom is play order. Images show on the {{ surfaceLabel }}.</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="muted">Loading...</div>
|
||||
<div v-else-if="!slides.length" class="muted empty">No slides yet. Upload some images.</div>
|
||||
|
||||
<div v-else class="slide-grid">
|
||||
<div v-for="(slide, idx) in slides" :key="slide.slideid" class="slide-tile">
|
||||
<label class="pick">
|
||||
<input type="checkbox" :value="slide.filename" v-model="selected" />
|
||||
</label>
|
||||
<img :src="slide.url" :alt="slide.filename" class="thumb" />
|
||||
<div class="slide-meta">
|
||||
<span class="fname" :title="slide.filename">{{ slide.filename }}</span>
|
||||
<div class="move">
|
||||
<button class="btn btn-sm btn-secondary" :disabled="idx === 0" @click="move(idx, -1)" title="Move up">↑</button>
|
||||
<button class="btn btn-sm btn-secondary" :disabled="idx === slides.length - 1" @click="move(idx, 1)" title="Move down">↓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { slidesApi } from '@/api'
|
||||
|
||||
const surfaces = [
|
||||
{ key: 'lobby', label: 'Lobby Display' },
|
||||
{ key: 'shopfloor', label: 'Shopfloor Screensaver' }
|
||||
]
|
||||
const surface = ref('lobby')
|
||||
const slides = ref([])
|
||||
const selected = ref([])
|
||||
const loading = ref(true)
|
||||
const uploading = ref(false)
|
||||
|
||||
const surfaceLabel = computed(() => surfaces.find(s => s.key === surface.value)?.label || surface.value)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
selected.value = []
|
||||
try {
|
||||
const response = await slidesApi.list(surface.value)
|
||||
slides.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading slides:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function switchSurface(key) {
|
||||
if (key === surface.value) return
|
||||
surface.value = key
|
||||
load()
|
||||
}
|
||||
|
||||
async function onUpload(event) {
|
||||
const files = Array.from(event.target.files || [])
|
||||
if (!files.length) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
files.forEach(f => formData.append('files', f))
|
||||
await slidesApi.upload(surface.value, formData)
|
||||
await load()
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function move(idx, delta) {
|
||||
const target = idx + delta
|
||||
if (target < 0 || target >= slides.value.length) return
|
||||
const arr = slides.value.slice()
|
||||
const [item] = arr.splice(idx, 1)
|
||||
arr.splice(target, 0, item)
|
||||
slides.value = arr
|
||||
try {
|
||||
await slidesApi.reorder(surface.value, arr.map(s => s.filename))
|
||||
} catch (err) {
|
||||
console.error('Reorder failed:', err)
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
if (!selected.value.length) return
|
||||
if (!confirm(`Delete ${selected.value.length} slide(s)?`)) return
|
||||
try {
|
||||
await slidesApi.remove(surface.value, selected.value)
|
||||
await load()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tab {
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-light);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid transparent;
|
||||
}
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.upload-btn { position: relative; cursor: pointer; }
|
||||
.hint { color: var(--text-light); font-size: 0.85rem; }
|
||||
.muted { color: var(--text-light); }
|
||||
.empty { padding: 30px 0; text-align: center; }
|
||||
|
||||
.slide-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.slide-tile {
|
||||
position: relative;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.slide-tile .pick {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.thumb {
|
||||
width: 100%;
|
||||
height: 130px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
.slide-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.fname {
|
||||
font-size: 0.78rem;
|
||||
font-family: monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.move { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
</style>
|
||||
319
frontend/src/views/settings/StatusesList.vue
Normal file
319
frontend/src/views/settings/StatusesList.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Asset Statuses</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Status</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Color</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in statuses" :key="s.statusid">
|
||||
<td>
|
||||
<span class="status-badge" :style="getStatusStyle(s.color)">
|
||||
{{ s.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="color-preview" :style="{ backgroundColor: s.color || '#6c757d' }"></span>
|
||||
{{ s.color || 'default' }}
|
||||
</td>
|
||||
<td class="cell-truncate" :title="s.description">{{ s.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="openModal(s)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger btn-sm"
|
||||
@click="confirmDelete(s)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="statuses.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No statuses found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingStatus ? 'Edit Status' : 'Add Status' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveStatus">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="status">Status Name *</label>
|
||||
<input
|
||||
id="status"
|
||||
v-model="form.status"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Color</label>
|
||||
<ColorSwatchPicker v-model="form.color" />
|
||||
<small class="form-hint">Used in UI to visually distinguish statuses</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Delete Status</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ statusToDelete?.status }}</strong>?</p>
|
||||
<p style="color: var(--text-light); font-size: 0.875rem;">
|
||||
Cannot delete if assets are using this status.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteStatus">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { assetsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const statuses = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, setPage } = useListQuery({ onChange: loadStatuses })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingStatus = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const statusToDelete = ref(null)
|
||||
|
||||
const form = ref({
|
||||
status: '',
|
||||
color: '#6c757d',
|
||||
description: ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadStatuses()
|
||||
})
|
||||
|
||||
async function loadStatuses() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
|
||||
const response = await assetsApi.statuses.list(params)
|
||||
statuses.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading statuses:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadStatuses()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadStatuses()
|
||||
}
|
||||
|
||||
function openModal(s = null) {
|
||||
editingStatus.value = s
|
||||
if (s) {
|
||||
form.value = {
|
||||
status: s.status || '',
|
||||
color: s.color || '#6c757d',
|
||||
description: s.description || ''
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
status: '',
|
||||
color: '#6c757d',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingStatus.value = null
|
||||
}
|
||||
|
||||
async function saveStatus() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
if (editingStatus.value) {
|
||||
await assetsApi.statuses.update(editingStatus.value.statusid, form.value)
|
||||
} else {
|
||||
await assetsApi.statuses.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadStatuses()
|
||||
} catch (err) {
|
||||
console.error('Error saving status:', err)
|
||||
error.value = apiError(err, 'Failed to save status')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(s) {
|
||||
statusToDelete.value = s
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteStatus() {
|
||||
try {
|
||||
await assetsApi.statuses.delete(statusToDelete.value.statusid)
|
||||
showDeleteModal.value = false
|
||||
statusToDelete.value = null
|
||||
loadStatuses()
|
||||
} catch (err) {
|
||||
console.error('Error deleting status:', err)
|
||||
error.value = apiError(err, 'Failed to delete status')
|
||||
toast.error(error.value)
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusStyle(color) {
|
||||
const bgColor = color || '#6c757d'
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
color: isLightColor(bgColor) ? '#000' : '#fff'
|
||||
}
|
||||
}
|
||||
|
||||
function isLightColor(color) {
|
||||
if (!color) return false
|
||||
const hex = color.replace('#', '')
|
||||
const r = parseInt(hex.substr(0, 2), 16)
|
||||
const g = parseInt(hex.substr(2, 2), 16)
|
||||
const b = parseInt(hex.substr(4, 2), 16)
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000
|
||||
return brightness > 128
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.color-input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
width: 50px;
|
||||
height: 38px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
567
frontend/src/views/settings/SubnetsList.vue
Normal file
567
frontend/src/views/settings/SubnetsList.vue
Normal file
@@ -0,0 +1,567 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Subnets</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Subnet</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search subnets..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="vlanFilter" class="form-control" @change="loadSubnets">
|
||||
<option value="">All VLANs</option>
|
||||
<option v-for="vlan in vlans" :key="vlan.vlanid" :value="vlan.vlanid">
|
||||
VLAN {{ vlan.vlannumber }} - {{ vlan.name }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="typeFilter" class="form-control" @change="loadSubnets">
|
||||
<option value="">All Types</option>
|
||||
<option value="ipv4">IPv4</option>
|
||||
<option value="ipv6">IPv6</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>CIDR</th>
|
||||
<th>Name</th>
|
||||
<th>VLAN</th>
|
||||
<th>Gateway</th>
|
||||
<th>DHCP</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="subnet in subnets" :key="subnet.subnetid">
|
||||
<td class="mono">{{ subnet.cidr }}</td>
|
||||
<td>{{ subnet.name }}</td>
|
||||
<td>
|
||||
<span v-if="subnet.vlanname">
|
||||
VLAN {{ subnet.vlannumber }} - {{ subnet.vlanname }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="mono">{{ subnet.gatewayip || '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="subnet.dhcpenabled ? 'badge-success' : 'badge-secondary'">
|
||||
{{ subnet.dhcpenabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ subnet.locationname || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="openModal(subnet)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger btn-sm"
|
||||
@click="confirmDelete(subnet)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="subnets.length === 0">
|
||||
<td colspan="7" style="text-align: center; color: var(--text-light);">
|
||||
No subnets found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingSubnet ? 'Edit Subnet' : 'Add Subnet' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveSubnet">
|
||||
<div class="modal-body">
|
||||
<!-- Basic Info -->
|
||||
<div class="form-section">
|
||||
<h4>Basic Information</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="cidr">CIDR *</label>
|
||||
<input
|
||||
id="cidr"
|
||||
v-model="form.cidr"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., 10.1.1.0/24"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Name *</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="vlanid">VLAN</label>
|
||||
<select id="vlanid" v-model="form.vlanid" class="form-control">
|
||||
<option value="">Select VLAN</option>
|
||||
<option v-for="vlan in vlans" :key="vlan.vlanid" :value="vlan.vlanid">
|
||||
VLAN {{ vlan.vlannumber }} - {{ vlan.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="locationid">Location</label>
|
||||
<select id="locationid" v-model="form.locationid" class="form-control">
|
||||
<option value="">Select Location</option>
|
||||
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
|
||||
{{ loc.location }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="form-control"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Details -->
|
||||
<div class="form-section">
|
||||
<h4>Network Details</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="gatewayip">Gateway IP</label>
|
||||
<input
|
||||
id="gatewayip"
|
||||
v-model="form.gatewayip"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., 10.1.1.1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subnetmask">Subnet Mask</label>
|
||||
<input
|
||||
id="subnetmask"
|
||||
v-model="form.subnetmask"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., 255.255.255.0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="dns1">Primary DNS</label>
|
||||
<input
|
||||
id="dns1"
|
||||
v-model="form.dns1"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dns2">Secondary DNS</label>
|
||||
<input
|
||||
id="dns2"
|
||||
v-model="form.dns2"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DHCP Settings -->
|
||||
<div class="form-section">
|
||||
<h4>DHCP Settings</h4>
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.dhcpenabled" />
|
||||
<span>DHCP Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.dhcpenabled" class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="dhcprangestart">DHCP Range Start</label>
|
||||
<input
|
||||
id="dhcprangestart"
|
||||
v-model="form.dhcprangestart"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., 10.1.1.100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dhcprangeend">DHCP Range End</label>
|
||||
<input
|
||||
id="dhcprangeend"
|
||||
v-model="form.dhcprangeend"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., 10.1.1.200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Delete Subnet</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete subnet <strong>{{ subnetToDelete?.cidr }}</strong>?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteSubnet">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { networkApi, locationsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const subnets = ref([])
|
||||
const vlans = ref([])
|
||||
const locations = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadSubnets })
|
||||
const vlanFilter = ref('')
|
||||
const typeFilter = ref('')
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingSubnet = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const subnetToDelete = ref(null)
|
||||
|
||||
const form = ref({
|
||||
cidr: '',
|
||||
name: '',
|
||||
description: '',
|
||||
gatewayip: '',
|
||||
subnetmask: '',
|
||||
vlanid: '',
|
||||
locationid: '',
|
||||
subnettype: '',
|
||||
dhcpenabled: true,
|
||||
dhcprangestart: '',
|
||||
dhcprangeend: '',
|
||||
dns1: '',
|
||||
dns2: ''
|
||||
})
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(async () => {
|
||||
// Check for vlanid query parameter
|
||||
if (route.query.vlanid) {
|
||||
vlanFilter.value = route.query.vlanid
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
loadVLANs(),
|
||||
loadLocations()
|
||||
])
|
||||
await loadSubnets()
|
||||
})
|
||||
|
||||
async function loadVLANs() {
|
||||
try {
|
||||
const response = await networkApi.vlans.list({ perpage: 100 })
|
||||
vlans.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading VLANs:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocations() {
|
||||
try {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
locations.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading locations:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubnets() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
if (vlanFilter.value) params.vlanid = vlanFilter.value
|
||||
if (typeFilter.value) params.type = typeFilter.value
|
||||
|
||||
const response = await networkApi.subnets.list(params)
|
||||
subnets.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading subnets:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadSubnets()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadSubnets()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadSubnets()
|
||||
}
|
||||
|
||||
function openModal(subnet = null) {
|
||||
editingSubnet.value = subnet
|
||||
if (subnet) {
|
||||
form.value = {
|
||||
cidr: subnet.cidr || '',
|
||||
name: subnet.name || '',
|
||||
description: subnet.description || '',
|
||||
gatewayip: subnet.gatewayip || '',
|
||||
subnetmask: subnet.subnetmask || '',
|
||||
vlanid: subnet.vlanid || '',
|
||||
locationid: subnet.locationid || '',
|
||||
subnettype: subnet.subnettype || '',
|
||||
dhcpenabled: subnet.dhcpenabled ?? true,
|
||||
dhcprangestart: subnet.dhcprangestart || '',
|
||||
dhcprangeend: subnet.dhcprangeend || '',
|
||||
dns1: subnet.dns1 || '',
|
||||
dns2: subnet.dns2 || ''
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
cidr: '',
|
||||
name: '',
|
||||
description: '',
|
||||
gatewayip: '',
|
||||
subnetmask: '',
|
||||
vlanid: vlanFilter.value || '',
|
||||
locationid: '',
|
||||
subnettype: '',
|
||||
dhcpenabled: true,
|
||||
dhcprangestart: '',
|
||||
dhcprangeend: '',
|
||||
dns1: '',
|
||||
dns2: ''
|
||||
}
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingSubnet.value = null
|
||||
}
|
||||
|
||||
async function saveSubnet() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
cidr: form.value.cidr,
|
||||
name: form.value.name,
|
||||
description: form.value.description || null,
|
||||
gatewayip: form.value.gatewayip || null,
|
||||
subnetmask: form.value.subnetmask || null,
|
||||
vlanid: form.value.vlanid || null,
|
||||
locationid: form.value.locationid || null,
|
||||
subnettype: form.value.subnettype || null,
|
||||
dhcpenabled: form.value.dhcpenabled,
|
||||
dhcprangestart: form.value.dhcprangestart || null,
|
||||
dhcprangeend: form.value.dhcprangeend || null,
|
||||
dns1: form.value.dns1 || null,
|
||||
dns2: form.value.dns2 || null
|
||||
}
|
||||
|
||||
if (editingSubnet.value) {
|
||||
await networkApi.subnets.update(editingSubnet.value.subnetid, payload)
|
||||
} else {
|
||||
await networkApi.subnets.create(payload)
|
||||
}
|
||||
closeModal()
|
||||
loadSubnets()
|
||||
} catch (err) {
|
||||
console.error('Error saving subnet:', err)
|
||||
error.value = apiError(err, 'Failed to save subnet')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(subnet) {
|
||||
subnetToDelete.value = subnet
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteSubnet() {
|
||||
try {
|
||||
await networkApi.subnets.delete(subnetToDelete.value.subnetid)
|
||||
showDeleteModal.value = false
|
||||
subnetToDelete.value = null
|
||||
loadSubnets()
|
||||
} catch (err) {
|
||||
console.error('Error deleting subnet:', err)
|
||||
toast.error(apiError(err, 'Failed to delete subnet'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.filters select {
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.modal-lg {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-section:last-of-type {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-section h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: var(--secondary);
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
345
frontend/src/views/settings/SupportTeamsList.vue
Normal file
345
frontend/src/views/settings/SupportTeamsList.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<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>Team Name</th>
|
||||
<th>Link</th>
|
||||
<th>Contacts</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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="4" 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>
|
||||
|
||||
<!-- 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">
|
||||
<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, 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 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)
|
||||
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(async () => {
|
||||
contactEmailDomain.value = await getContactEmailDomain()
|
||||
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 openContactsModal(team) {
|
||||
contactsTeamId.value = team.supportteamid
|
||||
showContactsModal.value = true
|
||||
}
|
||||
|
||||
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()
|
||||
} 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()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contacts-modal {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.contacts-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.contacts-table {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
746
frontend/src/views/settings/UsersList.vue
Normal file
746
frontend/src/views/settings/UsersList.vue
Normal file
@@ -0,0 +1,746 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>User Management</h1>
|
||||
<button class="btn-primary" @click="showCreateModal = true">Add User</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Name</th>
|
||||
<th>Roles</th>
|
||||
<th>Status</th>
|
||||
<th>Last Login</th>
|
||||
<th class="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="7" class="loading">Loading...</td>
|
||||
</tr>
|
||||
<tr v-else-if="!users.length">
|
||||
<td colspan="7" class="empty">No users found</td>
|
||||
</tr>
|
||||
<tr v-for="user in users" :key="user.userid">
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ [user.firstname, user.lastname].filter(Boolean).join(' ') || '-' }}</td>
|
||||
<td>
|
||||
<span
|
||||
v-for="role in user.roles"
|
||||
:key="role.roleid"
|
||||
class="badge"
|
||||
:class="role.rolename === 'admin' ? 'badge-primary' : 'badge-secondary'"
|
||||
>
|
||||
{{ role.rolename }}
|
||||
</span>
|
||||
<span v-if="!user.roles.length" class="text-muted">None</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="user.islocked" class="badge badge-danger">Locked</span>
|
||||
<span v-else-if="!user.isactive" class="badge badge-warning">Inactive</span>
|
||||
<span v-else class="badge badge-success">Active</span>
|
||||
</td>
|
||||
<td>{{ formatDate(user.lastlogindate) }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-sm" @click="editUser(user)">Edit</button>
|
||||
<button
|
||||
class="btn-sm btn-danger"
|
||||
@click="confirmDelete(user)"
|
||||
:disabled="user.userid === currentUserId"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles Section -->
|
||||
<div class="page-header" style="margin-top: 2rem">
|
||||
<h2>Roles</h2>
|
||||
<button class="btn-secondary" @click="showRoleModal = true">Add Role</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role Name</th>
|
||||
<th>Description</th>
|
||||
<th>Permissions</th>
|
||||
<th>Users</th>
|
||||
<th class="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="role in roles" :key="role.roleid">
|
||||
<td>
|
||||
<span class="badge" :class="role.rolename === 'admin' ? 'badge-primary' : 'badge-secondary'">
|
||||
{{ role.rolename }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="cell-truncate" :title="role.description">{{ role.description || '-' }}</td>
|
||||
<td>
|
||||
<span v-if="role.isadmin" class="text-muted">All permissions</span>
|
||||
<span v-else-if="role.permissions?.length">{{ role.permissions.length }} permissions</span>
|
||||
<span v-else class="text-muted">None</span>
|
||||
</td>
|
||||
<td>{{ role.usercount }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-sm" @click="editRole(role)">Edit</button>
|
||||
<button
|
||||
class="btn-sm btn-danger"
|
||||
@click="confirmDeleteRole(role)"
|
||||
:disabled="role.rolename === 'admin' || role.usercount > 0"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Modal -->
|
||||
<div v-if="showCreateModal || editingUser" class="modal-overlay" @click.self="closeUserModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingUser ? 'Edit User' : 'Create User' }}</h3>
|
||||
<button class="close-btn" @click="closeUserModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form @submit.prevent="saveUser">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="userForm.username"
|
||||
required
|
||||
:disabled="!!editingUser"
|
||||
placeholder="username"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" v-model="userForm.email" required placeholder="user@example.com">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>First Name</label>
|
||||
<input type="text" v-model="userForm.firstname" placeholder="John">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Last Name</label>
|
||||
<input type="text" v-model="userForm.lastname" placeholder="Doe">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ editingUser ? 'New Password (leave blank to keep current)' : 'Password' }}</label>
|
||||
<input
|
||||
type="password"
|
||||
v-model="userForm.password"
|
||||
:required="!editingUser"
|
||||
placeholder="********"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Roles</label>
|
||||
<div class="checkbox-group">
|
||||
<label v-for="role in roles" :key="role.roleid" class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="role.roleid"
|
||||
v-model="userForm.roles"
|
||||
>
|
||||
{{ role.rolename }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" v-if="editingUser">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="userForm.isactive">
|
||||
Account Active
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" v-if="editingUser && editingUser.islocked">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="userForm.unlock">
|
||||
Unlock Account
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-secondary" @click="closeUserModal">Cancel</button>
|
||||
<button type="submit" class="btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : (editingUser ? 'Update' : 'Create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role Modal -->
|
||||
<div v-if="showRoleModal || editingRole" class="modal-overlay" @click.self="closeRoleModal">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingRole ? 'Edit Role' : 'Create Role' }}</h3>
|
||||
<button class="close-btn" @click="closeRoleModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form @submit.prevent="saveRole">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Role Name</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="roleForm.rolename"
|
||||
required
|
||||
:disabled="!!editingRole"
|
||||
placeholder="rolename"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<input type="text" v-model="roleForm.description" placeholder="Role description">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="!editingRole?.isadmin">
|
||||
<label>Permissions</label>
|
||||
<p class="text-muted" style="font-size: 0.85rem; margin: 0 0 0.5rem 0">
|
||||
Select which actions this role can perform
|
||||
</p>
|
||||
<div class="permissions-grid">
|
||||
<div v-for="(perms, category) in permissionsGrouped" :key="category" class="permission-category">
|
||||
<div class="category-header">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isCategoryFullySelected(category)"
|
||||
:indeterminate="isCategoryPartiallySelected(category)"
|
||||
@change="toggleCategory(category, $event.target.checked)"
|
||||
>
|
||||
<strong>{{ formatCategory(category) }}</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="category-perms">
|
||||
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="p.name"
|
||||
v-model="roleForm.permissions"
|
||||
>
|
||||
{{ p.description }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="form-group">
|
||||
<label>Permissions</label>
|
||||
<p class="text-muted">Admin role has all permissions by default</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-secondary" @click="closeRoleModal">Cancel</button>
|
||||
<button type="submit" class="btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : (editingRole ? 'Update' : 'Create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation -->
|
||||
<div v-if="deletingUser" class="modal-overlay" @click.self="deletingUser = null">
|
||||
<div class="modal modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>Delete User</h3>
|
||||
<button class="close-btn" @click="deletingUser = null">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete user <strong>{{ deletingUser.username }}</strong>?</p>
|
||||
<p class="text-muted">This action cannot be undone.</p>
|
||||
<div class="form-actions">
|
||||
<button class="btn-secondary" @click="deletingUser = null">Cancel</button>
|
||||
<button class="btn-danger" @click="deleteUser" :disabled="saving">
|
||||
{{ saving ? 'Deleting...' : 'Delete' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-toast">{{ error }}</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { usersApi } from '../../api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const currentUserId = authStore.user?.userid
|
||||
|
||||
const users = ref([])
|
||||
const roles = ref([])
|
||||
const permissions = ref([])
|
||||
const permissionsGrouped = ref({})
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Modals
|
||||
const showCreateModal = ref(false)
|
||||
const showRoleModal = ref(false)
|
||||
const editingUser = ref(null)
|
||||
const editingRole = ref(null)
|
||||
const deletingUser = ref(null)
|
||||
|
||||
// Forms
|
||||
const userForm = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
isactive: true,
|
||||
unlock: false
|
||||
})
|
||||
|
||||
const roleForm = reactive({
|
||||
rolename: '',
|
||||
description: '',
|
||||
permissions: []
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [usersRes, rolesRes, permsRes] = await Promise.all([
|
||||
usersApi.list(),
|
||||
usersApi.roles.list(),
|
||||
usersApi.permissions.list()
|
||||
])
|
||||
users.value = usersRes.data.data
|
||||
roles.value = rolesRes.data.data
|
||||
permissions.value = permsRes.data.data.permissions
|
||||
permissionsGrouped.value = permsRes.data.data.grouped
|
||||
} catch (e) {
|
||||
error.value = 'Failed to load users'
|
||||
setTimeout(() => error.value = '', 3000)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatCategory(cat) {
|
||||
return cat.charAt(0).toUpperCase() + cat.slice(1)
|
||||
}
|
||||
|
||||
function isCategoryFullySelected(category) {
|
||||
const catPerms = permissionsGrouped.value[category] || []
|
||||
return catPerms.length > 0 && catPerms.every(p => roleForm.permissions.includes(p.name))
|
||||
}
|
||||
|
||||
function isCategoryPartiallySelected(category) {
|
||||
const catPerms = permissionsGrouped.value[category] || []
|
||||
const selected = catPerms.filter(p => roleForm.permissions.includes(p.name))
|
||||
return selected.length > 0 && selected.length < catPerms.length
|
||||
}
|
||||
|
||||
function toggleCategory(category, checked) {
|
||||
const catPerms = permissionsGrouped.value[category] || []
|
||||
const permNames = catPerms.map(p => p.name)
|
||||
|
||||
if (checked) {
|
||||
// Add all perms from this category
|
||||
for (const name of permNames) {
|
||||
if (!roleForm.permissions.includes(name)) {
|
||||
roleForm.permissions.push(name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove all perms from this category
|
||||
roleForm.permissions = roleForm.permissions.filter(p => !permNames.includes(p))
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return 'Never'
|
||||
return new Date(iso).toLocaleDateString()
|
||||
}
|
||||
|
||||
function editUser(user) {
|
||||
editingUser.value = user
|
||||
userForm.username = user.username
|
||||
userForm.email = user.email
|
||||
userForm.firstname = user.firstname || ''
|
||||
userForm.lastname = user.lastname || ''
|
||||
userForm.password = ''
|
||||
userForm.roles = user.roles.map(r => r.roleid)
|
||||
userForm.isactive = user.isactive
|
||||
userForm.unlock = false
|
||||
}
|
||||
|
||||
function closeUserModal() {
|
||||
showCreateModal.value = false
|
||||
editingUser.value = null
|
||||
Object.assign(userForm, {
|
||||
username: '', email: '', firstname: '', lastname: '',
|
||||
password: '', roles: [], isactive: true, unlock: false
|
||||
})
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = {
|
||||
email: userForm.email,
|
||||
firstname: userForm.firstname || null,
|
||||
lastname: userForm.lastname || null,
|
||||
roles: userForm.roles
|
||||
}
|
||||
|
||||
if (editingUser.value) {
|
||||
data.isactive = userForm.isactive
|
||||
if (userForm.password) data.password = userForm.password
|
||||
if (userForm.unlock) data.unlock = true
|
||||
await usersApi.update(editingUser.value.userid, data)
|
||||
} else {
|
||||
data.username = userForm.username
|
||||
data.password = userForm.password
|
||||
await usersApi.create(data)
|
||||
}
|
||||
|
||||
closeUserModal()
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'Failed to save user')
|
||||
setTimeout(() => error.value = '', 3000)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(user) {
|
||||
deletingUser.value = user
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
saving.value = true
|
||||
try {
|
||||
await usersApi.delete(deletingUser.value.userid)
|
||||
deletingUser.value = null
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'Failed to delete user')
|
||||
setTimeout(() => error.value = '', 3000)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Roles
|
||||
function editRole(role) {
|
||||
editingRole.value = role
|
||||
roleForm.rolename = role.rolename
|
||||
roleForm.description = role.description || ''
|
||||
roleForm.permissions = role.permissions ? [...role.permissions] : []
|
||||
}
|
||||
|
||||
function closeRoleModal() {
|
||||
showRoleModal.value = false
|
||||
editingRole.value = null
|
||||
roleForm.rolename = ''
|
||||
roleForm.description = ''
|
||||
roleForm.permissions = []
|
||||
}
|
||||
|
||||
async function saveRole() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = {
|
||||
description: roleForm.description,
|
||||
permissions: roleForm.permissions
|
||||
}
|
||||
|
||||
if (editingRole.value) {
|
||||
await usersApi.roles.update(editingRole.value.roleid, data)
|
||||
} else {
|
||||
data.rolename = roleForm.rolename
|
||||
await usersApi.roles.create(data)
|
||||
}
|
||||
closeRoleModal()
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'Failed to save role')
|
||||
setTimeout(() => error.value = '', 3000)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteRole(role) {
|
||||
if (confirm(`Delete role "${role.rolename}"?`)) {
|
||||
deleteRole(role)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRole(role) {
|
||||
try {
|
||||
await usersApi.roles.delete(role.roleid)
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'Failed to delete role')
|
||||
setTimeout(() => error.value = '', 3000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.badge-primary { background: var(--primary); color: white; }
|
||||
.badge-secondary { background: var(--secondary); color: white; }
|
||||
.badge-success { background: var(--success); color: white; }
|
||||
.badge-warning { background: var(--warning); color: #000; }
|
||||
.badge-danger { background: var(--danger); color: white; }
|
||||
|
||||
.text-muted { color: var(--text-light); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover { background: var(--primary-dark); }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-sm:hover { background: var(--border); }
|
||||
.btn-sm.btn-danger { background: var(--danger); color: white; border-color: var(--danger); }
|
||||
.btn-sm:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions button { margin-right: 0.25rem; }
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
width: 500px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-sm { width: 400px; }
|
||||
.modal-lg { width: 700px; }
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h3 { margin: 0; }
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.error-toast {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
/* Permissions grid */
|
||||
.permissions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.permission-category {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.category-perms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.perm-item {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
377
frontend/src/views/settings/VLANsList.vue
Normal file
377
frontend/src/views/settings/VLANsList.vue
Normal file
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>VLANs</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add VLAN</button>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search VLANs..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="typeFilter" class="form-control" @change="loadVLANs">
|
||||
<option value="">All Types</option>
|
||||
<option value="data">Data</option>
|
||||
<option value="voice">Voice</option>
|
||||
<option value="management">Management</option>
|
||||
<option value="guest">Guest</option>
|
||||
<option value="iot">IoT</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>VLAN #</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th>Subnets</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="vlan in vlans" :key="vlan.vlanid">
|
||||
<td class="mono">{{ vlan.vlannumber }}</td>
|
||||
<td>{{ vlan.name }}</td>
|
||||
<td>
|
||||
<span v-if="vlan.vlantype" class="badge" :class="getTypeClass(vlan.vlantype)">
|
||||
{{ vlan.vlantype }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="cell-truncate" :title="vlan.description">{{ vlan.description || '-' }}</td>
|
||||
<td>
|
||||
<router-link
|
||||
:to="{ path: '/settings/subnets', query: { vlanid: vlan.vlanid } }"
|
||||
class="subnet-link"
|
||||
>
|
||||
View Subnets
|
||||
</router-link>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="openModal(vlan)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger btn-sm"
|
||||
@click="confirmDelete(vlan)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="vlans.length === 0">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-light);">
|
||||
No VLANs found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingVLAN ? 'Edit VLAN' : 'Add VLAN' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveVLAN">
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="vlannumber">VLAN Number *</label>
|
||||
<input
|
||||
id="vlannumber"
|
||||
v-model.number="form.vlannumber"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="1"
|
||||
max="4094"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Name *</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="vlantype">VLAN Type</label>
|
||||
<select id="vlantype" v-model="form.vlantype" class="form-control">
|
||||
<option value="">Select Type</option>
|
||||
<option value="data">Data</option>
|
||||
<option value="voice">Voice</option>
|
||||
<option value="management">Management</option>
|
||||
<option value="guest">Guest</option>
|
||||
<option value="iot">IoT</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Delete VLAN</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete VLAN <strong>{{ vlanToDelete?.vlannumber }} ({{ vlanToDelete?.name }})</strong>?</p>
|
||||
<p style="color: var(--text-light); font-size: 0.875rem;">
|
||||
VLANs with associated subnets cannot be deleted.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteVLAN">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { networkApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const vlans = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVLANs })
|
||||
const typeFilter = ref('')
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingVLAN = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const vlanToDelete = ref(null)
|
||||
|
||||
const form = ref({
|
||||
vlannumber: null,
|
||||
name: '',
|
||||
vlantype: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadVLANs()
|
||||
})
|
||||
|
||||
async function loadVLANs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
if (typeFilter.value) params.type = typeFilter.value
|
||||
|
||||
const response = await networkApi.vlans.list(params)
|
||||
vlans.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading VLANs:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadVLANs()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadVLANs()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadVLANs()
|
||||
}
|
||||
|
||||
function getTypeClass(type) {
|
||||
switch (type) {
|
||||
case 'data': return 'badge-info'
|
||||
case 'voice': return 'badge-success'
|
||||
case 'management': return 'badge-warning'
|
||||
case 'guest': return 'badge-secondary'
|
||||
case 'iot': return 'badge-primary'
|
||||
default: return 'badge-info'
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(vlan = null) {
|
||||
editingVLAN.value = vlan
|
||||
if (vlan) {
|
||||
form.value = {
|
||||
vlannumber: vlan.vlannumber,
|
||||
name: vlan.name || '',
|
||||
vlantype: vlan.vlantype || '',
|
||||
description: vlan.description || ''
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
vlannumber: null,
|
||||
name: '',
|
||||
vlantype: '',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingVLAN.value = null
|
||||
}
|
||||
|
||||
async function saveVLAN() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
if (editingVLAN.value) {
|
||||
await networkApi.vlans.update(editingVLAN.value.vlanid, form.value)
|
||||
} else {
|
||||
await networkApi.vlans.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadVLANs()
|
||||
} catch (err) {
|
||||
console.error('Error saving VLAN:', err)
|
||||
error.value = apiError(err, 'Failed to save VLAN')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(vlan) {
|
||||
vlanToDelete.value = vlan
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteVLAN() {
|
||||
try {
|
||||
await networkApi.vlans.delete(vlanToDelete.value.vlanid)
|
||||
showDeleteModal.value = false
|
||||
vlanToDelete.value = null
|
||||
loadVLANs()
|
||||
} catch (err) {
|
||||
console.error('Error deleting VLAN:', err)
|
||||
toast.error(apiError(err, 'Failed to delete VLAN'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filters select {
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.subnet-link {
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.subnet-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: var(--secondary);
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
90
frontend/src/views/settings/ZabbixSettings.vue
Normal file
90
frontend/src/views/settings/ZabbixSettings.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Zabbix Supplies</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Connect to Zabbix for real-time printer supply monitoring. When enabled, supply levels
|
||||
are fetched from Zabbix API and displayed on printer detail pages.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Enable Zabbix Integration</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.zabbix_enabled }"
|
||||
@click="toggleSetting('zabbix_enabled')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="settings.zabbix_enabled">
|
||||
<label>
|
||||
<span>Zabbix API URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.zabbix_url"
|
||||
placeholder="http://zabbix.example.com:8080"
|
||||
@blur="saveSetting('zabbix_url', settings.zabbix_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="settings.zabbix_enabled">
|
||||
<label>
|
||||
<span>Zabbix API Token</span>
|
||||
<input
|
||||
type="password"
|
||||
v-model="settings.zabbix_token"
|
||||
placeholder="Enter API token"
|
||||
@blur="saveSetting('zabbix_token', settings.zabbix_token)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="status-indicator" v-if="settings.zabbix_enabled">
|
||||
<span class="status-dot" :class="zabbixStatus"></span>
|
||||
<span>{{ zabbixMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting, toggleSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
// Per-page connection status (stays with the page, not the composable).
|
||||
const zabbixStatus = computed(() => {
|
||||
if (!settings.zabbix_enabled) return 'inactive'
|
||||
if (!settings.zabbix_url || !settings.zabbix_token) return 'warning'
|
||||
return 'pending'
|
||||
})
|
||||
|
||||
const zabbixMessage = computed(() => {
|
||||
if (!settings.zabbix_enabled) return 'Disabled'
|
||||
if (!settings.zabbix_url) return 'URL not configured'
|
||||
if (!settings.zabbix_token) return 'Token not configured'
|
||||
return 'Configured (connectivity checked on first use)'
|
||||
})
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
127
frontend/src/views/settings/settingsNav.js
Normal file
127
frontend/src/views/settings/settingsNav.js
Normal file
@@ -0,0 +1,127 @@
|
||||
// Shared settings navigation catalog.
|
||||
// Used by SettingsLayout (left rail) and SettingsIndex (landing overview) so the
|
||||
// grouping lives in one place.
|
||||
// Order: site identity + the reference-data catalogs users touch daily come
|
||||
// first, then the platform/system groups (integrations, communication, search,
|
||||
// plugins, access) cluster together at the end.
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key } from 'lucide-vue-next'
|
||||
|
||||
export const settingsGroups = [
|
||||
{
|
||||
title: 'Site & Facility',
|
||||
cards: [
|
||||
{ to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, PC access domain, employee-ID pattern, printer hostname template' },
|
||||
{ to: '/settings/floormap', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint image and pixel dimensions (width, height) for the site map' },
|
||||
{ to: '/settings/branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and brand primary, accent, sidebar, and theme colors' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'General Reference',
|
||||
cards: [
|
||||
{ to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' },
|
||||
{ to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' },
|
||||
{ to: '/settings/modeltypes', icon: Monitor, title: 'Model Types', description: 'Manage model type categories' },
|
||||
{ to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' },
|
||||
{ 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)' },
|
||||
// Operating Systems is cross-asset (PCs, machines, measuring tools, network
|
||||
// devices all run an OS), so it lives in General Reference, not under PCs.
|
||||
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates (used by PCs, machines, network devices, and more)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'PCs',
|
||||
cards: [
|
||||
{ to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors + map colors' },
|
||||
{ to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Printers',
|
||||
cards: [
|
||||
{ to: '/settings/printertypes', icon: Printer, title: 'Printer Types', description: 'Manage printer subtypes + map colors' },
|
||||
{ to: '/settings/modelsupplies', icon: Droplets, title: 'Model Toners and Supplies', description: 'Map toner, drum, and waste part numbers to printer models' },
|
||||
{ to: '/settings/printerdrivers', icon: Printer, title: 'Printer Drivers', description: 'Named SMB / HTTP links to printer driver packages' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Machines',
|
||||
cards: [
|
||||
{ to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' },
|
||||
],
|
||||
},
|
||||
// The "Measuring Tools" cards are contributed by the measuringtools plugin via
|
||||
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog).
|
||||
// This empty placeholder fixes the group's position among the asset groups
|
||||
// (right after Machines); the merger fills it in place instead of appending a
|
||||
// new group at the end. When the plugin is disabled the group stays empty and
|
||||
// the rail/landing drop it (empty groups never render).
|
||||
{
|
||||
title: 'Measuring Tools',
|
||||
cards: [],
|
||||
},
|
||||
{
|
||||
title: 'Locations & Organization',
|
||||
cards: [
|
||||
{ to: '/settings/employeedirectory', icon: Contact, title: 'Employee Directory', description: 'Manage the self-hosted people directory (add/edit/import); read-only in external HR mode' },
|
||||
{ to: '/settings/locations', icon: MapPin, title: 'Locations', description: 'Manage physical locations and sites' },
|
||||
{ to: '/settings/locationtypes', icon: Tag, title: 'Location Types', description: 'Manage location types + colors' },
|
||||
{ to: '/settings/businessunits', icon: Building, title: 'Business Units', description: 'Manage organizational units' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Network',
|
||||
cards: [
|
||||
{ to: '/settings/networktypes', icon: Router, title: 'Network Device Types', description: 'Manage network device subtypes + map colors' },
|
||||
{ to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' },
|
||||
{ to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Displays & Kiosks',
|
||||
cards: [
|
||||
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default business unit' },
|
||||
{ to: '/settings/notificationtypes', icon: Bell, title: 'Notification Types', description: 'Manage notification types, display styles, colors, and auto-expiry' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
cards: [
|
||||
{ to: '/settings/servicenow', icon: Link, title: 'ServiceNow', description: 'ServiceNow ticket links (incident, change) prefixes and global-search redirect' },
|
||||
{ to: '/settings/zabbix', icon: Droplets, title: 'Zabbix Supplies', description: 'Zabbix API for real-time printer toner and supply monitoring' },
|
||||
{ to: '/settings/dellwarranty', icon: ShieldCheck, title: 'Dell Warranty', description: 'Dell TechDirect warranty API lookup by service tag' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Communication',
|
||||
cards: [
|
||||
{ to: '/settings/email', icon: Mail, title: 'Email / SMTP', description: 'SMTP mail host for email notifications, alerts, TLS, from address, and recipients' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Search & Identity',
|
||||
cards: [
|
||||
{ to: '/settings/globalsearch', icon: Search, title: 'Global Search', description: 'Choose which content types (domains) appear in global search results' },
|
||||
{ to: '/settings/assetidentifiers', icon: Fingerprint, title: 'Asset Identifiers', description: 'Enable optional asset identifiers per type: gauge lab, maintenance, FQDN hostname' },
|
||||
{ to: '/settings/printing', icon: Printer, title: 'Printing & Labels', description: 'Printed QR code and USB label targets, barcode style, and URL templates' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
cards: [
|
||||
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Access & Security',
|
||||
cards: [
|
||||
{ to: '/settings/authentication', icon: KeyRound, title: 'Authentication', description: 'SAML single sign-on (SSO) with your IdP, local login, and auto-create users' },
|
||||
{ to: '/settings/apitokens', icon: Key, title: 'API Tokens', description: 'Personal access tokens for scripts and integrations (e.g. long-running imports) that outlive login sessions' },
|
||||
{ to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' },
|
||||
{ to: '/settings/audit', icon: History, title: 'Audit & Logging', description: 'Audit log retention period and history purge policy' },
|
||||
{ to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' },
|
||||
],
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user