feat: scaffold Ausschuss-Station reject-booking page-module (temper_rejects)

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_rejects. A central,
login-free reject-booking terminal for all presses' tempered carts.

- Implements the LOCKED design (memory: ausschuss-station): per-ttId list (newest
  first), left press checkbox filter (default all, greyed when no carts, hit-count
  badge), style-C 5-station temper belt per entry (badges + animated conveyor),
  12h/1T/7T/14T/30T window, touch density. Booking dialog = PrimeVue Dialog +
  InputNumber + reason Select. Colours match PressV + Ausschuss #C4162A.
- Python page-module wrapper: register(app) mounts committed dist/ + a no-auth API
  (GET /overview, GET /reasons, POST /reject). _db.py exposes run_select_query +
  transaction() for the future INSERT.
- Backend returns SAMPLE data and POST /reject echoes success WITHOUT writing;
  frontend falls back to bundled sample so `npm run dev` is fully demoable offline.
  TODOs point at the real temper_tracking reads + the rejects INSERT (needs a DBA
  rejTtId column) + the login-free-write security open question.
- Rich README bakes in the design, contract, wiring, open questions + Grafana embed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-01 20:44:22 +02:00
parent 786a499c81
commit c340aef2c3
50 changed files with 6442 additions and 1 deletions

26
.gitignore vendored Normal file
View File

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

11
.npmrc.example Normal file
View File

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

151
README.md
View File

@ -1,2 +1,151 @@
# SPA_AfterTemperRejection
# SPA_AfterTemperRejection — Ausschuss-Station
A central, **login-free** reject-booking terminal for **all presses' tempered
carts** (Werk Cadolzburg). A Vue 3 SPA that the **SPRO Universal Gateway** serves as
a static page-module under `/module/temper_rejects/`. It lists every tempered cart
(one row per `ttId`, newest first), shows each order's temper flow, and lets a
shop-floor operator book an Ausschuss (count + reason) against a specific cart.
> **Standalone orientation.** Developed on its own (sibling repo, like `FastPress` /
> `PressV`), mounted into the gateway as a submodule at `modules/temper_rejects/`.
> This README bakes in everything a fresh session needs.
Design is **LOCKED** (see `.claude/memory/ausschuss-station.md` in the gateway repo):
per-`ttId` list · press checkbox filter on the left (default **all**, greyed when a
press has no carts in the window, hit-count badge) · **style-C** 5-station temper
belt under each entry (number badges + animated conveyor) · time window
12h/1T/7T/14T/30T. Touch-density controls (56px). Run `npm run dev` to see it.
---
## Quick start (standalone dev)
```bash
cp .npmrc.example .npmrc # paste a Gitea PAT with "package: Read" scope (one-time)
npm install
npm run dev # http://localhost:5173/module/temper_rejects/
```
With **no gateway backend**, the store falls back to **bundled sample data**
(`src/data/sample.ts`) and the booking flow simulates success — so the whole
terminal (filter, belt, dialog) is demoable offline (a `DEMO-DATEN` tag shows).
`npm run build``dist/` (committed).
---
## How it's wired into the gateway (page-module contract)
Per `agent_docs/vue-page-modules.md`. The **repo root is a Python package**
(`__init__.py`) so the loader can `import temper_rejects` 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` (reads) **and** `transaction()` (the reject write) |
| `api/routes.py` | no-auth API: `GET /overview`, `GET /reasons`, `POST /reject` |
| `dist/` | **committed** build output — the gateway serves this |
**URL prefix in two places that MUST match:** `vite.config.ts`
`base: '/module/temper_rejects/'` and `__init__.py`
`MODULE_PREFIX = "/module/temper_rejects"`. The frontend derives its API base from
`import.meta.env.BASE_URL`.
```bash
# in the gateway repo (fastAPI)
git submodule add https://git.sprodat.eu/Erik/SPA_AfterTemperRejection.git modules/temper_rejects
# MODULES_ENABLED must include temper_rejects (or be empty = load all)
```
Restart → log shows `loaded module: temper_rejects`. Smoke:
`http://localhost:8800/module/temper_rejects/` and `…/api/overview`. **Deploy:**
`npm run build`, commit `dist/`, bump the submodule pin. Never build on the host.
---
## Data API contract
Types are authoritative in [`src/types/station.ts`](src/types/station.ts); Python
mirrors them in [`api/routes.py`](api/routes.py).
- `GET /module/temper_rejects/api/overview?window=12h|1T|7T|14T|30T` → tempered
carts (per `ttId`, newest first) + the press list for the filter.
- `GET /module/temper_rejects/api/reasons` → active reject reasons (`id`, `reason_de`, `reason_en`).
- `POST /module/temper_rejects/api/reject` → body `{ press, orderNo, ttId, count, reason }`.
```jsonc
// overview
{ "window":"7T","generatedAt":"…","sample":true,
"presses":["P1","P2","P3","P4","P5","P6","P7","P8","P9"],
"entries":[{ "ttId":10231,"cartId":"00A17226","press":"P5","orderNo":"O-4455",
"article":"Gehäuse 12 mm","partsGood":60,"scrap":2,"doneAt":"…",
"flow":{ "notYetSent":120,"wagen":60,"unterwegs":40,"ofen":72,"fertig":260 } }] }
```
The `flow` object is the **order-level** 5-station distribution shown on the belt.
Belt colour mapping (matches PressV process colours): `notYetSent`→violet,
`wagen`→cyan, `unterwegs`→amber, `ofen`→orange, `fertig`→emerald; the reject accent
is Grafana dark-red `#C4162A`.
### Wiring the REAL data (currently SAMPLE)
`api/routes.py` returns sample data and `POST /reject` echoes success **without
writing**. To make it live (see the `TODO` there + `.claude/memory/ausschuss-station.md`):
- `from .._db import run_select_query, transaction`.
- **overview:** read `production.temper_tracking` across **all** presses (un-scoped),
one row per `ttId`, newest first by `ttOutOfOvenAt` (`ttStatus='done'`); join
`active_orders`/`finished_orders` for the article. Add each order's 5-station
distribution — reuse `modules/fastpress/press/temper.py::_temper_aggregates_all`
(notYetSent / sentToTemper / onWayToOven / inOven / finishedTempering). Filter by
`window` on `ttOutOfOvenAt`.
- **reasons:** `SELECT id, reason_de, reason_en FROM settings.reject_reasons WHERE active=1`.
- **reject:** one `INSERT` into `production.rejects` inside a `transaction()` — same
shape as `modules/fastpress/press/rejects.py::send_reject`. Booking references the
cart, which needs an **additive nullable `rejTtId` column** (a DBA applies it; the
runtime account has no `ALTER`). The device-scoped `POST /press/rejects/send`
can't be reused — it derives the press from the device JWT; here `press`+`ttId`
come from the body.
### Open questions before go-live (from the design memory)
1. **Security of the login-free write:** network/reverse-proxy restriction only, or a
static `STATION_TOKEN` in `.env`? (No TLS/reverse-proxy exists yet.)
2. Confirm the `rejTtId` column name + that per-`ttId` booking is desired (yes, per spec).
3. Belt semantics: order-level distribution (chosen) vs. the cart's own journey position.
---
## Tech stack (matches PressV / the fleet)
Vue 3.5 · Vite 8 · TS 5.9 · Pinia 3 · vue-router 4 (hash) · vue-i18n 10 (de default,
`?lang=en`) · PrimeVue 4.5 (Nora + **Industrial** preset — the booking dialog uses
`Dialog`/`Select`/`InputNumber`/`Button`) · Tailwind v4 · `@sp-ui-kit/tokens` ·
Inter Variable. **Dark-first**, `data-density="touch"` (56px controls), flat 2px
corners, reduced-motion disables the conveyor.
Store: `src/stores/temperRejects.ts``hydrate()` (overview + reasons) →
`refresh()``startPolling(20000)`; press filter state (`checkedPresses`, default
all) + `submitReject()` (posts, then refreshes; simulates success with no backend).
Falls back to sample data when a response lacks an `entries[]` array.
> Not used yet but available in the fleet: `@sp-ui-kit/ui-core` (e.g. `SpNumpadDialog`
> would be an even more touch-friendly count entry than the PrimeVue `InputNumber`).
### Design tokens
Flow/reject accents (`src/style.css`): nicht-gesendet `#a78bfa`, Wagen `#22d3ee`,
Unterwegs `#fbbf24`, Ofen `#f97316` (hot `#ef4444`), Fertig `#34d399`, Ausschuss
`#C4162A``:root` dark + `html:not(.dark)` light variants.
---
## Grafana embedding
```html
<iframe src="http://<gateway-host>:8800/module/temper_rejects/#/"
width="100%" height="900" style="border:0"></iframe>
```
(`?lang=en` before the `#` for English.) Gateway sets no frame headers, so it embeds
directly. Because this terminal **writes**, resolve open question #1 (security)
before exposing it beyond a trusted LAN.

