The step was called "Starter Data" and offered to "seed the data a new site needs", but seed_starter inserts eight vendor rows and nothing else: no assets, locations, departments or statuses. An operator ran it, saw every dashboard count stay at zero, and reasonably concluded the seed was broken. Rename the step to Reference Data, describe what is actually seeded, and say outright that no assets are created and that an empty dashboard is expected here. Assets arrive later via the import API.
461 lines
20 KiB
Vue
461 lines
20 KiB
Vue
<template>
|
|
<div class="setup-wizard">
|
|
<div class="wizard-card">
|
|
<header class="wizard-head">
|
|
<h1>Set up ShopDB</h1>
|
|
<p class="wizard-sub">A few quick steps to get this site ready.</p>
|
|
<ol class="wizard-steps">
|
|
<li v-for="(s, i) in steps" :key="s.key" :class="{ active: i === step, done: i < step }">
|
|
<span class="step-num">{{ i < step ? '✓' : i + 1 }}</span>
|
|
<span class="step-label">{{ s.label }}</span>
|
|
</li>
|
|
</ol>
|
|
</header>
|
|
|
|
<section class="wizard-body">
|
|
<!-- Site -->
|
|
<div v-if="current.key === 'site'">
|
|
<h2>Site details</h2>
|
|
<p class="hint">Used for the dashboard header and QR / absolute links.</p>
|
|
<div class="form-group">
|
|
<label>Facility name</label>
|
|
<input v-model="form.facility_name" type="text" class="form-control" placeholder="West Jefferson" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Site base URL <span class="hint">(blank = use the browsing origin)</span></label>
|
|
<input v-model="form.site_base_url" type="url" class="form-control" placeholder="https://shopdb.example.net" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Site logo <span class="hint">(optional)</span></label>
|
|
<input type="file" accept="image/*" @change="uploadSiteLogo($event)" :disabled="logoUploading" />
|
|
<img v-if="siteLogo" :src="siteLogo" class="wizard-map-thumb" alt="site logo" />
|
|
<small class="hint">Skip to keep the shipped GE branding. You can change this later under Settings > Branding.</small>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Plugins -->
|
|
<div v-else-if="current.key === 'plugins'">
|
|
<h2>Features</h2>
|
|
<p class="hint">Turn features on or off. Changes to routes take effect after the app restarts.</p>
|
|
<div v-if="plugins.length" class="plugin-list">
|
|
<div v-for="p in plugins" :key="p.name" class="plugin-item">
|
|
<label class="plugin-row">
|
|
<input type="checkbox" :checked="p.enabled" @change="togglePlugin(p, $event.target.checked)" />
|
|
<span class="plugin-name">{{ p.displayname || p.name }}</span>
|
|
<span class="plugin-desc">{{ p.description }}</span>
|
|
</label>
|
|
|
|
<!-- Plugins that provision tables: choose to create them here or
|
|
connect an existing database -->
|
|
<div v-if="p.enabled && p.provisioning_note && p.provisioning_note.mode_setting" class="plugin-mode">
|
|
<label class="mode-opt">
|
|
<input type="radio" value="selfhosted" v-model="pluginModes[p.provisioning_note.mode_setting]" />
|
|
Create the tables here <span class="hint">(recommended)</span>
|
|
</label>
|
|
<label class="mode-opt">
|
|
<input type="radio" value="external" v-model="pluginModes[p.provisioning_note.mode_setting]" />
|
|
Connect to your own database
|
|
</label>
|
|
|
|
<!-- Self-hosted: transparency note -->
|
|
<div v-if="pluginModes[p.provisioning_note.mode_setting] !== 'external'" class="provision-note">
|
|
<p>{{ p.provisioning_note.note }}</p>
|
|
<p class="provision-tables">Creates in shopdb: <code v-for="t in p.provisioning_note.tables" :key="t">{{ t }}</code></p>
|
|
<p v-if="p.provisioning_note.docs" class="provision-docs">Schema: <code>{{ p.provisioning_note.docs }}</code></p>
|
|
</div>
|
|
|
|
<!-- External: connection config -->
|
|
<div v-else class="config-block">
|
|
<div v-for="field in p.config_schema" :key="field.key" class="form-group">
|
|
<label>{{ field.label }}<span v-if="field.secret" class="secret-tag">secret</span></label>
|
|
<input
|
|
:type="field.type === 'password' ? 'password' : (field.type === 'number' ? 'number' : 'text')"
|
|
:placeholder="field.default || ''"
|
|
v-model="pluginConfig[field.key]"
|
|
class="form-control" />
|
|
<small v-if="field.help" class="hint">{{ field.help }}</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p v-else class="hint">No optional plugins found.</p>
|
|
|
|
<!-- Secrets are never stored in the DB; emit .env lines to paste -->
|
|
<div v-if="envLines" class="env-block">
|
|
<p class="hint">Paste these into <code>.env</code> and restart the app (secrets are not stored in the database):</p>
|
|
<pre class="env-pre">{{ envLines }}</pre>
|
|
<button type="button" class="btn btn-secondary btn-sm" @click="copyEnv">Copy</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Floor map -->
|
|
<div v-else-if="current.key === 'map'">
|
|
<h2>Floor map</h2>
|
|
<p class="hint">Upload your facility blueprint (light and dark), and set its native pixel size.</p>
|
|
<div class="form-group">
|
|
<label>Blueprint image (light theme)</label>
|
|
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
|
|
<img v-if="blueprintLight" :src="withBase(blueprintLight)" class="wizard-map-thumb" alt="light blueprint" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Blueprint image (dark theme)</label>
|
|
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
|
|
<img v-if="blueprintDark" :src="withBase(blueprintDark)" class="wizard-map-thumb dark" alt="dark blueprint" />
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Map width (px)</label>
|
|
<input v-model.number="form.map_width" type="number" class="form-control" placeholder="2000" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Map height (px)</label>
|
|
<input v-model.number="form.map_height" type="number" class="form-control" placeholder="1200" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Starter data -->
|
|
<div v-else-if="current.key === 'data'">
|
|
<h2>Reference data</h2>
|
|
<p class="hint">Seed the lookup data a new site needs: statuses, types, permissions, and a short list of common vendors. Both are idempotent - safe to run more than once.</p>
|
|
<p class="hint">This does not create any assets. The site starts empty and assets are loaded afterwards via the import API, so an empty dashboard here is expected.</p>
|
|
<div class="starter-actions">
|
|
<div>
|
|
<button class="btn btn-secondary" :disabled="seeding" @click="seedReference">
|
|
{{ seeding ? 'Working...' : 'Seed core reference data' }}
|
|
</button>
|
|
<p class="hint">Statuses, machine/location/relationship types, permissions, default settings. Run this if you installed without the CLI seed.</p>
|
|
</div>
|
|
<div>
|
|
<button class="btn btn-secondary" :disabled="seeding" @click="seedStarter">
|
|
{{ seeding ? 'Working...' : 'Add common vendors' }}
|
|
</button>
|
|
<p class="hint">Dell, HP, Lenovo, and other common hardware vendors.</p>
|
|
</div>
|
|
</div>
|
|
<p v-if="seedResult" class="seed-result">{{ seedResult }}</p>
|
|
</div>
|
|
|
|
<!-- Finish -->
|
|
<div v-else-if="current.key === 'finish'">
|
|
<h2>All set</h2>
|
|
<p class="hint">You can change any of this later under Settings. Finish to go to the dashboard.</p>
|
|
<div v-if="geenforceEnabled" class="finish-note">
|
|
<strong>GE-Enforce is on.</strong> Two more steps before the fleet
|
|
uses it: create a service token with the geenforce scopes under
|
|
Settings > API Tokens, and set the on-share export root on the
|
|
GE-Enforce page.
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<footer class="wizard-foot">
|
|
<button v-if="step > 0" class="btn btn-secondary" @click="back">Back</button>
|
|
<div class="foot-right">
|
|
<button type="button" class="btn btn-text" @click="skipForNow">Skip for now</button>
|
|
<button v-if="step < steps.length - 1" class="btn btn-primary" :disabled="saving" @click="next">
|
|
{{ saving ? 'Saving...' : 'Next' }}
|
|
</button>
|
|
<button v-else class="btn btn-primary" :disabled="saving" @click="finish">
|
|
{{ saving ? 'Finishing...' : 'Finish' }}
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { withBase } from '../utils/basePath'
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { settingsApi, pluginsApi, setupApi } from '../api'
|
|
import { useToast } from '../composables/toast'
|
|
import { apiError } from '../utils/apiError'
|
|
import { refreshSetupState, setupSkipped } from '../composables/setupState'
|
|
|
|
const router = useRouter()
|
|
const toast = useToast()
|
|
|
|
const steps = [
|
|
{ key: 'site', label: 'Site' },
|
|
{ key: 'plugins', label: 'Features' },
|
|
{ key: 'map', label: 'Floor Map' },
|
|
{ key: 'data', label: 'Reference Data' },
|
|
{ key: 'finish', label: 'Finish' },
|
|
]
|
|
const step = ref(0)
|
|
const current = computed(() => steps[step.value])
|
|
|
|
const form = ref({
|
|
facility_name: '', site_base_url: '',
|
|
map_width: null, map_height: null,
|
|
})
|
|
const plugins = ref([])
|
|
const pluginConfig = ref({}) // flat map: setting/field key -> value
|
|
const pluginModes = ref({}) // mode_setting key -> 'selfhosted' | 'external'
|
|
const saving = ref(false)
|
|
const seeding = ref(false)
|
|
const seedResult = ref('')
|
|
const mapUploading = ref(false)
|
|
const blueprintLight = ref('')
|
|
const blueprintDark = ref('')
|
|
const logoUploading = ref(false)
|
|
const siteLogo = ref('')
|
|
|
|
async function uploadSiteLogo(event) {
|
|
const file = event.target.files[0]
|
|
if (!file) return
|
|
logoUploading.value = true
|
|
try {
|
|
const { data } = await settingsApi.uploadBrandingLogo('site', file)
|
|
siteLogo.value = data?.data?.value || ''
|
|
toast.success('Logo uploaded')
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Upload failed'))
|
|
} finally {
|
|
logoUploading.value = false
|
|
event.target.value = ''
|
|
}
|
|
}
|
|
|
|
async function uploadBlueprint(theme, event) {
|
|
const file = event.target.files[0]
|
|
if (!file) return
|
|
mapUploading.value = true
|
|
try {
|
|
const { data } = await settingsApi.uploadMapBlueprint(theme, file)
|
|
const url = data?.data?.value
|
|
if (theme === 'light') blueprintLight.value = url
|
|
else blueprintDark.value = url
|
|
toast.success('Blueprint uploaded')
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Upload failed'))
|
|
} finally {
|
|
mapUploading.value = false
|
|
event.target.value = ''
|
|
}
|
|
}
|
|
|
|
function modeOf(plugin) {
|
|
const key = plugin.provisioning_note?.mode_setting
|
|
return key ? (pluginModes.value[key] || 'selfhosted') : null
|
|
}
|
|
|
|
// GE-Enforce needs post-setup operational config (service token + share root)
|
|
// the wizard does not collect; the Finish step points there when it is on.
|
|
const geenforceEnabled = computed(() =>
|
|
plugins.value.some(p => p.name === 'geenforce' && p.enabled))
|
|
|
|
// Enabled plugins whose external connection config should be collected right
|
|
// now: those in external mode (or with config but no mode concept).
|
|
const configurablePlugins = computed(() =>
|
|
plugins.value.filter(p => p.enabled && (p.config_schema || []).length &&
|
|
(!p.provisioning_note?.mode_setting || modeOf(p) === 'external')))
|
|
|
|
// .env lines for secret fields that have a value entered.
|
|
const envLines = computed(() => {
|
|
const lines = []
|
|
for (const plugin of configurablePlugins.value) {
|
|
for (const field of plugin.config_schema) {
|
|
const value = pluginConfig.value[field.key]
|
|
if (field.secret && field.envvar && value) {
|
|
lines.push(`${field.envvar}=${value}`)
|
|
}
|
|
}
|
|
}
|
|
return lines.join('\n')
|
|
})
|
|
|
|
// Which settings each step owns, so Next only saves what changed on that step.
|
|
const stepSettings = {
|
|
site: ['facility_name', 'site_base_url'],
|
|
map: ['map_width', 'map_height'],
|
|
}
|
|
|
|
onMounted(async () => {
|
|
// Load current values for the settings the wizard edits.
|
|
const keys = ['facility_name', 'site_base_url', 'map_width', 'map_height']
|
|
for (const key of keys) {
|
|
try {
|
|
const response = await settingsApi.get(key)
|
|
const value = response.data?.data?.value
|
|
if (value !== undefined && value !== null && value !== '') form.value[key] = value
|
|
} catch (err) { /* setting may not exist yet */ }
|
|
}
|
|
try {
|
|
const response = await pluginsApi.list()
|
|
plugins.value = response.data.data?.plugins || response.data.data || []
|
|
} catch (err) { /* ignore */ }
|
|
|
|
// Preload directory mode (defaults self-hosted) + non-secret config fields.
|
|
for (const plugin of plugins.value) {
|
|
const modeKey = plugin.provisioning_note?.mode_setting
|
|
if (modeKey && pluginModes.value[modeKey] === undefined) {
|
|
try {
|
|
const response = await settingsApi.get(modeKey)
|
|
pluginModes.value[modeKey] = response.data?.data?.value || 'selfhosted'
|
|
} catch (err) { pluginModes.value[modeKey] = 'selfhosted' }
|
|
}
|
|
for (const field of (plugin.config_schema || [])) {
|
|
if (field.secret) continue
|
|
try {
|
|
const response = await settingsApi.get(field.key)
|
|
const value = response.data?.data?.value
|
|
if (value !== undefined && value !== null && value !== '') pluginConfig.value[field.key] = value
|
|
} catch (err) { /* not set yet */ }
|
|
}
|
|
}
|
|
})
|
|
|
|
async function saveStep() {
|
|
// Plugins step: save each plugin's directory mode, plus non-secret external
|
|
// connection config (secrets stay in .env).
|
|
if (current.value.key === 'plugins') {
|
|
for (const [modeKey, modeValue] of Object.entries(pluginModes.value)) {
|
|
await settingsApi.update(modeKey, modeValue)
|
|
}
|
|
for (const plugin of configurablePlugins.value) {
|
|
for (const field of plugin.config_schema) {
|
|
if (field.secret) continue
|
|
const value = pluginConfig.value[field.key]
|
|
if (value === undefined || value === null || value === '') continue
|
|
await settingsApi.update(field.key, String(value))
|
|
}
|
|
}
|
|
return
|
|
}
|
|
const keys = stepSettings[current.value.key]
|
|
if (!keys) return
|
|
for (const key of keys) {
|
|
const value = form.value[key]
|
|
if (value === null || value === undefined) continue
|
|
await settingsApi.update(key, String(value))
|
|
}
|
|
}
|
|
|
|
async function copyEnv() {
|
|
try {
|
|
await navigator.clipboard.writeText(envLines.value)
|
|
toast.success('.env lines copied.')
|
|
} catch (err) {
|
|
toast.error('Copy failed - select and copy manually.')
|
|
}
|
|
}
|
|
|
|
async function next() {
|
|
saving.value = true
|
|
try {
|
|
await saveStep()
|
|
step.value++
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Could not save this step'))
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
function back() { if (step.value > 0) step.value-- }
|
|
|
|
function skipForNow() {
|
|
setupSkipped.value = true
|
|
router.push('/')
|
|
}
|
|
|
|
async function togglePlugin(plugin, enabled) {
|
|
try {
|
|
await pluginsApi.setEnabled(plugin.name, enabled)
|
|
plugin.enabled = enabled
|
|
toast.success(`${plugin.name} ${enabled ? 'enabled' : 'disabled'} (restart to apply route changes).`)
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Could not change plugin'))
|
|
}
|
|
}
|
|
|
|
async function seedReference() {
|
|
seeding.value = true
|
|
try {
|
|
const response = await setupApi.seedReference()
|
|
seedResult.value = response.data?.message || 'Reference data seeded.'
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Could not seed reference data'))
|
|
} finally {
|
|
seeding.value = false
|
|
}
|
|
}
|
|
|
|
async function seedStarter() {
|
|
seeding.value = true
|
|
try {
|
|
const response = await setupApi.seedStarter()
|
|
const data = response.data?.data || {}
|
|
seedResult.value = data.addedcount ? `Added ${data.addedcount}: ${data.added.join(', ')}` : 'All common vendors already present.'
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Could not seed starter data'))
|
|
} finally {
|
|
seeding.value = false
|
|
}
|
|
}
|
|
|
|
async function finish() {
|
|
saving.value = true
|
|
try {
|
|
await setupApi.complete()
|
|
await refreshSetupState()
|
|
toast.success('Setup complete.')
|
|
router.push('/')
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Could not finish setup'))
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.setup-wizard { display: flex; justify-content: center; padding: 2rem 1rem; }
|
|
.wizard-card { width: 100%; max-width: 640px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
|
.wizard-head { padding: 1.5rem 1.75rem 1rem; border-bottom: 1px solid var(--border); }
|
|
.wizard-head h1 { margin: 0; }
|
|
.wizard-sub { color: var(--text-light); margin: 0.25rem 0 1rem; }
|
|
.wizard-steps { list-style: none; display: flex; gap: 0.5rem; padding: 0; margin: 0; flex-wrap: wrap; }
|
|
.wizard-steps li { display: flex; align-items: center; gap: 0.4rem; font-size: 0.82rem; color: var(--text-light); }
|
|
.wizard-steps li.active { color: var(--primary); font-weight: 600; }
|
|
.wizard-steps li.done { color: var(--success); }
|
|
.step-num { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; border: 1px solid currentColor; font-size: 0.72rem; }
|
|
.wizard-body { padding: 1.5rem 1.75rem; min-height: 220px; }
|
|
.wizard-body h2 { margin-top: 0; }
|
|
.hint { color: var(--text-light); font-size: 0.88rem; }
|
|
.form-group { margin-bottom: 1rem; }
|
|
.form-group label { display: block; margin-bottom: 0.3rem; font-weight: 500; }
|
|
.form-row { display: flex; gap: 1rem; }
|
|
.form-row .form-group { flex: 1; }
|
|
.plugin-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
|
.plugin-row { display: grid; grid-template-columns: auto auto 1fr; gap: 0.6rem; align-items: baseline; padding: 0.5rem 0.6rem; background: var(--bg); border-radius: 6px; }
|
|
.plugin-name { font-weight: 600; }
|
|
.plugin-desc { color: var(--text-light); font-size: 0.85rem; }
|
|
.plugin-mode { margin: 0.4rem 0 0.2rem 1.9rem; }
|
|
.mode-opt { display: block; font-size: 0.86rem; margin-bottom: 0.3rem; cursor: pointer; }
|
|
.mode-opt input { margin-right: 0.4rem; }
|
|
.plugin-mode .provision-note, .plugin-mode .config-block { margin-left: 0; }
|
|
.provision-note { margin: 0.3rem 0 0.2rem 1.9rem; padding: 0.6rem 0.75rem; background: var(--bg); border-left: 3px solid var(--warning); border-radius: 4px; font-size: 0.82rem; }
|
|
.finish-note { margin: 0.75rem 0 0; padding: 0.6rem 0.75rem; background: var(--bg); border-left: 3px solid var(--primary); border-radius: 4px; font-size: 0.85rem; }
|
|
.provision-note p { margin: 0 0 0.35rem; }
|
|
.provision-note p:last-child { margin-bottom: 0; }
|
|
.provision-tables code, .provision-docs code { background: var(--bg-card); border: 1px solid var(--border); border-radius: 3px; padding: 0 0.3rem; margin-right: 0.3rem; font-size: 0.78rem; }
|
|
.seed-result { margin-top: 0.75rem; color: var(--success); font-size: 0.88rem; }
|
|
.starter-actions { display: flex; flex-direction: column; gap: 1.1rem; }
|
|
.starter-actions .hint { margin: 0.35rem 0 0; }
|
|
.config-block { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
|
.config-title { margin: 0 0 0.75rem; font-size: 1rem; text-transform: capitalize; }
|
|
.secret-tag { margin-left: 0.5rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--warning); border: 1px solid var(--warning); border-radius: 4px; padding: 0 0.3rem; }
|
|
.env-block { margin-top: 1rem; }
|
|
.env-pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem; overflow-x: auto; font-size: 0.82rem; margin: 0.5rem 0; }
|
|
.wizard-map-thumb { display: block; margin-top: 0.5rem; max-height: 90px; border: 1px solid var(--border); border-radius: 4px; background: #fff; }
|
|
.wizard-map-thumb.dark { background: #222; }
|
|
.wizard-foot { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.75rem; border-top: 1px solid var(--border); }
|
|
.foot-right { display: flex; align-items: center; gap: 0.75rem; }
|
|
.btn-text { background: none; border: none; color: var(--text-light); text-decoration: none; }
|
|
</style>
|