34 Commits
Author SHA1 Message Date
admin d30d7e7ecc fix: improve release visibility and cache handling 2026-07-29 09:28:29 +02:00
admin 71c78476a2 chore: prepare v0.4-beta2 build 2026-07-29 09:15:12 +02:00
admin 156a6b744a chore: update beta build information 2026-07-29 09:04:16 +02:00
admin b812f1658e docs: add English quick reference to help 2026-07-29 09:00:12 +02:00
admin 8d6d85bd2e docs: add PDF export for user manual 2 2026-07-28 15:41:22 +02:00
admin 3507c323e6 docs: refine roadmap and article data architecture 2026-07-28 15:40:24 +02:00
admin f81d6a871a docs: add PDF export for user manual 2026-07-28 13:54:14 +02:00
admin 3b54f5ecc0 feat: move user management to protected config file 2026-07-28 13:46:05 +02:00
admin 2276a124b8 chore: update beta build information 2026-07-28 13:05:15 +02:00
admin 216f53c641 feat: add in-app release notes and what's new dialog 2026-07-28 12:59:12 +02:00
admin 8903452e96 feat: integrate user manual into help section 2026-07-28 12:15:38 +02:00
admin 41dbb6297c docs: complete user manual v1.0 (without screenshots) 2026-07-28 08:40:06 +02:00
admin 1ac9b08233 Refine capacity summary and analysis presentation 2026-07-27 15:21:02 +02:00
admin 1827c6de6c Improve capacity analysis presentation 2026-07-27 14:50:38 +02:00
admin 4f4bdf8d72 Merge main into transport capacity optimizer 2026-07-27 11:12:31 +02:00
admin 3b44048250 Fix build info generation 2026-07-27 11:11:32 +02:00
admin fe267b8766 Merge version 0.4 2026-07-27 11:05:48 +02:00
admin ea23f3f35d Ignore runtime-generated files 2026-07-27 10:51:52 +02:00
admin 30ed0bb79b Ignore runtime-generated files 2026-07-27 10:50:44 +02:00
admin c2e4f64d82 Bug fix 2026-07-07 16:50:10 +02:00
admin 7a27fb85c4 Extend Transport Optimizer 2026-07-07 16:40:30 +02:00
admin 4670666421 Restore diameter result and uncertainty display 2026-07-07 16:20:27 +02:00
admin dcca3fa042 Use password hashes for authentication 2026-07-07 16:16:50 +02:00
admin 8f64760add show build on website footer 2026-07-07 16:12:12 +02:00
admin ffa34815cd Remove legacy admin UI from RollCalc 2026-07-07 12:20:09 +02:00
martin ac6840c759 Merge pull request 'Chore/systemd service prep' (#1) from chore/systemd-service-prep into main
Reviewed-on: #1
2026-07-07 09:46:36 +02:00
admin 04f6f7ba6a added systemd/rollcalc.system tu Repo 2026-07-07 07:42:10 +00:00
admin 2459955acf Updated Roadmap and Agents files 2026-07-07 09:32:07 +02:00
admin b1797bc1bb changed host to 0.0.0.0 in app.py 2026-07-06 13:51:29 +00:00
admin a5a8bd5ea8 Switch to service deamon 2026-07-06 15:41:56 +02:00
admin c3a8a4a59a Merging to main 2026-07-06 12:41:41 +02:00
admin a6791cb038 Updated AGENTS.md and KNOWLEDE.MD 2026-07-03 13:53:12 +02:00
admin 924a18dbc8 Added README.md 2026-07-03 13:44:28 +02:00
admin d6d441fa69 Disclaimer re-added, added several documentation files for Codex 2026-07-03 09:48:01 +02:00
31 changed files with 4781 additions and 3057 deletions
+6
View File
@@ -32,3 +32,9 @@ node_modules/
# Backup files # Backup files
*.bak *.bak
*.backup *.backup
# runtime-generated files
access_log.json
# User and passwords
config/users.json
+83
View File
@@ -0,0 +1,83 @@
# AGENTS.md
Guidance for coding agents working in this repository.
## Scope
This project is a small Flask application with a mostly client-side calculator UI. Treat it as an internal tool with sensitive access and product data assumptions.
Primary files:
- `app.py`: Flask app, HTTP Basic Auth, routes, access logging.
- `templates/roll_calculator.html`: active calculator UI, inline CSS, inline JavaScript, disclaimer gate, roll calculations, forklift check, load optimizer.
- `static/article-data.json`: active product/article data loaded by the UI.
- `static/config.json`: richer forklift/heavy-roll configuration, although the active template also contains inline config.
- `README.md`: developer-facing project overview.
- `PROJECT_KNOWLEDGE.md`: project-specific domain and implementation knowledge.
## Working Rules
- Before larger changes, present a concise plan.
- Do not change functional logic unless explicitly requested.
- Keep edits narrowly scoped to the requested task.
- Do not remove existing features or routes without explicit approval.
- Do not add Python packages without approval.
- Do not change existing APIs, route paths, data formats, or authentication behavior without approval.
- Do not manually edit generated product data unless the user explicitly asks for data maintenance.
- Do not add new article-data administration features to RollCalc; propose a separate app or integration boundary instead.
- Preserve login/access logging unless the user explicitly asks to change or remove it.
- Preserve the current Flask/vanilla JavaScript architecture unless the task is specifically a refactor.
- Prefer documenting observed behavior over assuming intended behavior.
## Planned Direction
- The previous admin UI/API has been removed from RollCalc.
- Do not reintroduce admin screens or admin APIs unless explicitly requested as a temporary measure.
- Keep login/access logging in RollCalc.
- Administration and maintenance of `article-data.json` should move to a separate application.
- Treat article-data administration as out of scope for RollCalc.
## Code Style
- Python should follow PEP 8.
- JavaScript should use modern ES6 style where it fits the existing code.
- Keep comments sparse and useful.
- Use clear names over explanatory comments where possible.
- Avoid broad refactors in `templates/roll_calculator.html`; it is large and risk-prone.
## Validation Expectations
For backend or template changes:
- Start the app locally when practical.
- If port `5000` is occupied, use:
```bash
flask --app app run --host 127.0.0.1 --port 5001
```
- Check authenticated endpoints with Basic Auth when practical.
- For pure documentation changes, no runtime validation is required.
Known local credential currently present in `app.py`:
```text
mtazl / rollcalc
```
These credentials are hardcoded and are a known risk; do not introduce more secrets.
## Important Technical Constraints
- `app.py` currently hardcodes users and passwords.
- Access logging writes to `access_log.json` by reading and rewriting the whole file.
- The active UI logic is largely inline in `templates/roll_calculator.html`.
- Several `static/*.js` files appear to be older, alternative, or integration modules. Confirm script inclusion before modifying them.
- The previous admin template and backend admin route have been removed; do not rebuild them by default.
- The disclaimer confirmation is client-side only.
## Safety Notes
- Treat `static/article-data.json` as generated ERP-derived data.
- The project contains product/material assumptions. Do not adjust formulas, thresholds, or category rules without a specific request.
- The calculator output is advisory and requires plausibility checks before use.
+341
View File
@@ -0,0 +1,341 @@
# PROJECT_KNOWLEDGE.md
Project-specific technical and domain knowledge for RollCalcPython.
## Purpose
RollCalcPython is an internal roll calculator for Naue roll products. The backend is a minimal Flask application that provides authentication, routing, static file delivery, and access logs. Most domain behavior runs in the browser inside `templates/roll_calculator.html`.
The tool is not intended as an unchecked source of truth. Inputs and outputs must be checked for plausibility before operational use.
## Current Architecture
Backend:
- `app.py` creates the Flask app.
- HTTP Basic Auth is implemented with `Flask-HTTPAuth`.
- Users are configured in local deployment file `config/users.json` with Werkzeug password hashes. This file is ignored by Git.
- `/` renders the active calculator template.
- `/help` renders the authenticated Help landing page.
- `/help/user-manual` renders `docs/de/user_manual.md` server-side inside the RollCalc Help layout.
- `/help/quick-reference` renders `docs/en/quick_reference.md` server-side inside the RollCalc Help layout.
- `/help/recent-changes` renders `docs/de/recent_changes.md` server-side inside the RollCalc Help layout.
- `/help/user-manual/screenshots/<path:filename>` serves German manual screenshots from `docs/de/screenshots/`.
- `/static/<path:filename>` is intended to serve static files behind Basic Auth.
- `/api/health` returns health/version information.
- `/api/user` returns the authenticated user.
- Access events are appended to `access_log.json`.
- `build_info.json` is loaded at startup and exposed to all templates as `build_info`.
Frontend:
- `templates/roll_calculator.html` is the active application page.
- The page contains most CSS and JavaScript inline.
- `window.ARTICLE_DATA` is loaded from `/static/article-data.json`.
- `window.APP_CONFIG` is currently defined inline for forklift rules.
- A disclaimer modal blocks use until the user checks the confirmation checkbox.
- The footer displays build metadata: version, branch, commit, and timestamp.
## Build Info
Build/deployment metadata is read from:
```text
build_info.json
```
Expected fields:
- `version`
- `branch`
- `commit`
- `timestamp`
If `build_info.json` is missing, malformed, or does not contain a usable value, RollCalc falls back to `unknown` for that value and continues serving the site.
The data is loaded centrally in `app.py` and made available to every template as `build_info`.
## Cache and Release Visibility
RollCalc uses the current build identifier to cache-bust RollCalc-owned static assets referenced by active templates. The preferred identifier is `build_info.commit`; if it is missing or `unknown`, the fallback is `build_info.version`.
Dynamic HTML responses are sent with `Cache-Control: no-cache` so the calculator and Help pages are revalidated after deployments. Static assets are not globally marked no-cache; versioned URLs are used for active static JSON assets instead.
For releases, keep these sources aligned:
- `build_info.json`
- the default `VERSION` in `scripts/update_build_info.sh`, unless deployment overrides it
- the newest heading in `docs/de/recent_changes.md`
- `version` in `static/recent_changes.json`
The Recent Changes page includes a persistent force-reload fallback note for users whose browser still shows an older interface after deployment.
## Help and User Manual
The in-app Help area keeps `docs/de/user_manual.md` as the leading German documentation source. RollCalc reads the Markdown file on each `/help/user-manual` request and renders it server-side with `markdown-it-py`.
`docs/en/quick_reference.md` is the English Quick Reference source and is rendered at `/help/quick-reference`. It is derived from the full German User Manual and must remain a concise workflow reference rather than an independently diverging technical source.
The documentation structure is prepared for language-specific manuals:
```text
docs/
de/
user_manual.md
screenshots/
en/
quick_reference.md
```
Additional languages can follow the same structure later. The current public route still opens the German manual, the Quick Reference is currently English only, and no language switcher is implemented.
Rendering rules:
- Raw HTML from the Markdown source is disabled.
- Heading ids and the table of contents are generated from the Markdown content.
- Relative image references such as `screenshots/disclaimer.png` are rewritten to the authenticated manual screenshot route.
- Screenshots belong in the active language's `screenshots/` directory, currently `docs/de/screenshots/`; the screenshot route must remain limited to that directory.
The Help pages use the same HTTP Basic Auth protection as the calculator.
### Recent Changes and What's New
Full user-facing release notes are maintained in:
```text
docs/de/recent_changes.md
```
The calculator header links directly to `/help/user-manual`. The Help landing page lists only existing documents, currently User Manual, Quick Reference, and Recent Changes.
The startup What's New dialog uses `build_info.version` and is suppressed when the version is empty or `unknown`. The last acknowledged version is stored only in browser `localStorage` under:
```text
rollcalc_last_seen_version
```
The short dialog item list is maintained separately in:
```text
static/recent_changes.json
```
This JSON is a compact summary for the dialog, not the complete release history. It contains a `version` field and an `items` list. The browser displays the dialog only when the JSON version matches `build_info.version`; mismatched or invalid JSON must not block the calculator.
## Authentication
RollCalc uses HTTP Basic Auth. User entries are stored in the local deployment file:
```text
config/users.json
```
This file is intentionally ignored by Git. The versioned file `config/users.example.json` documents the JSON format only and must not contain real usernames, hashes, or passwords.
The file stores username-to-password-hash mappings:
```json
{
"username": "<hash>"
}
```
Password verification uses `werkzeug.security.check_password_hash`. Users are managed with:
```bash
python3 scripts/manage_users.py
```
The management tool provides a text menu for listing users, creating users, changing passwords, and deleting users. New hashes use the existing RollCalc method `pbkdf2:sha256:600000`.
The Flask app requires a readable, valid, non-empty `config/users.json` at startup and does not silently create an empty user file. If the file is missing or invalid, startup should fail with a message pointing to `python3 scripts/manage_users.py`.
Passwords must not be logged. Password hashes should only be stored in `config/users.json` or a future protected deployment-specific secret source.
Deployment notes:
- The systemd/service user must be able to read `config/users.json`.
- Suitable server permissions can be owner `martin`, group `www-data`, mode `640`, adjusted to the actual deployment users.
- Do not hard-code deployment owners or groups in Python.
- Back up the productive `config/users.json` before deploying or replacing a server instance.
## Roll Geometry
Calculations assume an ideal cylindrical winding.
Assumptions:
- Constant material thickness.
- No compression.
- No air gaps.
- Roll geometry is derived mathematically, not physically measured in the app.
Nominal diameter formula used by the Direct Calculation tab:
```text
D = sqrt(d^2 + (4 * L * 1000 * t) / pi)
```
Variables:
- `D`: roll diameter in mm.
- `d`: core diameter in mm.
- `L`: product length in m.
- `t`: material thickness in mm.
When a tolerance/stddev value is present, the UI also displays a `-2σ` and `+2σ` diameter range.
## Product Length and Extrapolation
Product length from a known diameter is calculated by rearranging the diameter formula:
```text
L = (pi / (4 * t)) * (D^2 - d^2) / 1000
```
The extrapolation tab estimates a new diameter from a known roll diameter/length pair:
```text
D1 = sqrt(d^2 + (L1 / L0) * (D0^2 - d^2))
```
Variables:
- `D0`: known/measured roll diameter.
- `L0`: known/measured roll length.
- `L1`: target length.
- `D1`: extrapolated roll diameter.
## Roll Weight
Roll weight is calculated client-side when width, area weight, and length are available:
```text
weight_kg = area_weight_g_m2 * length_m * width_m / 1000
```
Material thickness and area weight values are loaded from article data and are generated automatically. They are not guaranteed to be complete or reviewed.
## Article Data
Active data source:
```text
static/article-data.json
```
The UI expects article records with fields such as:
- `nr`
- `name`
- `thickness`
- `thickness_stddev`
- `area_weight`
- `area_weight_stddev`
- `core_type`
Observed additional fields include min/max/count values for material thickness and area weight.
Important data rule:
- Article data is generated from ERP-derived sources.
- These values should not be manually changed unless the task is explicitly data maintenance.
Article-data architecture decision:
- Article data should evolve from a simple article catalogue into a shared production knowledge base.
- Each article may optionally provide production-related default values.
- Initial defaults include `Product Thickness`, `Area Weight`, `Core Diameter`, and `Roll Width`.
- Future production-related defaults can be added without changing the overall architecture.
- When an article is selected, available defaults are copied into the calculator fields.
- Missing optional values must never overwrite existing user input.
- All automatically populated values remain editable by the user.
- The planned Article-Data-Admin application will become the authoritative editor for these production defaults.
Known domain caveat from existing project notes:
- For articles in the groups Secugrid, Combigrid, Carbofol MF/MF, Carbofol F/F, Carbofol s/F, and Carbofol BF/s, the thickness from `article-data.json` must not be used without domain validation.
## Forklift and Heavy-Roll Rules
The active template currently evaluates forklift warnings through `checkForklift()` and inline `window.APP_CONFIG`.
Current inline rule behavior:
- Applies to category `bentofix`.
- Warning threshold: `1700 kg`.
- Hard limit: `2750 kg`.
- Minimum core outer diameter: `170 mm`.
There is also a richer JSON configuration in:
```text
static/config.json
```
That file includes localized messages and additional equipment requirements. Before changing forklift behavior, verify whether the active inline config or the static JSON should be authoritative.
## Load Optimizer
The Load Optimizer is implemented inline in `templates/roll_calculator.html`.
Classes:
- `LoadOptimizer`
- `LoadOptimizerUI`
It uses calculated roll data from the Direct Calculation tab:
- Roll diameter.
- Core diameter.
- Roll width.
- Roll weight.
- Product length.
- Square meters per roll.
It supports transport presets:
- 20ft Container.
- 40ft Container.
- 40ft High Cube.
- LKW Sattelzug.
- LKW Tandem.
It calculates limiting scenarios by:
- Weight.
- Volume.
- Geometry.
It also draws a side-view SVG visualization.
## Disclaimer Gate
The active calculator page includes a disclaimer modal shown after Basic Auth login and before calculator use.
The modal:
- Contains German and English disclaimer text.
- Requires checkbox confirmation.
- Enables the confirmation button only after the checkbox is selected.
- Hides the overlay after confirmation.
This is client-side only. It is not persisted, logged, or enforced server-side.
## Admin Area State
RollCalc no longer contains an admin UI or admin API. The previous `/admin/logs` route and `templates/admin.html` have been removed.
Login/access logging remains part of RollCalc, but log viewing and article-data administration should not be implemented inside this app. Maintenance of `article-data.json` is planned for a separate application.
## Known Technical Risks
- Password hashes are currently configured in `app.py`; this should eventually move to a protected external configuration or secrets mechanism.
- Basic Auth only; no sessions or role framework beyond the user dictionary.
- `access_log.json` is rewritten on every logged request and is not concurrency-safe.
- No log rotation or retention policy is implemented.
- Flask's default static route may conflict with the intended authenticated `/static/<path:filename>` behavior; verify effective routing before relying on protected static files.
- Most active logic is concentrated in one large HTML template.
- Multiple similar static JavaScript files exist; not all are necessarily active.
- No automated test suite is visible.
- Some docs and filenames in `docs/` may reflect earlier package/archive versions.
+473
View File
@@ -0,0 +1,473 @@
# RollCalcPython
Flask-based internal roll diameter calculator for Naue roll products. The app is a small authenticated Flask shell around a mostly client-side calculator UI.
This README is intended for developers maintaining the project, not for end users.
## Runtime Stack
- Python 3
- Flask 2.3.3
- Flask-HTTPAuth 4.8.0
- markdown-it-py 3.0.0 for server-side Markdown rendering
- Vanilla HTML, CSS, and JavaScript
- JSON files for product data and forklift/load rules
Pinned Python dependencies are defined in `requirements.txt`.
## Local Setup
Create and activate a virtual environment:
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
Start with the hardcoded settings in `app.py`:
```bash
python app.py
```
By default this attempts to bind to:
```text
http://localhost:5000
```
If port `5000` is already occupied, start through Flask's CLI without changing files:
```bash
flask --app app run --host 127.0.0.1 --port 5001
```
The app uses HTTP Basic Auth. Current users are defined in a local deployment file, `config/users.json`, with Werkzeug password hashes. This file is intentionally not versioned.
## Project Layout
```text
.
├── app.py
├── requirements.txt
├── README.md
├── build_info.json
├── access_log.json
├── config.json
├── article-data_.json
├── fix_article_data.py
├── service-worker.js
├── templates/
│ ├── help_index.html
│ ├── roll_calculator.html
│ └── user_manual.html
├── static/
│ ├── article-data.json
│ ├── config.json
│ ├── service-worker.js
│ ├── stddev_calculator.js
│ ├── direct_calc_handler.js
│ ├── rollcalc_v14.js
│ ├── rollcalc_improvements.js
│ ├── rollcalc-improvements.js
│ ├── rollcalc_stddev_ranges.js
│ └── rollcalc_stddev_integration.js
└── docs/
├── de/
│ ├── screenshots/
│ └── user_manual.md
├── DEPLOYMENT_GUIDE.md
├── QOL_UPDATE_SUMMARY.md
├── STDDEV_IMPLEMENTATION_CHECKLIST.md
└── STDDEV_RANGES_DOCS.md
```
## Backend
`app.py` owns the Flask application and authentication.
Implemented routes:
| Route | Methods | Auth | Purpose |
| --- | --- | --- | --- |
| `/` | `GET` | Basic Auth | Renders `templates/roll_calculator.html`. |
| `/help` | `GET` | Basic Auth | Renders the Help landing page. |
| `/help/user-manual` | `GET` | Basic Auth | Reads `docs/de/user_manual.md`, renders it as HTML, and displays it in the RollCalc Help layout. |
| `/help/quick-reference` | `GET` | Basic Auth | Reads `docs/en/quick_reference.md`, renders it as HTML, and displays it in the RollCalc Help layout. |
| `/help/recent-changes` | `GET` | Basic Auth | Reads `docs/de/recent_changes.md`, renders it as HTML, and displays it in the RollCalc Help layout. |
| `/help/user-manual/screenshots/<path:filename>` | `GET` | Basic Auth | Serves German user-manual screenshots from `docs/de/screenshots/` only. |
| `/static/<path:filename>` | `GET` | Basic Auth | Intended protected static-file serving from `static/`. |
| `/api/health` | `GET` | Basic Auth | Returns app health and version. |
| `/api/user` | `GET` | Basic Auth | Returns current authenticated user info. |
Authentication is implemented with `Flask-HTTPAuth`. The current code loads the local `config/users.json` deployment file and checks password hashes with `werkzeug.security.check_password_hash`; plaintext passwords are not stored in the application.
## User Management
Manage users with the interactive CLI:
```bash
python3 scripts/manage_users.py
```
The menu supports:
- List users
- Create user
- Change password
- Delete user
User data is stored in:
```text
config/users.json
```
`config/users.json` is local deployment data and is ignored by Git. The versioned file `config/users.example.json` documents the format only and must not contain real usernames, hashes, or passwords.
The file contains only username-to-password-hash mappings:
```json
{
"username": "<hash>"
}
```
Passwords are entered with `getpass`, are never echoed, and are never stored in plaintext. New and changed passwords use the existing RollCalc hash method:
```text
pbkdf2:sha256:600000
```
Do not commit real passwords or print them in logs.
On a server, ensure that the process running RollCalc can read `config/users.json`. A suitable deployment setup can be:
```text
owner: martin
group: www-data
mode: 640
```
The exact owner and group depend on the deployment. Do not hard-code them in the application. Before deploying or replacing a server instance, back up the productive `config/users.json` file.
Access logging is handled by `log_access()`, which reads `access_log.json`, appends a record, and writes the whole file back.
## Help and User Manual
RollCalc exposes an authenticated Help area at:
```text
/help
```
The user manual is available at:
```text
/help/user-manual
```
Recent changes are available at:
```text
/help/recent-changes
```
The English Quick Reference is available at:
```text
/help/quick-reference
```
`docs/de/user_manual.md` is the leading source for the German user manual. The app reads this Markdown file on request and renders it server-side with `markdown-it-py`; no generated static HTML copy is maintained. Raw HTML in the Markdown source is disabled during rendering.
`docs/en/quick_reference.md` is the Markdown source for the English Quick Reference. It is derived from the full German User Manual and must remain a concise reference for common workflows, not an independently diverging technical source.
`docs/de/recent_changes.md` is the leading source for full German release notes. It is rendered through the same authenticated Markdown infrastructure.
The documentation directory is prepared for additional languages:
```text
docs/
de/
user_manual.md
screenshots/
en/
quick_reference.md
```
The public route `/help/user-manual` currently opens the German manual. The Quick Reference is currently English only. No language switcher is implemented yet.
Screenshots referenced from the manual as `screenshots/filename.png` should be placed in:
```text
docs/de/screenshots/
```
The application rewrites those relative Markdown image paths to the authenticated manual screenshot route, which is limited to the active language's screenshot directory.
### What's New Dialog
The calculator page shows a compact What's New dialog once per build version. The current version comes from `build_info.version`; if that value is empty or `unknown`, the dialog is not shown.
The last acknowledged version is stored only in the browser:
```text
rollcalc_last_seen_version
```
The short dialog summary is maintained in:
```text
static/recent_changes.json
```
The JSON is versioned:
```json
{
"version": "v0.4-beta2",
"items": []
}
```
The dialog loads this file in the browser and only displays the items when `static/recent_changes.json.version` matches `build_info.version`. Keep `docs/de/recent_changes.md` as the complete user-facing release-note source and `static/recent_changes.json` as the short 3-5 item summary for the currently deployed version.
### Browser Cache and Release Visibility
RollCalc uses the build identifier as a cache-busting query parameter for RollCalc-owned static assets referenced by active templates. The preferred identifier is `build_info.commit`; if it is missing or `unknown`, the app falls back to `build_info.version`.
Dynamic HTML responses use:
```text
Cache-Control: no-cache
```
This requires browser revalidation for the calculator and Help pages while allowing versioned static assets to be cached normally.
For each deployment, keep these sources aligned:
- `build_info.json`
- `scripts/update_build_info.sh` default `VERSION`, unless overridden during deployment
- newest section in `docs/de/recent_changes.md`
- `version` in `static/recent_changes.json`
If RollCalc does not display the latest interface after an update, perform a force reload:
`Ctrl+F5` or `Ctrl+Shift+R` on Windows/Linux, `Cmd+Shift+R` on macOS.
## User Manual PDF Export
Generate the current PDF version of the German user manual with:
```bash
./scripts/build_user_manual_pdf.sh
```
The script renders `docs/de/user_manual.md` to `docs/de/user_manual.pdf` with Pandoc. The Markdown file remains the leading source.
## Frontend Entry Point
The active UI is `templates/roll_calculator.html`.
The template contains:
- Page layout and all main CSS.
- A disclaimer modal shown after Basic Auth login and before calculator use.
- Footer build metadata from `build_info`.
- Global state:
- `window.ARTICLE_DATA`
- `window.APP_CONFIG`
- Product/article data loading from `/static/article-data.json`.
- Direct roll calculations.
- Extrapolation calculations.
- Forklift/weight warnings.
- Load optimizer UI and calculations.
Most of the currently active JavaScript is inline in this template. Several files under `static/` appear to be older, alternative, or integration-oriented modules and should be checked before assuming they are active.
## Main Functional Areas
### Disclaimer Gate
The calculator page displays a modal disclaimer before use. The modal:
- Shows German and English disclaimer text.
- Requires a checkbox confirmation.
- Keeps the confirmation button disabled until checked.
- Hides the overlay after confirmation.
This is implemented in `templates/roll_calculator.html` and is client-side only.
### Article Data Loading
`templates/roll_calculator.html` fetches:
```text
/static/article-data.json
```
The loaded array is assigned to `window.ARTICLE_DATA` and used to populate article datalists for direct calculation and extrapolation.
Known article fields used by the UI include:
- `nr`
- `name`
- `thickness`
- `thickness_stddev`
- `area_weight`
- `area_weight_stddev`
- `core_type`
The active `static/article-data.json` currently contains product records with additional min/max/count statistics.
### Direct Calculation
The direct calculation tab supports multiple modes via mode buttons:
- Roll diameter from core diameter, material thickness, and product length.
- Product length from core diameter, material thickness, and roll diameter.
- Product length for a target diameter.
The nominal roll diameter formula used in the template is:
```text
D = sqrt(d^2 + (4 * L * 1000 * t) / pi)
```
Where:
- `D` is roll diameter in mm.
- `d` is core diameter in mm.
- `L` is product length in m.
- `t` is material thickness in mm.
If a tolerance/stddev value is present, the UI also displays a `-2σ` and `+2σ` diameter range.
Roll weight is calculated when roll width, area weight, and length are present:
```text
weight_kg = area_weight_g_m2 * length_m * width_m / 1000
```
### Extrapolation
The extrapolation tab estimates a new roll diameter from a known roll diameter/length pair and a new target length:
```text
D1 = sqrt(d^2 + (L1 / L0) * (D0^2 - d^2))
```
Where:
- `d` is core diameter.
- `D0` is measured/current diameter.
- `L0` is measured/current length.
- `L1` is target length.
- `D1` is the calculated new diameter.
### Forklift Check
`checkForklift()` in `templates/roll_calculator.html` evaluates heavy-roll warnings using `window.APP_CONFIG.forklift_rules`.
The current inline config targets the `bentofix` category and includes:
- Warning threshold: `1700 kg`
- Hard limit: `2750 kg`
- Minimum core outer diameter requirement: `170 mm`
There is also a richer `static/config.json` with localized messages and more detailed requirements.
### Load Optimizer
The Load Optimizer is implemented by inline classes in `templates/roll_calculator.html`:
- `LoadOptimizer`
- `LoadOptimizerUI`
It uses calculated roll data from the Direct Calculation tab and estimates loading capacity for transport presets or custom dimensions.
Preset transport units include:
- 20ft Container
- 40ft Container
- 40ft High Cube
- LKW Sattelzug
- LKW Tandem
The optimizer calculates limiting scenarios by:
- Weight
- Volume
- Geometry
It also renders a side-view SVG visualization of the loading arrangement.
### Standard Deviation Modules
There are standalone/static modules for standard deviation logic:
- `static/stddev_calculator.js`
- `static/rollcalc_stddev_ranges.js`
- `static/rollcalc_stddev_integration.js`
These provide or describe range calculations for material thickness, area weight, roll diameter, and roll weight. Check actual script inclusion before treating them as active in production, because the current template already contains inline stddev/tolerance behavior.
### Admin UI
RollCalc no longer contains an admin UI or admin API. Login/access logging remains in the backend, but logs are not exposed through a RollCalc admin screen.
Administration and maintenance of `article-data.json` is planned for a separate application. RollCalc should continue to consume `static/article-data.json` read-only.
## Static Assets and Data Files
### `static/article-data.json`
Primary product/article dataset used by the active calculator UI.
### `static/config.json`
JSON configuration for forklift/heavy-roll rules. The active template also contains an inline `window.APP_CONFIG`, so developers should verify which config source is authoritative before changing rule behavior.
### `access_log.json`
JSON audit log written by `app.py`.
Important implementation detail: each logged access reads and rewrites the entire JSON file. This is simple but not concurrency-safe and can become inefficient as the file grows.
### `build_info.json`
Deployment/build metadata displayed in the site footer.
Expected fields:
- `version`
- `branch`
- `commit`
- `timestamp`
`app.py` loads this file centrally at startup and exposes it to all templates as `build_info`. If the file is missing, invalid, or a field is empty, the affected values fall back to `unknown` and the website continues to work.
### `fix_article_data.py`
Utility script that updates relative frontend fetch/register paths to Flask-style `/static/...` paths and checks that key static files exist.
## Development Notes
- The codebase is currently closer to a single-page static calculator wrapped by Flask than to a conventional Flask MVC app.
- The active calculator logic is concentrated in `templates/roll_calculator.html`.
- There are duplicated or legacy-looking files with similar names. Before editing JavaScript under `static/`, confirm it is actually referenced by the active template.
- The documentation under `docs/` contains deployment and feature notes, but some filenames and route assumptions may not match the current app exactly.
- The Flask dev server is used for local development only. Production should use a WSGI server.
- Use `scripts/manage_users.py` to add users, rotate passwords, or delete users.
## Known Risks and Maintenance Items
- Password hashes are currently stored in `config/users.json`; a secrets manager or protected deployment-specific config would be more robust for production.
- `access_log.json` is not safe for concurrent writes.
- `access_log.json` grows without rotation or retention limits.
- The custom `/static/<path:filename>` route is intended to protect static files, but Flask also creates a default static route unless disabled. Verify effective route behavior before relying on static-file protection.
- RollCalc no longer includes an admin UI/API; article data administration belongs in a separate application.
- There is no visible automated test suite.
- The main template is large and mixes layout, styling, data loading, calculations, and UI behavior.
- The disclaimer confirmation is client-side only and is not persisted or audited server-side.
+75
View File
@@ -0,0 +1,75 @@
## Version 0.3
- Mehrsprachigkeit
- Filter der Artikelauswahl auf Basis der gewählten Produktgruppe: Secutex, Carbofol, Bentofix, Secumat, Secudrain
- PDF-Export
## Version 0.4
- Benutzermanagement auf sichere Methode umstellen, keine Klartext-Passworte mehr
- Login-/Zugriffslogging beibehalten und robuster machen
- Bestehenden Admin-UI-/Admin-API-Teil aus RollCalc entfernen
- Pflege der `article-data.json` in eine eigenständige Administrations-App auslagern
- Produktionsdaten perspektivisch aus SQL per Query direkt beziehen
## Version 0.5
- Schnittstelle bzw. Import-/Deployment-Prozess zwischen externer Artikelverwaltungs-App und RollCalc definieren
- Rollen-/Berechtigungskonzept für Zugriff auf RollCalc und externe Artikelverwaltung klären
### Article Data and Selection Improvements
- Article-specific production defaults
- Each article in `article-data.json` may optionally provide production-related default values.
- Initially planned defaults are `Product Thickness`, `Area Weight`, `Core Diameter`, and `Roll Width`.
- When an article is selected, available defaults should be copied into the existing calculator fields.
- Missing optional defaults must not clear or overwrite existing user input.
- Users must always be able to override suggested values manually.
- Stored `Core Diameter` values must match the values already allowed by the existing GUI control.
- `Roll Width` must be positive and plausible because it affects both `Roll Weight` and geometric loading in the `Load Optimizer`.
- The design should allow additional production-related defaults to be added later without changing the overall architecture. Possible future examples include preferred transport preset, minimum product length, maximum roll diameter, preferred rounding behavior, production restrictions, and handling limits.
- Maintenance of these defaults should be handled by the planned local Article-Data-Admin tool.
- Out of scope: deriving values from product group, production line, or site; multiple typical widths per article; multiple possible cores per article; locking manual edits; core load-bearing checks; line-dependent width checks; automatic `Roll Width` optimization.
### Production Line and Site Rules
- Define the data source and rule model for production-line- and site-dependent diameter limits.
- This planning is separate from article-specific production defaults.
### Documentation
- Derive the German Quick Reference from the full User Manual.
## Version 0.6
### Roll Planning and Limits
- Plan and validate minimum allowed `Product Length`.
- Validate minimum and maximum `Roll Diameter` once a defined source for production-line and site rules exists.
### Rounding and Production Logic
- Configurable rounding for suggested `Product Length` at a given diameter.
- Allow rounding up or down.
- Make the step size configurable, for example 5 m increments.
- Rounding must not violate minimum product length or any other technical limit.
### Documentation
- Add the English Quick Reference based on the German Quick Reference and the language-aware Help structure.
## Version 0.7
### Handling and Safety Checks
- Plan extended checks for `Roll Weight`.
- Forklift load capacity.
- Mandrel load capacity.
- Core load capacity.
- Additional relevant handling limits.
- Requires defined limits and data sources for forklift, mandrel, and core.
### Result Export
- PDF export for a concrete calculation result.
- Keep separate from the User Manual PDF export.
- Stabilize only after the result layout and exported metrics are defined.
-541
View File
@@ -1,541 +0,0 @@
[
{
"timestamp": "2026-05-28T13:06:19.854940",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:06:58.223722",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:07:05.902636",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:07:51.345366",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:09:37.481260",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:14:02.867188",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:28:08.386403",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:29:06.541184",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:30:47.979071",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:46:05.447815",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:46:19.317688",
"username": "beta",
"endpoint": "/admin",
"method": "GET",
"status": 403
},
{
"timestamp": "2026-05-28T14:42:29.477028",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:43:31.374462",
"username": "admin",
"endpoint": "/admin",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:43:31.411860",
"username": "admin",
"endpoint": "/api/admin/stats",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:43:31.562482",
"username": "admin",
"endpoint": "/api/admin/logs",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:01.420524",
"username": "admin",
"endpoint": "/api/admin/stats",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:01.438789",
"username": "admin",
"endpoint": "/api/admin/logs",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:13.773466",
"username": "admin",
"endpoint": "/admin",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:13.817782",
"username": "admin",
"endpoint": "/api/admin/stats",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:13.894128",
"username": "admin",
"endpoint": "/api/admin/logs",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-06-03T05:20:48.808775",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:48.451519",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:48.676701",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:54.481445",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:58.011356",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:32:48.747837",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:33:07.753648",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:42:11.820817",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:43:37.598978",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:47:39.689201",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T08:25:47.585793",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:32:59.419063",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:35:05.236655",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:40:47.890997",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:41:02.461403",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:42:11.914989",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:42:57.125161",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:54:34.067541",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T10:02:39.927663",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:00:50.808697",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:09:28.615255",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:09:40.011779",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:09:53.681061",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:10:06.334747",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:17:18.862106",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:44:00.451943",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:44:20.277301",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:49:56.333142",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:50:11.692907",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:56:07.569410",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:59:03.417506",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:59:21.102453",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:04:52.131022",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:05:09.915771",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:09:55.899370",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:10:20.387853",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:10:20.736005",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:12:48.924277",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:15:13.236624",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:17:32.470202",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:23:23.613251",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:23:24.369105",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:26:48.783516",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:47:07.888072",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:52:38.653855",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:53:02.662397",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:57:19.316896",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:05:28.671663",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:06:26.413418",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:06:42.715163",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:12:15.337499",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:12:19.510992",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:13:17.018760",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:28:30.320494",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:28:46.119436",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:36:55.439798",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:41:31.868950",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
}
]
-849
View File
@@ -1,849 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Roll Calculator Admin</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f4f6f9;
color: #333;
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #003366 0%, #0066cc 100%);
color: white;
padding: 20px 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 20px; }
.container {
max-width: 1200px;
margin: 30px auto;
padding: 0 20px;
}
.info-box {
background: #fffbea;
border-left: 4px solid #f0b429;
padding: 16px;
border-radius: 4px;
margin-bottom: 20px;
font-size: 13px;
color: #666;
line-height: 1.6;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #e0e8f0;
flex-wrap: wrap;
}
.tab-btn {
padding: 12px 20px;
border: none;
background: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: #666;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}
.tab-btn.active {
color: #0066cc;
border-bottom-color: #0066cc;
font-weight: 700;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
padding: 24px;
margin-bottom: 20px;
}
.card h2 {
font-size: 18px;
margin-bottom: 8px;
color: #003366;
}
.card > p {
font-size: 12px;
color: #666;
margin-bottom: 20px;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-size: 12px;
font-weight: 600;
color: #555;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.form-group input, .form-group textarea, .form-group select {
padding: 9px 11px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
font-family: 'Segoe UI', Arial, sans-serif;
transition: border 0.2s;
}
.form-group textarea {
font-family: 'Courier New', monospace;
resize: vertical;
min-height: 300px;
}
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0,102,204,0.1);
}
.button-group {
display: flex;
gap: 10px;
margin-top: 20px;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary {
background: #0066cc;
color: white;
}
.btn-primary:hover { background: #0052a3; box-shadow: 0 2px 6px rgba(0,102,204,0.3); }
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover { background: #d0d0d0; }
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover { background: #c82333; }
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover { background: #218838; }
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
table th {
background: #f5f5f5;
padding: 12px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid #0066cc;
color: #333;
}
table td {
padding: 12px;
border-bottom: 1px solid #e0e0e0;
}
table tr:hover { background: #f9f9f9; }
.alert {
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 13px;
display: none;
}
.alert.show { display: block; }
.alert-success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.alert-error {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.alert-info {
background: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
}
.action-buttons {
display: flex;
gap: 6px;
}
.action-buttons button {
padding: 6px 12px;
font-size: 11px;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
}
.edit-btn {
background: #0066cc;
color: white;
}
.edit-btn:hover { background: #0052a3; }
.delete-btn {
background: #dc3545;
color: white;
}
.delete-btn:hover { background: #c82333; }
.password-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.password-box {
background: white;
border-radius: 8px;
padding: 32px;
max-width: 400px;
box-shadow: 0 8px 40px rgba(0,0,0,0.3);
text-align: center;
}
.password-box h2 { margin-bottom: 20px; color: #003366; }
.password-box input {
width: 100%;
padding: 11px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
margin-bottom: 16px;
}
.password-box .btn { width: 100%; margin-bottom: 10px; }
.file-input {
display: none;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat {
background: linear-gradient(135deg, #e8f4fd 0%, #d0e8fa 100%);
border: 1px solid #0066cc;
border-radius: 6px;
padding: 16px;
text-align: center;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #003366;
}
.stat-label {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 5000;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: white;
border-radius: 8px;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
padding: 30px;
box-shadow: 0 8px 40px rgba(0,0,0,0.3);
width: 100%;
}
.modal h2 {
color: #003366;
margin-bottom: 20px;
}
.modal-buttons {
display: flex;
gap: 10px;
margin-top: 30px;
justify-content: flex-end;
}
</style>
</head>
<body>
<!-- PASSWORD OVERLAY -->
<div id="passwordOverlay" class="password-overlay">
<div class="password-box">
<h2>🔐 Roll Calculator Admin</h2>
<p style="font-size:13px;color:#666;margin-bottom:20px;">Geben Sie das Admin-Passwort ein</p>
<input type="password" id="passwordInput" placeholder="Passwort" autofocus>
<button class="btn btn-primary" onclick="checkPassword()">🔓 Anmelden</button>
<p style="font-size:11px;color:#999;margin-top:10px;">Standard: admin123</p>
</div>
</div>
<!-- EDIT MODAL -->
<div id="editModal" class="modal-overlay">
<div class="modal">
<h2>📝 Artikel bearbeiten</h2>
<div class="form-row">
<div class="form-group">
<label>Artikel-Nr. (nicht änderbar)</label>
<input type="text" id="editNr" disabled style="background:#f5f5f5;">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Name</label>
<input type="text" id="editName">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Produktdicke (mm)</label>
<input type="number" id="editThickness" step="0.001" min="0">
</div>
<div class="form-group">
<label>Area Weight (g/m²)</label>
<input type="number" id="editAreaWeight" step="1" min="0">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Gewicht pro Rolle (kg)</label>
<input type="number" id="editWeightPerRoll" step="0.1" min="0">
</div>
<div class="form-group">
<label>Breite (m)</label>
<input type="number" id="editWidth" step="0.01" min="0">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Preis (€)</label>
<input type="number" id="editPrice" step="0.01" min="0">
</div>
<div class="form-group">
<label>Typ</label>
<input type="text" id="editType" placeholder="z.B. Geotextil, Kunststoff">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Farbe</label>
<input type="text" id="editColor" placeholder="z.B. Schwarz, Weiß">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Beschreibung</label>
<textarea id="editDescription" placeholder="Zusätzliche Informationen..."></textarea>
</div>
</div>
<div class="modal-buttons">
<button class="btn btn-secondary" onclick="closeEditModal()">Abbrechen</button>
<button class="btn btn-primary" onclick="saveEditedArticle()">💾 Speichern</button>
</div>
</div>
</div>
<!-- MAIN APP -->
<div id="mainApp" style="display:none;">
<div class="header">
<h1>🛠️ Roll Calculator Admin</h1>
<button class="btn btn-secondary" onclick="logout()">Abmelden</button>
</div>
<div class="container">
<div class="info-box">
💡 Tipp: Alle Änderungen werden im Browser gespeichert. Mit "Download" kannst du die neuen JSON-Dateien herunterladen und auf deinen Server kopieren.
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('articles')">📄 Artikel-Daten</button>
<button class="tab-btn" onclick="switchTab('config')">⚙️ Konfiguration</button>
<button class="tab-btn" onclick="switchTab('tools')">🔧 Tools</button>
</div>
<!-- TAB: ARTICLES -->
<div id="articles" class="tab-content active">
<div class="card">
<h2>Artikel verwalten</h2>
<p>Artikel hinzufügen, bearbeiten oder löschen</p>
<div id="articleAlert" class="alert"></div>
<div class="stats">
<div class="stat">
<div class="stat-value" id="articleCount">0</div>
<div class="stat-label">Artikel</div>
</div>
</div>
<h3 style="font-size:14px;margin-bottom:12px;margin-top:20px;">Neuer Artikel</h3>
<div class="form-row">
<div class="form-group">
<label>Artikel-Nr. *</label>
<input type="text" id="artNr" placeholder="z.B. 180305">
</div>
<div class="form-group">
<label>Name *</label>
<input type="text" id="artName" placeholder="z.B. Bfix NSP 5300">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Produktdicke (mm) *</label>
<input type="number" id="artThickness" placeholder="z.B. 6.918" step="0.001" min="0">
</div>
<div class="form-group">
<label>Area Weight (g/m²)</label>
<input type="number" id="artAreaWeight" placeholder="z.B. 5300" step="1" min="0">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Gewicht pro Rolle (kg)</label>
<input type="number" id="artWeightPerRoll" placeholder="z.B. 25" step="0.1" min="0">
</div>
<div class="form-group">
<label>Breite (m)</label>
<input type="number" id="artWidth" placeholder="z.B. 5" step="0.01" min="0">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Preis (€)</label>
<input type="number" id="artPrice" placeholder="z.B. 250.00" step="0.01" min="0">
</div>
<div class="form-group">
<label>Typ</label>
<input type="text" id="artType" placeholder="z.B. Geotextil">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Farbe</label>
<input type="text" id="artColor" placeholder="z.B. Schwarz">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Beschreibung</label>
<textarea id="artDescription" placeholder="Zusätzliche Informationen..."></textarea>
</div>
</div>
<div class="button-group">
<button class="btn btn-primary" onclick="addArticle()">➕ Hinzufügen</button>
<button class="btn btn-secondary" onclick="clearArticleForm()">Zurücksetzen</button>
</div>
<h3 style="font-size:14px;margin-bottom:12px;margin-top:30px;">Artikel-Liste</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Nr.</th>
<th>Name</th>
<th>Dicke (mm)</th>
<th>Breite (m)</th>
<th>Preis (€)</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="articleTableBody">
<tr><td colspan="6" style="text-align:center;color:#999;">Keine Artikel</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- TAB: CONFIG -->
<div id="config" class="tab-content">
<div class="card">
<h2>Konfiguration (config.json)</h2>
<p>Gabelstapler-Regeln und Schwellenwerte - JSON direkt bearbeiten</p>
<div id="configAlert" class="alert"></div>
<textarea id="configJson" placeholder="config.json wird hier angezeigt..."></textarea>
<div class="button-group">
<button class="btn btn-primary" onclick="saveConfigJson()">💾 Aktualisieren</button>
<button class="btn btn-secondary" onclick="resetConfigJson()">🔄 Zurücksetzen</button>
</div>
<p style="font-size:11px;color:#999;margin-top:16px;">
💡 JSON wird automatisch validiert. Im Fehlerfall siehst du eine Fehlermeldung.
</p>
</div>
</div>
<!-- TAB: TOOLS -->
<div id="tools" class="tab-content">
<div class="card">
<h2>📥 Dateien verwalten</h2>
<p>Herunterladen, hochladen oder zurücksetzen</p>
<div id="toolsAlert" class="alert"></div>
<h3 style="font-size:13px;margin-bottom:12px;">Download</h3>
<div class="button-group">
<button class="btn btn-success" onclick="downloadArticleData()">📥 article-data.json</button>
<button class="btn btn-success" onclick="downloadConfig()">📥 config.json</button>
<button class="btn btn-success" onclick="downloadAll()">📦 Alle Dateien (ZIP)</button>
</div>
<h3 style="font-size:13px;margin-bottom:12px;margin-top:20px;">Upload</h3>
<p style="font-size:12px;color:#666;margin-bottom:10px;">Dateien hochladen und überschreiben:</p>
<div class="button-group">
<button class="btn btn-secondary" onclick="document.getElementById('uploadFile').click()">📤 JSON-Datei hochladen</button>
</div>
<input type="file" id="uploadFile" class="file-input" accept=".json" onchange="handleFileUpload(event)">
<h3 style="font-size:13px;margin-bottom:12px;margin-top:20px;">Daten</h3>
<p style="font-size:12px;color:#666;margin-bottom:10px;">Alle bearbeiteten Daten als ein JSON exportieren:</p>
<button class="btn btn-primary" onclick="exportAllAsJson()">💾 Alles exportieren</button>
<h3 style="font-size:13px;margin-bottom:12px;margin-top:20px;">⚠️ Vorsicht</h3>
<p style="font-size:12px;color:#666;margin-bottom:10px;">Bearbeitete Daten zurücksetzen (Achtung: nicht rückgängig!):</p>
<button class="btn btn-danger" onclick="if(confirm('Wirklich auf Original zurücksetzen?')) resetAll()">🔄 Auf Standardwerte zurücksetzen</button>
</div>
</div>
</div>
</div>
<script>
const ADMIN_PASSWORD = 'admin123';
let articlesData = [];
let configData = {};
let currentEditingArticle = null;
document.addEventListener('DOMContentLoaded', () => {
loadDataFromServer();
document.getElementById('passwordInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') checkPassword();
});
});
function loadDataFromServer() {
Promise.all([
fetch('article-data.json').then(r => r.json()).catch(() => []),
fetch('config.json').then(r => r.json()).catch(() => ({}))
]).then(([articles, config]) => {
articlesData = articles || [];
configData = config || {};
console.log('✅ Daten geladen:', articlesData.length, 'Artikel');
});
}
function checkPassword() {
const pwd = document.getElementById('passwordInput').value;
if (pwd === ADMIN_PASSWORD) {
document.getElementById('passwordOverlay').style.display = 'none';
document.getElementById('mainApp').style.display = 'block';
refreshUI();
} else {
alert('❌ Falsches Passwort!');
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
}
}
function logout() {
document.getElementById('mainApp').style.display = 'none';
document.getElementById('passwordOverlay').style.display = 'flex';
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
}
function switchTab(tabName) {
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById(tabName).classList.add('active');
event.target.classList.add('active');
if (tabName === 'config') loadConfigJson();
if (tabName === 'articles') refreshArticleTable();
}
function showAlert(containerId, message, type, duration = 4000) {
const alert = document.getElementById(containerId);
alert.className = `alert show alert-${type}`;
alert.textContent = message;
if (duration) setTimeout(() => { alert.classList.remove('show'); }, duration);
}
function refreshUI() {
refreshArticleTable();
loadConfigJson();
updateStats();
}
function updateStats() {
document.getElementById('articleCount').textContent = articlesData.length;
}
function openEditModal(nr) {
const article = articlesData.find(a => a.nr === nr);
if (!article) return;
currentEditingArticle = article;
document.getElementById('editNr').value = article.nr || '';
document.getElementById('editName').value = article.name || '';
document.getElementById('editThickness').value = article.thickness || '';
document.getElementById('editAreaWeight').value = article.area_weight || '';
document.getElementById('editWeightPerRoll').value = article.weight_per_roll || '';
document.getElementById('editWidth').value = article.width || '';
document.getElementById('editPrice').value = article.price || '';
document.getElementById('editType').value = article.type || '';
document.getElementById('editColor').value = article.color || '';
document.getElementById('editDescription').value = article.description || '';
document.getElementById('editModal').classList.add('active');
}
function closeEditModal() {
document.getElementById('editModal').classList.remove('active');
currentEditingArticle = null;
}
function saveEditedArticle() {
if (!currentEditingArticle) return;
currentEditingArticle.name = document.getElementById('editName').value.trim();
currentEditingArticle.thickness = parseFloat(document.getElementById('editThickness').value) || 0;
currentEditingArticle.area_weight = parseFloat(document.getElementById('editAreaWeight').value) || 0;
currentEditingArticle.weight_per_roll = parseFloat(document.getElementById('editWeightPerRoll').value) || 0;
currentEditingArticle.width = parseFloat(document.getElementById('editWidth').value) || 0;
currentEditingArticle.price = parseFloat(document.getElementById('editPrice').value) || 0;
currentEditingArticle.type = document.getElementById('editType').value.trim();
currentEditingArticle.color = document.getElementById('editColor').value.trim();
currentEditingArticle.description = document.getElementById('editDescription').value.trim();
closeEditModal();
showAlert('articleAlert', '✅ Artikel aktualisiert!', 'success');
refreshArticleTable();
}
function addArticle() {
const nr = document.getElementById('artNr').value.trim();
const name = document.getElementById('artName').value.trim();
const thickness = parseFloat(document.getElementById('artThickness').value) || 0;
if (!nr || !name || thickness === 0) {
showAlert('articleAlert', '❌ Artikel-Nr., Name und Dicke sind erforderlich!', 'error');
return;
}
if (articlesData.some(a => a.nr === nr)) {
showAlert('articleAlert', '❌ Artikel-Nr. existiert bereits!', 'error');
return;
}
const newArticle = {
nr,
name,
thickness,
area_weight: parseFloat(document.getElementById('artAreaWeight').value) || 0,
weight_per_roll: parseFloat(document.getElementById('artWeightPerRoll').value) || 0,
width: parseFloat(document.getElementById('artWidth').value) || 0,
price: parseFloat(document.getElementById('artPrice').value) || 0,
type: document.getElementById('artType').value.trim(),
color: document.getElementById('artColor').value.trim(),
description: document.getElementById('artDescription').value.trim()
};
articlesData.push(newArticle);
showAlert('articleAlert', '✅ Artikel hinzugefügt!', 'success');
clearArticleForm();
refreshArticleTable();
updateStats();
}
function deleteArticle(nr) {
if (confirm('Artikel ' + nr + ' wirklich löschen?')) {
articlesData = articlesData.filter(a => a.nr !== nr);
showAlert('articleAlert', '✅ Artikel gelöscht!', 'success');
refreshArticleTable();
updateStats();
}
}
function refreshArticleTable() {
const tbody = document.getElementById('articleTableBody');
if (articlesData.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#999;">Keine Artikel</td></tr>';
return;
}
tbody.innerHTML = articlesData.map(a => `
<tr>
<td><strong>${a.nr}</strong></td>
<td>${a.name}</td>
<td>${a.thickness.toFixed(3)}</td>
<td>${a.width ? a.width.toFixed(2) : '—'}</td>
<td>${a.price ? a.price.toFixed(2) + ' €' : '—'}</td>
<td>
<div class="action-buttons">
<button class="edit-btn" onclick="openEditModal('${a.nr}')">✎ Bearbeiten</button>
<button class="delete-btn" onclick="deleteArticle('${a.nr}')">✕ Löschen</button>
</div>
</td>
</tr>
`).join('');
}
function clearArticleForm() {
document.getElementById('artNr').value = '';
document.getElementById('artName').value = '';
document.getElementById('artThickness').value = '';
document.getElementById('artAreaWeight').value = '';
document.getElementById('artWeightPerRoll').value = '';
document.getElementById('artWidth').value = '';
document.getElementById('artPrice').value = '';
document.getElementById('artType').value = '';
document.getElementById('artColor').value = '';
document.getElementById('artDescription').value = '';
}
function loadConfigJson() {
document.getElementById('configJson').value = JSON.stringify(configData, null, 2);
}
function saveConfigJson() {
try {
configData = JSON.parse(document.getElementById('configJson').value);
showAlert('configAlert', '✅ Konfiguration aktualisiert!', 'success');
} catch (e) {
showAlert('configAlert', '❌ Ungültiges JSON: ' + e.message, 'error', 0);
}
}
function resetConfigJson() {
loadConfigJson();
showAlert('configAlert', '🔄 Zurückgesetzt', 'info');
}
function downloadFile(data, filename) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function downloadArticleData() {
downloadFile(articlesData, 'article-data.json');
showAlert('toolsAlert', '✅ article-data.json heruntergeladen', 'success');
}
function downloadConfig() {
downloadFile(configData, 'config.json');
showAlert('toolsAlert', '✅ config.json heruntergeladen', 'success');
}
function downloadAll() {
const allData = { articles: articlesData, config: configData, exported_at: new Date().toISOString() };
downloadFile(allData, 'roll-calculator-backup-' + new Date().toISOString().slice(0,10) + '.json');
showAlert('toolsAlert', '✅ Alle Daten heruntergeladen', 'success');
}
function exportAllAsJson() {
downloadAll();
}
function handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
if (data.articles) articlesData = data.articles;
if (data.config) configData = data.config;
if (data.articleData) articlesData = data.articleData;
showAlert('toolsAlert', '✅ Datei hochgeladen und geladen!', 'success');
refreshUI();
} catch (err) {
showAlert('toolsAlert', '❌ Fehler beim Laden der Datei: ' + err.message, 'error', 0);
}
};
reader.readAsText(file);
event.target.value = '';
}
function resetAll() {
articlesData = [];
configData = {};
showAlert('toolsAlert', '🔄 Auf Standardwerte zurückgesetzt', 'success');
refreshUI();
}
</script>
</body>
</html>
-694
View File
@@ -1,694 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Roll Calculator Admin - Einfach</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f4f6f9;
color: #333;
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #003366 0%, #0066cc 100%);
color: white;
padding: 20px 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 20px; }
.container {
max-width: 1200px;
margin: 30px auto;
padding: 0 20px;
}
.info-box {
background: #fffbea;
border-left: 4px solid #f0b429;
padding: 16px;
border-radius: 4px;
margin-bottom: 20px;
font-size: 13px;
color: #666;
line-height: 1.6;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #e0e8f0;
flex-wrap: wrap;
}
.tab-btn {
padding: 12px 20px;
border: none;
background: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: #666;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}
.tab-btn.active {
color: #0066cc;
border-bottom-color: #0066cc;
font-weight: 700;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
padding: 24px;
margin-bottom: 20px;
}
.card h2 {
font-size: 18px;
margin-bottom: 8px;
color: #003366;
}
.card > p {
font-size: 12px;
color: #666;
margin-bottom: 20px;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-size: 12px;
font-weight: 600;
color: #555;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.form-group input, .form-group textarea {
padding: 9px 11px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
font-family: 'Segoe UI', Arial, sans-serif;
transition: border 0.2s;
}
.form-group textarea {
font-family: 'Courier New', monospace;
resize: vertical;
min-height: 300px;
}
.form-group input:focus, .form-group textarea:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0,102,204,0.1);
}
.button-group {
display: flex;
gap: 10px;
margin-top: 20px;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary {
background: #0066cc;
color: white;
}
.btn-primary:hover { background: #0052a3; box-shadow: 0 2px 6px rgba(0,102,204,0.3); }
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover { background: #d0d0d0; }
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover { background: #c82333; }
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover { background: #218838; }
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
table th {
background: #f5f5f5;
padding: 12px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid #0066cc;
color: #333;
}
table td {
padding: 12px;
border-bottom: 1px solid #e0e0e0;
}
table tr:hover { background: #f9f9f9; }
.alert {
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 13px;
display: none;
}
.alert.show { display: block; }
.alert-success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.alert-error {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.alert-info {
background: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
}
.action-buttons {
display: flex;
gap: 6px;
}
.action-buttons button {
padding: 6px 12px;
font-size: 11px;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
}
.edit-btn {
background: #0066cc;
color: white;
}
.edit-btn:hover { background: #0052a3; }
.delete-btn {
background: #dc3545;
color: white;
}
.delete-btn:hover { background: #c82333; }
.password-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.password-box {
background: white;
border-radius: 8px;
padding: 32px;
max-width: 400px;
box-shadow: 0 8px 40px rgba(0,0,0,0.3);
text-align: center;
}
.password-box h2 { margin-bottom: 20px; color: #003366; }
.password-box input {
width: 100%;
padding: 11px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
margin-bottom: 16px;
}
.password-box .btn { width: 100%; margin-bottom: 10px; }
.file-input {
display: none;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat {
background: linear-gradient(135deg, #e8f4fd 0%, #d0e8fa 100%);
border: 1px solid #0066cc;
border-radius: 6px;
padding: 16px;
text-align: center;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #003366;
}
.stat-label {
font-size: 12px;
color: #666;
margin-top: 4px;
}
</style>
</head>
<body>
<!-- PASSWORD OVERLAY -->
<div id="passwordOverlay" class="password-overlay">
<div class="password-box">
<h2>🔐 Roll Calculator Admin</h2>
<p style="font-size:13px;color:#666;margin-bottom:20px;">Geben Sie das Admin-Passwort ein</p>
<input type="password" id="passwordInput" placeholder="Passwort" autofocus>
<button class="btn btn-primary" onclick="checkPassword()">🔓 Anmelden</button>
<p style="font-size:11px;color:#999;margin-top:10px;">Standard: admin123</p>
</div>
</div>
<!-- MAIN APP -->
<div id="mainApp" style="display:none;">
<div class="header">
<h1>🛠️ Roll Calculator Admin</h1>
<button class="btn btn-secondary" onclick="logout()">Abmelden</button>
</div>
<div class="container">
<div class="info-box">
💡 Tipp: Alle Änderungen werden im Browser gespeichert. Mit "Download" kannst du die neuen JSON-Dateien herunterladen und auf deinen Server kopieren.
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('articles')">📄 Artikel-Daten</button>
<button class="tab-btn" onclick="switchTab('config')">⚙️ Konfiguration</button>
<button class="tab-btn" onclick="switchTab('tools')">🔧 Tools</button>
</div>
<!-- TAB: ARTICLES -->
<div id="articles" class="tab-content active">
<div class="card">
<h2>Artikel verwalten</h2>
<p>Artikel hinzufügen, bearbeiten oder löschen</p>
<div id="articleAlert" class="alert"></div>
<div class="stats">
<div class="stat">
<div class="stat-value" id="articleCount">0</div>
<div class="stat-label">Artikel</div>
</div>
</div>
<h3 style="font-size:14px;margin-bottom:12px;margin-top:20px;">Neuer Artikel</h3>
<div class="form-row">
<div class="form-group">
<label>Artikel-Nr.</label>
<input type="text" id="artNr" placeholder="z.B. 180305">
</div>
<div class="form-group">
<label>Name</label>
<input type="text" id="artName" placeholder="z.B. Bfix NSP 5300, 5,00 x 40 m">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Produktdicke (mm)</label>
<input type="number" id="artThickness" placeholder="z.B. 6.918" step="0.001" min="0">
</div>
<div class="form-group">
<label>Area Weight (g/m²) - Optional</label>
<input type="number" id="artAreaWeight" placeholder="z.B. 5300" step="1" min="0">
</div>
</div>
<div class="button-group">
<button class="btn btn-primary" onclick="addArticle()">➕ Hinzufügen</button>
<button class="btn btn-secondary" onclick="clearArticleForm()">Zurücksetzen</button>
</div>
<h3 style="font-size:14px;margin-bottom:12px;margin-top:30px;">Artikel-Liste</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Nr.</th>
<th>Name</th>
<th>Dicke (mm)</th>
<th>Area Weight</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="articleTableBody">
<tr><td colspan="5" style="text-align:center;color:#999;">Keine Artikel</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- TAB: CONFIG -->
<div id="config" class="tab-content">
<div class="card">
<h2>Konfiguration (config.json)</h2>
<p>Gabelstapler-Regeln und Schwellenwerte - JSON direkt bearbeiten</p>
<div id="configAlert" class="alert"></div>
<textarea id="configJson" placeholder="config.json wird hier angezeigt..."></textarea>
<div class="button-group">
<button class="btn btn-primary" onclick="saveConfigJson()">💾 Aktualisieren</button>
<button class="btn btn-secondary" onclick="resetConfigJson()">🔄 Zurücksetzen</button>
</div>
<p style="font-size:11px;color:#999;margin-top:16px;">
💡 JSON wird automatisch validiert. Im Fehlerfall siehst du eine Fehlermeldung.
</p>
</div>
</div>
<!-- TAB: TOOLS -->
<div id="tools" class="tab-content">
<div class="card">
<h2>📥 Dateien verwalten</h2>
<p>Herunterladen, hochladen oder zurücksetzen</p>
<div id="toolsAlert" class="alert"></div>
<h3 style="font-size:13px;margin-bottom:12px;">Download</h3>
<div class="button-group">
<button class="btn btn-success" onclick="downloadArticleData()">📥 article-data.json</button>
<button class="btn btn-success" onclick="downloadConfig()">📥 config.json</button>
<button class="btn btn-success" onclick="downloadAll()">📦 Alle Dateien (ZIP)</button>
</div>
<h3 style="font-size:13px;margin-bottom:12px;margin-top:20px;">Upload</h3>
<p style="font-size:12px;color:#666;margin-bottom:10px;">Dateien hochladen und überschreiben:</p>
<div class="button-group">
<button class="btn btn-secondary" onclick="document.getElementById('uploadFile').click()">📤 JSON-Datei hochladen</button>
</div>
<input type="file" id="uploadFile" class="file-input" accept=".json" onchange="handleFileUpload(event)">
<h3 style="font-size:13px;margin-bottom:12px;margin-top:20px;">Daten</h3>
<p style="font-size:12px;color:#666;margin-bottom:10px;">Alle bearbeiteten Daten als ein JSON exportieren:</p>
<button class="btn btn-primary" onclick="exportAllAsJson()">💾 Alles exportieren</button>
<h3 style="font-size:13px;margin-bottom:12px;margin-top:20px;">⚠️ Vorsicht</h3>
<p style="font-size:12px;color:#666;margin-bottom:10px;">Bearbeitete Daten zurücksetzen (Achtung: nicht rückgängig!):</p>
<button class="btn btn-danger" onclick="if(confirm('Wirklich auf Original zurücksetzen?')) resetAll()">🔄 Auf Standardwerte zurücksetzen</button>
</div>
</div>
</div>
</div>
<script>
// ═════════════════════════════════════════════════════════════════════════════
// CONFIG
// ═════════════════════════════════════════════════════════════════════════════
const ADMIN_PASSWORD = 'admin123'; // ÄNDERN!
// Beispiel article-data.json (wird von Server geladen)
let articlesData = [];
// Beispiel config.json (wird von Server geladen)
let configData = {};
// ═════════════════════════════════════════════════════════════════════════════
// INIT
// ═════════════════════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
loadDataFromServer();
document.getElementById('passwordInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') checkPassword();
});
});
function loadDataFromServer() {
// Versuche, Daten vom Server zu laden
Promise.all([
fetch('article-data.json').then(r => r.json()).catch(() => []),
fetch('config.json').then(r => r.json()).catch(() => ({}))
]).then(([articles, config]) => {
articlesData = articles || [];
configData = config || {};
console.log('✅ Daten geladen:', articlesData.length, 'Artikel, config vorhanden');
});
}
// ═════════════════════════════════════════════════════════════════════════════
// AUTH
// ═════════════════════════════════════════════════════════════════════════════
function checkPassword() {
const pwd = document.getElementById('passwordInput').value;
if (pwd === ADMIN_PASSWORD) {
document.getElementById('passwordOverlay').style.display = 'none';
document.getElementById('mainApp').style.display = 'block';
refreshUI();
} else {
alert('❌ Falsches Passwort!');
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
}
}
function logout() {
document.getElementById('mainApp').style.display = 'none';
document.getElementById('passwordOverlay').style.display = 'flex';
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
}
// ═════════════════════════════════════════════════════════════════════════════
// UI
// ═════════════════════════════════════════════════════════════════════════════
function switchTab(tabName) {
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById(tabName).classList.add('active');
event.target.classList.add('active');
if (tabName === 'config') loadConfigJson();
if (tabName === 'articles') refreshArticleTable();
}
function showAlert(containerId, message, type, duration = 4000) {
const alert = document.getElementById(containerId);
alert.className = `alert show alert-${type}`;
alert.textContent = message;
if (duration) setTimeout(() => { alert.classList.remove('show'); }, duration);
}
function refreshUI() {
refreshArticleTable();
loadConfigJson();
updateStats();
}
function updateStats() {
document.getElementById('articleCount').textContent = articlesData.length;
}
// ═════════════════════════════════════════════════════════════════════════════
// ARTICLES
// ═════════════════════════════════════════════════════════════════════════════
function addArticle() {
const nr = document.getElementById('artNr').value.trim();
const name = document.getElementById('artName').value.trim();
const thickness = parseFloat(document.getElementById('artThickness').value) || 0;
const area_weight = parseFloat(document.getElementById('artAreaWeight').value) || 0;
if (!nr || !name || thickness === 0) {
showAlert('articleAlert', '❌ Artikel-Nr., Name und Dicke sind erforderlich!', 'error');
return;
}
if (articlesData.some(a => a.nr === nr)) {
showAlert('articleAlert', '❌ Artikel-Nr. existiert bereits!', 'error');
return;
}
articlesData.push({ nr, name, thickness, area_weight });
showAlert('articleAlert', '✅ Artikel hinzugefügt!', 'success');
clearArticleForm();
refreshArticleTable();
updateStats();
}
function deleteArticle(nr) {
if (confirm('Artikel ' + nr + ' wirklich löschen?')) {
articlesData = articlesData.filter(a => a.nr !== nr);
showAlert('articleAlert', '✅ Artikel gelöscht!', 'success');
refreshArticleTable();
updateStats();
}
}
function refreshArticleTable() {
const tbody = document.getElementById('articleTableBody');
if (articlesData.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#999;">Keine Artikel</td></tr>';
return;
}
tbody.innerHTML = articlesData.map(a => `
<tr>
<td><strong>${a.nr}</strong></td>
<td>${a.name}</td>
<td>${a.thickness.toFixed(3)}</td>
<td>${a.area_weight ? a.area_weight : '—'}</td>
<td>
<div class="action-buttons">
<button class="edit-btn" onclick="editArticle('${a.nr}')">✎ Bearbeiten</button>
<button class="delete-btn" onclick="deleteArticle('${a.nr}')">✕ Löschen</button>
</div>
</td>
</tr>
`).join('');
}
function editArticle(nr) {
const article = articlesData.find(a => a.nr === nr);
if (!article) return;
const newThickness = prompt('Produktdicke (mm):', article.thickness);
if (newThickness !== null) {
article.thickness = parseFloat(newThickness) || 0;
showAlert('articleAlert', '✅ Aktualisiert!', 'success');
refreshArticleTable();
}
}
function clearArticleForm() {
document.getElementById('artNr').value = '';
document.getElementById('artName').value = '';
document.getElementById('artThickness').value = '';
document.getElementById('artAreaWeight').value = '';
}
// ═════════════════════════════════════════════════════════════════════════════
// CONFIG
// ═════════════════════════════════════════════════════════════════════════════
function loadConfigJson() {
document.getElementById('configJson').value = JSON.stringify(configData, null, 2);
}
function saveConfigJson() {
try {
configData = JSON.parse(document.getElementById('configJson').value);
showAlert('configAlert', '✅ Konfiguration aktualisiert!', 'success');
} catch (e) {
showAlert('configAlert', '❌ Ungültiges JSON: ' + e.message, 'error', 0);
}
}
function resetConfigJson() {
loadConfigJson();
showAlert('configAlert', '🔄 Zurückgesetzt', 'info');
}
// ═════════════════════════════════════════════════════════════════════════════
// DOWNLOADS / UPLOADS
// ═════════════════════════════════════════════════════════════════════════════
function downloadFile(data, filename) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function downloadArticleData() {
downloadFile(articlesData, 'article-data.json');
showAlert('toolsAlert', '✅ article-data.json heruntergeladen', 'success');
}
function downloadConfig() {
downloadFile(configData, 'config.json');
showAlert('toolsAlert', '✅ config.json heruntergeladen', 'success');
}
function downloadAll() {
const allData = { articles: articlesData, config: configData, exported_at: new Date().toISOString() };
downloadFile(allData, 'roll-calculator-backup-' + new Date().toISOString().slice(0,10) + '.json');
showAlert('toolsAlert', '✅ Alle Daten heruntergeladen', 'success');
}
function exportAllAsJson() {
downloadAll();
}
function handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
if (data.articles) articlesData = data.articles;
if (data.config) configData = data.config;
if (data.articleData) articlesData = data.articleData; // alternate key
showAlert('toolsAlert', '✅ Datei hochgeladen und geladen!', 'success');
refreshUI();
} catch (err) {
showAlert('toolsAlert', '❌ Fehler beim Laden der Datei: ' + err.message, 'error', 0);
}
};
reader.readAsText(file);
event.target.value = ''; // Reset file input
}
function resetAll() {
articlesData = [];
configData = {};
showAlert('toolsAlert', '🔄 Auf Standardwerte zurückgesetzt', 'success');
refreshUI();
}
</script>
</body>
</html>
+434 -41
View File
@@ -3,12 +3,27 @@ Naue Roll Calculator - Beta
Flask app with HTTP Basic Authentication Flask app with HTTP Basic Authentication
""" """
from flask import Flask, render_template, request, send_file, send_from_directory, jsonify from flask import (
Flask,
abort,
jsonify,
render_template,
request,
send_file,
send_from_directory,
url_for,
)
from flask_httpauth import HTTPBasicAuth from flask_httpauth import HTTPBasicAuth
from markdown_it import MarkdownIt
from markupsafe import Markup
from werkzeug.security import check_password_hash
from functools import wraps from functools import wraps
import os import os
from datetime import datetime from datetime import datetime
import json import json
import re
import socket
import unicodedata
app = Flask(__name__) app = Flask(__name__)
auth = HTTPBasicAuth() auth = HTTPBasicAuth()
@@ -17,33 +32,180 @@ auth = HTTPBasicAuth()
# CONFIGURATION # CONFIGURATION
# ============================================================================ # ============================================================================
# Benutzer für Beta-Phase (in Produktion aus env-Variablen laden!)
BETA_USERS = {
"beta": "rollcalc_beta_2026", # ⚠️ ÄNDERN!
"naue": "naue_access_2026", # ⚠️ ÄNDERN!
"cniehues": "beta_test_2026", # ⚠️ ÄNDERN!
"lvollmert": "Geheim!", # ⚠️ ÄNDERN!
"mtazl": "rollcalc", # ⚠️ ÄNDERN!
"controlling": "beta2026" # ⚠️ ÄNDERN!
}
# Optional: Admin-Features (z.B. Logs, Stats)
ADMIN_USERS = {
"admin": "admin_secure_pwd_2026" # ⚠️ ÄNDERN!
}
# Logging für Auditing
LOG_FILE = "access_log.json" LOG_FILE = "access_log.json"
BUILD_INFO_FILE = "build_info.json"
USER_CONFIG_FILE = os.path.join(app.root_path, "config", "users.json")
DOCS_DIR = os.path.join(app.root_path, "docs")
DEFAULT_DOC_LANGUAGE = "de"
USER_MANUAL_FILENAME = "user_manual.md"
RECENT_CHANGES_FILENAME = "recent_changes.md"
QUICK_REFERENCE_FILENAME = "quick_reference.md"
WHATS_NEW_SUMMARY_FILE = os.path.join(app.root_path, "static", "recent_changes.json")
UNKNOWN_BUILD_INFO = {
"version": "unknown",
"branch": "unknown",
"commit": "unknown",
"timestamp": "unknown"
}
# ============================================================================
# BUILD INFO
# ============================================================================
def load_build_info():
"""Load deployment/build metadata for templates."""
try:
with open(BUILD_INFO_FILE, "r") as f:
data = json.load(f)
except Exception as e:
print(f"[Build Info Error] {e}")
return UNKNOWN_BUILD_INFO.copy()
if not isinstance(data, dict):
return UNKNOWN_BUILD_INFO.copy()
build_info = UNKNOWN_BUILD_INFO.copy()
for key in build_info:
value = data.get(key)
if isinstance(value, str) and value.strip():
build_info[key] = value.strip()
return build_info
BUILD_INFO = load_build_info()
def load_whats_new_summary():
"""Load compact release-note metadata for the What's New dialog."""
try:
with open(WHATS_NEW_SUMMARY_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
print(f"[What's New Error] {e}")
return {"version": "unknown", "items": []}
if not isinstance(data, dict):
return {"version": "unknown", "items": []}
version = data.get("version")
items = data.get("items")
if not isinstance(items, list):
items = []
return {
"version": version.strip()
if isinstance(version, str) and version.strip()
else "unknown",
"items": [
item.strip()
for item in items
if isinstance(item, str) and item.strip()
][:5]
}
WHATS_NEW_SUMMARY = load_whats_new_summary()
@app.context_processor
def inject_build_info():
"""Make build metadata and release-note summary available in templates."""
return {
"build_info": BUILD_INFO,
"whats_new_summary": WHATS_NEW_SUMMARY,
"static_asset_url": static_asset_url
}
def get_static_asset_version(build_info=None):
"""Return the preferred cache-busting build identifier."""
info = build_info or BUILD_INFO
for key in ("commit", "version"):
value = info.get(key) if isinstance(info, dict) else None
if isinstance(value, str) and value.strip() and value.strip() != "unknown":
return value.strip()
return None
def static_asset_url(filename):
"""Build a URL for RollCalc-owned static assets with a build query."""
asset_version = get_static_asset_version()
if asset_version:
return url_for("serve_static", filename=filename, v=asset_version)
return url_for("serve_static", filename=filename)
@app.after_request
def add_html_cache_policy(response):
"""Require revalidation for dynamic HTML while leaving static caching intact."""
if response.mimetype == "text/html":
response.headers["Cache-Control"] = "no-cache"
return response
# ============================================================================ # ============================================================================
# AUTHENTICATION # AUTHENTICATION
# ============================================================================ # ============================================================================
class UserConfigError(RuntimeError):
"""Raised when the user configuration is missing or invalid."""
def load_user_hashes(path=USER_CONFIG_FILE):
"""Load user password hashes from config/users.json."""
command_hint = "Run python3 scripts/manage_users.py to initialize or repair it."
if not os.path.exists(path):
raise UserConfigError(
f"User config file is missing: {path}. {command_hint}"
)
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as e:
raise UserConfigError(
f"Invalid JSON in user config file {path}: {e}. {command_hint}"
) from e
except Exception as e:
raise UserConfigError(
f"Could not read user config file {path}: {e}. {command_hint}"
) from e
if not isinstance(data, dict):
raise UserConfigError(
f"Invalid user config file {path}: root element must be an object. "
f"{command_hint}"
)
if not data:
raise UserConfigError(
f"User config file {path} does not contain any users. {command_hint}"
)
users = {}
for username, value in data.items():
if not isinstance(username, str) or not username.strip():
raise UserConfigError(
f"Invalid user config file {path}: usernames must be strings. "
f"{command_hint}"
)
if not isinstance(value, str) or not value.strip():
raise UserConfigError(
f"Invalid user config file {path}: password hash for "
f"user {username!r} must be a string. {command_hint}"
)
users[username.strip()] = value.strip()
return users
USER_PASSWORD_HASHES = load_user_hashes()
@auth.verify_password @auth.verify_password
def verify_password(username, password): def verify_password(username, password):
"""Verify HTTP Basic Auth credentials""" """Verify HTTP Basic Auth credentials"""
all_users = {**BETA_USERS, **ADMIN_USERS} password_hash = USER_PASSWORD_HASHES.get(username)
if username in all_users and all_users[username] == password: if not password_hash:
return None
if password_hash and check_password_hash(password_hash, password):
return username return username
return None return None
@@ -67,10 +229,124 @@ def log_access(username, endpoint, method, status=200):
except Exception as e: except Exception as e:
print(f"[Logging Error] {e}") print(f"[Logging Error] {e}")
def find_free_port(start_port=5000, max_attempts=10, host="0.0.0.0"):
"""Find the first available TCP port at or above start_port."""
for port in range(start_port, start_port + max_attempts):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((host, port))
except OSError:
continue
return port
raise RuntimeError(
f"No free port found from {start_port} to "
f"{start_port + max_attempts - 1}"
)
# ============================================================================ # ============================================================================
# ROUTES # ROUTES
# ============================================================================ # ============================================================================
def slugify_heading(text):
"""Create stable, URL-friendly heading ids for rendered Markdown."""
normalized = unicodedata.normalize("NFKD", text)
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-zA-Z0-9]+", "-", ascii_text).strip("-").lower()
return slug or "section"
def build_manual_renderer():
"""Build a Markdown renderer with raw HTML disabled."""
renderer = MarkdownIt("commonmark", {"html": False})
renderer.enable("table")
return renderer
def get_document_path(filename, language=DEFAULT_DOC_LANGUAGE):
"""Return the Markdown path for a language-specific help document."""
return os.path.join(DOCS_DIR, language, filename)
def get_manual_path(language=DEFAULT_DOC_LANGUAGE):
"""Return the Markdown path for a language-specific manual."""
return get_document_path(USER_MANUAL_FILENAME, language)
def get_manual_screenshot_dir(language=DEFAULT_DOC_LANGUAGE):
"""Return the screenshot directory for a language-specific manual."""
return os.path.join(DOCS_DIR, language, "screenshots")
def rewrite_manual_asset_paths(markdown_text, language=DEFAULT_DOC_LANGUAGE):
"""Route relative manual screenshot links through the protected docs route."""
screenshot_url = url_for("manual_screenshot", filename="")
return markdown_text.replace("](screenshots/", f"]({screenshot_url}")
def add_heading_ids_and_toc(tokens):
"""Attach ids to headings and create a compact table of contents."""
toc = []
used_slugs = {}
for index, token in enumerate(tokens):
if token.type != "heading_open":
continue
level = int(token.tag[1])
inline_token = tokens[index + 1] if index + 1 < len(tokens) else None
title = (
inline_token.content
if inline_token and inline_token.type == "inline"
else ""
)
base_slug = slugify_heading(title)
count = used_slugs.get(base_slug, 0)
used_slugs[base_slug] = count + 1
slug = base_slug if count == 0 else f"{base_slug}-{count + 1}"
token.attrSet("id", slug)
if level <= 2:
toc.append({
"level": level,
"title": title,
"id": slug
})
return toc
def render_markdown_document(filename, language=DEFAULT_DOC_LANGUAGE):
"""Read and render a Markdown help document into HTML and a generated TOC."""
renderer = build_manual_renderer()
document_path = get_document_path(filename, language)
with open(document_path, "r", encoding="utf-8") as document_file:
markdown_text = document_file.read()
markdown_text = rewrite_manual_asset_paths(markdown_text, language)
tokens = renderer.parse(markdown_text)
toc = add_heading_ids_and_toc(tokens)
html = renderer.renderer.render(tokens, renderer.options, {})
return Markup(html), toc
def render_user_manual(language=DEFAULT_DOC_LANGUAGE):
"""Read and render the Markdown user manual."""
return render_markdown_document(USER_MANUAL_FILENAME, language)
def render_recent_changes(language=DEFAULT_DOC_LANGUAGE):
"""Read and render the Markdown recent changes document."""
return render_markdown_document(RECENT_CHANGES_FILENAME, language)
def render_quick_reference(language="en"):
"""Read and render the Markdown quick reference document."""
return render_markdown_document(QUICK_REFERENCE_FILENAME, language)
@app.route("/", methods=["GET"]) @app.route("/", methods=["GET"])
@auth.login_required @auth.login_required
def index(): def index():
@@ -78,6 +354,140 @@ def index():
log_access(auth.current_user(), "/", "GET") log_access(auth.current_user(), "/", "GET")
return render_template("roll_calculator.html") return render_template("roll_calculator.html")
@app.route("/help", methods=["GET"])
@auth.login_required
def help_index():
"""Help landing page - requires authentication."""
log_access(auth.current_user(), "/help", "GET")
return render_template("help_index.html")
@app.route("/help/user-manual", methods=["GET"])
@auth.login_required
def user_manual():
"""Render the Markdown user manual inside the RollCalc layout."""
log_access(auth.current_user(), "/help/user-manual", "GET")
try:
manual_html, toc = render_user_manual(DEFAULT_DOC_LANGUAGE)
return render_template(
"user_manual.html",
document_title="User Manual",
document_subtitle="RollCalc technical user documentation",
document_actions=[
{
"href": url_for("quick_reference"),
"label": "Open English Quick Reference",
}
],
error_title="User Manual unavailable",
manual_html=manual_html,
toc=toc,
render_error=None
)
except Exception:
app.logger.exception("Could not render user manual")
return render_template(
"user_manual.html",
document_title="User Manual",
document_subtitle="RollCalc technical user documentation",
document_actions=[
{
"href": url_for("quick_reference"),
"label": "Open English Quick Reference",
}
],
error_title="User Manual unavailable",
manual_html=None,
toc=[],
render_error=(
"The user manual could not be loaded. "
"Please contact the RollCalc maintainer if the problem persists."
)
), 500
@app.route("/help/quick-reference", methods=["GET"])
@auth.login_required
def quick_reference():
"""Render the English Quick Reference inside the RollCalc Help layout."""
log_access(auth.current_user(), "/help/quick-reference", "GET")
try:
quick_html, toc = render_quick_reference("en")
return render_template(
"user_manual.html",
document_title="Quick Reference",
document_subtitle="Concise English reference for common RollCalc workflows",
document_actions=[],
error_title="Quick Reference unavailable",
manual_html=quick_html,
toc=toc,
render_error=None
)
except Exception:
app.logger.exception("Could not render quick reference")
return render_template(
"user_manual.html",
document_title="Quick Reference",
document_subtitle="Concise English reference for common RollCalc workflows",
document_actions=[],
error_title="Quick Reference unavailable",
manual_html=None,
toc=[],
render_error=(
"The quick reference could not be loaded. "
"Please contact the RollCalc maintainer if the problem persists."
)
), 500
@app.route("/help/recent-changes", methods=["GET"])
@auth.login_required
def recent_changes():
"""Render recent changes inside the RollCalc Help layout."""
log_access(auth.current_user(), "/help/recent-changes", "GET")
try:
changes_html, toc = render_recent_changes(DEFAULT_DOC_LANGUAGE)
return render_template(
"user_manual.html",
document_title="Recent Changes",
document_subtitle="RollCalc release notes",
document_actions=[],
error_title="Recent Changes unavailable",
manual_html=changes_html,
toc=toc,
render_error=None
)
except Exception:
app.logger.exception("Could not render recent changes")
return render_template(
"user_manual.html",
document_title="Recent Changes",
document_subtitle="RollCalc release notes",
document_actions=[],
error_title="Recent Changes unavailable",
manual_html=None,
toc=[],
render_error=(
"Recent changes could not be loaded. "
"Please contact the RollCalc maintainer if the problem persists."
)
), 500
@app.route("/help/user-manual/screenshots/<path:filename>", methods=["GET"])
@auth.login_required
def manual_screenshot(filename, language=DEFAULT_DOC_LANGUAGE):
"""Serve user-manual screenshots from the selected language directory."""
log_access(
auth.current_user(),
f"/help/user-manual/screenshots/{filename}",
"GET"
)
try:
return send_from_directory(get_manual_screenshot_dir(language), filename)
except Exception:
app.logger.warning("Missing or inaccessible manual screenshot: %s", filename)
abort(404)
@app.route("/static/<path:filename>", methods=["GET"]) @app.route("/static/<path:filename>", methods=["GET"])
@auth.login_required @auth.login_required
def serve_static(filename): def serve_static(filename):
@@ -96,30 +506,12 @@ def health_check():
def get_user(): def get_user():
"""Get current authenticated user info""" """Get current authenticated user info"""
user = auth.current_user() user = auth.current_user()
is_admin = user in ADMIN_USERS
return jsonify({ return jsonify({
"username": user, "username": user,
"authenticated": True, "authenticated": True,
"is_admin": is_admin,
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
}), 200 }), 200
@app.route("/admin/logs", methods=["GET"])
@auth.login_required
def get_logs():
"""View access logs - admin only"""
user = auth.current_user()
if user not in ADMIN_USERS:
log_access(user, "/admin/logs", "GET", 403)
return jsonify({"error": "Unauthorized"}), 403
log_access(user, "/admin/logs", "GET")
logs = []
if os.path.exists(LOG_FILE):
with open(LOG_FILE, "r") as f:
logs = json.load(f)
return jsonify({"logs": logs}), 200
# ============================================================================ # ============================================================================
# ERROR HANDLERS # ERROR HANDLERS
# ============================================================================ # ============================================================================
@@ -146,21 +538,22 @@ def internal_error(e):
if __name__ == "__main__": if __name__ == "__main__":
os.makedirs("templates", exist_ok=True) os.makedirs("templates", exist_ok=True)
os.makedirs("static", exist_ok=True) os.makedirs("static", exist_ok=True)
port = find_free_port(start_port=5000, max_attempts=10, host="0.0.0.0")
print(""" print(f"""
╔═══════════════════════════════════════════════════════════╗ ╔═══════════════════════════════════════════════════════════╗
║ Naue Roll Calculator - V14 + QoL Update ║ ║ Naue Roll Calculator - V14 + QoL Update ║
║ HTTP Basic Auth Enabled ║ ║ HTTP Basic Auth Enabled ║
║ ║ ║ ║
║ ⚠️ BEFORE PRODUCTION: Update passwords in app.py ║ ║ ⚠️ BEFORE PRODUCTION: Update passwords in app.py ║
║ ║ ║ ║
║ Starten auf: http://localhost:5000 ║ ║ Starten auf: http://localhost:{port:<5} ║
╚═══════════════════════════════════════════════════════════╝ ╚═══════════════════════════════════════════════════════════╝
""") """)
app.run( app.run(
host="0.0.0.0", host="0.0.0.0",
port=5000, port=port,
debug=False, debug=False,
use_reloader=True use_reloader=False
) )
+6
View File
@@ -0,0 +1,6 @@
{
"version": "v0.4-beta2",
"branch": "develop/v0.4",
"commit": "156a6b7",
"timestamp": "2026-07-29T09:15:04+02:00"
}
+3
View File
@@ -0,0 +1,3 @@
{
"example-user": "pbkdf2:sha256:600000$example-salt$example-hash"
}
+165 -219
View File
@@ -1,267 +1,213 @@
# 🚀 RollCalc V14 + QoL Update - Deployment Guide # Deployment Guide
## 📦 Archive Contents Dieses Dokument beschreibt den empfohlenen Ablauf für das Deployment von RollCalc auf dem Beta- oder Produktivserver.
---
# Deployment Checklist
Vor Abschluss eines Deployments sollten alle folgenden Punkte erfüllt sein:
- [ ] Repository aktualisiert
- [ ] Python-Abhängigkeiten installiert
- [ ] `config/users.json` vorhanden und lesbar
- [ ] `build_info.json` geprüft bzw. aktualisiert
- [ ] RollCalc-Service erfolgreich neu gestartet
- [ ] Smoke-Test erfolgreich durchgeführt
---
# Voraussetzungen
Erwartete Serverstruktur:
- Projektverzeichnis
```text
/var/www/html
``` ```
rollcalc_v14_complete_final.tar.gz (18 KB)
rollcalc_files/ - Python Virtual Environment
├── app.py ← Flask Backend
├── service-worker.js ← Offline Support ```text
├── config.json ← Forklift Rules /opt/rollcalc-venv
├── article-data.json ← Product Database (PLACEHOLDER) ```
│
├── rollcalc_improvements.js ← V14: Article Selection - systemd-Service
├── rollcalc_stddev_ranges.js ← QoL: Stddev Ranges
├── rollcalc_stddev_integration.js ← QoL: Integration ```text
│ rollcalc.service
├── deploy_rollcalc.py ← 🎯 DEPLOYMENT SCRIPT
│
├── README.md ← Full Documentation
├── QUICKREF.md ← Quick Reference
├── INTEGRATION_GUIDE.html ← Step-by-Step Setup
├── FILE_STRUCTURE.md ← File Reference
├── DELIVERY_SUMMARY.md ← V14 Overview
├── QOL_UPDATE_SUMMARY.md ← QoL Overview
├── STDDEV_RANGES_DOCS.md ← Stddev Documentation
└── STDDEV_IMPLEMENTATION_CHECKLIST.md ← Implementation Checklist
``` ```
--- ---
## 🎯 Quick Deployment (3 Steps) # Deployment
### Step 1: Extract Archive ## 1. Repository aktualisieren
```bash ```bash
tar -xzf rollcalc_v14_complete_final.tar.gz cd /var/www/html
cd rollcalc_files/
git fetch origin
git switch develop/v0.4
git pull --ff-only origin develop/v0.4
``` ```
### Step 2: Run Deployment Script ---
## 2. Python-Abhängigkeiten aktualisieren
Neue Python-Abhängigkeiten werden ausschließlich in der virtuellen Umgebung installiert.
```bash ```bash
# Make executable (first time only) sudo /opt/rollcalc-venv/bin/python \
chmod +x deploy_rollcalc.py -m pip install -r requirements.txt
# Run deployment
python3 deploy_rollcalc.py /path/to/your/flask/root/
# Example:
python3 deploy_rollcalc.py /home/user/rollcalc/
``` ```
### Step 3: Update Configuration ---
Edit `app.py` (in your Flask root) and update passwords: ## 3. Benutzerdatei prüfen
```python Die Datei
BETA_USERS = {
"beta": "CHANGE_THIS_PASSWORD",
"mtazl": "CHANGE_THIS_PASSWORD",
"cniehues": "CHANGE_THIS_PASSWORD",
# ... etc
}
ADMIN_USERS = { ```text
"admin": "CHANGE_THIS_ADMIN_PASSWORD" config/users.json
}
``` ```
Then run: ist **nicht Bestandteil des Git-Repositories**.
Vor dem ersten Start nach einem Deployment sicherstellen:
- Datei vorhanden
- gültiges JSON
- mindestens ein Benutzer vorhanden
- für den Service-Benutzer lesbar
Empfohlene Dateirechte:
```bash ```bash
cd /path/to/your/flask/root/ sudo chown martin:www-data config/users.json
python app.py sudo chmod 640 config/users.json
``` ```
Open: **http://localhost:5000** Neue Benutzer werden ausschließlich mit folgendem Werkzeug verwaltet:
---
## 📋 What the Deployment Script Does
The `deploy_rollcalc.py` script automates everything:
✅ Creates required directories
✅ Backs up existing files
✅ Deploys all files to correct locations
✅ Verifies Python dependencies
✅ Checks for default passwords
✅ Validates all files after deployment
✅ Prints clear next steps
**No manual copying needed!**
---
## ⚠️ Important: article-data.json
The archive contains a **placeholder** for `article-data.json`.
**You need to add your product data:**
### Option A: Import from Existing System
```bash ```bash
# If you already have article-data.json: python3 scripts/manage_users.py
cp /path/to/your/article-data.json /path/to/flask/static/
```
### Option B: Use Minimal Data for Testing
The placeholder is already there - you can test the calculator without product data.
### Option C: Generate from ERP
Update the export script in your ERP system to create article-data.json format:
```json
[
{
"nr": "180005",
"name": "Bfix NSP 4000, 5,00 x 50 m",
"thickness": 6.228,
"thickness_stddev": 0.379,
"area_weight": 3899.02,
"area_weight_stddev": 133.24,
"core_type": 0.0,
...
}
]
``` ```
--- ---
## 🛠️ Manual Deployment (If Script Fails) ## 4. Build-Informationen prüfen
If the Python script doesn't work, deploy manually: Falls erforderlich:
```bash ```bash
# Extract ./scripts/update_build_info.sh
tar -xzf rollcalc_v14_complete_final.tar.gz
# Backend
cp rollcalc_files/app.py /path/to/flask/
# Static files
cp rollcalc_files/*.js /path/to/flask/static/
cp rollcalc_files/*.json /path/to/flask/static/
# Documentation
mkdir -p /path/to/flask/docs
cp rollcalc_files/*.md /path/to/flask/docs/
cp rollcalc_files/*.html /path/to/flask/docs/
# Run
cd /path/to/flask/
python app.py
``` ```
--- Anschließend kontrollieren:
## ✅ Verification Checklist
After deployment, verify:
- [ ] `app.py` in Flask root
- [ ] `service-worker.js` in `/static/`
- [ ] `rollcalc_improvements.js` in `/static/`
- [ ] `rollcalc_stddev_ranges.js` in `/static/`
- [ ] `rollcalc_stddev_integration.js` in `/static/`
- [ ] `article-data.json` in `/static/` (with real data)
- [ ] `config.json` in `/static/`
- [ ] `templates/roll_calculator.html` exists (add 2 script tags for StdDev)
- [ ] Passwords updated in `app.py`
- [ ] Server runs: `python app.py`
- [ ] Page loads: `http://localhost:5000`
- [ ] Login works (use credentials from `BETA_USERS`)
---
## 🎨 HTML Integration (StdDev Ranges)
If you want to use the **Standard Deviation Ranges** feature, add to your `roll_calculator.html` in the `<head>` section:
```html
<!-- Add these two lines: -->
<script src="/static/rollcalc-stddev-ranges.js"></script>
<script src="/static/rollcalc-stddev-integration.js"></script>
<!-- Also add this to your Direct Calc form: -->
<!-- (Details in STDDEV_IMPLEMENTATION_CHECKLIST.md) -->
```
---
## 📚 Documentation Files
| File | Purpose |
|------|---------|
| `README.md` | Full technical documentation |
| `QUICKREF.md` | Quick reference card |
| `QOL_UPDATE_SUMMARY.md` | What's new in this update |
| `STDDEV_RANGES_DOCS.md` | How StdDev ranges work |
| `STDDEV_IMPLEMENTATION_CHECKLIST.md` | How to implement StdDev ranges |
| `INTEGRATION_GUIDE.html` | Original V14 setup guide |
| `FILE_STRUCTURE.md` | File reference |
Read these **after deployment** to understand all features.
---
## 🆘 Troubleshooting
### "Deploy script fails: Missing dependencies"
```bash ```bash
pip install flask flask-httpauth cat build_info.json
``` ```
### "Page won't load at localhost:5000" Die Build-Version wird unter anderem für den **What's New**-Dialog verwendet.
1. Check Flask is running: `python app.py`
2. Check port 5000 is free
3. Check firewall allows localhost:5000
### "Login prompt appears but password doesn't work"
1. Check `BETA_USERS` in `app.py` - use those credentials
2. Username: `mtazl`, Password: `rollcalc` (default)
3. Change to your custom passwords
### "Article dropdown is empty"
1. Check `article-data.json` exists in `/static/`
2. Check it has valid JSON format
3. Refresh browser (Ctrl+F5 or Cmd+Shift+R)
4. Check browser console (F12) for errors
### "Service Worker not working"
1. Clear browser cache (Ctrl+Shift+Delete)
2. Hard refresh (Ctrl+Shift+F5)
3. Check DevTools → Application → Service Workers
--- ---
## 🚀 You're Ready! ## 5. RollCalc neu starten
That's it! The deployment script handles 90% of the work. ```bash
sudo systemctl restart rollcalc.service
**Questions?** Check the documentation files in `/docs/` sudo systemctl status rollcalc.service --no-pager
```
**Issues?** Look in browser console (F12) for error messages
**Success indicators:**
- ✅ Page loads at localhost:5000
- ✅ Login prompt appears
- ✅ Article dropdown has data
- ✅ Calculations work
- ✅ Offline mode works (DevTools → Network → Offline)
--- ---
**Version:** 14.1 ## 6. Smoke-Test
**Date:** 2026-07-01
**Status:** Production Ready ✅ Nach jedem Deployment mindestens folgende Punkte prüfen:
- Login funktioniert
- Roll Calculator öffnet
- User Manual erreichbar
- Recent Changes erreichbar
- What's New erscheint bei neuer Version
- Beispielberechnung durchführen
- Load Optimizer funktioniert
---
# Fehlerdiagnose
## Service-Status
```bash
sudo systemctl status rollcalc.service --no-pager
```
## Journal anzeigen
```bash
sudo journalctl -u rollcalc.service -n 100 --no-pager
```
## Benutzerdatei prüfen
```bash
ls -l config/users.json
sudo -u www-data test -r config/users.json && echo "users.json readable"
```
---
# Benutzerverwaltung
Die Benutzerverwaltung erfolgt ausschließlich über:
```bash
python3 scripts/manage_users.py
```
Die Datei
```text
config/users.json
```
wird **nicht versioniert** und muss auf jedem Zielsystem separat gepflegt werden.
Die Datei darf niemals in das Git-Repository eingecheckt werden.
---
# Rollback
Vor jedem Deployment empfiehlt es sich, den aktuellen Commit zu notieren:
```bash
git rev-parse HEAD
```
Rollback auf einen früheren Stand:
```bash
git switch develop/v0.4
git reset --hard <Commit-ID>
sudo systemctl restart rollcalc.service
```
---
# Wartungshinweise
- Neue Python-Abhängigkeiten immer über die virtuelle Umgebung installieren.
- `config/users.json` regelmäßig sichern.
cp config/users.json backups/users_$(date +%F).json
- `build_info.json` vor einem Release aktualisieren.
- Nach Änderungen an der Authentifizierung die Benutzerverwaltung testen.
- Nach Änderungen am Help-System User Manual und Recent Changes prüfen.
- Nach Änderungen an Berechnungen mindestens einen vollständigen Rechentest durchführen.
+39
View File
@@ -0,0 +1,39 @@
# Documentation Guidelines
## Language
The primary documentation language is German.
An English version will be maintained as a translation of the German master document.
## UI terminology
All user interface terms remain in English.
Examples:
- Direct Calculation
- Extrapolation
- Roll Diameter
- Product Length
- Capacity Summary
- Capacity Analysis
- Governing Constraint
Do not translate UI labels.
## Style
- Technical user manual
- Workflow-oriented
- Explain why, not only how
- Engineering language
- No marketing language
## Workflow
1. Write German chapter.
2. Technical review.
3. Add screenshots.
4. Approve.
5. Translate to English.
View File
View File
+21
View File
@@ -0,0 +1,21 @@
# Recent Changes
## v0.4-beta2 — Current Version
- English Quick Reference added to the integrated Help area for the main calculation and transport-analysis workflows.
- Release notes and What’s New now focus on the currently deployed version.
- Browser cache handling improved so updated frontend data is loaded more reliably after deployments.
## After an Update
If RollCalc does not display the latest interface after an update, perform a force reload:
Ctrl+F5 or Ctrl+Shift+R on Windows/Linux,
Cmd+Shift+R on macOS.
## Earlier Changes
- User Manual integrated into RollCalc.
- Help area added for internal documentation.
- Capacity Analysis presentation improved.
- Governing Constraint highlighted more clearly.
- Remaining Capacity added to the Capacity Summary.
File diff suppressed because it is too large Load Diff
Binary file not shown.
+315
View File
@@ -0,0 +1,315 @@
# RollCalc Quick Reference
## 1. Before You Start
RollCalc is an engineering support tool for roll geometry, roll weight, and transport capacity assessment. It helps experienced technical users prepare and interpret calculations, but it does not replace production, handling, or shipping approval.
Before using a result operationally, check that the selected article, article data, manually entered values, and transport assumptions match the actual case. The calculated result is only meaningful if the input data is technically plausible.
Use this Quick Reference for the most common workflows. For background, detailed interpretation, and troubleshooting, use the full `User Manual`.
## 2. Which Function Should I Use?
| Task | Function / Calculation Mode |
| --- | --- |
| Calculate the diameter of a planned roll | `Direct Calculation` → `Roll Diameter` |
| Estimate product length from a measured roll | `Direct Calculation` → `Product Length` |
| Determine maximum permitted product length | `Direct Calculation` → `Target Diameter` |
| Estimate a new roll from a reference roll | `Extrapolation` |
| Assess transport capacity | `Load Optimizer` |
| Understand why a transport result is limited | `Capacity Summary` and `Capacity Analysis` |
| Visually check the geometric arrangement | `Front View – Cross-Section` |
## 3. Article Selection and Input Data
Use `Article Number (optional)` when an article is available in the article data. The current application can prefill available article values such as `Product Thickness`, tolerance data, `Area Weight`, and `Core Diameter` when these values are present in the data source.
Prefilled values are starting values, not approvals. The values currently visible in the input fields are decisive for the calculation. You can manually change the populated values before calculating.
Planned article-data extension: future article data may also provide a typical `Roll Width` for automatic prefilling. Do not assume `Roll Width` is currently populated by article selection unless it is visibly filled in the UI.
Check before calculation:
- `Product Category` and `Production Site` match the intended context.
- `Article Number (optional)` refers to the correct article.
- `Product Thickness` is plausible for the product.
- `Area Weight` is current and product-specific.
- `Core Diameter` matches the actual core.
- `Roll Width` is entered when `Calculated Weight` or `Load Optimizer` will be used.
## 4. Direct Calculation – Roll Diameter
Use `Direct Calculation` with `Calculation Mode` `Roll Diameter` when a planned `Product Length` is known and the expected outer roll size must be assessed.
### Required inputs
- `Core Diameter (d)`
- `Product Thickness (t)`
- `Product Length (L)`
### Optional weight inputs
- `Roll Width`
- `Area Weight`
### Main results
- `Roll Diameter (D)`
- tolerance range when tolerance is entered
- `Calculated Weight`, when `Roll Width`, `Area Weight`, and `Product Length` are available
- `Forklift Check`, where applicable
### Check before use
- Units: mm for diameters and thickness, m for length and width, g/m² for `Area Weight`, kg for weight.
- `Product Thickness` is plausible and belongs to the article.
- `Core Diameter` matches the actual core type.
- `Product Length` is realistic for the planned roll.
- `Calculated Weight` is plausible before using the result in `Forklift Check` or `Load Optimizer`.
## 5. Direct Calculation – Product Length
Use `Direct Calculation` with `Calculation Mode` `Product Length` when a roll has a measured `Roll Diameter` and the product length on the roll must be estimated.
### Required inputs
- `Core Diameter (d)`
- `Roll Diameter (D)`
- `Product Thickness (t)`
### Main result
- `Product Length (L)`
### Typical use
- measured roll
- stock check
- quality check
- comparison with a planned or nominal length
### Important caution
The accuracy depends directly on the measured `Roll Diameter` and the assumed `Product Thickness`. If either value is inaccurate or does not match the actual product, the calculated `Product Length` will be inaccurate as well.
## 6. Direct Calculation – Target Diameter
Use `Direct Calculation` with `Calculation Mode` `Target Diameter` to determine the maximum allowed `Product Length` for one roll under entered diameter and weight limits. This is a limit workflow for a single roll. It is not a transport optimization and does not answer how many rolls fit into a container or vehicle.
### Relevant inputs
- `Core Diameter (d)`
- `Product Thickness (t)`
- `Roll Width`
- `Area Weight`
- `Max. Roll Diameter`
- `Max. Roll Weight`
- `Minimum Product Length`
### Main results
- `Maximum Allowed Product Length`
- `Resulting Roll Diameter`
- `Resulting weight`
- `Minimum length`
- `Difference`
- `Limiting factor`
- diameter and weight utilization line
### Interpretation
| Result situation | Meaning |
| --- | --- |
| `Limiting factor: Diameter` | `Max. Roll Diameter` is reached before the weight limit. |
| `Limiting factor: Weight` | `Max. Roll Weight` is reached before the diameter limit. |
| `Limiting factor: Diameter + Weight` | Both limits are reached at the same practical length. |
| warning below `Minimum length` | The requested `Minimum Product Length` cannot be achieved under the entered limits. |
The result describes the maximum allowed single-roll layout under the entered limits. It is not automatically the best roll design.
## 7. Roll Weight and Forklift Check
`Roll Weight` depends on `Product Length`, `Roll Width`, and `Area Weight`. Similar `Roll Diameter` values can therefore produce very different roll weights if width, length, or material weight differ.
`Calculated Weight` is used as the practical roll weight for downstream assessment. It is relevant for handling, `Forklift Check`, `Max. Roll Weight` checks, and the `Load Optimizer`.
`Forklift Check` is a technical warning or handling indication. It may show messages such as `Forklift Check OK`, `Heavy Roll – Special Equipment Required`, or `Weight Limit Exceeded`. It is not an operational approval, prohibition, or safety release.
Check first if `Forklift Check` appears unexpected:
- `Calculated Weight`
- `Core Diameter`
- `Product Category`
- `Roll Width`
- `Area Weight`
- article data used for the calculation
## 8. Extrapolation
Use `Extrapolation` when a `Known Reference Roll` exists and a comparable roll with a changed length must be estimated.
### Use only when
- a known reference roll exists,
- the same article is used,
- `Core Diameter` is unchanged,
- material structure and `Product Thickness` are comparable,
- mainly `Product Length` changes.
### Required inputs
- `Core Diameter (d)`
- `Known Roll Diameter (D₀)`
- `Known Roll Length (L₀)`
- `New Roll Length (L₁)`
### Main result
- `Extrapolated Roll Diameter (D₁)`
- optional min/max range when `Tolerance (optional)` is entered
### Do not use when
- the article changes,
- `Product Thickness` changes,
- `Core Diameter` changes,
- reference data is uncertain,
- a full technical roll layout is required.
The quality of the result depends on the quality and comparability of the reference roll.
## 9. Load Optimizer
Use `Load Optimizer – Transport Capacity Calculator` after the roll has already been defined. The `Load Optimizer` evaluates an existing roll in a selected transport space. It does not redesign the roll and does not automatically select the best transport mode.
### Required roll data
- `Roll Diameter`
- `Roll Width`
- `Roll Weight`
These values are taken from the completed `Direct Calculation` workflow. Roll data must be plausible before the transport calculation is meaningful.
### Required transport data
- `Preset Transport Type:`
- or `Custom` with `Length (m)`, `Width (m)`, `Height (m)`, and `Max. Load (kg)`
- `Loading Clearances`
- `Side Wall Clearance (one side)`
- `Ceiling Clearance (top only)`
`Loading Clearances` reduce the effective loading space. They can change the number of rolls that fit geometrically and may change the `Governing Constraint`.
## 10. Reading the Transport Results
Read transport results in this order:
1. Read `Capacity Summary`.
2. Check `Total Rolls`.
3. Identify the `Governing Constraint`.
4. Review `Remaining Capacity`.
5. Check `Front View – Cross-Section`.
6. Use `Capacity Analysis` to understand the technical limit.
### Capacity Summary
`Capacity Summary` shows the actual transport result. It is divided into:
- `Governing Constraint`
- `Transport Result`
- `Remaining Capacity`
`Transport Result` includes `Total Rolls`, `Weight Utilization`, `Volume Utilization`, and the transported product area. Use these values together; a single utilization value does not explain the transport limit by itself.
Use this area first because it shows the result that can actually be reached under the entered assumptions.
### Governing Constraint
| Governing Constraint | Meaning |
| --- | --- |
| `Geometry` | No additional roll fits into the actual arrangement. Payload or volume may remain unused. |
| `Weight` | The permitted payload is reached before geometric capacity is exhausted. |
| `Volume` | The calculated transport volume is reached before another constraint becomes decisive. |
`Governing Constraint` is not a recommendation. It is the technical constraint that determines the final `Total Rolls`.
### Remaining Capacity
`Remaining Capacity` shows reserves after the actual loading result:
- `Remaining Payload`
- `Unused Payload`
- `Unused Volume`
Remaining values do not automatically mean that another roll fits. They must be read together with `Governing Constraint` and `Front View – Cross-Section`.
### Capacity Analysis
`Capacity Analysis` is a constraint analysis, not a list of alternative loading solutions. Its rows show theoretical capacity limits for:
- `Weight`
- `Volume`
- `Geometry`
The smallest applicable limit determines the actual `Total Rolls`. The governing row is marked with `→ Governing Constraint`.
The table columns are:
- `Constraint`
- `Max Rolls`
- `Total Weight`
- `Transported Area [m²]`
- `Payload Util. %` — indicates the percentage of the permitted payload that is used.
### Front View – Cross-Section
`Front View – Cross-Section` visualizes the actual geometric loading configuration. Use it to check whether the geometric arrangement is plausible, especially when `Geometry` is the `Governing Constraint`.
### Important interpretation rules
- `Remaining Payload` does not automatically mean that another roll fits.
- `Unused Volume` does not automatically mean that another roll fits.
- `Capacity Analysis` does not show alternative loading solutions.
- `Weight`, `Volume`, and `Geometry` are theoretical capacity limits.
- The smallest applicable limit determines the actual `Total Rolls`.
## 11. Final Plausibility Check
Before using a result for production, handling, or transport planning, check:
- [ ] Correct article selected
- [ ] `Product Thickness` checked
- [ ] `Area Weight` checked
- [ ] `Core Diameter` checked
- [ ] `Roll Width` checked
- [ ] `Product Length` or `Roll Diameter` checked
- [ ] `Roll Weight` plausible
- [ ] `Calculated Weight` plausible
- [ ] Transport dimensions correct
- [ ] `Max. Load (kg)` correct
- [ ] `Loading Clearances` realistic
- [ ] `Governing Constraint` understandable
- [ ] `Remaining Payload` and `Volume Utilization` plausible
- [ ] `Front View – Cross-Section` plausible
- [ ] Result compared with experience or a reference case
## 12. Common Problems
| Observation | Check first |
| --- | --- |
| `Roll Diameter` is unexpectedly high or low | `Product Thickness`, `Product Length`, `Core Diameter`, units |
| `Product Length` is much larger or smaller than expected | measured `Roll Diameter`, `Product Thickness`, `Core Diameter` |
| `Roll Weight` appears incorrect | `Product Length`, `Roll Width`, `Area Weight` |
| Similar `Roll Diameter` values have very different weights | `Roll Width`, `Area Weight`, `Product Length` |
| `Forklift Check` appears unexpected | `Calculated Weight`, `Core Diameter`, `Product Category`, article data |
| `Extrapolation` result appears implausible | `Known Roll Diameter (D₀)`, `Known Roll Length (L₀)`, `New Roll Length (L₁)`, comparability of both rolls |
| Fewer rolls fit than expected | roll data, `Loading Clearances`, transport dimensions, `Governing Constraint` |
| `Remaining Payload` is available but no additional roll fits | `Geometry`, `Front View – Cross-Section` |
| `Capacity Analysis` appears contradictory | governing row, `Capacity Summary`, `Remaining Capacity`, `Front View – Cross-Section` |
| Similar calculations produce different results | article data, roll geometry, transport data, `Loading Clearances` |
Recalculate completely after changes to article data, roll geometry, or transport parameters. Old results should not be interpreted after changing values such as `Product Thickness`, `Area Weight`, `Core Diameter`, `Roll Width`, `Max. Load (kg)`, or `Loading Clearances`.
## 13. Key Rule
> The calculated result is only as reliable as the entered article data, roll data, and transport assumptions. Always perform a technical plausibility check before operational use.
View File
-107
View File
@@ -1,107 +0,0 @@
#!/usr/bin/env python3
"""
Automatischer Fix für article-data.json Fetch-URLs in Flask
Ersetzt relative URLs durch /static/ URLs
"""
import re
import os
from pathlib import Path
def fix_html_file(html_path):
"""Ersetzt die alten Fetch-URLs durch die neuen Flask-URLs"""
if not os.path.exists(html_path):
print(f"❌ Datei nicht gefunden: {html_path}")
return False
print(f"📖 Öffne: {html_path}")
with open(html_path, 'r', encoding='utf-8') as f:
content = f.read()
original_content = content
changes = []
# Fix 1: fetch('article-data.json') → fetch('/static/article-data.json')
if "fetch('article-data.json')" in content or 'fetch("article-data.json")' in content:
content = content.replace("fetch('article-data.json')", "fetch('/static/article-data.json')")
content = content.replace('fetch("article-data.json")', 'fetch("/static/article-data.json")')
changes.append("✓ article-data.json URL aktualisiert")
# Fix 2: fetch('config.json') → fetch('/static/config.json')
if "fetch('config.json')" in content or 'fetch("config.json")' in content:
content = content.replace("fetch('config.json')", "fetch('/static/config.json')")
content = content.replace('fetch("config.json")', 'fetch("/static/config.json")')
changes.append("✓ config.json URL aktualisiert")
# Fix 3: register('service-worker.js') → register('/static/service-worker.js')
if "register('service-worker.js')" in content or 'register("service-worker.js")' in content:
content = content.replace("register('service-worker.js')", "register('/static/service-worker.js')")
content = content.replace('register("service-worker.js")', 'register("/static/service-worker.js")')
changes.append("✓ service-worker.js URL aktualisiert")
if content != original_content:
# Backup erstellen
backup_path = html_path + ".backup"
with open(backup_path, 'w', encoding='utf-8') as f:
f.write(original_content)
print(f"💾 Backup erstellt: {backup_path}")
# Neue Version speichern
with open(html_path, 'w', encoding='utf-8') as f:
f.write(content)
print(f"✅ HTML aktualisiert: {html_path}")
for change in changes:
print(f" {change}")
return True
else:
print("⚠️ Keine Änderungen notwendig (URLs sind bereits korrekt)")
return False
def check_static_files():
"""Prüft ob die benötigten Dateien im static/ Ordner sind"""
print("\n🔍 Prüfe static/ Verzeichnis...")
files_to_check = ['article-data.json', 'config.json']
found = []
missing = []
for filename in files_to_check:
path = os.path.join('static', filename)
if os.path.exists(path):
size = os.path.getsize(path)
found.append(f" ✓ {filename} ({size} bytes)")
else:
missing.append(f" ❌ {filename} (NICHT GEFUNDEN)")
for msg in found:
print(msg)
for msg in missing:
print(msg)
return len(missing) == 0
if __name__ == "__main__":
print("=" * 60)
print("Fix für article-data.json Flask-URLs")
print("=" * 60)
html_file = "templates/roll_calculator.html"
success = fix_html_file(html_file)
files_ok = check_static_files()
print("\n" + "=" * 60)
if success or files_ok:
print("✅ Fix abgeschlossen!")
print("\n📝 Nächste Schritte:")
print(" 1. Browser neuladen (Ctrl+Shift+R)")
print(" 2. Öffne Browser Console (F12)")
print(" 3. Du solltest sehen:")
print(" 'Article data loaded: XXX items'")
print(" 'Config updated from file'")
else:
print("⚠️ Keine Änderungen nötig")
print("=" * 60)
+1
View File
@@ -5,3 +5,4 @@ click==8.1.7
itsdangerous==2.1.2 itsdangerous==2.1.2
Jinja2==3.1.2 Jinja2==3.1.2
MarkupSafe==2.1.3 MarkupSafe==2.1.3
markdown-it-py==3.0.0
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SOURCE_MD="$ROOT_DIR/docs/de/user_manual.md"
SCREENSHOT_DIR="$ROOT_DIR/docs/de/screenshots"
OUTPUT_PDF="$ROOT_DIR/docs/de/user_manual.pdf"
if ! command -v pandoc >/dev/null 2>&1; then
echo "Error: Pandoc is not installed or not available in PATH." >&2
echo "Install Pandoc first, then run this script again." >&2
exit 1
fi
if [[ ! -f "$SOURCE_MD" ]]; then
echo "Error: source Markdown not found: $SOURCE_MD" >&2
exit 1
fi
PDF_ENGINE=""
for candidate in xelatex lualatex tectonic pdflatex wkhtmltopdf weasyprint; do
if command -v "$candidate" >/dev/null 2>&1; then
PDF_ENGINE="$candidate"
break
fi
done
if [[ -z "$PDF_ENGINE" ]]; then
echo "Error: no Pandoc-compatible PDF engine found." >&2
echo "Install xelatex, lualatex, tectonic, wkhtmltopdf, or weasyprint." >&2
exit 1
fi
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
TMP_MD="$TMP_DIR/user_manual.md"
export XDG_CACHE_HOME="$TMP_DIR/cache"
mkdir -p "$XDG_CACHE_HOME/fontconfig"
python3 - "$SOURCE_MD" "$TMP_MD" "$SCREENSHOT_DIR" <<'PY'
import pathlib
import re
import sys
source = pathlib.Path(sys.argv[1])
target = pathlib.Path(sys.argv[2])
screenshot_dir = pathlib.Path(sys.argv[3])
document_dir = screenshot_dir.parent
markdown = source.read_text(encoding="utf-8")
image_pattern = re.compile(r"!\[([^\]]*)\]\((screenshots/[^)]+)\)")
def replace_missing_image(match):
alt_text = match.group(1).strip()
image_ref = match.group(2)
image_path = document_dir / image_ref
if image_path.exists():
return match.group(0)
label = alt_text or pathlib.PurePosixPath(image_ref).name
return f"*Screenshot placeholder: {label}*"
target.write_text(image_pattern.sub(replace_missing_image, markdown), encoding="utf-8")
PY
pandoc "$TMP_MD" \
--from markdown+pipe_tables+grid_tables+multiline_tables+implicit_figures \
--resource-path="$ROOT_DIR/docs/de" \
--pdf-engine="$PDF_ENGINE" \
--toc \
--toc-depth=2 \
--number-sections \
--syntax-highlighting=tango \
--wrap=preserve \
-V papersize:a4 \
-V geometry:margin=20mm \
-V lang=de-DE \
-V colorlinks=true \
-o "$OUTPUT_PDF"
echo "User manual PDF generated successfully: $OUTPUT_PDF"
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""Interactive RollCalc user management."""
import json
import os
import stat
import sys
from getpass import getpass
from pathlib import Path
from werkzeug.security import generate_password_hash
ROOT_DIR = Path(__file__).resolve().parents[1]
USER_FILE = ROOT_DIR / "config" / "users.json"
HASH_METHOD = "pbkdf2:sha256:600000"
class UserManagementError(Exception):
"""Expected user-management error."""
def ensure_user_file(path=USER_FILE):
"""Create an empty user file if needed."""
path = Path(path)
if path.exists():
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{}\n", encoding="utf-8")
set_restrictive_permissions(path)
def set_restrictive_permissions(path):
"""Set user-only read/write permissions where supported."""
try:
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
except OSError as exc:
print(f"Warning: could not set restrictive permissions: {exc}")
def load_users(path=USER_FILE):
"""Load user hashes from JSON."""
path = Path(path)
ensure_user_file(path)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise UserManagementError(f"Invalid JSON in {path}: {exc}") from exc
except OSError as exc:
raise UserManagementError(f"Could not read {path}: {exc}") from exc
if not isinstance(data, dict):
raise UserManagementError(f"Invalid user file format in {path}: expected object")
users = {}
for username, password_hash in data.items():
if not isinstance(username, str) or not username.strip():
raise UserManagementError(f"Invalid username in {path}")
if not isinstance(password_hash, str) or not password_hash.strip():
raise UserManagementError(f"Invalid password hash for user {username!r}")
users[username.strip()] = password_hash.strip()
return users
def save_users(users, path=USER_FILE):
"""Persist user hashes to JSON."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
existing_mode = None
if path.exists():
try:
existing_mode = stat.S_IMODE(path.stat().st_mode)
except OSError:
existing_mode = None
temp_path = path.with_name(f".{path.name}.tmp")
try:
temp_path.write_text(
json.dumps(users, indent=2, sort_keys=True) + "\n",
encoding="utf-8"
)
if existing_mode is not None:
os.chmod(temp_path, existing_mode)
else:
os.chmod(temp_path, stat.S_IRUSR | stat.S_IWUSR)
os.replace(temp_path, path)
except OSError as exc:
try:
temp_path.unlink()
except OSError:
pass
raise UserManagementError(f"Could not write {path}: {exc}") from exc
if existing_mode is None:
set_restrictive_permissions(path)
def hash_password(password):
"""Hash a password with RollCalc's established hash method."""
return generate_password_hash(password, method=HASH_METHOD)
def add_user(users, username, password):
"""Add a user to a user dictionary."""
username = username.strip()
if not username:
raise UserManagementError("Username must not be empty.")
if username in users:
raise UserManagementError(f'User "{username}" already exists.')
users[username] = hash_password(password)
def update_password(users, username, password):
"""Update an existing user's password hash."""
username = username.strip()
if username not in users:
raise UserManagementError(f'User "{username}" does not exist.')
users[username] = hash_password(password)
def remove_user(users, username):
"""Remove an existing user."""
username = username.strip()
if username not in users:
raise UserManagementError(f'User "{username}" does not exist.')
del users[username]
def prompt_password_pair():
"""Read and validate a repeated password."""
password = getpass("Password: ")
repeat = getpass("Repeat password: ")
if password != repeat:
raise UserManagementError("Passwords do not match.")
if not password:
raise UserManagementError("Password must not be empty.")
return password
def list_users(users):
"""Print existing usernames without hashes."""
print("\nExisting users\n")
if not users:
print("(none)")
else:
for username in sorted(users):
print(f"- {username}")
print()
def create_user_interactive(users):
"""Create a user from prompts."""
username = input("Username: ").strip()
password = prompt_password_pair()
add_user(users, username, password)
print(f'User "{username}" created.')
def change_password_interactive(users):
"""Change a user's password from prompts."""
username = input("Username: ").strip()
password = prompt_password_pair()
update_password(users, username, password)
print(f'Password for "{username}" changed.')
def delete_user_interactive(users):
"""Delete a user after confirmation."""
username = input("Username: ").strip()
if username not in users:
raise UserManagementError(f'User "{username}" does not exist.')
confirmation = input(f'Delete user "{username}"? yes/no: ').strip().lower()
if confirmation != "yes":
print("Delete cancelled.")
return
remove_user(users, username)
print(f'User "{username}" deleted.')
def print_menu():
"""Print the main menu."""
print(
"\n"
"--------------------------------------------------\n\n"
"RollCalc User Management\n\n"
"1 - List users\n"
"2 - Create user\n"
"3 - Change password\n"
"4 - Delete user\n"
"5 - Exit\n\n"
"--------------------------------------------------"
)
def run_menu(path=USER_FILE):
"""Run the interactive menu."""
while True:
try:
users = load_users(path)
except UserManagementError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
print_menu()
choice = input("Select option: ").strip()
try:
if choice == "1":
list_users(users)
continue
if choice == "2":
create_user_interactive(users)
elif choice == "3":
change_password_interactive(users)
elif choice == "4":
delete_user_interactive(users)
elif choice == "5":
print("Exit.")
return 0
else:
print("Unknown option.")
continue
save_users(users, path)
except UserManagementError as exc:
print(f"Error: {exc}")
def main():
"""CLI entry point."""
return run_menu()
if __name__ == "__main__":
raise SystemExit(main())
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION="${VERSION:-v0.4-beta2}"
cd "$PROJECT_ROOT"
cat > build_info.json <<EOF
{
"version": "$VERSION",
"branch": "$(git branch --show-current)",
"commit": "$(git rev-parse --short HEAD)",
"timestamp": "$(date -Iseconds)"
}
EOF
+8
View File
@@ -0,0 +1,8 @@
{
"version": "v0.4-beta2",
"items": [
"English Quick Reference added to the Help area.",
"Recent Changes now identifies the current deployed version.",
"Browser cache handling improved for updated frontend data."
]
}
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=RollCalc Flask Application
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/html
ExecStart=/usr/bin/python3 /var/www/html/app.py
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
-568
View File
@@ -1,568 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0066cc">
<title>Admin Panel – Roll Calculator</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f4f6f9;
color: #333;
min-height: 100vh;
}
/* ---- HEADER ---- */
.admin-header {
background: linear-gradient(135deg, #003366 0%, #0066cc 100%);
color: white;
padding: 20px 32px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
display: flex;
justify-content: space-between;
align-items: center;
}
.admin-header h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: 0.5px;
}
.admin-header .subtitle {
font-size: 12px;
opacity: 0.85;
}
.admin-header .user-info {
font-size: 12px;
opacity: 0.9;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.admin-header .user-info strong {
opacity: 1;
font-weight: 600;
}
/* ---- CONTAINER ---- */
.admin-container {
max-width: 1300px;
margin: 30px auto;
padding: 0 20px 60px;
}
/* ---- STATS GRID ---- */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
border-left: 4px solid #0066cc;
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.stat-card.warning {
border-left-color: #f0b429;
}
.stat-card.danger {
border-left-color: #dc3545;
}
.stat-card .label {
font-size: 12px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.4px;
margin-bottom: 8px;
font-weight: 600;
}
.stat-card .value {
font-size: 36px;
font-weight: 700;
color: #0066cc;
line-height: 1;
}
.stat-card.warning .value {
color: #f0b429;
}
.stat-card.danger .value {
color: #dc3545;
}
.stat-card .sublabel {
font-size: 11px;
color: #999;
margin-top: 8px;
}
/* ---- LOGS SECTION ---- */
.logs-section {
background: white;
border-radius: 10px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
}
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.logs-header h2 {
font-size: 18px;
color: #003366;
font-weight: 700;
}
.controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
background: #0066cc;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
transition: background 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.btn:hover {
background: #0052a3;
}
.btn.secondary {
background: #e0e0e0;
color: #333;
}
.btn.secondary:hover {
background: #d0d0d0;
}
.btn-refresh {
animation: spin 1s linear infinite;
}
.btn-refresh.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ---- TABLE ---- */
.logs-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.logs-table thead {
background: #f0f4fa;
border-bottom: 2px solid #0066cc;
}
.logs-table th {
padding: 12px;
text-align: left;
font-weight: 600;
color: #333;
}
.logs-table td {
padding: 10px 12px;
border-bottom: 1px solid #e8ecf0;
}
.logs-table tbody tr {
transition: background 0.15s;
}
.logs-table tbody tr:hover {
background: #f8f9fa;
}
.status-200 {
color: #28a745;
font-weight: 600;
background: #f0f9f6;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
}
.status-401 {
color: #dc3545;
font-weight: 600;
background: #fff0f0;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
}
.status-403 {
color: #f0b429;
font-weight: 600;
background: #fffbea;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
}
/* ---- LOADING / EMPTY ---- */
.loading {
text-align: center;
padding: 40px;
color: #999;
}
.empty {
text-align: center;
padding: 40px;
color: #999;
font-style: italic;
}
.error {
background: #fff0f0;
color: #dc3545;
padding: 12px;
border-radius: 5px;
border-left: 4px solid #dc3545;
margin: 12px 0;
}
/* ---- FILTERS ---- */
.filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-input {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 5px;
font-size: 13px;
}
.filter-input:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 2px rgba(0,102,204,0.1);
}
/* ---- FOOTER ---- */
.admin-footer {
text-align: center;
font-size: 11px;
color: #aaa;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e8ecf0;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.admin-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.admin-header .user-info {
align-items: flex-start;
}
.stats-grid {
grid-template-columns: 1fr;
}
.logs-header {
flex-direction: column;
align-items: flex-start;
}
.controls {
width: 100%;
}
.btn {
flex: 1;
justify-content: center;
}
.logs-table {
font-size: 12px;
}
.logs-table th, .logs-table td {
padding: 8px;
}
}
</style>
</head>
<body>
<!-- HEADER -->
<div class="admin-header">
<div>
<h1>🔐 Admin Panel</h1>
<div class="subtitle">Roll Calculator – Naue GmbH & Co. KG</div>
</div>
<div class="user-info">
<span>Angemeldet als:</span>
<strong id="authUser">admin</strong>
</div>
</div>
<!-- MAIN CONTAINER -->
<div class="admin-container">
<!-- STATISTICS -->
<div class="stats-grid" id="statsGrid">
<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: #999;">
⏳ Statistiken werden geladen...
</div>
</div>
<!-- LOGS SECTION -->
<div class="logs-section">
<!-- HEADER -->
<div class="logs-header">
<h2>📋 Access Logs</h2>
<div class="controls">
<button class="btn" id="refreshBtn" onclick="refreshData()">
<span id="refreshIcon">🔄</span> Aktualisieren
</button>
<button class="btn secondary" onclick="downloadLogs()">
⬇️ Download
</button>
</div>
</div>
<!-- FILTERS -->
<div class="filters">
<input
type="text"
class="filter-input"
placeholder="Nach Benutzer filtern..."
id="filterUser"
onkeyup="filterLogs()"
>
<input
type="text"
class="filter-input"
placeholder="Nach Endpoint filtern..."
id="filterEndpoint"
onkeyup="filterLogs()"
>
</div>
<!-- TABLE -->
<div id="errorContainer"></div>
<table class="logs-table">
<thead>
<tr>
<th>Zeitstempel</th>
<th>Benutzer</th>
<th>Endpoint</th>
<th>Methode</th>
<th style="width: 80px;">Status</th>
</tr>
</thead>
<tbody id="logsList">
<tr>
<td colspan="5" class="loading">Logs werden geladen...</td>
</tr>
</tbody>
</table>
</div>
<!-- FOOTER -->
<div class="admin-footer">
ℹ️ Logs werden automatisch alle 30 Sekunden aktualisiert |
Letztes Update: <span id="lastUpdate">-</span>
</div>
</div>
<!-- SCRIPTS -->
<script>
let allLogs = [];
// Get authenticated user from API
async function getAuthUser() {
try {
const res = await fetch('/api/user');
const data = await res.json();
document.getElementById('authUser').textContent = data.username || 'admin';
} catch (e) {
console.warn('Konnte Benutzerinformation nicht laden');
}
}
// Refresh Data (Statistiken + Logs)
async function refreshData() {
const btn = document.getElementById('refreshBtn');
const icon = document.getElementById('refreshIcon');
try {
icon.style.animation = 'spin 1s linear infinite';
// Load Statistics
const statsRes = await fetch('/api/admin/stats');
if (!statsRes.ok) throw new Error('Statistiken konnten nicht geladen werden');
const stats = await statsRes.json();
// Load Logs
const logsRes = await fetch('/api/admin/logs');
if (!logsRes.ok) throw new Error('Logs konnten nicht geladen werden');
const logsData = await logsRes.json();
allLogs = logsData.logs || [];
// Render Statistics
renderStats(stats);
// Render Logs
renderLogs(allLogs);
// Update timestamp
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString('de-DE');
// Clear error
document.getElementById('errorContainer').innerHTML = '';
} catch (error) {
console.error('Fehler beim Laden der Daten:', error);
document.getElementById('errorContainer').innerHTML =
'<div class="error">❌ ' + error.message + '</div>';
} finally {
icon.style.animation = 'none';
}
}
// Render Statistics
function renderStats(stats) {
const html = `
<div class="stat-card">
<div class="label">📊 Gesamte Anfragen</div>
<div class="value">${stats.total_requests || 0}</div>
</div>
<div class="stat-card">
<div class="label">👥 Eindeutige Benutzer</div>
<div class="value">${stats.unique_users || 0}</div>
</div>
<div class="stat-card danger">
<div class="label">⛔ Fehlgeschlagene Logins</div>
<div class="value">${stats.failed_logins || 0}</div>
</div>
`;
document.getElementById('statsGrid').innerHTML = html;
}
// Render Logs
function renderLogs(logs) {
if (!logs || logs.length === 0) {
document.getElementById('logsList').innerHTML =
'<tr><td colspan="5" class="empty">Keine Logs verfügbar</td></tr>';
return;
}
const html = logs.reverse().map(log => {
const time = new Date(log.timestamp).toLocaleString('de-DE');
const statusClass = `status-${log.status}`;
return `
<tr>
<td>${time}</td>
<td><strong>${log.username}</strong></td>
<td><code>${log.endpoint}</code></td>
<td>${log.method}</td>
<td><span class="${statusClass}">${log.status}</span></td>
</tr>
`;
}).join('');
document.getElementById('logsList').innerHTML = html;
}
// Filter Logs
function filterLogs() {
const userFilter = document.getElementById('filterUser').value.toLowerCase();
const endpointFilter = document.getElementById('filterEndpoint').value.toLowerCase();
const filtered = allLogs.filter(log =>
log.username.toLowerCase().includes(userFilter) &&
log.endpoint.toLowerCase().includes(endpointFilter)
);
renderLogs(filtered);
}
// Download Logs as JSON
function downloadLogs() {
const data = JSON.stringify(allLogs, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `access-logs-${new Date().toISOString()}.json`;
a.click();
window.URL.revokeObjectURL(url);
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
getAuthUser();
refreshData();
// Auto-refresh every 30 seconds
setInterval(refreshData, 30000);
});
</script>
</body>
</html>
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="RollCalc Help">
<title>RollCalc Help</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #2f3437; min-height: 100vh; }
.help-header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.help-header-inner { max-width: 980px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.help-header h1 { font-size: 24px; margin-bottom: 5px; }
.help-header p { font-size: 13px; opacity: 0.9; }
.help-nav { display: flex; gap: 10px; flex-wrap: wrap; }
.help-nav a { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
.help-nav a:hover { background: rgba(255,255,255,0.18); }
.help-container { max-width: 980px; margin: 30px auto; padding: 0 16px 60px; }
.help-intro { margin-bottom: 22px; line-height: 1.55; color: #4b555a; }
.help-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 18px; }
.help-card { background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
.help-card h2 { color: #1F5438; font-size: 18px; margin-bottom: 10px; }
.help-card p { color: #4f5b60; font-size: 14px; line-height: 1.55; margin-bottom: 18px; }
.help-card a { display: inline-block; background: #2BAC70; color: #fff; text-decoration: none; border-radius: 6px; padding: 10px 16px; font-size: 14px; font-weight: 700; }
.help-card a:hover { background: #1F5438; }
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; }
.footer-build-info { margin-top: 6px; color: #bbb; }
@media (max-width: 640px) {
.help-header { padding: 18px 16px; }
.help-header-inner { align-items: flex-start; flex-direction: column; }
}
</style>
</head>
<body>
<header class="help-header">
<div class="help-header-inner">
<div>
<h1>RollCalc Help</h1>
<p>Documentation for roll calculation and transport capacity analysis</p>
</div>
<nav class="help-nav" aria-label="Help navigation">
<a href="{{ url_for('index') }}">Calculator</a>
<a href="{{ url_for('user_manual') }}">User Manual</a>
<a href="{{ url_for('quick_reference') }}">Quick Reference</a>
<a href="{{ url_for('recent_changes') }}">Recent Changes</a>
</nav>
</div>
</header>
<main class="help-container">
<p class="help-intro">
This area provides internal technical documentation for RollCalc. The documents are rendered from the maintained Markdown sources.
</p>
<section class="help-grid" aria-label="Available help documents">
<article class="help-card">
<h2>User Manual</h2>
<p>
Workflow-oriented manual for roll calculation, handling evaluation, Load Optimizer usage, and interpretation of Capacity Summary and Capacity Analysis.
</p>
<a href="{{ url_for('user_manual') }}">Open User Manual</a>
</article>
<article class="help-card">
<h2>Quick Reference</h2>
<p>
Concise English reference for the most common RollCalc workflows and result interpretations.
</p>
<a href="{{ url_for('quick_reference') }}">Open Quick Reference</a>
</article>
<article class="help-card">
<h2>Recent Changes</h2>
<p>
Compact release notes for relevant user-facing changes in RollCalc.
</p>
<a href="{{ url_for('recent_changes') }}">Open Recent Changes</a>
</article>
</section>
</main>
<footer class="footer">
RollCalc Beta &copy; Naue
<div class="footer-build-info">
Version {{ build_info.version }} |
Branch {{ build_info.branch }} |
Commit {{ build_info.commit }} |
Built {{ build_info.timestamp }}
</div>
</footer>
</body>
</html>
+484 -26
View File
@@ -9,8 +9,11 @@
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #333; min-height: 100vh; } body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #333; min-height: 100vh; }
.header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); } .header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.header-inner { max-width: 900px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.header h1 { font-size: 24px; margin-bottom: 5px; } .header h1 { font-size: 24px; margin-bottom: 5px; }
.header p { font-size: 13px; opacity: 0.9; } .header p { font-size: 13px; opacity: 0.9; }
.header-help-link { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
.header-help-link:hover { background: rgba(255,255,255,0.18); }
.container { max-width: 900px; margin: 30px auto; padding: 0 16px 60px; } .container { max-width: 900px; margin: 30px auto; padding: 0 16px 60px; }
.card { background: #fff; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.07); margin-bottom: 24px; overflow: hidden; } .card { background: #fff; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.07); margin-bottom: 24px; overflow: hidden; }
.card-header { background: #f0f4fa; padding: 16px 22px; border-bottom: 1px solid #e0e8f0; } .card-header { background: #f0f4fa; padding: 16px 22px; border-bottom: 1px solid #e0e8f0; }
@@ -48,6 +51,9 @@
.result-label { font-size: 12px; color: #1F5438; font-weight: 600; text-transform: uppercase; } .result-label { font-size: 12px; color: #1F5438; font-weight: 600; text-transform: uppercase; }
.result-value { font-size: 28px; font-weight: 700; color: #1F5438; line-height: 1.2; } .result-value { font-size: 28px; font-weight: 700; color: #1F5438; line-height: 1.2; }
.result-range { font-size: 12px; color: #1F5438; margin-top: 4px; font-weight: 500; } .result-range { font-size: 12px; color: #1F5438; margin-top: 4px; font-weight: 500; }
.result-range div { margin-top: 2px; }
.result-range .range-warning { color: #9a6700; font-weight: 700; }
.result-range .range-ok { color: #1F5438; font-weight: 700; }
.forklift-check { border-radius: 7px; padding: 14px 16px; margin-top: 14px; font-size: 13px; line-height: 1.6; display: none; } .forklift-check { border-radius: 7px; padding: 14px 16px; margin-top: 14px; font-size: 13px; line-height: 1.6; display: none; }
.forklift-check.show { display: block; } .forklift-check.show { display: block; }
.forklift-check.fc-ok { background: #eafaf1; border: 1.5px solid #28a745; } .forklift-check.fc-ok { background: #eafaf1; border: 1.5px solid #28a745; }
@@ -60,6 +66,139 @@
.fc-req-nok { color: #dc3545; font-weight: 600; } .fc-req-nok { color: #dc3545; font-weight: 600; }
.formula-box { background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 6px; padding: 10px 14px; font-size: 12px; color: #666; margin-top: 18px; font-family: 'Courier New', monospace; } .formula-box { background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 6px; padding: 10px 14px; font-size: 12px; color: #666; margin-top: 18px; font-family: 'Courier New', monospace; }
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; margin-top: 30px; } .footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; margin-top: 30px; }
.footer-build-info { margin-top: 6px; color: #bbb; }
.disclaimer-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(20, 28, 34, 0.72);
}
.disclaimer-overlay.is-hidden { display: none; }
.disclaimer-dialog {
width: min(760px, 100%);
max-height: min(86vh, 820px);
display: flex;
flex-direction: column;
background: #fff;
border-radius: 8px;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.28);
overflow: hidden;
}
.disclaimer-header {
padding: 18px 22px;
background: #1F5438;
color: #fff;
}
.disclaimer-header h2 { font-size: 18px; margin: 0; }
.disclaimer-content {
padding: 20px 22px;
overflow-y: auto;
line-height: 1.5;
font-size: 14px;
}
.disclaimer-content h3 {
margin: 0 0 8px;
color: #1F5438;
font-size: 15px;
}
.disclaimer-content p { margin: 0 0 12px; }
.disclaimer-language + .disclaimer-language {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid #e0e8f0;
}
.disclaimer-actions {
padding: 16px 22px;
border-top: 1px solid #e0e8f0;
background: #f8fafa;
}
.disclaimer-confirm {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 14px;
font-size: 13px;
color: #333;
}
.disclaimer-confirm input {
width: 18px;
height: 18px;
margin-top: 1px;
flex: 0 0 auto;
}
.disclaimer-btn {
width: 100%;
border: none;
border-radius: 6px;
padding: 11px 18px;
background: #2BAC70;
color: #fff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.disclaimer-btn:hover { background: #1F5438; }
.disclaimer-btn:disabled {
background: #b8c7bf;
cursor: not-allowed;
}
.whats-new-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(20, 28, 34, 0.55);
}
.whats-new-overlay.is-hidden { display: none; }
.whats-new-dialog {
width: min(520px, 100%);
background: #fff;
border-radius: 8px;
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.24);
overflow: hidden;
}
.whats-new-header {
padding: 16px 20px;
background: #1F5438;
color: #fff;
}
.whats-new-header h2 { font-size: 18px; margin: 0 0 4px; }
.whats-new-version { font-size: 12px; opacity: 0.88; }
.whats-new-content {
padding: 18px 20px 8px;
font-size: 14px;
line-height: 1.5;
color: #333;
}
.whats-new-content ul { margin: 8px 0 0 20px; }
.whats-new-content li { margin-bottom: 5px; }
.whats-new-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px 18px;
border-top: 1px solid #e0e8f0;
background: #f8fafa;
}
.whats-new-btn {
border: none;
border-radius: 6px;
padding: 9px 14px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.whats-new-btn-primary { background: #2BAC70; color: #fff; }
.whats-new-btn-primary:hover { background: #1F5438; }
.whats-new-btn-secondary { background: #fff; color: #1F5438; border: 1px solid #a8c8ba; }
.whats-new-btn-secondary:hover { background: #eef4f1; }
/* ============================================ /* ============================================
LOAD OPTIMIZER STYLES LOAD OPTIMIZER STYLES
@@ -206,6 +345,23 @@
border-left: 4px solid #2BAC70; border-left: 4px solid #2BAC70;
} }
.lo-results-summary h4 { margin-top: 0; color: #333; font-size: 14px; margin-bottom: 12px; } .lo-results-summary h4 { margin-top: 0; color: #333; font-size: 14px; margin-bottom: 12px; }
.lo-summary-section {
padding-top: 12px;
margin-top: 12px;
border-top: 1px solid #e6ece9;
}
.lo-summary-section:first-of-type {
padding-top: 0;
margin-top: 0;
border-top: 0;
}
.lo-summary-section-title {
margin-bottom: 8px;
color: #1F5438;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.lo-summary-item { .lo-summary-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -214,9 +370,23 @@
font-size: 13px; font-size: 13px;
} }
.lo-summary-item:last-child { margin-bottom: 0; } .lo-summary-item:last-child { margin-bottom: 0; }
.lo-summary-item.lo-governing-summary {
background: #eef8f2;
border-left: 3px solid #2BAC70;
padding: 8px 10px;
border-radius: 4px;
}
.lo-value-highlight { color: #2BAC70; font-weight: bold; font-size: 16px; } .lo-value-highlight { color: #2BAC70; font-weight: bold; font-size: 16px; }
.lo-value-number { color: #1F5438; font-weight: bold; } .lo-value-number { color: #1F5438; font-weight: bold; }
.lo-util-group { display: flex; flex-direction: column; gap: 4px; text-align: right; } .lo-util-group { display: flex; flex-direction: column; gap: 4px; text-align: right; }
.lo-reserve-note {
margin-top: 4px;
color: #666;
font-size: 12px;
line-height: 1.35;
text-align: right;
max-width: 300px;
}
.lo-results-table { .lo-results-table {
background: white; background: white;
padding: 16px; padding: 16px;
@@ -232,10 +402,15 @@
font-weight: 600; font-weight: 600;
color: #333; color: #333;
border-bottom: 2px solid #2BAC70; border-bottom: 2px solid #2BAC70;
white-space: normal;
} }
.lo-results-table td { padding: 8px; border-bottom: 1px solid #e0e0e0; } .lo-results-table td { padding: 8px; border-bottom: 1px solid #e0e0e0; }
.lo-results-table tbody tr:hover { background: #f9f9f9; } .lo-results-table tbody tr:hover { background: #f9f9f9; }
.lo-results-table tbody tr.lo-recommended { background: #fffacd; font-weight: bold; } .lo-results-table tbody tr.lo-governing {
background: #eef8f2;
font-weight: 600;
border-left: 3px solid #2BAC70;
}
.lo-warning { .lo-warning {
background: #fff8e1; background: #fff8e1;
border: 1px solid #f0b429; border: 1px solid #f0b429;
@@ -313,6 +488,11 @@
} }
.lo-sideview-section.show { display: block; } .lo-sideview-section.show { display: block; }
.lo-sideview-section h4 { margin: 0 0 12px 0; font-size: 14px; color: #333; } .lo-sideview-section h4 { margin: 0 0 12px 0; font-size: 14px; color: #333; }
.lo-sideview-context {
margin: -6px 0 12px;
color: #666;
font-size: 12px;
}
.lo-sideview-wrap { .lo-sideview-wrap {
background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 4px; overflow: hidden; background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 4px; overflow: hidden;
} }
@@ -324,6 +504,10 @@
.margin-color { background: repeating-linear-gradient(45deg,#fff7e0,#fff7e0 3px,#ffe8a0 3px,#ffe8a0 6px); border-color:#f0b429; } .margin-color { background: repeating-linear-gradient(45deg,#fff7e0,#fff7e0 3px,#ffe8a0 3px,#ffe8a0 6px); border-color:#f0b429; }
@media (max-width: 768px) { @media (max-width: 768px) {
.header { padding: 18px 16px; }
.header-inner { align-items: flex-start; }
.whats-new-actions { flex-direction: column; }
.whats-new-btn { width: 100%; }
.lo-accordion-header h3 { font-size: 14px; } .lo-accordion-header h3 { font-size: 14px; }
.lo-preset-buttons { flex-direction: column; } .lo-preset-buttons { flex-direction: column; }
.lo-preset-btn { width: 100%; } .lo-preset-btn { width: 100%; }
@@ -336,9 +520,61 @@
</head> </head>
<body> <body>
<div class="disclaimer-overlay" id="disclaimerOverlay" role="dialog" aria-modal="true" aria-labelledby="disclaimerTitle">
<div class="disclaimer-dialog">
<div class="disclaimer-header">
<h2 id="disclaimerTitle">Disclaimer</h2>
</div>
<div class="disclaimer-content">
<section class="disclaimer-language" aria-label="Deutscher Disclaimer">
<h3>Deutsch</h3>
<p><strong>⚠️ Disclaimer</strong></p>
<p>Dieses Tool ist ausschließlich zur persönlichen, internen Verwendung bestimmt. Die Weitergabe von Logins ist nicht gestattet.</p>
<p>Berechtigt sind nur: Produktmanagement, Produktionsleitung, R&amp;D, Controlling und Logistik.</p>
<p>Wichtig: Alle Eingaben und Ausgaben müssen vor der Verwendung auf Plausibilität geprüft werden. Die Datenbasis für Materialdicke und Flächengewicht wird automatisch erstellt und ist weder geprüft noch erhebt sie Anspruch auf Vollständigkeit.</p>
</section>
<section class="disclaimer-language" aria-label="English disclaimer">
<h3>English</h3>
<p><strong>⚠️ Disclaimer</strong></p>
<p>This tool is exclusively for personal, internal use. Sharing login credentials is prohibited.</p>
<p>Authorized departments only: Product Management, Production Management, R&amp;D, Controlling, and Logistics.</p>
<p>Important: All inputs and outputs must be verified for plausibility before use. The material thickness and area weight data is automatically generated and has not been reviewed. It does not claim to be complete or comprehensive.</p>
</section>
</div>
<div class="disclaimer-actions">
<label class="disclaimer-confirm" for="disclaimerAccepted">
<input type="checkbox" id="disclaimerAccepted">
<span>I have read and understood the disclaimer / Ich habe den Disclaimer gelesen und verstanden.</span>
</label>
<button class="disclaimer-btn" id="disclaimerConfirmBtn" type="button" disabled>Confirm / Bestätigen</button>
</div>
</div>
</div>
<div class="whats-new-overlay is-hidden" id="whatsNewOverlay" role="dialog" aria-modal="true" aria-labelledby="whatsNewTitle">
<div class="whats-new-dialog">
<div class="whats-new-header">
<h2 id="whatsNewTitle">What’s New</h2>
<div class="whats-new-version">Version: <span id="whatsNewVersion">–</span></div>
</div>
<div class="whats-new-content">
<ul id="whatsNewList"></ul>
</div>
<div class="whats-new-actions">
<button class="whats-new-btn whats-new-btn-secondary" id="whatsNewOpenChangesBtn" type="button">Open Recent Changes</button>
<button class="whats-new-btn whats-new-btn-primary" id="whatsNewContinueBtn" type="button">Continue</button>
</div>
</div>
</div>
<div class="header"> <div class="header">
<div class="header-inner">
<div>
<h1>Naue Roll Diameter Calculator</h1> <h1>Naue Roll Diameter Calculator</h1>
<p>Roll Diameter • Product Length • Roll Weight • Load Optimizer</p> <p>Roll Diameter • Product Length • Roll Weight • Load Optimizer</p>
</div>
<a class="header-help-link" href="{{ url_for('user_manual') }}">User Manual</a>
</div>
</div> </div>
<div class="container"> <div class="container">
@@ -461,6 +697,27 @@
<span class="unit-label">kg</span> <span class="unit-label">kg</span>
</div> </div>
</div> </div>
<div class="form-group">
<label>Max. Roll Diameter</label>
<div class="input-with-unit">
<input type="number" id="d-target-di" placeholder="mm" min="0" step="1">
<span class="unit-label">mm</span>
</div>
</div>
<div class="form-group">
<label>Max. Roll Weight</label>
<div class="input-with-unit">
<input type="number" id="d-max-weight" placeholder="kg" min="0" step="1">
<span class="unit-label">kg</span>
</div>
</div>
<div class="form-group">
<label>Minimum Product Length</label>
<div class="input-with-unit">
<input type="number" id="d-min-le" placeholder="m" min="0" step="0.1">
<span class="unit-label">m</span>
</div>
</div>
</div> </div>
</div> </div>
@@ -672,11 +929,17 @@
<div class="lo-results" id="resultsContainer"> <div class="lo-results" id="resultsContainer">
<div class="lo-results-summary"> <div class="lo-results-summary">
<h4>Results:</h4> <h4>Capacity Summary:</h4>
<div class="lo-summary-item"> <div class="lo-summary-section">
<span><strong>Limiting Factor:</strong></span> <div class="lo-summary-section-title">Governing Constraint</div>
<div class="lo-summary-item lo-governing-summary">
<span><strong>Constraint:</strong></span>
<span id="limitingFactorValue" class="lo-value-highlight">–</span> <span id="limitingFactorValue" class="lo-value-highlight">–</span>
</div> </div>
</div>
<div class="lo-summary-section">
<div class="lo-summary-section-title">Transport Result</div>
<div class="lo-summary-item"> <div class="lo-summary-item">
<span><strong>Total Rolls:</strong></span> <span><strong>Total Rolls:</strong></span>
<div style="text-align:right;"> <div style="text-align:right;">
@@ -687,23 +950,37 @@
<div class="lo-summary-item"> <div class="lo-summary-item">
<span><strong>Utilization:</strong></span> <span><strong>Utilization:</strong></span>
<div class="lo-util-group"> <div class="lo-util-group">
<div>Weight: <span id="weightUtilValue" class="lo-value-number">–</span> %</div> <div>Weight Utilization: <span id="weightUtilValue" class="lo-value-number">–</span> %</div>
<div>Volume: <span id="volumeUtilValue" class="lo-value-number">–</span> %</div> <div>Volume Utilization: <span id="volumeUtilValue" class="lo-value-number">–</span> %</div>
<div>Product: <span id="productM2Value" class="lo-value-number">–</span> m²</div> <div>Transported Product Area: <span id="productM2Value" class="lo-value-number">–</span> m²</div>
</div>
</div>
</div>
<div class="lo-summary-section">
<div class="lo-summary-section-title">Remaining Capacity</div>
<div class="lo-summary-item">
<span><strong>Remaining Capacity:</strong></span>
<div class="lo-util-group">
<div>Remaining Payload: <span id="remainingPayloadValue" class="lo-value-number">–</span> kg</div>
<div>Unused Payload: <span id="unusedPayloadValue" class="lo-value-number">–</span> %</div>
<div>Unused Volume: <span id="unusedVolumeValue" class="lo-value-number">–</span> %</div>
<div id="capacityInterpretationValue" class="lo-reserve-note"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="lo-results-table"> <div class="lo-results-table">
<h4>Comparison Table:</h4> <h4>Capacity Analysis:</h4>
<table id="comparisonTable"> <table id="comparisonTable">
<thead> <thead>
<tr> <tr>
<th>Scenario</th> <th>Constraint</th>
<th>Rolls</th> <th>Max Rolls</th>
<th>kg</th> <th>Total Weight</th>
<th>m²</th> <th>Transported Area [m²]</th>
<th>Util. %</th> <th>Payload Util. %</th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
@@ -712,6 +989,7 @@
<div class="lo-sideview-section" id="sideViewSection"> <div class="lo-sideview-section" id="sideViewSection">
<h4>🔍 Front View – Cross-Section</h4> <h4>🔍 Front View – Cross-Section</h4>
<p class="lo-sideview-context">Cross-section of the actual loading configuration.</p>
<div class="lo-sideview-wrap"> <div class="lo-sideview-wrap">
<svg id="sideViewSVG" width="100%" viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg"> <svg id="sideViewSVG" width="100%" viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg">
<text x="400" y="130" text-anchor="middle" fill="#aaa" font-size="14">Run a calculation to see the side view.</text> <text x="400" y="130" text-anchor="middle" fill="#aaa" font-size="14">Run a calculation to see the side view.</text>
@@ -734,6 +1012,9 @@
<div class="footer"> <div class="footer">
Naue GmbH & Co. KG · Roll Diameter Calculator · Internal Application Naue GmbH & Co. KG · Roll Diameter Calculator · Internal Application
<div class="footer-build-info">
Version {{ build_info.version }} · Branch {{ build_info.branch }} · Commit {{ build_info.commit }} · Built {{ build_info.timestamp }}
</div>
</div> </div>
<script> <script>
@@ -742,6 +1023,8 @@
// ===================================================== // =====================================================
window.ARTICLE_DATA = []; window.ARTICLE_DATA = [];
window.APP_CONFIG = {"forklift_rules":{"applicable_categories":["bentofix"],"rules":[{"weight_threshold_kg":1700,"max_weight_kg":2750,"requirements":{"min_core_outer_diameter_mm":170},"messages":{"en":{"warning_title":"Heavy Roll – Special Equipment Required","core_ok":"Core diameter OK (≥ 170 mm)","core_nok":"Core diameter too small"}}}],"hard_limit":{"max_weight_kg":2750,"messages":{"en":{"block_title":"Weight Limit Exceeded","block_text":"Roll weight exceeds 2750 kg maximum"}}}}}; window.APP_CONFIG = {"forklift_rules":{"applicable_categories":["bentofix"],"rules":[{"weight_threshold_kg":1700,"max_weight_kg":2750,"requirements":{"min_core_outer_diameter_mm":170},"messages":{"en":{"warning_title":"Heavy Roll – Special Equipment Required","core_ok":"Core diameter OK (≥ 170 mm)","core_nok":"Core diameter too small"}}}],"hard_limit":{"max_weight_kg":2750,"messages":{"en":{"block_title":"Weight Limit Exceeded","block_text":"Roll weight exceeds 2750 kg maximum"}}}}};
const ROLLCALC_ARTICLE_DATA_URL = {{ static_asset_url('article-data.json')|tojson }};
const ROLLCALC_RECENT_CHANGES_JSON_URL = {{ static_asset_url('recent_changes.json')|tojson }};
// ===================================================== // =====================================================
// LOAD ARTICLE DATA // LOAD ARTICLE DATA
@@ -806,7 +1089,7 @@ function populateArticleDropdowns() {
(async function() { (async function() {
try { try {
const resp = await fetch('/static/article-data.json'); const resp = await fetch(ROLLCALC_ARTICLE_DATA_URL);
if (resp.ok) { if (resp.ok) {
window.ARTICLE_DATA = await resp.json(); window.ARTICLE_DATA = await resp.json();
console.log('✅ Article data loaded:', window.ARTICLE_DATA.length, 'items'); console.log('✅ Article data loaded:', window.ARTICLE_DATA.length, 'items');
@@ -879,21 +1162,69 @@ document.getElementById('d-calc').addEventListener('click', () => {
resRange.textContent = ''; resRange.textContent = '';
document.getElementById('d-le').value = L_calc.toFixed(2); document.getElementById('d-le').value = L_calc.toFixed(2);
} else if (mode === 'target') { } else if (mode === 'target') {
if (!d || !t) { alert('Please enter d and t.'); return; } const wi = parseFloat(document.getElementById('d-wi').value) || 0;
const D_target = D || 800; const aw = parseFloat(document.getElementById('d-aw').value) || 0;
const L_calc = (Math.PI / (4 * t)) * (D_target*D_target - d*d) / 1000; const maxWeight = parseFloat(document.getElementById('d-max-weight').value) || 0;
resLabel.textContent = 'Length for Target Diameter ' + D_target + ' mm'; const minLength = parseFloat(document.getElementById('d-min-le').value) || 0;
resVal.textContent = L_calc.toFixed(2) + ' m'; const D_target = parseFloat(document.getElementById('d-target-di').value) || 0;
resRange.textContent = '';
document.getElementById('d-le').value = L_calc.toFixed(2); if (!d || !t || !D_target || !wi || !aw || !maxWeight) {
alert('Please enter d, t, max. roll diameter, roll width, area weight, and max. roll weight.');
return;
}
if (D_target <= d) {
alert('Max. roll diameter must be larger than core diameter.');
return;
}
const L_diameter = (Math.PI / (4 * t)) * (D_target*D_target - d*d) / 1000;
const L_weight = maxWeight * 1000 / (aw * wi);
const L_allowed = Math.min(L_diameter, L_weight);
const D_result = Math.sqrt(d*d + (4*L_allowed*1000*t)/Math.PI);
const W_result = aw * L_allowed * wi / 1000;
const diameterUtil = D_result / D_target * 100;
const weightUtil = W_result / maxWeight * 100;
const lengthDiff = minLength > 0 ? L_allowed - minLength : null;
const epsilon = 0.0001;
let limitingFactor = 'Diameter + Weight';
if (L_diameter < L_weight - epsilon) {
limitingFactor = 'Diameter';
} else if (L_weight < L_diameter - epsilon) {
limitingFactor = 'Weight';
}
resLabel.textContent = 'Maximum Allowed Product Length';
resVal.textContent = L_allowed.toFixed(2) + ' m';
const minLengthText = minLength > 0 ? minLength.toFixed(2) + ' m' : 'not set';
const diffText = lengthDiff !== null ? lengthDiff.toFixed(2) + ' m' : 'n/a';
let minStatus = '<div>Minimum length not set</div>';
if (minLength > 0 && L_allowed < minLength) {
minStatus = '<div class="range-warning">Warning: maximum allowed length is below the minimum product length.</div>';
} else if (minLength > 0) {
minStatus = '<div class="range-ok">Minimum length OK</div>';
}
resRange.innerHTML =
'<div>Resulting Roll Diameter: ' + D_result.toFixed(1) + ' mm</div>' +
'<div>Resulting weight: ' + W_result.toFixed(1) + ' kg</div>' +
'<div>Minimum length: ' + minLengthText + '</div>' +
'<div>Difference: ' + diffText + '</div>' +
'<div>Limiting factor: ' + limitingFactor + '</div>' +
'<div>Utilization: Diameter ' + diameterUtil.toFixed(1) + '% | Weight ' + weightUtil.toFixed(1) + '%</div>' +
minStatus;
document.getElementById('d-le').value = L_allowed.toFixed(2);
document.getElementById('d-wo').value = W_result.toFixed(1);
} }
const wi = parseFloat(document.getElementById('d-wi').value) || 0; const wi = parseFloat(document.getElementById('d-wi').value) || 0;
const aw = parseFloat(document.getElementById('d-aw').value) || 0; const aw = parseFloat(document.getElementById('d-aw').value) || 0;
const L_final = parseFloat(document.getElementById('d-le').value) || 0; const L_final = parseFloat(document.getElementById('d-le').value) || 0;
if (wi > 0 && aw > 0 && L_final > 0) { if (wi > 0 && aw > 0 && L_final > 0) {
const weight = (aw * L_final * wi / 1000).toFixed(1); const weight = aw * L_final * wi / 1000;
document.getElementById('d-wo').value = weight; document.getElementById('d-wo').value = weight.toFixed(1);
checkForklift(weight, d, category); checkForklift(weight, d, category);
} else { } else {
document.getElementById('forkliftCheck').classList.remove('show'); document.getElementById('forkliftCheck').classList.remove('show');
@@ -903,7 +1234,7 @@ document.getElementById('d-calc').addEventListener('click', () => {
}); });
document.getElementById('d-reset').addEventListener('click', () => { document.getElementById('d-reset').addEventListener('click', () => {
['d-co', 'd-th', 'd-tol', 'd-le', 'd-di', 'd-wi', 'd-aw', 'd-wo'].forEach(id => { ['d-co', 'd-th', 'd-tol', 'd-le', 'd-di', 'd-wi', 'd-aw', 'd-wo', 'd-target-di', 'd-max-weight', 'd-min-le'].forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.value = id === 'd-co' ? '150' : ''; if (el) el.value = id === 'd-co' ? '150' : '';
}); });
@@ -1164,6 +1495,14 @@ class LoadOptimizerUI {
showResults(res, maxW) { showResults(res, maxW) {
const m2PerRoll = this.optimizer.state.m2PerRoll || 0; const m2PerRoll = this.optimizer.state.m2PerRoll || 0;
const remainingPayload = Math.max(0, maxW - res.finalWeight);
const unusedPayload = Math.max(0, 100 - res.utilizationWeight);
const unusedVolume = Math.max(0, 100 - res.utilizationVolume);
const interpretations = {
geometry: 'Geometry prevents loading additional rolls although payload capacity remains available.',
weight: 'Payload limit is reached before the available geometry or volume is fully used.',
volume: 'Volume capacity is reached before payload or geometric capacity is fully used.'
};
document.getElementById('limitingFactorValue').textContent = res.limiting.name; document.getElementById('limitingFactorValue').textContent = res.limiting.name;
document.getElementById('totalRollsValue').textContent = res.finalRolls; document.getElementById('totalRollsValue').textContent = res.finalRolls;
document.getElementById('rollsDetailValue').textContent = document.getElementById('rollsDetailValue').textContent =
@@ -1171,16 +1510,21 @@ class LoadOptimizerUI {
document.getElementById('weightUtilValue').textContent = this.optimizer.fmt(res.utilizationWeight); document.getElementById('weightUtilValue').textContent = this.optimizer.fmt(res.utilizationWeight);
document.getElementById('volumeUtilValue').textContent = this.optimizer.fmt(res.utilizationVolume); document.getElementById('volumeUtilValue').textContent = this.optimizer.fmt(res.utilizationVolume);
document.getElementById('productM2Value').textContent = this.optimizer.fmt(res.finalM2, 0); document.getElementById('productM2Value').textContent = this.optimizer.fmt(res.finalM2, 0);
document.getElementById('remainingPayloadValue').textContent = this.optimizer.fmt(remainingPayload, 0);
document.getElementById('unusedPayloadValue').textContent = this.optimizer.fmt(unusedPayload);
document.getElementById('unusedVolumeValue').textContent = this.optimizer.fmt(unusedVolume);
document.getElementById('capacityInterpretationValue').textContent =
interpretations[res.limiting.type] || '';
const tbody = document.querySelector('#comparisonTable tbody'); const tbody = document.querySelector('#comparisonTable tbody');
tbody.innerHTML = ''; tbody.innerHTML = '';
res.scenarios.forEach(sc => { res.scenarios.forEach(sc => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
const lim = (sc.type === res.limiting.type); const lim = (sc.type === res.limiting.type);
if (lim) tr.classList.add('lo-recommended'); if (lim) tr.classList.add('lo-governing');
const util = maxW > 0 ? (sc.weight / maxW * 100).toFixed(1) : '–'; const util = maxW > 0 ? (sc.weight / maxW * 100).toFixed(1) : '–';
const scM2 = isFinite(sc.rolls) && m2PerRoll > 0 ? Math.round(sc.rolls * m2PerRoll) : '–'; const scM2 = isFinite(sc.rolls) && m2PerRoll > 0 ? Math.round(sc.rolls * m2PerRoll) : '–';
tr.innerHTML = tr.innerHTML =
'<td>'+ sc.name +(lim?' ★':'')+'</td>'+ '<td>'+ sc.name +(lim?' &rarr; Governing Constraint':'')+'</td>'+
'<td>'+ (sc.rolls === Infinity ? '–' : sc.rolls) +'</td>'+ '<td>'+ (sc.rolls === Infinity ? '–' : sc.rolls) +'</td>'+
'<td>'+ (isFinite(sc.weight) ? Math.round(sc.weight) : '–') +'</td>'+ '<td>'+ (isFinite(sc.weight) ? Math.round(sc.weight) : '–') +'</td>'+
'<td>'+ scM2 +'</td>'+ '<td>'+ scM2 +'</td>'+
@@ -1301,6 +1645,120 @@ class LoadOptimizerUI {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
window.loadOptimizerUI = new LoadOptimizerUI(); window.loadOptimizerUI = new LoadOptimizerUI();
}); });
const ROLLCALC_BUILD_VERSION = {{ build_info.version|tojson }};
const ROLLCALC_LAST_SEEN_VERSION_KEY = 'rollcalc_last_seen_version';
function shouldShowWhatsNew(currentVersion, storedVersion) {
return !!currentVersion &&
currentVersion !== 'unknown' &&
currentVersion !== storedVersion;
}
function markWhatsNewSeen(currentVersion) {
if (!currentVersion || currentVersion === 'unknown') return;
try {
localStorage.setItem(ROLLCALC_LAST_SEEN_VERSION_KEY, currentVersion);
} catch (e) {
console.warn('Could not store RollCalc release note state', e);
}
}
function renderWhatsNewItems(items) {
const list = document.getElementById('whatsNewList');
if (!list) return;
list.innerHTML = '';
items.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
list.appendChild(li);
});
}
async function loadWhatsNewData(currentVersion) {
try {
const resp = await fetch(ROLLCALC_RECENT_CHANGES_JSON_URL);
if (!resp.ok) {
console.warn('Could not load RollCalc release notes summary:', resp.status);
return null;
}
const data = await resp.json();
if (!data || typeof data.version !== 'string' || !Array.isArray(data.items)) {
console.warn('Invalid RollCalc release notes summary format');
return null;
}
if (data.version !== currentVersion) {
return null;
}
const items = data.items.filter(item =>
typeof item === 'string' && item.trim()
);
return items.length ? { version: data.version, items } : null;
} catch (e) {
console.warn('Could not load RollCalc release notes summary', e);
return null;
}
}
async function maybeShowWhatsNew() {
const overlay = document.getElementById('whatsNewOverlay');
const versionValue = document.getElementById('whatsNewVersion');
const continueBtn = document.getElementById('whatsNewContinueBtn');
const openChangesBtn = document.getElementById('whatsNewOpenChangesBtn');
if (!overlay || !versionValue || !continueBtn || !openChangesBtn) return;
let storedVersion = null;
try {
storedVersion = localStorage.getItem(ROLLCALC_LAST_SEEN_VERSION_KEY);
} catch (e) {
storedVersion = null;
}
if (!shouldShowWhatsNew(ROLLCALC_BUILD_VERSION, storedVersion)) return;
const releaseData = await loadWhatsNewData(ROLLCALC_BUILD_VERSION);
if (!releaseData) return;
versionValue.textContent = ROLLCALC_BUILD_VERSION;
renderWhatsNewItems(releaseData.items);
overlay.classList.remove('is-hidden');
continueBtn.addEventListener('click', () => {
markWhatsNewSeen(ROLLCALC_BUILD_VERSION);
overlay.classList.add('is-hidden');
}, { once: true });
openChangesBtn.addEventListener('click', () => {
markWhatsNewSeen(ROLLCALC_BUILD_VERSION);
window.location.href = {{ url_for('recent_changes')|tojson }};
}, { once: true });
}
document.addEventListener('DOMContentLoaded', () => {
const overlay = document.getElementById('disclaimerOverlay');
const checkbox = document.getElementById('disclaimerAccepted');
const confirmBtn = document.getElementById('disclaimerConfirmBtn');
if (!overlay || !checkbox || !confirmBtn) {
maybeShowWhatsNew();
return;
}
checkbox.addEventListener('change', () => {
confirmBtn.disabled = !checkbox.checked;
});
confirmBtn.addEventListener('click', () => {
if (!checkbox.checked) return;
overlay.classList.add('is-hidden');
maybeShowWhatsNew();
});
});
</script> </script>
</body> </body>
+123
View File
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="RollCalc {{ document_title }}">
<title>RollCalc {{ document_title }}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #2f3437; min-height: 100vh; }
.help-header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.help-header-inner { max-width: 1180px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.help-header h1 { font-size: 24px; margin-bottom: 5px; }
.help-header p { font-size: 13px; opacity: 0.9; }
.help-nav { display: flex; gap: 10px; flex-wrap: wrap; }
.help-nav a { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
.help-nav a:hover { background: rgba(255,255,255,0.18); }
.manual-shell { max-width: 1180px; margin: 28px auto; padding: 0 16px 60px; }
.document-actions { display: flex; gap: 10px; flex-wrap: wrap; margin: 0 0 18px; }
.document-actions a { display: inline-block; color: #1F5438; border: 1px solid #cddbd4; border-radius: 6px; background: #fff; padding: 8px 12px; text-decoration: none; font-size: 13px; font-weight: 700; box-shadow: 0 1px 6px rgba(0,0,0,0.04); }
.document-actions a:hover { background: #eef4f1; }
.manual-layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 24px; align-items: start; }
.manual-toc { position: sticky; top: 18px; background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.05); max-height: calc(100vh - 36px); overflow: auto; }
.manual-toc h2 { color: #1F5438; font-size: 14px; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0; }
.manual-toc ul { list-style: none; }
.manual-toc li { margin: 0 0 8px; }
.manual-toc a { color: #34443b; text-decoration: none; font-size: 13px; line-height: 1.35; }
.manual-toc a:hover { color: #1F5438; text-decoration: underline; }
.manual-toc .toc-level-2 { padding-left: 14px; }
.manual-content { background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 34px 42px; box-shadow: 0 2px 12px rgba(0,0,0,0.05); line-height: 1.62; font-size: 15px; }
.manual-content h1, .manual-content h2, .manual-content h3, .manual-content h4 { color: #1F5438; line-height: 1.25; scroll-margin-top: 18px; }
.manual-content h1 { font-size: 30px; margin: 0 0 24px; padding-bottom: 14px; border-bottom: 2px solid #dfe7e2; }
.manual-content h2 { font-size: 22px; margin: 34px 0 14px; padding-top: 4px; }
.manual-content h3 { font-size: 17px; margin: 26px 0 10px; color: #244b39; }
.manual-content h4 { font-size: 15px; margin: 20px 0 8px; color: #244b39; }
.manual-content p { margin: 0 0 14px; }
.manual-content ul, .manual-content ol { margin: 0 0 16px 24px; }
.manual-content li { margin: 5px 0; }
.manual-content a { color: #1F5438; font-weight: 600; }
.manual-content code { background: #eef4f1; border: 1px solid #d7e4de; border-radius: 4px; padding: 1px 4px; font-family: Consolas, 'Courier New', monospace; font-size: 0.92em; }
.manual-content pre { background: #f5f7f6; border: 1px solid #dfe7e2; border-radius: 6px; padding: 14px; margin: 16px 0; overflow-x: auto; }
.manual-content pre code { background: transparent; border: 0; padding: 0; }
.manual-content blockquote { margin: 18px 0; padding: 14px 18px; border-left: 4px solid #2BAC70; background: #f1f8f5; color: #34443b; }
.manual-content blockquote p:last-child, .manual-content blockquote ul:last-child { margin-bottom: 0; }
.manual-content table { width: 100%; border-collapse: collapse; margin: 18px 0; display: block; overflow-x: auto; }
.manual-content th, .manual-content td { border: 1px solid #d7e4de; padding: 9px 11px; text-align: left; vertical-align: top; }
.manual-content th { background: #eef4f1; color: #1F5438; }
.manual-content img { max-width: 100%; height: auto; border: 1px solid #dfe7e2; border-radius: 6px; background: #f8fafa; display: block; margin: 18px 0 8px; }
.manual-error { background: #fff; border: 1px solid #e6c7c7; border-left: 4px solid #b84a4a; border-radius: 8px; padding: 20px; color: #5a2d2d; box-shadow: 0 2px 12px rgba(0,0,0,0.05); }
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; }
.footer-build-info { margin-top: 6px; color: #bbb; }
@media (max-width: 840px) {
.help-header { padding: 18px 16px; }
.help-header-inner { align-items: flex-start; flex-direction: column; }
.manual-layout { grid-template-columns: 1fr; }
.manual-toc { position: static; max-height: none; }
.manual-content { padding: 24px 20px; }
.manual-content h1 { font-size: 25px; }
.manual-content h2 { font-size: 20px; }
}
</style>
</head>
<body>
<header class="help-header">
<div class="help-header-inner">
<div>
<h1>{{ document_title }}</h1>
<p>{{ document_subtitle }}</p>
</div>
<nav class="help-nav" aria-label="Help navigation">
<a href="{{ url_for('index') }}">Calculator</a>
<a href="{{ url_for('help_index') }}">Help</a>
<a href="{{ url_for('user_manual') }}">User Manual</a>
<a href="{{ url_for('quick_reference') }}">Quick Reference</a>
<a href="{{ url_for('recent_changes') }}">Recent Changes</a>
</nav>
</div>
</header>
<main class="manual-shell">
{% if document_actions %}
<nav class="document-actions" aria-label="Related documents">
{% for action in document_actions %}
<a href="{{ action.href }}">{{ action.label }}</a>
{% endfor %}
</nav>
{% endif %}
{% if render_error %}
<section class="manual-error" role="alert">
<strong>{{ error_title }}</strong>
<p>{{ render_error }}</p>
</section>
{% else %}
<div class="manual-layout">
<nav class="manual-toc" aria-label="Table of contents">
<h2>Contents</h2>
<ul>
{% for entry in toc %}
<li class="toc-level-{{ entry.level }}">
<a href="#{{ entry.id }}">{{ entry.title }}</a>
</li>
{% endfor %}
</ul>
</nav>
<article class="manual-content">
{{ manual_html }}
</article>
</div>
{% endif %}
</main>
<footer class="footer">
RollCalc Beta &copy; Naue
<div class="footer-build-info">
Version {{ build_info.version }} |
Branch {{ build_info.branch }} |
Commit {{ build_info.commit }} |
Built {{ build_info.timestamp }}
</div>
</footer>
</body>
</html>