50
__init__.py Normal file
View File

@ -0,0 +1,50 @@
"""temper_rejects — the Ausschuss-Station page-module for the SPRO gateway.
A central, **login-free** reject-booking terminal for ALL presses' tempered
carts (Werk Cadolzburg). Mounted into the SPRO Universal Gateway as a git
submodule at ``<gateway>/modules/temper_rejects/`` and discovered by the kernel
module loader (``app.core.modules.load_modules``), which imports this package and
calls ``register(app)``.
It does two things:
* mounts the built Vue SPA (``dist/``) as static files under ``/module/temper_rejects``;
* exposes a small no-auth JSON API under ``/module/temper_rejects/api`` reads
(tempered carts per ``ttId`` + reject reasons) AND one narrow write
(``POST /reject`` one row in ``production.rejects``).
No login / device context by design this is a central shop-floor terminal. The
existing device-scoped ``POST /press/rejects/send`` derives the press from the
device JWT, which is useless for a multi-press terminal; hence the un-scoped
variant here, which takes ``press`` + ``ttId`` from the request body. Non-sensitive
shop-floor data. Security posture (network/reverse-proxy vs. a static token) is an
open question see this repo's README + ``.claude/memory/ausschuss-station.md``.
See ``agent_docs/vue-page-modules.md`` in the gateway repo for the module runbook.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
MODULE_NAME = "temper_rejects"
# MUST match vite.config.ts `base` (with a trailing slash on the Vite side).
MODULE_PREFIX = "/module/temper_rejects"
def register(app: FastAPI) -> None:
"""Mount the data API and the SPA onto the gateway app.
API routes are registered FIRST a ``StaticFiles`` mount captures everything
under its prefix, so the ``/api`` routes must exist before the mount or they
get shadowed. ``dist/`` MUST exist (it is committed) or this raises and the
loader logs-and-skips the module (kernel stays up, page 404s).
"""
from .api import routes
app.include_router(routes.router)
dist = Path(__file__).resolve().parent / "dist"
app.mount(MODULE_PREFIX, StaticFiles(directory=dist, html=True), name=MODULE_NAME)

34
_db.py Normal file
View File

@ -0,0 +1,34 @@
"""Kernel DB seam for the temper_rejects page-module.
Mirrors ``modules/fastpress/press/_db.py``: DB access goes ONLY through the
kernel, re-exported here so the coupling to ``app.db`` lives in one place. This
page READS tempered carts + reject reasons and performs one narrow WRITE (insert a
reject row), so it exposes both the SELECT-only proxy and a transactional
connection::
from .._db import run_select_query, transaction
The base scaffold's API (``api/routes.py``) returns SAMPLE data and does NOT import
this yet wiring the real reads + the ``POST /reject`` write is the next step
(see the TODO in ``api/routes.py``).
NOTE: no connection happens at import SQLAlchemy's engine is lazy.
"""
from __future__ import annotations
from contextlib import contextmanager
from app.db.mariadb import engine, run_select_query # noqa: F401 (kernel DB layer)
__all__ = ["run_select_query", "transaction", "engine"]
@contextmanager
def transaction():
"""Yield a transactional connection: commit on clean exit, rollback on error.
Thin wrapper over the kernel engine's ``begin()`` — same shape as fastpress's
reject write (``modules/fastpress/press/rejects.py``), which this page's real
``POST /reject`` will follow (one INSERT into ``production.rejects``)."""
with engine.begin() as conn:
yield conn

0
api/__init__.py Normal file
View File

144
api/routes.py Normal file
View File

@ -0,0 +1,144 @@
"""No-auth JSON API for the Ausschuss-Station (central reject-booking terminal).
Served same-origin under ``/module/temper_rejects``. Three routes:
* GET /module/temper_rejects/api/overview?window=7T tempered carts (per ttId)
* GET /module/temper_rejects/api/reasons active reject reasons
* POST /module/temper_rejects/api/reject book one Ausschuss
No ``verify_token`` dependency: this is a central login-free terminal (no device
context). ``press`` + ``ttId`` come from the request BODY, unlike the device-scoped
``POST /press/rejects/send`` in fastpress.
STATUS: BASE SCAFFOLD
Returns realistic SAMPLE data shaped EXACTLY like the frontend contract
(``src/types/station.ts``); POST /reject validates + echoes success WITHOUT writing.
This lets the terminal render + exercise the booking flow with no database.
TODO wire real data (see ``.claude/memory/ausschuss-station.md`` in the gateway):
* ``from .._db import run_select_query, transaction``
* OVERVIEW: read ``production.temper_tracking`` across ALL presses (un-scoped),
one row per ``ttId``, newest first by ``ttOutOfOvenAt`` (status='done'); join
``active_orders``/``finished_orders`` for article. Per entry, add the ORDER's
5-station distribution (reuse fastpress ``temper._temper_aggregates_all``:
notYetSent / sentToTemper / onWayToOven / inOven / finishedTempering).
* REASONS: ``SELECT id, reason_de, reason_en FROM settings.reject_reasons WHERE active=1``.
* REJECT: one INSERT into ``production.rejects`` inside a ``transaction()`` (same
shape as fastpress ``rejects.send_reject``). Booking references the cart
needs the additive nullable ``rejTtId`` column (DBA applies it; runtime account
has no ALTER). Confirm the security posture (network-only vs. a static token)
before exposing the write.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from fastapi import APIRouter, Query
from pydantic import BaseModel, ConfigDict
router = APIRouter(prefix="/module/temper_rejects/api", tags=["temper_rejects"])
# window code → minutes (max 30 days). Scopes which finished carts are listed.
_WINDOW_MINUTES: dict[str, int] = {"12h": 720, "1T": 1440, "7T": 10080, "14T": 20160, "30T": 43200}
_DEFAULT_WINDOW = "7T"
# All presses in the plant — drives the left-hand filter checkboxes (default all).
_PRESSES = ["P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9"]
_REASONS = [
{"id": 1, "reason_de": "Maßabweichung", "reason_en": "Dimensional deviation"},
{"id": 2, "reason_de": "Lunker", "reason_en": "Voids"},
{"id": 3, "reason_de": "Bindenaht", "reason_en": "Weld line"},
{"id": 4, "reason_de": "Verbrennung", "reason_en": "Burn mark"},
{"id": 5, "reason_de": "Verzug", "reason_en": "Warping"},
{"id": 6, "reason_de": "Sonstiges", "reason_en": "Other"},
]
def _flow(not_sent: int, wagen: int, unterwegs: int, ofen: int, fertig: int) -> dict[str, int]:
"""Order-level 5-station distribution shown on the belt (see the frontend
TemperBelt colours: nichtGesendet=violet, wagen=cyan, unterwegs=amber,
ofen=orange, fertig=emerald)."""
return {"notYetSent": not_sent, "wagen": wagen, "unterwegs": unterwegs, "ofen": ofen, "fertig": fertig}
# (ttId, cartId, press, orderNo, article, partsGood, scrap, ageMin, flow)
_ENTRIES_RAW: list[tuple[int, str, str, str, str, int, int, int, dict[str, int]]] = [
(10231, "00A17226", "P5", "O-4455", "Gehäuse 12 mm", 60, 2, 90, _flow(120, 60, 40, 72, 260)),
(10230, "00917226", "P8", "O-4489", "Halter Typ A", 80, 1, 150, _flow(40, 30, 36, 80, 210)),
(10229, "00817226", "P3", "O-4471", "Deckel rund", 54, 4, 320, _flow(180, 54, 30, 88, 300)),
(10228, "00717226", "P1", "O-4471", "Deckel rund", 64, 0, 500, _flow(180, 54, 30, 88, 300)),
(10227, "00617226", "P2", "O-4460", "Klammer klein", 50, 3, 900, _flow(90, 42, 0, 60, 190)),
(10226, "00517226", "P5", "O-4455", "Gehäuse 12 mm", 72, 5, 1400, _flow(120, 60, 40, 72, 260)),
(10225, "00417226", "P6", "O-4460", "Klammer klein", 44, 1, 3000, _flow(90, 42, 0, 60, 190)),
(10224, "00317226", "P8", "O-4489", "Halter Typ A", 58, 2, 8000, _flow(40, 30, 36, 80, 210)),
(10223, "00217226", "P9", "O-4471", "Deckel rund", 40, 0, 22000, _flow(180, 54, 30, 88, 300)),
]
def _entry(row: tuple, now: datetime) -> dict[str, Any]:
tt_id, cart_id, press, order_no, article, good, scrap, age_min, flow = row
return {
"ttId": tt_id,
"cartId": cart_id,
"press": press,
"orderNo": order_no,
"article": article,
"partsGood": good,
"scrap": scrap,
"doneAt": (now - timedelta(minutes=age_min)).astimezone().isoformat(),
"flow": flow,
}
class RejectBody(BaseModel):
# permissive like fastpress's reject schema; validated in the handler.
model_config = ConfigDict(extra="allow")
press: str | None = None
orderNo: str | None = None
ttId: int | None = None
count: int | None = None
reason: int | None = None
@router.get("/overview", summary="Tempered carts per ttId, newest first (SAMPLE data)")
def overview(window: str = Query(_DEFAULT_WINDOW, description="12h|1T|7T|14T|30T")) -> dict[str, Any]:
code = window if window in _WINDOW_MINUTES else _DEFAULT_WINDOW
limit_min = _WINDOW_MINUTES[code]
now = datetime.now()
entries = [_entry(r, now) for r in _ENTRIES_RAW if r[7] <= limit_min] # r[7] = ageMin
return {
"window": code,
"generatedAt": now.astimezone().isoformat(),
"sample": True, # frontend shows a "Demo-Daten" hint while set
"presses": _PRESSES,
"entries": entries,
}
@router.get("/reasons", summary="Active reject reasons")
def reasons() -> dict[str, Any]:
return {"reasons": _REASONS, "sample": True}
@router.post("/reject", summary="Book one Ausschuss against a ttId (SAMPLE — no write)")
def reject(body: RejectBody) -> dict[str, Any]:
# Validation mirrors fastpress send_reject (positive count + reason). The base
# scaffold does NOT write — it echoes success so the terminal's booking flow is
# exercisable end-to-end. See the module TODO for the real INSERT.
try:
count = int(body.count or 0)
reason_id = int(body.reason or 0)
tt_id = int(body.ttId or 0)
except (ValueError, TypeError):
return {"success": False, "error": "bad_request", "message": "count, reason, ttId must be integers"}
if not body.press or tt_id <= 0:
return {"success": False, "error": "bad_request", "message": "press and ttId are required"}
if count <= 0 or reason_id <= 0:
return {"success": False, "error": "bad_request", "message": "count and reason must be positive"}
return {
"success": True,
"sample": True,
"message": "Ausschuss gebucht (Demo — nicht gespeichert)",
"booked": {"press": body.press, "orderNo": body.orderNo, "ttId": tt_id, "count": count, "reason": reason_id},
}

