first commit

This commit is contained in:
2025-08-27 18:55:45 +02:00
commit 1dce2ebf2c
34 changed files with 52590 additions and 0 deletions

532
data/orders.json Normal file
View File

@@ -0,0 +1,532 @@
[
{
"id": "b94528cb-ccf5-41d3-af05-dbd4a75b758e",
"structure": "Athanor",
"quantity": 1,
"me": 0,
"status": "done",
"created_at": "2025-08-21T16:02:00.451895+00:00",
"done_at": "2025-08-21T16:02:20.363756+00:00",
"system": "Jita",
"industry_structure": "Station",
"industry_rig": "None",
"reaction_structure": "Athanor",
"reaction_rig": "None"
},
{
"id": "e0c631c3-0a64-44ac-a64a-cc0647b2a2b9",
"structure": "Raitaru",
"quantity": 6,
"me": 4,
"status": "done",
"created_at": "2025-08-21T16:02:17.240266+00:00",
"done_at": "2025-08-21T16:02:21.570702+00:00",
"system": "Jita",
"industry_structure": "Station",
"industry_rig": "None",
"reaction_structure": "Athanor",
"reaction_rig": "None"
},
{
"id": "0bdf2545-b129-4856-bcd8-23837c4a04bf",
"structure": "Athanor",
"quantity": 5,
"me": 10,
"notes": "Test Auftrag",
"status": "done",
"created_at": "2025-08-21T16:13:36.220731+00:00",
"done_at": "2025-08-21T16:13:42.494046+00:00",
"system": "Jita",
"industry_structure": "Station",
"industry_rig": "None",
"reaction_structure": "Athanor",
"reaction_rig": "None"
},
{
"id": "09bed2df-89a0-475a-a0bb-3e942f594e11",
"structure": "Raitaru",
"quantity": 2,
"me": 3,
"notes": "",
"status": "done",
"created_at": "2025-08-21T16:22:08.029669+00:00",
"done_at": "2025-08-21T17:27:58.230171+00:00",
"system": "Jita",
"industry_structure": "Station",
"industry_rig": "None",
"reaction_structure": "Athanor",
"reaction_rig": "None"
},
{
"id": "32aea266-0f57-4101-b17e-051cca285381",
"structure": "Astrahus",
"quantity": 6,
"me": 0,
"notes": "",
"status": "done",
"created_at": "2025-08-21T17:28:04.199917+00:00",
"done_at": "2025-08-21T19:56:26.687066+00:00",
"system": "Jita",
"industry_structure": "Station",
"industry_rig": "None",
"reaction_structure": "Athanor",
"reaction_rig": "None"
},
{
"id": "38c32e75-ca24-463f-924c-665731736541",
"structure": "Athanor",
"quantity": 2,
"me": 6,
"notes": "Test",
"system": "Jita",
"industry_structure": "Sotiyo",
"industry_rig": "T1",
"reaction_structure": "Tatara",
"reaction_rig": "T1",
"status": "done",
"created_at": "2025-08-21T19:56:21.284564+00:00",
"done_at": "2025-08-22T07:54:33+0100",
"cookbook": {
"error": 0,
"status": 200,
"message": {
"materialCost": 1469368496.29,
"jobCost": 450873981.87,
"additionalCost": 0,
"totalCost": 1920242478.16,
"producedQuantity": 2,
"buildCostPerUnit": 960121239.08,
"excessMaterialsValue": 0,
"blueprintTypeId": 36977,
"blueprintName": "Athanor Blueprint"
}
},
"last_updated": "2025-08-22T07:33:15+0100",
"materials": {
"materials": [
{
"type_id": 21959,
"name": "Structure Reprocessing Plant",
"quantity": 6.0,
"cost_per_unit": 84744021.74,
"cost": 508464130.44
},
{
"type_id": 36957,
"name": "Structure Acceleration Coils",
"quantity": 2.0,
"cost_per_unit": 152817857.14,
"cost": 305635714.28
},
{
"type_id": 36956,
"name": "Structure Electromagnetic Sensor",
"quantity": 2.0,
"cost_per_unit": 141638461.54,
"cost": 283276923.08
},
{
"type_id": 21951,
"name": "Structure Storage Bay",
"quantity": 2.0,
"cost_per_unit": 73250833.33,
"cost": 146501666.66
},
{
"type_id": 21949,
"name": "Structure Hangar Array",
"quantity": 2.0,
"cost_per_unit": 53526739.13,
"cost": 107053478.26
},
{
"type_id": 21947,
"name": "Structure Construction Parts",
"quantity": 2.0,
"cost_per_unit": 52829037.04,
"cost": 105658074.08
},
{
"type_id": 21961,
"name": "Structure Docking Bay",
"quantity": 2.0,
"cost_per_unit": 48298064.52,
"cost": 96596129.04
},
{
"type_id": 21957,
"name": "Structure Repair Facility",
"quantity": 2.0,
"cost_per_unit": 46331224.49,
"cost": 92662448.98
}
]
}
},
{
"id": "6cbd23eaca8941319037b3ac14a4970e",
"status": "done",
"created_at": "2025-08-22T07:54:47+0100",
"structure": "Raitaru",
"system": "Jita",
"industry_structure": "Sotiyo",
"industry_rig": "T1",
"reaction_structure": "Athanor",
"reaction_rig": "T1",
"quantity": 1,
"me": 5,
"notes": "Test",
"blueprint_type_id": 36971,
"cookbook": {
"error": 0,
"status": 200,
"message": {
"materialCost": 494601665.69,
"jobCost": 169775434.21,
"additionalCost": 0,
"totalCost": 664377099.9,
"producedQuantity": 1,
"buildCostPerUnit": 664377099.9,
"excessMaterialsValue": 0,
"blueprintTypeId": 36971,
"blueprintName": "Raitaru Blueprint"
}
},
"last_updated": "2025-08-22T07:59:54+0100",
"materials": {
"materials": [
{
"type_id": 21955,
"name": "Structure Factory",
"quantity": 1.0,
"cost_per_unit": 106032424.24,
"cost": 106032424.24
},
{
"type_id": 21959,
"name": "Structure Reprocessing Plant",
"quantity": 1.0,
"cost_per_unit": 84744021.74,
"cost": 84744021.74
},
{
"type_id": 21951,
"name": "Structure Storage Bay",
"quantity": 1.0,
"cost_per_unit": 73250833.33,
"cost": 73250833.33
},
{
"type_id": 21953,
"name": "Structure Laboratory",
"quantity": 1.0,
"cost_per_unit": 71164821.43,
"cost": 71164821.43
},
{
"type_id": 21967,
"name": "Structure Office Center",
"quantity": 1.0,
"cost_per_unit": 67570149.25,
"cost": 67570149.25
},
{
"type_id": 21949,
"name": "Structure Hangar Array",
"quantity": 1.0,
"cost_per_unit": 53526739.13,
"cost": 53526739.13
},
{
"type_id": 21947,
"name": "Structure Construction Parts",
"quantity": 1.0,
"cost_per_unit": 52829037.04,
"cost": 52829037.04
},
{
"type_id": 21961,
"name": "Structure Docking Bay",
"quantity": 1.0,
"cost_per_unit": 48298064.52,
"cost": 48298064.52
},
{
"type_id": 21957,
"name": "Structure Repair Facility",
"quantity": 1.0,
"cost_per_unit": 46331224.49,
"cost": 46331224.49
}
]
},
"done_at": "2025-08-22T08:00:14+0100"
},
{
"id": "e4661b4a72064437a7f33ced2d446cde",
"status": "done",
"created_at": "2025-08-22T08:00:05+0100",
"structure": "Athanor",
"system": "Jita",
"industry_structure": "Sotiyo",
"industry_rig": "T1",
"reaction_structure": "Athanor",
"reaction_rig": "T1",
"quantity": 1,
"me": 0,
"notes": "",
"blueprint_type_id": 36977,
"done_at": "2025-08-22T08:00:15+0100"
},
{
"id": "a453ec6587c44d8ebdbb5156c58c9ec7",
"status": "done",
"created_at": "2025-08-22T08:15:52+0100",
"structure": "Keepstar",
"system": "Jita",
"industry_structure": "Sotiyo",
"industry_rig": "T1",
"reaction_structure": "Athanor",
"reaction_rig": "T1",
"quantity": 1,
"me": 10,
"notes": "",
"blueprint_type_id": 36968,
"cookbook": {
"error": 0,
"status": 200,
"message": {
"materialCost": 156812981803.45,
"jobCost": 74872180070.6,
"additionalCost": 0,
"totalCost": 231685161874.05,
"producedQuantity": 1,
"buildCostPerUnit": 231685161874.05,
"excessMaterialsValue": 0,
"blueprintTypeId": 36968,
"blueprintName": "Keepstar Blueprint"
}
},
"last_updated": "2025-08-22T08:37:05+0100",
"materials": {
"materials": [
{
"type_id": 21963,
"name": "Structure Market Network",
"quantity": 713.0,
"cost_per_unit": 166346666.67,
"cost": 118605173335.71
},
{
"type_id": 21951,
"name": "Structure Storage Bay",
"quantity": 179.0,
"cost_per_unit": 73250833.33,
"cost": 13111899166.07
},
{
"type_id": 21967,
"name": "Structure Office Center",
"quantity": 179.0,
"cost_per_unit": 67570149.25,
"cost": 12095056715.75
},
{
"type_id": 21949,
"name": "Structure Hangar Array",
"quantity": 179.0,
"cost_per_unit": 53526739.13,
"cost": 9581286304.27
},
{
"type_id": 21947,
"name": "Structure Construction Parts",
"quantity": 179.0,
"cost_per_unit": 52829037.04,
"cost": 9456397630.16
},
{
"type_id": 21965,
"name": "Structure Medical Center",
"quantity": 179.0,
"cost_per_unit": 50834772.73,
"cost": 9099424318.67
},
{
"type_id": 21961,
"name": "Structure Docking Bay",
"quantity": 179.0,
"cost_per_unit": 48298064.52,
"cost": 8645353549.08
},
{
"type_id": 21957,
"name": "Structure Repair Facility",
"quantity": 179.0,
"cost_per_unit": 46331224.49,
"cost": 8293289183.71
},
{
"type_id": 21955,
"name": "Structure Factory",
"quantity": 72.0,
"cost_per_unit": 106032424.24,
"cost": 7634334545.28
},
{
"type_id": 36958,
"name": "Structure Advertisement Nexus",
"quantity": 72.0,
"cost_per_unit": 86114000.0,
"cost": 6200208000.0
},
{
"type_id": 21959,
"name": "Structure Reprocessing Plant",
"quantity": 72.0,
"cost_per_unit": 84744021.74,
"cost": 6101569565.28
},
{
"type_id": 21953,
"name": "Structure Laboratory",
"quantity": 72.0,
"cost_per_unit": 71164821.43,
"cost": 5123867142.96
},
{
"type_id": 36957,
"name": "Structure Acceleration Coils",
"quantity": 8.0,
"cost_per_unit": 152817857.14,
"cost": 1222542857.12
},
{
"type_id": 36956,
"name": "Structure Electromagnetic Sensor",
"quantity": 8.0,
"cost_per_unit": 141638461.54,
"cost": 1133107692.32
},
{
"type_id": 3810,
"name": "Marines",
"quantity": 891.0,
"cost_per_unit": 7474.2,
"cost": 6659512.2
},
{
"type_id": 13267,
"name": "Janitor",
"quantity": 223.0,
"cost_per_unit": 6525.04,
"cost": 1455083.92
},
{
"type_id": 21969,
"name": "Structure Mission Network",
"quantity": 8.0,
"cost_per_unit": 0.0,
"cost": 0.0
}
]
},
"done_at": "2025-08-22T11:25:47+0100"
},
{
"id": "f14623326be343e790e769a0de50d43a",
"status": "done",
"created_at": "2025-08-22T09:45:25+0100",
"structure": "Athanor",
"system": "Jita",
"industry_structure": "Sotiyo",
"industry_rig": "T1",
"reaction_structure": "Athanor",
"reaction_rig": "None",
"quantity": 1,
"me": 5,
"notes": "",
"blueprint_type_id": 36977,
"done_at": "2025-08-22T11:46:35+0100"
},
{
"id": "bfa32ea583ba44e58872ceb84518c546",
"status": "done",
"created_at": "2025-08-22T11:45:30+0100",
"structure": "Keepstar",
"system": "Jita",
"industry_structure": "Sotiyo",
"industry_rig": "T2",
"reaction_structure": "Tatara",
"reaction_rig": "T2",
"quantity": 1,
"me": 6,
"notes": "Test",
"blueprint_type_id": 36968,
"done_at": "2025-08-22T11:46:27+0100"
},
{
"id": "63eedb769b854322a75606380954b748",
"status": "done",
"created_at": "2025-08-22T11:46:53+0100",
"structure": "Fortizar",
"system": "Amarr",
"industry_structure": "Sotiyo",
"industry_rig": "T2",
"reaction_structure": "Athanor",
"reaction_rig": "None",
"quantity": 1,
"me": 0,
"notes": "",
"blueprint_type_id": 36967,
"done_at": "2025-08-22T12:13:41+0100"
},
{
"id": "644be14fb305436ca035eaeea7d7a4e7",
"status": "done",
"created_at": "2025-08-22T11:47:47+0100",
"structure": "Raitaru",
"system": "Jita",
"industry_structure": "Sotiyo",
"industry_rig": "T1",
"reaction_structure": "Athanor",
"reaction_rig": "None",
"quantity": 5,
"me": 0,
"notes": "",
"blueprint_type_id": 36971,
"done_at": "2025-08-22T12:13:43+0100"
},
{
"id": "6a1c0282910d4f9d95b0fac76438f725",
"status": "open",
"created_at": "2025-08-22T12:13:50+0100",
"structure": "Astrahus",
"system": "Jita",
"industry_structure": "Sotiyo",
"industry_rig": "T1",
"reaction_structure": "Athanor",
"reaction_rig": "None",
"quantity": 1,
"me": 5,
"notes": "",
"blueprint_type_id": 36966,
"cache": {
"materials": {
"error": 0,
"message": {
"additionalCost": 0,
"blueprintName": "Astrahus Blueprint",
"blueprintTypeId": 36966,
"buildCostPerUnit": 1180755487.44,
"excessMaterialsValue": 0,
"jobCost": 346965820.03,
"materialCost": 833789667.41,
"producedQuantity": 1,
"totalCost": 1180755487.44
},
"status": 200
}
}
}
]

