feat: scaffold Temper-Linie live-overview page-module (temper_overview)

Vue 3 + Vite 8 + TS + PrimeVue 4 (Nora/Industrial) + Pinia + vue-i18n SPA, served
by the SPRO gateway as a static page-module under /module/temper_overview and
embedded into Grafana via iframe.

- Ports the LOCKED design #4 "Shop-floor Schematic": one connected line
  Pressen -> (cyan) -> Ofen -> (green) -> Fertig; carts under their origin press;
  oven trays as dwell-progress bars; finished band; range picker scoping only the
  Fertig figures. Flow/order colours match PressV (fleet recognisability).
- Python page-module wrapper: register(app) mounts committed dist/ + a no-auth
  read API (GET /api/overview). Kernel DB seam in _db.py.
- Backend returns SAMPLE data; frontend falls back to bundled sample so `npm run
  dev` renders with zero backend (TODO markers point at the real temper aggregates).
- Rich README bakes in the design, contract, wiring + Grafana embedding for
  standalone development.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-01 20:41:04 +02:00
parent e1adb06815
commit aa115a0b8d
53 changed files with 5378 additions and 1 deletions

26
.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Local env / secrets — NEVER commit the real .npmrc (holds the registry token)
.env
.env.*
!.env.example
.npmrc
!.npmrc.example
# Editor / OS
.DS_Store
*.local
.idea/
.vscode/*
!.vscode/extensions.json
# Build caches
node_modules/.tmp/
*.tsbuildinfo
# NOTE: dist/ is intentionally NOT ignored. The gateway serves the committed
# build (the deploy host has no Node toolchain). Rebuild with `npm run build`
# and commit dist/ whenever the app changes.

11
.npmrc.example Normal file
View File

@ -0,0 +1,11 @@
# The @sp-ui-kit/* packages (shared design tokens) live on the private SPRO
# Gitea npm registry. Copy this file to `.npmrc` and paste a Gitea Personal
# Access Token with the "package: Read" scope. The real `.npmrc` is gitignored
# — never commit the token.
#
# Get a token: git.sprodat.eu → Settings → Applications → Manage Access Tokens
# → Generate New Token (scope: package Read).
@sp-ui-kit:registry=https://git.sprodat.eu/api/packages/SPRO_PROJECTS/npm/
//git.sprodat.eu/api/packages/SPRO_PROJECTS/npm/:_authToken=PUT_YOUR_TOKEN_HERE
//git.sprodat.eu/api/packages/SPRO_PROJECTS/npm/:always-auth=true

153
README.md
View File

@ -1,2 +1,153 @@
# SPA_TemperProductionOverview # SPA_TemperProductionOverview — Temper-Linie · Live-Übersicht
A **login-free, read-only** whole-line temper overview, built as a Vue 3 SPA that
the **SPRO Universal Gateway** serves as a static page-module under
`/module/temper_overview/` and that is **embedded into Grafana via an `<iframe>`**.
> **Standalone orientation.** This repo is developed on its own (like `FastPress`,
> `PressV`, `sp-ui-kit` — sibling repos), but it only comes fully alive when mounted
> into the gateway as a git submodule at `modules/temper_overview/`. This README
> bakes in everything a fresh session needs — you shouldn't need the gateway repo
> open to work on the frontend.
Board it renders (dark, one connected line): **Pressen → (cyan ↓) → Ofen →
(green ↓) → Fertig**, carts hanging under their origin press, oven trays whose
background fills with dwell progress, a finished band, and a time-range picker that
scopes only the "Fertig" figures. Run `npm run dev` to see it.
---
## Quick start (standalone dev)
```bash
# 1) auth for the private @sp-ui-kit design-token package (one-time)
cp .npmrc.example .npmrc # then paste a Gitea PAT with "package: Read" scope
# 2) install + run
npm install
npm run dev # http://localhost:5173/module/temper_overview/
```
With **no gateway backend running**, the store falls back to **bundled sample data**
(`src/data/sample.ts`), so `npm run dev` shows the full design immediately (a
`DEMO-DATEN` tag appears in the header). `npm run build``dist/` (committed).
---
## How it's wired into the gateway (page-module contract)
This is a gateway module per `agent_docs/vue-page-modules.md` in the gateway repo.
The **repo root is a Python package** (`__init__.py`) so the kernel's module loader
can `import temper_overview` and call `register(app)`:
| File | Role |
|---|---|
| `__init__.py` | `register(app)` — mounts `dist/` at `MODULE_PREFIX` + includes the API router |
| `_db.py` | kernel DB seam (`run_select_query`) — used once real SQL is wired |
| `api/routes.py` | the no-auth read API (`GET /module/temper_overview/api/overview`) |
| `dist/` | **committed** build output — the gateway serves this (deploy host has no Node) |
| `src/` etc. | the Vue app source |
**URL prefix lives in two places that MUST match** — change both together:
`vite.config.ts``base: '/module/temper_overview/'` and `__init__.py`
`MODULE_PREFIX = "/module/temper_overview"`. The frontend derives its API base from
`import.meta.env.BASE_URL`, so it follows the prefix automatically.
Add it to a gateway checkout:
```bash
# in the gateway repo (fastAPI)
git submodule add https://git.sprodat.eu/Erik/SPA_TemperProductionOverview.git modules/temper_overview
# MODULES_ENABLED in .env must include temper_overview (or be empty = load all)
```
Restart the gateway; the log shows `loaded module: temper_overview`. Smoke-test
(mind the trailing slash): `http://localhost:8800/module/temper_overview/` and
`…/api/overview`.
> **Deploy rule:** always `npm run build` and **commit `dist/`** when the app
> changes, then bump the submodule pin in the gateway repo. Never build on the
> gateway host.
---
## Data API contract
`GET /module/temper_overview/api/overview?range=12h|1d|7d|14d|30d` → one JSON
payload for the whole line. The TypeScript shape is authoritative:
[`src/types/overview.ts`](src/types/overview.ts); the Python side builds the same
shape in [`api/routes.py`](api/routes.py). `range` scopes **only** the `finished`
figures — presses / on-the-way / oven are always real-time.
```jsonc
{
"plant": "Werk 1", "generatedAt": "…", "range": "1d",
"ovenTempC": 185, "ovenAvgMin": 120, "sample": true,
"presses": [{ "name":"P1","status":"run","orders":[{"orderNo":"O-4471","colorIndex":0}],
"good":1240,"cycleTime":"4.2","notVerladen":34,"note":null,"placeholder":false }],
"onWay": { "P3":[{ "cartId":"C-014a","orders":[{"orderNo":"O-4471","qty":54,"colorIndex":0}],"etaMin":3 }] },
"oven": [{ "cartId":"C-011","orders":[],"totalMin":120,"remainingMin":42 }],
"finished": { "carts":47,"parts":5120,"scrap":63,"avgOvenMin":118,
"byOrder":[{"orderNo":"O-4471","colorIndex":0,"parts":1680}] },
"finishedList": [{ "cartId":"C-010","orders":[],"ago":"vor 8 min" }]
}
```
### Wiring the REAL data (currently SAMPLE)
`api/routes.py` returns sample data today. To make it live (see the big `TODO` there
and `.claude/memory/temper-line-overview.md` in the gateway):
- Import the kernel proxy: `from .._db import run_select_query` (read-only).
- Reuse the temper status-derivation in `modules/fastpress/press/temper.py`
(`_temper_aggregates_all` derives on-way / in-oven / finished from planned
timestamps vs `NOW()`), un-scoped across **all** presses. Read
`production.temper_tracking` (carts grouped by `ttCartId`, keyed by origin press
`ttPress`) + `production.active_orders`/`finished_orders`.
- `notVerladen` = produced good parts (cycles) parts logged to temper. **Open
question:** per-press or plant-wide?
- Scope `finished` by `range` on `ttOutOfOvenAt`. Never echo customer/price fields.
Alternatively, add a public `GET /press/temper/overview` in fastpress and have the
frontend call that — decide before wiring; keep the payload shape above either way.
---
## Tech stack (matches PressV / the fleet)
Vue 3.5 · Vite 8 · TypeScript 5.9 · Pinia 3 · vue-router 4 (hash) · vue-i18n 10
(de default, `?lang=en`) · PrimeVue 4.5 (Nora + custom **Industrial** preset, sky
primary) · Tailwind v4 (CSS-first) · `@sp-ui-kit/tokens` (shared design tokens,
private registry) · Inter Variable. **Dark-first**, flat 2px corners, 0s
transitions, `prefers-reduced-motion` disables the conveyor animations.
**Store pattern** (mirrors PressV `productionOverview`): `src/stores/temperOverview.ts`
does `loadCache()` (sessionStorage) → `hydrate()` (spinner only on cold start) →
`refresh()` (background) → `startPolling(12000)`. Robust to a dev server's SPA
fallback: a response without a `presses[]` array is treated as "no backend" → sample.
### Design tokens (recognisable across SPRO apps)
Flow accents match PressV `--c-*`: press/machine `#a78bfa`, on-the-way/cart
`#22d3ee`, oven `#f97316` (hot `#ef4444`), finished `#34d399`. Order chips (from
PressV `orderColors.ts`): `#5794F2 #E8825A #B57EDC #4FB7A8`. Defined in
`src/style.css` (`:root` dark + `html:not(.dark)` light) and `src/data/orderColors.ts`.
> Not yet used but part of the fleet stack — add when needed: `@sp-ui-kit/ui-core`
> components and `echarts`/`vue-echarts`. The board's by-order bars are CSS today.
---
## Grafana embedding
The gateway sets no `X-Frame-Options`/CSP, so a Grafana **Text** panel (HTML mode,
`disable_sanitize_html = true`) can iframe it:
```html
<iframe src="http://<gateway-host>:8800/module/temper_overview/#/"
width="100%" height="900" style="border:0"></iframe>
```
Append `?lang=en` before the `#` for English. Design is locked as design #4
"Shop-floor Schematic" — see `.claude/memory/temper-line-overview.md` and the
prototype at `modules/fastpress/reference/temper-overview/` in the gateway repo.

51
__init__.py Normal file
View File

@ -0,0 +1,51 @@
"""temper_overview — the Temper-Linie live-overview page-module for the SPRO gateway.
This repository is a **gateway page-module**, not a standalone server. It is
mounted into the SPRO Universal Gateway as a git submodule at
``<gateway>/modules/temper_overview/`` and discovered automatically by the kernel
module loader (``app.core.modules.load_modules``), which imports this package and
calls ``register(app)`` after the core routers are mounted.
It does two things:
* mounts the built Vue SPA (``dist/``) as static files under ``/module/temper_overview``;
* exposes a small **read-only, no-auth** JSON API under ``/module/temper_overview/api``
for the page to call (same origin no CORS, no token).
The data is non-sensitive shop-floor data, so like the kernel's ``/health`` and
``/heartbeat`` the page API is deliberately unauthenticated. This is a per-page,
purpose-built read surface: never expose the generic ``POST /query/mariadb`` proxy
to the browser.
See ``agent_docs/vue-page-modules.md`` in the gateway repo for the full runbook,
and this repo's ``README.md`` for the design + build/deploy notes.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
MODULE_NAME = "temper_overview"
# MUST match vite.config.ts `base` (with a trailing slash on the Vite side).
MODULE_PREFIX = "/module/temper_overview"
def register(app: FastAPI) -> None:
"""Mount the data API and the SPA onto the gateway app.
Called once by the kernel loader during app construction. Order matters: a
``StaticFiles`` mount captures every path under its prefix, so the ``/api``
routes are registered FIRST or the mount would shadow them.
"""
# 1) Data API first (imported lazily so package import stays cheap).
from .api import routes
app.include_router(routes.router)
# 2) Then the SPA. html=True serves dist/index.html at the prefix root.
# dist/ MUST exist (it is committed) — if it is missing this raises and the
# loader logs-and-skips the module (the kernel stays up but the page 404s).
dist = Path(__file__).resolve().parent / "dist"
app.mount(MODULE_PREFIX, StaticFiles(directory=dist, html=True), name=MODULE_NAME)

21
_db.py Normal file
View File

@ -0,0 +1,21 @@
"""Kernel DB seam for the temper_overview page-module.
Mirrors ``modules/fastpress/press/_db.py``: the module reaches the database ONLY
through the gateway kernel's SELECT-only proxy, re-exported here so the coupling
to ``app.db`` lives in exactly one place. This page is read-only, so it needs
``run_select_query`` and nothing else (no ``transaction`` it never writes).
The base scaffold's API (``api/routes.py``) returns SAMPLE data and does NOT import
this yet wiring real SQL is the next step (see the TODO in ``api/routes.py``).
Keeping the seam here means that when the real query is added it imports::
from .._db import run_select_query
NOTE: no connection happens at import SQLAlchemy's engine is lazy; the first
real connection opens only when a query actually executes.
"""
from __future__ import annotations
from app.db.mariadb import run_select_query # noqa: F401 (kernel SELECT-only proxy)
__all__ = ["run_select_query"]

0
api/__init__.py Normal file
View File

141
api/routes.py Normal file
View File

@ -0,0 +1,141 @@
"""Read-only, no-auth JSON API for the Temper-Linie overview page.
The page is served same-origin under ``/module/temper_overview`` and calls
``GET /module/temper_overview/api/overview?range=1d`` for the whole-line payload.
No ``verify_token`` dependency: this is the deliberate no-auth read surface for the
Grafana iframe (non-sensitive shop-floor data). Keep every route narrow and
read-only NEVER proxy arbitrary SQL.
STATUS: BASE SCAFFOLD
This returns realistic SAMPLE data shaped EXACTLY like the frontend contract
(``src/types/overview.ts``) so the page renders end-to-end with no database. The
``range`` query param scales only the "Fertig" (finished) figures, mirroring the
locked design (presses / on-the-way / oven are always real-time).
TODO wire real data (see the gateway repo, this repo's README, and the memory
``.claude/memory/temper-line-overview.md``):
* Import the kernel SELECT proxy: ``from .._db import run_select_query``
* Whole-line, un-scoped across presses. Reuse the temper status-derivation from
``modules/fastpress/press/temper.py`` (``_temper_aggregates_all`` derives
on_way / in_oven / finished from planned timestamps vs NOW()); read
``production.temper_tracking`` (carts, grouped by ``ttCartId``, keyed by origin
press ``ttPress``) + ``production.active_orders``/``finished_orders``.
* "nichtVerladen" = produced good parts (cycles) parts logged to temper
(open question: per-press or plant-wide see the memory).
* Scope the finished aggregates by ``range`` (12h..30d) on ``ttOutOfOvenAt``.
* Never echo customer/price fields.
Alternatively, add a public ``GET /press/temper/overview`` in fastpress and have
the page call that instead decide before wiring; keep the payload shape below.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Query
router = APIRouter(prefix="/module/temper_overview/api", tags=["temper_overview"])
# ── Sample data (ported verbatim from the locked prototype) ─────────────────────
# Order identity: key → (Auftragsnummer, colour index 0..3). The 4 colours live in
# the frontend (src/data/orderColors.ts) — the API only sends the index, so the
# page and legend always line up.
_ORDERS: dict[str, tuple[str, int]] = {
"o1": ("O-4471", 0),
"o2": ("O-4460", 1),
"o3": ("O-4455", 2),
"o4": ("O-4489", 3),
}
# range code → factor applied to the finished figures only (from the prototype).
_RANGE_FACTORS: dict[str, float] = {"12h": 0.5, "1d": 1.0, "7d": 6.4, "14d": 12.7, "30d": 27.2}
_DEFAULT_RANGE = "1d"
def _orders(*specs: tuple[str, int]) -> list[dict[str, Any]]:
"""[(order_key, qty), ...] → [{orderNo, qty, colorIndex}, ...]."""
out: list[dict[str, Any]] = []
for key, qty in specs:
name, color = _ORDERS[key]
out.append({"orderNo": name, "qty": qty, "colorIndex": color})
return out
def _order_chips(*keys: str) -> list[dict[str, Any]]:
"""order keys → [{orderNo, colorIndex}, ...] (no qty — for the press header)."""
return [{"orderNo": _ORDERS[k][0], "colorIndex": _ORDERS[k][1]} for k in keys]
_PRESSES: list[dict[str, Any]] = [
{"name": "P1", "status": "run", "orders": _order_chips("o1"), "good": 1240, "cycleTime": "4.2", "notVerladen": 34, "note": None, "placeholder": False},
{"name": "P2", "status": "run", "orders": _order_chips("o2"), "good": 880, "cycleTime": "6.1", "notVerladen": 18, "note": None, "placeholder": False},
{"name": "P3", "status": "run", "orders": _order_chips("o1", "o4"), "good": 612, "cycleTime": "4.3", "notVerladen": 52, "note": None, "placeholder": False},
{"name": "P4", "status": "stop", "orders": _order_chips("o2"), "good": 430, "cycleTime": None, "notVerladen": 0, "note": "Werkzeug rüsten", "placeholder": False},
{"name": "P5", "status": "run", "orders": _order_chips("o3"), "good": 1975, "cycleTime": "2.8", "notVerladen": 41, "note": None, "placeholder": False},
{"name": "P6", "status": "run", "orders": _order_chips("o2"), "good": 744, "cycleTime": "6.0", "notVerladen": 23, "note": None, "placeholder": False},
{"name": "P7", "status": "idle", "orders": [], "good": 0, "cycleTime": None, "notVerladen": 0, "note": "Kein Auftrag", "placeholder": False},
{"name": "P8", "status": "run", "orders": _order_chips("o4"), "good": 1102, "cycleTime": "3.9", "notVerladen": 29, "note": None, "placeholder": False},
{"name": "P9", "status": "run", "orders": _order_chips("o1"), "good": 968, "cycleTime": "4.1", "notVerladen": 12, "note": None, "placeholder": False},
{"name": "P10", "status": "idle", "orders": [], "good": 0, "cycleTime": None, "notVerladen": 0, "note": None, "placeholder": True},
]
# carts on the way, keyed by origin press (a press may have several → its column scrolls).
_ON_WAY: dict[str, list[dict[str, Any]]] = {
"P3": [{"cartId": "C-014a", "orders": _orders(("o1", 54), ("o2", 42)), "etaMin": 3}],
"P5": [{"cartId": "C-014b", "orders": _orders(("o3", 60)), "etaMin": 7}],
"P8": [
{"cartId": "C-015", "orders": _orders(("o1", 30), ("o4", 30), ("o2", 30), ("o3", 30)), "etaMin": 9},
{"cartId": "C-019", "orders": _orders(("o4", 36)), "etaMin": 14},
{"cartId": "C-020", "orders": _orders(("o1", 28)), "etaMin": 19},
],
}
_OVEN: list[dict[str, Any]] = [
{"cartId": "C-011", "orders": _orders(("o1", 88)), "totalMin": 120, "remainingMin": 42},
{"cartId": "C-012", "orders": _orders(("o2", 60), ("o4", 44)), "totalMin": 120, "remainingMin": 71},
{"cartId": "C-013", "orders": _orders(("o3", 72)), "totalMin": 120, "remainingMin": 12},
{"cartId": "C-016", "orders": _orders(("o1", 40), ("o2", 36)), "totalMin": 120, "remainingMin": 98},
{"cartId": "C-017", "orders": _orders(("o4", 80)), "totalMin": 120, "remainingMin": 104},
]
_FINISHED_BASE = {"carts": 47, "parts": 5120, "scrap": 63, "avgOvenMin": 118}
_FINISHED_BY_ORDER: dict[str, int] = {"o1": 1680, "o2": 1290, "o3": 1150, "o4": 1000}
_FINISHED_LIST: list[dict[str, Any]] = [
{"cartId": "C-010", "orders": _orders(("o2", 50), ("o3", 40)), "ago": "vor 8 min"},
{"cartId": "C-009", "orders": _orders(("o1", 64)), "ago": "vor 21 min"},
{"cartId": "C-008", "orders": _orders(("o4", 58)), "ago": "vor 35 min"},
{"cartId": "C-007", "orders": _orders(("o2", 44), ("o1", 20)), "ago": "vor 52 min"},
{"cartId": "C-006", "orders": _orders(("o3", 72)), "ago": "vor 1 h 6 min"},
]
@router.get("/overview", summary="Whole temper-line overview (SAMPLE data)")
def overview(range: str = Query(_DEFAULT_RANGE, description="finished-figures window: 12h|1d|7d|14d|30d")) -> dict[str, Any]:
code = range if range in _RANGE_FACTORS else _DEFAULT_RANGE
factor = _RANGE_FACTORS[code]
finished = {
"carts": round(_FINISHED_BASE["carts"] * factor),
"parts": round(_FINISHED_BASE["parts"] * factor),
"scrap": round(_FINISHED_BASE["scrap"] * factor),
"avgOvenMin": _FINISHED_BASE["avgOvenMin"],
"byOrder": [
{"orderNo": _ORDERS[k][0], "colorIndex": _ORDERS[k][1], "parts": round(v * factor)}
for k, v in _FINISHED_BY_ORDER.items()
],
}
return {
"plant": "Werk 1",
"generatedAt": datetime.now().astimezone().isoformat(),
"range": code,
"ovenTempC": 185,
"ovenAvgMin": 120,
"sample": True, # frontend shows a "Demo-Daten" hint while this is set
"presses": _PRESSES,
"onWay": _ON_WAY,
"oven": _OVEN,
"finished": finished,
"finishedList": _FINISHED_LIST,
}

143
dist/assets/index-BOzN3Frr.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-xY8RtBtx.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
dist/assets/primeicons-C6QP2o4f.woff2 vendored Normal file

Binary file not shown.

BIN
dist/assets/primeicons-DMOk5skT.eot vendored Normal file

Binary file not shown.

345
dist/assets/primeicons-Dr5RGzOO.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 334 KiB

BIN
dist/assets/primeicons-MpK4pl85.ttf vendored Normal file

Binary file not shown.

BIN
dist/assets/primeicons-WjwUDZjB.woff vendored Normal file

Binary file not shown.

18
dist/index.html vendored Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<!--
Dark-first board (class="dark" → @sp-ui-kit token dark values + PrimeVue dark
mode, whose darkModeSelector is ".dark"). data-density="comfortable": this is a
view-only wall board, not a finger-touch panel (the reject STATION uses "touch").
-->
<html lang="de" class="dark" data-density="comfortable">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Temper-Linie · Live-Übersicht</title>
<script type="module" crossorigin src="/module/temper_overview/assets/index-BOzN3Frr.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_overview/assets/index-xY8RtBtx.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

7
env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
// CSS-only subpath imports. The runtime resolves these via each package's
// `exports` map; TypeScript needs an explicit declaration because there is no
// .d.ts for a `.css` file (with `noUncheckedSideEffectImports` on). Same as PressV.
declare module '@sp-ui-kit/tokens/css'
declare module '@fontsource-variable/inter'

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<!--
Dark-first board (class="dark" → @sp-ui-kit token dark values + PrimeVue dark
mode, whose darkModeSelector is ".dark"). data-density="comfortable": this is a
view-only wall board, not a finger-touch panel (the reject STATION uses "touch").
-->
<html lang="de" class="dark" data-density="comfortable">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Temper-Linie · Live-Übersicht</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2313
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "spa-temper-production-overview",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Temper-Linie · Live-Übersicht — a login-free whole-line temper overview SPA, served by the SPRO gateway under /module/temper_overview and embedded into Grafana via iframe.",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@primevue/themes": "^4.5.4",
"@sp-ui-kit/tokens": "^0.1.0",
"axios": "^1.14.0",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"vue": "^3.5.30",
"vue-i18n": "10",
"vue-router": "4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"vue-tsc": "^3.2.5"
}
}

7
src/App.vue Normal file
View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>

14
src/api/index.ts Normal file
View File

@ -0,0 +1,14 @@
import axios from 'axios'
// The SPA is served by the gateway at `import.meta.env.BASE_URL`
// (= vite.config.ts `base` = "/module/temper_overview/"), and its data API lives
// right under that prefix at `/api`. Deriving the baseURL from BASE_URL keeps the
// app correct if the URL prefix ever changes (change it in ONE place: vite base +
// ../__init__.py MODULE_PREFIX). Same origin → no CORS, no auth header.
const api = axios.create({
baseURL: import.meta.env.BASE_URL + 'api',
timeout: 15000,
headers: { 'Content-Type': 'application/json' },
})
export default api

View File

@ -0,0 +1,214 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import OrderLine from '@/components/OrderLine.vue'
import { orderColor } from '@/data/orderColors'
import type { FinishedSummary, FinishedCart } from '@/types/overview'
const props = defineProps<{ finished: FinishedSummary; list: FinishedCart[]; rangeLabel: string }>()
const { t } = useI18n()
const de = (n: number) => Math.round(n).toLocaleString('de-DE')
const maxByOrder = computed(() => Math.max(1, ...props.finished.byOrder.map((o) => o.parts)))
</script>
<template>
<div class="band fband">
<div class="bhd">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" class="rack">
<path d="M4 4v16M20 4v16" />
<path d="M4 9h16M4 15h16" />
<rect x="6" y="4.6" width="5" height="3.6" rx=".4" />
<rect x="13" y="10.6" width="5" height="3.6" rx=".4" />
</svg>
<span class="t">{{ t('finished.title') }}</span>
<span class="m">{{ t('finished.range', { label: rangeLabel }) }}</span>
</div>
<div class="finrow">
<div class="finsum">
<div class="finhero">
<span class="v num">{{ de(finished.parts) }}</span>
<span class="l">{{ t('finished.goodParts') }}</span>
</div>
<div class="finmeta">
<span>{{ t('finished.carts') }} <b class="num">{{ de(finished.carts) }}</b></span>
<span class="scrap">{{ t('finished.scrap') }} <b class="num">{{ de(finished.scrap) }}</b></span>
<span>{{ t('finished.avgOven') }} <b class="num">{{ finished.avgOvenMin }} min</b></span>
</div>
</div>
<div class="finmid">
<div class="byorder">
<div v-for="o in finished.byOrder" :key="o.orderNo" class="row">
<span class="nm">{{ o.orderNo }}</span>
<span class="track"><i :style="{ width: (o.parts / maxByOrder) * 100 + '%', background: orderColor(o.colorIndex) }"></i></span>
<span class="val">{{ de(o.parts) }}</span>
</div>
</div>
</div>
</div>
<div class="finscroll">
<div v-for="c in list" :key="c.cartId" class="fcart">
<div class="fhd">
<span class="cid">{{ c.cartId }}</span>
<span class="ago">{{ c.ago }}</span>
</div>
<div class="ols">
<OrderLine v-for="(o, i) in c.orders" :key="i" :order-no="o.orderNo" :qty="o.qty" :color-index="o.colorIndex" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.band {
border-radius: 6px;
padding: 12px 14px;
}
.fband {
border: 1px solid rgba(52, 211, 153, 0.35);
background: linear-gradient(180deg, var(--panel), #0e1712);
}
.bhd {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 10px;
color: var(--done);
}
.rack {
width: 19px;
height: 19px;
}
.t {
font-family: var(--mono);
font-size: 0.75rem;
letter-spacing: 0.14em;
text-transform: uppercase;
font-weight: 800;
}
.m {
margin-left: auto;
font-family: var(--mono);
font-size: 0.68rem;
color: var(--ink-dim);
}
.finrow {
display: flex;
gap: 18px;
align-items: flex-start;
flex-wrap: wrap;
}
.finsum {
flex: 0 0 230px;
}
.finhero {
display: flex;
align-items: baseline;
gap: 9px;
margin-bottom: 9px;
}
.finhero .v {
font-family: var(--mono);
font-size: 2.25rem;
font-weight: 800;
color: var(--done);
line-height: 1;
}
.finhero .l {
font-size: 0.75rem;
color: var(--ink-dim);
}
.finmeta {
display: flex;
flex-direction: column;
gap: 5px;
font-family: var(--mono);
font-size: 0.68rem;
color: var(--ink-dim);
}
.finmeta b {
color: var(--ink);
}
.finmeta .scrap b {
color: var(--hot);
}
.finmid {
flex: 1 1 200px;
min-width: 190px;
}
.byorder {
display: flex;
flex-direction: column;
gap: 7px;
}
.row {
display: flex;
align-items: center;
gap: 9px;
font-size: 0.75rem;
}
.nm {
font-family: var(--mono);
color: var(--ink);
width: 64px;
}
.track {
flex: 1;
height: 9px;
background: var(--ground);
border: 1px solid var(--line);
border-radius: var(--r);
overflow: hidden;
}
.track i {
display: block;
height: 100%;
}
.val {
font-family: var(--mono);
color: var(--ink-dim);
width: 52px;
text-align: right;
}
.finscroll {
margin-top: 12px;
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 6px;
scrollbar-width: thin;
}
.fcart {
flex: 0 0 148px;
border: 1px solid var(--line);
border-top: 2px solid var(--done);
border-radius: var(--r);
padding: 7px 9px;
}
.fhd {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.cid {
font-family: var(--mono);
font-size: 0.68rem;
font-weight: 700;
}
.ago {
margin-left: auto;
font-family: var(--mono);
font-size: 0.6rem;
color: var(--ink-faint);
}
.ols {
display: flex;
flex-direction: column;
gap: 3px;
}
</style>

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ORDER_COLORS } from '@/data/orderColors'
const { t } = useI18n()
</script>
<template>
<div class="flow">
<div class="node">
<span class="sw" :style="{ background: 'var(--press)' }"></span>
<span><span class="nm">{{ t('flow.presses') }}</span> <span class="sub">{{ t('flow.pressesSub') }}</span></span>
</div>
<div class="node"><span class="sw" :style="{ background: 'var(--way)' }"></span><span class="nm">{{ t('flow.onWay') }}</span></div>
<div class="node"><span class="sw" :style="{ background: 'var(--oven)' }"></span><span class="nm">{{ t('flow.oven') }}</span></div>
<div class="node"><span class="sw" :style="{ background: 'var(--done)' }"></span><span class="nm">{{ t('flow.finished') }}</span></div>
<span class="orders">
<span class="ol2">{{ t('flow.orders') }}</span>
<span v-for="(c, i) in ORDER_COLORS" :key="i" class="chip" :style="{ background: c }"></span>
<span class="ol2">{{ t('flow.ordersMax') }}</span>
</span>
</div>
</template>
<style scoped>
.flow {
display: flex;
align-items: center;
gap: 0;
flex-wrap: wrap;
margin: 12px 0 14px;
padding: 11px 16px;
border: 1px solid var(--line);
background: var(--panel);
border-radius: var(--r);
}
.node {
display: flex;
align-items: center;
gap: 9px;
padding: 2px 4px;
position: relative;
}
.node:not(:last-of-type) {
padding-right: 30px;
}
.node:not(:last-of-type)::after {
content: '→';
position: absolute;
right: 9px;
color: var(--ink-faint);
font-size: 1rem;
}
.sw {
width: 12px;
height: 12px;
border-radius: var(--r);
}
.nm {
font-size: 0.8rem;
font-weight: 600;
}
.sub {
font-family: var(--mono);
font-size: 0.62rem;
color: var(--ink-faint);
}
.orders {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
padding-left: 18px;
border-left: 1px solid var(--line);
}
.ol2 {
font-family: var(--mono);
font-size: 0.62rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-faint);
}
.chip {
width: 12px;
height: 12px;
border-radius: 3px;
}
</style>

116
src/components/KpiStrip.vue Normal file
View File

@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { OverviewPayload } from '@/types/overview'
const props = defineProps<{ payload: OverviewPayload }>()
const { t } = useI18n()
const de = (n: number) => Math.round(n).toLocaleString('de-DE')
const partsOf = (orders: { qty: number }[]) => orders.reduce((s, o) => s + o.qty, 0)
const notVerladen = computed(() => props.payload.presses.reduce((s, p) => s + p.notVerladen, 0))
const wayCarts = computed(() => Object.values(props.payload.onWay).flat())
const wayParts = computed(() => wayCarts.value.reduce((s, c) => s + partsOf(c.orders), 0))
const ovenParts = computed(() => props.payload.oven.reduce((s, b) => s + partsOf(b.orders), 0))
const finished = computed(() => props.payload.finished)
const scrapRate = computed(() => {
const f = finished.value
const total = f.parts + f.scrap
if (total === 0) return '0,0 %'
return ((f.scrap / total) * 100).toFixed(1).replace('.', ',') + ' %'
})
</script>
<template>
<div class="kpis">
<div class="kpi nv">
<div class="kl">{{ t('kpi.notVerladen') }}</div>
<div class="kv num">{{ de(notVerladen) }}</div>
<div class="ks">{{ t('kpi.notVerladenSub') }}</div>
</div>
<div class="kpi way">
<div class="kl">{{ t('kpi.onWay') }}</div>
<div class="kv num">{{ de(wayParts) }}</div>
<div class="ks">{{ t('kpi.onWaySub', { n: wayCarts.length }) }}</div>
</div>
<div class="kpi oven">
<div class="kl">{{ t('kpi.oven') }}</div>
<div class="kv num">{{ de(ovenParts) }}</div>
<div class="ks">{{ t('kpi.ovenSub', { n: payload.oven.length }) }}</div>
</div>
<div class="kpi done">
<div class="kl">{{ t('kpi.finished') }} · {{ payload.range }}</div>
<div class="kv num">{{ de(finished.parts) }}</div>
<div class="ks">{{ t('kpi.finishedSub', { n: de(finished.carts) }) }}</div>
</div>
<div class="kpi">
<div class="kl">{{ t('kpi.scrapRate') }} · {{ payload.range }}</div>
<div class="kv num">{{ scrapRate }}</div>
<div class="ks">{{ t('kpi.scrapRateSub') }}</div>
</div>
</div>
</template>
<style scoped>
.kpis {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
margin: 0 0 14px;
}
.kpi {
border: 1px solid var(--line);
background: var(--panel);
border-radius: var(--r);
padding: 11px 13px;
border-left: 3px solid var(--line-2);
}
.kpi.nv {
border-left-color: var(--press);
}
.kpi.way {
border-left-color: var(--way);
}
.kpi.oven {
border-left-color: var(--oven);
}
.kpi.done {
border-left-color: var(--done);
}
.kl {
font-family: var(--mono);
font-size: 0.6rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-faint);
}
.kv {
font-family: var(--mono);
font-size: 1.6rem;
font-weight: 800;
line-height: 1.15;
}
.kpi.nv .kv {
color: var(--press);
}
.kpi.way .kv {
color: var(--way);
}
.kpi.oven .kv {
color: var(--oven);
}
.kpi.done .kv {
color: var(--done);
}
.ks {
font-family: var(--mono);
font-size: 0.66rem;
color: var(--ink-dim);
}
@media (max-width: 900px) {
.kpis {
grid-template-columns: 1fr 1fr;
}
}
</style>

View File

@ -0,0 +1,39 @@
<script setup lang="ts">
import { orderColor } from '@/data/orderColors'
defineProps<{ orderNo: string; qty: number; colorIndex: number }>()
</script>
<template>
<div class="ol">
<span class="chip" :style="{ background: orderColor(colorIndex) }"></span>
<span class="on">{{ orderNo }}</span>
<span class="oq">{{ qty }}</span>
</div>
</template>
<style scoped>
.ol {
display: flex;
align-items: center;
gap: 7px;
font-size: 0.72rem;
}
.chip {
width: 11px;
height: 11px;
border-radius: 2px;
flex: none;
}
.on {
font-family: var(--mono);
color: var(--ink);
}
.oq {
margin-left: auto;
font-family: var(--mono);
font-weight: 700;
color: var(--ink-dim);
font-variant-numeric: tabular-nums;
}
</style>

199
src/components/OvenBand.vue Normal file
View File

@ -0,0 +1,199 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import OrderLine from '@/components/OrderLine.vue'
import type { OvenBatch } from '@/types/overview'
const props = defineProps<{ batches: OvenBatch[]; tempC: number; avgMin: number }>()
const { t } = useI18n()
const partsOf = (b: OvenBatch) => b.orders.reduce((s, o) => s + o.qty, 0)
const totalParts = computed(() => props.batches.reduce((s, b) => s + partsOf(b), 0))
// soonest-out first; the soonest gets the pulsing "nächster raus" tag.
const sorted = computed(() => [...props.batches].sort((a, b) => a.remainingMin - b.remainingMin))
const nextOutId = computed(() => (sorted.value.length ? sorted.value[0].cartId : null))
function pct(b: OvenBatch) {
return Math.round(((b.totalMin - b.remainingMin) / b.totalMin) * 100)
}
function trayStyle(b: OvenBatch) {
const hot = b.remainingMin <= 15
const p = pct(b)
const fill = hot ? '239,68,68' : '249,115,22'
const edge = Math.max(p - 1.5, 0)
return {
background: `linear-gradient(90deg, rgba(${fill},.20) 0, rgba(${fill},.20) ${edge}%, rgba(${fill},.65) ${edge}%, rgba(${fill},.65) ${p}%, rgba(6,10,15,.3) ${p}%)`,
}
}
</script>
<template>
<div class="band oband">
<div class="bhd">
<svg viewBox="0 0 24 24" class="ovenicon" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="16" rx="1.5" />
<path d="M3 8h18" />
<circle cx="7" cy="6" r=".6" fill="currentColor" />
<circle cx="10" cy="6" r=".6" fill="currentColor" />
<rect x="6" y="11" width="12" height="6" rx="1" />
</svg>
<span class="t">{{ t('oven.title') }}</span>
<span class="flame">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3c1 3-2 4-2 7a2 2 0 0 0 4 0c0-1 0-1 .3-1.8C16 11 17 13 17 15a5 5 0 0 1-10 0c0-4 5-7 5-12z" />
</svg>
<span>{{ tempC }}°C</span>
</span>
<span class="m">{{ t('oven.meta', { parts: totalParts, batches: batches.length, avg: avgMin }) }}</span>
</div>
<div class="otrays">
<div v-for="b in sorted" :key="b.cartId" class="tray" :class="{ hot: b.remainingMin <= 15 }" :style="trayStyle(b)">
<div class="thd">
<span class="cid">{{ b.cartId }}</span>
<span v-if="b.cartId === nextOutId" class="nextout">{{ t('oven.nextOut') }}</span>
<span class="rem" :style="{ color: b.remainingMin <= 15 ? 'var(--hot)' : 'var(--oven)' }">
{{ b.remainingMin }}<span class="remu"> {{ t('oven.unit') }}</span>
</span>
</div>
<div class="prog">
<span class="pv">{{ pct(b) }}%</span> · {{ t('oven.progress', { done: b.totalMin - b.remainingMin, total: b.totalMin }) }}
</div>
<div class="ols">
<OrderLine v-for="(o, i) in b.orders" :key="i" :order-no="o.orderNo" :qty="o.qty" :color-index="o.colorIndex" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.band {
border-radius: 6px;
padding: 12px 14px;
}
.oband {
border: 1px solid rgba(249, 115, 22, 0.4);
background:
radial-gradient(70% 120% at 50% 0%, rgba(239, 68, 68, 0.1), transparent 60%),
linear-gradient(180deg, var(--panel), #160f0b);
}
.bhd {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 10px;
color: var(--oven);
}
.ovenicon {
width: 19px;
height: 19px;
animation: flame 1.6s ease-in-out infinite;
}
.t {
font-family: var(--mono);
font-size: 0.75rem;
letter-spacing: 0.14em;
text-transform: uppercase;
font-weight: 800;
}
.flame {
display: flex;
align-items: center;
gap: 5px;
color: var(--hot);
font-family: var(--mono);
font-size: 0.75rem;
}
.flame svg {
width: 14px;
height: 14px;
animation: flicker 1.1s ease-in-out infinite;
}
.m {
margin-left: auto;
font-family: var(--mono);
font-size: 0.68rem;
color: var(--ink-dim);
}
.otrays {
display: flex;
gap: 9px;
overflow-x: auto;
padding-bottom: 6px;
scrollbar-width: thin;
}
.tray {
position: relative;
flex: 0 0 196px;
border: 1px solid var(--line-2);
border-top: 3px solid var(--oven);
border-radius: var(--r);
padding: 9px 10px;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.tray.hot {
border-top-color: var(--hot);
}
.thd {
display: flex;
align-items: center;
gap: 7px;
}
.cid {
font-family: var(--mono);
font-size: 0.8rem;
font-weight: 800;
}
.rem {
margin-left: auto;
font-family: var(--mono);
font-size: 1rem;
font-weight: 800;
line-height: 1;
padding: 3px 8px;
border-radius: var(--r);
background: var(--ground);
border: 1px solid var(--line-2);
}
.remu {
font-size: 0.56rem;
font-weight: 600;
color: var(--ink-faint);
}
.prog {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--mono);
font-size: 0.6rem;
color: var(--ink-dim);
}
.pv {
color: var(--oven);
font-weight: 700;
}
.tray.hot .pv {
color: var(--hot);
}
.nextout {
font-family: var(--mono);
font-size: 0.5rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--hot);
border: 1px solid rgba(239, 68, 68, 0.5);
border-radius: var(--r);
padding: 1px 4px;
animation: blink 1.8s ease-in-out infinite;
}
.ols {
display: flex;
flex-direction: column;
gap: 3px;
padding-top: 5px;
border-top: 1px solid var(--line);
}
</style>

View File

@ -0,0 +1,146 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RangeCode } from '@/types/overview'
defineProps<{ plant: string; range: RangeCode; sample: boolean }>()
const emit = defineEmits<{ (e: 'update:range', code: RangeCode): void }>()
const { t } = useI18n()
const RANGES: RangeCode[] = ['12h', '1d', '7d', '14d', '30d']
const clock = ref('')
let timer: ReturnType<typeof setInterval> | null = null
function tick() {
clock.value = new Date().toLocaleTimeString('de-DE')
}
onMounted(() => {
tick()
timer = setInterval(tick, 1000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<template>
<div class="bar">
<span class="mark">{{ t('app.brand') }}</span>
<span class="title">{{ plant }} · <b>{{ t('app.line') }}</b> · {{ t('app.subtitle') }}</span>
<span v-if="sample" class="demo">{{ t('app.demo') }}</span>
<span class="spacer"></span>
<span class="live"><span class="led"></span>{{ t('app.live') }}</span>
<span class="clock num">{{ clock }}</span>
<span class="rangep" role="group" :aria-label="t('range.label')">
<span class="rl">{{ t('range.label') }}</span>
<button
v-for="code in RANGES"
:key="code"
type="button"
:aria-pressed="code === range"
@click="emit('update:range', code)"
>
{{ code }}
</button>
</span>
</div>
</template>
<style scoped>
.bar {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
border: 1px solid var(--line);
background: linear-gradient(180deg, var(--panel), color-mix(in srgb, var(--panel) 70%, transparent));
border-radius: var(--r);
flex-wrap: wrap;
}
.mark {
font-family: var(--mono);
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
font-size: 0.75rem;
color: var(--ground);
background: var(--way);
padding: 5px 9px;
border-radius: var(--r);
}
.title {
font-weight: 650;
font-size: 1rem;
}
.title b {
color: var(--way);
}
.demo {
font-family: var(--mono);
font-size: 0.62rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--oven);
border: 1px solid color-mix(in srgb, var(--oven) 50%, transparent);
border-radius: var(--r);
padding: 2px 7px;
}
.spacer {
margin-left: auto;
}
.live {
display: flex;
align-items: center;
gap: 7px;
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-dim);
}
.led {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--run);
animation: blink 2.4s ease-in-out infinite;
}
.clock {
font-size: 0.95rem;
color: var(--ink-dim);
}
.rangep {
display: inline-flex;
border: 1px solid var(--line-2);
border-radius: var(--r);
overflow: hidden;
}
.rl {
align-self: center;
font-family: var(--mono);
font-size: 0.62rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-faint);
padding: 0 11px;
}
.rangep button {
font-family: var(--mono);
background: transparent;
border: 0;
border-left: 1px solid var(--line);
color: var(--ink-dim);
padding: 8px 13px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.rangep button:hover {
background: var(--panel-3);
color: var(--ink);
}
.rangep button[aria-pressed='true'] {
background: var(--way);
color: var(--ground);
}
</style>

View File

@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { orderColor } from '@/data/orderColors'
import type { PressInfo } from '@/types/overview'
const props = defineProps<{ press: PressInfo }>()
const { t } = useI18n()
const de = (n: number) => Math.round(n).toLocaleString('de-DE')
const ledColor = computed(() => `var(--${props.press.status === 'run' ? 'run' : props.press.status === 'stop' ? 'stop' : 'idle'})`)
const statusText = computed(() =>
props.press.status === 'run' ? t('press.statusRun') : props.press.status === 'stop' ? t('press.statusStop') : t('press.statusIdle'),
)
</script>
<template>
<div v-if="press.placeholder" class="press empty">
<div>
<div class="pn">{{ press.name }}</div>
<div class="sub">{{ t('press.free') }}</div>
</div>
</div>
<div v-else class="press" :class="press.status">
<div class="top">
<span class="pn">{{ press.name }}</span>
<span class="stled" :style="{ background: ledColor }"></span>
</div>
<div class="chips">
<span v-for="(o, i) in press.orders" :key="i" class="chip" :style="{ background: orderColor(o.colorIndex) }"></span>
</div>
<template v-if="press.status === 'run'">
<div class="good num">{{ de(press.good) }}</div>
<div class="sub">{{ t('press.good') }} · {{ press.cycleTime }}s</div>
</template>
<template v-else>
<div class="good status" :style="{ color: ledColor }">{{ statusText }}</div>
<div class="sub">{{ press.note || '' }}</div>
</template>
<div class="nv">
<span class="nvv num">{{ press.notVerladen }}</span>
<span class="nvl">{{ t('press.notVerladen') }}</span>
</div>
</div>
</template>
<style scoped>
.press {
border: 1px solid var(--line-2);
border-top: 2px solid var(--line-2);
border-radius: var(--r);
background: linear-gradient(180deg, var(--panel), var(--panel-2));
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
}
.press::after {
content: '';
position: absolute;
left: 6px;
right: -7px;
bottom: -7px;
height: 7px;
background: rgba(2, 6, 12, 0.32);
transform: skewX(-52deg);
transform-origin: top left;
}
.press.run {
border-top-color: var(--press);
}
.press.stop {
border-top-color: var(--stop);
}
.press.idle {
border-top-color: var(--idle);
}
.top {
display: flex;
align-items: center;
gap: 6px;
}
.pn {
font-weight: 800;
font-size: 0.88rem;
}
.stled {
width: 9px;
height: 9px;
border-radius: 50%;
margin-left: auto;
}
.chips {
display: flex;
gap: 3px;
min-height: 12px;
}
.chip {
width: 12px;
height: 12px;
border-radius: 3px;
}
.good {
font-family: var(--mono);
font-size: 1.25rem;
font-weight: 800;
line-height: 1;
margin-top: 2px;
}
.good.status {
font-size: 0.75rem;
}
.sub {
font-family: var(--mono);
font-size: 0.6rem;
color: var(--ink-faint);
}
.nv {
display: flex;
align-items: baseline;
gap: 5px;
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid var(--line);
}
.nvv {
font-family: var(--mono);
font-size: 0.88rem;
font-weight: 700;
color: var(--press);
}
.nvl {
font-family: var(--mono);
font-size: 0.53rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-faint);
line-height: 1.1;
}
.press.empty {
border-style: dashed;
border-color: var(--line);
background: repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(148, 163, 184, 0.04) 6px, rgba(148, 163, 184, 0.04) 12px);
align-items: center;
justify-content: center;
color: var(--ink-faint);
text-align: center;
}
.press.empty::after {
display: none;
}
</style>

View File

@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import OrderLine from '@/components/OrderLine.vue'
import type { CartOnWay } from '@/types/overview'
const props = defineProps<{ carts: CartOnWay[] }>()
const { t } = useI18n()
const active = computed(() => props.carts.length > 0)
const partsOf = (c: CartOnWay) => c.orders.reduce((s, o) => s + o.qty, 0)
</script>
<template>
<div class="lane" :class="{ active }">
<span class="conn"></span>
<span class="lc" :class="active ? 'on' : 'off'">
{{ active ? t('lane.wagen', { n: carts.length }) : t('lane.none') }}
</span>
<div v-for="cart in carts" :key="cart.cartId" class="cart">
<div class="chd">
<span class="cid">{{ cart.cartId }}</span>
<span class="cp">{{ t('lane.parts', { n: partsOf(cart) }) }}</span>
</div>
<div class="cbody">
<OrderLine v-for="(o, i) in cart.orders" :key="i" :order-no="o.orderNo" :qty="o.qty" :color-index="o.colorIndex" />
</div>
<div class="cfoot">{{ t('lane.ovenIn', { n: cart.etaMin }) }}</div>
</div>
</div>
</template>
<style scoped>
.lane {
position: relative;
min-height: 132px;
max-height: 236px;
overflow-y: auto;
padding-top: 22px;
display: flex;
flex-direction: column;
gap: 8px;
scrollbar-width: thin;
}
.conn {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 2px;
transform: translateX(-50%);
z-index: 0;
background: repeating-linear-gradient(180deg, var(--way) 0 3px, transparent 3px 11px);
background-size: 2px 11px;
opacity: 0.3;
}
.lane.active .conn {
opacity: 0.95;
animation: flowdown 1.05s linear infinite;
}
.lc {
position: absolute;
left: 50%;
top: 3px;
transform: translateX(-50%);
font-family: var(--mono);
font-size: 0.53rem;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
background: var(--ground);
padding: 0 5px;
z-index: 3;
}
.lc.on {
color: var(--way);
}
.lc.off {
color: var(--ink-faint);
}
.cart {
position: relative;
z-index: 2;
border: 1px solid var(--line-2);
border-top: 2px solid var(--way);
border-radius: var(--r);
background: var(--panel);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.chd {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
background: var(--panel-3);
border-bottom: 1px solid var(--line);
}
.cid {
font-family: var(--mono);
font-size: 0.75rem;
font-weight: 800;
}
.cp {
margin-left: auto;
font-family: var(--mono);
font-size: 0.68rem;
color: var(--ink-dim);
}
.cbody {
padding: 6px 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.cfoot {
padding: 4px 8px;
border-top: 1px solid var(--line);
font-family: var(--mono);
font-size: 0.62rem;
color: var(--way);
}
</style>

View File

@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PressCell from '@/components/PressCell.vue'
import PressLane from '@/components/PressLane.vue'
import OvenBand from '@/components/OvenBand.vue'
import FinishedBand from '@/components/FinishedBand.vue'
import type { OverviewPayload, CartOnWay } from '@/types/overview'
const props = defineProps<{ payload: OverviewPayload }>()
const { t } = useI18n()
const running = computed(() => props.payload.presses.filter((p) => p.status === 'run').length)
const total = computed(() => props.payload.presses.length)
const wayCarts = computed(() => Object.values(props.payload.onWay).flat().length)
const rangeLabel = computed(() => t(`range.${props.payload.range}`))
function cartsFor(pressName: string): CartOnWay[] {
return props.payload.onWay[pressName] ?? []
}
</script>
<template>
<div class="production">
<div class="stagehd">
<span class="sw" :style="{ background: 'var(--press)' }"></span>
{{ t('line.header') }}
<span class="c">{{ t('line.active', { running, total }) }}</span>
</div>
<div class="matrix">
<div class="grid">
<PressCell v-for="p in payload.presses" :key="'p-' + p.name" :press="p" />
<PressLane v-for="p in payload.presses" :key="'l-' + p.name" :carts="cartsFor(p.name)" />
</div>
</div>
<div class="link" :style="{ '--lc': 'var(--way)' }">
<span class="lbl"><b>{{ t('line.toOven') }}</b> {{ t('line.toOvenTail') }} · {{ t('line.wagen', { n: wayCarts }) }}</span>
</div>
<OvenBand :batches="payload.oven" :temp-c="payload.ovenTempC" :avg-min="payload.ovenAvgMin" />
<div class="link" :style="{ '--lc': 'var(--done)' }">
<span class="lbl"><b>{{ t('line.toStorage') }}</b> {{ t('line.toStorageTail') }}</span>
</div>
<FinishedBand :finished="payload.finished" :list="payload.finishedList" :range-label="rangeLabel" />
</div>
</template>
<style scoped>
.production {
border: 1px solid var(--line-2);
border-radius: 8px;
padding: 14px 14px 16px;
background:
repeating-linear-gradient(0deg, transparent 0 39px, rgba(148, 163, 184, 0.04) 39px 40px),
repeating-linear-gradient(90deg, transparent 0 39px, rgba(148, 163, 184, 0.04) 39px 40px),
#0a0e13;
}
html:not(.dark) .production {
background: var(--panel);
}
.stagehd {
display: flex;
align-items: center;
gap: 9px;
font-family: var(--mono);
font-size: 0.66rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-faint);
margin: 0 0 9px;
}
.sw {
width: 9px;
height: 9px;
border-radius: var(--r);
}
.c {
margin-left: auto;
color: var(--ink-dim);
}
.matrix {
overflow-x: auto;
}
.grid {
display: grid;
grid-template-columns: repeat(10, minmax(102px, 1fr));
gap: 8px;
min-width: 1080px;
}
.link {
position: relative;
height: 34px;
margin: 4px 0;
}
.link::before {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 9px;
width: 3px;
transform: translateX(-50%);
background: repeating-linear-gradient(180deg, var(--lc) 0 4px, transparent 4px 11px);
background-size: 3px 11px;
animation: flowdown 1s linear infinite;
box-shadow: 0 0 8px var(--lc);
}
.link::after {
content: '▼';
position: absolute;
left: 50%;
bottom: -3px;
transform: translateX(-50%);
color: var(--lc);
font-size: 0.8rem;
line-height: 1;
}
.lbl {
position: absolute;
left: calc(50% + 16px);
top: 8px;
font-family: var(--mono);
font-size: 0.6rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-faint);
white-space: nowrap;
}
.lbl b {
color: var(--ink-dim);
}
</style>

11
src/data/orderColors.ts Normal file
View File

@ -0,0 +1,11 @@
// Order-identity colours — copied verbatim from PressV (src/lib/orderColors.ts)
// so an order reads with the SAME chip colour across every SPRO app.
//
// The API sends only a colour INDEX (0..3) per order; this maps it to a hex.
// Index wraps if a cart ever carries more than four orders.
export const ORDER_COLORS = ['#5794F2', '#E8825A', '#B57EDC', '#4FB7A8'] as const
export function orderColor(i: number): string {
return ORDER_COLORS[i % ORDER_COLORS.length]
}

100
src/data/sample.ts Normal file
View File

@ -0,0 +1,100 @@
// Bundled sample payload — powers `npm run dev` (no gateway backend) and acts as
// a graceful fallback if the data API is unreachable. Mirrors the SAMPLE data in
// ../../api/routes.py (Python). When the gateway serves real data this is unused.
import type {
OverviewPayload,
OrderPortion,
OrderChip,
PressInfo,
CartOnWay,
OvenBatch,
RangeCode,
} from '@/types/overview'
// order key → [Auftragsnummer, colour index]
const ORDERS: Record<string, [string, number]> = {
o1: ['O-4471', 0],
o2: ['O-4460', 1],
o3: ['O-4455', 2],
o4: ['O-4489', 3],
}
const RANGE_FACTORS: Record<RangeCode, number> = { '12h': 0.5, '1d': 1, '7d': 6.4, '14d': 12.7, '30d': 27.2 }
function portions(...specs: [string, number][]): OrderPortion[] {
return specs.map(([k, qty]) => ({ orderNo: ORDERS[k][0], qty, colorIndex: ORDERS[k][1] }))
}
function chips(...keys: string[]): OrderChip[] {
return keys.map((k) => ({ orderNo: ORDERS[k][0], colorIndex: ORDERS[k][1] }))
}
const PRESSES: PressInfo[] = [
{ name: 'P1', status: 'run', orders: chips('o1'), good: 1240, cycleTime: '4.2', notVerladen: 34, note: null, placeholder: false },
{ name: 'P2', status: 'run', orders: chips('o2'), good: 880, cycleTime: '6.1', notVerladen: 18, note: null, placeholder: false },
{ name: 'P3', status: 'run', orders: chips('o1', 'o4'), good: 612, cycleTime: '4.3', notVerladen: 52, note: null, placeholder: false },
{ name: 'P4', status: 'stop', orders: chips('o2'), good: 430, cycleTime: null, notVerladen: 0, note: 'Werkzeug rüsten', placeholder: false },
{ name: 'P5', status: 'run', orders: chips('o3'), good: 1975, cycleTime: '2.8', notVerladen: 41, note: null, placeholder: false },
{ name: 'P6', status: 'run', orders: chips('o2'), good: 744, cycleTime: '6.0', notVerladen: 23, note: null, placeholder: false },
{ name: 'P7', status: 'idle', orders: [], good: 0, cycleTime: null, notVerladen: 0, note: 'Kein Auftrag', placeholder: false },
{ name: 'P8', status: 'run', orders: chips('o4'), good: 1102, cycleTime: '3.9', notVerladen: 29, note: null, placeholder: false },
{ name: 'P9', status: 'run', orders: chips('o1'), good: 968, cycleTime: '4.1', notVerladen: 12, note: null, placeholder: false },
{ name: 'P10', status: 'idle', orders: [], good: 0, cycleTime: null, notVerladen: 0, note: null, placeholder: true },
]
const ON_WAY: Record<string, CartOnWay[]> = {
P3: [{ cartId: 'C-014a', orders: portions(['o1', 54], ['o2', 42]), etaMin: 3 }],
P5: [{ cartId: 'C-014b', orders: portions(['o3', 60]), etaMin: 7 }],
P8: [
{ cartId: 'C-015', orders: portions(['o1', 30], ['o4', 30], ['o2', 30], ['o3', 30]), etaMin: 9 },
{ cartId: 'C-019', orders: portions(['o4', 36]), etaMin: 14 },
{ cartId: 'C-020', orders: portions(['o1', 28]), etaMin: 19 },
],
}
const OVEN: OvenBatch[] = [
{ cartId: 'C-011', orders: portions(['o1', 88]), totalMin: 120, remainingMin: 42 },
{ cartId: 'C-012', orders: portions(['o2', 60], ['o4', 44]), totalMin: 120, remainingMin: 71 },
{ cartId: 'C-013', orders: portions(['o3', 72]), totalMin: 120, remainingMin: 12 },
{ cartId: 'C-016', orders: portions(['o1', 40], ['o2', 36]), totalMin: 120, remainingMin: 98 },
{ cartId: 'C-017', orders: portions(['o4', 80]), totalMin: 120, remainingMin: 104 },
]
const FINISHED_BASE = { carts: 47, parts: 5120, scrap: 63, avgOvenMin: 118 }
const FINISHED_BY_ORDER: Record<string, number> = { o1: 1680, o2: 1290, o3: 1150, o4: 1000 }
const FINISHED_LIST = [
{ cartId: 'C-010', orders: portions(['o2', 50], ['o3', 40]), ago: 'vor 8 min' },
{ cartId: 'C-009', orders: portions(['o1', 64]), ago: 'vor 21 min' },
{ cartId: 'C-008', orders: portions(['o4', 58]), ago: 'vor 35 min' },
{ cartId: 'C-007', orders: portions(['o2', 44], ['o1', 20]), ago: 'vor 52 min' },
{ cartId: 'C-006', orders: portions(['o3', 72]), ago: 'vor 1 h 6 min' },
]
/** Build the sample payload for a range (finished figures scale; live stats don't). */
export function sampleOverview(range: RangeCode = '1d'): OverviewPayload {
const f = RANGE_FACTORS[range] ?? 1
return {
plant: 'Werk 1',
generatedAt: new Date().toISOString(),
range,
ovenTempC: 185,
ovenAvgMin: 120,
sample: true,
presses: PRESSES,
onWay: ON_WAY,
oven: OVEN,
finished: {
carts: Math.round(FINISHED_BASE.carts * f),
parts: Math.round(FINISHED_BASE.parts * f),
scrap: Math.round(FINISHED_BASE.scrap * f),
avgOvenMin: FINISHED_BASE.avgOvenMin,
byOrder: Object.entries(FINISHED_BY_ORDER).map(([k, v]) => ({
orderNo: ORDERS[k][0],
colorIndex: ORDERS[k][1],
parts: Math.round(v * f),
})),
},
finishedList: FINISHED_LIST,
}
}