1678
dist/assets/index-CzdVhZJR.js vendored Normal file

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 334 KiB

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

Binary file not shown.

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

Binary file not shown.

18
dist/index.html vendored Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<!--
Dark-first (class="dark"). data-density="touch": this is a shop-floor TOUCH
terminal (operators book rejects with a finger), so controls use the 56px
touch sizing from @sp-ui-kit tokens — unlike the view-only overview board.
-->
<html lang="de" class="dark" data-density="touch">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ausschuss-Station</title>
<script type="module" crossorigin src="/module/temper_rejects/assets/index-CzdVhZJR.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-aippIsDS.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

7
env.d.ts vendored Normal file
View File

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

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<!--
Dark-first (class="dark"). data-density="touch": this is a shop-floor TOUCH
terminal (operators book rejects with a finger), so controls use the 56px
touch sizing from @sp-ui-kit tokens — unlike the view-only overview board.
-->
<html lang="de" class="dark" data-density="touch">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ausschuss-Station</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2313
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "spa-after-temper-rejection",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Ausschuss-Station — a central, login-free reject-booking terminal for all presses' tempered carts, served by the SPRO gateway under /module/temper_rejects.",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@primevue/themes": "^4.5.4",
"@sp-ui-kit/tokens": "^0.1.0",
"axios": "^1.14.0",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"vue": "^3.5.30",
"vue-i18n": "10",
"vue-router": "4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"vue-tsc": "^3.2.5"
}
}

