Import the wilm agent store and WINC adoption work, with four fixes
Eleven commits from the sister site: a reversible Maximo/WINC machine-adoption import, a versioned agent binary store with upload and rollback, remote-BuildKit CI, tray version reporting, and UI fixes. Applied as a patch again - the published mirror rewrites hashes, so the branches have unrelated histories and git refuses to merge them. Reviewed by three agents before merging. Four things fixed here. THE PUBLISH WORKFLOW WAS DEAD. The new remote-BuildKit step in publish-images.yml sits at column 0 instead of indented under steps:, so the whole file is invalid YAML and GitHub would refuse it. Their entire GHCR distribution channel - which Start-ShopDBDev.ps1 and the compose files consume - was broken at the source and clearly never run. Indented; all five workflows now parse. THE IMPORT'S APPROVAL CHECK COULD NOT FAIL. commit() built the plan and the approval packet from the same upload and then asked the writer whether they matched, so packet_matches_plan compared a hash against itself. The documented contract - "a human approves a specific, frozen plan, and the writer can prove nothing drifted" - did not exist in the web path: an admin could preview file A and commit file B, gated only by the needs-create group NAMES they ticked. The preview's planhash is now required at commit and checked against a freshly rebuilt plan. Their tests committed without previewing, which is what let this through; they go through the real flow now, and three new tests pin it - all confirmed to fail with the check stubbed out. endpoints.agentpackage QUIETLY GREW. It used to mean "wrap the staged exe in a ZIP"; with the upload routes it also means "choose the executable every future package installs and runs as SYSTEM". Before this, staging needed filesystem access to the server. The permission description still said only "generate and download a package", so an admin granting it could not see what they were granting. Reworded. NOTHING RECORDED WHAT WAS STAGED, OR BY WHOM. The manifest held version, size and timestamp; re-uploading a version replaces the bytes in place under the same label. A sha256 of the stored file is now recorded, and the upload is audit-logged with it - after an incident there was previously no answer to who put that binary on the fleet. Also documented the nine new routes in docs/api-inventory.json (that gate cannot run on the mirror, so it falls to us at promotion time again), regenerated the spec and the project map, and kept our own docs where they conflicted: docs/ is scrubbed from the mirror, so their DEV-BUILD-TEST.md still had the dev-be channel mapping we corrected, and their PLUGIN-HOOKS.md was a new 45-line file rather than our 737-line canonical one. Their journal section is appended to ours. Contract version comes across as 0.30.0 for a genuinely additive surface. Suite 2369, frontend 324, gate green. Scope: product
This commit is contained in:
@@ -1252,6 +1252,42 @@ export const settingsApi = {
|
||||
return api.post('/settings/asset-import/import', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
wincPreview(file) {
|
||||
// Read-only WINC (Maximo-style) machine-adoption analysis. Uploads one CSV
|
||||
// and returns the approval packet (counts, needs-create business units,
|
||||
// unresolved values); writes nothing.
|
||||
const form = new FormData()
|
||||
form.append('files', file)
|
||||
return api.post('/settings/asset-import/winc/preview', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
wincCommit(file, acknowledge = [], planhash = '') {
|
||||
// Apply a WINC plan reversibly. acknowledge is the list of needs-create
|
||||
// group names (e.g. ['businessunit']) the admin confirmed. planhash is the
|
||||
// one the PREVIEW returned - the server rejects a commit without it,
|
||||
// because otherwise "approved" means only that these group names were
|
||||
// ticked, and the plan applied could be a different one.
|
||||
const form = new FormData()
|
||||
form.append('files', file)
|
||||
if (acknowledge.length) {
|
||||
form.append('acknowledge', acknowledge.join(','))
|
||||
}
|
||||
if (planhash) {
|
||||
form.append('planhash', planhash)
|
||||
}
|
||||
return api.post('/settings/asset-import/winc/commit', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
wincBatches() {
|
||||
// Applied/undone WINC import batches, newest first, for the undo list.
|
||||
return api.get('/settings/asset-import/winc/batches')
|
||||
},
|
||||
wincUndo(batchid) {
|
||||
// Reverse an applied WINC import batch by id.
|
||||
return api.post('/settings/asset-import/winc/undo', { batchid })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
187
frontend/src/views/settings/ImportSiteAssets.spec.js
Normal file
187
frontend/src/views/settings/ImportSiteAssets.spec.js
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
// The WINC source is the one import path that is NOT a canonical-CSV adapter:
|
||||
// it previews a machine-adoption plan, gates a commit behind an explicit
|
||||
// business-unit acknowledgment, and exposes an undo list. These specs pin that
|
||||
// gate and the preview/commit/undo wiring, mocking the settings API so no
|
||||
// network or backend is involved.
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
settingsApi: {
|
||||
get: vi.fn().mockRejectedValue({ response: { status: 404 } }),
|
||||
listImportTemplates: vi.fn().mockResolvedValue({ data: { data: { tables: [] } } }),
|
||||
wincPreview: vi.fn(),
|
||||
wincCommit: vi.fn(),
|
||||
wincBatches: vi.fn().mockResolvedValue({ data: { data: { batches: [] } } }),
|
||||
wincUndo: vi.fn().mockResolvedValue({ data: { data: { reverted: 3, conflicts: [] } } }),
|
||||
},
|
||||
computersApi: { types: { listAll: vi.fn().mockResolvedValue([]) } },
|
||||
}))
|
||||
vi.mock('@/utils/apiError', () => ({ apiError: (e) => String(e) }))
|
||||
vi.mock('../../utils/apiError', () => ({ apiError: (e) => String(e) }))
|
||||
|
||||
import { settingsApi } from '@/api'
|
||||
import ImportSiteAssets from './ImportSiteAssets.vue'
|
||||
|
||||
function previewReport(overrides = {}) {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
committed: false,
|
||||
blocking: false,
|
||||
rowcount: 1,
|
||||
summary: { in_scope: 1, skipped_no_existing_machine: 0, ambiguous_match: 0 },
|
||||
needscreate: { businessunit: { 'Large Parts': 1 } },
|
||||
unresolved: {},
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const wrapper = mount(ImportSiteAssets, {
|
||||
global: {
|
||||
stubs: { 'router-link': { template: '<a><slot /></a>' } },
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function selectWinc(wrapper) {
|
||||
// The WINC card is the fourth source-format button.
|
||||
const card = wrapper.findAll('.source-card').find(c => c.text().includes('WINC'))
|
||||
return card.trigger('click')
|
||||
}
|
||||
|
||||
async function pickFile(wrapper) {
|
||||
const input = wrapper.find('.winc-panel input[type="file"]')
|
||||
const file = new File(['assetnum\nW0083A\n'], 'winc.csv', { type: 'text/csv' })
|
||||
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
|
||||
await input.trigger('change')
|
||||
return file
|
||||
}
|
||||
|
||||
describe('WINC source selection', () => {
|
||||
beforeEach(() => {
|
||||
settingsApi.wincPreview.mockReset()
|
||||
settingsApi.wincCommit.mockReset()
|
||||
})
|
||||
|
||||
it('offers WINC as a fourth source card', async () => {
|
||||
const wrapper = await render()
|
||||
const labels = wrapper.findAll('.source-card').map(c => c.text())
|
||||
expect(labels.some(t => t.includes('WINC'))).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the WINC panel instead of the generic controls when selected', async () => {
|
||||
const wrapper = await render()
|
||||
await selectWinc(wrapper)
|
||||
expect(wrapper.find('.winc-panel').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WINC preview and commit gating', () => {
|
||||
beforeEach(() => {
|
||||
settingsApi.wincPreview.mockReset()
|
||||
settingsApi.wincCommit.mockReset()
|
||||
settingsApi.wincBatches.mockClear()
|
||||
settingsApi.wincBatches.mockResolvedValue({ data: { data: { batches: [] } } })
|
||||
})
|
||||
|
||||
it('previews the uploaded export and lists needs-create business units', async () => {
|
||||
settingsApi.wincPreview.mockResolvedValue(previewReport())
|
||||
const wrapper = await render()
|
||||
await selectWinc(wrapper)
|
||||
await pickFile(wrapper)
|
||||
|
||||
await wrapper.find('.winc-panel .btn-secondary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(settingsApi.wincPreview).toHaveBeenCalledOnce()
|
||||
expect(wrapper.find('.winc-needscreate').text()).toContain('Large Parts')
|
||||
})
|
||||
|
||||
it('blocks commit until the business-unit creation is acknowledged', async () => {
|
||||
settingsApi.wincPreview.mockResolvedValue(previewReport())
|
||||
const wrapper = await render()
|
||||
await selectWinc(wrapper)
|
||||
await pickFile(wrapper)
|
||||
await wrapper.find('.winc-panel .btn-secondary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const commitBtn = wrapper.find('.winc-panel .btn-primary')
|
||||
expect(commitBtn.attributes('disabled')).toBeDefined()
|
||||
|
||||
await wrapper.find('.winc-ack input[type="checkbox"]').setValue(true)
|
||||
expect(commitBtn.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('commits with the businessunit acknowledgment and refreshes the batch list', async () => {
|
||||
settingsApi.wincPreview.mockResolvedValue(previewReport())
|
||||
settingsApi.wincCommit.mockResolvedValue({
|
||||
data: { data: { committed: true, rowcount: 1, batchid: 'b-1', summary: { in_scope: 1, skipped_no_existing_machine: 0, ambiguous_match: 0 }, needscreate: {}, unresolved: {} } },
|
||||
})
|
||||
const wrapper = await render()
|
||||
await selectWinc(wrapper)
|
||||
await pickFile(wrapper)
|
||||
await wrapper.find('.winc-panel .btn-secondary').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.find('.winc-ack input[type="checkbox"]').setValue(true)
|
||||
|
||||
await wrapper.find('.winc-panel .btn-primary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const [file, acknowledge] = settingsApi.wincCommit.mock.calls[0]
|
||||
expect(file.name).toBe('winc.csv')
|
||||
expect(acknowledge).toEqual(['businessunit'])
|
||||
// Batches reloaded once on mount and once after commit.
|
||||
expect(settingsApi.wincBatches).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not require acknowledgment when there is nothing to create', async () => {
|
||||
settingsApi.wincPreview.mockResolvedValue(previewReport({ needscreate: {} }))
|
||||
const wrapper = await render()
|
||||
await selectWinc(wrapper)
|
||||
await pickFile(wrapper)
|
||||
await wrapper.find('.winc-panel .btn-secondary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.winc-ack').exists()).toBe(false)
|
||||
expect(wrapper.find('.winc-panel .btn-primary').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('blocks commit when the plan is blocking', async () => {
|
||||
settingsApi.wincPreview.mockResolvedValue(previewReport({
|
||||
blocking: true, needscreate: {}, unresolved: { location: { 'WM-XYZ': 2 } },
|
||||
}))
|
||||
const wrapper = await render()
|
||||
await selectWinc(wrapper)
|
||||
await pickFile(wrapper)
|
||||
await wrapper.find('.winc-panel .btn-secondary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.winc-panel .btn-primary').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('.problems').text()).toContain('WM-XYZ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WINC undo', () => {
|
||||
it('undoes an applied batch and reloads the list', async () => {
|
||||
settingsApi.wincBatches.mockResolvedValue({
|
||||
data: { data: { batches: [
|
||||
{ batchid: 'b-1', kind: 'winc-machine-adoption', rowcount: 5, createdat: '2026-01-01T00:00:00Z', createdby: 'admin', status: 'applied', undoneat: null },
|
||||
] } },
|
||||
})
|
||||
const wrapper = await render()
|
||||
await selectWinc(wrapper)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.winc-batches .btn-secondary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(settingsApi.wincUndo).toHaveBeenCalledWith('b-1')
|
||||
})
|
||||
})
|
||||
@@ -112,6 +112,15 @@
|
||||
: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>
|
||||
@@ -204,7 +213,166 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="import-controls">
|
||||
<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"
|
||||
@@ -448,11 +616,48 @@ 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', 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.' }
|
||||
{
|
||||
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.
|
||||
@@ -463,6 +668,34 @@ 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.
|
||||
@@ -651,6 +884,82 @@ async function commit() {
|
||||
}
|
||||
}
|
||||
|
||||
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'] : []
|
||||
// The hash from the preview the admin actually looked at. Without it the
|
||||
// server refuses: a commit has to be able to prove it is applying the plan
|
||||
// that was approved, not whatever the file says now.
|
||||
const planhash = wincReport.value?.planhash || ''
|
||||
const response = await settingsApi.wincCommit(wincFile.value, acknowledge,
|
||||
planhash)
|
||||
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
|
||||
@@ -683,6 +992,7 @@ onMounted(async () => {
|
||||
await loadLocation()
|
||||
await loadTemplates()
|
||||
await loadComputerTypes()
|
||||
await loadWincBatches()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -769,27 +1079,63 @@ onMounted(async () => {
|
||||
.source-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 4px 0;
|
||||
gap: 12px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.source-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
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;
|
||||
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;
|
||||
@@ -963,4 +1309,33 @@ onMounted(async () => {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user