77
src/i18n/de.ts Normal file
View File

@ -0,0 +1,77 @@
export default {
app: {
brand: 'PressV',
plant: 'Werk',
line: 'Temper-Linie',
subtitle: 'Live-Übersicht',
live: 'Live',
demo: 'Demo-Daten',
},
flow: {
presses: 'Pressen',
pressesSub: 'inkl. nicht verladen',
onWay: 'Auf dem Weg',
oven: 'Ofen',
finished: 'Fertig',
orders: 'Aufträge',
ordersMax: 'bis 4 / Wagen',
},
kpi: {
notVerladen: 'Nicht verladen · auf Linie',
notVerladenSub: 'Teile noch an der Presse',
onWay: 'Auf dem Weg',
onWaySub: '{n} Wagen unterwegs',
oven: 'Im Ofen',
ovenSub: '{n} Batches',
finished: 'Fertig',
finishedSub: '{n} Wagen getempert',
scrapRate: 'Ausschussquote',
scrapRateSub: 'Ausschuss / Gesamt',
},
line: {
header: 'Pressenhalle → auf dem Weg',
active: '{running}/{total} aktiv · Wagen hängen unter ihrer Presse',
toOven: 'Auf dem Weg',
toOvenTail: 'in den Ofen',
toStorage: 'Aus dem Ofen',
toStorageTail: 'Fertig / Lager',
wagen: '{n} Wagen',
},
press: {
good: 'Gut',
notVerladen: 'nicht verladen',
free: 'frei',
statusRun: 'Läuft',
statusStop: 'Störung',
statusIdle: 'Leerlauf',
},
lane: {
wagen: '{n} Wagen ↓',
none: '—',
parts: '{n} Teile',
ovenIn: 'Ofen in {n} min',
},
oven: {
title: 'Temper-Ofen',
meta: '{parts} Teile · {batches} Batches · Ø {avg} min',
nextOut: 'nächster raus',
progress: '{done}/{total} min getempert',
unit: 'min',
},
finished: {
title: 'Fertig · getempert',
range: 'Zeitraum · {label}',
goodParts: 'Gutteile',
carts: 'Wagen',
scrap: 'Ausschuss',
avgOven: 'Ø Ofenzeit',
},
range: {
label: 'Fertig · Zeitraum',
'12h': 'letzte 12 h',
'1d': 'letzter Tag',
'7d': 'letzte 7 Tage',
'14d': 'letzte 14 Tage',
'30d': 'letzte 30 Tage',
},
}