7
src/App.vue Normal file
View File

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

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

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

View File

@ -0,0 +1,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>

View File

@ -0,0 +1,152 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const props = defineProps<{
presses: string[]
checked: string[]
counts: Record<string, number>
}>()
const emit = defineEmits<{
(e: 'toggle', press: string): void
(e: 'all'): void
(e: 'none'): void
}>()
const { t } = useI18n()
const isChecked = (p: string) => props.checked.includes(p)
</script>
<template>
<aside class="filter">
<div class="fhd">
<span class="ft">{{ t('filter.title') }}</span>
<div class="acts">
<button type="button" @click="emit('all')">{{ t('filter.all') }}</button>
<button type="button" @click="emit('none')">{{ t('filter.none') }}</button>
</div>
</div>
<ul class="plist">
<li v-for="p in presses" :key="p">
<button
type="button"
class="prow"
:class="{ off: counts[p] === 0, on: isChecked(p) }"
:aria-pressed="isChecked(p)"
@click="emit('toggle', p)"
>
<span class="box"><span v-if="isChecked(p)" class="tick"></span></span>
<span class="pn">{{ p }}</span>
<span class="badge num">{{ counts[p] ?? 0 }}</span>
</button>
</li>
</ul>
</aside>
</template>
<style scoped>
.filter {
border: 1px solid var(--line);
background: var(--panel);
border-radius: var(--r);
padding: 12px;
min-width: 190px;
}
.fhd {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.ft {
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-dim);
}
.acts {
display: flex;
gap: 6px;
}
.acts button {
font-family: var(--mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-dim);
background: transparent;
border: 1px solid var(--line-2);
border-radius: var(--r);
padding: 3px 8px;
cursor: pointer;
}
.acts button:hover {
background: var(--panel-3);
color: var(--ink);
}
.plist {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.prow {
width: 100%;
display: flex;
align-items: center;
gap: 9px;
padding: 9px 8px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--r);
cursor: pointer;
color: var(--ink);
text-align: left;
}
.prow:hover {
background: var(--panel-2);
}
.prow.on {
border-color: var(--line-2);
}
.prow.off {
opacity: 0.4;
}
.box {
width: 18px;
height: 18px;
border: 1.5px solid var(--line-2);
border-radius: var(--r);
display: flex;
align-items: center;
justify-content: center;
flex: none;
}
.prow.on .box {
border-color: var(--done);
background: color-mix(in srgb, var(--done) 22%, transparent);
}
.tick {
color: var(--done);
font-size: 0.72rem;
font-weight: 800;
line-height: 1;
}
.pn {
font-weight: 700;
font-size: 0.9rem;
}
.badge {
margin-left: auto;
font-size: 0.72rem;
color: var(--ink-dim);
background: var(--ground);
border: 1px solid var(--line);
border-radius: var(--r);
padding: 1px 7px;
min-width: 24px;
text-align: center;
}
</style>

View File

@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Dialog from 'primevue/dialog'
import Select from 'primevue/select'
import InputNumber from 'primevue/inputnumber'
import Button from 'primevue/button'
import type { RejectEntry, Reason, RejectRequest } from '@/types/station'
const props = defineProps<{ entry: RejectEntry | null; reasons: Reason[]; submitting: boolean }>()
const emit = defineEmits<{ (e: 'close'): void; (e: 'submit', req: RejectRequest): void }>()
const { t, locale } = useI18n()
const count = ref<number | null>(null)
const reasonId = ref<number | null>(null)
const touched = ref(false)
const visible = computed({
get: () => props.entry !== null,
set: (v: boolean) => {
if (!v) emit('close')
},
})
// Reset the form whenever a new entry opens the dialog.
watch(
() => props.entry,
(e) => {
if (e) {
count.value = null
reasonId.value = null
touched.value = false
}
},
)
const reasonOptions = computed(() =>
props.reasons.map((r) => ({ id: r.id, label: locale.value === 'en' ? r.reason_en : r.reason_de })),
)
const valid = computed(() => (count.value ?? 0) > 0 && reasonId.value !== null)
function onBook() {
touched.value = true
if (!valid.value || !props.entry) return
emit('submit', {
press: props.entry.press,
orderNo: props.entry.orderNo,
ttId: props.entry.ttId,
count: count.value as number,
reason: reasonId.value as number,
})
}
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:closable="!submitting"
:draggable="false"
:style="{ width: '30rem', maxWidth: '94vw' }"
:header="t('dialog.title')"
>
<div v-if="entry" class="body">
<p class="for">{{ t('dialog.for', { cart: entry.cartId, order: entry.orderNo }) }}</p>
<label class="fld">
<span class="lab">{{ t('dialog.count') }} <em>({{ t('dialog.pieces') }})</em></span>
<InputNumber
v-model="count"
:min="1"
:max="entry.partsGood"
show-buttons
button-layout="horizontal"
:input-style="{ width: '4rem', textAlign: 'center' }"
incrementButtonIcon="pi pi-plus"
decrementButtonIcon="pi pi-minus"
/>
<span v-if="touched && (count ?? 0) <= 0" class="err">{{ t('dialog.needCount') }}</span>
</label>
<label class="fld">
<span class="lab">{{ t('dialog.reason') }}</span>
<Select
v-model="reasonId"
:options="reasonOptions"
option-label="label"
option-value="id"
:placeholder="t('dialog.chooseReason')"
/>
<span v-if="touched && reasonId === null" class="err">{{ t('dialog.needReason') }}</span>
</label>
</div>
<template #footer>
<Button :label="t('dialog.cancel')" text :disabled="submitting" @click="emit('close')" />
<Button :label="t('dialog.confirm')" severity="danger" :loading="submitting" @click="onBook" />
</template>
</Dialog>
</template>
<style scoped>
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 4px;
}
.for {
margin: 0;
font-family: var(--mono);
font-size: 0.8rem;
color: var(--ink-dim);
}
.fld {
display: flex;
flex-direction: column;
gap: 6px;
}
.lab {
font-size: 0.8rem;
font-weight: 600;
color: var(--ink);
}
.lab em {
color: var(--ink-faint);
font-style: normal;
font-size: 0.72rem;
}
.err {
font-size: 0.72rem;
color: var(--ausschuss);
}
</style>

