Import the wilm agent store, WINC adoption work and batches CLI, with four fixes
This commit is contained in:
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