Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.
What arrives:
endpoints plugin - a ShopDB agent reporting endpoint state, telemetry,
services and interfaces, with a command channel the agent
long-polls. Ingest stays on the generic collector route
(ADR-006); the agent key is a UI-managed single-scope
token whose revocation is an instant kill switch.
computers policies - Configuration and Business Policies (RMM-style), scope
trees validated at write time, actions checked against
the endpoints command allowlist. Feature-gated: every
route 404s while the setting is off, so enabling it can
never half-expose the surface.
asset CSV import - templates, a collector script carrying the site path, and
a validate-then-commit import. A plugin can own a virtual
column plus a post-write row hook, which is how a PC's
machine number becomes a controls link without a
hand-built relationships sheet.
agent/ - the Windows agent and its installer.
Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.
MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).
Review fixes carried on top:
- six policy GETs were jwt_required(optional=True), which authenticates a
caller without checking anything; they require computers.view now, and six
redundant jwt_required decorators are gone
- the agent installer took a default BaseUrl and CollectorKey, so a mistyped
run silently pointed an endpoint at the wrong instance; both are mandatory
and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
- a committing CSV import wrote no audit row
Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.
Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
636 lines
21 KiB
Vue
636 lines
21 KiB
Vue
<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>
|
|
<EmailReportButton v-if="emailtable && canemail"
|
|
:subject="currentReport.name"
|
|
:columns="emailtable.columns" :rows="emailtable.rows" />
|
|
<button class="btn btn-secondary" @click="clearReport">Back to Reports</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="reportError" class="error-message">{{ reportError }}</div>
|
|
|
|
<!-- Server-side filters this report accepts (see reportFilterFields) -->
|
|
<div v-if="activeFilterFields.length" class="filters">
|
|
<select
|
|
v-if="activeFilterFields.includes('period')"
|
|
v-model="filterState.period"
|
|
class="form-control"
|
|
@change="refreshReport"
|
|
>
|
|
<option v-for="option in PERIOD_OPTIONS" :key="option.value" :value="option.value">
|
|
{{ option.label }}
|
|
</option>
|
|
</select>
|
|
<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 -->
|
|
<!--
|
|
Any report that returns {columns, rows} renders as a table. Every core
|
|
report above has a hand-written block; a plugin report had none, so it
|
|
fell through to a raw JSON dump - which is what the printedparts reports
|
|
have been showing since they were added. The shape is already the
|
|
convention on the server side, so honouring it here means a plugin gets
|
|
a real report with no core code at all.
|
|
-->
|
|
<div v-else-if="genericRows" class="report-content">
|
|
<p v-if="reportData.summary" class="report-summary">
|
|
<span v-for="(value, key) in reportData.summary" :key="key" class="summary-figure">
|
|
<strong>{{ value }}</strong> {{ titleCase(key) }}
|
|
</span>
|
|
</p>
|
|
<p v-else class="report-summary">{{ genericRows.length }} row(s)</p>
|
|
<div class="table-scroll">
|
|
<table>
|
|
<thead>
|
|
<tr><th v-for="column in genericColumns" :key="column">{{ titleCase(column) }}</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(row, index) in genericRows" :key="index">
|
|
<td v-for="column in genericColumns" :key="column">{{ row[column] }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p v-if="!genericRows.length" class="report-empty">
|
|
Nothing in this period.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Last resort: a shape nothing here knows. Better visible than blank. -->
|
|
<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 api, { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
|
|
import { apiError } from '@/utils/apiError'
|
|
import EmailReportButton from '@/components/EmailReportButton.vue'
|
|
import { emailTableFrom, titleCase } from '@/utils/reportTable'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const authStore = useAuthStore()
|
|
|
|
// Emailing a report is the reports.export permission, the same one the endpoint
|
|
// enforces. Offering the button to somebody who will only get a 403 is worse
|
|
// than not offering it.
|
|
const canemail = computed(() => authStore.hasPermission('reports.export'))
|
|
|
|
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'],
|
|
// Time-windowed reports. The endpoints already accepted a window; there was
|
|
// simply no way to choose one from the page.
|
|
'usb-audit': ['period'],
|
|
'printedparts-consumption': ['period'],
|
|
'printedparts-by-person': ['period']
|
|
}
|
|
|
|
// Named windows rather than a day count: the question is "this shift" or "this
|
|
// year", and making somebody work out that a year is 365 serves nobody.
|
|
const PERIOD_OPTIONS = [
|
|
{ value: 'day', label: 'Today' },
|
|
{ value: 'month', label: 'Last month' },
|
|
{ value: 'sixmonths', label: 'Last 6 months' },
|
|
{ value: 'year', label: 'Last year' },
|
|
{ value: 'all', label: 'All time' }
|
|
]
|
|
const PERIOD_DAYS = { day: 1, month: 30, sixmonths: 182, year: 365, all: 0 }
|
|
|
|
const filterState = ref({ businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '', period: 'month' })
|
|
const reportError = ref('')
|
|
const filterOptions = ref({ businessunits: [], assettypes: [], locations: [], applications: [] })
|
|
|
|
// A report is "generic" when it returns rows plus the columns to show them in.
|
|
const genericRows = computed(() => {
|
|
const data = reportData.value
|
|
return data && Array.isArray(data.rows) ? data.rows : null
|
|
})
|
|
const genericColumns = computed(() => {
|
|
const data = reportData.value
|
|
if (!data) return []
|
|
if (Array.isArray(data.columns) && data.columns.length) return data.columns
|
|
// No declared columns: take them from the first row so the table still works.
|
|
return data.rows?.length ? Object.keys(data.rows[0]) : []
|
|
})
|
|
|
|
// The table to email, built from whatever the open report returned. Emailing
|
|
// was available on two pages and nowhere else, purely because those two were
|
|
// built as their own routed pages - the endpoint has always taken any
|
|
// {columns, rows}. Lives in its own module so the shapes can be tested.
|
|
const emailtable = computed(() => emailTableFrom(reportData.value))
|
|
|
|
const activeFilterFields = computed(() =>
|
|
currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : []
|
|
)
|
|
|
|
function resetFilters() {
|
|
filterState.value = { businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '', period: 'month' }
|
|
reportError.value = ''
|
|
}
|
|
|
|
// 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) {
|
|
// listAll: 100 is the server-side cap, not a generous limit, and the
|
|
// location list is past it - a report filtered by a late-alphabet
|
|
// location could not be built.
|
|
filterOptions.value.locations = await locationsApi.listAll()
|
|
}
|
|
if (fields.includes('application') && !filterOptions.value.applications.length) {
|
|
// listAll: 100 is the server-side cap, not a generous limit, and the
|
|
// catalogue is past it - a report filtered by a late-alphabet
|
|
// application could not be built.
|
|
filterOptions.value.applications = await applicationsApi.listAll()
|
|
}
|
|
} 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
|
|
if (activeFilterFields.value.includes('period') && state.period) {
|
|
params.period = state.period
|
|
// Sent as well so an endpoint that only knows ?days= still honours it.
|
|
params.days = PERIOD_DAYS[state.period]
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
// 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
|
|
reportError.value = ''
|
|
reportData.value = null
|
|
window.scrollTo(0, 0)
|
|
|
|
const params = filterParams()
|
|
try {
|
|
// Any report that declares an endpoint is fetched from it.
|
|
//
|
|
// This used to be a switch over five hardcoded core ids with
|
|
// `default: console.error('Unknown report')`. A plugin could register a
|
|
// report, have it appear in the catalogue, and then render nothing at all
|
|
// when opened - the three printedparts reports were in exactly that state.
|
|
// The endpoint is already on the contract; using it means a plugin never
|
|
// needs a line of core code to be runnable.
|
|
const url = report.endpoint || `/api/reports/${report.id}`
|
|
const response = await api.get(stripApiPrefix(url), { params })
|
|
reportData.value = response.data.data
|
|
} catch (error) {
|
|
console.error('Error running report:', error)
|
|
reportError.value = apiError(error, 'Could not run this report')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// The api client is already mounted at /api, so an endpoint from the contract
|
|
// (which carries the prefix, because other consumers call it absolutely) has to
|
|
// have it removed rather than doubled.
|
|
function stripApiPrefix (url) {
|
|
return url.startsWith('/api/') ? url.slice(4) : url
|
|
}
|
|
|
|
async function exportCSV() {
|
|
if (!currentReport.value) return
|
|
// Same endpoint the report itself came from - a plugin report used to export
|
|
// from /api/reports/<id>, which does not exist for it.
|
|
//
|
|
// Fetched through the api client, not window.open: a new window carries no
|
|
// Authorization header, so any report behind a real jwt_required (every
|
|
// plugin one) answered the export button with a 401 page. The core reports
|
|
// are jwt-optional, which is the only reason window.open ever appeared to
|
|
// work.
|
|
const url = currentReport.value.endpoint || `/api/reports/${currentReport.value.id}`
|
|
reportError.value = ''
|
|
try {
|
|
const response = await api.get(stripApiPrefix(url), {
|
|
params: { format: 'csv', ...filterParams() },
|
|
responseType: 'blob'
|
|
})
|
|
const link = document.createElement('a')
|
|
link.href = URL.createObjectURL(response.data)
|
|
link.download = `${currentReport.value.id}.csv`
|
|
link.click()
|
|
URL.revokeObjectURL(link.href)
|
|
} catch (error) {
|
|
console.error('Error exporting report:', error)
|
|
reportError.value = apiError(error, 'Could not export this report')
|
|
}
|
|
}
|
|
|
|
function clearReport() {
|
|
// Drop the query param; the watcher clears the panel state.
|
|
router.push({ query: {} })
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.summary-figure { margin-right: 1.25rem; }
|
|
.table-scroll { overflow-x: auto; }
|
|
.report-empty { color: var(--text-light); margin-top: 0.75rem; }
|
|
|
|
.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>
|