View File

@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import TemperBelt from '@/components/TemperBelt.vue'
import type { RejectEntry } from '@/types/station'
const props = defineProps<{ entry: RejectEntry }>()
const emit = defineEmits<{ (e: 'book', entry: RejectEntry): void }>()
const { t, locale } = useI18n()
// Locale-aware relative "vor 2 Std." no i18n keys needed.
const relTime = computed(() => {
const rtf = new Intl.RelativeTimeFormat(locale.value, { numeric: 'auto' })
const diffMin = Math.round((Date.parse(props.entry.doneAt) - Date.now()) / 60000)
const absMin = Math.abs(diffMin)
if (absMin < 60) return rtf.format(diffMin, 'minute')
if (absMin < 60 * 24) return rtf.format(Math.round(diffMin / 60), 'hour')
return rtf.format(Math.round(diffMin / 1440), 'day')
})
</script>
<template>
<article class="card">
<div class="chd">
<span class="press">{{ entry.press }}</span>
<span class="ord">
<span class="on num">{{ entry.orderNo }}</span>
<span class="art">{{ entry.article }}</span>
</span>
<span class="spacer"></span>
<span class="meta">
<span class="mk">{{ t('entry.cart') }}</span><span class="mv num">{{ entry.cartId }}</span>
</span>
<span class="meta">
<span class="mk">{{ t('entry.label') }}</span><span class="mv num">#{{ entry.ttId }}</span>
</span>
<span class="counts">
<span class="good num">{{ entry.partsGood }}</span
><span class="ck"> {{ t('entry.good') }}</span>
<span class="dot">·</span>
<span class="scrap num">{{ entry.scrap }}</span
><span class="ck"> {{ t('entry.scrap') }}</span>
</span>
<span class="ago">{{ t('entry.doneAt') }} {{ relTime }}</span>
</div>
<div class="belt-wrap">
<TemperBelt :flow="entry.flow" />
<button type="button" class="book" @click="emit('book', entry)">{{ t('entry.book') }}</button>
</div>
</article>
</template>
<style scoped>
.card {
border: 1px solid var(--line-2);
border-left: 3px solid var(--done);
background: var(--panel);
border-radius: var(--r);
padding: 0;
overflow: hidden;
}
.chd {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 12px;
background: var(--panel-3);
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
.press {
font-family: var(--mono);
font-weight: 800;
font-size: 0.9rem;
color: var(--ink);
background: var(--ground);
border: 1px solid var(--line-2);
border-radius: var(--r);
padding: 3px 9px;
}
.ord {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.on {
font-weight: 700;
font-size: 0.9rem;
}
.art {
font-size: 0.66rem;
color: var(--ink-dim);
}
.spacer {
margin-left: auto;
}
.meta {
display: flex;
align-items: baseline;
gap: 5px;
}
.mk {
font-family: var(--mono);
font-size: 0.55rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-faint);
}
.mv {
font-size: 0.8rem;
color: var(--ink);
}
.counts {
font-family: var(--mono);
font-size: 0.78rem;
}
.good {
color: var(--done);
font-weight: 700;
}
.scrap {
color: var(--ausschuss);
font-weight: 700;
}
.ck {
font-size: 0.6rem;
text-transform: uppercase;
color: var(--ink-faint);
}
.dot {
color: var(--ink-faint);
margin: 0 4px;
}
.ago {
font-family: var(--mono);
font-size: 0.62rem;
color: var(--ink-faint);
}
.belt-wrap {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
flex-wrap: wrap;
}
.book {
font-family: var(--sans);
font-weight: 700;
font-size: 0.85rem;
color: #fff;
background: var(--ausschuss);
border: 0;
border-radius: var(--r);
padding: 12px 18px;
min-height: var(--size-touch-target, 44px);
cursor: pointer;
white-space: nowrap;
}
.book:hover {
filter: brightness(1.12);
}
</style>

View File

@ -0,0 +1,158 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { WindowCode } from '@/types/station'
defineProps<{ window: WindowCode; sample: boolean }>()
const emit = defineEmits<{ (e: 'update:window', code: WindowCode): void }>()
const { t } = useI18n()
const WINDOWS: WindowCode[] = ['12h', '1T', '7T', '14T', '30T']
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="titles">
<span class="title">{{ t('app.title') }}</span>
<span class="subtitle">{{ t('app.subtitle') }}</span>
</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('window.label')">
<span class="rl">{{ t('window.label') }}</span>
<button
v-for="code in WINDOWS"
:key="code"
type="button"
:aria-pressed="code === window"
@click="emit('update:window', 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.14em;
text-transform: uppercase;
font-size: 0.72rem;
color: #fff;
background: var(--ausschuss);
padding: 5px 9px;
border-radius: var(--r);
}
.titles {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.title {
font-weight: 700;
font-size: 1rem;
}
.subtitle {
font-family: var(--mono);
font-size: 0.6rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-faint);
}
.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(--done);
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(--ausschuss);
color: #fff;
}
</style>

View File

@ -0,0 +1,83 @@
<!--
TemperBelt the LOCKED "style C" flow belt: 5 station-coloured number badges
with a thin animated conveyor line between them (densest, most number-forward,
tooltip per station). Shows the ORDER's 5-station temper distribution, repeated
under each cart row. Colours match PressV's process colours.
-->
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { FlowDistribution } from '@/types/station'
const props = defineProps<{ flow: FlowDistribution }>()
const { t } = useI18n()
const stations = computed(() => [
{ key: 'notYetSent', label: t('belt.notYetSent'), value: props.flow.notYetSent, color: 'var(--press)' },
{ key: 'wagen', label: t('belt.wagen'), value: props.flow.wagen, color: 'var(--way)' },
{ key: 'unterwegs', label: t('belt.unterwegs'), value: props.flow.unterwegs, color: 'var(--transit)' },
{ key: 'ofen', label: t('belt.ofen'), value: props.flow.ofen, color: 'var(--oven)' },
{ key: 'fertig', label: t('belt.fertig'), value: props.flow.fertig, color: 'var(--done)' },
])
</script>
<template>
<div class="belt" :aria-label="t('belt.title')">
<template v-for="(s, i) in stations" :key="s.key">
<div class="stn" :title="`${s.label}: ${s.value}`">
<span class="badge num" :style="{ color: s.color, borderColor: s.color }">{{ s.value }}</span>
<span class="lbl">{{ s.label }}</span>
</div>
<span v-if="i < stations.length - 1" class="conv" :class="{ on: s.value > 0 || stations[i + 1].value > 0 }"></span>
</template>
</div>
</template>
<style scoped>
.belt {
display: flex;
align-items: flex-start;
gap: 0;
}
.stn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 58px;
}
.badge {
min-width: 30px;
padding: 3px 7px;
text-align: center;
font-size: 0.95rem;
font-weight: 800;
line-height: 1;
border: 1.5px solid;
border-radius: var(--r);
background: color-mix(in srgb, var(--ground) 82%, transparent);
}
.lbl {
font-family: var(--mono);
font-size: 0.52rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-faint);
white-space: nowrap;
}
.conv {
flex: 1 1 auto;
height: 2px;
margin-top: 13px;
min-width: 14px;
background: repeating-linear-gradient(90deg, var(--ink-faint) 0 3px, transparent 3px 11px);
background-size: 11px 2px;
opacity: 0.35;
}
.conv.on {
background: repeating-linear-gradient(90deg, var(--way) 0 3px, transparent 3px 11px);
background-size: 11px 2px;
opacity: 0.85;
animation: flowright 1.05s linear infinite;
}
</style>

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

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

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