8
main.py Normal file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env python3
from __future__ import annotations
from webapp import create_app
if __name__ == "__main__":
app = create_app()
app.run(host="127.0.0.1", port=5000, debug=True)

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
Flask==3.1.2
Requests==2.32.5

44
webapp/__init__.py Normal file
View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from flask import Flask, render_template
from datetime import datetime
# bestehende Blueprints
from .routes.structures import bp as structures_bp
from .routes.cookbook import bp as cookbook_bp
from .routes.archive import bp as archive_bp
# NEU: Blueprint-ID API
from .routes.blueprints import bp as blueprints_api_bp
def create_app() -> Flask:
app = Flask(__name__, template_folder="templates")
# Jinja-Filter für Timestamps (wird in Templates genutzt)
def fmt_ts(value):
if not value:
return ""
try:
# akzeptiert "YYYY-mm-ddTHH:MM:SS" etc.
if isinstance(value, (int, float)):
return datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M")
s = str(value).replace("T", " ").split("+", 1)[0]
return s[:16]
except Exception:
return str(value)
app.jinja_env.filters["fmt_ts"] = fmt_ts
# Blueprints registrieren
app.register_blueprint(structures_bp)
app.register_blueprint(cookbook_bp)
app.register_blueprint(archive_bp)
app.register_blueprint(blueprints_api_bp) # <-- neu
# Home + Favicon
@app.get("/")
def home():
return render_template("index.html")
@app.get("/favicon.ico")
def _favicon():
return ("", 204)
return app

Binary file not shown.

1761
webapp/data/orders.json Normal file

File diff suppressed because it is too large Load Diff

48130
webapp/data/typeIDs.csv Normal file

File diff suppressed because it is too large Load Diff

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

27
webapp/routes/archive.py Normal file
View File

@@ -0,0 +1,27 @@
# webapp/routes/archive.py
from __future__ import annotations
import time
from flask import Blueprint, render_template, redirect, url_for
from webapp.storage.orders import load_orders, update_order
bp = Blueprint("archive", __name__)
@bp.get("/archiv")
def archive_index():
"""Zeigt ausschließlich Aufträge mit status == 'archived'."""
archived = [o for o in load_orders() if o.get("status") == "archived"]
archived.sort(
key=lambda o: (o.get("archived_at") or o.get("done_at") or o.get("created_at") or ""),
reverse=True,
)
return render_template("archive.html", archived_orders=archived)
@bp.post("/archiv/add/<order_id>")
def archive_add(order_id: str):
"""
Markiert einen Auftrag als archiviert.
Danach BLEIBEN wir auf der Strukturen-Seite (kein Wechsel zur Archiv-Seite).
"""
now = time.strftime("%Y-%m-%dT%H:%M:%S%z", time.gmtime())
update_order(order_id, {"status": "archived", "archived_at": now})
return redirect(url_for("structures.structures")) # zurück zu /strukturen

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from flask import Blueprint, request, jsonify
from webapp.services.blueprints import (
resolve_blueprint_id,
resolve_blueprint_id_by_product_id,
)
bp = Blueprint("blueprints_api", __name__)
@bp.get("/api/blueprint_id")
def api_blueprint_id():
"""
Liefert die Blueprint-TypeID.
Query-Parameter:
- name: Produktname (z. B. "Structure Market Network")
- productTypeId: Produkt-TypeID (optional, wenn 'name' fehlt)
Antwort: { ok: true, blueprint_type_id: 12345 } oder { ok:false, error: "..." }
"""
name = (request.args.get("name") or "").strip()
pid = request.args.get("productTypeId")
bp_tid = None
if name:
bp_tid = resolve_blueprint_id(name)
elif pid:
try:
bp_tid = resolve_blueprint_id_by_product_id(int(pid))
except Exception:
bp_tid = None
if not bp_tid:
return jsonify({"ok": False, "error": "not-found"}), 404
return jsonify({"ok": True, "blueprint_type_id": int(bp_tid)})

293
webapp/routes/cookbook.py Normal file
View File

