Files
shopdb-flask/frontend/src/views/settings/ImportSiteAssets.vue
cproudlock 75627d4398 Close the review findings on the policy routes, installer and import
Applied on top of af13337 so nothing already on wilm is rewritten.

AUTHORIZATION. The six new policy GETs were @jwt_required(optional=True) with
no permission at all, so an anonymous caller could list configuration and
business policies - gated only by a feature flag that 404s while off. They now
require computers.view. The six policy write routes stacked @jwt_required()
above @require_permission, the same pattern removed from nine endpoints routes
in this very push: the outer decorator answers an anonymous request 401 before
require_permission can consult the guest role, so a grant to guests silently
does nothing. Removed, and the now-unused import with them.

tests/test_core/test_public_read_surface.py failed at af13337 with seven ungated
GETs. It passes now. The seventh, endpoints.claim_next_command, is genuinely
agent-facing - authenticated in the handler by the endpoints collector key,
because a bay has no user session - so it joins PUBLIC_ENDPOINTS with its reason
written down, exactly as report_command_result already does in test_authz.py.
Note which half is public: claiming a queued command is the agent's business,
while ENQUEUEING one is the real authority and stays gated on endpoints.command.

INSTALLER. -BaseUrl and -CollectorKey are now mandatory with no defaults, and
https is enforced unless -AllowInsecureHttp is passed for a local test box. The
defaults were a real hostname (ADR-015) and a live collector key: a key in
source is a key in git history, and that key is also the command channel's kill
switch. Removing it here does not undo the disclosure - it must be rotated on
the server, and purged from af13337 - but it stops the next clone carrying it.

IMPORT. A committed import wrote thousands of rows and left no audit trail; it
now writes one AuditLog row inside the same transaction, so it cannot survive a
rollback or go missing from a commit. The computer-type dropdown asked for
per_page: 200, which the API ignores in favour of perpage and clamps to 100
anyway - it uses a new computersApi.types.listAll(), the pattern already applied
to the other selectors. And the comment claiming the template example rows are
"emitted as commented rows the engine skips" was the opposite of the truth: they
import as live data, the emitted instructions say so, and a test depends on it.

Not fixed here, deliberately: rotating the leaked key, purging it from history,
and the endpoint agent's own hardening (per-host credentials, a protected
process denylist so stop cannot kill security tooling, signing before the
PowerShell runner is enabled, registry ACLs, and the 25-second long poll that
will pin a worker thread per bay).
2026-08-28 15:52:15 -04:00

967 lines
29 KiB
Vue