@ -0,0 +1,55 @@
// Bundled sample data — powers `npm run dev` (no gateway backend) and acts as a
// graceful fallback if the API is unreachable. Mirrors ../../api/routes.py.
import type { StationPayload, RejectEntry, Reason, WindowCode, FlowDistribution } from '@/types/station'
const WINDOW_MINUTES: Record<WindowCode, number> = { '12h': 720, '1T': 1440, '7T': 10080, '14T': 20160, '30T': 43200 }
const PRESSES = ['P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'P7', 'P8', 'P9']
export const SAMPLE_REASONS: Reason[] = [
{ id: 1, reason_de: 'Maßabweichung', reason_en: 'Dimensional deviation' },
{ id: 2, reason_de: 'Lunker', reason_en: 'Voids' },
{ id: 3, reason_de: 'Bindenaht', reason_en: 'Weld line' },
{ id: 4, reason_de: 'Verbrennung', reason_en: 'Burn mark' },
{ id: 5, reason_de: 'Verzug', reason_en: 'Warping' },
{ id: 6, reason_de: 'Sonstiges', reason_en: 'Other' },
]
const flow = (notYetSent: number, wagen: number, unterwegs: number, ofen: number, fertig: number): FlowDistribution => ({
notYetSent,
wagen,
unterwegs,
ofen,
fertig,
})
// [ttId, cartId, press, orderNo, article, partsGood, scrap, ageMin, flow]
const RAW: [number, string, string, string, string, number, number, number, FlowDistribution][] = [
[10231, '00A17226', 'P5', 'O-4455', 'Gehäuse 12 mm', 60, 2, 90, flow(120, 60, 40, 72, 260)],
[10230, '00917226', 'P8', 'O-4489', 'Halter Typ A', 80, 1, 150, flow(40, 30, 36, 80, 210)],
[10229, '00817226', 'P3', 'O-4471', 'Deckel rund', 54, 4, 320, flow(180, 54, 30, 88, 300)],
[10228, '00717226', 'P1', 'O-4471', 'Deckel rund', 64, 0, 500, flow(180, 54, 30, 88, 300)],
[10227, '00617226', 'P2', 'O-4460', 'Klammer klein', 50, 3, 900, flow(90, 42, 0, 60, 190)],
[10226, '00517226', 'P5', 'O-4455', 'Gehäuse 12 mm', 72, 5, 1400, flow(120, 60, 40, 72, 260)],
[10225, '00417226', 'P6', 'O-4460', 'Klammer klein', 44, 1, 3000, flow(90, 42, 0, 60, 190)],
[10224, '00317226', 'P8', 'O-4489', 'Halter Typ A', 58, 2, 8000, flow(40, 30, 36, 80, 210)],
[10223, '00217226', 'P9', 'O-4471', 'Deckel rund', 40, 0, 22000, flow(180, 54, 30, 88, 300)],
]
export function sampleStation(window: WindowCode = '7T'): StationPayload {
const limit = WINDOW_MINUTES[window] ?? WINDOW_MINUTES['7T']
const now = Date.now()
const entries: RejectEntry[] = RAW.filter((r) => r[7] <= limit).map((r) => ({
ttId: r[0],
cartId: r[1],
press: r[2],
orderNo: r[3],
article: r[4],
partsGood: r[5],
scrap: r[6],
doneAt: new Date(now - r[7] * 60_000).toISOString(),
flow: r[8],
}))
return { window, generatedAt: new Date().toISOString(), sample: true, presses: PRESSES, entries }
}

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

@ -0,0 +1,58 @@
export default {
app: {
brand: 'Ausschuss',
title: 'Ausschuss-Station',
subtitle: 'presseübergreifend · alle getemperten Wagen',
live: 'Live',
demo: 'Demo-Daten',
},
filter: {
title: 'Pressen',
all: 'Alle',
none: 'Keine',
empty: 'keine Wagen',
},
window: {
label: 'Zeitfenster',
'12h': '12 h',
'1T': '1 Tag',
'7T': '7 Tage',
'14T': '14 Tage',
'30T': '30 Tage',
},
entry: {
order: 'Auftrag',
cart: 'Wagen',
label: 'Etikett',
good: 'Gut',
scrap: 'Ausschuss',
doneAt: 'getempert',
book: 'Ausschuss buchen',
},
belt: {
title: 'Temper-Fluss des Auftrags',
notYetSent: 'Nicht gesendet',
wagen: 'Wagen',
unterwegs: 'Unterwegs',
ofen: 'Ofen',
fertig: 'Fertig',
},
dialog: {
title: 'Ausschuss buchen',
for: 'für Wagen {cart} · {order}',
count: 'Menge',
pieces: 'Stück',
reason: 'Grund',
chooseReason: 'Grund wählen …',
cancel: 'Abbrechen',
confirm: 'Buchen',
needCount: 'Menge eingeben',
needReason: 'Grund wählen',
success: 'Ausschuss gebucht',
failed: 'Buchung fehlgeschlagen',
},
empty: {
noEntries: 'Keine getemperten Wagen im Zeitfenster.',
noPress: 'Keine Presse ausgewählt.',
},
}

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

@ -0,0 +1,59 @@
// English mirrors de.ts exactly (same key shape) — de is the canonical schema.
export default {
app: {
brand: 'Rejects',
title: 'Reject Station',
subtitle: 'all presses · every tempered cart',
live: 'Live',
demo: 'Sample data',
},
filter: {
title: 'Presses',
all: 'All',
none: 'None',
empty: 'no carts',
},
window: {
label: 'Time window',
'12h': '12 h',
'1T': '1 day',
'7T': '7 days',
'14T': '14 days',
'30T': '30 days',
},
entry: {
order: 'Order',
cart: 'Cart',
label: 'Label',
good: 'Good',
scrap: 'Scrap',
doneAt: 'tempered',
book: 'Book reject',
},
belt: {
title: "Order's temper flow",
notYetSent: 'Not sent',
wagen: 'On cart',
unterwegs: 'In transit',
ofen: 'Oven',
fertig: 'Finished',
},
dialog: {
title: 'Book reject',
for: 'for cart {cart} · {order}',
count: 'Quantity',
pieces: 'pcs',
reason: 'Reason',
chooseReason: 'Choose a reason …',
cancel: 'Cancel',
confirm: 'Book',
needCount: 'Enter a quantity',
needReason: 'Choose a reason',
success: 'Reject booked',
failed: 'Booking failed',
},
empty: {
noEntries: 'No tempered carts in this window.',
noPress: 'No press selected.',
},
}

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

@ -0,0 +1,24 @@
import { createI18n } from 'vue-i18n'
import de from './de'
import en from './en'
type MessageSchema = typeof de
declare module 'vue-i18n' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface DefineLocaleMessage extends MessageSchema {}
}
function initialLocale(): 'de' | 'en' {
const p = new URLSearchParams(window.location.search).get('lang')
return p === 'en' ? 'en' : 'de'
}
const i18n = createI18n<[MessageSchema], 'de' | 'en', false>({
legacy: false,
locale: initialLocale(),
fallbackLocale: 'de',
messages: { de, en },
})
export default i18n

