Compare commits
3 Commits
7529bc1949
...
0696f16458
| Author | SHA1 | Date | |
|---|---|---|---|
| 0696f16458 | |||
| 979aae426b | |||
| 07c6789e03 |
4
_db.py
4
_db.py
@ -18,9 +18,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from app.db.mariadb import engine, run_select_query # noqa: F401 (kernel DB layer)
|
from app.db.mariadb import engine, run_select_query, cached_select # noqa: F401 (kernel DB layer)
|
||||||
|
|
||||||
__all__ = ["run_select_query", "transaction", "engine"]
|
__all__ = ["run_select_query", "cached_select", "transaction", "engine"]
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|||||||
140
api/routes.py
140
api/routes.py
@ -51,7 +51,7 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from starlette import status
|
from starlette import status
|
||||||
|
|
||||||
from .._db import run_select_query, transaction
|
from .._db import run_select_query, cached_select, transaction
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -96,7 +96,9 @@ def _press_list(plant: str, entry_presses: set[str]) -> list[str]:
|
|||||||
if the registry is unavailable. Numeric press ids sort numerically."""
|
if the registry is unavailable. Numeric press ids sort numerically."""
|
||||||
presses: set[str] = set(entry_presses)
|
presses: set[str] = set(entry_presses)
|
||||||
try:
|
try:
|
||||||
rows = run_select_query(
|
# Reference registry (admin-edited); served from cache so it's off the pool each poll.
|
||||||
|
rows = cached_select(
|
||||||
|
f"trj:presses:{plant}",
|
||||||
"SELECT psPress AS p FROM press_settings.presses WHERE psPlant = :plant",
|
"SELECT psPress AS p FROM press_settings.presses WHERE psPlant = :plant",
|
||||||
{"plant": plant},
|
{"plant": plant},
|
||||||
)
|
)
|
||||||
@ -172,71 +174,77 @@ def _good_parts_map(plant: str, pairs: set[tuple[str, str]]) -> dict[tuple[str,
|
|||||||
return {}
|
return {}
|
||||||
import json
|
import json
|
||||||
|
|
||||||
by_press: dict[str, list[str]] = defaultdict(list)
|
# Batched across ALL presses: 3 grouped queries total (was 3 per press = N+1).
|
||||||
for p, o in pairs:
|
# Semantics preserved from the per-press version — only the query scope changed
|
||||||
by_press[p].append(o)
|
# (grouped by press). On any DB error the whole map degrades to {} (the caller's
|
||||||
|
# _flow_for then treats notYetSent as 0), matching the sibling _temper_agg_map.
|
||||||
|
presses = sorted({p for p, _ in pairs})
|
||||||
|
orders = sorted({o for _, o in pairs})
|
||||||
|
pbinds = {f"p{i}": p for i, p in enumerate(presses)}
|
||||||
|
obinds = {f"o{i}": o for i, o in enumerate(orders)}
|
||||||
|
pin = ", ".join(f":p{i}" for i in range(len(presses)))
|
||||||
|
oin = ", ".join(f":o{i}" for i in range(len(orders)))
|
||||||
|
try:
|
||||||
|
# 1) cavities per (press, order) — one query.
|
||||||
|
cav_by: dict[tuple[str, str], int] = {
|
||||||
|
(str(r["press"]), str(r["o"])): int(r["cav"] or 0)
|
||||||
|
for r in run_select_query(
|
||||||
|
"SELECT aoPress AS press, aoOrdernumber AS o, aoCavities AS cav "
|
||||||
|
"FROM production.active_orders "
|
||||||
|
f"WHERE aoPlant = :plant AND aoPress IN ({pin}) AND aoOrdernumber IN ({oin})",
|
||||||
|
{"plant": plant, **pbinds, **obinds},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if not cav_by:
|
||||||
|
return {} # none of these orders are active → notYetSent stays 0
|
||||||
|
|
||||||
|
# 2) cycle counts grouped by (Press, Joborder) — one scan. Joborder is unfiltered
|
||||||
|
# (a plain order number OR a JSON array), matched to orders in Python below.
|
||||||
|
cyc_by_press: dict[str, list[tuple[Any, int]]] = defaultdict(list)
|
||||||
|
for cr in run_select_query(
|
||||||
|
"SELECT Press AS press, Joborder AS jo, COUNT(*) AS c "
|
||||||
|
f"FROM production.cycles_new WHERE Press IN ({pin}) GROUP BY Press, Joborder",
|
||||||
|
pbinds,
|
||||||
|
):
|
||||||
|
cyc_by_press[str(cr["press"])].append((cr["jo"], int(cr["c"] or 0)))
|
||||||
|
|
||||||
|
# 3) rejects summed per (press, order) — one grouped query.
|
||||||
|
rej_by: dict[tuple[str, str], int] = {
|
||||||
|
(str(rr["press"]), str(rr["o"])): int(rr["total"] or 0)
|
||||||
|
for rr in run_select_query(
|
||||||
|
"SELECT rejPress AS press, rejOrdernumber AS o, "
|
||||||
|
"COALESCE(SUM(rejQuantity),0) AS total FROM production.rejects "
|
||||||
|
f"WHERE rejPress IN ({pin}) AND rejOrdernumber IN ({oin}) "
|
||||||
|
"GROUP BY rejPress, rejOrdernumber",
|
||||||
|
{**pbinds, **obinds},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
except Exception as e: # noqa: BLE001 — whole map degrades to {} (matches _temper_agg_map)
|
||||||
|
logger.warning("good-parts derivation failed: %s", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _cycles_for(press: str, orderno: str) -> int:
|
||||||
|
total = 0
|
||||||
|
for jo, c in cyc_by_press.get(press, ()):
|
||||||
|
if jo is None:
|
||||||
|
continue
|
||||||
|
if jo == orderno:
|
||||||
|
total += c
|
||||||
|
elif isinstance(jo, str) and orderno in jo:
|
||||||
|
try:
|
||||||
|
arr = json.loads(jo)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
arr = None
|
||||||
|
if isinstance(arr, list) and orderno in arr:
|
||||||
|
total += c
|
||||||
|
return total
|
||||||
|
|
||||||
result: dict[tuple[str, str], int] = {}
|
result: dict[tuple[str, str], int] = {}
|
||||||
for press, orders in by_press.items():
|
for (press, orderno), cav in cav_by.items():
|
||||||
try:
|
if (press, orderno) not in pairs: # keep emitted keys identical to the input pairs
|
||||||
obinds: dict[str, Any] = {"plant": plant, "press": press}
|
|
||||||
for i, o in enumerate(orders):
|
|
||||||
obinds[f"o{i}"] = o
|
|
||||||
oin = ", ".join(f":o{i}" for i in range(len(orders)))
|
|
||||||
|
|
||||||
# 1) cavities + target per active order (article/qty snapshot).
|
|
||||||
active = run_select_query(
|
|
||||||
"SELECT aoOrdernumber AS o, aoCavities AS cav FROM production.active_orders "
|
|
||||||
f"WHERE aoPlant = :plant AND aoPress = :press AND aoOrdernumber IN ({oin})",
|
|
||||||
obinds,
|
|
||||||
)
|
|
||||||
cav_by_order = {str(r["o"]): int(r["cav"] or 0) for r in active}
|
|
||||||
if not cav_by_order:
|
|
||||||
continue # none of these orders are active → notYetSent stays 0
|
|
||||||
|
|
||||||
# 2) cycle counts grouped by Joborder (one scan for the press).
|
|
||||||
cyc_groups = [
|
|
||||||
(cr["jo"], int(cr["c"] or 0))
|
|
||||||
for cr in run_select_query(
|
|
||||||
"SELECT Joborder AS jo, COUNT(*) AS c FROM production.cycles_new "
|
|
||||||
"WHERE Press = :press GROUP BY Joborder",
|
|
||||||
{"press": press},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
# 3) rejects summed per order (one grouped query).
|
|
||||||
rej_by_order: dict[str, int] = {}
|
|
||||||
for rr in run_select_query(
|
|
||||||
"SELECT rejOrdernumber AS o, COALESCE(SUM(rejQuantity),0) AS total "
|
|
||||||
"FROM production.rejects WHERE rejPress = :press "
|
|
||||||
f"AND rejOrdernumber IN ({oin}) GROUP BY rejOrdernumber",
|
|
||||||
obinds,
|
|
||||||
):
|
|
||||||
rej_by_order[str(rr["o"])] = int(rr["total"] or 0)
|
|
||||||
|
|
||||||
def _cycles_for(orderno: str) -> int:
|
|
||||||
total = 0
|
|
||||||
for jo, c in cyc_groups:
|
|
||||||
if jo is None:
|
|
||||||
continue
|
|
||||||
if jo == orderno:
|
|
||||||
total += c
|
|
||||||
elif isinstance(jo, str) and orderno in jo:
|
|
||||||
try:
|
|
||||||
arr = json.loads(jo)
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
arr = None
|
|
||||||
if isinstance(arr, list) and orderno in arr:
|
|
||||||
total += c
|
|
||||||
return total
|
|
||||||
|
|
||||||
for orderno, cav in cav_by_order.items():
|
|
||||||
produced = _cycles_for(orderno) * cav
|
|
||||||
good = max(0, produced - rej_by_order.get(orderno, 0))
|
|
||||||
result[(press, orderno)] = good
|
|
||||||
except Exception as e: # noqa: BLE001 — degrade this press to no notYetSent
|
|
||||||
logger.warning("good-parts derivation failed for press %s: %s", press, e)
|
|
||||||
continue
|
continue
|
||||||
|
produced = _cycles_for(press, orderno) * cav
|
||||||
|
result[(press, orderno)] = max(0, produced - rej_by.get((press, orderno), 0))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@ -460,7 +468,9 @@ def reasons() -> dict[str, Any]:
|
|||||||
"""Active reject reasons — same query as fastpress ``get_reject_reasons``.
|
"""Active reject reasons — same query as fastpress ``get_reject_reasons``.
|
||||||
Global (not press-scoped). Read-only."""
|
Global (not press-scoped). Read-only."""
|
||||||
try:
|
try:
|
||||||
rows = run_select_query(
|
# Reference data (admin-edited); served from cache.
|
||||||
|
rows = cached_select(
|
||||||
|
"trj:reject_reasons",
|
||||||
"SELECT id, reason_de, reason_en FROM settings.reject_reasons "
|
"SELECT id, reason_de, reason_en FROM settings.reject_reasons "
|
||||||
"WHERE active = 1 ORDER BY id",
|
"WHERE active = 1 ORDER BY id",
|
||||||
{},
|
{},
|
||||||
|
|||||||
1
dist/assets/index-CU7fLO-K.css
vendored
Normal file
1
dist/assets/index-CU7fLO-K.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
dist/assets/index-Oxhdj_dg.css
vendored
1
dist/assets/index-Oxhdj_dg.css
vendored
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
@ -9,8 +9,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Ausschuss-Station</title>
|
<title>Ausschuss-Station</title>
|
||||||
<script type="module" crossorigin src="/module/temper_rejects/assets/index-DceU8_gJ.js"></script>
|
<script type="module" crossorigin src="/module/temper_rejects/assets/index-CjCewD1P.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-Oxhdj_dg.css">
|
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-CU7fLO-K.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { WindowCode } from '@/types/station'
|
import type { WindowCode } from '@/types/station'
|
||||||
|
|
||||||
defineProps<{ window: WindowCode; sample: boolean }>()
|
defineProps<{ window: WindowCode; sample: boolean; disconnected?: boolean }>()
|
||||||
const emit = defineEmits<{ (e: 'update:window', code: WindowCode): void }>()
|
const emit = defineEmits<{ (e: 'update:window', code: WindowCode): void }>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@ -32,7 +32,8 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
<span v-if="sample" class="demo">{{ t('app.demo') }}</span>
|
<span v-if="sample" class="demo">{{ t('app.demo') }}</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<span class="live"><span class="led"></span>{{ t('app.live') }}</span>
|
<span v-if="disconnected" class="reconnect"><span class="spinner sm" aria-hidden="true"></span>{{ t('conn.reconnecting') }}</span>
|
||||||
|
<span v-else class="live"><span class="led"></span>{{ t('app.live') }}</span>
|
||||||
<span class="clock num">{{ clock }}</span>
|
<span class="clock num">{{ clock }}</span>
|
||||||
<span class="rangep" role="group" :aria-label="t('window.label')">
|
<span class="rangep" role="group" :aria-label="t('window.label')">
|
||||||
<span class="rl">{{ t('window.label') }}</span>
|
<span class="rl">{{ t('window.label') }}</span>
|
||||||
@ -117,6 +118,16 @@ onUnmounted(() => {
|
|||||||
background: var(--done);
|
background: var(--done);
|
||||||
animation: blink 2.4s ease-in-out infinite;
|
animation: blink 2.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
.reconnect {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--transit);
|
||||||
|
}
|
||||||
.clock {
|
.clock {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
|
|||||||
@ -6,6 +6,11 @@ export default {
|
|||||||
live: 'Live',
|
live: 'Live',
|
||||||
demo: 'Demo-Daten',
|
demo: 'Demo-Daten',
|
||||||
},
|
},
|
||||||
|
conn: {
|
||||||
|
connecting: 'Verbinde mit Server …',
|
||||||
|
lost: 'Verbindung verloren – neuer Versuch …',
|
||||||
|
reconnecting: 'Verbinde neu …',
|
||||||
|
},
|
||||||
filter: {
|
filter: {
|
||||||
title: 'Maschinen',
|
title: 'Maschinen',
|
||||||
all: 'Alle',
|
all: 'Alle',
|
||||||
|
|||||||
@ -7,6 +7,11 @@ export default {
|
|||||||
live: 'Live',
|
live: 'Live',
|
||||||
demo: 'Sample data',
|
demo: 'Sample data',
|
||||||
},
|
},
|
||||||
|
conn: {
|
||||||
|
connecting: 'Connecting to server …',
|
||||||
|
lost: 'Connection lost – retrying …',
|
||||||
|
reconnecting: 'Reconnecting …',
|
||||||
|
},
|
||||||
filter: {
|
filter: {
|
||||||
title: 'Presses',
|
title: 'Presses',
|
||||||
all: 'All',
|
all: 'All',
|
||||||
|
|||||||
@ -6,7 +6,12 @@ import { sampleStation, SAMPLE_REASONS } from '@/data/sample'
|
|||||||
import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station'
|
import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station'
|
||||||
|
|
||||||
// Same convention as PressV: loadCache() → hydrate() → refresh() → startPolling().
|
// Same convention as PressV: loadCache() → hydrate() → refresh() → startPolling().
|
||||||
// Falls back to bundled sample data when the API is unreachable (dev / no gateway).
|
//
|
||||||
|
// Connection loss on this LIVE terminal: never fabricate carts. A failed fetch keeps
|
||||||
|
// the last known REAL payload (stale > fake — an operator must never book against a
|
||||||
|
// demo cart) and flips `disconnected`, so the UI shows a reconnect spinner and polling
|
||||||
|
// retries faster. Bundled sample data renders ONLY under `npm run dev` (no gateway) —
|
||||||
|
// gated behind `import.meta.env.DEV`, so `vite build` strips it from production.
|
||||||
|
|
||||||
const CACHE_KEY = 'temper_rejects_payload'
|
const CACHE_KEY = 'temper_rejects_payload'
|
||||||
|
|
||||||
@ -18,9 +23,12 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const usingSample = ref(false)
|
const usingSample = ref(false)
|
||||||
|
const disconnected = ref(false)
|
||||||
const lastUpdated = ref<number | null>(null)
|
const lastUpdated = ref<number | null>(null)
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let pollMs = 20000
|
||||||
|
const RETRY_MS = 5000 // faster cadence while disconnected, to recover quickly
|
||||||
let filterInitialized = false
|
let filterInitialized = false
|
||||||
|
|
||||||
const hasData = computed(() => payload.value !== null)
|
const hasData = computed(() => payload.value !== null)
|
||||||
@ -76,13 +84,16 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
|
|||||||
}
|
}
|
||||||
payload.value = res.data
|
payload.value = res.data
|
||||||
usingSample.value = res.data.sample === true
|
usingSample.value = res.data.sample === true
|
||||||
|
disconnected.value = false
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Connection lost. Keep the last known REAL data (stale > fabricated): never
|
||||||
|
// replace live carts with demo carts on a live terminal, or an operator could
|
||||||
|
// book against a fabricated entry. Flag `disconnected` so the UI shows a
|
||||||
|
// reconnect spinner (cold start) or a header indicator (stale data) and retries.
|
||||||
|
disconnected.value = true
|
||||||
error.value = e instanceof Error ? e.message : 'Overview API unreachable'
|
error.value = e instanceof Error ? e.message : 'Overview API unreachable'
|
||||||
// Keep the last known REAL data on a transient failure (stale > fabricated): never
|
// Sample data is for `npm run dev` only (no gateway) — `vite build` strips this.
|
||||||
// replace live carts with bundled demo carts on a live terminal, or an operator
|
if (import.meta.env.DEV && (!payload.value || usingSample.value)) {
|
||||||
// could book against a fabricated entry. Only fall back to sample on a COLD start
|
|
||||||
// with nothing to show (dev standalone, where there is no gateway).
|
|
||||||
if (!payload.value || usingSample.value) {
|
|
||||||
payload.value = sampleStation(window.value)
|
payload.value = sampleStation(window.value)
|
||||||
usingSample.value = true
|
usingSample.value = true
|
||||||
}
|
}
|
||||||
@ -102,9 +113,15 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
|
|||||||
async function fetchReasons() {
|
async function fetchReasons() {
|
||||||
try {
|
try {
|
||||||
const res = await api.get<{ reasons: Reason[] }>('/reasons')
|
const res = await api.get<{ reasons: Reason[] }>('/reasons')
|
||||||
reasons.value = Array.isArray(res.data?.reasons) ? res.data.reasons : SAMPLE_REASONS
|
if (Array.isArray(res.data?.reasons) && res.data.reasons.length) {
|
||||||
|
reasons.value = res.data.reasons
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error('no reasons')
|
||||||
} catch {
|
} catch {
|
||||||
reasons.value = SAMPLE_REASONS
|
// Keep any reasons already loaded; only seed the bundled sample reasons under
|
||||||
|
// `npm run dev` — production must never offer fabricated reason codes.
|
||||||
|
if (import.meta.env.DEV && !reasons.value.length) reasons.value = SAMPLE_REASONS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,13 +169,22 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Self-scheduling poll: next tick is the normal cadence when connected, a faster
|
||||||
|
// retry while disconnected so the terminal recovers as soon as the gateway is back.
|
||||||
|
function scheduleNext() {
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
await refresh()
|
||||||
|
scheduleNext()
|
||||||
|
}, disconnected.value ? RETRY_MS : pollMs)
|
||||||
|
}
|
||||||
function startPolling(ms = 20000) {
|
function startPolling(ms = 20000) {
|
||||||
if (timer) return
|
if (timer) return
|
||||||
timer = setInterval(refresh, ms)
|
pollMs = ms
|
||||||
|
scheduleNext()
|
||||||
}
|
}
|
||||||
function stopPolling() {
|
function stopPolling() {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearInterval(timer)
|
clearTimeout(timer)
|
||||||
timer = null
|
timer = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -173,6 +199,7 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
usingSample,
|
usingSample,
|
||||||
|
disconnected,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
hasData,
|
hasData,
|
||||||
allPresses,
|
allPresses,
|
||||||
|
|||||||
@ -114,6 +114,27 @@ html:not(.dark) body {
|
|||||||
background-position: 11px 0;
|
background-position: 11px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reconnect spinner (connection-loss states). `.sm` is the inline header variant. */
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 3px solid var(--line-2);
|
||||||
|
border-top-color: var(--transit);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
.spinner.sm {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* {
|
* {
|
||||||
|
|||||||
@ -5,8 +5,10 @@
|
|||||||
5-station temper belt and an "Ausschuss buchen" action → booking popover
|
5-station temper belt and an "Ausschuss buchen" action → booking popover
|
||||||
(a tooltip-style panel anchored to the button, not a centered modal).
|
(a tooltip-style panel anchored to the button, not a centered modal).
|
||||||
|
|
||||||
Login-free by design. Data from the store (polled); no-backend falls back to
|
Login-free by design. Data from the store (polled). On connection loss the terminal
|
||||||
bundled sample data so `npm run dev` renders + the booking flow is demoable.
|
keeps the last real carts (or shows a reconnect spinner on a cold start) and retries
|
||||||
|
— it never shows demo carts in production. Sample data renders only under
|
||||||
|
`npm run dev` (no gateway), where the booking flow is demoable.
|
||||||
-->
|
-->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
@ -21,7 +23,7 @@ import type { RejectEntry, RejectRequest } from '@/types/station'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const store = useTemperRejectsStore()
|
const store = useTemperRejectsStore()
|
||||||
const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample } = storeToRefs(store)
|
const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample, hasData, disconnected } = storeToRefs(store)
|
||||||
|
|
||||||
const pop = ref<InstanceType<typeof RejectPopover> | null>(null)
|
const pop = ref<InstanceType<typeof RejectPopover> | null>(null)
|
||||||
const bookingEntry = ref<RejectEntry | null>(null)
|
const bookingEntry = ref<RejectEntry | null>(null)
|
||||||
@ -80,9 +82,15 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<StationHeader :window="window" :sample="usingSample" @update:window="store.setWindow" />
|
<StationHeader :window="window" :sample="usingSample" :disconnected="disconnected" @update:window="store.setWindow" />
|
||||||
|
|
||||||
<div class="layout">
|
<!-- Cold start / no data yet: never blank, never fake — spinner that keeps retrying. -->
|
||||||
|
<div v-if="!hasData" class="state connect">
|
||||||
|
<span class="spinner" aria-hidden="true"></span>
|
||||||
|
<span class="msg">{{ disconnected ? t('conn.lost') : t('conn.connecting') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="layout">
|
||||||
<PressFilter
|
<PressFilter
|
||||||
:presses="allPresses"
|
:presses="allPresses"
|
||||||
:checked="checkedPresses"
|
:checked="checkedPresses"
|
||||||
@ -140,6 +148,18 @@ onUnmounted(() => {
|
|||||||
border: 1px dashed var(--line-2);
|
border: 1px dashed var(--line-2);
|
||||||
border-radius: var(--r);
|
border-radius: var(--r);
|
||||||
}
|
}
|
||||||
|
/* Connection spinner state (cold start / reconnecting): full-width, no dashed box. */
|
||||||
|
.state.connect {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 60vh;
|
||||||
|
margin-top: 14px;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.state.connect .msg {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user