Add custom fields + warranty plugin, rework settings into two-pane shell
Feature work from the 2026-07 session: Settings IA - Replace the flat 27-card settings hub with a persistent two-pane shell (SettingsLayout.vue): grouped, searchable left rail + content pane. - Nest all settings/* routes under the shell via router post-processing; shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers, Equipment, Network) so per-type settings stop scattering. Custom fields (core) - customfields + customfieldvalues tables (migration 7d14), CRUD API at /api/customfields, per-asset value get/save. - Settings management page + reusable CustomFieldsSection (detail) and CustomFieldsInputs (form) wired into all four asset types. Warranty (new plugin) - plugins/warranty: warranties + warrantyassets (migration 7d15), derived coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs). - API CRUD + per-asset panel + report buckets; WarrantyPanel on all four detail pages; Warranties management page; Warranty report + Reports card. - Seed warranty.* permissions. Printer drivers - printerdrivers table (migration 7d13) linked to printer models; drivers now surface on the matching printer's detail page. Other - PCDetail rebalanced (Network + Status + Warranty + custom fields on the right). - Rename PCs list "Features" column to "Remote Access"; fix badge hover underline. - Drop equipment islocationonly field. - Centralize asset-type label/route maps into utils/assetTypes.js. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notification">{{ isRecognition ? 'Recognition Message' : 'Notification' }} *</label>
|
||||
<label for="notification">{{ messageLabel }} *</label>
|
||||
<textarea
|
||||
id="notification"
|
||||
v-model="form.notification"
|
||||
@@ -39,12 +39,12 @@
|
||||
rows="4"
|
||||
required
|
||||
:disabled="isDetail"
|
||||
:placeholder="isRecognition ? 'Enter the recognition message...' : 'Enter the notification message...'"
|
||||
:placeholder="messagePlaceholder"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Employee Search - Only for Recognition -->
|
||||
<div v-if="isRecognition" class="form-group">
|
||||
<!-- Employee Search - for Recognition and Recertification -->
|
||||
<div v-if="isEmployeeType" class="form-group">
|
||||
<label>Employee(s) *</label>
|
||||
<div class="employee-search-container">
|
||||
<input
|
||||
@@ -68,7 +68,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-hint">Search for employees or press Enter to add a custom name</small>
|
||||
<small class="form-hint">Search and pick, or paste multiple SSOs / names separated by commas and press Enter</small>
|
||||
|
||||
<!-- Selected Employees -->
|
||||
<div v-if="selectedEmployees.length" class="selected-employees">
|
||||
@@ -83,7 +83,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isRecognition" class="form-group">
|
||||
<div v-if="!isEmployeeType" class="form-group">
|
||||
<label for="businessunitid">Business Unit</label>
|
||||
<select
|
||||
id="businessunitid"
|
||||
@@ -103,7 +103,7 @@
|
||||
<small class="form-hint">Leave blank to apply to all</small>
|
||||
</div>
|
||||
|
||||
<div v-if="!isRecognition" class="form-group">
|
||||
<div v-if="!isEmployeeType" class="form-group">
|
||||
<label for="appid">Related Application</label>
|
||||
<select
|
||||
id="appid"
|
||||
@@ -123,7 +123,7 @@
|
||||
<small class="form-hint">Link to a specific application (e.g., for software updates)</small>
|
||||
</div>
|
||||
|
||||
<div v-if="!isRecognition" class="form-group">
|
||||
<div v-if="!isEmployeeType" class="form-group">
|
||||
<label for="ticketnumber">Ticket Number</label>
|
||||
<input
|
||||
id="ticketnumber"
|
||||
@@ -151,7 +151,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Time fields - Hidden for Recognition (auto-set) -->
|
||||
<div v-if="!isRecognition" class="form-row">
|
||||
<div v-if="!isEmployeeType" class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="starttime">Start Time *</label>
|
||||
<div class="input-group">
|
||||
@@ -275,11 +275,28 @@ const form = ref({
|
||||
employeesso: ''
|
||||
})
|
||||
|
||||
// Check if selected type is Recognition
|
||||
const isRecognition = computed(() => {
|
||||
function selectedTypeName() {
|
||||
const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid))
|
||||
return selectedType?.typename?.toLowerCase() === 'recognition'
|
||||
})
|
||||
return selectedType?.typename?.toLowerCase() || ''
|
||||
}
|
||||
|
||||
// Recognition and recertification are the employee-photo types: both show the
|
||||
// employee picker, hide the time/BU/app fields, and get a server-computed
|
||||
// display window.
|
||||
const isRecognition = computed(() => selectedTypeName() === 'recognition')
|
||||
const isRecertification = computed(() => selectedTypeName() === 'recertification')
|
||||
const isEmployeeType = computed(() => isRecognition.value || isRecertification.value)
|
||||
|
||||
const messageLabel = computed(() =>
|
||||
isRecertification.value ? 'Recertification Message'
|
||||
: isRecognition.value ? 'Recognition Message'
|
||||
: 'Notification'
|
||||
)
|
||||
const messagePlaceholder = computed(() =>
|
||||
isRecertification.value ? 'Enter the recertification message...'
|
||||
: isRecognition.value ? 'Enter the recognition message...'
|
||||
: 'Enter the notification message...'
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -313,16 +330,18 @@ onMounted(async () => {
|
||||
employeesso: n.employeesso || ''
|
||||
}
|
||||
|
||||
// Parse existing employee data
|
||||
// Parse existing employee data: SSOs are comma-joined and names are
|
||||
// ", "-joined, so pair them up by index (one chip per person). Falling
|
||||
// back to the SSO when a name is missing.
|
||||
if (n.employeesso) {
|
||||
const ssos = n.employeesso.split(',')
|
||||
for (const sso of ssos) {
|
||||
if (sso.trim()) {
|
||||
// Try to look up the employee name
|
||||
const name = n.employeename || sso.trim()
|
||||
selectedEmployees.value.push({ sso: sso.trim(), name })
|
||||
}
|
||||
}
|
||||
const names = (n.employeename || '').split(',')
|
||||
ssos.forEach((rawSso, i) => {
|
||||
const sso = rawSso.trim()
|
||||
if (!sso) return
|
||||
const name = (names[i] || '').trim() || sso
|
||||
selectedEmployees.value.push({ sso, name })
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Set default start date to now
|
||||
@@ -353,13 +372,12 @@ function setNow(field) {
|
||||
}
|
||||
|
||||
function onTypeChange() {
|
||||
// If switching to Recognition, auto-set times
|
||||
if (isRecognition.value) {
|
||||
const now = new Date()
|
||||
form.value.starttime = formatDateForInput(now.toISOString())
|
||||
// End time = now + 30 days
|
||||
const endDate = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
form.value.endtime = formatDateForInput(endDate.toISOString())
|
||||
// Recognition and recertification get a server-computed display window
|
||||
// (recognition clears at 8 AM Eastern; recertification runs two weeks). Set
|
||||
// start to now and leave end blank so the backend applies the per-type rule.
|
||||
if (isEmployeeType.value) {
|
||||
form.value.starttime = formatDateForInput(new Date().toISOString())
|
||||
form.value.endtime = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,15 +419,31 @@ function selectEmployee(emp) {
|
||||
updateEmployeeSso()
|
||||
}
|
||||
|
||||
function addCustomEmployee() {
|
||||
const name = employeeSearch.value.trim()
|
||||
if (!name) return
|
||||
// Add one or many at once. The input may hold a single value or a
|
||||
// comma-separated list of SSOs and/or names. Numeric tokens are treated as
|
||||
// SSOs and resolved to a real name; everything else is a custom name.
|
||||
async function addCustomEmployee() {
|
||||
const raw = employeeSearch.value.trim()
|
||||
if (!raw) return
|
||||
|
||||
// Add as custom name (no SSO)
|
||||
selectedEmployees.value.push({
|
||||
sso: `NAME:${name}`,
|
||||
name: name
|
||||
})
|
||||
const tokens = raw.split(',').map(t => t.trim()).filter(Boolean)
|
||||
for (const token of tokens) {
|
||||
if (/^\d{4,}$/.test(token)) {
|
||||
if (selectedEmployees.value.some(e => e.sso === token)) continue
|
||||
let name = token
|
||||
try {
|
||||
const emp = (await employeesApi.lookup(token)).data.data
|
||||
if (emp) name = `${emp.First_Name} ${emp.Last_Name}`.trim() || token
|
||||
} catch (err) {
|
||||
// SSO not found - keep the number as the label
|
||||
}
|
||||
selectedEmployees.value.push({ sso: token, name })
|
||||
} else {
|
||||
const key = `NAME:${token}`
|
||||
if (selectedEmployees.value.some(e => e.sso === key)) continue
|
||||
selectedEmployees.value.push({ sso: key, name: token })
|
||||
}
|
||||
}
|
||||
|
||||
employeeSearch.value = ''
|
||||
employeeResults.value = []
|
||||
@@ -428,9 +462,9 @@ function updateEmployeeSso() {
|
||||
async function saveNotification() {
|
||||
error.value = ''
|
||||
|
||||
// Validation for Recognition type
|
||||
if (isRecognition.value && selectedEmployees.value.length === 0) {
|
||||
error.value = 'Please select at least one employee for recognition'
|
||||
// Employee-photo types require at least one employee
|
||||
if (isEmployeeType.value && selectedEmployees.value.length === 0) {
|
||||
error.value = `Please select at least one employee for ${isRecertification.value ? 'recertification' : 'recognition'}`
|
||||
return
|
||||
}
|
||||
|
||||
@@ -451,8 +485,8 @@ async function saveNotification() {
|
||||
employeesso: form.value.employeesso || null
|
||||
}
|
||||
|
||||
// For recognition, also send employeename
|
||||
if (isRecognition.value && selectedEmployees.value.length > 0) {
|
||||
// For employee-photo types, also send employeename
|
||||
if (isEmployeeType.value && selectedEmployees.value.length > 0) {
|
||||
data.employeename = selectedEmployees.value.map(e => e.name).join(', ')
|
||||
}
|
||||
|
||||
|
||||
349
frontend/src/views/notifications/NotificationTypesList.vue
Normal file
349
frontend/src/views/notifications/NotificationTypesList.vue
Normal file
@@ -0,0 +1,349 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Notification Types</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 Type</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Color</th>
|
||||
<th>Display style</th>
|
||||
<th>Employee</th>
|
||||
<th>Auto-expiry</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in types" :key="t.notificationtypeid">
|
||||
<td>
|
||||
<strong>{{ t.typename }}</strong>
|
||||
<div v-if="t.typedescription" class="muted">{{ t.typedescription }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="swatch" :style="{ backgroundColor: swatchColor(t.typecolor) }"></span>
|
||||
<span class="mono">{{ t.typecolor }}</span>
|
||||
</td>
|
||||
<td><span class="badge">{{ t.displaystyle || 'standard' }}</span></td>
|
||||
<td>
|
||||
<span v-if="t.splitperemployee" class="badge badge-success">split</span>
|
||||
<span v-if="t.showemployeephoto" class="badge badge-success">photo</span>
|
||||
<span v-if="!t.splitperemployee && !t.showemployeephoto" class="muted">-</span>
|
||||
</td>
|
||||
<td>{{ expiryLabel(t) }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="t.isactive ? 'badge-success' : 'badge-secondary'">
|
||||
{{ t.isactive ? 'yes' : 'no' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-sm btn-secondary" @click="openEdit(t)">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !types.length">
|
||||
<td colspan="7" class="muted" style="text-align:center;">No notification types.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div v-if="editing" class="modal-overlay" @click.self="close">
|
||||
<div class="modal-panel">
|
||||
<h2>{{ form.notificationtypeid ? 'Edit' : 'New' }} Notification Type</h2>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input v-model="form.typename" type="text" maxlength="50" placeholder="e.g. Safety Alert" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Description</span>
|
||||
<input v-model="form.typedescription" type="text" placeholder="Shown to editors" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Color</span>
|
||||
<ColorSwatchPicker v-model="form.typecolor" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Display style</span>
|
||||
<select v-model="form.displaystyle">
|
||||
<option value="standard">Standard rows</option>
|
||||
<option value="carousel">Carousel (rotating photo card)</option>
|
||||
<option value="grid">Grid (cycling row of tiles)</option>
|
||||
<option value="banner">Banner (full-width strip)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field checkbox">
|
||||
<input v-model="form.splitperemployee" type="checkbox" />
|
||||
<span>Split one card per employee</span>
|
||||
</label>
|
||||
|
||||
<label class="field checkbox">
|
||||
<input v-model="form.showemployeephoto" type="checkbox" />
|
||||
<span>Show employee photo + name (HR lookup)</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Auto-expiry</span>
|
||||
<select v-model="form.expirymode">
|
||||
<option value="none">None (stays until end time / indefinite)</option>
|
||||
<option value="duration">Duration (N days after posting)</option>
|
||||
<option value="dailytime">Daily reset (clears at an hour, Eastern)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label v-if="form.expirymode === 'duration'" class="field">
|
||||
<span>Days</span>
|
||||
<input v-model.number="form.expirydays" type="number" min="1" />
|
||||
</label>
|
||||
|
||||
<label v-if="form.expirymode === 'dailytime'" class="field">
|
||||
<span>Hour (0-23, Eastern)</span>
|
||||
<input v-model.number="form.expiryhour" type="number" min="0" max="23" />
|
||||
</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 || !form.typename" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { notificationsApi } from '@/api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
|
||||
const types = ref([])
|
||||
const loading = ref(true)
|
||||
const editing = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({})
|
||||
|
||||
// Keyword typecolors the shopfloor board maps to fixed accent colors; anything
|
||||
// else is a literal hex.
|
||||
const KEYWORD_COLORS = {
|
||||
recognition: '#ffc107',
|
||||
recertification: '#0d6efd',
|
||||
training: '#17a2b8',
|
||||
success: '#04b962',
|
||||
warning: '#ff8800',
|
||||
danger: '#f5365c',
|
||||
info: '#14abef',
|
||||
primary: '#7934f3'
|
||||
}
|
||||
|
||||
function isHex(c) {
|
||||
return typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c)
|
||||
}
|
||||
function swatchColor(c) {
|
||||
if (!c) return '#888'
|
||||
return KEYWORD_COLORS[c] || c
|
||||
}
|
||||
|
||||
function expiryLabel(t) {
|
||||
if (t.expirymode === 'duration' && t.expirydays) return `${t.expirydays} day(s)`
|
||||
if (t.expirymode === 'dailytime') return `daily @ ${String(t.expiryhour ?? 8).padStart(2, '0')}:00 ET`
|
||||
return 'none'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await notificationsApi.types.list()
|
||||
types.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading notification types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
error.value = ''
|
||||
form.value = {
|
||||
typename: '',
|
||||
typedescription: '',
|
||||
typecolor: '#17a2b8',
|
||||
displaystyle: 'standard',
|
||||
splitperemployee: false,
|
||||
showemployeephoto: false,
|
||||
expirymode: 'none',
|
||||
expirydays: null,
|
||||
expiryhour: null,
|
||||
isactive: true
|
||||
}
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function openEdit(t) {
|
||||
error.value = ''
|
||||
form.value = {
|
||||
notificationtypeid: t.notificationtypeid,
|
||||
typename: t.typename || '',
|
||||
typedescription: t.typedescription || '',
|
||||
typecolor: t.typecolor || '#17a2b8',
|
||||
displaystyle: t.displaystyle || 'standard',
|
||||
splitperemployee: !!t.splitperemployee,
|
||||
showemployeephoto: !!t.showemployeephoto,
|
||||
expirymode: t.expirymode || 'none',
|
||||
expirydays: t.expirydays ?? null,
|
||||
expiryhour: t.expiryhour ?? null,
|
||||
isactive: t.isactive !== false
|
||||
}
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
const payload = { ...form.value }
|
||||
try {
|
||||
if (payload.notificationtypeid) {
|
||||
await notificationsApi.types.update(payload.notificationtypeid, payload)
|
||||
} else {
|
||||
await notificationsApi.types.create(payload)
|
||||
}
|
||||
editing.value = false
|
||||
await load()
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Save failed.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.muted {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.swatch.lg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.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: 560px;
|
||||
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"],
|
||||
.field select {
|
||||
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;
|
||||
}
|
||||
.color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.color-row input[type="text"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>Notifications</h1>
|
||||
<router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link>
|
||||
<div class="actions">
|
||||
<router-link to="/settings/notificationtypes" class="btn btn-secondary">Manage Types</router-link>
|
||||
<router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
@@ -45,7 +48,7 @@
|
||||
<tbody>
|
||||
<tr v-for="notification in notifications" :key="notification.notificationid">
|
||||
<td>
|
||||
<router-link :to="`/notifications/${notification.notificationid}`">
|
||||
<router-link :to="`/notifications/${notification.notificationid}/edit`">
|
||||
{{ notification.title }}
|
||||
</router-link>
|
||||
<span v-if="notification.ispinned" class="badge badge-primary" title="Pinned">Pinned</span>
|
||||
|
||||
Reference in New Issue
Block a user