add group export

This commit is contained in:
2026-06-17 08:14:10 +02:00
parent 2f271265e9
commit e98660134a
8 changed files with 558 additions and 0 deletions
+188
View File
@@ -4166,3 +4166,191 @@ document.getElementById('btnRestoreColors')?.addEventListener('click', () => {
setTimeout(() => openSettingsModal(), 200);
}
})();
// =============================================================
// Gruppen-Export
// =============================================================
const GexState = {
searchResults: [],
selectedGroup: null,
members: null,
memberFilter: '',
};
function openGroupExportModal() {
if (!State.connected) { toast('Bitte zuerst verbinden.', 'warn'); return; }
GexState.searchResults = [];
GexState.selectedGroup = null;
GexState.members = null;
GexState.memberFilter = '';
document.getElementById('gexSearch').value = '';
document.getElementById('gexMemberFilter').value = '';
document.getElementById('gexSearchResults').innerHTML = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
document.getElementById('gexMembersArea').innerHTML = '<div class="gex-hint">Gruppe auswählen um Mitglieder zu laden.</div>';
document.getElementById('gexMembersTitle').textContent = 'Mitglieder';
document.getElementById('gexStats').textContent = '';
document.getElementById('gexExportBtn').disabled = true;
document.getElementById('modalGroupExport').classList.remove('hidden');
setTimeout(() => document.getElementById('gexSearch').focus(), 80);
}
document.getElementById('btnGroupExport')?.addEventListener('click', openGroupExportModal);
// Suche mit Debounce
let _gexSearchTimer = null;
document.getElementById('gexSearch')?.addEventListener('input', e => {
clearTimeout(_gexSearchTimer);
const q = e.target.value.trim();
if (q.length < 2) {
GexState.searchResults = [];
document.getElementById('gexSearchResults').innerHTML = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
return;
}
document.getElementById('gexSearchResults').innerHTML = '<div class="gex-loading"><div class="spinner" style="width:14px;height:14px;border-width:2px;"></div><span>Suche…</span></div>';
_gexSearchTimer = setTimeout(() => _gexDoSearch(q), 350);
});
async function _gexDoSearch(q) {
try {
const data = await api(`/api/groups/search?q=${encodeURIComponent(q)}`);
GexState.searchResults = data.items || [];
_gexRenderResults();
} catch (e) {
document.getElementById('gexSearchResults').innerHTML = `<div class="gex-error">Suche fehlgeschlagen: ${escapeHtml(e.message)}</div>`;
}
}
function _gexRenderResults() {
const el = document.getElementById('gexSearchResults');
if (!el) return;
const items = GexState.searchResults;
if (items.length === 0) {
el.innerHTML = '<div class="gex-empty">Keine Gruppen gefunden.</div>';
return;
}
el.innerHTML = items.map(g => `
<div class="gex-group-item ${GexState.selectedGroup?.Id === g.Id ? 'selected' : ''}"
data-id="${escapeHtml(g.Id)}" data-name="${escapeHtml(g.DisplayName)}">
<div class="gex-group-name">${escapeHtml(g.DisplayName)}</div>
${g.Description ? `<div class="gex-group-desc">${escapeHtml(g.Description)}</div>` : ''}
</div>
`).join('');
el.querySelectorAll('.gex-group-item').forEach(item => {
item.addEventListener('click', () => _gexSelectGroup(item.dataset.id, item.dataset.name));
});
}
async function _gexSelectGroup(id, name) {
GexState.selectedGroup = { Id: id, DisplayName: name };
GexState.members = null;
GexState.memberFilter = '';
document.getElementById('gexMemberFilter').value = '';
_gexRenderResults(); // aktualisiert selected-Highlight
document.getElementById('gexMembersTitle').textContent = `Mitglieder — ${name}`;
document.getElementById('gexStats').textContent = '';
document.getElementById('gexExportBtn').disabled = true;
document.getElementById('gexMembersArea').innerHTML =
'<div class="gex-loading"><div class="spinner" style="width:16px;height:16px;border-width:2px;"></div><span>Mitglieder werden aufgelöst (inkl. verschachtelte Gruppen)…</span></div>';
try {
const data = await api(`/api/groups/${encodeURIComponent(id)}/members/export`);
GexState.members = data;
_gexRenderMembers();
document.getElementById('gexExportBtn').disabled = data.count === 0;
let stats = `${data.count} Benutzer`;
if (data.duplicates > 0) stats += ` · ${data.duplicates} Duplikate aus Verschachtelung entfernt`;
stats += ` · ${data.resolvedMs}ms`;
document.getElementById('gexStats').textContent = stats;
} catch (e) {
document.getElementById('gexMembersArea').innerHTML =
`<div class="gex-error">Fehler beim Laden: ${escapeHtml(e.message)}</div>`;
}
}
// Live-Filter für die Mitgliederliste
document.getElementById('gexMemberFilter')?.addEventListener('input', e => {
GexState.memberFilter = e.target.value.trim().toLowerCase();
_gexApplyFilter();
});
function _gexApplyFilter() {
const q = GexState.memberFilter;
document.querySelectorAll('#gexMembersArea .gex-user-item').forEach(row => {
const text = row.dataset.search || '';
row.classList.toggle('hidden-filter', q !== '' && !text.includes(q));
});
// Pfad-Gruppen ohne sichtbare Zeilen ausblenden
document.querySelectorAll('#gexMembersArea .gex-path-group').forEach(pg => {
const visible = pg.querySelectorAll('.gex-user-item:not(.hidden-filter)').length > 0;
pg.style.display = visible ? '' : 'none';
});
}
function _gexRenderMembers() {
const area = document.getElementById('gexMembersArea');
if (!area || !GexState.members) return;
const m = GexState.members;
if (m.count === 0) {
area.innerHTML = '<div class="gex-empty">Keine Benutzer in dieser Gruppe gefunden.</div>';
return;
}
// Nach SourcePath gruppieren
const byPath = new Map();
for (const u of m.users) {
const p = u.SourcePath || m.groupName;
if (!byPath.has(p)) byPath.set(p, []);
byPath.get(p).push(u);
}
let html = `<div class="gex-summary">${m.count} Benutzer`;
if (m.duplicates > 0) html += ` (${m.duplicates} Duplikate aus verschachtelten Gruppen zusammengeführt)`;
html += '</div>';
for (const [path, users] of byPath.entries()) {
html += `<div class="gex-path-group">
<div class="gex-path-head">${escapeHtml(path)} <span class="count-badge">${users.length}</span></div>
<div class="gex-user-list">`;
for (const u of users) {
const upn = u.UserPrincipalName || u.Mail || u.Id;
const searchKey = `${(u.DisplayName || '').toLowerCase()} ${upn.toLowerCase()}`;
html += `<div class="gex-user-item" data-search="${escapeHtml(searchKey)}">
<span class="gex-user-name">${escapeHtml(u.DisplayName || '—')}</span>
<span class="gex-user-upn">${escapeHtml(upn)}</span>
</div>`;
}
html += '</div></div>';
}
area.innerHTML = html;
if (GexState.memberFilter) _gexApplyFilter();
}
document.getElementById('gexExportBtn')?.addEventListener('click', () => {
if (!GexState.members?.users) return;
const m = GexState.members;
const rows = [['DisplayName', 'UserPrincipalName', 'Mail', 'SourcePath', 'GroupId', 'GroupName']];
for (const u of m.users) {
rows.push([
u.DisplayName || '',
u.UserPrincipalName || '',
u.Mail || '',
u.SourcePath || m.groupName,
m.groupId,
m.groupName,
]);
}
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 url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${m.groupName.replace(/[^a-zA-Z0-9_-]/g, '_')}-members.csv`;
a.click();
URL.revokeObjectURL(url);
toast(`${m.count} Benutzer exportiert.`, 'ok');
});
@@ -32,6 +32,9 @@
</div>
<div class="header-actions">
<button id="btnGroupExport" class="icon-btn icon-btn-header" title="Gruppen-Export" aria-label="Gruppen-Export öffnen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/><polyline points="17 8 19 10 23 6"/></svg>
</button>
<button id="btnSettings" class="icon-btn icon-btn-header" title="Einstellungen" aria-label="Einstellungen öffnen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.86l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.86-.34 1.7 1.7 0 0 0-1.04 1.57V21a2 2 0 0 1-4 0v-.09a1.7 1.7 0 0 0-1.1-1.57 1.7 1.7 0 0 0-1.86.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.86 1.7 1.7 0 0 0-1.57-1.04H3a2 2 0 0 1 0-4h.09A1.7 1.7 0 0 0 4.66 9a1.7 1.7 0 0 0-.34-1.86l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.86.34H9a1.7 1.7 0 0 0 1.04-1.57V3a2 2 0 0 1 4 0v.09c0 .68.42 1.29 1.04 1.57.62.27 1.36.13 1.86-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.86V9a1.7 1.7 0 0 0 1.57 1.04H21a2 2 0 0 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1z"/></svg>
</button>
@@ -795,6 +798,52 @@
</div>
</div>
<!-- MODAL: Gruppen-Export -->
<div id="modalGroupExport" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-xl">
<div class="modal-head">
<h3>Gruppen-Export</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body gex-body">
<!-- Linke Spalte: Suche + Ergebnisliste -->
<div class="gex-left">
<div class="gex-section-title">Gruppe suchen</div>
<div class="search-wrap" style="margin-bottom:10px;">
<svg class="search-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="search" id="gexSearch" class="input input-search" placeholder="Gruppenname eingeben…" autocomplete="off">
</div>
<div id="gexSearchResults" class="gex-results">
<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>
</div>
</div>
<!-- Rechte Spalte: aufgelöste Mitglieder -->
<div class="gex-right">
<div class="gex-section-title" id="gexMembersTitle">Mitglieder</div>
<div class="search-wrap" style="margin-bottom:10px;">
<svg class="search-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="search" id="gexMemberFilter" class="input input-search" placeholder="Mitglieder filtern…" autocomplete="off">
</div>
<div id="gexMembersArea" class="gex-members-area">
<div class="gex-hint">Gruppe auswählen um Mitglieder zu laden.</div>
</div>
</div>
</div>
<div class="modal-foot">
<span id="gexStats" class="muted" style="margin-right:auto;"></span>
<button class="btn btn-secondary" data-close>Schließen</button>
<button class="btn btn-primary" id="gexExportBtn" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Als CSV exportieren
</button>
</div>
</div>
</div>
<!-- Markdown-Renderer (bundled, MIT, marked v15) — wird im App-Detail-Panel
fuer die Beschreibung benutzt. Vor app.js laden damit window.marked
beim ersten render() bereitsteht. -->
@@ -4392,6 +4392,163 @@ html[data-theme="dark"] .numInputWrapper span.arrowDown {
border-color: var(--hairline);
}
/* =============================================================
Gruppen-Export Modal
============================================================= */
.gex-body {
display: flex;
gap: 16px;
min-height: 420px;
padding: 0 !important;
overflow: hidden;
}
.gex-left {
width: 300px;
flex-shrink: 0;
border-right: 1px solid var(--hairline);
padding: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.gex-right {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.gex-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--muted);
margin-bottom: 8px;
}
.gex-results {
flex: 1;
overflow-y: auto;
border: 1px solid var(--hairline);
border-radius: var(--r-md);
}
.gex-group-item {
padding: 9px 12px;
border-bottom: 1px solid var(--hairline-soft);
cursor: pointer;
transition: background var(--tx);
}
.gex-group-item:last-child { border-bottom: none; }
.gex-group-item:hover { background: var(--surface-soft); }
.gex-group-item.selected {
background: var(--surface-soft);
border-left: 3px solid var(--ink);
padding-left: 9px;
}
.gex-group-name {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.gex-group-desc {
font-size: 11.5px;
color: var(--muted);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gex-members-area {
flex: 1;
overflow-y: auto;
border: 1px solid var(--hairline);
border-radius: var(--r-md);
}
.gex-summary {
padding: 8px 12px;
font-size: 12px;
color: var(--muted);
border-bottom: 1px solid var(--hairline-soft);
background: var(--surface-soft);
}
.gex-path-group {
border-bottom: 1px solid var(--hairline-soft);
}
.gex-path-group:last-child { border-bottom: none; }
.gex-path-head {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
font-size: 12px;
font-weight: 600;
background: var(--surface-soft);
border-bottom: 1px solid var(--hairline-soft);
color: var(--muted);
position: sticky;
top: 0;
z-index: 1;
}
.gex-user-list { }
.gex-user-item {
display: flex;
align-items: baseline;
gap: 8px;
padding: 6px 12px;
border-bottom: 1px solid var(--hairline-soft);
font-size: 13px;
}
.gex-user-item:last-child { border-bottom: none; }
.gex-user-item.hidden-filter { display: none; }
.gex-user-name {
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 120px;
max-width: 220px;
}
.gex-user-upn {
font-size: 11.5px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gex-loading {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 16px;
font-size: 13px;
color: var(--muted);
}
.gex-hint, .gex-empty, .gex-error {
padding: 16px;
font-size: 13px;
color: var(--muted);
text-align: center;
}
.gex-error { color: var(--error); }
/* Kompakte Optik fuer beide Themes — passt besser zum Tool */
.flatpickr-calendar {
font-family: inherit;