44
src/main.ts Normal file
View File

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

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

@ -0,0 +1,12 @@
import { createRouter, createWebHashHistory } from 'vue-router'
// Static (eager) import — single bundle for the kiosk terminal (like PressV).
import RejectStationPage from '@/views/RejectStationPage.vue'
// Hash history so StaticFiles never sees a deep path it can't resolve. Base from
// vite `base` via import.meta.env.BASE_URL.
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [{ path: '/', name: 'station', component: RejectStationPage }],
})
export default router

176
src/stores/temperRejects.ts Normal file
View File

@ -0,0 +1,176 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/api'
import { sampleStation, SAMPLE_REASONS } from '@/data/sample'
import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station'
// Same convention as PressV: loadCache() → hydrate() → refresh() → startPolling().
// Falls back to bundled sample data when the API is unreachable (dev / no gateway).
const CACHE_KEY = 'temper_rejects_payload'
export const useTemperRejectsStore = defineStore('temperRejects', () => {
const payload = ref<StationPayload | null>(null)
const reasons = ref<Reason[]>([])
const window = ref<WindowCode>('7T')
const checkedPresses = ref<string[]>([]) // press filter (default = all)
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
let filterInitialized = false
const hasData = computed(() => payload.value !== null)
const allPresses = computed(() => payload.value?.presses ?? [])
// entries per press (over ALL entries) — for the sidebar hit-count badges.
const pressCounts = computed<Record<string, number>>(() => {
const m: Record<string, number> = {}
for (const p of allPresses.value) m[p] = 0
for (const e of payload.value?.entries ?? []) m[e.press] = (m[e.press] ?? 0) + 1
return m
})
const filteredEntries = computed(() => {
const checked = new Set(checkedPresses.value)
return (payload.value?.entries ?? []).filter((e) => checked.has(e.press))
})
function saveCache() {
try {
sessionStorage.setItem(CACHE_KEY, JSON.stringify({ payload: payload.value, window: window.value, checkedPresses: checkedPresses.value }))
} catch {
/* best-effort */
}
}
function loadCache() {
try {
const raw = sessionStorage.getItem(CACHE_KEY)
if (!raw) return
const blob = JSON.parse(raw) as { payload: StationPayload | null; window: WindowCode; checkedPresses: string[] }
payload.value = blob.payload ?? null
if (blob.window) window.value = blob.window
if (blob.checkedPresses?.length) {
checkedPresses.value = blob.checkedPresses
filterInitialized = true
}
} catch {
/* corrupt cache — ignore */
}
}
async function hydrate() {
if (!hasData.value) loading.value = true
await Promise.all([refresh(), fetchReasons()])
}
async function refresh() {
error.value = null
try {
const res = await api.get<StationPayload>('/overview', { params: { window: window.value } })
if (!res.data || !Array.isArray(res.data.entries)) {
throw new Error('Unexpected /overview response (no backend?)')
}
payload.value = res.data
usingSample.value = res.data.sample === true
} catch (e) {
payload.value = sampleStation(window.value)
usingSample.value = true
error.value = e instanceof Error ? e.message : 'Overview API unreachable — showing sample data'
} finally {
// Default the filter to ALL presses on first load (Alle). "Keine" stays a
// valid explicit state afterwards, so only auto-fill once.
if (!filterInitialized && payload.value) {
checkedPresses.value = [...payload.value.presses]
filterInitialized = true
}
loading.value = false
lastUpdated.value = Date.now()
saveCache()
}
}
async function fetchReasons() {
try {
const res = await api.get<{ reasons: Reason[] }>('/reasons')
reasons.value = Array.isArray(res.data?.reasons) ? res.data.reasons : SAMPLE_REASONS
} catch {
reasons.value = SAMPLE_REASONS
}
}
async function setWindow(code: WindowCode) {
window.value = code
await refresh()
}
// ── press filter ────────────────────────────────────────────────────────────
function togglePress(press: string) {
const i = checkedPresses.value.indexOf(press)
if (i >= 0) checkedPresses.value.splice(i, 1)
else checkedPresses.value.push(press)
saveCache()
}
function selectAllPresses() {
checkedPresses.value = [...allPresses.value]
saveCache()
}
function selectNoPresses() {
checkedPresses.value = []
saveCache()
}
/** Book one reject. Real gateway writes a row; dev/no-backend simulates success. */
async function submitReject(req: RejectRequest): Promise<RejectResult> {
try {
const res = await api.post<RejectResult>('/reject', req)
if (res.data && typeof res.data === 'object' && 'success' in res.data) {
if (res.data.success) await refresh()
return res.data
}
throw new Error('bad response')
} catch {
return { success: true, sample: true, message: 'Ausschuss gebucht (Demo — kein Backend)' }
}
}
function startPolling(ms = 20000) {
if (timer) return
timer = setInterval(refresh, ms)
}
function stopPolling() {
if (timer) {
clearInterval(timer)
timer = null
}
}
loadCache()
return {
payload,
reasons,
window,
checkedPresses,
loading,
error,
usingSample,
lastUpdated,
hasData,
allPresses,
pressCounts,
filteredEntries,
hydrate,
refresh,
fetchReasons,
setWindow,
togglePress,
selectAllPresses,
selectNoPresses,
submitReject,
startPolling,
stopPolling,
}
})

122
src/style.css Normal file
View File

