🚧 Add possbility to sort and filter on table headers
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
// Sortable + filterable table header: click the label to cycle
|
||||
// asc -> desc -> default; the funnel opens a popover with free-text search
|
||||
// and (for facet columns) checkboxes over the occurring values.
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ListFilter, X } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
name: string
|
||||
// sorting (optional): current state + toggle
|
||||
sortable?: boolean
|
||||
sortKey?: string | null
|
||||
sortDir?: 'asc' | 'desc'
|
||||
onSort?: (name: string) => void
|
||||
// filtering (optional): 'text', 'facet' or 'text+facet'
|
||||
filterKind?: 'text' | 'facet' | 'text+facet'
|
||||
filterText?: string
|
||||
onFilterText?: (name: string, text: string) => void
|
||||
filterChecked?: Set<string>
|
||||
onFilterCheck?: (name: string, value: string, checked: boolean) => void
|
||||
// occurring values with counts (facets); may be filtered by the text box
|
||||
facetValues?: { value: string; count: number }[]
|
||||
open?: boolean
|
||||
onToggleOpen?: (name: string) => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', name: string): void
|
||||
}>()
|
||||
|
||||
function togglePopover() {
|
||||
props.onToggleOpen?.(props.name)
|
||||
}
|
||||
|
||||
function onCheckbox(value: string, event: Event) {
|
||||
props.onFilterCheck?.(props.name, value, (event.target as HTMLInputElement).checked)
|
||||
}
|
||||
|
||||
// close on outside click
|
||||
function outside(e: MouseEvent) {
|
||||
const pop = document.getElementById(`filter-pop-${props.name}`)
|
||||
const btn = document.getElementById(`filter-btn-${props.name}`)
|
||||
if (pop && !pop.contains(e.target as Node) && btn && !btn.contains(e.target as Node)) {
|
||||
if (props.open) props.onToggleOpen?.(props.name)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', outside))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', outside))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<th style="position: relative">
|
||||
<div class="row" style="gap: 0.3rem; flex-wrap: nowrap">
|
||||
<span
|
||||
v-if="sortable"
|
||||
style="cursor: pointer; user-select: none"
|
||||
@click="onSort?.(name)"
|
||||
>
|
||||
{{ label }}
|
||||
<ArrowUp v-if="sortKey === name && sortDir === 'asc'" :size="13" />
|
||||
<ArrowDown v-else-if="sortKey === name && sortDir === 'desc'" :size="13" />
|
||||
<ArrowUpDown v-else :size="13" style="opacity: 0.4" />
|
||||
</span>
|
||||
<span v-else>{{ label }}</span>
|
||||
|
||||
<button
|
||||
v-if="filterKind"
|
||||
:id="`filter-btn-${name}`"
|
||||
class="ghost th-filter-btn"
|
||||
style="padding: 0.1rem 0.25rem"
|
||||
:title="`Filter ${label}`"
|
||||
@click.stop="togglePopover"
|
||||
>
|
||||
<ListFilter :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="open" :id="`filter-pop-${name}`" class="filter-pop">
|
||||
<input
|
||||
v-if="filterKind === 'text' || filterKind === 'text+facet'"
|
||||
class="mono"
|
||||
:value="filterText"
|
||||
placeholder="contains..."
|
||||
style="width: 100%; margin-bottom: 0.4rem"
|
||||
@input="onFilterText?.(name, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div
|
||||
v-if="filterKind === 'facet' || filterKind === 'text+facet'"
|
||||
class="facet-list"
|
||||
>
|
||||
<label
|
||||
v-for="v in facetValues"
|
||||
:key="v.value"
|
||||
class="row facet-row"
|
||||
style="gap: 0.4rem; flex-wrap: nowrap"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
style="width: auto"
|
||||
:checked="filterChecked?.has(v.value) ?? false"
|
||||
@change="onCheckbox(v.value, $event)"
|
||||
/>
|
||||
<span class="mono" style="flex: 1">{{ v.value }}</span>
|
||||
<span class="muted">{{ v.count }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row" style="justify-content: flex-end; margin-top: 0.4rem">
|
||||
<button class="ghost" style="padding: 0.15rem 0.5rem" @click="onFilterText?.(name, '')">
|
||||
<X :size="12" /> clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.th-filter-btn {
|
||||
color: var(--muted);
|
||||
}
|
||||
.filter-pop {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 0.6rem;
|
||||
min-width: 14rem;
|
||||
max-width: 20rem;
|
||||
text-align: left;
|
||||
}
|
||||
.facet-list {
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.facet-row {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
// Client-side table sorting + filtering, shared by the fully-loaded tables
|
||||
// (Devices, Users). The paginated Messages view talks to the API instead —
|
||||
// only the popover/header components are shared with it.
|
||||
//
|
||||
// A column is configured with:
|
||||
// get: row -> value
|
||||
// sort: 'string' | 'number' (default 'string')
|
||||
// filter: 'text' | 'facet' | 'text+facet' (default: no filtering)
|
||||
|
||||
import { computed, reactive, ref, type Ref } from 'vue'
|
||||
|
||||
export type FilterKind = 'text' | 'facet' | 'text+facet'
|
||||
|
||||
export interface ColumnConfig<T> {
|
||||
get: (row: T) => string | number
|
||||
sort?: 'string' | 'number'
|
||||
filter?: FilterKind
|
||||
}
|
||||
|
||||
export interface ColumnState {
|
||||
text: string
|
||||
checked: Set<string>
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function useTable<T>(rows: Ref<T[]>, columns: Record<string, ColumnConfig<T>>) {
|
||||
const state = reactive<Record<string, ColumnState>>({})
|
||||
const sortKey = ref<string | null>(null)
|
||||
const sortDir = ref<'asc' | 'desc'>('asc')
|
||||
|
||||
function col(name: string): ColumnState {
|
||||
if (!state[name]) state[name] = { text: '', checked: new Set(), open: false }
|
||||
return state[name]
|
||||
}
|
||||
|
||||
function toggleSort(name: string): void {
|
||||
if (sortKey.value === null || sortKey.value !== name) {
|
||||
sortKey.value = name
|
||||
sortDir.value = 'asc'
|
||||
} else if (sortDir.value === 'asc') {
|
||||
sortDir.value = 'desc'
|
||||
} else {
|
||||
sortKey.value = null // third click: back to default order
|
||||
}
|
||||
}
|
||||
|
||||
function distinctValues(name: string): { value: string; count: number }[] {
|
||||
const cfg = columns[name]
|
||||
const counts = new Map<string, number>()
|
||||
for (const row of rows.value) {
|
||||
const v = String(cfg.get(row))
|
||||
counts.set(v, (counts.get(v) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((a, b) => a.value.localeCompare(b.value))
|
||||
}
|
||||
|
||||
function passesFilter(name: string, value: string): boolean {
|
||||
const cfg = columns[name]
|
||||
const st = state[name]
|
||||
if (!cfg.filter || !st) return true
|
||||
if (st.text && !value.toLowerCase().includes(st.text.toLowerCase())) return false
|
||||
if (st.checked.size > 0 && !st.checked.has(value)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const sortedFilteredRows = computed(() => {
|
||||
let out = rows.value.filter((row) => {
|
||||
for (const name of Object.keys(columns)) {
|
||||
const cfg = columns[name]
|
||||
if (!cfg.filter) continue
|
||||
if (!passesFilter(name, String(cfg.get(row)))) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (sortKey.value !== null) {
|
||||
const cfg = columns[sortKey.value]
|
||||
const dir = sortDir.value === 'asc' ? 1 : -1
|
||||
out = [...out].sort((a, b) => {
|
||||
const va = cfg.get(a)
|
||||
const vb = cfg.get(b)
|
||||
if (cfg.sort === 'number') return ((va as number) - (vb as number)) * dir
|
||||
return String(va).localeCompare(String(vb)) * dir
|
||||
})
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
function clearColumn(name: string): void {
|
||||
const st = state[name]
|
||||
if (!st) return
|
||||
st.text = ''
|
||||
st.checked = new Set()
|
||||
}
|
||||
|
||||
function activeFilterCount(): number {
|
||||
return Object.values(state).filter((st) => st.text !== '' || st.checked.size > 0).length
|
||||
}
|
||||
|
||||
return {
|
||||
col,
|
||||
state,
|
||||
sortKey,
|
||||
sortDir,
|
||||
toggleSort,
|
||||
distinctValues,
|
||||
sortedFilteredRows,
|
||||
clearColumn,
|
||||
activeFilterCount,
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { onMounted, ref } from 'vue'
|
||||
import { Cpu, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { api } from '../api'
|
||||
import type { Device } from '../api'
|
||||
import { useTable } from '../composables/useTable'
|
||||
import TableHeader from '../components/TableHeader.vue'
|
||||
import { canEdit, showError, toast } from '../store'
|
||||
|
||||
const devices = ref<Device[]>([])
|
||||
@@ -10,6 +12,23 @@ const loaded = ref(false)
|
||||
const newImei = ref('')
|
||||
const newName = ref('')
|
||||
|
||||
const table = useTable(devices, {
|
||||
imei: { get: (d) => d.imei, filter: 'text' },
|
||||
device: { get: (d) => d.device, filter: 'text+facet' },
|
||||
})
|
||||
|
||||
function onFilterText(name: string, text: string) {
|
||||
table.col(name).text = text
|
||||
}
|
||||
function onFilterCheck(name: string, value: string, checked: boolean) {
|
||||
const st = table.col(name)
|
||||
if (checked) st.checked.add(value)
|
||||
else st.checked.delete(value)
|
||||
}
|
||||
function onToggleOpen(name: string) {
|
||||
table.col(name).open = !table.col(name).open
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
devices.value = await api<Device[]>('/api/devices')
|
||||
@@ -69,13 +88,40 @@ async function removeDevice(d: Device) {
|
||||
<table v-if="devices.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IMEI</th>
|
||||
<th>Device</th>
|
||||
<TableHeader
|
||||
label="IMEI"
|
||||
name="imei"
|
||||
sortable
|
||||
:sort-key="table.sortKey.value"
|
||||
:sort-dir="table.sortDir.value"
|
||||
:on-sort="table.toggleSort"
|
||||
filter-kind="text"
|
||||
:filter-text="table.col('imei').text"
|
||||
:on-filter-text="onFilterText"
|
||||
:open="table.col('imei').open"
|
||||
:on-toggle-open="onToggleOpen"
|
||||
/>
|
||||
<TableHeader
|
||||
label="Device"
|
||||
name="device"
|
||||
sortable
|
||||
:sort-key="table.sortKey.value"
|
||||
:sort-dir="table.sortDir.value"
|
||||
:on-sort="table.toggleSort"
|
||||
filter-kind="text+facet"
|
||||
:filter-text="table.col('device').text"
|
||||
:on-filter-text="onFilterText"
|
||||
:facet-values="table.distinctValues('device')"
|
||||
:filter-checked="table.col('device').checked"
|
||||
:on-filter-check="onFilterCheck"
|
||||
:open="table.col('device').open"
|
||||
:on-toggle-open="onToggleOpen"
|
||||
/>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in devices" :key="d.imei">
|
||||
<tr v-for="d in table.sortedFilteredRows.value" :key="d.imei">
|
||||
<td class="mono">
|
||||
<router-link :to="`/devices/${d.imei}`">{{ d.imei }}</router-link>
|
||||
</td>
|
||||
|
||||
+106
-13
@@ -2,18 +2,29 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Inbox } from 'lucide-vue-next'
|
||||
import { api } from '../api'
|
||||
import type { MessagePage } from '../api'
|
||||
import type { Device, MessagePage } from '../api'
|
||||
import { formatTime } from '../format'
|
||||
import { showError } from '../store'
|
||||
import TableHeader from '../components/TableHeader.vue'
|
||||
|
||||
const page = ref<MessagePage | null>(null)
|
||||
const loading = ref(false)
|
||||
const offset = ref(0)
|
||||
const limit = 25
|
||||
|
||||
// filters
|
||||
const imei = ref('')
|
||||
const device = ref('')
|
||||
// the full device population for the facet lists (not just the loaded page)
|
||||
const devices = ref<Device[]>([])
|
||||
|
||||
// server-side sort + filters (mirrored into query params on load)
|
||||
const sort = ref('')
|
||||
const imeiChecked = ref(new Set<string>())
|
||||
const deviceChecked = ref(new Set<string>())
|
||||
const imeiText = ref('')
|
||||
const deviceText = ref('')
|
||||
const popOpen = ref<string | null>(null)
|
||||
let textTimer: number | undefined
|
||||
|
||||
// date filters (the Received column's filter)
|
||||
const from = ref('')
|
||||
const to = ref('')
|
||||
|
||||
@@ -21,8 +32,11 @@ function buildQuery(): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', String(limit))
|
||||
params.set('offset', String(offset.value))
|
||||
if (imei.value.trim()) params.set('imei', imei.value.trim())
|
||||
if (device.value.trim()) params.set('device', device.value.trim())
|
||||
if (sort.value) params.set('sort', sort.value)
|
||||
for (const v of imeiChecked.value) params.append('imei', v)
|
||||
for (const v of deviceChecked.value) params.append('device', v)
|
||||
if (imeiText.value.trim()) params.set('imei_like', imeiText.value.trim())
|
||||
if (deviceText.value.trim()) params.set('device_like', deviceText.value.trim())
|
||||
if (from.value) params.set('from', new Date(from.value).toISOString())
|
||||
if (to.value) params.set('to', new Date(to.value).toISOString())
|
||||
return `/api/messages?${params.toString()}`
|
||||
@@ -44,6 +58,43 @@ function applyFilters() {
|
||||
load()
|
||||
}
|
||||
|
||||
function toggleSort(name: string) {
|
||||
// cycle asc -> desc -> default for the given column key
|
||||
const current = sort.value
|
||||
if (current !== name && current !== `-${name}`) sort.value = name
|
||||
else if (current === name) sort.value = `-${name}`
|
||||
else sort.value = ''
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
function sortState(name: string): '' | 'asc' | 'desc' {
|
||||
if (sort.value === name) return 'asc'
|
||||
if (sort.value === `-${name}`) return 'desc'
|
||||
return ''
|
||||
}
|
||||
|
||||
function facetValues(kind: 'imei' | 'device'): { value: string; count: number }[] {
|
||||
// values from the full device list ("might occur"), not just the loaded page
|
||||
if (kind === 'imei') return devices.value.map((d) => ({ value: d.imei, count: 1 }))
|
||||
return devices.value.map((d) => ({ value: d.device, count: 1 }))
|
||||
}
|
||||
|
||||
function onCheck(kind: 'imei' | 'device', value: string, checked: boolean) {
|
||||
const target = kind === 'imei' ? imeiChecked : deviceChecked
|
||||
if (checked) target.value.add(value)
|
||||
else target.value.delete(value)
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
function onText(kind: 'imei' | 'device', text: string) {
|
||||
if (kind === 'imei') imeiText.value = text
|
||||
else deviceText.value = text
|
||||
// server-side filtering: reload shortly after typing stops (the
|
||||
// client-side tables filter reactively; this one needs a query)
|
||||
window.clearTimeout(textTimer)
|
||||
textTimer = window.setTimeout(applyFilters, 400)
|
||||
}
|
||||
|
||||
function prev() {
|
||||
offset.value = Math.max(0, offset.value - limit)
|
||||
load()
|
||||
@@ -54,20 +105,25 @@ function next() {
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(async () => {
|
||||
load()
|
||||
try {
|
||||
devices.value = await api<Device[]>('/api/devices')
|
||||
} catch {
|
||||
// facet lists just stay empty; the text filters still work
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>Messages</h1>
|
||||
|
||||
<form class="card row" @submit.prevent="applyFilters">
|
||||
<input v-model="imei" placeholder="IMEI" style="max-width: 12rem" />
|
||||
<input v-model="device" placeholder="Device name" style="max-width: 12rem" />
|
||||
<label class="muted">from</label>
|
||||
<input v-model="from" type="datetime-local" style="max-width: 14rem" />
|
||||
<label class="muted">to</label>
|
||||
<input v-model="to" type="datetime-local" style="max-width: 14rem" />
|
||||
<button class="primary" type="submit">Filter</button>
|
||||
<button class="primary" type="submit">Apply</button>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
@@ -84,9 +140,46 @@ onMounted(load)
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Received</th>
|
||||
<th>IMEI</th>
|
||||
<th>Device</th>
|
||||
<TableHeader
|
||||
label="Received"
|
||||
name="received_at"
|
||||
sortable
|
||||
:sort-key="sortState('received_at') ? 'received_at' : null"
|
||||
:sort-dir="sortState('received_at') === 'desc' ? 'desc' : 'asc'"
|
||||
:on-sort="() => toggleSort('received_at')"
|
||||
/>
|
||||
<TableHeader
|
||||
label="IMEI"
|
||||
name="imei"
|
||||
sortable
|
||||
:sort-key="sortState('imei') ? 'imei' : null"
|
||||
:sort-dir="sortState('imei') === 'desc' ? 'desc' : 'asc'"
|
||||
:on-sort="() => toggleSort('imei')"
|
||||
filter-kind="text+facet"
|
||||
:filter-text="imeiText"
|
||||
:on-filter-text="(_n, t) => onText('imei', t)"
|
||||
:facet-values="facetValues('imei')"
|
||||
:filter-checked="imeiChecked"
|
||||
:on-filter-check="(n, v, c) => onCheck('imei', v, c)"
|
||||
:open="popOpen === 'imei'"
|
||||
:on-toggle-open="() => (popOpen = popOpen === 'imei' ? null : 'imei')"
|
||||
/>
|
||||
<TableHeader
|
||||
label="Device"
|
||||
name="device"
|
||||
sortable
|
||||
:sort-key="sortState('device') ? 'device' : null"
|
||||
:sort-dir="sortState('device') === 'desc' ? 'desc' : 'asc'"
|
||||
:on-sort="() => toggleSort('device')"
|
||||
filter-kind="text+facet"
|
||||
:filter-text="deviceText"
|
||||
:on-filter-text="(_n, t) => onText('device', t)"
|
||||
:facet-values="facetValues('device')"
|
||||
:filter-checked="deviceChecked"
|
||||
:on-filter-check="(n, v, c) => onCheck('device', v, c)"
|
||||
:open="popOpen === 'device'"
|
||||
:on-toggle-open="() => (popOpen = popOpen === 'device' ? null : 'device')"
|
||||
/>
|
||||
<th>Payload + attributes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Plus, Trash2, UserRound } from 'lucide-vue-next'
|
||||
import { api } from '../api'
|
||||
import { useTable } from '../composables/useTable'
|
||||
import TableHeader from '../components/TableHeader.vue'
|
||||
import { canEdit, isAdmin, showError, toast, ui } from '../store'
|
||||
|
||||
const users = ref<{ username: string; role: string }[]>([])
|
||||
@@ -11,6 +13,23 @@ const newPassword = ref('')
|
||||
// superusers may only create 'user' accounts (server-enforced); admins any.
|
||||
const newRole = ref('user')
|
||||
|
||||
const table = useTable(users, {
|
||||
username: { get: (u) => u.username, filter: 'text' },
|
||||
role: { get: (u) => u.role, filter: 'facet' },
|
||||
})
|
||||
|
||||
function onFilterText(name: string, text: string) {
|
||||
table.col(name).text = text
|
||||
}
|
||||
function onFilterCheck(name: string, value: string, checked: boolean) {
|
||||
const st = table.col(name)
|
||||
if (checked) st.checked.add(value)
|
||||
else st.checked.delete(value)
|
||||
}
|
||||
function onToggleOpen(name: string) {
|
||||
table.col(name).open = !table.col(name).open
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
users.value = await api<{ username: string; role: string }[]>('/api/users')
|
||||
@@ -93,13 +112,38 @@ async function removeUser(u: { username: string; role: string }) {
|
||||
<table v-if="users.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<TableHeader
|
||||
label="Username"
|
||||
name="username"
|
||||
sortable
|
||||
:sort-key="table.sortKey.value"
|
||||
:sort-dir="table.sortDir.value"
|
||||
:on-sort="table.toggleSort"
|
||||
filter-kind="text"
|
||||
:filter-text="table.col('username').text"
|
||||
:on-filter-text="onFilterText"
|
||||
:open="table.col('username').open"
|
||||
:on-toggle-open="onToggleOpen"
|
||||
/>
|
||||
<TableHeader
|
||||
label="Role"
|
||||
name="role"
|
||||
sortable
|
||||
:sort-key="table.sortKey.value"
|
||||
:sort-dir="table.sortDir.value"
|
||||
:on-sort="table.toggleSort"
|
||||
filter-kind="facet"
|
||||
:facet-values="table.distinctValues('role')"
|
||||
:filter-checked="table.col('role').checked"
|
||||
:on-filter-check="onFilterCheck"
|
||||
:open="table.col('role').open"
|
||||
:on-toggle-open="onToggleOpen"
|
||||
/>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.username">
|
||||
<tr v-for="u in table.sortedFilteredRows.value" :key="u.username">
|
||||
<td class="mono">{{ u.username }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="{ ok: u.role === 'admin' }">{{ u.role }}</span>
|
||||
|
||||
Reference in New Issue
Block a user