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:
101
frontend/src/views/reports/CalibrationReport.vue
Normal file
101
frontend/src/views/reports/CalibrationReport.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Calibration Due</h1>
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="summary-row">
|
||||
<div v-for="b in bucketOrder" :key="b.key" class="summary-card" :style="cardStyle(b.color)">
|
||||
<span class="summary-count">{{ counts[b.key] || 0 }}</span>
|
||||
<span class="summary-label">{{ b.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="b in bucketOrder" :key="b.key">
|
||||
<div class="bucket card" v-if="(buckets[b.key] || []).length">
|
||||
<h3 class="bucket-title">
|
||||
<span class="dot" :style="{ background: b.color }"></span>
|
||||
{{ b.label }} ({{ (buckets[b.key] || []).length }})
|
||||
</h3>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset #</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Next Calibration</th>
|
||||
<th>Provider</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in buckets[b.key]" :key="row.assetid">
|
||||
<td>
|
||||
<router-link :to="`/measuringtools/${row.measuringtool.measuringtoolid}`" class="asset-chip">
|
||||
{{ row.assetnumber }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ row.name || '-' }}</td>
|
||||
<td>{{ row.measuringtool.measuringtooltypename || '-' }}</td>
|
||||
<td>{{ row.measuringtool.nextcalibrationdate ? formatDate(row.measuringtool.nextcalibrationdate) : '-' }}</td>
|
||||
<td>{{ row.measuringtool.calibrationprovider || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { measuringtoolsApi } from '../../api'
|
||||
|
||||
const loading = ref(true)
|
||||
const counts = ref({})
|
||||
const buckets = ref({})
|
||||
|
||||
const bucketOrder = [
|
||||
{ key: 'overdue', label: 'Overdue', color: '#F44336' },
|
||||
{ key: 'duesoon', label: 'Due Soon', color: '#FF9800' },
|
||||
{ key: 'current', label: 'Current', color: '#4CAF50' },
|
||||
{ key: 'unknown', label: 'Unknown', color: '#9E9E9E' },
|
||||
]
|
||||
|
||||
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
|
||||
function cardStyle(color) { return { borderTop: `3px solid ${color}` } }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await measuringtoolsApi.calibrationReport()
|
||||
counts.value = response.data.data.counts || {}
|
||||
buckets.value = response.data.data.buckets || {}
|
||||
} catch (err) {
|
||||
console.error('Error loading calibration report:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summary-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
||||
.summary-card {
|
||||
flex: 1; min-width: 140px; padding: 1rem 1.25rem; background: var(--bg-card);
|
||||
border: 1px solid var(--border); border-radius: 8px; display: flex; flex-direction: column; gap: 0.25rem;
|
||||
}
|
||||
.summary-count { font-size: 1.8rem; font-weight: 700; color: var(--text); }
|
||||
.summary-label { font-size: 0.85rem; color: var(--text-light); }
|
||||
.bucket { margin-bottom: 1.25rem; }
|
||||
.bucket-title { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.75rem; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
||||
.asset-chip {
|
||||
display: inline-block; padding: 0.15rem 0.55rem;
|
||||
background: var(--bg); border-radius: 12px; font-size: 0.85rem; text-decoration: none; color: var(--text);
|
||||
}
|
||||
</style>
|
||||
144
frontend/src/views/reports/PCRelationshipsReport.vue
Normal file
144
frontend/src/views/reports/PCRelationshipsReport.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>PC-Machine Relationships</h2>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" @click="copyTable">Copy Table</button>
|
||||
<button class="btn btn-secondary" @click="copyCSV">Copy CSV</button>
|
||||
<button class="btn btn-secondary" @click="copyJSON">Copy JSON</button>
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted">PCs with relationships to shop floor machines</p>
|
||||
|
||||
<div v-if="copied" class="copy-toast">{{ copiedFormat }} copied to clipboard!</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table id="relationshipsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine #</th>
|
||||
<th>Vendor</th>
|
||||
<th>Model</th>
|
||||
<th>PC Hostname</th>
|
||||
<th>PC IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in data" :key="idx">
|
||||
<td>{{ row.machine_number }}</td>
|
||||
<td>{{ row.vendor }}</td>
|
||||
<td>{{ row.model }}</td>
|
||||
<td class="mono">{{ row.hostname }}</td>
|
||||
<td class="mono">{{ row.ip }}</td>
|
||||
</tr>
|
||||
<tr v-if="data.length === 0">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-light);">
|
||||
No relationships found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="record-count">{{ data.length }} records found</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { reportsApi } from '../../api'
|
||||
|
||||
const loading = ref(true)
|
||||
const data = ref([])
|
||||
const copied = ref(false)
|
||||
const copiedFormat = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await reportsApi.pcRelationships()
|
||||
data.value = response.data.data?.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading report:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function showCopied(format) {
|
||||
copiedFormat.value = format
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
|
||||
function copyTable() {
|
||||
const headers = ['Machine #', 'Vendor', 'Model', 'PC Hostname', 'PC IP']
|
||||
let text = headers.join('\t') + '\n'
|
||||
for (const row of data.value) {
|
||||
text += [row.machine_number, row.vendor, row.model, row.hostname, row.ip].join('\t') + '\n'
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(() => showCopied('Table'))
|
||||
}
|
||||
|
||||
function copyCSV() {
|
||||
const headers = ['Machine #', 'Vendor', 'Model', 'PC Hostname', 'PC IP']
|
||||
let csv = headers.map(h => `"${h}"`).join(',') + '\n'
|
||||
for (const row of data.value) {
|
||||
csv += [row.machine_number, row.vendor, row.model, row.hostname, row.ip]
|
||||
.map(v => `"${v}"`)
|
||||
.join(',') + '\n'
|
||||
}
|
||||
navigator.clipboard.writeText(csv).then(() => showCopied('CSV'))
|
||||
}
|
||||
|
||||
function copyJSON() {
|
||||
const jsonData = data.value.map(row => ({
|
||||
Name: row.hostname,
|
||||
IpAddress: row.ip,
|
||||
Group: null
|
||||
}))
|
||||
navigator.clipboard.writeText(JSON.stringify(jsonData, null, 2)).then(() => showCopied('JSON'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-muted {
|
||||
color: var(--text-light);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.record-count {
|
||||
color: var(--text-light);
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.copy-toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
z-index: 9999;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
514
frontend/src/views/reports/ReportsIndex.vue
Normal file
514
frontend/src/views/reports/ReportsIndex.vue
Normal file
@@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>Reports</h1>
|
||||
</div>
|
||||
|
||||
<!-- Card grid hides while a report is open so results sit at the top. -->
|
||||
<template v-if="!currentReport">
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search reports..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- All report cards, grouped by category. Cards come from GET /api/reports,
|
||||
which merges core reports with plugin-contributed cards (get_reports). -->
|
||||
<div v-for="group in groupedReports" :key="group.category" class="report-group">
|
||||
<h2 class="group-title">{{ titleCase(group.category) }}</h2>
|
||||
<div class="reports-grid">
|
||||
<div
|
||||
v-for="report in group.reports"
|
||||
:key="report.id"
|
||||
class="report-card card"
|
||||
@click="openReport(report)"
|
||||
>
|
||||
<h3>{{ report.name }}</h3>
|
||||
<p>{{ report.description }}</p>
|
||||
<span class="badge">{{ report.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!groupedReports.length" class="empty-state">
|
||||
No reports match your search.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Inline report results (endpoint-backed reports) -->
|
||||
<div v-if="currentReport" class="report-results card">
|
||||
<div class="report-header">
|
||||
<h2>{{ currentReport.name }}</h2>
|
||||
<div class="report-actions">
|
||||
<button class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<button class="btn btn-secondary" @click="clearReport">Back to Reports</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server-side filters this report accepts (see reportFilterFields) -->
|
||||
<div v-if="activeFilterFields.length" class="filters">
|
||||
<select
|
||||
v-if="activeFilterFields.includes('businessunit')"
|
||||
v-model="filterState.businessunitid"
|
||||
class="form-control"
|
||||
@change="refreshReport"
|
||||
>
|
||||
<option value="">All business units</option>
|
||||
<option v-for="bu in filterOptions.businessunits" :key="bu.businessunitid" :value="bu.businessunitid">
|
||||
{{ bu.businessunit }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="activeFilterFields.includes('assettype')"
|
||||
v-model="filterState.assettypeid"
|
||||
class="form-control"
|
||||
@change="refreshReport"
|
||||
>
|
||||
<option value="">All asset types</option>
|
||||
<option v-for="at in filterOptions.assettypes" :key="at.assettypeid" :value="at.assettypeid">
|
||||
{{ titleCase(at.assettype) }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="activeFilterFields.includes('location')"
|
||||
v-model="filterState.locationid"
|
||||
class="form-control"
|
||||
@change="refreshReport"
|
||||
>
|
||||
<option value="">All locations</option>
|
||||
<option v-for="loc in filterOptions.locations" :key="loc.locationid" :value="loc.locationid">
|
||||
{{ loc.locationname }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="activeFilterFields.includes('application')"
|
||||
v-model="filterState.appid"
|
||||
class="form-control"
|
||||
@change="refreshReport"
|
||||
>
|
||||
<option value="">All required applications</option>
|
||||
<option v-for="app in filterOptions.applications" :key="app.appid" :value="app.appid">
|
||||
{{ app.appname }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-if="activeFilterFields.includes('limit')"
|
||||
v-model.number="filterState.limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
class="form-control limit-input"
|
||||
placeholder="Limit (20)"
|
||||
@change="refreshReport"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading report...</div>
|
||||
<div v-else-if="reportData">
|
||||
<!-- Machines by Type -->
|
||||
<div v-if="currentReport.id === 'machines-by-type'" class="report-content">
|
||||
<p class="report-summary">Total: {{ reportData.total }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine Type</th>
|
||||
<th>Description</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.machinetype">
|
||||
<td>{{ item.machinetype }}</td>
|
||||
<td>{{ item.description }}</td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Assets by Status -->
|
||||
<div v-else-if="currentReport.id === 'assets-by-status'" class="report-content">
|
||||
<p class="report-summary">Total: {{ reportData.total }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.status">
|
||||
<td>
|
||||
<span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span>
|
||||
</td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- KB Popularity -->
|
||||
<div v-else-if="currentReport.id === 'kb-popularity'" class="report-content">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Article</th>
|
||||
<th>Application</th>
|
||||
<th>Clicks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.linkid">
|
||||
<td>
|
||||
<a v-if="item.linkurl" :href="item.linkurl" target="_blank">{{ item.shortdescription }}</a>
|
||||
<span v-else>{{ item.shortdescription }}</span>
|
||||
</td>
|
||||
<td>{{ item.application || '-' }}</td>
|
||||
<td>{{ item.clicks }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Asset Inventory -->
|
||||
<div v-else-if="currentReport.id === 'asset-inventory'" class="report-content">
|
||||
<p class="report-summary">Total Assets: {{ reportData.total }}</p>
|
||||
|
||||
<h3>By Type</h3>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bytype" :key="item.type">
|
||||
<td>{{ item.type }}</td><td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>By Status</h3>
|
||||
<table>
|
||||
<thead><tr><th>Status</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bystatus" :key="item.status">
|
||||
<td><span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span></td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>By Location</h3>
|
||||
<table>
|
||||
<thead><tr><th>Location</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bylocation" :key="item.location">
|
||||
<td>{{ item.location }}</td><td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Generic fallback -->
|
||||
<div v-else class="report-content">
|
||||
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const reports = ref([])
|
||||
const search = ref('')
|
||||
const currentReport = ref(null)
|
||||
const reportData = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Preferred category order; anything else falls in after, sorted alphabetically.
|
||||
const categoryOrder = ['inventory', 'compliance', 'usage']
|
||||
|
||||
// server-side filters each inline report accepts (query params on its endpoint)
|
||||
const reportFilterFields = {
|
||||
'machines-by-type': ['businessunit'],
|
||||
'assets-by-status': ['assettype', 'businessunit'],
|
||||
'asset-inventory': ['businessunit', 'location'],
|
||||
'kb-popularity': ['limit'],
|
||||
'software-compliance': ['application']
|
||||
}
|
||||
|
||||
const filterState = ref({ businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' })
|
||||
const filterOptions = ref({ businessunits: [], assettypes: [], locations: [], applications: [] })
|
||||
|
||||
const activeFilterFields = computed(() =>
|
||||
currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : []
|
||||
)
|
||||
|
||||
function resetFilters() {
|
||||
filterState.value = { businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' }
|
||||
}
|
||||
|
||||
// fetch dropdown options once, only for the fields the open report needs
|
||||
async function loadFilterOptions(fields) {
|
||||
try {
|
||||
if (fields.includes('businessunit') && !filterOptions.value.businessunits.length) {
|
||||
const response = await businessunitsApi.list({ perpage: 100 })
|
||||
filterOptions.value.businessunits = response.data.data || []
|
||||
}
|
||||
if (fields.includes('assettype') && !filterOptions.value.assettypes.length) {
|
||||
const response = await assetsApi.types.list()
|
||||
filterOptions.value.assettypes = response.data.data || []
|
||||
}
|
||||
if (fields.includes('location') && !filterOptions.value.locations.length) {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
filterOptions.value.locations = response.data.data || []
|
||||
}
|
||||
if (fields.includes('application') && !filterOptions.value.applications.length) {
|
||||
const response = await applicationsApi.list({ perpage: 100 })
|
||||
filterOptions.value.applications = response.data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading filter options:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function filterParams() {
|
||||
const params = {}
|
||||
const state = filterState.value
|
||||
if (state.businessunitid) params.businessunitid = state.businessunitid
|
||||
if (state.assettypeid) params.assettypeid = state.assettypeid
|
||||
if (state.locationid) params.locationid = state.locationid
|
||||
if (state.appid) params.appid = state.appid
|
||||
if (state.limit) params.limit = state.limit
|
||||
return params
|
||||
}
|
||||
|
||||
function refreshReport() {
|
||||
if (currentReport.value) runReport(currentReport.value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadReports()
|
||||
// Honor a deep link / refresh with ?report=<id> already in the URL.
|
||||
applyQuery(route.query.report)
|
||||
})
|
||||
|
||||
async function loadReports() {
|
||||
try {
|
||||
const response = await reportsApi.list()
|
||||
reports.value = response.data.data.reports
|
||||
} catch (error) {
|
||||
console.error('Error loading reports:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function titleCase(text) {
|
||||
return String(text || '')
|
||||
.split(/[\s_-]+/)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
// Filter by name/description/category, then group by category in the fixed order.
|
||||
const groupedReports = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
const matches = reports.value.filter(report => {
|
||||
if (!term) return true
|
||||
return (
|
||||
(report.name || '').toLowerCase().includes(term) ||
|
||||
(report.description || '').toLowerCase().includes(term) ||
|
||||
(report.category || '').toLowerCase().includes(term)
|
||||
)
|
||||
})
|
||||
|
||||
const byCategory = {}
|
||||
for (const report of matches) {
|
||||
const category = report.category || 'other'
|
||||
if (!byCategory[category]) byCategory[category] = []
|
||||
byCategory[category].push(report)
|
||||
}
|
||||
|
||||
const categories = Object.keys(byCategory).sort((a, b) => {
|
||||
const ai = categoryOrder.indexOf(a)
|
||||
const bi = categoryOrder.indexOf(b)
|
||||
if (ai !== -1 || bi !== -1) {
|
||||
return (ai === -1 ? Infinity : ai) - (bi === -1 ? Infinity : bi)
|
||||
}
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
return categories.map(category => ({ category, reports: byCategory[category] }))
|
||||
})
|
||||
|
||||
function openReport(report) {
|
||||
// Cards with a dedicated frontend page carry a route; navigate to it.
|
||||
if (report.route) {
|
||||
router.push(report.route)
|
||||
return
|
||||
}
|
||||
// PC Relationships has its own dedicated page.
|
||||
if (report.id === 'pc-relationships') {
|
||||
router.push('/reports/pc-relationships')
|
||||
return
|
||||
}
|
||||
// Inline reports live in the URL query so browser back/forward works:
|
||||
// back pops the query and returns to the card grid.
|
||||
router.push({ query: { report: report.id } })
|
||||
}
|
||||
|
||||
// The query param is the source of truth for the open inline report.
|
||||
watch(() => route.query.report, applyQuery)
|
||||
|
||||
function applyQuery(id) {
|
||||
if (!id) {
|
||||
currentReport.value = null
|
||||
reportData.value = null
|
||||
return
|
||||
}
|
||||
const report = reports.value.find(r => r.id === id)
|
||||
if (report) {
|
||||
resetFilters()
|
||||
loadFilterOptions(reportFilterFields[id] || [])
|
||||
runReport(report)
|
||||
}
|
||||
}
|
||||
|
||||
async function runReport(report) {
|
||||
currentReport.value = report
|
||||
loading.value = true
|
||||
reportData.value = null
|
||||
window.scrollTo(0, 0)
|
||||
|
||||
const params = filterParams()
|
||||
try {
|
||||
let response
|
||||
switch (report.id) {
|
||||
case 'machines-by-type':
|
||||
response = await reportsApi.machinesByType(params)
|
||||
break
|
||||
case 'assets-by-status':
|
||||
response = await reportsApi.assetsByStatus(params)
|
||||
break
|
||||
case 'kb-popularity':
|
||||
response = await reportsApi.kbPopularity(params)
|
||||
break
|
||||
case 'software-compliance':
|
||||
response = await reportsApi.softwareCompliance(params)
|
||||
break
|
||||
case 'asset-inventory':
|
||||
response = await reportsApi.assetInventory(params)
|
||||
break
|
||||
default:
|
||||
console.error('Unknown report:', report.id)
|
||||
return
|
||||
}
|
||||
reportData.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error running report:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
if (!currentReport.value) return
|
||||
const params = new URLSearchParams({ format: 'csv', ...filterParams() })
|
||||
window.open(withBase(`/api/reports/${currentReport.value.id}?${params}`), '_blank')
|
||||
}
|
||||
|
||||
function clearReport() {
|
||||
// Drop the query param; the watcher clears the panel state.
|
||||
router.push({ query: {} })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.reports-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.report-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.report-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-card p {
|
||||
color: var(--text-light);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-light);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.report-results {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.report-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.report-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.report-summary {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.report-content h3 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-content table {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.limit-input {
|
||||
max-width: 140px;
|
||||
}
|
||||
</style>
|
||||
304
frontend/src/views/reports/TonerReport.vue
Normal file
304
frontend/src/views/reports/TonerReport.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Toner Report</h1>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
|
||||
:columns="emailColumns" :rows="emailRows" />
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading supply data...</div>
|
||||
|
||||
<div v-else-if="error" class="card">
|
||||
<p class="text-danger">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Summary Bar -->
|
||||
<div class="summary-bar">
|
||||
<div class="summary-stat card">
|
||||
<div class="stat-value">{{ summary.total_checked }}</div>
|
||||
<div class="stat-label">Printers Checked</div>
|
||||
</div>
|
||||
<div class="summary-stat card stat-low">
|
||||
<div class="stat-value">{{ summary.low }}</div>
|
||||
<div class="stat-label">Low Supply</div>
|
||||
</div>
|
||||
<div class="summary-stat card stat-critical">
|
||||
<div class="stat-value">{{ summary.critical }}</div>
|
||||
<div class="stat-label">Critical Supply</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<div class="filters">
|
||||
<button
|
||||
v-for="f in filterOptions"
|
||||
:key="f.value"
|
||||
class="btn"
|
||||
:class="filter === f.value ? 'btn-primary' : 'btn-secondary'"
|
||||
@click="filter = f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Printers Table -->
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table v-if="filteredPrinters.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Printer Name</th>
|
||||
<th>Asset #</th>
|
||||
<th>Location</th>
|
||||
<th>IP Address</th>
|
||||
<th>Supplies</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
|
||||
<td>
|
||||
<router-link :to="`/printers/${printer.printerid}`">
|
||||
{{ printer.printername || 'Unknown' }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ printer.assetnumber }}</td>
|
||||
<td>{{ printer.location || '-' }}</td>
|
||||
<td>{{ printer.ipaddress }}</td>
|
||||
<td class="supplies-cell">
|
||||
<div
|
||||
v-for="(supply, idx) in printer.supplies"
|
||||
:key="idx"
|
||||
class="supply-row"
|
||||
>
|
||||
<span class="supply-name">{{ supply.name }}</span>
|
||||
<div class="supply-bar-track">
|
||||
<div
|
||||
class="supply-bar-fill"
|
||||
:class="'supply-' + supply.status"
|
||||
:style="{ width: Math.max(supply.level, 2) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="supply-level" :class="'supply-text-' + supply.status">
|
||||
{{ supply.level }}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="empty-state">No printers match the selected filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import EmailReportButton from '../../components/EmailReportButton.vue'
|
||||
|
||||
const emailColumns = [
|
||||
{ key: 'printer', label: 'Printer' },
|
||||
{ key: 'assetnumber', label: 'Asset #' },
|
||||
{ key: 'location', label: 'Location' },
|
||||
{ key: 'ipaddress', label: 'IP Address' },
|
||||
{ key: 'supply', label: 'Supply' },
|
||||
{ key: 'level', label: 'Level' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const printers = ref([])
|
||||
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
|
||||
const filter = ref('all')
|
||||
|
||||
const filterOptions = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Critical', value: 'critical' },
|
||||
{ label: 'Low', value: 'low' }
|
||||
]
|
||||
|
||||
const filteredPrinters = computed(() => {
|
||||
if (filter.value === 'all') return printers.value
|
||||
return printers.value.filter(p =>
|
||||
p.supplies.some(s => s.status === filter.value)
|
||||
)
|
||||
})
|
||||
|
||||
// One row per supply, honoring the active filter, for the emailed table.
|
||||
const emailRows = computed(() => {
|
||||
const rows = []
|
||||
for (const printer of filteredPrinters.value) {
|
||||
for (const supply of printer.supplies || []) {
|
||||
rows.push({
|
||||
printer: printer.printername || '',
|
||||
assetnumber: printer.assetnumber || '',
|
||||
location: printer.location || '',
|
||||
ipaddress: printer.ipaddress || '',
|
||||
supply: supply.name || '',
|
||||
level: supply.level + '%',
|
||||
status: supply.status || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
function exportCSV() {
|
||||
// one row per supply, honoring the active filter
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
const rows = [['printer', 'assetnumber', 'location', 'ipaddress', 'supply', 'level', 'status']]
|
||||
for (const printer of filteredPrinters.value) {
|
||||
for (const supply of printer.supplies || []) {
|
||||
rows.push([
|
||||
printer.printername || '', printer.assetnumber || '', printer.location || '',
|
||||
printer.ipaddress || '', supply.name || '', supply.level, supply.status || ''
|
||||
])
|
||||
}
|
||||
}
|
||||
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
|
||||
link.download = 'toner_report.csv'
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await printersApi.lowSupplies()
|
||||
const data = response.data.data
|
||||
printers.value = data.printers || []
|
||||
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
|
||||
} catch (err) {
|
||||
console.error('Error loading toner report:', err)
|
||||
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-stat {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-light);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-low .stat-value {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.stat-critical .stat-value {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.supplies-cell {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.supply-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.supply-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.supply-name {
|
||||
flex: 0 0 120px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.supply-bar-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.supply-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.supply-ok {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.supply-low {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.supply-critical {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.supply-level {
|
||||
flex: 0 0 40px;
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.supply-text-ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.supply-text-low {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.supply-text-critical {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
156
frontend/src/views/reports/WarrantyReport.vue
Normal file
156
frontend/src/views/reports/WarrantyReport.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Warranty Report</h1>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<EmailReportButton v-if="!loading" subject="Warranty Report"
|
||||
:columns="emailColumns" :rows="emailRows" />
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="summary-row">
|
||||
<div v-for="b in bucketOrder" :key="b.key" class="summary-card" :style="cardStyle(b.color)">
|
||||
<span class="summary-count">{{ counts[b.key] || 0 }}</span>
|
||||
<span class="summary-label">{{ b.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="b in bucketOrder" :key="b.key">
|
||||
<div class="bucket card" v-if="(buckets[b.key] || []).length">
|
||||
<h3 class="bucket-title">
|
||||
<span class="dot" :style="{ background: b.color }"></span>
|
||||
{{ b.label }} ({{ (buckets[b.key] || []).length }})
|
||||
</h3>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Vendor</th>
|
||||
<th>Service Level</th>
|
||||
<th>Ends</th>
|
||||
<th>Covers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="w in buckets[b.key]" :key="w.warrantyid">
|
||||
<td><strong>{{ w.vendor }}</strong></td>
|
||||
<td class="servicelevel-cell" :title="w.servicelevel">{{ w.servicelevel || '-' }}</td>
|
||||
<td>{{ w.enddate ? formatDate(w.enddate) : '-' }}</td>
|
||||
<td>
|
||||
<router-link v-for="a in w.assets" :key="a.assetid" :to="assetLink(a)" class="asset-chip">{{ a.assetnumber }}</router-link>
|
||||
<span v-if="!w.assets.length" class="muted">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { warrantyApi } from '../../api'
|
||||
import EmailReportButton from '../../components/EmailReportButton.vue'
|
||||
|
||||
const loading = ref(true)
|
||||
const counts = ref({})
|
||||
const buckets = ref({})
|
||||
|
||||
const emailColumns = [
|
||||
{ key: 'bucket', label: 'Status' },
|
||||
{ key: 'vendor', label: 'Vendor' },
|
||||
{ key: 'servicelevel', label: 'Service Level' },
|
||||
{ key: 'enddate', label: 'Ends' },
|
||||
{ key: 'assets', label: 'Covers' },
|
||||
]
|
||||
|
||||
// Flatten the buckets into one row per warranty for the emailed table.
|
||||
const emailRows = computed(() => {
|
||||
const rows = []
|
||||
for (const b of bucketOrder) {
|
||||
for (const w of buckets.value[b.key] || []) {
|
||||
rows.push({
|
||||
bucket: b.label,
|
||||
vendor: w.vendor || '',
|
||||
servicelevel: w.servicelevel || '',
|
||||
enddate: w.enddate || '',
|
||||
assets: (w.assets || []).map(a => a.assetnumber).join(', '),
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
const bucketOrder = [
|
||||
{ key: 'expired', label: 'Expired', color: '#F44336' },
|
||||
{ key: 'expiring', label: 'Expiring Soon', color: '#FF9800' },
|
||||
{ key: 'active', label: 'Active', color: '#4CAF50' },
|
||||
{ key: 'unknown', label: 'Unknown', color: '#9E9E9E' },
|
||||
]
|
||||
|
||||
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
|
||||
function cardStyle(color) { return { borderTop: `3px solid ${color}` } }
|
||||
function assetLink(a) {
|
||||
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/', measuring_tool: '/measuringtools/by-asset/' }
|
||||
return (map[a.assettypename] || '/assets/') + a.assetid
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
// one row per warranty, covered assets joined with ;
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
const rows = [['bucket', 'vendor', 'servicelevel', 'enddate', 'assets']]
|
||||
for (const b of bucketOrder) {
|
||||
for (const w of buckets.value[b.key] || []) {
|
||||
rows.push([
|
||||
b.label, w.vendor || '', w.servicelevel || '', w.enddate || '',
|
||||
(w.assets || []).map(a => a.assetnumber).join('; ')
|
||||
])
|
||||
}
|
||||
}
|
||||
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
|
||||
link.download = 'warranty_report.csv'
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await warrantyApi.report()
|
||||
counts.value = response.data.data.counts || {}
|
||||
buckets.value = response.data.data.buckets || {}
|
||||
} catch (err) {
|
||||
console.error('Error loading warranty report:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summary-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
||||
.summary-card {
|
||||
flex: 1; min-width: 140px; padding: 1rem 1.25rem; background: var(--bg-card);
|
||||
border: 1px solid var(--border); border-radius: 8px; display: flex; flex-direction: column; gap: 0.25rem;
|
||||
}
|
||||
.summary-count { font-size: 1.8rem; font-weight: 700; color: var(--text); }
|
||||
.summary-label { font-size: 0.85rem; color: var(--text-light); }
|
||||
.bucket { margin-bottom: 1.25rem; }
|
||||
.bucket-title { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.75rem; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
||||
.asset-chip {
|
||||
display: inline-block; padding: 0.15rem 0.55rem; margin: 0 0.25rem 0.25rem 0;
|
||||
background: var(--bg); border-radius: 12px; font-size: 0.8rem; text-decoration: none; color: var(--text);
|
||||
}
|
||||
.muted { color: var(--text-light); }
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.servicelevel-cell { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user