78
src/i18n/en.ts Normal file
View File

@ -0,0 +1,78 @@
// English mirrors de.ts exactly (same key shape) — de is the canonical schema.
export default {
app: {
brand: 'PressV',
plant: 'Plant',
line: 'Temper Line',
subtitle: 'Live Overview',
live: 'Live',
demo: 'Sample data',
},
flow: {
presses: 'Presses',
pressesSub: 'incl. not yet loaded',
onWay: 'On the way',
oven: 'Oven',
finished: 'Finished',
orders: 'Orders',
ordersMax: 'up to 4 / cart',
},
kpi: {
notVerladen: 'Not loaded · on line',
notVerladenSub: 'Parts still at the press',
onWay: 'On the way',
onWaySub: '{n} carts in transit',
oven: 'In oven',
ovenSub: '{n} batches',
finished: 'Finished',
finishedSub: '{n} carts tempered',
scrapRate: 'Scrap rate',
scrapRateSub: 'Scrap / total',
},
line: {
header: 'Press hall → on the way',
active: '{running}/{total} active · carts hang under their press',
toOven: 'On the way',
toOvenTail: 'into the oven',
toStorage: 'Out of the oven',
toStorageTail: 'finished / storage',
wagen: '{n} carts',
},
press: {
good: 'Good',
notVerladen: 'not loaded',
free: 'free',
statusRun: 'Running',
statusStop: 'Fault',
statusIdle: 'Idle',
},
lane: {
wagen: '{n} carts ↓',
none: '—',
parts: '{n} parts',
ovenIn: 'oven in {n} min',
},
oven: {
title: 'Temper oven',
meta: '{parts} parts · {batches} batches · avg {avg} min',
nextOut: 'next out',
progress: '{done}/{total} min tempered',
unit: 'min',
},
finished: {
title: 'Finished · tempered',
range: 'Window · {label}',
goodParts: 'Good parts',
carts: 'Carts',
scrap: 'Scrap',
avgOven: 'Avg oven time',
},
range: {
label: 'Finished · window',
'12h': 'last 12 h',
'1d': 'last day',
'7d': 'last 7 days',
'14d': 'last 14 days',
'30d': 'last 30 days',
},
}

