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" { return Get-GroupsEndpoint -Query $Query }
|
||||||
"GET /api/groups/rpa" { return Get-RpaGroupsEndpoint }
|
"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" { return New-GroupEndpoint -Body $Body }
|
||||||
"POST /api/groups/check" { return Test-GroupNameEndpoint -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)
|
# 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$") {
|
if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members$") {
|
||||||
return Get-GroupMembersEndpoint -GroupId $matches[1]
|
return Get-GroupMembersEndpoint -GroupId $matches[1]
|
||||||
}
|
}
|
||||||
@@ -1185,6 +1191,87 @@ function Get-GroupMembersEndpoint {
|
|||||||
return @{ items = $items; count = $items.Count }
|
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
|
# 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"
|
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 {
|
function Get-GraphGroupMembersTransitive {
|
||||||
param([string]$GroupId)
|
param([string]$GroupId)
|
||||||
return Get-GraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/transitiveMembers?`$select=id,displayName,userPrincipalName"
|
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);
|
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>
|
||||||
|
|
||||||
<div class="header-actions">
|
<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">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -795,6 +798,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<!-- Markdown-Renderer (bundled, MIT, marked v15) — wird im App-Detail-Panel
|
||||||
fuer die Beschreibung benutzt. Vor app.js laden damit window.marked
|
fuer die Beschreibung benutzt. Vor app.js laden damit window.marked
|
||||||
beim ersten render() bereitsteht. -->
|
beim ersten render() bereitsteht. -->
|
||||||
|
|||||||
@@ -4392,6 +4392,163 @@ html[data-theme="dark"] .numInputWrapper span.arrowDown {
|
|||||||
border-color: var(--hairline);
|
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 */
|
/* Kompakte Optik fuer beide Themes — passt besser zum Tool */
|
||||||
.flatpickr-calendar {
|
.flatpickr-calendar {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
|||||||
Reference in New Issue
Block a user