Initial commit – AufmaßCreater v2.35

This commit is contained in:
2026-06-10 11:03:43 +02:00
commit 84c933ea9c
2823 changed files with 490495 additions and 0 deletions
+422
View File
@@ -0,0 +1,422 @@
{% extends "base.html" %}
{% block content %}
{% set default_cols = ['fav','drag','pos_nr','text','einheit','ep','aktion'] %}
{% set col_labels = {'fav':'⭐','drag':'#','pos_nr':'Pos-Nr','text':'Kurztext / Langtext','einheit':'EH','ep':'EP (€)','aktion':'Aktion'} %}
{% set col_sortable = {'fav':False,'drag':False,'pos_nr':True,'text':True,'einheit':True,'ep':True,'aktion':False} %}
<div class="level">
<div class="level-left"><h1 class="title is-3">Leistungsverzeichnis</h1></div>
<div class="level-right">
<a class="button is-light" href="{{ url_for('admin.dashboard') }}">← Dashboard</a>
</div>
</div>
<div id="lv-layout" class="lv-layout">
<!-- Linkes Panel -->
<div class="lv-panel" id="panel-left" style="width:300px;min-width:150px">
<div class="panel-header">
<span class="has-text-weight-bold is-size-7">LV-Auswahl</span>
<span class="panel-actions">
<a class="panel-btn" onclick="togglePanel('left')" title="Einklappen"></a>
<a class="panel-btn" onclick="resetPanel('left')" title="Standard"></a>
</span>
</div>
<div class="panel-body">
<form method="GET" action="{{ url_for('lv.index') }}" id="lv-select-form">
<div class="select is-small is-fullwidth">
<select name="lv" onchange="this.form.submit()">
<option value=""> LV wählen </option>
{% for name in lv_names %}
<option value="{{ name }}" {{ 'selected' if name == selected_lv }}>{{ name }}</option>
{% endfor %}
</select>
</div>
</form>
{% if current_user.is_firmadmin() or current_user.darf_lv_verwalten %}
<details class="mt-1"><summary class="has-text-link is-size-7">+ Neues LV</summary>
<form method="POST" action="{{ url_for('lv.neu_lv') }}" class="mt-1">
<div class="field has-addons"><div class="control is-expanded"><input class="input is-small" type="text" name="lv_name" placeholder="LV-Name" required></div>
<div class="control"><button class="button is-small is-primary">Anlegen</button></div></div>
</form>
</details>
<details class="mt-1"><summary class="has-text-link is-size-7">📥 TXT importieren</summary>
<form method="POST" action="{{ url_for('lv.import_txt') }}" enctype="multipart/form-data" class="mt-1">
<input type="hidden" name="lv_name" value="{{ selected_lv }}">
<div class="field"><div class="control"><input class="input is-small" type="file" name="datei" accept=".txt" required></div></div>
<button class="button is-small is-info">Importieren</button>
</form>
</details>
{% endif %}
<hr class="my-2">
<form method="GET" action="{{ url_for('lv.index') }}" id="search-form">
<input type="hidden" name="lv" value="{{ selected_lv }}">
<div class="field"><div class="control has-icons-left"><input class="input is-small" name="q" id="live-search" placeholder="Live-Suche..." value="{{ search }}" autocomplete="off">
<span class="icon is-left is-small">🔍</span></div></div>
</form>
<details class="mt-1"><summary class="has-text-link is-size-7">+ Position hinzufügen</summary>
<form method="POST" action="{{ url_for('lv.position_neu') }}" class="mt-1">
<input type="hidden" name="lv_name" value="{{ selected_lv }}">
<div class="field"><input class="input is-small" name="pos_nr" placeholder="Pos-Nr *" required></div>
<div class="field"><input class="input is-small" name="kurztext" placeholder="Kurztext"></div>
<div class="field"><textarea class="textarea is-small" name="langtext" placeholder="Langtext" rows="2"></textarea></div>
<div class="columns is-mobile"><div class="column"><input class="input is-small" name="einheit" placeholder="EH" value="ST"></div>
<div class="column"><input class="input is-small" name="einzelpreis" placeholder="EP (€)" type="number" step="0.01"></div></div>
<button class="button is-small is-primary mt-1">Hinzufügen</button>
</form>
</details>
</div>
</div>
<div class="lv-divider" id="divider-1" data-prev="left" data-next="center"></div>
<!-- Mittleres Panel: Tabelle -->
<div class="lv-panel" id="panel-center" style="flex:1;min-width:300px">
<div class="panel-header">
<span class="has-text-weight-bold is-size-7">{{ selected_lv or 'Leistungsverzeichnis' }}</span>
<span class="tag is-light is-size-7 ml-1">{{ positionen|length }} Pos.</span>
<span class="panel-actions">
<a class="panel-btn" onclick="togglePanel('center')" title="Einklappen"></a>
<a class="panel-btn" onclick="resetPanel('center')" title="Standardgröße"></a>
<a class="panel-btn" onclick="maxPanel('center')" title="Maximieren"></a>
<a class="panel-btn" onclick="autoFitColumns()" title="Auto-Breite"></a>
<span class="panel-btn-sep">|</span>
<div class="dropdown is-hoverable is-right" style="display:inline-block">
<div class="dropdown-trigger"><a class="panel-btn" title="Ansicht">👁</a></div>
<div class="dropdown-menu" style="min-width:200px">
<div class="dropdown-content" id="view-dropdown">
<div class="dropdown-item has-text-weight-bold is-size-7">Ansichten</div>
<hr class="dropdown-divider">
<div id="view-list"></div>
<hr class="dropdown-divider">
<div class="dropdown-item">
<div class="field has-addons">
<div class="control is-expanded"><input class="input is-small" id="view-name" placeholder="Name"></div>
<div class="control"><button class="button is-small is-primary" onclick="saveView()">Speichern</button></div>
</div>
</div>
</div>
</div>
</div>
</span>
</div>
<div class="panel-body" id="table-body">
{% if selected_lv %}
<div class="table-wrap">
<table class="table is-fullwidth is-hoverable is-striped" id="lv-table">
<thead id="thead-sortable">
<tr>
{% set col_order = view_config.column_order if view_config else default_cols %}
{% set col_visible = view_config.column_visible if view_config else {} %}
{% set col_widths = view_config.column_widths if view_config else {} %}
{% for key in col_order %}
{% if col_visible.get(key, True) %}
<th data-col="{{ key }}" style="width:{{ col_widths.get(key, '') }}px;" class="col-header {{ 'sort-'+sort_dir if sort_col == key }}">
{% if col_sortable.get(key) %}
<a class="col-label" href="{{ url_for('lv.index', lv=selected_lv, q=search, sort=key if sort_col != key or sort_dir == 'desc' else '', dir='asc' if sort_col != key or sort_dir == 'desc' else 'desc') }}">{{ col_labels.get(key, key) }}</a>
<span class="sort-icon">{{ '▲' if sort_col == key and sort_dir == 'asc' else '▼' if sort_col == key else '⇅' }}</span>
{% else %}
<span class="col-label">{{ col_labels.get(key, key) }}</span>
{% endif %}
<div class="col-resize-handle"></div>
</th>
{% endif %}
{% endfor %}
</tr>
</thead>
<tbody id="lv-sortable">
{% for pos in positionen %}
<tr data-id="{{ pos.id }}" data-order="{{ pos.order_index }}"
data-posnr="{{ pos.pos_nr }}" data-kurztext="{{ pos.kurztext or '' }}"
data-langtext="{{ pos.langtext or '' }}" data-einheit="{{ pos.einheit }}"
data-ep="{{ pos.einzelpreis }}" data-gruppe="{{ pos.gruppe or '' }}"
class="{{ 'has-background-warning-light' if pos.favorite }}">
{% for key in col_order %}
{% if col_visible.get(key, True) %}
<td class="col-{{ key }}">
{% if key == 'fav' %}
<span style="cursor:pointer" onclick="toggleFav({{ pos.id }})">{{ '⭐' if pos.favorite else '☆' }}</span>
{% elif key == 'drag' %}
<span class="drag-handle has-text-grey-light"></span>
{% elif key == 'pos_nr' %}
<code>{{ pos.pos_nr }}</code>
{% elif key == 'text' %}
<a href="#" onclick="showLang({{ pos.id }});return false" class="has-text-dark">
<strong>{{ pos.kurztext or '' }}</strong>
</a>
{% if pos.langtext %}<div class="langtext-preview">{{ pos.langtext[:200] }}</div>{% endif %}
{% elif key == 'einheit' %}
{{ pos.einheit }}
{% elif key == 'ep' %}
{% if preise_sichtbar %}{{ pos.einzelpreis|german_number }}{% else %}{% endif %}
{% elif key == 'aktion' %}
<button class="button is-small btn-edit" onclick="editLV({{ pos.id }})"></button>
<form method="POST" action="{{ url_for('lv.position_loeschen', pos_id=pos.id) }}"
style="display:inline" onsubmit="return confirm('Position löschen?')">
<button class="button is-small is-danger is-outlined"></button>
</form>
{% endif %}
</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% if not positionen %}
<p class="has-text-grey has-text-centered is-size-7">Keine Positionen{{ ' für "' ~ search ~ '"' if search }}.</p>
{% endif %}
</div>
{% else %}
<p class="has-text-grey has-text-centered is-size-7">Bitte links ein LV auswählen.</p>
{% endif %}
</div>
</div>
<div class="lv-divider" id="divider-2" data-prev="center" data-next="right"></div>
<!-- Rechtes Panel: Langtext -->
<div class="lv-panel" id="panel-right" style="width:380px;min-width:200px;display:none">
<div class="panel-header">
<span class="has-text-weight-bold is-size-7">Langtext</span>
<span class="panel-actions">
<a class="panel-btn" onclick="togglePanel('right')" title="Einklappen"></a>
<a class="panel-btn" onclick="maxPanel('right')" title="Maximieren"></a>
<a class="panel-btn" onclick="hidePanel('right')" title="Schließen"></a>
</span>
</div>
<div class="panel-body" id="lang-content" style="font-size:0.85rem;white-space:pre-wrap;overflow-y:auto;max-height:calc(100vh - 200px)"></div>
</div>
</div>
<!-- Edit-Modal -->
<div id="lv-edit-modal" class="modal">
<div class="modal-background"></div>
<div class="modal-card"><header class="modal-card-head"><p class="modal-card-title">Position bearbeiten</p><button class="delete" onclick="closeEditLV()"></button></header>
<section class="modal-card-body" id="lv-edit-content"></section></div>
</div>
{% endblock %}
{% block scripts %}
<script>
const ALL_COLS = ['fav','drag','pos_nr','text','einheit','ep','aktion'];
const COL_LABELS = {'fav':'⭐','drag':'#','pos_nr':'Pos-Nr','text':'Kurztext/Langtext','einheit':'EH','ep':'EP (€)','aktion':'Aktion'};
/* === View Profiles === */
function getCurrentConfig() {
const order = [], visible = {}, widths = {};
document.querySelectorAll('#thead-sortable th').forEach(function(th){
const k=th.dataset.col; order.push(k); visible[k]=true;
const w=th.style.width; widths[k]=w ? parseInt(w) : 0;
});
ALL_COLS.forEach(function(k){if(!order.includes(k))visible[k]=false;});
const pw={};
['left','center','right'].forEach(function(n){
const el=document.getElementById('panel-'+n);
pw[n]=el.style.display==='none'?0:el.offsetWidth;
});
return {column_order:order,column_visible:visible,column_widths:widths,pane_widths:pw};
}
function loadViews() {
fetch('/views/api/profiles?view_type=lv').then(function(r){return r.json()}).then(function(profiles){
const list=document.getElementById('view-list'); list.innerHTML='';
if(!profiles.length){list.innerHTML='<div class="dropdown-item is-size-7 has-text-grey">Keine gespeicherten Ansichten</div>';return;}
profiles.forEach(function(p){
const d=document.createElement('div'); d.className='dropdown-item'; d.style.cssText='font-size:0.8rem;cursor:pointer';
d.innerHTML=(p.is_default?'⭐ ':'')+esc(p.name)+'<a class="is-pulled-right has-text-danger" style="font-size:0.7rem;cursor:pointer" onclick="event.stopPropagation();deleteView('+p.id+')">✕</a>';
d.onclick=function(){const url=new URL(window.location.href);url.searchParams.set('view_id',p.id);window.location.href=url.toString();};
list.appendChild(d);
});
});
}
function saveView(){
const name=document.getElementById('view-name').value.trim();
if(!name){alert('Bitte Namen eingeben');return;}
fetch('/views/api/profiles',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:name,view_type:'lv',config:getCurrentConfig()})})
.then(function(r){return r.json()}).then(function(){document.getElementById('view-name').value='';loadViews();});
}
function deleteView(id){
if(!confirm('Ansicht löschen?'))return;
fetch('/views/api/profiles/'+id,{method:'DELETE'}).then(function(){loadViews();});
}
/* === Resizable Panels === */
document.addEventListener('DOMContentLoaded',function(){
document.querySelectorAll('.lv-divider').forEach(function(div){
let drag=false,sx=0,prev=null,next=null;
div.addEventListener('mousedown',function(e){
drag=true;sx=e.clientX;
prev=document.getElementById('panel-'+div.dataset.prev);
next=document.getElementById('panel-'+div.dataset.next);
document.body.style.cursor='col-resize';document.body.style.userSelect='none';
});
document.addEventListener('mousemove',function(e){
if(!drag||!prev||!next)return;
const dx=e.clientX-sx;
if(div.id==='divider-1'){const nw=prev.offsetWidth+dx;if(nw>120){prev.style.width=nw+'px';prev.style.flex='none';sx=e.clientX;}}
else{const nw=next.offsetWidth-dx;if(nw>150){next.style.width=nw+'px';next.style.flex='none';sx=e.clientX;}}
});
document.addEventListener('mouseup',function(){if(drag){drag=false;document.body.style.cursor='';document.body.style.userSelect='';}});
});
});
/* === Panel Collapse / Maximize === */
function togglePanel(name){
const p=document.getElementById('panel-'+name);
p.classList.toggle('collapsed');
p.querySelector('.panel-btn:first-child').textContent=p.classList.contains('collapsed')?'▸':'▾';
}
function maxPanel(name){
document.querySelectorAll('.lv-panel').forEach(function(p){
if(p.id==='panel-'+name){p.style.flex='1';p.style.width='';}
else if(p.style.display!=='none'){p.style.flex='none';if(p.id!=='panel-right')p.style.width='40px';}
});
}
function resetPanel(name){
const def={left:300,center:null,right:380};
document.querySelectorAll('.lv-panel').forEach(function(p){
p.style.flex='';p.classList.remove('collapsed');
if(p.id==='panel-left')p.style.width=def.left+'px';
else if(p.id==='panel-right')p.style.width=def.right+'px';
else p.style.width='';
const b=p.querySelector('.panel-body');if(b)b.style.display='';
const btn=p.querySelector('.panel-btn:first-child');if(btn)btn.textContent='▾';
});
}
function hidePanel(name){
document.getElementById('panel-'+name).style.display='none';
}
/* === Column Resize (drag th handle) + Double-click auto-fit === */
document.addEventListener('DOMContentLoaded',function(){
document.querySelectorAll('.col-resize-handle').forEach(function(h){
let drag=false,startX=0,th=null,startW=0;
h.addEventListener('mousedown',function(e){
drag=true;startX=e.clientX;th=e.target.closest('th');
startW=th.offsetWidth-20; // -20px padding (10 left + 10 right)
window._colW={};document.querySelectorAll('#lv-table thead th').forEach(function(t){window._colW[t.dataset.col]=t.offsetWidth-20;});
e.stopPropagation();document.body.style.cursor='col-resize';document.body.style.userSelect='none';
});
document.addEventListener('mousemove',function(e){
if(!drag||!th)return;
const nw=startW+(e.clientX-startX);
if(nw>14){
Object.keys(window._colW).forEach(function(k){if(k!==th.dataset.col)setColWidth(k,window._colW[k]);});
setColWidth(th.dataset.col,nw);
}
});
document.addEventListener('mouseup',function(){if(drag){drag=false;document.body.style.cursor='';document.body.style.userSelect='';}});
h.addEventListener('dblclick',function(e){
e.stopPropagation();
const thEl=e.target.closest('th');
const col=thEl&&thEl.dataset.col;
if(col)autoFitColumn(col);
});
});
/* Auch Doppelklick auf die rechten 15px jeder Th (wenn man den Griff verfehlt) */
document.querySelectorAll('#lv-table thead th').forEach(function(th){
th.addEventListener('dblclick',function(e){
if(e.target.closest('.sort-icon')||e.target.closest('.col-resize-handle'))return;
const rect=th.getBoundingClientRect();
if(rect.right-e.clientX<15){const col=th.dataset.col;if(col)autoFitColumn(col);}
});
});
});
/* === Auto-Fit Columns === */
function autoFitColumn(col){
var fixed={fav:32,drag:28,aktion:70};
if(fixed[col]!==undefined){setColWidth(col,fixed[col]);return;}
const table=document.getElementById('lv-table');
if(!table)return;
const th=table.querySelector('thead th[data-col="'+col+'"]');
if(!th)return;
/* style.width ist Content-Breite, d.h. rendered = style.width + 20px Padding.
Wir messen Textbreite + 20px Padding + 10px Atem = 30px Zuschlag insgesamt,
setzen aber nur Textbreite + 10px als style.width. Browser addiert 20px Padding. */
let maxW=0;
if(th.querySelector('.col-label'))maxW=th.querySelector('.col-label').offsetWidth+10;
const rows=table.querySelectorAll('tbody tr');
rows.forEach(function(row){
const cell=row.querySelector('.col-'+col);
if(cell){
const tmp=document.createElement('span');
tmp.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;font-size:0.85rem';
tmp.textContent=cell.textContent;
document.body.appendChild(tmp);
const w=tmp.offsetWidth+10;
document.body.removeChild(tmp);
if(w>maxW)maxW=w;
}
});
setColWidth(col,Math.min(maxW,800));
}
function setColWidth(col,w){
const th=document.querySelector('#lv-table thead th[data-col="'+col+'"]');
if(th)th.style.width=w+'px';
}
function autoFitColumns(){
const table=document.getElementById('lv-table');
if(!table)return;
table.querySelectorAll('thead th').forEach(function(th){
const col=th.dataset.col;
if(col)autoFitColumn(col);
});
}
/* === Standard Functions === */
function setupSortable(){
const el=document.getElementById('lv-sortable');
if(!el||el._sortable)return;
el._sortable=new Sortable(el,{handle:'.drag-handle',animation:150,
onEnd:function(){
const rows=el.querySelectorAll('tr');const r=[];
rows.forEach(function(row,i){r.push({id:parseInt(row.dataset.id),order_index:i+1});});
fetch('{{ url_for("lv.positionen_reihenfolge") }}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reihenfolge:r})});
}
});
}
function toggleFav(id){
fetch('/lv/position/'+id+'/favorite',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})
.then(function(r){return r.json()}).then(function(){location.reload();});
}
function showLang(id){
fetch('/lv/position/'+id+'/langtext').then(function(r){return r.text()}).then(function(html){
document.getElementById('lang-content').innerHTML=html;
const p=document.getElementById('panel-right');
p.style.display='';p.classList.remove('collapsed');
const b=p.querySelector('.panel-body');if(b)b.style.display='';
p.querySelector('.panel-btn:first-child').textContent='▾';
});
}
function editLV(id){
const row=document.querySelector('tr[data-id="'+id+'"]');if(!row)return;
const d=row.dataset;
document.getElementById('lv-edit-content').innerHTML=
'<form method="POST" action="/lv/position/'+id+'/bearbeiten">'+
'<div class="field"><label class="label">Pos-Nr</label><input class="input" name="pos_nr" value="'+esc(d.posnr)+'" required></div>'+
'<div class="field"><label class="label">Kurztext</label><input class="input" name="kurztext" value="'+esc(d.kurztext)+'"></div>'+
'<div class="field"><label class="label">Langtext</label><textarea class="textarea" name="langtext" rows="3">'+esc(d.langtext)+'</textarea></div>'+
'<div class="columns"><div class="column"><label class="label">Einheit</label><input class="input" name="einheit" value="'+esc(d.einheit)+'"></div>'+
'<div class="column"><label class="label">EP (€)</label><input class="input" name="einzelpreis" type="number" step="0.01" value="'+d.ep+'"></div></div>'+
'<div class="field"><label class="label">Gruppe</label><input class="input" name="gruppe" value="'+esc(d.gruppe)+'"></div>'+
'<button class="button is-primary" type="submit">Speichern</button>'+
'<button class="button is-light" type="button" onclick="closeEditLV()">Abbrechen</button></form>';
document.getElementById('lv-edit-modal').classList.add('is-active');
}
function closeEditLV(){document.getElementById('lv-edit-modal').classList.remove('is-active');}
function esc(s){if(!s)return '';return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;');}
/* === Live Search === */
(function(){
const inp=document.getElementById('live-search');
if(!inp)return;let t=null;
inp.addEventListener('input',function(){clearTimeout(t);t=setTimeout(function(){document.getElementById('search-form').submit();},350);});
})();
/* === Init === */
document.addEventListener('DOMContentLoaded',function(){
setupSortable();document.body.addEventListener('htmx:load',setupSortable);loadViews();
// Auto-fit on initial load if no custom view
const urlParams=new URLSearchParams(window.location.search);
if(!urlParams.get('view_id')){setTimeout(autoFitColumns,200);}
});
</script>
{% endblock %}