28
src/i18n/index.ts Normal file
View File

@ -0,0 +1,28 @@
import { createI18n } from 'vue-i18n'
import de from './de'
import en from './en'
// German is the canonical schema (shop-floor default); en must match its shape.
// Module augmentation propagates the typed key paths to t() so a typo breaks the
// build instead of silently returning the key string.
type MessageSchema = typeof de
declare module 'vue-i18n' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface DefineLocaleMessage extends MessageSchema {}
}
// Default German; ?lang=en on the iframe URL switches (handy for Grafana embeds).
function initialLocale(): 'de' | 'en' {
const p = new URLSearchParams(window.location.search).get('lang')
return p === 'en' ? 'en' : 'de'
}
const i18n = createI18n<[MessageSchema], 'de' | 'en', false>({
legacy: false,
locale: initialLocale(),
fallbackLocale: 'de',
messages: { de, en },
})
export default i18n

44
src/main.ts Normal file
View File

@ -0,0 +1,44 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { definePreset } from '@primeuix/themes'
import Nora from '@primeuix/themes/nora'
import 'primeicons/primeicons.css'
import '@fontsource-variable/inter'
// @sp-ui-kit tokens FIRST so the CSS variables (surfaces, text, status, density,
// dark/light) exist before anything references them; app style.css layers the
// temper/order colours + prototype styles on top.
import '@sp-ui-kit/tokens/css'
import router from './router'
import i18n from './i18n'
import App from './App.vue'
import './style.css'
// Same Industrial preset as PressV: Nora with a sky-blue primary (industrial HMI
// accent). darkModeSelector ".dark" matches index.html's <html class="dark">.
const Industrial = definePreset(Nora, {
semantic: {
primary: {
50: '{sky.50}',
100: '{sky.100}',
200: '{sky.200}',
300: '{sky.300}',
400: '{sky.400}',
500: '{sky.500}',
600: '{sky.600}',
700: '{sky.700}',
800: '{sky.800}',
900: '{sky.900}',
950: '{sky.950}',
},
},
})
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(i18n)
app.use(PrimeVue, {
theme: { preset: Industrial, options: { darkModeSelector: '.dark' } },
})
app.mount('#app')