@@ -0,0 +1,293 @@
from __future__ import annotations
from typing import Any, Dict, List, Tuple
from flask import Blueprint, request, jsonify
import time
# Services
from webapp.services.ccookbook import (
call_cookbook_build_cost,
call_eve_ref_materials,
)
# Storage
from webapp.storage.orders import (
load_orders, update_order,
cache_costs, cache_materials,
)
bp = Blueprint("cookbook", __name__, url_prefix="/api")
def _now() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%S%z", time.gmtime())
# ----------------------------- Helpers ----------------------------------------
def _arg(name: str, default: str = "") -> str:
v = request.args.get(name)
return v if v is not None else default
def _to_int(s: str, default: int = 0) -> int:
try:
return int(float(s))
except Exception:
return default
def _order_by_id(order_id: str) -> Dict[str, Any] | None:
for o in load_orders():
if o.get("id") == order_id:
return o
return None
# ----------------------------- Endpoints Raw --------------------------------
@bp.get("/cookbook/build_cost")
def api_build_cost():
"""Kosten via evecookbook (Preise, Gesamt etc.)."""
bps = request.args.getlist("blueprintTypeId")
if not bps and request.args.get("blueprintTypeId"):
bps = [request.args.get("blueprintTypeId")]
if not bps:
return jsonify({"error": "missing blueprintTypeId"}), 400
try:
data = call_cookbook_build_cost(
blueprint_type_ids=bps,
quantity=_to_int(_arg("quantity", "1")),
price_mode=_arg("priceMode", "buy"),
additional_costs=_to_int(_arg("additionalCosts", "0")),
base_me=_to_int(_arg("baseMe", "0")),
components_me=_to_int(_arg("componentsMe", "0")),
system=_arg("system", "Jita"),
facility_tax=_to_int(_arg("facilityTax", "0")),
industry_structure_type=_arg("industryStructureType", "Station"),
industry_rig=_arg("industryRig", "None"),
reaction_structure_type=_arg("reactionStructureType", "Athanor"),
reaction_rig=_arg("reactionRig", "None"),
reaction_flag=_arg("reactionFlag", ""),
blueprint_version=_arg("blueprintVersion", "tq"),
)
return jsonify(data)
except Exception as e:
return jsonify({"error": str(e)}), 502
@bp.get("/cookbook/materials")
def api_materials():
"""
Materialliste via EVE Ref.
Erwartet: blueprintTypeId, optional quantity/baseMe/industryStructureType.
"""
if not request.args.get("blueprintTypeId"):
return jsonify({"error": "missing blueprintTypeId"}), 400
try:
data = call_eve_ref_materials(dict(request.args))
# Sicherstellen, dass immer eine Array-Liste unter "materials" steht
mats = data.get("materials") or []
if isinstance(mats, dict):
mats = list(mats.values())
data["materials"] = mats
return jsonify(data)
except Exception as e:
return jsonify({"error": str(e)}), 502
# ----------------------------- Endpoints Cache/Order ------------------------
@bp.post("/order/<order_id>/bp")
def api_set_bp(order_id: str):
payload = request.get_json(silent=True) or {}
bp_id = payload.get("blueprint_type_id")
if not bp_id:
return jsonify({"error": "missing blueprint_type_id"}), 400
ok = update_order(order_id, {"blueprint_type_id": int(bp_id), "last_updated": _now()})
return jsonify({"ok": bool(ok)})
@bp.post("/order/<order_id>/cache")
def api_cache(order_id: str):
payload = request.get_json(silent=True) or {}
ok = False
if "costs" in payload:
ok = cache_costs(order_id, payload["costs"])
if "materials" in payload:
ok = cache_materials(order_id, payload["materials"])
return jsonify({"ok": bool(ok)})
@bp.post("/order/<order_id>/refresh")
def api_refresh_one(order_id: str):
o = _order_by_id(order_id)
if not o:
return jsonify({"error": "order not found"}), 404
bp_id = o.get("blueprint_type_id")
if not bp_id:
return jsonify({"error": "blueprint_type_id missing"}), 400
# Basis-Parameter aus Order
p = {
"blueprintTypeId": str(bp_id),
"quantity": str(o.get("quantity", 1)),
"priceMode": "buy",
"additionalCosts": "0",
"baseMe": str(o.get("me", 0)),
"componentsMe": str(o.get("me", 0)),
"system": o.get("system", "Jita"),
"facilityTax": str(o.get("facility_tax", 0)),
"industryStructureType": o.get("industry_structure", "Station"),
"industryRig": o.get("industry_rig", "None"),
"reactionStructureType": o.get("reaction_structure", "Athanor"),
"reactionRig": o.get("reaction_rig", "None"),
"reactionFlag": "",
"blueprintVersion": "tq",
}
# Kosten
try:
costs = call_cookbook_build_cost([bp_id],
quantity=int(p["quantity"]),
price_mode=p["priceMode"],
additional_costs=int(p["additionalCosts"]),
base_me=int(p["baseMe"]),
components_me=int(p["componentsMe"]),
system=p["system"],
facility_tax=int(float(p["facilityTax"])),
industry_structure_type=p["industryStructureType"],
industry_rig=p["industryRig"],
reaction_structure_type=p["reactionStructureType"],
reaction_rig=p["reactionRig"],
reaction_flag=p["reactionFlag"],
blueprint_version=p["blueprintVersion"],
)
cache_costs(order_id, costs)
except Exception as e:
costs = {"error": str(e)}
# Materialien
try:
mats = call_eve_ref_materials(p)
if isinstance(mats.get("materials"), dict):
mats["materials"] = list(mats["materials"].values())
cache_materials(order_id, mats)
except Exception as e:
mats = {"error": str(e)}
return jsonify({"ok": True, "costs": costs, "materials": mats})
@bp.post("/orders/refresh_all")
def api_refresh_all():
orders = [o for o in load_orders() if o.get("status") == "open" and o.get("blueprint_type_id")]
done = 0
for o in orders:
try:
request.environ["werkzeug.server.shutdown"] # no-op
except Exception:
pass
try:
_ = api_refresh_one(o["id"])
done += 1
except Exception:
continue
return jsonify({"ok": True, "refreshed": done})
# ----------------------------- Endpoints Aggregation ------------------------
@bp.get("/cookbook/open_costs")
def api_open_costs():
"""
Aggregiert Kosten je Order (nutzt Cache; fehlt er, wird berechnet).
"""
items: List[Dict[str, Any]] = []
total = 0.0
for o in load_orders():
if o.get("status") != "open" or not o.get("blueprint_type_id"):
continue
entry: Dict[str, Any] = {
"structure": o.get("structure"),
"quantity": o.get("quantity"),
"me": o.get("me"),
"system": o.get("system"),
"industry_structure": o.get("industry_structure"),
"industry_rig": o.get("industry_rig"),
"reaction_structure": o.get("reaction_structure"),
"reaction_rig": o.get("reaction_rig"),
}
costs = o.get("cookbook")
if not costs:
try:
costs = call_cookbook_build_cost([o["blueprint_type_id"]],
quantity=int(o.get("quantity", 1)),
price_mode="buy",
additional_costs=0,
base_me=int(o.get("me", 0)),
components_me=int(o.get("me", 0)),
system=o.get("system", "Jita"),
facility_tax=int(float(o.get("facility_tax", 0))),
industry_structure_type=o.get("industry_structure", "Station"),
industry_rig=o.get("industry_rig", "None"),
reaction_structure_type=o.get("reaction_structure", "Athanor"),
reaction_rig=o.get("reaction_rig", "None"),
reaction_flag="",
blueprint_version="tq",
)
cache_costs(o["id"], costs)
except Exception as e:
entry["error"] = str(e)
items.append(entry)
continue
msg = costs.get("message") or costs
unit = msg.get("buildCostPerUnit") or msg.get("unitCost")
tot = msg.get("totalCost") or msg.get("total") or 0
entry["unit_cost"] = unit
entry["total_cost"] = tot
total += float(tot or 0)
items.append(entry)
return jsonify({"items": items, "total_cost": total, "refreshed_at": _now()})
@bp.get("/cookbook/open_materials")
def api_open_materials():
"""
Aggregiert Materialien über alle offenen Orders.
"""
agg: Dict[int, Dict[str, Any]] = {}
total_cost = 0.0
def _add(tid: int, name: str, qty: float, cost: float | None):
row = agg.setdefault(tid, {"type_id": tid, "name": name, "quantity": 0, "cost": 0})
row["quantity"] += float(qty or 0)
if cost is not None:
row["cost"] += float(cost or 0)
for o in load_orders():
if o.get("status") != "open" or not o.get("blueprint_type_id"):
continue
mats = o.get("materials")
if not mats:
try:
mats = call_eve_ref_materials({
"blueprintTypeId": str(o["blueprint_type_id"]),
"quantity": str(o.get("quantity", 1)),
"baseMe": str(o.get("me", 0)),
"industryStructureType": o.get("industry_structure", "Station"),
"facilityTax": str(o.get("facility_tax", 0)),
})
if isinstance(mats.get("materials"), dict):
mats["materials"] = list(mats["materials"].values())
cache_materials(o["id"], mats)
except Exception:
mats = {"materials": []}
for m in mats.get("materials") or []:
tid = int(m.get("type_id") or m.get("typeId") or 0)
if not tid:
continue
name = m.get("name") or f"Type {tid}"
qty = m.get("quantity") or m.get("qty") or m.get("amount") or 0
cost = m.get("cost") or m.get("cost_per_unit") or m.get("unit_cost")
_add(tid, name, qty, cost)
if cost:
total_cost += float(cost or 0)
items = sorted(agg.values(), key=lambda r: (r.get("cost") or 0), reverse=True)
return jsonify({"items": items, "total_cost": total_cost, "refreshed_at": _now()})

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
import uuid, time
from typing import List, Dict, Any
from flask import Blueprint, render_template, request, redirect, url_for
from webapp.storage.orders import load_orders, add_order, mark_done
from webapp.services.blueprints import resolve_blueprint_id
bp = Blueprint("structures", __name__)
STRUCTURES = ["Athanor", "Raitaru", "Astrahus", "Fortizar", "Keepstar", "Tatara", "Sotiyo"]
SYSTEMS = ["Jita", "Perimeter", "Amarr", "Dodixie", "Rens", "Hek", "OGV-AS", "B-9C24", "K-6K16", "PUIG-F"]
IND_STRUCTS = ["Station", "Raitaru", "Azbel", "Sotiyo"]
IND_RIGS = ["None", "T1", "T2"]
REAC_STRUCTS= ["Athanor", "Tatara"]
REAC_RIGS = ["None", "T1", "T2"]
def _split_orders() -> Dict[str, List[Dict[str, Any]]]:
orders = load_orders()
open_orders = [o for o in orders if o.get("status") == "open"]
done_orders = [o for o in orders if o.get("status") == "done"]
open_orders.sort(key=lambda o: o.get("created_at",""), reverse=True)
done_orders.sort(key=lambda o: o.get("done_at","") or "", reverse=True)
return {"open": open_orders, "done": done_orders}
@bp.route("/strukturen", methods=["GET", "POST"])
def structures():
if request.method == "POST":
structure = request.form.get("structure","").strip()
quantity = int(request.form.get("quantity") or 1)
me = int(request.form.get("me") or 0)
system = request.form.get("system","Jita")
ind_struct= request.form.get("industry_structure","Station")
ind_rig = request.form.get("industry_rig","None")
reac_struct = request.form.get("reaction_structure","Athanor")
reac_rig = request.form.get("reaction_rig","None")
notes = request.form.get("notes","")
oid = uuid.uuid4().hex
now = time.strftime("%Y-%m-%dT%H:%M:%S%z", time.gmtime())
bp_id = resolve_blueprint_id(structure)
add_order({
"id": oid,
"status": "open",
"created_at": now,
"structure": structure,
"system": system,
"industry_structure": ind_struct,
"industry_rig": ind_rig,
"reaction_structure": reac_struct,
"reaction_rig": reac_rig,
"quantity": quantity,
"me": me,
"notes": notes,
"blueprint_type_id": bp_id
})
return redirect(url_for("structures.structures"))
parts = _split_orders()
return render_template(
"structures.html",
structures=STRUCTURES,
systems=SYSTEMS,
ind_structs=IND_STRUCTS,
ind_rigs=IND_RIGS,
reac_structs=REAC_STRUCTS,
reac_rigs=REAC_RIGS,
open_orders=parts["open"],
done_orders=parts["done"],
selected_system="Jita",
selected_ind_struct="Station",
selected_ind_rig="None",
selected_reac_struct="Athanor",
selected_reac_rig="None",
)
# ←← NEU/ WICHTIG: exakte Unterseiten-Route
@bp.get("/strukturen/mineralien")
def mineralien():
return render_template("minerals.html")
@bp.post("/strukturen/done/<order_id>")
def done(order_id: str):
mark_done(order_id)
return redirect(url_for("structures.structures"))

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
import csv
import os
import json
from typing import Optional, Dict
from flask import current_app
# Cache: "name in lowercase" -> type_id
_BP_NAME2ID: Dict[str, int] = {}
# Cache: product type id -> (product name)
_ID2NAME: Dict[int, str] = {}
# Optional benutzerdefinierte Überschreibungen
# Format: { "Structure Market Network": 123456, ... }
_USER_MAP: Dict[str, int] = {}
def _data_path(filename: str) -> str:
return os.path.join(current_app.root_path, "data", filename)
def _ensure_loaded() -> None:
"""Liest typeIDs.csv einmalig ein und baut schnelle Nachschlage-Maps auf."""
global _BP_NAME2ID, _ID2NAME, _USER_MAP
if _BP_NAME2ID and _ID2NAME:
return
# Benutzer-Overrides (optional)
try:
override = _data_path("blueprints.json")
if os.path.isfile(override):
with open(override, "r", encoding="utf-8") as f:
_USER_MAP = json.load(f) or {}
except Exception:
_USER_MAP = {}
csv_path = _data_path("typeIDs.csv")
if not os.path.isfile(csv_path):
return
try:
with open(csv_path, "r", encoding="utf-8", newline="") as f:
# Datei ist ;-separiert: id;name;group_id;iconID;graphicID
reader = csv.reader(f, delimiter=";")
header = next(reader, None) # "id;name;group_id;iconID;graphicID"
for row in reader:
if not row or len(row) < 2:
continue
try:
tid = int(row[0])
except Exception:
continue
name = (row[1] or "").strip()
if not name:
continue
_ID2NAME[tid] = name
key = name.lower()
# Merke auch die Blaupause separat (Name endet exakt auf " Blueprint")
if key.endswith(" blueprint"):
_BP_NAME2ID[key] = tid
except Exception:
# Map bleibt ggf. leer; Aufrufer bekommen dann None zurück
pass
def product_name_by_id(type_id: int) -> Optional[str]:
"""Gibt den Produktnamen zur TypeID zurück (aus typeIDs.csv)."""
_ensure_loaded()
return _ID2NAME.get(type_id)
def resolve_blueprint_id(product_name: str) -> Optional[int]:
"""
Ermittelt die Blueprint-TypeID zu einem *Produktnamen*.
Regeln:
1) Benutzer-Override (blueprints.json)
2) Exakter Treffer "<Produktname> Blueprint" in typeIDs.csv
3) Weicher Treffer (enthält Produktname & 'blueprint')
"""
if not product_name:
return None
_ensure_loaded()
# 1) User-Override
if product_name in _USER_MAP:
return int(_USER_MAP[product_name])
wanted_exact = f"{product_name} Blueprint".lower()
# 2) Exakter Treffer
if wanted_exact in _BP_NAME2ID:
return _BP_NAME2ID[wanted_exact]
# 3) Weiche Suche
prod_l = product_name.lower()
for k, tid in _BP_NAME2ID.items():
if prod_l in k and "blueprint" in k:
return tid
return None
def resolve_blueprint_id_by_product_id(product_type_id: int) -> Optional[int]:
"""Ermittelt die Blueprint-TypeID aus der *Produkt*-TypeID."""
name = product_name_by_id(int(product_type_id))
if not name:
return None
return resolve_blueprint_id(name)

View File

@@ -0,0 +1,145 @@
from __future__ import annotations
from typing import Any, Dict, List, Tuple, Union
from urllib.parse import urljoin
import requests
__all__ = ["call_cookbook_build_cost", "call_eve_ref_materials"]
# ---------- HTTP Session mit Retries (für evecookbook + everef) ---------------
from requests.adapters import HTTPAdapter, Retry
_SESSION = requests.Session()
_RETRY = Retry(
total=3, connect=3, read=3,
backoff_factor=0.3,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset(["GET", "POST"]),
)
_ADAPTER = HTTPAdapter(max_retries=_RETRY)
_SESSION.mount("http://", _ADAPTER)
_SESSION.mount("https://", _ADAPTER)
# ---------- EVE Cookbook: Build Cost ------------------------------------------
def call_cookbook_build_cost(
blueprint_type_ids: Union[List[str], List[int]],
*,
quantity: int = 1,
price_mode: str = "buy",
additional_costs: int = 0,
base_me: int = 0,
components_me: int = 0,
system: str = "Jita",
facility_tax: int = 0,
industry_structure_type: str = "Station",
industry_rig: str = "None",
reaction_structure_type: str = "Athanor",
reaction_rig: str = "None",
reaction_flag: str = "",
blueprint_version: str = "tq",
timeout: Tuple[float, float] = (5.0, 25.0),
) -> Dict[str, Any]:
base = "https://evecookbook.com/api/"
url = urljoin(base, "buildCost")
params: List[Tuple[str, Any]] = []
for bid in blueprint_type_ids:
params.append(("blueprintTypeId", str(bid)))
params.extend([
("quantity", str(int(quantity))),
("priceMode", price_mode),
("additionalCosts", str(int(additional_costs))),
("baseMe", str(int(base_me))),
("componentsMe", str(int(components_me))),
("system", system),
("facilityTax", str(int(facility_tax))),
("industryStructureType", industry_structure_type),
("industryRig", industry_rig),
("reactionStructureType", reaction_structure_type),
("reactionRig", reaction_rig),
("reactionFlag", reaction_flag),
("blueprintVersion", blueprint_version),
])
headers = {"Accept": "application/json"}
resp = _SESSION.get(url, params=params, headers=headers, timeout=timeout)
try:
resp.raise_for_status()
except requests.HTTPError as e:
try:
detail = resp.json()
except Exception:
detail = resp.text[:600]
raise RuntimeError(f"Cookbook API {resp.status_code}: {detail}") from e
try:
return resp.json() if resp.content else {}
except Exception:
return {"raw": resp.text}
# ---------- EVE Ref: Materials (BOM) ------------------------------------------
EVEREF_COST_URL = "https://api.everef.net/v1/industry/cost"
REFDATA_TYPE_URL = "https://ref-data.everef.net/types/{type_id}"
_TYPE_NAME_CACHE: Dict[int, str] = {}
def _type_name(type_id: int) -> str:
if type_id in _TYPE_NAME_CACHE:
return _TYPE_NAME_CACHE[type_id]
try:
r = _SESSION.get(REFDATA_TYPE_URL.format(type_id=type_id), timeout=10)
r.raise_for_status()
data = r.json()
name = (data.get("name", {}) or {}).get("en") or data.get("name") or str(type_id)
except Exception:
name = str(type_id)
_TYPE_NAME_CACHE[type_id] = name
return name
def call_eve_ref_materials(params: Dict[str, Any]) -> Dict[str, Any]:
"""
Holt Materialliste (Manufacturing) zu einer Blueprint-ID.
Erwartet mindestens: blueprintTypeId; optional: quantity, baseMe, industryStructureType.
"""
bp_id = int(params.get("blueprintTypeId"))
runs = int(params.get("quantity", 1))
me = int(params.get("baseMe", 0))
te = int(params.get("baseTe", 0)) if params.get("baseTe") is not None else 0
structure_map = {"Station": None, "Raitaru": 35825, "Azbel": 35826, "Sotiyo": 35827}
st_name = params.get("industryStructureType", "Station")
st_id = structure_map.get(st_name)
q: Dict[str, Any] = {"blueprint_id": bp_id, "runs": runs, "me": me, "te": te}
if st_id:
q["structure_type_id"] = st_id
r = _SESSION.get(EVEREF_COST_URL, params=q, timeout=20)
r.raise_for_status()
payload = r.json()
manuf = (payload.get("manufacturing") or {})
if not manuf:
return {"materials": []}
first_key = next(iter(manuf.keys()))
mats = manuf[first_key].get("materials", {}) or {}
out: List[Dict[str, Any]] = []
for _, row in mats.items():
try:
tid = int(row.get("type_id"))
except Exception:
continue
out.append({
"type_id": tid,
"name": _type_name(tid),
"quantity": row.get("quantity"),
"cost_per_unit": row.get("cost_per_unit"),
"cost": row.get("cost"),
})
out.sort(key=lambda x: (x.get("cost") or 0), reverse=True)
return {"materials": out}

121
webapp/services/cookbook.py Normal file
View File

