Files
shopdb-flask/frontend/src/views/settings/ImportSiteAssets.vue
benjamin.baker 2998e35ddf Add reversible Maximo/WINC machine-adoption import
Machine data arrives as a Maximo asset export (validated against the WINC
site) that must update existing machines in place, not create canonical
per-table rows, so it does not fit the ShopDB-native/Azure/Thor CSV adapter
path. Bulk edits to live machines also need an escape hatch, so the whole
commit is journaled and can be undone as one batch.

Add a dedicated dry-run analyzer, approval packet, resolver/suggest helpers,
and a reversible writer/undo backed by a new importbatches/importjournalentries
schema (with migration). Expose preview/commit/batches/undo settings routes and
a matching frontend source card and panel, and surface the batch/journal
contract through shopdb.api for plugins.
2026-09-03 05:04:22 -04:00

1337 lines
41 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-head">
<svg class="source-card-icon" viewBox="0 0 24 24" aria-hidden="true">
<path :d="SOURCE_ICONS[s.icon]" fill="currentColor" />
</svg>
<span class="source-card-tag">{{ s.tag }}</span>
<svg v-if="importSource === s.key" class="source-card-check" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 16.2 4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z" fill="currentColor" />
</svg>
</span>
<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 v-if="importSource === 'winc'" class="winc-panel">
<p class="muted">
A WINC (Maximo-style) asset export adopts existing machines: it never
creates an asset, only updates a machine that already exists, matched
on its workstation number. It sets the machine's name, location, and
business unit. Preview first - it writes nothing and reports what would
change, which business units it would create (you must confirm), and
any values it cannot resolve (which block the import). Every commit is
journalled, so it can be undone from the list below.
</p>
<div class="import-controls">
<input
ref="wincFileInput"
type="file"
accept=".csv"
@change="onWincFilePicked"
/>
<div class="actions">
<button
class="btn btn-secondary"
:disabled="!wincFile || wincPreviewing || wincCommitting"
@click="wincPreview"
>
{{ wincPreviewing ? 'Checking...' : 'Preview (dry run)' }}
</button>
<button
class="btn btn-primary"
:disabled="!wincCanCommit"
@click="wincCommit"
>
{{ wincCommitting ? 'Importing...' : 'Commit Import' }}
</button>
<button
v-if="wincFile"
class="btn btn-secondary btn-sm"
:disabled="wincPreviewing || wincCommitting"
@click="clearWincSelection"
>
Clear
</button>
</div>
</div>
<p v-if="wincFile" class="muted">Selected: {{ wincFile.name }}</p>
<p v-if="wincError" class="error">{{ wincError }}</p>
<div v-if="wincReport" class="report">
<p
:class="['report-banner', wincReport.committed ? 'ok' : (wincReport.blocking ? 'warn' : 'info')]"
>
<template v-if="wincReport.committed">
Imported: {{ wincReport.rowcount }} machine(s) updated. Undo it
below if needed.
</template>
<template v-else-if="wincReport.blocking">
{{ wincReport.rowcount }} row(s) in scope, but unresolved values
block the import - nothing was written. Resolve them and preview
again.
</template>
<template v-else>
Looks good: {{ wincReport.rowcount }} machine(s) would be updated.
Review below, then Commit Import.
</template>
</p>
<table class="report-table">
<tbody>
<tr>
<td>In scope (existing machines matched)</td>
<td>{{ wincReport.summary.in_scope }}</td>
</tr>
<tr>
<td>Skipped (no existing machine)</td>
<td>{{ wincReport.summary.skipped_no_existing_machine }}</td>
</tr>
<tr v-if="wincReport.summary.ambiguous_match">
<td>Ambiguous (skipped, not guessed)</td>
<td>{{ wincReport.summary.ambiguous_match }}</td>
</tr>
</tbody>
</table>
<div v-if="wincNeedsBusinessUnit" class="winc-needscreate">
<h4>New business units to be created</h4>
<p class="muted">
These business units are referenced by the export but do not exist
yet. Confirm they are correct - you can edit them later in
Settings. Nothing is created until you acknowledge and commit.
</p>
<ul>
<li v-for="(count, name) in wincReport.needscreate.businessunit" :key="name">
<strong>{{ name }}</strong> ({{ count }} machine(s))
</li>
</ul>
<label class="winc-ack" v-if="!wincReport.committed">
<input type="checkbox" v-model="wincAckBusinessUnit" />
I confirm these business units should be created.
</label>
</div>
<div v-if="wincReport.unresolved && Object.keys(wincReport.unresolved).length" class="problems">
<h4>Unresolved values (blocking)</h4>
<p class="muted">
These export values have no mapping yet, so the import will not
touch the affected rows. An administrator must add an alias for
each before this import can run.
</p>
<ul>
<li v-for="(items, group) in wincReport.unresolved" :key="group">
<strong>{{ group }}:</strong>
<span v-for="(count, value) in items" :key="value">
"{{ value }}" ({{ count }}){{ ' ' }}
</span>
</li>
</ul>
</div>
</div>
<div v-if="wincBatches.length" class="winc-batches">
<h4>Import history</h4>
<p class="muted">
Each committed WINC import can be undone. Undo reverses only cells
this import set that have not been changed since, and removes only
business units this import created.
</p>
<table class="report-table">
<thead>
<tr>
<th>When</th>
<th>By</th>
<th>Rows</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="b in wincBatches" :key="b.batchid">
<td>{{ b.createdat }}</td>
<td>{{ b.createdby || '-' }}</td>
<td>{{ b.rowcount }}</td>
<td>{{ b.status }}</td>
<td>
<button
v-if="b.status === 'applied'"
class="btn btn-secondary btn-sm"
:disabled="wincUndoingId === b.batchid"
@click="undoWincBatch(b.batchid)"
>
{{ wincUndoingId === b.batchid ? 'Undoing...' : 'Undo' }}
</button>
<span v-else class="muted">undone</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="importSource !== 'winc'" 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.
// Each source carries a short tag (the kind of thing it writes) and an icon key
// so the picker reads at a glance. Icons are inline SVG paths (24x24 viewBox),
// kept ASCII-only per the house style - no emoji or icon-font glyphs.
const IMPORT_SOURCES = [
{
key: 'native',
label: 'ShopDB native',
tag: 'All tables',
icon: 'table',
description: 'The canonical per-table CSVs below (assets, computers, vendors, ...).'
},
{
key: 'azure',
label: 'Azure / Intune device group',
tag: 'Computers',
icon: 'cloud',
description: 'A device-group member export from Entra. Creates PCs by hostname and stores the deviceId.'
},
{
key: 'thor',
label: 'Thor PC inventory',
tag: 'Computers',
icon: 'desktop',
description: 'A Thor inventory export. Maps hostname, vendor, model, OS, user, and location.'
},
{
key: 'winc',
label: 'Maximo Import',
tag: 'Machines',
icon: 'wrench',
description: 'A Maximo asset export (validated against the WINC / West Manufacturing site). Updates existing machines only; reversible, with an approval step.'
}
]
// Inline 24x24 SVG path data keyed by IMPORT_SOURCES[].icon. Single-color paths
// that inherit currentColor, so they tint with the active/inactive card state.
const SOURCE_ICONS = {
table: 'M3 5h18v14H3V5zm2 2v3h5V7H5zm7 0v3h7V7h-7zM5 12v5h5v-5H5zm7 0v5h7v-5h-7z',
cloud: 'M6.5 19a4.5 4.5 0 0 1-.5-8.97A6 6 0 0 1 17.66 9.1 4.5 4.5 0 0 1 17.5 19h-11z',
desktop: 'M3 4h18v12H3V4zm2 2v8h14V6H5zm4 12h6v2H9v-2z',
wrench: 'M21.7 6.3a5 5 0 0 1-6.4 6.4l-6.9 6.9a2 2 0 0 1-2.9-2.9l6.9-6.9a5 5 0 0 1 6.4-6.4l-2.8 2.8 1.4 3.5 3.5 1.4 2.8-2.8z'
}
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('')
// WINC (Maximo-style) machine adoption is its own reversible path, not a
// canonical-CSV adapter: one uploaded export, an approval report, an explicit
// acknowledgment before any business unit is created, and an undo list.
const wincFile = ref(null)
const wincFileInput = ref(null)
const wincReport = ref(null)
const wincError = ref('')
const wincPreviewing = ref(false)
const wincCommitting = ref(false)
const wincAckBusinessUnit = ref(false)
const wincBatches = ref([])
const wincUndoingId = ref('')
// A WINC commit is allowed only after a clean, non-blocking preview of the
// current file, with every needs-create group acknowledged.
const wincNeedsBusinessUnit = computed(() =>
!!(wincReport.value && wincReport.value.needscreate
&& wincReport.value.needscreate.businessunit))
const wincCanCommit = computed(() => {
const wincResult = wincReport.value
if (!wincResult || wincCommitting.value || wincPreviewing.value) return false
if (wincResult.committed || wincResult.blocking) return false
if (wincResult.rowcount === 0) return false
if (wincNeedsBusinessUnit.value && !wincAckBusinessUnit.value) return false
return true
})
// 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
}
}
function onWincFilePicked(event) {
wincFile.value = (event.target.files || [])[0] || null
// A new file invalidates the previous report and its acknowledgment.
wincReport.value = null
wincError.value = ''
wincAckBusinessUnit.value = false
}
function clearWincSelection() {
wincFile.value = null
wincReport.value = null
wincError.value = ''
wincAckBusinessUnit.value = false
if (wincFileInput.value) wincFileInput.value.value = ''
}
async function wincPreview() {
if (!wincFile.value) return
wincPreviewing.value = true
wincError.value = ''
wincAckBusinessUnit.value = false
try {
const response = await settingsApi.wincPreview(wincFile.value)
wincReport.value = response.data?.data || response.data || null
} catch (err) {
wincError.value = apiError(err, 'WINC preview failed.')
wincReport.value = null
} finally {
wincPreviewing.value = false
}
}
async function wincCommit() {
if (!wincCanCommit.value) return
wincCommitting.value = true
wincError.value = ''
try {
const acknowledge = wincNeedsBusinessUnit.value ? ['businessunit'] : []
const response = await settingsApi.wincCommit(wincFile.value, acknowledge)
wincReport.value = response.data?.data || response.data || null
await loadWincBatches()
} catch (err) {
wincError.value = apiError(err, 'WINC import failed.')
} finally {
wincCommitting.value = false
}
}
async function loadWincBatches() {
try {
const response = await settingsApi.wincBatches()
const data = response.data?.data || response.data || {}
wincBatches.value = data.batches || []
} catch {
wincBatches.value = []
}
}
async function undoWincBatch(batchid) {
wincUndoingId.value = batchid
wincError.value = ''
try {
await settingsApi.wincUndo(batchid)
await loadWincBatches()
} catch (err) {
wincError.value = apiError(err, 'WINC undo failed.')
} finally {
wincUndoingId.value = ''
}
}
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()
await loadWincBatches()
})
</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: 12px;
margin: 6px 0;
}
.source-card {
display: flex;
flex-direction: column;
gap: 6px;
text-align: left;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg);
color: var(--text);
cursor: pointer;
position: relative;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, background 0.15s ease;
}
.source-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
transform: translateY(-1px);
}
.source-card.active {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary) inset, 0 2px 10px rgba(0, 0, 0, 0.22);
background: color-mix(in srgb, var(--primary) 8%, var(--bg));
}
.source-card-head {
display: flex;
align-items: center;
gap: 8px;
}
.source-card-icon {
width: 22px;
height: 22px;
color: var(--text-light);
flex: none;
}
.source-card.active .source-card-icon {
color: var(--primary);
}
.source-card-tag {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 14%, transparent);
color: var(--primary);
}
.source-card-check {
width: 18px;
height: 18px;
color: var(--primary);
margin-left: auto;
flex: none;
}
.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;
}
.winc-panel {
margin-top: 12px;
}
.winc-needscreate {
margin-top: 12px;
border: 1px solid var(--warning, #d0a44a);
border-radius: 8px;
padding: 12px 14px;
}
.winc-needscreate h4 {
margin: 0 0 6px;
}
.winc-needscreate ul {
margin: 6px 0;
padding-left: 20px;
}
.winc-ack {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 0.9rem;
}
.winc-batches {
margin-top: 16px;
}
.winc-batches h4 {
margin: 0 0 6px;
}
</style>