Catalog seeding, blank-unique fix, slides rotation, console repair
This commit is contained in:
@@ -205,30 +205,41 @@
|
||||
<!-- Map Position (optional) -->
|
||||
<fieldset>
|
||||
<legend>Map Position (Optional)</legend>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="mapx">Map X Position</label>
|
||||
<input
|
||||
id="mapx"
|
||||
v-model.number="form.mapx"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mapy">Map Y Position</label>
|
||||
<input
|
||||
id="mapy"
|
||||
v-model.number="form.mapy"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="0"
|
||||
/>
|
||||
<!--
|
||||
Picked on the floor plan, the same way machines, PCs and printers do
|
||||
it. This form used to ask for the X and Y as raw numbers, which meant
|
||||
reading coordinates off another screen and typing them in.
|
||||
-->
|
||||
<div class="form-group">
|
||||
<label>Map Location</label>
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
Set Location on Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Map Picker Modal -->
|
||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||
<div class="map-modal-content">
|
||||
<ShopFloorMap
|
||||
:pickerMode="true"
|
||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||
:theme="currentTheme"
|
||||
@positionPicked="handlePositionPicked"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="showMapPicker = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="confirmMapPosition">Confirm Location</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Notes -->
|
||||
<fieldset>
|
||||
<legend>Notes</legend>
|
||||
@@ -274,11 +285,16 @@ import {
|
||||
businessunitsApi
|
||||
} from '@/api'
|
||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const showMapPicker = ref(false)
|
||||
const tempMapPosition = ref(null)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -483,6 +499,24 @@ function cancel() {
|
||||
router.push('/network')
|
||||
}
|
||||
}
|
||||
function handlePositionPicked(position) {
|
||||
tempMapPosition.value = position
|
||||
}
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapx = tempMapPosition.value.left
|
||||
form.value.mapy = tempMapPosition.value.top
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
|
||||
function clearMapPosition() {
|
||||
form.value.mapx = null
|
||||
form.value.mapy = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<!-- The tag prefix is shown as a fixed addon so operators type only
|
||||
the number part off the label. -->
|
||||
<div class="entry-input-group">
|
||||
<span class="entry-prefix">{{ LABEL_PREFIX }}</span>
|
||||
<span v-if="LABEL_PREFIX" class="entry-prefix">{{ LABEL_PREFIX }}</span>
|
||||
<input ref="entryInput" v-model="manualCode" class="entry-input has-prefix"
|
||||
inputmode="none" autocomplete="off" placeholder="number"
|
||||
@keydown.enter.prevent="lookupItem(manualCode)" />
|
||||
@@ -92,13 +92,16 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { printedpartsApi } from '@/api'
|
||||
import { printedpartsApi, settingsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import TouchKeypad from '@/components/TouchKeypad.vue'
|
||||
|
||||
// Fixed tag prefix shown before the label number at the kiosk. Change here if
|
||||
// the gage lab tags use a different leading string.
|
||||
const LABEL_PREFIX = 'WJ'
|
||||
// Leading text on the physical labels, shown before the number box so operators
|
||||
// type only the digits. It was hardcoded to 'WJ', which is West Jefferson's tag
|
||||
// format and nobody else's - on another site's kiosk it told operators to expect
|
||||
// letters that are not on their labels. Display only: the lookup sends whatever
|
||||
// was typed, unprefixed.
|
||||
const LABEL_PREFIX = ref('')
|
||||
|
||||
const step = ref('item')
|
||||
const item = ref(null)
|
||||
@@ -113,7 +116,20 @@ const scannedRevision = ref(null)
|
||||
const entryInput = ref(null)
|
||||
let resetTimer = null
|
||||
|
||||
onMounted(focusEntry)
|
||||
onMounted(async () => {
|
||||
// A kiosk left running for weeks reads this once at load, which is fine: the
|
||||
// label format is a property of the site's physical tags, not something that
|
||||
// changes during a shift.
|
||||
try {
|
||||
const response = await settingsApi.get('printedparts_label_prefix')
|
||||
LABEL_PREFIX.value = (response.data?.data?.value || '').trim()
|
||||
} catch (err) {
|
||||
// Never block the kiosk over a cosmetic hint. An unreachable or unseeded
|
||||
// setting simply means no prefix is shown.
|
||||
LABEL_PREFIX.value = ''
|
||||
}
|
||||
focusEntry()
|
||||
})
|
||||
onBeforeUnmount(() => clearTimeout(resetTimer))
|
||||
|
||||
function focusEntry(event) {
|
||||
|
||||
@@ -18,6 +18,17 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Kiosk label prefix</label>
|
||||
<input v-model="values.printedparts_label_prefix" type="text"
|
||||
class="form-control" maxlength="8" placeholder="none" />
|
||||
<p class="field-hint">
|
||||
Leading text on this site's physical labels, shown at the kiosk before the
|
||||
number box so operators type only the digits. Leave it empty to show no
|
||||
prefix. This is about the printed tags, not the item codes above.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Default low-stock threshold</label>
|
||||
<input v-model.number="values.printedparts_default_threshold"
|
||||
@@ -103,6 +114,7 @@ import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
||||
|
||||
const KEYS = [
|
||||
'printedparts_code_prefix',
|
||||
'printedparts_label_prefix',
|
||||
'printedparts_default_threshold',
|
||||
'printedparts_unknown_badge',
|
||||
'printedparts_alert_email',
|
||||
@@ -113,6 +125,7 @@ const KEYS = [
|
||||
|
||||
const values = ref({
|
||||
printedparts_code_prefix: '3DP',
|
||||
printedparts_label_prefix: '',
|
||||
printedparts_default_threshold: 5,
|
||||
printedparts_unknown_badge: 'deny',
|
||||
printedparts_alert_email: '',
|
||||
|
||||
@@ -126,6 +126,10 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
defaults = [
|
||||
('printedparts_code_prefix', '3DP', 'string',
|
||||
'Prefix for generated item codes'),
|
||||
('printedparts_label_prefix', '', 'string',
|
||||
'Leading text on the physical gage-lab labels, shown at the kiosk '
|
||||
'before the number box so operators type only the digits. Empty '
|
||||
'shows no prefix. Site-specific: West Jefferson labels read WJ'),
|
||||
('printedparts_default_threshold', '5', 'integer',
|
||||
'Default low-stock threshold for new items'),
|
||||
('printedparts_unknown_badge', 'deny', 'string',
|
||||
|
||||
@@ -1,191 +1,207 @@
|
||||
<template>
|
||||
<div class="tv-dashboard">
|
||||
<div class="slideshow-container">
|
||||
<div
|
||||
v-for="(slide, idx) in slides"
|
||||
:key="slide.filename"
|
||||
class="slide"
|
||||
:class="{ active: idx === currentSlide }"
|
||||
>
|
||||
<img :src="withBase(basePath + slide.filename)" :alt="slide.filename" />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
<h2>Display Error</h2>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!slides.length && !error" class="error-message">
|
||||
<h2>No Slides</h2>
|
||||
<p>No slides configured for display</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar" :style="progressStyle"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import api from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
// Which slide surface this display renders: 'lobby' (default) or 'shopfloor'
|
||||
// (the screensaver). Set by the route meta (/screensaver) or a ?surface= query.
|
||||
const route = useRoute()
|
||||
const VALID_SURFACES = ['lobby', 'shopfloor']
|
||||
const surface = (VALID_SURFACES.includes(route.meta?.surface) && route.meta.surface)
|
||||
|| (VALID_SURFACES.includes(route.query?.surface) && route.query.surface)
|
||||
|| 'lobby'
|
||||
|
||||
const INTERVAL = 10 // seconds between slides
|
||||
|
||||
const slides = ref([])
|
||||
const basePath = ref('/static/slides/')
|
||||
const currentSlide = ref(0)
|
||||
const error = ref('')
|
||||
const progress = ref(0)
|
||||
|
||||
let slideTimer = null
|
||||
let progressTimer = null
|
||||
let refreshTimer = null
|
||||
|
||||
const progressStyle = computed(() => ({
|
||||
width: `${progress.value}%`,
|
||||
transition: progress.value === 0 ? 'none' : `width ${INTERVAL}s linear`
|
||||
}))
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSlides()
|
||||
|
||||
// Start slideshow if we have slides
|
||||
if (slides.value.length > 1) {
|
||||
startSlideshow()
|
||||
}
|
||||
|
||||
// Refresh slide list every 60 seconds
|
||||
refreshTimer = setInterval(fetchSlides, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (slideTimer) clearTimeout(slideTimer)
|
||||
if (progressTimer) clearTimeout(progressTimer)
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
})
|
||||
|
||||
async function fetchSlides() {
|
||||
try {
|
||||
// Flat feed shape: { success, surface, basepath, interval, slides:[...] }
|
||||
const response = await api.get('/slides/feed', { params: { surface } })
|
||||
const data = response.data
|
||||
|
||||
if (data.success && data.slides?.length > 0) {
|
||||
slides.value = data.slides
|
||||
basePath.value = data.basepath || `/api/slides/img/${surface}/`
|
||||
error.value = ''
|
||||
|
||||
// Restart slideshow if slides changed
|
||||
if (slides.value.length > 1 && !slideTimer) {
|
||||
startSlideshow()
|
||||
}
|
||||
} else {
|
||||
slides.value = []
|
||||
error.value = 'No slides configured'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching slides:', err)
|
||||
error.value = 'Unable to load slides'
|
||||
}
|
||||
}
|
||||
|
||||
function startSlideshow() {
|
||||
scheduleNextSlide()
|
||||
}
|
||||
|
||||
function scheduleNextSlide() {
|
||||
// Reset progress bar
|
||||
progress.value = 0
|
||||
|
||||
// Start progress animation after a small delay
|
||||
progressTimer = setTimeout(() => {
|
||||
progress.value = 100
|
||||
}, 50)
|
||||
|
||||
// Schedule next slide
|
||||
slideTimer = setTimeout(() => {
|
||||
nextSlide()
|
||||
scheduleNextSlide()
|
||||
}, INTERVAL * 1000)
|
||||
}
|
||||
|
||||
function nextSlide() {
|
||||
if (slides.value.length === 0) return
|
||||
currentSlide.value = (currentSlide.value + 1) % slides.value.length
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tv-dashboard {
|
||||
background: #000;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slideshow-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slide {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 1s ease-in-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.slide.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slide img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 4px;
|
||||
background: #4181ff;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.error-message h2 {
|
||||
color: #ff4444;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="tv-dashboard">
|
||||
<div class="slideshow-container">
|
||||
<div
|
||||
v-for="(slide, idx) in slides"
|
||||
:key="slide.filename"
|
||||
class="slide"
|
||||
:class="{ active: idx === currentSlide }"
|
||||
>
|
||||
<img :src="withBase(basePath + slide.filename)" :alt="slide.filename" />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
<h2>Display Error</h2>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!slides.length && !error" class="error-message">
|
||||
<h2>No Slides</h2>
|
||||
<p>No slides configured for display</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar" :style="progressStyle"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import api from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
// Which slide surface this display renders: 'lobby' (default) or 'shopfloor'
|
||||
// (the screensaver). Set by the route meta (/screensaver) or a ?surface= query.
|
||||
const route = useRoute()
|
||||
const VALID_SURFACES = ['lobby', 'shopfloor']
|
||||
const surface = (VALID_SURFACES.includes(route.meta?.surface) && route.meta.surface)
|
||||
|| (VALID_SURFACES.includes(route.query?.surface) && route.query.surface)
|
||||
|| 'lobby'
|
||||
|
||||
const INTERVAL = 10 // seconds between slides
|
||||
|
||||
const slides = ref([])
|
||||
const basePath = ref('/static/slides/')
|
||||
const currentSlide = ref(0)
|
||||
const error = ref('')
|
||||
const progress = ref(0)
|
||||
|
||||
let slideTimer = null
|
||||
let progressTimer = null
|
||||
let refreshTimer = null
|
||||
|
||||
const progressStyle = computed(() => ({
|
||||
width: `${progress.value}%`,
|
||||
// Matches the slide's own duration, or the bar finishes early and sits full.
|
||||
transition: progress.value === 0 ? 'none' : `width ${currentSlideSeconds()}s linear`
|
||||
}))
|
||||
|
||||
onMounted(async () => {
|
||||
// fetchSlides starts the slideshow itself once it has more than one slide.
|
||||
// Starting it again here ran TWO timer chains at once, and the second
|
||||
// assignment to slideTimer lost the handle to the first so nothing could stop
|
||||
// it. Both chains called nextSlide a few milliseconds apart, so with exactly
|
||||
// two slides the display advanced 0 -> 1 -> 0 every cycle and looked frozen.
|
||||
await fetchSlides()
|
||||
|
||||
// Refresh slide list every 60 seconds
|
||||
refreshTimer = setInterval(fetchSlides, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (slideTimer) clearTimeout(slideTimer)
|
||||
if (progressTimer) clearTimeout(progressTimer)
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
})
|
||||
|
||||
async function fetchSlides() {
|
||||
try {
|
||||
// Flat feed shape: { success, surface, basepath, interval, slides:[...] }
|
||||
const response = await api.get('/slides/feed', { params: { surface } })
|
||||
const data = response.data
|
||||
|
||||
if (data.success && data.slides?.length > 0) {
|
||||
slides.value = data.slides
|
||||
basePath.value = data.basepath || `/api/slides/img/${surface}/`
|
||||
error.value = ''
|
||||
|
||||
// Restart slideshow if slides changed
|
||||
if (slides.value.length > 1 && !slideTimer) {
|
||||
startSlideshow()
|
||||
}
|
||||
} else {
|
||||
slides.value = []
|
||||
error.value = 'No slides configured'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching slides:', err)
|
||||
error.value = 'Unable to load slides'
|
||||
}
|
||||
}
|
||||
|
||||
function startSlideshow() {
|
||||
scheduleNextSlide()
|
||||
}
|
||||
|
||||
function scheduleNextSlide() {
|
||||
// Cancel anything already pending. Belt and braces after the double-start
|
||||
// bug above: any future caller that schedules twice now replaces the timer
|
||||
// rather than leaking an untracked one.
|
||||
if (slideTimer) { clearTimeout(slideTimer); slideTimer = null }
|
||||
if (progressTimer) { clearTimeout(progressTimer); progressTimer = null }
|
||||
|
||||
// Reset progress bar
|
||||
progress.value = 0
|
||||
|
||||
// Start progress animation after a small delay
|
||||
progressTimer = setTimeout(() => {
|
||||
progress.value = 100
|
||||
}, 50)
|
||||
|
||||
// The feed sends a per-slide duration and this used to ignore it, so a slide
|
||||
// set to hold for a minute still changed after ten seconds.
|
||||
const seconds = currentSlideSeconds()
|
||||
|
||||
slideTimer = setTimeout(() => {
|
||||
nextSlide()
|
||||
scheduleNextSlide()
|
||||
}, seconds * 1000)
|
||||
}
|
||||
|
||||
function currentSlideSeconds() {
|
||||
const slide = slides.value[currentSlide.value]
|
||||
const seconds = Number(slide && slide.seconds)
|
||||
return seconds > 0 ? seconds : INTERVAL
|
||||
}
|
||||
|
||||
function nextSlide() {
|
||||
if (slides.value.length === 0) return
|
||||
currentSlide.value = (currentSlide.value + 1) % slides.value.length
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tv-dashboard {
|
||||
background: #000;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slideshow-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slide {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 1s ease-in-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.slide.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slide img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 4px;
|
||||
background: #4181ff;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.error-message h2 {
|
||||
color: #ff4444;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user