add group export
This commit is contained in:
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -29,6 +29,7 @@ function Invoke-ApiHandler {
|
||||
|
||||
"GET /api/groups" { return Get-GroupsEndpoint -Query $Query }
|
||||
"GET /api/groups/rpa" { return Get-RpaGroupsEndpoint }
|
||||
"GET /api/groups/search" { return Search-GroupsEndpoint -Query $Query }
|
||||
"POST /api/groups" { return New-GroupEndpoint -Body $Body }
|
||||
"POST /api/groups/check" { return Test-GroupNameEndpoint -Body $Body }
|
||||
|
||||
@@ -45,6 +46,11 @@ function Invoke-ApiHandler {
|
||||
}
|
||||
|
||||
# 2-segment fallbacks (z.B. /api/groups/<id>/members)
|
||||
# Export-Route VOR der generischen /members-Route pruefen
|
||||
if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members/export$") {
|
||||
return Get-GroupMembersExportEndpoint -GroupId $matches[1]
|
||||
}
|
||||
|
||||
if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members$") {
|
||||
return Get-GroupMembersEndpoint -GroupId $matches[1]
|
||||
}
|
||||
@@ -1185,6 +1191,87 @@ function Get-GroupMembersEndpoint {
|
||||
return @{ items = $items; count = $items.Count }
|
||||
}
|
||||
|
||||
function Search-GroupsEndpoint {
|
||||
param($Query)
|
||||
$err = Test-Connected
|
||||
if ($err) { return $err }
|
||||
|
||||
$term = [string]$Query.q
|
||||
if ([string]::IsNullOrWhiteSpace($term) -or $term.Length -lt 2) {
|
||||
return @{ items = @(); count = 0 }
|
||||
}
|
||||
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$raw = @(Search-GraphGroups -SearchTerm $term -Top 50)
|
||||
$sw.Stop()
|
||||
Write-Host " [GSEARCH] '$term': $($raw.Count) Treffer in $($sw.ElapsedMilliseconds)ms" -ForegroundColor DarkGray
|
||||
|
||||
$items = @()
|
||||
foreach ($g in $raw) {
|
||||
$id = if ($g.id) { $g.id } else { $g.Id }
|
||||
$name = if ($g.displayName) { $g.displayName } else { $g.displayname }
|
||||
if ($id) {
|
||||
$items += [pscustomobject]@{
|
||||
Id = [string]$id
|
||||
DisplayName = [string]$name
|
||||
Description = [string]$g.description
|
||||
}
|
||||
}
|
||||
}
|
||||
return @{ items = $items; count = $items.Count }
|
||||
}
|
||||
|
||||
function Get-GroupMembersExportEndpoint {
|
||||
# Loest alle Benutzer einer Gruppe auf, einschliesslich Mitglieder aus
|
||||
# verschachtelten Unter-Gruppen. Gibt die flache, deduplizierte Benutzer-
|
||||
# liste mit SourcePath-Angabe zurueck — das Frontend erzeugt daraus den CSV.
|
||||
param([string]$GroupId)
|
||||
$err = Test-Connected
|
||||
if ($err) { return $err }
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($GroupId)) {
|
||||
return @{ __status = 400; error = "GroupId fehlt" }
|
||||
}
|
||||
|
||||
$groupName = $GroupId
|
||||
try {
|
||||
$g = Get-GraphGroupById -Id $GroupId -Property @("id","displayName")
|
||||
if ($g.displayName) { $groupName = [string]$g.displayName }
|
||||
} catch {
|
||||
return @{ __status = 404; error = "Gruppe nicht gefunden: $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
Write-Host "[EXPORT] Starte Aufloesung: '$groupName' ($GroupId)" -ForegroundColor Cyan
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
$allUsers = @(Resolve-GroupMembersWithNesting -GroupId $GroupId -GroupName $groupName)
|
||||
|
||||
# Deduplizieren: erster Treffer (= direktes Mitglied) gewinnt
|
||||
$seen = @{}
|
||||
$unique = [System.Collections.Generic.List[object]]::new()
|
||||
$dups = 0
|
||||
foreach ($u in $allUsers) {
|
||||
if (-not $seen.ContainsKey($u.Id)) {
|
||||
$seen[$u.Id] = $true
|
||||
$unique.Add($u)
|
||||
} else {
|
||||
$dups++
|
||||
}
|
||||
}
|
||||
|
||||
$sw.Stop()
|
||||
Write-Host " -> $($unique.Count) eindeutige Benutzer, $dups Duplikate, $($sw.ElapsedMilliseconds)ms" -ForegroundColor Green
|
||||
|
||||
return @{
|
||||
groupId = $GroupId
|
||||
groupName = $groupName
|
||||
users = $unique.ToArray()
|
||||
count = $unique.Count
|
||||
duplicates = $dups
|
||||
resolvedMs = [int]$sw.ElapsedMilliseconds
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Users
|
||||
# ============================================================
|
||||
|
||||
@@ -101,6 +101,83 @@ function Get-GraphGroupMembers {
|
||||
return Get-GraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members?`$select=id,displayName,userPrincipalName"
|
||||
}
|
||||
|
||||
function Search-GraphGroups {
|
||||
# Sucht Gruppen nach displayName. Versucht zuerst startswith (schnell),
|
||||
# dann $search-Fallback (Substring, braucht ConsistencyLevel: eventual).
|
||||
param([string]$SearchTerm, [int]$Top = 50)
|
||||
$term = $SearchTerm -replace "'", "''"
|
||||
try {
|
||||
$filter = "startswith(displayName,'$term')"
|
||||
$uri = "https://graph.microsoft.com/v1.0/groups?`$select=id,displayName,description&`$filter=$([uri]::EscapeDataString($filter))&`$top=$Top"
|
||||
$resp = Invoke-MgGraphRequest -Uri $uri -Method GET
|
||||
$items = @()
|
||||
if ($resp -and $resp.value) { $items = @($resp.value) }
|
||||
if ($items.Count -gt 0) { return $items }
|
||||
} catch {
|
||||
Write-Host " [GSEARCH] startswith fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow
|
||||
}
|
||||
try {
|
||||
$clean = $SearchTerm -replace '"', ''
|
||||
$searchExpr = [uri]::EscapeDataString('"displayName:' + $clean + '"')
|
||||
$uri = "https://graph.microsoft.com/v1.0/groups?`$select=id,displayName,description&`$search=$searchExpr&`$top=$Top"
|
||||
$resp = Invoke-MgGraphRequest -Uri $uri -Method GET -Headers @{ ConsistencyLevel = "eventual" }
|
||||
if ($resp -and $resp.value) { return @($resp.value) }
|
||||
} catch {
|
||||
Write-Host " [GSEARCH] search-Fallback fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Resolve-GroupMembersWithNesting {
|
||||
# Loest alle Benutzer einer Gruppe rekursiv auf (inkl. verschachtelter Unter-
|
||||
# gruppen). Gibt eine flache Liste zurueck; jeder Eintrag traegt den Pfad
|
||||
# der Herkunfts-Gruppen (z.B. "Top-Gruppe > Unter-Gruppe").
|
||||
# Zirkulaere Referenzen werden ueber ein HashSet vermieden.
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$GroupId,
|
||||
[string]$GroupName = "",
|
||||
[System.Collections.Generic.HashSet[string]]$Visited = $null,
|
||||
[string[]]$Path = @()
|
||||
)
|
||||
if ($null -eq $Visited) {
|
||||
$Visited = New-Object System.Collections.Generic.HashSet[string]
|
||||
}
|
||||
if (-not $Visited.Add($GroupId)) { return @() }
|
||||
|
||||
$thisPath = if ($GroupName) { @($Path) + @($GroupName) } else { $Path }
|
||||
$sourcePath = $thisPath -join " > "
|
||||
|
||||
$raw = @()
|
||||
try {
|
||||
$raw = @(Get-GraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members?`$select=id,displayName,userPrincipalName,mail")
|
||||
} catch {
|
||||
Write-Host " [EXPORT] Mitglieder von $GroupId konnten nicht geladen werden: $($_.Exception.Message)" -ForegroundColor DarkYellow
|
||||
return @()
|
||||
}
|
||||
|
||||
$result = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($m in $raw) {
|
||||
$type = [string]$m.'@odata.type'
|
||||
if ($type -like '*#microsoft.graph.group*') {
|
||||
$sub = Resolve-GroupMembersWithNesting `
|
||||
-GroupId ([string]$m.id) `
|
||||
-GroupName ([string]$m.displayName) `
|
||||
-Visited $Visited `
|
||||
-Path $thisPath
|
||||
foreach ($u in $sub) { $result.Add($u) }
|
||||
} else {
|
||||
$result.Add([pscustomobject]@{
|
||||
Id = [string]$m.id
|
||||
DisplayName = [string]$m.displayName
|
||||
UserPrincipalName = [string]$m.userPrincipalName
|
||||
Mail = [string]$m.mail
|
||||
SourcePath = $sourcePath
|
||||
})
|
||||
}
|
||||
}
|
||||
return $result.ToArray()
|
||||
}
|
||||
|
||||
function Get-GraphGroupMembersTransitive {
|
||||
param([string]$GroupId)
|
||||
return Get-GraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/transitiveMembers?`$select=id,displayName,userPrincipalName"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user