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.
1337 lines
41 KiB
Vue
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->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>
|