9 Commits
14 changed files with 821 additions and 1651 deletions
+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 existing admin UI/API in RollCalc is planned for removal.
- Do not expand or repair the admin area 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 `templates/admin.html` and `/api/admin/*` expectations as legacy/transitional, not as target architecture.
## 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 admin template and backend admin routes are inconsistent and scheduled for removal rather than expansion.
- 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.
+212
View File
@@ -0,0 +1,212 @@
# 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 currently hardcoded in `BETA_USERS` and `ADMIN_USERS`.
- `/` renders the active calculator template.
- `/static/<path:filename>` is intended to serve static files behind Basic Auth.
- `/api/health` returns health/version information.
- `/api/user` returns the authenticated user and admin flag.
- `/admin/logs` returns access logs for admin users.
- Access events are appended to `access_log.json`.
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.
## 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.
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
Current backend route:
```text
/admin/logs
```
Current `templates/admin.html` expects:
```text
/api/admin/stats
/api/admin/logs
```
Those `/api/admin/*` endpoints are not currently implemented in `app.py`, and there is no route rendering `templates/admin.html`.
## Known Technical Risks
- Hardcoded plaintext credentials in `app.py`.
- Basic Auth only; no sessions or role framework beyond user dictionaries.
- `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.
+299
View File
@@ -0,0 +1,299 @@
# 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
- 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 credentials are defined in `BETA_USERS` and `ADMIN_USERS` in `app.py`.
## Project Layout
```text
.
├── app.py
├── requirements.txt
├── README.md
├── access_log.json
├── config.json
├── article-data_.json
├── fix_article_data.py
├── service-worker.js
├── templates/
│ ├── roll_calculator.html
│ └── admin.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/
├── 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`. |
| `/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 and admin flag. |
| `/admin/logs` | `GET` | Basic Auth plus admin check | Returns `access_log.json` contents. |
Authentication is implemented with `Flask-HTTPAuth`. The current code merges `BETA_USERS` and `ADMIN_USERS` and performs direct plaintext string comparison.
Access logging is handled by `log_access()`, which reads `access_log.json`, appends a record, and writes the whole file back.
## 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.
- 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
There are admin-oriented HTML files:
- `templates/admin.html`
- `admin.html`
- `admin_simple.html`
The backend currently exposes `/admin/logs`, but `templates/admin.html` expects:
```text
/api/admin/stats
/api/admin/logs
```
Those `/api/admin/*` routes are not implemented in `app.py` at the time this README was written. There is also no route currently rendering `templates/admin.html`.
## 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.
### `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.
## Known Risks and Maintenance Items
- Credentials are hardcoded in `app.py` and should be moved to environment variables or a secrets manager.
- Passwords are stored in plaintext and compared directly.
- `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.
- Admin frontend and backend routes are currently inconsistent.
- 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.
+17
View File
@@ -0,0 +1,17 @@
## 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
+63
View File
@@ -537,5 +537,68 @@
"endpoint": "/", "endpoint": "/",
"method": "GET", "method": "GET",
"status": 200 "status": 200
},
{
"timestamp": "2026-07-02T16:07:56.334163",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T16:08:38.097844",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:39:44.284544",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:41:57.673023",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:42:19.877155",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T10:34:02.710459",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:14:58.113794",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:15:20.071507",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:15:32.538576",
"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>
+1 -1
View File
@@ -162,5 +162,5 @@ if __name__ == "__main__":
host="0.0.0.0", host="0.0.0.0",
port=5000, port=5000,
debug=False, debug=False,
use_reloader=True use_reloader=False
) )
View File
View File
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)
+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
+127
View File
@@ -60,6 +60,85 @@
.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; }
.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;
}
/* ============================================ /* ============================================
LOAD OPTIMIZER STYLES LOAD OPTIMIZER STYLES
@@ -336,6 +415,37 @@
</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="header"> <div class="header">
<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>
@@ -1301,6 +1411,23 @@ class LoadOptimizerUI {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
window.loadOptimizerUI = new LoadOptimizerUI(); window.loadOptimizerUI = new LoadOptimizerUI();
}); });
document.addEventListener('DOMContentLoaded', () => {
const overlay = document.getElementById('disclaimerOverlay');
const checkbox = document.getElementById('disclaimerAccepted');
const confirmBtn = document.getElementById('disclaimerConfirmBtn');
if (!overlay || !checkbox || !confirmBtn) return;
checkbox.addEventListener('change', () => {
confirmBtn.disabled = !checkbox.checked;
});
confirmBtn.addEventListener('click', () => {
if (!checkbox.checked) return;
overlay.classList.add('is-hidden');
});
});
</script> </script>
</body> </body>