Files
eve_structure/webapp/templates/minerals.html
2025-08-27 18:55:45 +02:00

441 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>