Files

423 lines
24 KiB
HTML
Raw Permalink 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.
{% 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 %}