15
src/router/index.ts Normal file
View File

@ -0,0 +1,15 @@
import { createRouter, createWebHashHistory } from 'vue-router'
// Static (eager) import — NOT `() => import(...)`. The board runs embedded in a
// Grafana kiosk panel; a single bundle that loads once avoids re-fetching lazy
// chunks over a flaky shop-floor network (same rationale as PressV).
import TemperOverviewPage from '@/views/TemperOverviewPage.vue'
// Hash history so StaticFiles never sees a deep path it can't resolve — the
// server-visible URL stays "/module/temper_overview/" and routing happens after
// "#". Base comes from vite `base` via import.meta.env.BASE_URL.
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [{ path: '/', name: 'overview', component: TemperOverviewPage }],
})
export default router

View File

@ -0,0 +1,116 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/api'
import { sampleOverview } from '@/data/sample'
import type { OverviewPayload, RangeCode } from '@/types/overview'
// Mirrors PressV's productionOverview store: sync loadCache() → hydrate() cold
// start → refresh() background fetch → startPolling(ms). sessionStorage caches the
// last payload so a reload paints instantly with stale data while fresh loads.
//
// Fallback: if the data API is unreachable (e.g. `npm run dev` with no gateway),
// the store falls back to the bundled sample payload so the design still renders.
const CACHE_KEY = 'temper_overview_payload'
export const useTemperOverviewStore = defineStore('temperOverview', () => {
const payload = ref<OverviewPayload | null>(null)
const range = ref<RangeCode>('1d')
const loading = ref(false)
const error = ref<string | null>(null)
const usingSample = ref(false)
const lastUpdated = ref<number | null>(null)
let timer: ReturnType<typeof setInterval> | null = null
const hasData = computed(() => payload.value !== null)
function saveCache() {
try {
sessionStorage.setItem(CACHE_KEY, JSON.stringify({ payload: payload.value, range: range.value }))
} catch {
/* best-effort */
}
}
function loadCache() {
try {
const raw = sessionStorage.getItem(CACHE_KEY)
if (!raw) return
const blob = JSON.parse(raw) as { payload: OverviewPayload | null; range: RangeCode }
payload.value = blob.payload ?? null
if (blob.range) range.value = blob.range
} catch {
/* corrupt cache — ignore */
}
}
/** Foreground load: spinner only on a true cold start (no cache). */
async function hydrate() {
if (!hasData.value) loading.value = true
await refresh()
}
/** Background refresh. Keeps prior data on error; falls back to sample data. */
async function refresh() {
error.value = null
try {
const res = await api.get<OverviewPayload>('/overview', { params: { range: range.value } })
// Guard the shape: a dev server (vite dev/preview) SPA-fallbacks unknown
// routes to index.html (HTTP 200 HTML), so a missing backend looks like a
// "successful" non-JSON response. Treat anything without a presses[] array
// as a miss → fall through to the bundled sample.
if (!res.data || !Array.isArray(res.data.presses)) {
throw new Error('Unexpected /overview response (no backend?)')
}
payload.value = res.data
usingSample.value = res.data.sample === true
lastUpdated.value = Date.now()
saveCache()
} catch (e) {
// No backend (dev) or API down → render the bundled sample so the board is
// never blank. Real deployments hit the gateway API and never land here.
payload.value = sampleOverview(range.value)
usingSample.value = true
lastUpdated.value = Date.now()
error.value = e instanceof Error ? e.message : 'Overview API unreachable — showing sample data'
} finally {
loading.value = false
}
}
/** Change the finished-figures window and refetch immediately. */
async function setRange(code: RangeCode) {
range.value = code
await refresh()
}
function startPolling(ms = 12000) {
if (timer) return
timer = setInterval(refresh, ms)
}
function stopPolling() {
if (timer) {
clearInterval(timer)
timer = null
}
}
loadCache()
return {
payload,
range,
loading,
error,
usingSample,
lastUpdated,
hasData,
hydrate,
refresh,
setRange,
startPolling,
stopPolling,
}
})

