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