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:
parent
786a499c81
commit
c340aef2c3
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
|
||||
151
README.md
151
README.md
@ -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
50
__init__.py
Normal 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
34
_db.py
Normal 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
0
api/__init__.py
Normal file
144
api/routes.py
Normal file
144
api/routes.py
Normal 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
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
1
dist/assets/index-aippIsDS.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 (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
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 (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
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-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
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
|
||||
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>
|
||||
152
src/components/PressFilter.vue
Normal file
152
src/components/PressFilter.vue
Normal 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>
|
||||
136
src/components/RejectDialog.vue
Normal file
136
src/components/RejectDialog.vue
Normal 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>
|
||||
165
src/components/RejectEntryCard.vue
Normal file
165
src/components/RejectEntryCard.vue
Normal 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>
|
||||
158
src/components/StationHeader.vue
Normal file
158
src/components/StationHeader.vue
Normal 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>
|
||||
83
src/components/TemperBelt.vue
Normal file
83
src/components/TemperBelt.vue
Normal 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
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]
|
||||
}
|
||||
55
src/data/sample.ts
Normal file
55
src/data/sample.ts
Normal 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
58
src/i18n/de.ts
Normal 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
59
src/i18n/en.ts
Normal 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
24
src/i18n/index.ts
Normal 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
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')
|
||||
12
src/router/index.ts
Normal file
12
src/router/index.ts
Normal 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
176
src/stores/temperRejects.ts
Normal 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
122
src/style.css
Normal 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
58
src/types/station.ts
Normal 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
|
||||
}
|
||||
147
src/views/RejectStationPage.vue
Normal file
147
src/views/RejectStationPage.vue
Normal 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
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"]
|
||||
}
|
||||
26
vite.config.ts
Normal file
26
vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user