@@ -0,0 +1,121 @@
from __future__ import annotations
from typing import Any, Dict, List, Tuple, Union
from urllib.parse import urljoin
import requests
from requests.adapters import HTTPAdapter, Retry
__all__ = ["call_cookbook_build_cost", "call_eve_ref_materials"]
_SESSION = requests.Session()
_RETRY = Retry(total=3, connect=3, read=3, backoff_factor=0.3,
status_forcelist=(429,500,502,503,504),
allowed_methods=frozenset(["GET","POST"]))
_ADAPTER = HTTPAdapter(max_retries=_RETRY)
_SESSION.mount("http://", _ADAPTER)
_SESSION.mount("https://", _ADAPTER)
def call_cookbook_build_cost(
blueprint_type_ids: Union[List[str], List[int]],
*,
quantity: int = 1,
price_mode: str = "buy",
additional_costs: int = 0,
base_me: int = 0,
components_me: int = 0,
system: str = "Jita",
facility_tax: int = 0,
industry_structure_type: str = "Station",
industry_rig: str = "None",
reaction_structure_type: str = "Athanor",
reaction_rig: str = "None",
reaction_flag: str = "",
blueprint_version: str = "tq",
timeout: Tuple[float, float] = (5.0, 25.0),
) -> Dict[str, Any]:
base = "https://evecookbook.com/api/"
url = urljoin(base, "buildCost")
params: List[Tuple[str, Any]] = []
for bid in blueprint_type_ids:
params.append(("blueprintTypeId", str(bid)))
params.extend([
("quantity", str(int(quantity))),
("priceMode", price_mode),
("additionalCosts", str(int(additional_costs))),
("baseMe", str(int(base_me))),
("componentsMe", str(int(components_me))),
("system", system),
("facilityTax", str(int(facility_tax))),
("industryStructureType", industry_structure_type),
("industryRig", industry_rig),
("reactionStructureType", reaction_structure_type),
("reactionRig", reaction_rig),
("reactionFlag", reaction_flag),
("blueprintVersion", blueprint_version),
])
headers = {"Accept": "application/json"}
resp = _SESSION.get(url, params=params, headers=headers, timeout=timeout)
resp.raise_for_status()
try:
return resp.json() if resp.content else {}
except Exception:
return {"raw": resp.text}
EVEREF_COST_URL = "https://api.everef.net/v1/industry/cost"
REFDATA_TYPE_URL = "https://ref-data.everef.net/types/{type_id}"
_TYPE_NAME_CACHE: Dict[int, str] = {}
def _type_name(type_id: int) -> str:
if type_id in _TYPE_NAME_CACHE:
return _TYPE_NAME_CACHE[type_id]
try:
r = _SESSION.get(REFDATA_TYPE_URL.format(type_id=type_id), timeout=10)
r.raise_for_status()
data = r.json()
name = (data.get("name", {}) or {}).get("en") or data.get("name") or str(type_id)
except Exception:
name = str(type_id)
_TYPE_NAME_CACHE[type_id] = name
return name
def call_eve_ref_materials(params: Dict[str, Any]) -> Dict[str, Any]:
bp_id = int(params.get("blueprintTypeId"))
runs = int(params.get("quantity", 1))
me = int(params.get("baseMe", 0))
te = int(params.get("baseTe", 0)) if params.get("baseTe") is not None else 0
structure_map = {"Station": None, "Raitaru": 35825, "Azbel": 35826, "Sotiyo": 35827}
st_name = params.get("industryStructureType", "Station")
st_id = structure_map.get(st_name)
q: Dict[str, Any] = {"blueprint_id": bp_id, "runs": runs, "me": me, "te": te}
if st_id:
q["structure_type_id"] = st_id
r = _SESSION.get(EVEREF_COST_URL, params=q, timeout=20)
r.raise_for_status()
payload = r.json()
manuf = (payload.get("manufacturing") or {})
if not manuf:
return {"materials": []}
first_key = next(iter(manuf.keys()))
mats = manuf[first_key].get("materials", {}) or {}
out: List[Dict[str, Any]] = []
for _, row in mats.items():
try:
tid = int(row.get("type_id"))
except Exception:
continue
out.append({
"type_id": tid,
"name": _type_name(tid),
"quantity": row.get("quantity"),
"cost_per_unit": row.get("cost_per_unit"),
"cost": row.get("cost"),
})
out.sort(key=lambda x: (x.get("cost") or 0), reverse=True)
return {"materials": out}

View File