146
src/style.css Normal file
View File

@ -0,0 +1,146 @@
@import 'tailwindcss';
/* Class-based dark mode: dark: utilities apply when .dark is on any ancestor
(index.html sets <html class="dark">). Matches PressV + PrimeVue's selector. */
@variant dark (&:where(.dark, .dark *));
/* HMI panel breakpoints (same as the fleet) — usable as hmi-sm:/hmi-md:/hmi-lg:. */
@theme {
--breakpoint-hmi-sm: 800px;
--breakpoint-hmi-md: 1024px;
--breakpoint-hmi-lg: 1280px;
}
/* ==========================================================================
BOARD TOKENS
Neutral surfaces are the locked prototype's board palette (dark, layered,
with a radial glow). The FLOW ACCENTS match PressV's --c-* process colours
verbatim so the temper process reads the same across every SPRO app
(press=violet, on-the-way/cart=cyan, oven=orange, hot=red, finished=emerald).
Font families come from @sp-ui-kit/tokens (Inter Variable + mono).
========================================================================== */
:root {
--ground: #11151b;
--panel: #1a212a;
--panel-2: #222b36;
--panel-3: #2a3542;
--line: #2c3641;
--line-2: #3a4654;
--ink: #e8edf2;
--ink-dim: #97a3b1;
--ink-faint: #5a6573;
--press: #a78bfa; /* violet — Pressen / not yet loaded */
--way: #22d3ee; /* cyan — Auf dem Weg / cart */
--oven: #f97316; /* orange — Ofen */
--hot: #ef4444; /* red — Ofen, kurz vor raus */
--done: #34d399; /* emerald — Fertig / Lager */
--o1: #5794f2;
--o2: #e8825a;
--o3: #b57edc;
--o4: #4fb7a8;
--run: #34d399;
--stop: #ef4444;
--idle: #5a6573;
--r: 2px;
--sans: var(--font-family-sans, 'Inter Variable', ui-sans-serif, system-ui, sans-serif);
--mono: var(--font-family-mono, ui-monospace, 'Cascadia Mono', 'SFMono-Regular', Menlo, Consolas, monospace);
}
/* Light theme (no .dark on <html>): cool slate surfaces + darker accents that
stay legible on light backgrounds (PressV's contrast-checked --c-* light set). */
html:not(.dark) {
--ground: #f1f5f9;
--panel: #e2e8f0;
--panel-2: #cbd5e1;
--panel-3: #94a3b8;
--line: #cbd5e1;
--line-2: #94a3b8;
--ink: #0f172a;
--ink-dim: #475569;
--ink-faint: #64748b;
--press: #6d28d9;
--way: #0e7490;
--oven: #c2410c;
--hot: #dc2626;
--done: #15803d;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
/* Fluid base like the fleet: ~13px small screens → 16px large. */
font-size: clamp(13px, 1.6vw, 16px);
font-family: var(--sans);
}
body {
margin: 0;
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
background:
radial-gradient(1300px 700px at 72% -12%, #182230 0%, transparent 58%),
var(--ground);
}
html:not(.dark) body {
background:
radial-gradient(1300px 700px at 72% -12%, #e2e8f0 0%, transparent 58%),
var(--ground);
}
/* Tabular monospace numerals — used for every count on the board. */
.num {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
/* ── Global keyframes (referenced by scoped component styles) ──────────────── */
@keyframes blink {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
@keyframes flowdown {
to {
background-position: 0 11px;
}
}
@keyframes flame {
0%,
100% {
opacity: 0.8;
transform: translateY(0) scale(1);
}
50% {
opacity: 1;
transform: translateY(-1px) scale(1.07);
}
}
@keyframes flicker {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
/* Reduced motion: disable the conveyor / flame animations (accessibility). */
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
}
}

84
src/types/overview.ts Normal file
View File

@ -0,0 +1,84 @@
// Response contract for GET /module/temper_overview/api/overview
// Mirrors the payload built in ../api/routes.py (Python). Keep the two in sync.
export type RangeCode = '12h' | '1d' | '7d' | '14d' | '30d'
export type PressStatus = 'run' | 'stop' | 'idle'
/** An order portion on a cart: chip colour + order number + piece count. */
export interface OrderPortion {
orderNo: string
qty: number
colorIndex: number
}
/** An order chip on a press header (no qty). */
export interface OrderChip {
orderNo: string
colorIndex: number
}
export interface PressInfo {
name: string
status: PressStatus
orders: OrderChip[]
/** good parts produced at the press (this order/shift) */
good: number
/** last cycle time in seconds, formatted (e.g. "4.2"); null when stopped/idle */
cycleTime: string | null
/** produced but not yet loaded onto a temper cart */
notVerladen: number
note: string | null
/** true for the dashed empty placeholder press (e.g. P10) */
placeholder: boolean
}
export interface CartOnWay {
cartId: string
orders: OrderPortion[]
/** minutes until it enters the oven */
etaMin: number
}
export interface OvenBatch {
cartId: string
orders: OrderPortion[]
totalMin: number
remainingMin: number
}
export interface FinishedByOrder {
orderNo: string
colorIndex: number
parts: number
}
export interface FinishedSummary {
carts: number
parts: number
scrap: number
avgOvenMin: number
byOrder: FinishedByOrder[]
}
export interface FinishedCart {
cartId: string
orders: OrderPortion[]
ago: string
}
export interface OverviewPayload {
plant: string
/** ISO timestamp the payload was generated (tz-aware) */
generatedAt: string
range: RangeCode
ovenTempC: number
ovenAvgMin: number
/** true when the backend is returning sample data (base scaffold) */
sample?: boolean
presses: PressInfo[]
/** carts on the way, keyed by origin press name */
onWay: Record<string, CartOnWay[]>
oven: OvenBatch[]
finished: FinishedSummary
finishedList: FinishedCart[]
}

View File

@ -0,0 +1,63 @@
<!--
TemperOverviewPage.vue the whole-line temper board (design #4 "Shop-floor
Schematic", locked). One connected line: Pressen (cyan ) Ofen (green )
Fertig. Carts hang under their origin press; the oven & finished bands scroll
horizontally. The time-range picker scopes ONLY the Fertig figures.
Data comes from the store (GET /api/overview, polled). With no backend the
store falls back to bundled sample data, so `npm run dev` renders the design.
-->
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import OverviewHeader from '@/components/OverviewHeader.vue'
import FlowLegend from '@/components/FlowLegend.vue'
import KpiStrip from '@/components/KpiStrip.vue'
import ProductionLine from '@/components/ProductionLine.vue'
import { useTemperOverviewStore } from '@/stores/temperOverview'
const { t } = useI18n()
const store = useTemperOverviewStore()
const { payload, range, loading, hasData, usingSample } = storeToRefs(store)
onMounted(async () => {
await store.hydrate()
store.startPolling(12000)
})
onUnmounted(() => store.stopPolling())
</script>
<template>
<div class="wrap">
<template v-if="hasData && payload">
<OverviewHeader
:plant="payload.plant"
:range="range"
:sample="usingSample"
@update:range="store.setRange"
/>
<FlowLegend />
<KpiStrip :payload="payload" />
<ProductionLine :payload="payload" />
</template>
<div v-else-if="loading" class="state">{{ t('app.live') }} </div>
</div>
</template>
<style scoped>
.wrap {
max-width: 1240px;
margin: 0 auto;
padding: 20px 22px 80px;
}
.state {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
color: var(--ink-dim);
font-family: var(--mono);
}
</style>

15
tailwind.config.cjs Normal file
View File

@ -0,0 +1,15 @@
// Tailwind config — opts into the @sp-ui-kit/tokens preset (same as PressV).
//
// This SPA uses Tailwind v4's CSS-first @theme directive in src/style.css for
// its OWN tokens (flow/order colours, HMI breakpoints). This file is ADDITIVE —
// it pulls in the sp-ui-kit token surface (primary scale, surface levels,
// status colours, nav vars, density-aware spacing) so `surface-1`, `ink`,
// `line`, `danger`, `hmi-sm:` etc. resolve identically to the rest of the fleet.
// Tailwind v4 auto-discovers this file; no @config directive needed in the CSS.
//
// .cjs because package.json is "type": "module" — keeps require() available.
/** @type {import('tailwindcss').Config} */
module.exports = {
presets: [require('@sp-ui-kit/tokens/tailwind-preset')],
}

23
tsconfig.app.json Normal file
View File

@ -0,0 +1,23 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"noEmit": true,
"types": ["vite/client"],
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"exactOptionalPropertyTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"]
}

4
tsconfig.json Normal file
View File

@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

26
tsconfig.node.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

34
vite.config.ts Normal file
View File

@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
// This SPA is served by the SPRO gateway as static files under a URL PREFIX
// (see ../__init__.py → MODULE_PREFIX). `base` MUST equal that prefix WITH a
// trailing slash, or the built asset URLs 404 behind the gateway. It also
// becomes `import.meta.env.BASE_URL`, which src/api/index.ts uses to build the
// data-API base — so the app keeps working if the prefix ever changes.
const BASE = '/module/temper_overview/'
export default defineConfig({
base: BASE,
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
// Match PressV: dedupe the framework copies so a future `yarn link` of an
// @sp-ui-kit/* package can't pull a second Vue/PrimeVue into the bundle.
dedupe: ['vue', 'primevue', '@primevue/themes', '@primeuix/themes', 'primeicons'],
},
build: {
outDir: 'dist',
emptyOutDir: true,
// Eager, single-bundle build (the router uses static imports — see
// src/router/index.ts). This page is embedded in a Grafana kiosk panel; a
// single bundle that loads once avoids re-fetching lazy chunks over a flaky
// shop-floor network. The committed dist/ is what the gateway serves — the
// deploy host has no Node toolchain, so we never build there.
chunkSizeWarningLimit: 1500,
},
})