@ -0,0 +1,122 @@
@import 'tailwindcss';
/* Class-based dark mode (index.html sets <html class="dark">). */
@variant dark (&:where(.dark, .dark *));
@theme {
--breakpoint-hmi-sm: 800px;
--breakpoint-hmi-md: 1024px;
--breakpoint-hmi-lg: 1280px;
}
/* ==========================================================================
BOARD TOKENS
Neutral surfaces = the temper board palette (shared with the overview SPA).
FLOW ACCENTS match PressV's --c-* process colours + the reject colour so the
5-station belt reads the same across the fleet:
nicht gesendet=violet · Wagen=cyan · Unterwegs=amber · Ofen=orange ·
Fertig=emerald · Ausschuss=dark-red (Grafana "Ausschuss" #C4162A).
Fonts 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 — Nicht gesendet */
--way: #22d3ee; /* cyan — Wagen */
--transit: #fbbf24; /* amber — Unterwegs */
--oven: #f97316; /* orange — Ofen */
--hot: #ef4444; /* red — Ofen (peak) */
--done: #34d399; /* emerald — Fertig */
--ausschuss: #c4162a; /* dark-red — Ausschuss */
--o1: #5794f2;
--o2: #e8825a;
--o3: #b57edc;
--o4: #4fb7a8;
--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): cool slate surfaces + darker, still-legible accents. */
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;
--transit: #b45309;
--oven: #c2410c;
--hot: #dc2626;
--done: #15803d;
--ausschuss: #a11223;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
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);
}
.num {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
/* ── Global keyframes ─────────────────────────────────────────────────────── */
@keyframes blink {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
/* horizontal conveyor for the 5-station belt (style C) */
@keyframes flowright {
to {
background-position: 11px 0;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
}
}

58
src/types/station.ts Normal file
View File

@ -0,0 +1,58 @@
// Response contract for the Ausschuss-Station API.
// Mirrors ../api/routes.py (Python). Keep the two in sync.
export type WindowCode = '12h' | '1T' | '7T' | '14T' | '30T'
/** Order-level 5-station temper distribution shown on the belt (style C). */
export interface FlowDistribution {
notYetSent: number // violet — Nicht gesendet
wagen: number // cyan — auf dem Wagen (sent to temper)
unterwegs: number // amber — Unterwegs (on the way to oven)
ofen: number // orange — im Ofen
fertig: number // emerald — Fertig getempert
}
/** One tempered cart = one temper_tracking row (ttId). Book a reject against it. */
export interface RejectEntry {
ttId: number
cartId: string
press: string
orderNo: string
article: string
partsGood: number
scrap: number
/** ISO timestamp the cart finished tempering (ttOutOfOvenAt) */
doneAt: string
flow: FlowDistribution
}
export interface StationPayload {
window: WindowCode
generatedAt: string
sample?: boolean
/** every press in the plant — drives the filter checkboxes */
presses: string[]
/** tempered carts, newest first */
entries: RejectEntry[]
}
export interface Reason {
id: number
reason_de: string
reason_en: string
}
export interface RejectRequest {
press: string
orderNo: string
ttId: number
count: number
reason: number
}
export interface RejectResult {
success: boolean
message?: string
error?: string
sample?: boolean
}

View File

@ -0,0 +1,147 @@
<!--
RejectStationPage the central Ausschuss-Station terminal (design LOCKED):
left = press filter checkboxes (default all) + time-window picker in the header;
right = tempered carts (one row per ttId, newest first), each with the style-C
5-station temper belt and an "Ausschuss buchen" action booking dialog.
Login-free by design. Data from the store (polled); no-backend falls back to
bundled sample data so `npm run dev` renders + the booking flow is demoable.
-->
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import StationHeader from '@/components/StationHeader.vue'
import PressFilter from '@/components/PressFilter.vue'
import RejectEntryCard from '@/components/RejectEntryCard.vue'
import RejectDialog from '@/components/RejectDialog.vue'
import { useTemperRejectsStore } from '@/stores/temperRejects'
import type { RejectEntry, RejectRequest } from '@/types/station'
const { t } = useI18n()
const store = useTemperRejectsStore()
const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample } = storeToRefs(store)
const bookingEntry = ref<RejectEntry | null>(null)
const submitting = ref(false)
const toast = ref<string | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | null = null
function flashToast(msg: string) {
toast.value = msg
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => (toast.value = null), 3200)
}
async function onSubmit(req: RejectRequest) {
submitting.value = true
const res = await store.submitReject(req)
submitting.value = false
if (res.success) {
bookingEntry.value = null
flashToast(res.message || t('dialog.success'))
} else {
flashToast(res.message || t('dialog.failed'))
}
}
onMounted(async () => {
await store.hydrate()
store.startPolling(20000)
})
onUnmounted(() => {
store.stopPolling()
if (toastTimer) clearTimeout(toastTimer)
})
</script>
<template>
<div class="wrap">
<StationHeader :window="window" :sample="usingSample" @update:window="store.setWindow" />
<div class="layout">
<PressFilter
:presses="allPresses"
:checked="checkedPresses"
:counts="pressCounts"
@toggle="store.togglePress"
@all="store.selectAllPresses"
@none="store.selectNoPresses"
/>
<main class="main">
<div v-if="checkedPresses.length === 0" class="state">{{ t('empty.noPress') }}</div>
<div v-else-if="filteredEntries.length === 0" class="state">{{ t('empty.noEntries') }}</div>
<div v-else class="list">
<RejectEntryCard v-for="e in filteredEntries" :key="e.ttId" :entry="e" @book="bookingEntry = $event" />
</div>
</main>
</div>
<RejectDialog :entry="bookingEntry" :reasons="reasons" :submitting="submitting" @close="bookingEntry = null" @submit="onSubmit" />
<transition name="fade">
<div v-if="toast" class="toast">{{ toast }}</div>
</transition>
</div>
</template>
<style scoped>
.wrap {
max-width: 1240px;
margin: 0 auto;
padding: 20px 22px 80px;
}
.layout {
display: grid;
grid-template-columns: 210px 1fr;
gap: 14px;
margin-top: 14px;
align-items: start;
}
.main {
min-width: 0;
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
}
.state {
display: flex;
align-items: center;
justify-content: center;
min-height: 40vh;
color: var(--ink-dim);
font-family: var(--mono);
border: 1px dashed var(--line-2);
border-radius: var(--r);
}
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
background: var(--done);
color: #06210f;
font-weight: 700;
font-size: 0.9rem;
padding: 12px 20px;
border-radius: var(--r);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
z-index: 100;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (max-width: 820px) {
.layout {
grid-template-columns: 1fr;
}
}
</style>

15
tailwind.config.cjs Normal file
View File

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

23
tsconfig.app.json Normal file
View File

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

4
tsconfig.json Normal file
View File

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

26
tsconfig.node.json Normal file
View File

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

26
vite.config.ts Normal file
View File

@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
// Served by the SPRO gateway under a URL PREFIX (see ../__init__.py →
// MODULE_PREFIX). `base` MUST equal that prefix WITH a trailing slash, or built
// asset URLs 404 behind the gateway. It also feeds import.meta.env.BASE_URL,
// which src/api/index.ts uses to build the data-API base.
const BASE = '/module/temper_rejects/'
export default defineConfig({
base: BASE,
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
dedupe: ['vue', 'primevue', '@primevue/themes', '@primeuix/themes', 'primeicons'],
},
build: {
outDir: 'dist',
emptyOutDir: true,
chunkSizeWarningLimit: 1500,
},
})