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:
parent
e1adb06815
commit
aa115a0b8d
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
11
.npmrc.example
Normal 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
153
README.md
@ -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
51
__init__.py
Normal 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
21
_db.py
Normal 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
0
api/__init__.py
Normal file
141
api/routes.py
Normal file
141
api/routes.py
Normal 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
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
1
dist/assets/index-xY8RtBtx.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2
vendored
Normal file
BIN
dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2
vendored
Normal file
Binary file not shown.
BIN
dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2
vendored
Normal file
BIN
dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2
vendored
Normal file
Binary file not shown.
BIN
dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2
vendored
Normal file
BIN
dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2
vendored
Normal file
Binary file not shown.
BIN
dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2
vendored
Normal file
BIN
dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2
vendored
Normal file
Binary file not shown.
BIN
dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2
vendored
Normal file
BIN
dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2
vendored
Normal file
Binary file not shown.
BIN
dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2
vendored
Normal file
BIN
dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2
vendored
Normal file
Binary file not shown.
BIN
dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2
vendored
Normal file
BIN
dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2
vendored
Normal file
Binary file not shown.
BIN
dist/assets/primeicons-C6QP2o4f.woff2
vendored
Normal file
BIN
dist/assets/primeicons-C6QP2o4f.woff2
vendored
Normal file
Binary file not shown.
BIN
dist/assets/primeicons-DMOk5skT.eot
vendored
Normal file
BIN
dist/assets/primeicons-DMOk5skT.eot
vendored
Normal file
Binary file not shown.
345
dist/assets/primeicons-Dr5RGzOO.svg
vendored
Normal file
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
BIN
dist/assets/primeicons-MpK4pl85.ttf
vendored
Normal file
Binary file not shown.
BIN
dist/assets/primeicons-WjwUDZjB.woff
vendored
Normal file
BIN
dist/assets/primeicons-WjwUDZjB.woff
vendored
Normal file
Binary file not shown.
18
dist/index.html
vendored
Normal file
18
dist/index.html
vendored
Normal 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
7
env.d.ts
vendored
Normal 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
17
index.html
Normal 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
2313
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal 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
7
src/App.vue
Normal 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
14
src/api/index.ts
Normal 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
|
||||||
214
src/components/FinishedBand.vue
Normal file
214
src/components/FinishedBand.vue
Normal 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>
|
||||||
88
src/components/FlowLegend.vue
Normal file
88
src/components/FlowLegend.vue
Normal 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
116
src/components/KpiStrip.vue
Normal 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>
|
||||||
39
src/components/OrderLine.vue
Normal file
39
src/components/OrderLine.vue
Normal 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
199
src/components/OvenBand.vue
Normal 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>
|
||||||
146
src/components/OverviewHeader.vue
Normal file
146
src/components/OverviewHeader.vue
Normal 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>
|
||||||
153
src/components/PressCell.vue
Normal file
153
src/components/PressCell.vue
Normal 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>
|
||||||
122
src/components/PressLane.vue
Normal file
122
src/components/PressLane.vue
Normal 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>
|
||||||
136
src/components/ProductionLine.vue
Normal file
136
src/components/ProductionLine.vue
Normal 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
11
src/data/orderColors.ts
Normal 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
100
src/data/sample.ts
Normal 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
77
src/i18n/de.ts
Normal 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
78
src/i18n/en.ts
Normal 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
28
src/i18n/index.ts
Normal 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
44
src/main.ts
Normal 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
15
src/router/index.ts
Normal 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
|
||||||
116
src/stores/temperOverview.ts
Normal file
116
src/stores/temperOverview.ts
Normal 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
146
src/style.css
Normal 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
84
src/types/overview.ts
Normal 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[]
|
||||||
|
}
|
||||||
63
src/views/TemperOverviewPage.vue
Normal file
63
src/views/TemperOverviewPage.vue
Normal 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
15
tailwind.config.cjs
Normal 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
23
tsconfig.app.json
Normal 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
4
tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal 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
34
vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user