<template>
<div>
<div class="page-header">
<h1>Import Site Assets</h1>
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
</div>
<div class="card">
<p class="lead">
There are several ways to seed this facility's inventory - Machines, PCs,
Applications, and their properties - into ShopDB. You can combine them:
each source writes to the same set of importable CSVs, and a later run
fills gaps without wiping data an earlier run captured.
</p>
<div class="methods">
<div class="method is-primary">
<div class="method-head">
<span class="badge">Recommended</span>
<h3>Asset Collector PowerShell script</h3>
</div>
<p>
Deploy <code>Collect-ShopDB.ps1</code> to your existing endpoints in a
way of your choosing (logon script, scheduled task, remote execution,
or a manual run). It runs read-only, extracts each PC and any attached
eDNC/DNC machine, and extrapolates vendor, model, OS, network, and
installed-application details. Every run UPSERTs rows into the shared
CSV store below, so a site builds its inventory one PC at a time.
</p>
</div>
<div class="method">
<div class="method-head">
<h3>Existing databases</h3>
</div>
<p>
Export from an existing MES, CMDB, or asset spreadsheet into the same
CSV schema and drop the files in the collection location.
</p>
</div>
<div class="method">
<div class="method-head">
<h3>Intune / Azure exports</h3>
</div>
<p>
Map a device or application export (for example from Microsoft Graph)
onto the computers and applications CSVs, then place them alongside the
script output.
</p>
</div>
</div>
</div>
<div class="card">
<h3 class="section-title">Asset Collector Script</h3>
<p class="muted">
Download <code>Collect-ShopDB.ps1</code> and deploy it to your endpoints
(logon script, scheduled task, remote execution, or a manual run). The CSV
Collections Location you save below is baked into the script's
<code>$NetworkStoreRoot</code> variable, so every endpoint publishes into
the same store without editing PowerShell by hand.
</p>
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<label class="field">
<span>CSV Collections Location</span>
<input
v-model="location"
type="text"
placeholder="\\server.fqdn\share\folder"
/>
<small class="muted">
UNC path or folder where the script deposits its CSVs (e.g.
\\server.fqdn\share\folder). Saved here and embedded into the script
download.
</small>
</label>
<div class="actions">
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save Location' }}
</button>
<button class="btn btn-secondary" :disabled="downloadingScript" @click="downloadScript">
{{ downloadingScript ? 'Preparing...' : 'Download Script' }}
</button>
<span v-if="saved" class="muted">Saved.</span>
<span v-if="error" class="error">{{ error }}</span>
</div>
</template>
</div>
<div class="card">
<h3 class="section-title">Import CSVs</h3>
<p class="muted">
Select one or more filled-in CSVs. They are ordered by dependency
automatically, so you can pick the whole set at once. Preview first: it
writes nothing and reports exactly what each file would create or update,
plus any rows that need fixing. Nothing is imported until you press
Commit, and the whole set is applied together - if any row has a problem,
nothing is written.
</p>
<label class="field">
<span>Source format</span>
<div class="source-cards">
<button
v-for="s in IMPORT_SOURCES"
:key="s.key"
type="button"
:class="['source-card', { active: importSource === s.key }]"
@click="selectSource(s.key)"
>
<span class="source-card-title">{{ s.label }}</span>
<span class="source-card-desc">{{ s.description }}</span>
</button>
</div>
<small class="muted">
ShopDB native expects the canonical per-table CSVs below. Azure and
Thor accept those tools' own exports and convert them for you.
</small>
</label>
<div v-if="importSource === 'azure'" class="source-help">
<p class="muted">
An Azure device-group export lists device hostnames plus their Entra
deviceId. The device displayName is matched to an existing PC by
hostname (case-insensitive): a match updates that PC in place and
stores its deviceId, object id, and category; a device with no
matching hostname creates a new computer (assettype computer, status
In Use). It carries no model, OS, location, or user data - enrich
those later with the collector or a Thor import.
</p>
<label class="field">
<span>Assign computer type (optional)</span>
<select v-model="azureComputerType">
<option value="">No type</option>
<option v-for="t in computerTypes" :key="t.computertypeid" :value="t.computertype">
{{ t.computertype }}
</option>
<option value="__custom__">Custom (add a new type)...</option>
</select>
<small class="muted">
Stamped onto every device in this file. For example import the SHP
group with "Shopfloor PC" and the BUS group with "Inspection PC".
Picking from the list prevents typos labelling hundreds of machines.
</small>
</label>
<label v-if="azureComputerType === '__custom__'" class="field">
<span>New computer type name</span>
<div class="custom-type-row">
<input
v-model="customComputerType"
type="text"
placeholder="e.g. Inspection PC"
/>
<button
class="btn btn-secondary btn-sm"
:disabled="!customComputerType.trim() || addingType"
@click="addComputerType"
>
{{ addingType ? 'Adding...' : 'Add type' }}
</button>
</div>
<small v-if="customTypeError" class="error">{{ customTypeError }}</small>
<small v-else class="muted">
Adds the type to your settings now and selects it for this import.
</small>
</label>
<label class="field">
<span>Azure device category (optional)</span>
<input
v-model="azureDeviceCategory"
type="text"
placeholder="e.g. SFLD_DYN_Device_Group_<Site>_BUS_NC_COUNTRY:USA"
/>
<small class="muted">
The Entra group/category these devices belong to. Stored on every
imported device and shown under Entra Device Info on the PC card.
</small>
</label>
<details class="instructions">
<summary>How to export group members from Azure</summary>
<ol>
<li>Open the Microsoft Entra admin center (entra.microsoft.com).</li>
<li>Go to Groups, then open the device group you want to import.</li>
<li>Select Members in the left menu.</li>
<li>In the top command bar, click Bulk operations-&gt;Download members to export the CSV.</li>
<li>Upload that CSV here with the source set to Azure device group.</li>
</ol>
</details>
</div>
<div v-else-if="importSource === 'thor'" class="source-help">
<p class="muted">
A Thor PC-inventory export is a wide, fixed-format dump. This maps the
hostname, BIOS manufacturer, model, OS, logged-in user, last-reported
and boot times, and location into ShopDB. Each host is matched to an
existing PC by hostname (case-insensitive): a match updates that PC in
place; a host with no matching hostname creates a new computer.
Network details (MAC, switch, VLAN) and the machine relationship are
not imported in this pass.
</p>
</div>
<div class="import-controls">
<input
ref="fileInput"
type="file"
accept=".csv"
multiple
@change="onFilesPicked"
/>
<div class="actions">
<button
class="btn btn-secondary"
:disabled="!selectedFiles.length || previewing || committing"
@click="preview"
>
{{ previewing ? 'Checking...' : 'Preview (dry run)' }}
</button>
<button
class="btn btn-primary"
:disabled="!canCommit"
@click="commit"
>
{{ committing ? 'Importing...' : 'Commit Import' }}
</button>
<button
v-if="selectedFiles.length"
class="btn btn-secondary btn-sm"
:disabled="previewing || committing"
@click="clearSelection"
>
Clear
</button>
</div>
</div>
<p v-if="selectedFiles.length" class="muted">
{{ selectedFiles.length }} file(s) selected:
{{ selectedFiles.map(f => f.name).join(', ') }}
</p>
<p v-if="importError" class="error">{{ importError }}</p>
<div v-if="report" class="report">
<p v-if="report.request_error" class="error">
{{ report.request_error }}
</p>
<p
v-else
:class="['report-banner', report.committed ? 'ok' : (report.problem_count ? 'warn' : 'info')]"
>
<template v-if="report.committed">
Imported: {{ report.total_created }} created,
{{ report.total_updated }} updated.
</template>
<template v-else-if="report.problem_count">
{{ report.problem_count }} problem(s) found - nothing was written.
Fix the rows below and preview again.
</template>
<template v-else>
Looks good: {{ report.total_created }} would be created,
{{ report.total_updated }} updated. Press Commit Import to apply.
</template>
</p>
<p v-if="report.reconciled_count" class="report-banner reconcile">
{{ report.reconciled_count }} name(s) reconciled to an existing record
within the similarity threshold ({{ report.reconcile_threshold }}).
Review the matches below before committing.
</p>
<p v-if="report.warning_count" class="report-banner amber">
{{ report.warning_count }} warning(s): these rows still import, but
something needs a look - most often a machine link that could not be
made. Review them below; each says what to do.
</p>
<table v-if="report.files && report.files.length" class="report-table">
<thead>
<tr>
<th>File</th>
<th>Table</th>
<th>Created</th>
<th>Updated</th>
<th>Reconciled</th>
<th>Warnings</th>
<th>Problems</th>
</tr>
</thead>
<tbody>
<tr v-for="f in report.files" :key="f.table">
<td><code>{{ f.filename }}</code></td>
<td>{{ f.table }}</td>
<td>{{ f.created }}</td>
<td>{{ f.updated }}</td>
<td :class="{ 'has-reconcile': (f.reconciliations || []).length }">
{{ (f.reconciliations || []).length }}
</td>
<td :class="{ 'has-warnings': (f.warnings || []).length }">
{{ (f.warnings || []).length }}
</td>
<td :class="{ 'has-problems': f.problems.length }">
{{ f.problems.length }}
</td>
</tr>
</tbody>
</table>
<div v-if="allReconciliations.length" class="reconciliations">
<h4>Reconciled matches</h4>
<p class="muted">
These names did not match exactly but were close enough to bind to an
existing record. Confirm each is correct; if any is wrong, add the
exact name to the matching CSV (or lower/raise the reconciliation
threshold in Site settings) and preview again.
</p>
<ul>
<li v-for="(r, i) in allReconciliations" :key="i">
<code>{{ r.filename }}</code>
line {{ r.line }}<template v-if="r.column">, column
<code>{{ r.column }}</code></template>:
"{{ r.input }}" matched <strong>{{ r.matched }}</strong>
({{ r.table }}, {{ Math.round(r.score * 100) }}%)
</li>
</ul>
</div>
<div v-if="allWarnings.length" class="warnings">
<h4>Warnings</h4>
<p class="muted">
Each row below imported, but something could not be completed - most
often a PC's machine link. Nothing here blocks the import; fix what
you can and re-import to complete the links.
</p>
<ul>
<li v-for="(w, i) in allWarnings" :key="i">
<code>{{ w.filename }}</code>
line {{ w.line }}<template v-if="w.column">, column
<code>{{ w.column }}</code></template>:
{{ w.reason }}<template v-if="w.action"> - <em>{{ w.action }}</em></template>
</li>
</ul>
</div>
<div v-if="allProblems.length" class="problems">
<h4>Problems to fix</h4>
<ul>
<li v-for="(p, i) in allProblems" :key="i">
<code>{{ p.filename }}</code>
line {{ p.line }}<template v-if="p.column">, column
<code>{{ p.column }}</code></template>: {{ p.message }}
</li>
</ul>
</div>
<p v-if="report.skipped && report.skipped.length" class="muted warn">
Skipped (not importable): {{ report.skipped.join(', ') }}
</p>
</div>
</div>
<div class="card">
<h3 class="section-title">CSV Import Templates</h3>
<p class="muted">
These import templates are generated from the live ShopDB schema. Each
file has a header row plus a small worked example - commented rows you
can uncomment and edit. The examples are internally consistent across
every file (the same locations, vendors, and types are referenced), so
you can download the full set, keep it as a starter workbook, and fill
in your own assets right away. Import them in the order below - a table
never references something that has not been loaded yet. Foreign keys
accept the referenced row's name or its numeric id, and re-importing a
file updates matching rows instead of duplicating them.
</p>
<p v-if="templatesLoading" class="muted">Loading...</p>
<ol v-else class="templates">
<li v-for="t in templates" :key="t.table">
<div class="template-row">
<code>{{ t.filename }}</code>
<button class="btn btn-secondary btn-sm" @click="downloadTemplate(t)">
Download Template
</button>
</div>
</li>
</ol>
<p v-if="templatesError" class="error">{{ templatesError }}</p>
</div>
<div class="card">
<h3 class="section-title">PC to Machine Relationships</h3>
<p class="muted">
A PC that drives a machine (a CMM PC, a DNC/eDNC host) is linked to that
machine so the two show up connected across ShopDB. You can build that
link two ways. The Asset Collector builds it automatically: the script
reads the four-digit workstation number from the PC it runs on, reports
it, and ShopDB matches that number to a machine's
<code>assetnumber</code>.
</p>
<p class="muted">
Or build it straight from the spreadsheet: put the machine's
<code>assetnumber</code> in the <code>machineassetnumber</code> column on
<code>computers.csv</code>, and the import creates the link for you.
</p>
<p class="muted">
Either way, load your machines first (so their
<code>assetnumber</code> values exist), then the PCs. A machine number
that does not match an existing machine - or one already driven by
another PC - is reported as a warning and skipped, not guessed, so
nothing links to the wrong asset. The PC still imports; fix the number
and re-import to complete the link.
</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { settingsApi, computersApi } from '@/api'
import { apiError } from '../../utils/apiError'
const LOCATION_KEY = 'asset_import_csv_location'
const location = ref('')
const loading = ref(true)
const saving = ref(false)
const saved = ref(false)
const error = ref('')
const downloadingScript = ref(false)
const templates = ref([])
const templatesLoading = ref(false)
const templatesError = ref('')
const fileInput = ref(null)
const selectedFiles = ref([])
const previewing = ref(false)
const committing = ref(false)
const importError = ref('')
const report = ref(null)
// Which format the selected files are in. 'native' means the uploads are
// already canonical per-table CSVs; 'azure' and 'thor' route through a
// server-side adapter that transforms the foreign export first.
const IMPORT_SOURCES = [
{ key: 'native', label: 'ShopDB native', description: 'The canonical per-table CSVs below (assets, computers, vendors, ...).' },
{ key: 'azure', label: 'Azure / Intune device group', description: 'A device-group member export from Entra. Creates PCs by hostname and stores the deviceId.' },
{ key: 'thor', label: 'Thor PC inventory', description: 'A Thor inventory export. Maps hostname, vendor, model, OS, user, and location.' }
]
const importSource = ref('native')
// Optional computer type stamped onto every computer an Azure import creates or
// updates, so a SHP group becomes Shopfloor PCs and a BUS group Inspection PCs.
const azureComputerType = ref('')
const customComputerType = ref('')
const customTypeError = ref('')
const addingType = ref(false)
const computerTypes = ref([])
const azureDeviceCategory = ref('')
// Commit is allowed only after a clean preview of the CURRENT selection: a
// report with no problems and no request-level error that has not already been
// committed.
const canCommit = computed(() => {
if (!report.value || committing.value || previewing.value) return false
if (report.value.committed) return false
if (report.value.request_error) return false
return report.value.problem_count === 0
})
const allProblems = computed(() => {
if (!report.value?.files) return []
const out = []
for (const file of report.value.files) {
for (const problem of file.problems) {
out.push({ filename: file.filename, ...problem })
}
}
return out
})
const allWarnings = computed(() => {
if (!report.value?.files) return []
const out = []
for (const file of report.value.files) {
for (const warning of (file.warnings || [])) {
out.push({ filename: file.filename, ...warning })
}
}
return out
})
const allReconciliations = computed(() => {
if (!report.value?.files) return []
const out = []
for (const file of report.value.files) {
for (const event of (file.reconciliations || [])) {
out.push({ filename: file.filename, ...event })
}
}
return out
})
async function loadLocation() {
loading.value = true
try {
const response = await settingsApi.get(LOCATION_KEY).catch(nullOn404)
location.value = readValue(response) || ''
} catch (err) {
error.value = apiError(err, 'Could not load the CSV location.')
} finally {
loading.value = false
}
}
function nullOn404(err) {
if (err.response?.status === 404) return null
throw err
}
function readValue(response) {
if (!response) return ''
const setting = response.data?.data || response.data
return setting?.value ?? ''
}
async function save() {
saving.value = true
saved.value = false
error.value = ''
try {
await settingsApi.update(LOCATION_KEY, location.value)
saved.value = true
} catch (err) {
error.value = apiError(err, 'Save failed.')
} finally {
saving.value = false
}
}
function saveBlob(data, filename) {
const url = window.URL.createObjectURL(new Blob([data]))
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
}
async function downloadScript() {
downloadingScript.value = true
error.value = ''
try {
const response = await settingsApi.downloadCollectorScript()
saveBlob(response.data, 'Collect-ShopDB.ps1')
} catch (err) {
error.value = apiError(err, 'Script download failed.')
} finally {
downloadingScript.value = false
}
}
async function loadTemplates() {
templatesLoading.value = true
templatesError.value = ''
try {
const response = await settingsApi.listImportTemplates()
const data = response.data?.data || response.data || {}
templates.value = data.tables || []
} catch (err) {
templatesError.value = apiError(err, 'Could not list import templates.')
} finally {
templatesLoading.value = false
}
}
async function downloadTemplate(t) {
templatesError.value = ''
try {
const response = await settingsApi.downloadImportTemplate(t.table)
saveBlob(response.data, t.filename)
} catch (err) {
templatesError.value = apiError(err, 'Template download failed.')
}
}
function onFilesPicked(event) {
selectedFiles.value = Array.from(event.target.files || [])
// A new selection invalidates any previous preview so a stale clean report
// cannot be committed against different files.
report.value = null
importError.value = ''
}
function clearSelection() {
selectedFiles.value = []
report.value = null
importError.value = ''
if (fileInput.value) fileInput.value.value = ''
}
function selectSource(key) {
importSource.value = key
// Switching format invalidates a preview built against the previous adapter.
report.value = null
importError.value = ''
}
async function runImport(commit) {
importError.value = ''
try {
const options = { source: importSource.value }
if (importSource.value === 'azure') {
if (azureComputerType.value && azureComputerType.value !== '__custom__') {
options.computertype = azureComputerType.value.trim()
}
if (azureDeviceCategory.value.trim()) {
options.category = azureDeviceCategory.value.trim()
}
}
const response = await settingsApi.importAssetCsvs(selectedFiles.value, commit, options)
report.value = response.data?.data || response.data || null
} catch (err) {
importError.value = apiError(err, commit ? 'Import failed.' : 'Preview failed.')
report.value = null
}
}
async function preview() {
previewing.value = true
try {
await runImport(false)
} finally {
previewing.value = false
}
}
async function commit() {
committing.value = true
try {
await runImport(true)
} finally {
committing.value = false
}
}
async function addComputerType() {
const name = customComputerType.value.trim()
if (!name) return
customTypeError.value = ''
addingType.value = true
try {
const response = await computersApi.types.create({ computertype: name })
const created = response.data?.data || response.data
await loadComputerTypes()
// Select the freshly added type by its name.
azureComputerType.value = created?.computertype || name
customComputerType.value = ''
} catch (err) {
customTypeError.value = apiError(err, 'Could not add the computer type.')
} finally {
addingType.value = false
}
}
async function loadComputerTypes() {
try {
// listAll returns the rows themselves, already paged through.
computerTypes.value = await computersApi.types.listAll()
} catch {
computerTypes.value = []
}
}
onMounted(async () => {
await loadLocation()
await loadTemplates()
await loadComputerTypes()
})
</script>
<style scoped>
.lead {
margin-top: 0;
color: var(--text);
}
.methods {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-top: 12px;
}
.method {
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
background: var(--bg);
}
.method.is-primary {
border-color: var(--primary);
}
.method-head {
display: flex;
align-items: center;
gap: 8px;
}
.method-head h3 {
margin: 0;
font-size: 1rem;
}
.method p {
margin: 8px 0 0;
color: var(--text-light);
font-size: 0.9rem;
}
.badge {
background: var(--primary);
color: #fff;
border-radius: 10px;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.section-title {
margin: 0 0 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 640px;
margin-top: 12px;
}
.field > span {
font-weight: 600;
}
.field input[type='text'] {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-family: var(--font-mono, monospace);
}
.field select {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.custom-type-row {
display: flex;
gap: 8px;
align-items: center;
}
.custom-type-row input {
flex: 1;
}
.source-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
margin: 4px 0;
}
.source-card {
display: flex;
flex-direction: column;
gap: 4px;
text-align: left;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
cursor: pointer;
}
.source-card:hover {
border-color: var(--primary);
}
.source-card.active {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary) inset;
}
.source-card-title {
font-weight: 600;
}
.source-card-desc {
font-size: 0.85rem;
color: var(--text-light);
}
.source-help {
margin-top: 12px;
border-left: 3px solid var(--border);
padding-left: 12px;
}
.source-help .instructions {
margin-top: 8px;
}
.source-help .instructions summary {
cursor: pointer;
font-weight: 600;
color: var(--text);
}
.source-help .instructions ol {
margin: 8px 0 0;
padding-left: 20px;
color: var(--text-light);
font-size: 0.9rem;
}
.source-help .instructions li {
margin: 4px 0;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.templates {
margin: 8px 0 0;
padding-left: 20px;
}
.templates li {
margin: 6px 0;
}
.template-row {
display: flex;
align-items: center;
gap: 12px;
}
.template-row code {
min-width: 200px;
}
.btn-sm {
padding: 4px 10px;
font-size: 0.8rem;
}
.muted {
color: var(--text-light);
font-size: 0.9rem;
}
.muted.warn {
color: var(--warning, #c47f00);
}
.error {
color: var(--danger);
margin-top: 8px;
}
.import-controls {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.report {
margin-top: 14px;
}
.report-banner {
padding: 8px 12px;
border-radius: 6px;
font-weight: 600;
}
.report-banner.ok {
background: var(--success-bg, rgba(40, 160, 80, 0.15));
color: var(--success, #2e7d32);
}
.report-banner.info {
background: var(--info-bg, rgba(60, 120, 200, 0.15));
color: var(--text);
}
.report-banner.warn {
background: var(--warning-bg, rgba(196, 127, 0, 0.15));
color: var(--warning, #c47f00);
}
.report-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
.report-table th,
.report-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
.report-table td.has-problems {
color: var(--danger);
font-weight: 700;
}
.report-table td.has-reconcile {
color: var(--warning, #c47f00);
font-weight: 700;
}
.report-banner.reconcile {
background: var(--warning-bg, rgba(196, 127, 0, 0.15));
color: var(--warning, #c47f00);
}
.reconciliations {
margin-top: 12px;
}
.reconciliations h4 {
margin: 0 0 6px;
color: var(--warning, #c47f00);
}
.reconciliations ul {
margin: 6px 0 0;
padding-left: 20px;
}
.reconciliations li {
margin: 4px 0;
font-size: 0.9rem;
}
.report-table td.has-warnings {
color: var(--amber, #d18b16);
font-weight: 700;
}
.report-banner.amber {
background: var(--amber-bg, rgba(209, 139, 22, 0.15));
color: var(--amber, #d18b16);
}
.warnings {
margin-top: 12px;
}
.warnings h4 {
margin: 0 0 6px;
color: var(--amber, #d18b16);
}
.warnings ul {
margin: 6px 0 0;
padding-left: 20px;
}
.warnings li {
margin: 4px 0;
font-size: 0.9rem;
}
.warnings li em {
color: var(--text-muted, #9aa4b2);
font-style: normal;
}
.problems {
margin-top: 12px;
}
.problems h4 {
margin: 0 0 6px;
color: var(--danger);
}
.problems ul {
margin: 0;
padding-left: 20px;
}
.problems li {
margin: 4px 0;
font-size: 0.9rem;
}
</style>