@@ -0,0 +1,2 @@
from .orders import load_orders, add_order, mark_done
__all__ = ["load_orders", "add_order", "mark_done"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

60
webapp/storage/orders.py Normal file
View File

@@ -0,0 +1,60 @@
from __future__ import annotations
import os, json, time
from typing import List, Dict, Any
from flask import current_app
FILE_NAME = "orders.json"
def _file_path() -> str:
return os.path.join(current_app.root_path, "data", FILE_NAME)
def load_orders() -> List[Dict[str, Any]]:
p = _file_path()
if not os.path.isfile(p): return []
try:
with open(p, "r", encoding="utf-8") as f:
return json.load(f) or []
except Exception:
return []
def _save_orders(orders: List[Dict[str, Any]]) -> None:
p = _file_path()
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w", encoding="utf-8") as f:
json.dump(orders, f, ensure_ascii=False, indent=2)
def add_order(order: Dict[str, Any]) -> None:
orders = load_orders()
orders.append(order)
_save_orders(orders)
def mark_done(order_id: str) -> bool:
orders = load_orders()
changed = False
now = time.strftime("%Y-%m-%dT%H:%M:%S%z", time.gmtime())
for o in orders:
if o.get("id") == order_id and o.get("status") != "done":
o["status"] = "done"
o["done_at"] = now
changed = True
if changed: _save_orders(orders)
return changed
def update_order(order_id: str, patch: Dict[str, Any]) -> bool:
orders = load_orders()
ok = False
for o in orders:
if o.get("id") == order_id:
o.update(patch)
ok = True
break
if ok: _save_orders(orders)
return ok
def cache_costs(order_id: str, costs: Dict[str, Any]) -> bool:
now = time.strftime("%Y-%m-%dT%H:%M:%S%z", time.gmtime())
return update_order(order_id, {"cookbook": costs, "last_updated": now})
def cache_materials(order_id: str, mats: Dict[str, Any]) -> bool:
now = time.strftime("%Y-%m-%dT%H:%M:%S%z", time.gmtime())
return update_order(order_id, {"materials": mats, "last_updated": now})

View File

@@ -0,0 +1,175 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EVE Web Helper Archiv</title>
<style>
:root { color-scheme: light dark; }
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;margin:2rem}
.container{max-width:1440px;margin:0 auto}
.nav{display:flex;gap:.6rem;margin-bottom:1rem}
.tab{padding:.5rem .8rem;border:1px solid #e5e7eb;border-radius:.6rem;text-decoration:none;color:#111827}
.tab.active{background:#111827;color:#fff;border-color:#111827}
.card{border:1px solid #e5e7eb;border-radius:.8rem;padding:1rem 1.2rem;margin-bottom:1rem;background:#fff}
.full{width:100%}
.section-title{font-weight:700;margin-bottom:.6rem}
.table-wrap{overflow:auto;border:1px solid #e5e7eb;border-radius:.6rem}
.table-wrap table{width:100%;border-collapse:collapse;margin:0;table-layout:fixed}
th,td{padding:.5rem .45rem;border-bottom:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:break-word;overflow-wrap:anywhere}
th{font-weight:700}
.id{font-family:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;font-size:.85em;max-width:90px}
.num{text-align:right}
.empty{padding:.65rem .8rem;border:1px dashed #d1d5db;border-radius:.6rem;color:#6b7280;background:transparent}
/* Modal (nur lesen) */
.modal{position:fixed;inset:0;display:none;align-items:flex-start;justify-content:center;padding:4vh 1rem;background:rgba(0,0,0,.45);z-index:999}
.modal:target{display:flex}
.dialog{max-width:1100px;width:min(92vw,1100px);background:#fff;border-radius:14px;padding:1rem 1.2rem;box-shadow:0 20px 45px rgba(0,0,0,.35);max-height:92vh;overflow:auto;position:relative}
.close{position:sticky;top:0;float:right;font-size:1.4rem;text-decoration:none;color:#111827;padding:.2rem .5rem;border-radius:.4rem}
.kv{display:grid;grid-template-columns:180px 1fr;gap:.35rem .8rem}
.pill{display:inline-block;background:#111827;color:#fff;border-radius:999px;padding:.2rem .55rem;font-size:.85rem;margin-bottom:.5rem}
.result{border:1px solid #e5e7eb;border-radius:.6rem;padding:.7rem .8rem;background:#fafafa;margin-top:.6rem}
@media (prefers-color-scheme:dark){
body{background:#0b0f19;color:#e5e7eb}
.tab{border-color:#374151;color:#e5e7eb}
.tab.active{background:#1f2937;border-color:#374151}
.card,.dialog{background:#0f1423;border-color:#374151}
th,td{border-bottom-color:#374151}
.table-wrap{border-color:#374151}
.result{background:#111318;border-color:#374151}
.close{color:#e5e7eb}
}
</style>
</head>
<body>
<div class="container">
<nav class="nav">
<a class="tab" href="/">Home</a>
<a class="tab" href="/strukturen">Strukturen</a>
<a class="tab active" href="/archiv">Archiv</a>
</nav>
<section class="card full">
<div class="section-title">Archivierte / fertige Aufträge</div>
{% if archived_orders %}
<div class="table-wrap">
<table aria-label="Archivierte Aufträge">
<thead>
<tr>
<th>ID</th>
<th>Struktur</th>
<th>System</th>
<th>Ind.-Struktur</th>
<th>Ind.-Rig</th>
<th>Reakt.-Struktur</th>
<th>Reakt.-Rig</th>
<th class="num">Menge</th>
<th class="num">ME&nbsp;%</th>
<th>Erstellt</th>
<th>Fertig/Archiv</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for o in archived_orders %}
<tr>
<td class="id">{{ o.id[:8] }}…</td>
<td>{{ o.structure }}</td>
<td>{{ o.system|default('Jita') }}</td>
<td>{{ o.industry_structure|default('Station') }}</td>
<td>{{ o.industry_rig|default('None') }}</td>
<td>{{ o.reaction_structure|default('Athanor') }}</td>
<td>{{ o.reaction_rig|default('None') }}</td>
<td class="num">{{ o.quantity }}</td>
<td class="num">{{ o.me }}</td>
<td>{{ o.created_at|fmt_ts }}</td>
<td>{{ (o.archived_at or o.done_at)|fmt_ts }}</td>
<td><a class="tab" style="padding:.35rem .6rem" href="#m-{{ o.id }}">Details</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">Keine archivierten Aufträge vorhanden.</div>
{% endif %}
</section>
<!-- Readonly-Details pro Auftrag -->
{% for o in archived_orders %}
<div id="m-{{ o.id }}" class="modal" aria-hidden="true">
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="h-{{ o.id }}">
<a class="close" href="#" aria-label="Schließen">×</a>
<h2 id="h-{{ o.id }}">Auftrag {{ o.structure }}</h2>
<div class="kv">
<div><strong>ID</strong></div><div class="id">{{ o.id }}</div>
<div><strong>Status</strong></div><div>{{ o.status }}</div>
<div><strong>Struktur</strong></div><div>{{ o.structure }}</div>
<div><strong>System</strong></div><div>{{ o.system|default('Jita') }}</div>
<div><strong>Industrie-Struktur</strong></div><div>{{ o.industry_structure|default('Station') }}</div>
<div><strong>Industrie-Rig</strong></div><div>{{ o.industry_rig|default('None') }}</div>
<div><strong>Reaktions-Struktur</strong></div><div>{{ o.reaction_structure|default('Athanor') }}</div>
<div><strong>Reaktions-Rig</strong></div><div>{{ o.reaction_rig|default('None') }}</div>
<div><strong>Menge</strong></div><div>{{ o.quantity }}</div>
<div><strong>ME&nbsp;%</strong></div><div>{{ o.me }}</div>
<div><strong>Notizen</strong></div><div>{{ o.notes|default('') }}</div>
<div><strong>Erstellt</strong></div><div>{{ o.created_at|fmt_ts }}</div>
<div><strong>Fertig/Archiv</strong></div><div>{{ (o.archived_at or o.done_at)|fmt_ts }}</div>
<div><strong>Blueprint&nbsp;ID</strong></div><div>{{ o.blueprint_type_id or '—' }}</div>
</div>
{% set cb = o.cookbook and (o.cookbook.message or o.cookbook) %}
{% if cb %}
<div class="pill">Gesamtkosten:
{{ (cb.totalCost or cb.total or cb.buildCostPerUnit) | default('—') }}
</div>
<div class="result">
<details>
<summary>CookbookRohdaten</summary>
<pre style="white-space:pre-wrap">{{ o.cookbook | tojson(indent=2) }}</pre>
</details>
</div>
{% endif %}
{% if o.materials %}
<div class="result">
<h4 style="margin:.2rem 0 .5rem">Materialien</h4>
<table style="width:100%;border-collapse:collapse">
<thead><tr><th>Item</th><th class="num">Menge</th><th class="num">Kosten</th></tr></thead>
<tbody>
{% set mats = o.materials.materials or o.materials.message and o.materials.message.materials or [] %}
{% for m in mats %}
{% set tid = m.type_id or m.typeId %}
{% set name = m.name or ('Type ' ~ tid) %}
{% set qty = m.quantity or m.qty or m.amount or m.count %}
{% set cost = m.cost or m.cost_per_unit or m.unit_cost %}
<tr>
<td>{{ name }} {% if tid %}<span class="id">({{ tid }})</span>{% endif %}</td>
<td class="num">{{ qty }}</td>
<td class="num">{% if cost is not none %}<strong>{{ cost }}</strong>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<details style="margin-top:.4rem">
<summary>MaterialRohdaten</summary>
<pre style="white-space:pre-wrap">{{ o.materials | tojson(indent=2) }}</pre>
</details>
</div>
{% endif %}
<div style="margin-top:.8rem"><a class="tab" href="#" style="padding:.4rem .7rem">Schließen</a></div>
</div>
</div>
{% endfor %}
</div>
</body>
</html>

View File

@@ -0,0 +1,78 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EVE Web Helper {% block title %}{% endblock %}</title>
<style>
:root { color-scheme: light dark; }
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;margin:2rem}
.container{max-width:1440px;margin:0 auto}
/* Einheitliche Tabs */
.nav{display:flex;gap:.6rem;margin-bottom:1.2rem}
.tab{padding:.5rem .8rem;border:1px solid #e5e7eb;border-radius:.6rem;text-decoration:none;color:#111827}
.tab.active{background:#111827;color:#fff;border-color:#111827}
/* Grund-UI */
.card{border:1px solid #e5e7eb;border-radius:.8rem;padding:1rem 1.2rem;margin-bottom:1rem;background:#fff}
.wide{max-width:1200px}
.full{width:100%}
h1{font-size:1.35rem;margin:.2rem 0 1rem}
label{font-weight:600;display:block;margin-bottom:.35rem}
select,input,textarea,button,a.button{padding:.5rem .7rem;border:1px solid #d1d5db;border-radius:.45rem}
textarea{width:100%;min-height:90px;resize:vertical}
button,a.button{background:#111827;color:#fff;cursor:pointer;text-decoration:none;display:inline-block}
.muted{color:#6b7280;font-size:.9rem}
table{width:100%;border-collapse:collapse;table-layout:fixed}
th,td{padding:.5rem .45rem;border-bottom:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:break-word;overflow-wrap:anywhere}
th{font-weight:700}
.id{font-family:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;font-size:.85em;max-width:90px}
.num{text-align:right}
.actions form{display:inline}
.empty{padding:.65rem .8rem;border:1px dashed #d1d5db;border-radius:.6rem;color:#6b7280;background:transparent}
.table-wrap{overflow:auto;border:1px solid #e5e7eb;border-radius:.6rem}
.table-wrap thead th{position:sticky;top:0;background:#fff;z-index:1}
/* Modal (global) */
.modal{position:fixed;inset:0;display:none;align-items:flex-start;justify-content:center;padding:4vh 1rem;background:rgba(0,0,0,.45);z-index:999}
.modal:target{display:flex}
.dialog{max-width:1100px;width:min(92vw,1100px);background:#fff;border-radius:14px;padding:1rem 1.2rem;box-shadow:0 20px 45px rgba(0,0,0,.35);max-height:92vh;overflow:auto;position:relative;z-index:1000;pointer-events:auto}
.modal *{pointer-events:auto}
.modal h2{margin:.2rem 0 1rem;font-size:1.2rem}
.close{position:sticky;top:0;float:right;font-size:1.4rem;text-decoration:none;color:#111827;padding:.2rem .5rem;border-radius:.4rem;z-index:2}
.kv{display:grid;grid-template-columns:180px 1fr;gap:.35rem .8rem}
.pill{display:inline-block;background:#111827;color:#fff;border-radius:999px;padding:.2rem .55rem;font-size:.85rem}
.result{border:1px solid #e5e7eb;border-radius:.6rem;padding:.7rem .8rem;background:#fafafa;margin-top:.6rem}
.err{color:#b91c1c}
@media (prefers-color-scheme:dark){
body{background:#0b0f19;color:#e5e7eb}
.tab{border-color:#374151;color:#e5e7eb}
.tab.active{background:#1f2937;border-color:#374151}
.card,.dialog{background:#0f1423;border-color:#374151}
th,td{border-bottom-color:#374151}
.table-wrap{border-color:#374151}
.table-wrap thead th{background:#0f1423}
.result{background:#111318;border-color:#374151}
.close{color:#e5e7eb}
}
</style>
{% block head_extra %}{% endblock %}
</head>
<body>
<div class="container">
<nav class="nav">
{% set p = request.path %}
<a class="tab {{ 'active' if p == '/' else '' }}" href="/">Home</a>
<a class="tab {{ 'active' if p.startswith('/strukturen') else '' }}" href="/strukturen">Strukturen</a>
<a class="tab {{ 'active' if p.startswith('/archiv') else '' }}" href="/archiv">Archiv</a>
</nav>
{% block content %}{% endblock %}
</div>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block title %}Home{% endblock %}
{% block content %}
<h1>EVE Web Helper</h1>
<p>Willkommen! Nutze die Tabs oben, um zu den Strukturen oder zum Archiv zu wechseln.</p>
{% endblock %}

View File

@@ -0,0 +1,440 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EVE Web Helper Materialien</title>
<style>
:root { color-scheme: light dark; }
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;margin:2rem}
.container{max-width:1440px;margin:0 auto}
.nav{display:flex;gap:.6rem;margin-bottom:1rem}
.tab{padding:.5rem .8rem;border:1px solid #e5e7eb;border-radius:.6rem;text-decoration:none;color:#111827}
.tab.struct{background:#111827;color:#fff;border-color:#111827}
/* Unterreiter links */
.subtabs-left{position:fixed;left:.8rem;top:96px;display:flex;flex-direction:column;gap:.5rem;
width:190px;padding:.6rem;border:1px solid #e5e7eb;border-radius:.6rem;background:#fff;
box-shadow:0 2px 8px rgba(0,0,0,.05);z-index:50}
.subtabs-left a{display:block;text-decoration:none;padding:.5rem .6rem;border:1px solid #e5e7eb;border-radius:.6rem;color:#111827;background:#fff}
.subtabs-left a.active{background:#111827;color:#fff;border-color:#111827}
@media (max-width:1200px){.subtabs-left{display:none}}
.row{display:flex;gap:.6rem;flex-wrap:wrap;align-items:center}
.select, .button, input[type="number"]{padding:.5rem .7rem;border:1px solid #d1d5db;border-radius:.45rem}
.button{background:#111827;color:#fff;cursor:pointer}
.muted{color:#6b7280;font-size:.9rem}
.bad{color:#b91c1c}
.card{border:1px solid #e5e7eb;border-radius:.8rem;padding:1rem 1.2rem;margin-bottom:1rem;background:#fff}
.table-wrap{overflow:auto;border:1px solid #e5e7eb;border-radius:.6rem}
table{width:100%;border-collapse:collapse}
th,td{padding:.5rem .45rem;border-bottom:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:break-word}
th{font-weight:700}
.num{text-align:right}
details{border:1px solid #e5e7eb;border-radius:.6rem;margin:.6rem 0;background:#fff}
details>summary{cursor:pointer;padding:.5rem .6rem;font-weight:600}
details .inner{padding:.5rem .6rem;border-top:1px solid #e5e7eb}
@media (prefers-color-scheme:dark){
body{background:#0b0f19;color:#e5e7eb}
.tab{border-color:#374151;color:#e5e7eb}
.card,details{background:#0f1423;border-color:#374151}
th,td{border-bottom-color:#374151}
.table-wrap{border-color:#374151}
.button{background:#1f2937;border-color:#374151}
.subtabs-left{background:#0f1423;border-color:#374151}
.subtabs-left a{color:#e5e7eb;border-color:#374151;background:#0f1423}
.subtabs-left a.active{background:#1f2937}
details .inner{border-top-color:#374151}
.select, input, .button{border-color:#374151}
}
</style>
</head>
<body>
<nav class="subtabs-left" aria-label="Unterreiter">
<a href="{{ url_for('structures.structures') }}">Aufträge</a>
<a class="active" href="{{ url_for('structures.mineralien') }}">Mineralien</a>
</nav>
<div class="container">
<nav class="nav">
<a class="tab" href="/">Home</a>
<a class="tab struct" href="{{ url_for('structures.structures') }}">Strukturen</a>
<a class="tab" href="/archiv">Archiv</a>
</nav>
<section class="card">
<div class="row" style="justify-content:space-between">
<h1 style="margin:0">Material-Übersicht (aus offenen Aufträgen)</h1>
<div class="row">
<label class="muted">Ansicht:
<select id="view-mode" class="select">
<option value="all" selected>Alle Materialien</option>
<option value="minerals">Nur Mineralien</option>
</select>
</label>
<label class="muted" id="morphite-wrap" style="display:none">
<input type="checkbox" id="include-morphite" checked> Morphite einschließen
</label>
<button id="btn-refresh" class="button" type="button" onclick="refreshAll()">Aktualisieren</button>
<button id="btn-csv" class="button" type="button" onclick="exportCSV()">CSV</button>
</div>
</div>
<!-- Mini-Einstellungen NUR für Mineralien-Abfragen (persistiert via localStorage) -->
<details class="card" style="margin-top:.8rem">
<summary>Einstellungen für Berechnung (werden gespeichert)</summary>
<div class="row" style="margin-top:.6rem">
<label class="muted">System
<select id="set-system" class="select" style="min-width:12rem">
<option>Jita</option>
<option>Perimeter</option>
<option>Amarr</option>
<option>Dodixie</option>
<option>Rens</option>
<option>Hek</option>
</select>
</label>
<label class="muted">ME % (Blueprint)
<input id="set-baseMe" type="number" min="0" max="10" step="1" class="select" style="width:6rem" />
</label>
<label class="muted">Steuersatz % (facilityTax)
<input id="set-facilityTax" type="number" min="0" max="100" step="1" class="select" style="width:7rem" />
</label>
<button class="button" type="button" onclick="saveSettingsFromForm()">Speichern</button>
</div>
<p class="muted" style="margin:.6rem 0 0">Eine ME-Einstellung reicht sie wird auch für Komponenten verwendet. Werte werden im Browser gespeichert.</p>
</details>
<p class="muted">Wir lösen die Zwischenprodukte in <em>Blatt-Materialien</em> auf (nur echte Eingänge, keine Doppelzählung).</p>
<div id="box" class="card" style="padding:.6rem 1rem;margin:.8rem 0">Lade Materialien …</div>
<!-- 1) Pro-Item-Aufschlüsselung -->
<div id="wrap-breakdown" style="display:none">
<h3>Aufschlüsselung pro Zwischenprodukt</h3>
<div id="breakdown"></div>
</div>
<!-- 2) Gesamtübersicht -->
<div id="wrap-total" style="display:none;margin-top:1rem">
<h3>Gesamtübersicht (aggregiert)</h3>
<div class="table-wrap">
<table aria-label="Materialien Gesamt">
<thead>
<tr><th>Item</th><th class="num">Gesamtmenge</th><th class="num">Ø Preis / Einh.</th><th class="num">Gesamtkosten</th></tr>
</thead>
<tbody id="tbody-total"></tbody>
<tfoot>
<tr><th colspan="3" class="num">Summe</th><th class="num"><strong id="sum-total">0 ISK</strong></th></tr>
</tfoot>
</table>
</div>
<div class="muted" id="ts" style="margin-top:.4rem"></div>
</div>
</section>
</div>
<script>
/* ===== Persistente Einstellungen NUR für Mineralien ===== */
const SETTINGS_KEY = "sp_global_mats_settings";
// nur 1 ME, System & facilityTax
let SETTINGS = { system:"Jita", baseMe:0, facilityTax:0 };
function loadLocalSettings(){
try{
const raw = localStorage.getItem(SETTINGS_KEY);
if(raw){
const s = JSON.parse(raw);
if(s && typeof s === 'object'){
// migriert alte Objekte (componentsMe ggf. vorhanden -> ignoriert)
SETTINGS.system = s.system ?? SETTINGS.system;
SETTINGS.baseMe = (s.baseMe ?? s.componentsMe ?? SETTINGS.baseMe);
SETTINGS.facilityTax = s.facilityTax ?? SETTINGS.facilityTax;
}
}
}catch(_){}
// UI füllen
document.getElementById('set-system').value = SETTINGS.system;
document.getElementById('set-baseMe').value = SETTINGS.baseMe;
document.getElementById('set-facilityTax').value = SETTINGS.facilityTax;
}
function saveLocalSettings(){
localStorage.setItem(SETTINGS_KEY, JSON.stringify(SETTINGS));
}
function saveSettingsFromForm(){
SETTINGS.system = document.getElementById('set-system').value || SETTINGS.system;
SETTINGS.baseMe = parseInt(document.getElementById('set-baseMe').value || SETTINGS.baseMe, 10);
SETTINGS.facilityTax = parseInt(document.getElementById('set-facilityTax').value || SETTINGS.facilityTax, 10);
saveLocalSettings();
loadMaterialsDeep(); // neu berechnen
}
/* ===== Konstanten / Helfer ===== */
const MINERAL_TYPES={34:"Tritanium",35:"Pyerite",36:"Mexallon",37:"Isogen",38:"Nocxium",39:"Zydrine",40:"Megacyte",11399:"Morphite"};
const MINERAL_ORDER=[34,35,36,37,38,39,40,11399];
const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
function formatISK(x){const n=Number(x);if(!isFinite(n))return String(x);return n.toLocaleString('de-DE')+" ISK";}
function safeNum(v){const n=Number(v);return isFinite(n)?n:0;}
function formatTS(s){if(!s)return"";const d=new Date(s);if(isNaN(d.getTime())){const t=String(s);return t.slice(0,16);}const y=d.getFullYear(),m=String(d.getMonth()+1).padStart(2,'0'),da=String(d.getDate()).padStart(2,'0'),hh=String(d.getHours()).padStart(2,'0'),mm=String(d.getMinutes()).padStart(2,'0');return `${y}-${m}-${da} ${hh}:${mm}`;}
function looksLikeMatRow(o){
if(!o||typeof o!=='object')return false;
const hasId=('type_id'in o)||('typeId'in o);
const hasQty=('quantity'in o)||('qty'in o)||('amount'in o)||('count'in o);
return hasId && hasQty;
}
function hasChildren(o){
for(const k of ['materials','components','inputs','children','parts','steps','subMaterials']){
if(Array.isArray(o[k]) && o[k].length) return true;
}
return false;
}
// sammelt NUR Blatt-Materialien
function collectLeafMatRowsDeep(x,out){
if(!x) return;
if(Array.isArray(x)){ for(const v of x) collectLeafMatRowsDeep(v,out); return; }
if(typeof x==='object'){
if(looksLikeMatRow(x) && !hasChildren(x)) out.push(x);
for(const v of Object.values(x)) collectLeafMatRowsDeep(v,out);
}
}
function normalizeRows(rows){
return rows.map(r=>{
const id=Number(r.type_id??r.typeId??0);
const name=r.name??r.item_name??r.typeName??("Type "+id);
const qty=safeNum(r.quantity??r.qty??r.amount??r.count);
const cost=safeNum(r.cost??r.total_cost);
const unit=safeNum(r.unit_cost??r.cost_per_unit??(qty?cost/qty:0));
return {type_id:id,name,quantity:qty,cost,unit};
});
}
function isMineralId(id){return MINERAL_ORDER.includes(Number(id));}
function filterByViewMode(normRows){
const mode=document.getElementById('view-mode').value;
if(mode==='minerals'){
const includeMorphite=document.getElementById('include-morphite').checked;
const allowed=new Set(includeMorphite?MINERAL_ORDER:MINERAL_ORDER.filter(x=>x!==11399));
return normRows.filter(r=>allowed.has(r.type_id));
}
return normRows; // 'all'
}
function aggregateRows(rows){
const map=new Map();
for(const r of rows){
const k=r.type_id;
if(!map.has(k)) map.set(k,{type_id:k,name:r.name,quantity:0,cost:0,unitSum:0,unitWeight:0});
const a=map.get(k);
a.quantity+=safeNum(r.quantity);
a.cost+=safeNum(r.cost);
if(r.unit>0 && r.quantity>0){a.unitSum+=r.unit*r.quantity; a.unitWeight+=r.quantity;}
}
const all=[...map.values()];
const minerals=all.filter(x=>isMineralId(x.type_id)).sort((a,b)=>MINERAL_ORDER.indexOf(a.type_id)-MINERAL_ORDER.indexOf(b.type_id));
const others=all.filter(x=>!isMineralId(x.type_id)).sort((a,b)=>a.name.localeCompare(b.name,'de'));
return [...minerals,...others].map(a=>{
const avg=a.unitWeight>0 ? a.unitSum/a.unitWeight : (a.quantity ? a.cost/a.quantity : 0);
return {type_id:a.type_id,name:a.name,quantity:a.quantity,unit:avg,cost:a.cost};
});
}
/* ===== API + Blueprint-Lookup ===== */
async function fetchJSON(url,{retries=2,backoff=700}={}){let last=null;for(let i=0;i<=retries;i++){try{const r=await fetch(url);const retr=r.status>=500;if(!r.ok){const t=await r.text();last=new Error(`HTTP ${r.status} ${t.slice(0,200)}`);if(!retr)throw last;}else{return await r.json();}}catch(e){last=e;}if(i<retries)await sleep(backoff*Math.pow(1.6,i));}throw last||new Error('Unbekannter Fehler');}
function q(obj){const p=new URLSearchParams();for(const [k,v] of Object.entries(obj)){if(v!==undefined&&v!==null)p.append(k,String(v));}return p.toString();}
const BP_CACHE=new Map();
async function getBlueprintId(productName, productTypeId){
const key=(productName||"")+":"+String(productTypeId||"");
if(BP_CACHE.has(key)) return BP_CACHE.get(key);
const url="/api/blueprint_id?"+q({name:productName,productTypeId:productTypeId});
const j=await fetchJSON(url,{retries:1,backoff:400});
const id=j && j.blueprint_type_id;
if(!id) throw new Error("Blueprint-ID nicht gefunden");
BP_CACHE.set(key,id);
return id;
}
// *** eine ME-Einstellung -> für baseMe UND componentsMe nutzen ***
async function callMaterialsEndpoint(blueprintTypeId, qty){
const params={
blueprintTypeId,
quantity:qty,
priceMode:"buy",
baseMe:SETTINGS.baseMe,
componentsMe:SETTINGS.baseMe, // gleicher Wert
system:SETTINGS.system,
facilityTax:SETTINGS.facilityTax
};
return await fetchJSON("/api/cookbook/materials?"+q(params),{retries:2,backoff:800});
}
async function callBuildCostEndpoint(blueprintTypeId, qty){
const params={
blueprintTypeId,
quantity:qty,
priceMode:"buy",
baseMe:SETTINGS.baseMe,
componentsMe:SETTINGS.baseMe, // gleicher Wert
system:SETTINGS.system,
facilityTax:SETTINGS.facilityTax,
detailed:"Yes",steps:"Yes",showDetailed:"Yes",showDetailedBuildSteps:"Yes"
};
return await fetchJSON("/api/cookbook/build_cost?"+q(params),{retries:2,backoff:900});
}
async function loadLeafMaterialsFor(productName, productTypeId, qty){
try{
const bpId=await getBlueprintId(productName, productTypeId);
let rows=[], r=await callMaterialsEndpoint(bpId, qty);
collectLeafMatRowsDeep(r, rows);
if(!rows.length){ r=await callBuildCostEndpoint(bpId, qty); collectLeafMatRowsDeep(r, rows); }
return rows;
}catch(e){ console.warn("Materialermittlung fehlgeschlagen:", e); return []; }
}
/* ===== Rendering ===== */
function renderTotal(rows, metaTs){
const tbody=document.getElementById('tbody-total');
const wrap=document.getElementById('wrap-total');
const box=document.getElementById('box');
const sumEl=document.getElementById('sum-total');
const ts=document.getElementById('ts');
if(!rows.length){ box.textContent='Keine Materialien ermittelt.'; wrap.style.display='none'; return; }
tbody.innerHTML=rows.map(r=>`
<tr>
<td>${r.name} <span class="muted">(${r.type_id})</span></td>
<td class="num">${r.quantity.toLocaleString('de-DE')}</td>
<td class="num">${r.unit?formatISK(r.unit):'—'}</td>
<td class="num"><strong>${formatISK(r.cost)}</strong></td>
</tr>`).join('');
const total=rows.reduce((s,r)=>s+safeNum(r.cost),0);
sumEl.textContent=formatISK(total);
ts.textContent = metaTs ? ('Stand: '+formatTS(metaTs)) : '';
box.style.display='none';
wrap.style.display='block';
}
function renderBreakdown(perItem){
const host=document.getElementById('breakdown');
const wrap=document.getElementById('wrap-breakdown');
if(!perItem.length){ wrap.style.display='none'; return; }
host.innerHTML=perItem.map(it=>{
const inner = it.rows.length
? `<div class="table-wrap">
<table>
<thead><tr><th>Item</th><th class="num">Menge</th><th class="num">Ø Preis / Einh.</th><th class="num">Kosten</th></tr></thead>
<tbody>
${it.rows.map(r=>`<tr>
<td>${r.name} <span class="muted">(${r.type_id})</span></td>
<td class="num">${r.quantity.toLocaleString('de-DE')}</td>
<td class="num">${r.unit?formatISK(r.unit):'—'}</td>
<td class="num">${formatISK(r.cost)}</td>
</tr>`).join('')}
</tbody>
</table>
</div>`
: `<div class="muted inner">Keine (Blatt-)Materialien benötigt oder nicht ermittelbar.</div>`;
const err = it.error ? `<div class="bad inner">Fehler: ${it.error}</div>` : '';
return `<details>
<summary>${it.label} <span class="muted">(${it.type_id})</span> Menge: ${it.qty.toLocaleString('de-DE')}</summary>
<div class="inner">${inner}${err}</div>
</details>`;
}).join('');
wrap.style.display='block';
}
/* ===== Hauptlogik ===== */
async function loadMaterialsDeep(){
const box=document.getElementById('box');
const totalWrap=document.getElementById('wrap-total');
const breakdownWrap=document.getElementById('wrap-breakdown');
box.textContent='Lade offene Materialien …';
box.style.display='block';
totalWrap.style.display='none';
breakdownWrap.style.display='none';
try{
const open=await fetchJSON("/api/cookbook/open_materials",{retries:1,backoff:500});
const items=Array.isArray(open.items)?open.items:[];
if(!items.length){ box.textContent='Keine offenen Zwischenprodukte vorhanden.'; return; }
let done=0; const progress=()=> box.textContent=`Ermittle Eingangs-Materialien … (${done}/${items.length})`;
const perItem=[]; // für Breakdown
const allRows=[]; // für Gesamt
for(const it of items){
progress();
const typeId=it.type_id??it.typeId;
const qty=safeNum(it.quantity??it.qty??it.amount??0);
const label=(it.name??it.item_name??it.typeName??("Type "+typeId));
if(!typeId || qty<=0){ done++; continue; }
try{
const rowsRaw = await loadLeafMaterialsFor(label, typeId, qty); // echte Blätter
const norm = normalizeRows(rowsRaw);
const filtered= filterByViewMode(norm);
const aggr = aggregateRows(filtered);
perItem.push({type_id:typeId,label,qty,rows:aggr});
aggr.forEach(r=>allRows.push(r));
}catch(err){
perItem.push({type_id:typeId,label,qty,rows:[],error:String(err && err.message ? err.message : err)});
}
done++; progress();
await sleep(300); // Upstream schonen
}
// 1) Breakdown pro Item
renderBreakdown(perItem);
// 2) Gesamt-Übersicht
const total = aggregateRows(allRows);
renderTotal(total, open.refreshed_at);
}catch(err){
box.innerHTML = `<span class="bad">Fehler: ${err && err.message ? err.message : err}</span>`;
}
}
function refreshAll(){
const b=document.getElementById("btn-refresh"); if(b) b.disabled=true;
fetch("/api/orders/refresh_all",{method:"POST"})
.then(()=>loadMaterialsDeep())
.finally(()=>{ if(b) b.disabled=false; });
}
function exportCSV(){
const rows=[["Item","Gesamtmenge","Ø Preis / Einh.","Gesamtkosten"]];
document.querySelectorAll("#tbody-total tr").forEach(tr=>{
const tds=[...tr.querySelectorAll("td")].map(td=>td.textContent);
rows.push(tds);
});
const csv=rows.map(r=>r.map(v=>`"${String(v).replace(/"/g,'""')}"`).join(";")).join("\r\n");
const blob=new Blob([csv],{type:"text/csv;charset=utf-8;"});
const a=document.createElement("a"); a.href=URL.createObjectURL(blob); a.download="materialien_summe.csv"; a.click(); URL.revokeObjectURL(a.href);
}
document.addEventListener("DOMContentLoaded",()=>{
loadLocalSettings(); // Einstellungen laden
const modeSel=document.getElementById('view-mode');
const morphiteWrap=document.getElementById('morphite-wrap');
modeSel.addEventListener('change',()=>{
morphiteWrap.style.display = modeSel.value==='minerals' ? 'inline-flex' : 'none';
loadMaterialsDeep();
});
document.getElementById('include-morphite')?.addEventListener('change', loadMaterialsDeep);
morphiteWrap.style.display = modeSel.value==='minerals' ? 'inline-flex' : 'none';
loadMaterialsDeep();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,537 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EVE Web Helper Strukturen</title>
<style>
:root { color-scheme: light dark; }
html{scroll-behavior:smooth}
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;margin:2rem}
.container{max-width:1440px;margin:0 auto}
/* Haupt-Tabs oben */
.nav{display:flex;gap:.6rem;margin-bottom:1rem}
.tab{padding:.5rem .8rem;border:1px solid #e5e7eb;border-radius:.6rem;text-decoration:none;color:#111827}
.tab.active{background:#111827;color:#fff;border-color:#111827}
/* Linke Unterreiter (untergeordnete Seite) */
.subtabs-left{
position:fixed; left:.8rem; top:96px; display:flex; flex-direction:column; gap:.5rem;
width:170px; padding:.6rem; border:1px solid #e5e7eb; border-radius:.6rem; background:#fff;
box-shadow:0 2px 8px rgba(0,0,0,.05); z-index:50
}
.subtabs-left a{display:block;text-decoration:none;padding:.5rem .6rem;border:1px solid #e5e7eb;border-radius:.6rem;color:#111827;background:#fff}
.subtabs-left a.active{background:#111827;color:#fff;border-color:#111827}
@media (max-width:1200px){.subtabs-left{display:none}}
.card{border:1px solid #e5e7eb;border-radius:.8rem;padding:1rem 1.2rem;margin-bottom:1rem;background:#fff}
.wide{max-width:1200px}
.full{width:100%}
h1{font-size:1.35rem;margin:.2rem 0 1rem}
label{font-weight:600;display:block;margin-bottom:.35rem}
select,input,textarea,button,a.button{padding:.5rem .7rem;border:1px solid #d1d5db;border-radius:.45rem}
textarea{width:100%;min-height:90px;resize:vertical}
button,a.button{background:#111827;color:#fff;cursor:pointer;text-decoration:none;display:inline-block}
.muted{color:#6b7280;font-size:.9rem}
.row{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.8rem;margin-top:.8rem}
@media (max-width:1000px){.row{grid-template-columns:1fr 1fr}}
table{width:100%;border-collapse:collapse;table-layout:fixed}
th,td{padding:.5rem .45rem;border-bottom:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:break-word;overflow-wrap:anywhere}
th{font-weight:700}
.id{font-family:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;font-size:.85em;max-width:90px}
.num{text-align:right}
.actions form{display:inline}
.section-title{font-weight:700;margin-bottom:.6rem}
.empty{padding:.65rem .8rem;border:1px dashed #d1d5db;border-radius:.6rem;color:#6b7280;background:transparent}
.table-wrap{overflow:auto;border:1px solid #e5e7eb;border-radius:.6rem}
.table-wrap thead th{position:sticky;top:0;background:#fff;z-index:1}
.scroll-5{max-height:260px}
@media (max-width:1600px){ .col-notes{display:none} }
@media (max-width:1450px){ .col-react-rig{display:none} }
@media (max-width:1350px){ .col-ind-rig{display:none} }
@media (max-width:1250px){ .col-ind-struct,.col-react-struct{display:none} }
/* Modal */
.modal{position:fixed;inset:0;display:none;align-items:flex-start;justify-content:center;padding:4vh 1rem;background:rgba(0,0,0,.45);z-index:999}
.modal:target{display:flex}
.dialog{max-width:1100px;width:min(92vw,1100px);background:#fff;border-radius:14px;padding:1rem 1.2rem;box-shadow:0 20px 45px rgba(0,0,0,.35);max-height:92vh;overflow:auto;position:relative;z-index:1000;pointer-events:auto}
.modal *{pointer-events:auto}
.modal h2{margin:.2rem 0 1rem;font-size:1.2rem}
.close{position:sticky;top:0;float:right;font-size:1.4rem;text-decoration:none;color:#111827;padding:.2rem .5rem;border-radius:.4rem;z-index:2}
.kv{display:grid;grid-template-columns:180px 1fr;gap:.35rem .8rem}
hr.sep{border:none;border-top:1px solid #e5e7eb;margin:.8rem 0}
.pill{display:inline-block;background:#111827;color:#fff;border-radius:999px;padding:.2rem .55rem;font-size:.85rem}
.result{border:1px solid #e5e7eb;border-radius:.6rem;padding:.7rem .8rem;background:#fafafa;margin-top:.6rem}
.err{color:#b91c1c}
@media (prefers-color-scheme:dark){
body{color:#e5e7eb;background:#0b0f19}
.tab{border-color:#374151;color:#e5e7eb}
.tab.active{background:#1f2937;border-color:#374151}
.card,.dialog,.subtabs-left{background:#0f1423;border-color:#374151}
th,td{border-bottom-color:#374151}
.table-wrap{border-color:#374151}
.table-wrap thead th{background:#0f1423}
.result{background:#111318;border-color:#374151}
.close{color:#e5e7eb}
.subtabs-left a{color:#e5e7eb;border-color:#374151;background:#0f1423}
.subtabs-left a.active{background:#1f2937}
}
body{overflow-x:hidden}
</style>
</head>
<body>
<!-- Unterreiter links AKTIV-Status serverseitig über request.path -->
<nav class="subtabs-left" aria-label="Unterreiter">
<a href="/strukturen"
class="{{ 'active' if request.path == '/strukturen' else '' }}">Aufträge</a>
<a href="/strukturen/mineralien"
class="{{ 'active' if request.path.startswith('/strukturen/mineralien') else '' }}">Mineralien</a>
</nav>
<div class="container">
<nav class="nav">
<a class="tab" href="/">Home</a>
<a class="tab active" href="/strukturen">Strukturen</a>
<a class="tab" href="/archiv">Archiv</a>
</nav>
<!-- Formular -->
<section class="card wide">
<h1>Struktur-Auftrag anlegen</h1>
<form method="post">
<div class="row">
<div>
<label for="structure-select">Struktur</label>
<select id="structure-select" name="structure" required>
<option value="" disabled selected>— bitte wählen —</option>
{% for s in structures %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
</select>
</div>
<div>
<label for="quantity">Menge</label>
<input id="quantity" name="quantity" type="number" min="1" step="1" value="{{ quantity or 1 }}" required />
</div>
<div>
<label for="me">ME&nbsp;%</label>
<input id="me" name="me" type="number" min="0" max="10" step="1" value="{{ me_percent or 0 }}" />
</div>
<div>
<label for="system">System</label>
<select id="system" name="system">
{% for sys in systems %}<option value="{{ sys }}" {{ 'selected' if selected_system == sys else '' }}>{{ sys }}</option>{% endfor %}
</select>
</div>
</div>
<div class="row">
<div>
<label for="industry_structure">Industrie-Struktur</label>
<select id="industry_structure" name="industry_structure">
{% for x in ind_structs %}<option value="{{ x }}" {{ 'selected' if selected_ind_struct == x else '' }}>{{ x }}</option>{% endfor %}
</select>
</div>
<div>
<label for="industry_rig">Industrie-Rig</label>
<select id="industry_rig" name="industry_rig">
{% for x in ind_rigs %}<option value="{{ x }}" {{ 'selected' if selected_ind_rig == x else '' }}>{{ x }}</option>{% endfor %}
</select>
</div>
<div>
<label for="reaction_structure">Reaktions-Struktur</label>
<select id="reaction_structure" name="reaction_structure">
{% for x in reac_structs %}<option value="{{ x }}" {{ 'selected' if selected_reac_struct == x else '' }}>{{ x }}</option>{% endfor %}
</select>
</div>
<div>
<label for="reaction_rig">Reaktions-Rig</label>
<select id="reaction_rig" name="reaction_rig">
{% for x in reac_rigs %}<option value="{{ x }}" {{ 'selected' if selected_reac_rig == x else '' }}>{{ x }}</option>{% endfor %}
</select>
</div>
</div>
<div class="row">
<div>
<label for="facility_tax">Steuersatz&nbsp;% (facilityTax)</label>
<input id="facility_tax" name="facility_tax" type="number" min="0" max="50" step="0.1" value="0" />
</div>
<div style="grid-column: span 3">
<label for="notes">Notizen</label>
<textarea id="notes" name="notes" placeholder="Optionale Notizen zum Auftrag (max. ~2000 Zeichen)"></textarea>
<div class="muted" style="margin-top:.4rem">ME in 1er Schritten (010). Steuersatz in Prozent.</div>
</div>
</div>
<button type="submit" style="margin-top:.8rem">Auftrag hinzufügen</button>
</form>
</section>
<!-- Offene Aufträge -->
<section class="card full">
<div class="section-title">Offene Aufträge</div>
{% if open_orders %}
<div class="table-wrap scroll-5">
<table aria-label="Offene Aufträge">
<thead>
<tr>
<th>ID</th>
<th>Struktur</th>
<th>System</th>
<th class="col-ind-struct">Ind.-Struktur</th>
<th class="col-ind-rig">Ind.-Rig</th>
<th class="col-react-struct">Reakt.-Struktur</th>
<th class="col-react-rig">Reakt.-Rig</th>
<th class="num">Menge</th>
<th class="num">ME&nbsp;%</th>
<th class="col-notes">Notizen</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for o in open_orders %}
<tr>
<td class="id">{{ o.id[:8] }}…</td>
<td>{{ o.structure }}</td>
<td>{{ o.system|default('Jita') }}</td>
<td class="col-ind-struct">{{ o.industry_structure|default('Station') }}</td>
<td class="col-ind-rig">{{ o.industry_rig|default('None') }}</td>
<td class="col-react-struct">{{ o.reaction_structure|default('Athanor') }}</td>
<td class="col-react-rig">{{ o.reaction_rig|default('None') }}</td>
<td class="num">{{ o.quantity }}</td>
<td class="num">{{ o.me }}</td>
<td class="col-notes">{{ o.notes|default('') }}</td>
<td>{{ o.created_at|fmt_ts }}</td>
<td class="actions">
<a class="button" href="#m-{{ o.id }}">Details</a>
<button type="button" onclick="refreshOne('{{ o.id }}')">Aktualisieren</button>
<form method="post" action="/strukturen/done/{{ o.id }}"><button type="submit">Fertig</button></form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">Keine offenen Aufträge.</div>
{% endif %}
</section>
<!-- Fertige Aufträge -->
<section class="card full">
<div class="section-title">Fertige Aufträge</div>
{% if done_orders %}
<div class="table-wrap scroll-5">
<table aria-label="Fertige Aufträge">
<thead>
<tr>
<th>ID</th>
<th>Struktur</th>
<th>System</th>
<th class="col-ind-struct">Ind.-Struktur</th>
<th class="col-ind-rig">Ind.-Rig</th>
<th class="col-react-struct">Reakt.-Struktur</th>
<th class="col-react-rig">Reakt.-Rig</th>
<th class="num">Menge</th>
<th class="num">ME&nbsp;%</th>
<th class="col-notes">Notizen</th>
<th>Erstellt</th>
<th>Fertig am</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for o in done_orders %}
<tr>
<td class="id">{{ o.id[:8] }}…</td>
<td>{{ o.structure }}</td>
<td>{{ o.system|default('Jita') }}</td>
<td class="col-ind-struct">{{ o.industry_structure|default('Station') }}</td>
<td class="col-ind-rig">{{ o.industry_rig|default('None') }}</td>
<td class="col-react-struct">{{ o.reaction_structure|default('Athanor') }}</td>
<td class="col-react-rig">{{ o.reaction_rig|default('None') }}</td>
<td class="num">{{ o.quantity }}</td>
<td class="num">{{ o.me }}</td>
<td class="col-notes">{{ o.notes|default('') }}</td>
<td>{{ o.created_at|fmt_ts }}</td>
<td>{{ o.done_at|fmt_ts }}</td>
<td class="actions">
<a class="button" href="#m-{{ o.id }}">Details</a>
<form method="post" action="/archiv/add/{{ o.id }}"><button type="submit">Archivieren</button></form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">Noch keine fertigen Aufträge.</div>
{% endif %}
</section>
<!-- Kostenübersicht -->
<section class="card">
<div class="section-title" style="display:flex;justify-content:space-between;align-items:center">
<span>Kostenübersicht Offene Aufträge</span>
<button id="btn-refresh-all" type="button" onclick="refreshAll()">Aktualisieren</button>
</div>
<div id="costs-box" class="empty">Lade Kosten …</div>
<div id="costs-table" style="display:none">
<div class="table-wrap">
<table aria-label="Kostenübersicht">
<thead>
<tr>
<th>Struktur</th><th class="num">M</th><th class="num">ME</th><th>System</th><th>Industrie</th><th>Reaktion</th><th class="num">€/E</th><th class="num">Gesamt</th>
</tr>
</thead>
<tbody id="costs-body"></tbody>
<tfoot>
<tr><th colspan="7" class="num">Summe</th><th class="num"><strong id="costs-total">0 ISK</strong></th></tr>
</tfoot>
</table>
</div>
<div class="muted" id="costs-ts" style="margin-top:.4rem"></div>
</div>
</section>
<!-- Materialübersicht -->
<section class="card">
<div class="section-title" style="display:flex;justify-content:space-between;align-items:center">
<span>Materialübersicht Offene Aufträge</span>
<button id="btn-refresh-mats" type="button" onclick="refreshAll()">Aktualisieren</button>
</div>
<div id="mats-box" class="empty">Lade Materialien …</div>
<div id="mats-table" style="display:none">
<div class="table-wrap">
<table aria-label="Materialübersicht">
<thead>
<tr><th>Item</th><th class="num">Menge</th><th class="num">Kosten</th></tr>
</thead>
<tbody id="mats-body"></tbody>
<tfoot>
<tr><th colspan="2" class="num">Gesamtkosten</th><th class="num"><strong id="mats-total">0 ISK</strong></th></tr>
</tfoot>
</table>
</div>
<div class="muted" id="mats-ts" style="margin-top:.4rem"></div>
</div>
</section>
<!-- Modals -->
{% for o in open_orders + done_orders %}
<div id="m-{{ o.id }}" class="modal" aria-hidden="true">
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="h-{{ o.id }}">
<a class="close" href="#" aria-label="Schließen">×</a>
<h2 id="h-{{ o.id }}">Auftrag {{ o.structure }}</h2>
<div class="kv">
<div><strong>ID</strong></div><div class="id">{{ o.id }}</div>
<div><strong>Struktur</strong></div><div data-field="structure">{{ o.structure }}</div>
<div><strong>System</strong></div><div data-field="system">{{ o.system|default('Jita') }}</div>
<div><strong>Industrie-Struktur</strong></div><div data-field="industry_structure">{{ o.industry_structure|default('Station') }}</div>
<div><strong>Industrie-Rig</strong></div><div data-field="industry_rig">{{ o.industry_rig|default('None') }}</div>
<div><strong>Reaktions-Struktur</strong></div><div data-field="reaction_structure">{{ o.reaction_structure|default('Athanor') }}</div>
<div><strong>Reaktions-Rig</strong></div><div data-field="reaction_rig">{{ o.reaction_rig|default('None') }}</div>
<div><strong>Menge</strong></div><div data-field="quantity">{{ o.quantity }}</div>
<div><strong>ME&nbsp;%</strong></div><div data-field="me">{{ o.me }}</div>
<div><strong>Steuersatz&nbsp;%</strong></div><div data-field="facility_tax">{{ o.facility_tax|default(0) }}</div>
<div><strong>Notizen</strong></div><div class="note">{{ o.notes|default('') }}</div>
<div><strong>Erstellt</strong></div><div>{{ o.created_at|fmt_ts }}</div>
<div><strong>Blueprint&nbsp;ID</strong></div>
<div>
<input id="bp-{{ o.id }}" type="text" style="width:11rem"
value="{{ o.blueprint_type_id or '' }}" {{ '' if not o.blueprint_type_id else 'readonly' }} />
<button type="button" onclick="toggleEditBp('{{ o.id }}')">Bearbeiten</button>
<button type="button" onclick="saveBp('{{ o.id }}')">Speichern</button>
</div>
</div>
<hr class="sep" />
<h3 style="margin:.2rem 0 .4rem">Cookbook Kosten & Materialien</h3>
<div class="muted" style="margin-bottom:.4rem">Die Blueprint-ID ist vorausgefüllt und kann bei Bedarf überschrieben werden.</div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;margin-bottom:.4rem">
<button type="button" onclick="fetchCookbook('{{ o.id }}')">Kosten abrufen</button>
<button type="button" onclick="fetchMaterials('{{ o.id }}')">Materialien abrufen</button>
<button type="button" onclick="refreshOne('{{ o.id }}')">Aktualisieren &amp; speichern</button>
<span id="cb-status-{{ o.id }}" class="muted"></span>
</div>
<div id="cb-total-{{ o.id }}" class="pill" style="display:none;margin-bottom:.5rem"></div>
<div id="cb-result-{{ o.id }}" class="result" style="display:none"></div>
<div id="cb-mats-{{ o.id }}" class="result" style="display:none"></div>
<div style="margin-top:.8rem">
{% if o.status == 'open' %}
<form method="post" action="/strukturen/done/{{ o.id }}" style="display:inline"><button type="submit">Als fertig markieren</button></form>
{% elif o.status == 'done' %}
<form method="post" action="/archiv/add/{{ o.id }}" style="display:inline"><button type="submit">Archivieren</button></form>
{% endif %}
<a class="button" href="#" style="margin-left:.4rem">Schließen</a>
</div>
</div>
</div>
{% endfor %}
</div>
<script>
function formatISK(x){const n=Number(x);if(!isFinite(n))return String(x);return n.toLocaleString('de-DE')+" ISK";}
function formatTS(s){
if(!s) return ""; const d=new Date(s);
if(isNaN(d.getTime())){ const t=String(s); return t.slice(0,16); }
const y=d.getFullYear(), m=String(d.getMonth()+1).padStart(2,'0'), da=String(d.getDate()).padStart(2,'0'),
hh=String(d.getHours()).padStart(2,'0'), mm=String(d.getMinutes()).padStart(2,'0');
return `${y}-${m}-${da} ${hh}:${mm}`;
}
function textOf(c,s){const el=c.querySelector(s);return el?el.textContent.trim():"";}
function toggleEditBp(id){const i=document.getElementById("bp-"+id);i.readOnly=!i.readOnly;i.focus();}
function saveBp(id){
const i=document.getElementById("bp-"+id), v=(i.value||"").trim();
if(!/^[0-9]+$/.test(v)){alert("Bitte gültige Blueprint-TypeID eingeben.");return;}
fetch("/api/order/"+encodeURIComponent(id)+"/bp",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({blueprint_type_id:v})}).then(()=>{i.readOnly=true;});
}
/* ===== Kosten ===== */
function pullTotal(d){
if(d&&d.total!=null) return d.total;
if(d&&d.totalCost!=null) return d.totalCost;
if(d&&d.message&&d.message.totalCost!=null) return d.message.totalCost;
return null;
}
function renderCost(root,data){
const m=(data&&data.message&&typeof data.message==='object')?data.message:data;
const rows=[
["Kosten / Einheit", m.buildCostPerUnit],
["Produzierte Menge", m.producedQuantity],
["Materialkosten", m.materialCost],
["Jobkosten", m.jobCost],
["Überschüssige Materialien", m.excessMaterialsValue],
["Zusatzkosten", m.additionalCost],
["Gesamtkosten", pullTotal(data)]
].filter(r=>r[1]!=null);
let html='<table style="width:100%;border-collapse:collapse"><tbody>';
rows.forEach(([k,v],i)=>{const strong=i===rows.length-1?' style="font-weight:700"':'';html+=`<tr><th style="text-align:left;padding:.25rem .35rem">${k}</th><td class="num" style="padding:.25rem .35rem"${strong}>${typeof v==='number'?formatISK(v):String(v)}</td></tr>`;});
html+='</tbody></table>';
const det=document.createElement('details'); const sum=document.createElement('summary'); sum.textContent='Rohdaten';
const pre=document.createElement('pre'); pre.style.whiteSpace='pre-wrap'; pre.textContent=JSON.stringify(data,null,2);
det.appendChild(sum); det.appendChild(pre);
root.innerHTML=html; root.appendChild(det);
}
/* ===== Materialien ===== (robustes Parsing) */
function looksLikeMatRow(o){
if(!o || typeof o!=='object') return false;
const hasId = ('type_id' in o) || ('typeId' in o);
const hasQty = ('quantity' in o) || ('qty' in o) || ('amount' in o) || ('count' in o);
return !!(hasId && hasQty);
}
function dictRowsToArray(obj){
const vals = Object.values(obj||{});
if(!vals.length) return null;
if(vals.every(looksLikeMatRow)) return vals;
return null;
}
function extractMaterials(data){
if(!data) return null;
if(data.materials){
if(Array.isArray(data.materials)) return data.materials;
const arr = dictRowsToArray(data.materials); if(arr) return arr;
}
if(data.message && typeof data.message==='object'){
const m = data.message;
if(m.materials){
if(Array.isArray(m.materials)) return m.materials;
const arr = dictRowsToArray(m.materials); if(arr) return arr;
}
}
let found=null;
(function walk(x){
if(found||!x) return;
if(Array.isArray(x)){
if(x.length && looksLikeMatRow(x[0])){ found=x; return; }
for(const y of x){ walk(y); if(found) return; }
}else if(typeof x==='object'){
const asArr = dictRowsToArray(x);
if(asArr){ found=asArr; return; }
for(const v of Object.values(x)){ walk(v); if(found) return; }
}
})(data);
return found;
}
function renderMaterials(root,data){
const mats = extractMaterials(data);
let html = "";
if(!mats || !mats.length){
html += "Keine Materialien gefunden.";
} else {
const rows=mats.map(m=>{
const typeId = m.type_id ?? m.typeId ?? "";
const name = m.name ?? m.item_name ?? m.typeName ?? ('Type '+typeId);
const qty = (m.quantity ?? m.qty ?? m.amount ?? m.count ?? "");
const cost = (m.cost ?? m.cost_per_unit ?? m.unit_cost);
const costHtml = (cost!=null)?('<strong>'+formatISK(cost)+'</strong>'):"";
return `<tr><td>${name} ${typeId?`<span class="muted">(${typeId})</span>`:""}</td><td class="num">${qty}</td><td class="num">${costHtml}</td></tr>`;
}).join("");
html += '<h4 style="margin:.2rem 0 .5rem">Materialien (Manufacturing)</h4>'+
`<table style="width:100%;border-collapse:collapse"><thead><tr><th>Item</th><th class="num">Menge</th><th class="num">Kosten</th></tr></thead><tbody>${rows}</tbody></table>`;
}
const det=document.createElement('details'); const sum=document.createElement('summary'); sum.textContent='Rohdaten';
const pre=document.createElement('pre'); pre.style.whiteSpace='pre-wrap'; pre.textContent=JSON.stringify(data,null,2);
det.appendChild(sum); det.appendChild(pre);
root.innerHTML=html; root.appendChild(det);
}
function saveCache(id,payload){ fetch("/api/order/"+encodeURIComponent(id)+"/cache",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)}).catch(()=>{}); }
function loadCostsOverview(){
const box=document.getElementById("costs-box"), tbl=document.getElementById("costs-table"), body=document.getElementById("costs-body"), total=document.getElementById("costs-total"), ts=document.getElementById("costs-ts");
box.style.display="block"; box.textContent="Lade Kosten …"; tbl.style.display="none";
fetch("/api/cookbook/open_costs").then(r=>{if(!r.ok){return r.text().then(t=>{throw new Error('HTTP '+r.status+' - '+t.slice(0,200));});}return r.json();})
.then(d=>{
const items=d.items||[]; if(!items.length){box.textContent="Keine offenen Aufträge (oder keine Blueprint-IDs).";return;}
body.innerHTML=items.map(it=>{
const unit=(it.unit_cost!=null)?formatISK(it.unit_cost):"", tot=(it.total_cost!=null)?"<strong>"+formatISK(it.total_cost)+"</strong>":"";
const ind=(it.industry_structure||"")+((it.industry_rig&&it.industry_rig!=="None")?" / "+it.industry_rig:"");
const reac=(it.reaction_structure||"")+((it.reaction_rig&&it.reaction_rig!=="None")?" / "+it.reaction_rig:"");
const err=it.error?` <span class="err">(${it.error})</span>`:"";
return `<tr><td>${(it.structure||"")+err}</td><td class="num">${it.quantity||""}</td><td class="num">${it.me||""}</td><td>${it.system||""}</td><td>${ind}</td><td>${reac}</td><td class="num">${unit}</td><td class="num">${tot}</td></tr>`;
}).join("");
total.textContent=formatISK(d.total_cost||0);
ts.textContent=d.refreshed_at?("Stand: "+formatTS(d.refreshed_at)):"";
box.style.display="none"; tbl.style.display="block";
}).catch(err=>{box.innerHTML='<span class="err">Fehler: '+(err&&err.message?err.message:err)+'</span>'; tbl.style.display="none";});
}
function loadMatsOverview(){
const box=document.getElementById("mats-box"), tbl=document.getElementById("mats-table"), body=document.getElementById("mats-body"), total=document.getElementById("mats-total"), ts=document.getElementById("mats-ts");
box.style.display="block"; box.textContent="Lade Materialien …"; tbl.style.display="none";
fetch("/api/cookbook/open_materials").then(r=>{if(!r.ok){return r.text().then(t=>{throw new Error('HTTP '+r.status+' - '+t.slice(0,200));});}return r.json();})
.then(d=>{
const items=d.items||[]; if(!items.length){box.textContent="Keine Daten vorhanden.";return;}
body.innerHTML=items.map(it=>`<tr><td>${it.name||("Type "+it.type_id)} <span class="muted">(${it.type_id})</span></td><td class="num">${it.quantity!=null?it.quantity:""}</td><td class="num">${it.cost!=null?("<strong>"+formatISK(it.cost)+"</strong>"):""}</td></tr>`).join("");
total.textContent=formatISK(d.total_cost||0);
ts.textContent=d.refreshed_at?("Stand: "+formatTS(d.refreshed_at)):"";
box.style.display="none"; tbl.style.display="block";
}).catch(err=>{box.innerHTML='<span class="err">Fehler: '+(err&&err.message?err.message:err)+'</span>'; tbl.style.display="none";});
}
function refreshAll(){
const b1=document.getElementById("btn-refresh-all"), b2=document.getElementById("btn-refresh-mats");
if(b1) b1.disabled=true; if(b2) b2.disabled=true;
fetch("/api/orders/refresh_all",{method:"POST"}).then(r=>r.json()).then(()=>{
loadCostsOverview(); loadMatsOverview();
}).finally(()=>{ if(b1) b1.disabled=false; if(b2) b2.disabled=false; });
}
function refreshOne(id){
fetch("/api/order/"+encodeURIComponent(id)+"/refresh",{method:"POST"}).then(r=>r.json()).then(()=>{ loadCostsOverview(); loadMatsOverview(); });
}
document.addEventListener("DOMContentLoaded",function(){ loadCostsOverview(); loadMatsOverview(); });
</script>
</body>
</html>